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
46 changed files with 2720 additions and 432 deletions
+11
View File
@@ -1422,6 +1422,17 @@ CREATE TABLE IF NOT EXISTS specialization_ability_attacks (
CHECK(charge_policy<>'none' OR charge_count=1) CHECK(charge_policy<>'none' OR charge_count=1)
); );
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name); CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
CREATE TABLE IF NOT EXISTS specialization_ability_skill_chip_links (
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
compatible_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
PRIMARY KEY(ability_id,compatible_ability_id),
CHECK(ability_id<compatible_ability_id)
);
CREATE INDEX IF NOT EXISTS specialization_ability_skill_chip_links_compatible_idx
ON specialization_ability_skill_chip_links(compatible_ability_id,ability_id);
-- db-migrate.mjs installs insert/update triggers for active same-specialization
-- endpoints and one-pair-per-ability enforcement. Trigger bodies cannot live in
-- this semicolon-delimited schema file.
CREATE TABLE IF NOT EXISTS specialization_ability_effects ( CREATE TABLE IF NOT EXISTS specialization_ability_effects (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE, ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
+84 -31
View File
@@ -341,17 +341,36 @@ squeezed beside long module headings on mobile.
### Command Grid builder ### Command Grid builder
The production builder uses Command Grid as its loadout workbench. Keep the The production builder uses Command Grid as its loadout workbench. Keep the
entire build visible in a dense matrix while one connected editor configures the entire build visible in a compact matrix while one connected editor configures
selected cell. The full-width Specialization and Focus cell is the root choice. the selected cell. The editor uses two lanes when its container has enough
Its three supplied abilities appear directly below it, followed by two weapons, width. Equipment identity and base values occupy the first lane; allocations,
six gear positions, and one full-width OS Protocol. rolls, Talents, and upgrades occupy the second. The lanes stack when either
would become too narrow. The full-width Specialization and Focus cell is the
root choice. Its three supplied abilities appear directly below it, followed by
two weapons, six gear positions, and one full-width OS Protocol.
Matrix cells summarize selected Talent names, Attribute names and values, Brand Matrix cells show the slot and equipped item at rest. The selected cell reveals
Sets, or Skill Chips. Do not replace those choices with configuration counts. its compact Talent, Attribute, Brand Set, or Skill Chip summary. This
Selecting an ability opens its three attached Skill Chip positions. Selecting progressive disclosure keeps the matrix scannable without replacing configured
equipment opens the searchable real-data catalog and its compatible choices with counts. Selecting an ability opens its three attached Skill Chip
configuration controls. Do not display a prototype option that the current positions. Selecting equipment opens the searchable real-data catalog and its
loadout schema cannot save. compatible configuration controls. Large Talent pools show the selected Talent
at rest and open their search grid through an explicit Change action. Do not
display a prototype option that the current loadout schema cannot save.
Builder cells use one leading icon track. Specializations, abilities, Skill
Chips, and OS Protocols use registered artwork; weapons and gear use static
category or slot fallbacks when no item artwork exists. Skill Chip slots keep
the icon beside the set name and quality so empty and configured slots share one
scan path. Visible builder labels stay short; full validation text remains
available through the cell's accessible name or title.
Each equipped gear or weapon Talent exposes two adjacent icon choices for Tier I
and Tier II. Both choices reuse the Talent artwork; Tier II applies a restrained
warm recolor so players can distinguish the pair before reading the labels.
Every tile retains `T1` or `T2`, its full tier name, and selected or unavailable
text. Disable Tier II when the canonical Talent has no authored Tier II data.
Weapon equipment tier and Talent tier are independent selections.
Equipment readiness belongs at the point of resolution. Mark an incomplete Equipment readiness belongs at the point of resolution. Mark an incomplete
matrix cell with a compact red text indicator and status rail, then let the cell matrix cell with a compact red text indicator and status rail, then let the cell
@@ -361,20 +380,19 @@ and Skill DPS, encounter assumptions, activation profiles, rotations, and
simulation as `COMING SOON`; they do not report calculation-profile blockers as simulation as `COMING SOON`; they do not report calculation-profile blockers as
current player errors. current player errors.
The builder uses three workbench columns on wide screens, moves the live summary The builder uses two primary workbench columns on wide screens: the compact
below the matrix and editor at intermediate widths, and stacks all three regions matrix and the two-lane editor. The live summary spans both columns below them.
on narrow screens. Every grid track and editor child must accept content Narrow screens stack the matrix, editor, and summary. Every grid track and
shrinkage. At 560px and below, stack attribute picker/value pairs and section editor child must accept content shrinkage. At 560px and below, stack Attribute
headings; keep the command actions in two equal columns. Validation markers use picker/value pairs and section headings; keep the command actions in two equal
short visible labels while their full messages remain available to assistive columns. Validation markers use short visible labels while their full messages
technology and pointer users. remain available to assistive technology and pointer users.
Use `--shd-builder-canvas` for recessed equipment fields. Use Use `--shd-builder-canvas` for recessed equipment fields. Use
`--shd-builder-selection` with `--shd-builder-selection-border` for the selected `--shd-builder-selection` with `--shd-builder-selection-border` for the selected
cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed
feedback, and textual readiness states. At narrower desktop widths, keep the feedback, and textual readiness states. On mobile, stack the matrix, editor, and
matrix and editor together and move live results below them. On mobile, stack live results in that order.
the matrix, editor, and live results in that order.
The command header contains the required build name, local and account save The command header contains the required build name, local and account save
actions, account visibility, and optional sharing notes. Sharing notes include a actions, account visibility, and optional sharing notes. Sharing notes include a
@@ -2283,6 +2301,13 @@ active abilities, each ability has exactly three active Skill Chip definitions,
tree rows are contiguous, at least one paid node exists, and the tree uses the tree rows are contiguous, at least one paid node exists, and the tree uses the
configured available point total. configured available point total.
The ability editor may pair the open ability with one active ability from the
same specialization for Skill Chip sharing. Present this as an explicit optional
relationship, exclude the current ability, and disable abilities that already
belong to another pair. Do not infer compatibility from names or Focus
membership. Saving either side updates the shared pair and includes it in the
ability audit snapshot. Archiving either ability clears the pair.
Compose focus identity from uploaded specialization artwork and the canonical Compose focus identity from uploaded specialization artwork and the canonical
Firepower, Toughness, or Engineering icon. Keep the specialization art primary Firepower, Toughness, or Engineering icon. Keep the specialization art primary
and place the attribute mark in a smaller corner badge. Mirror ability slots 1 and place the attribute mark in a smaller corner badge. Mirror ability slots 1
@@ -2570,12 +2595,35 @@ anchor scrolling and sticky-state transitions.
#### Builder calculation modes #### Builder calculation modes
The current Builder and shared Build pages do not expose calculation modes or The current Builder and shared Build pages do not expose calculation modes or
combat assumptions. They show deterministic equipped-item totals and reserve combat assumptions. The Builder summary rail additionally contains one local
conditional Talent outcomes, weapon and Skill DPS, encounter assumptions, experimental catalog baseline panel; shared Build pages do not render it. Keep
activation profiles, rotations, and simulation behind explicit `COMING SOON` the panel in the summary rail only, with two independent cards labeled `PRIMARY`
panels. and `SECONDARY`.
Loadout schema version 5 retains `maximum`, `custom`, conditional state, 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 encounter-assumption, and per-ability activation fields. Version 4 is obsolete
prototype data rather than a compatibility format. The current player UI ignores prototype data rather than a compatibility format. The current player UI ignores
the simulation fields. When player simulation ships, one required two-option the simulation fields. When player simulation ships, one required two-option
@@ -2630,12 +2678,17 @@ The builder presents Specialization and Focus as the first loadout decision. The
Focus sets the primary Attribute, progression tree, and three ordered abilities. Focus sets the primary Attribute, progression tree, and three ordered abilities.
Its identity combines Specialization artwork and the primary Attribute mark. Its identity combines Specialization artwork and the primary Attribute mark.
Each ability panel contains three fixed Skill Chip positions. A chip picker Each ability panel contains three fixed Skill Chip positions. A chip picker
shows definitions assigned to that ability and excludes definitions assigned shows the ability's three definitions. When ISAC explicitly pairs that ability
elsewhere. Skill Chips have no item-level control. The editor uses the shared with one compatible ability, the picker also shows the partner's three
quality profiles for direct rolls. It labels the four ability-scoped primary definitions and labels shared entries. The relationship is bidirectional and
values `NOT YET DEFINED` and excludes them from current Agent and combat totals. does not apply to other abilities in the same specialization. Players still
Secondary capacity is Standard zero, Specialized one, Superior two, and High-end equip three chips in each ability panel. Skill Chips have no item-level control.
two; each equipped chip uses distinct selections from the shared pool. The editor uses the shared quality profiles for direct rolls. It labels the four
ability-scoped primary values `NOT YET DEFINED` and excludes them from current
Agent and combat totals. Shared-chip primary values and 3-piece Talent outcomes
target the ability represented by the panel. Secondary capacity is Standard
zero, Specialized one, Superior two, and High-end two; each equipped chip uses
distinct selections from the shared pool.
The focus tree groups nodes by row and uses segmented rank controls. Disabled The focus tree groups nodes by row and uses segmented rank controls. Disabled
ranks remain visible with their lock state. The builder permits a row after the ranks remain visible with their lock state. The builder permits a row after the
+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 - **Healing:** Count each healing event. A heal-over-time effect counts each
tick as a separate event. tick as a separate event.
- **Extra Health gained:** Count every Extra Health grant, including a grant - **Extra Health gained:** Count every Extra Health grant, including a grant
received while the Agent already has Extra Health. Use received while the Agent already has Extra Health. Use `extra_health_granted`
`extra_health_granted` for the grant action. for the grant action.
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit - **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
it when the Agent receives a grant, when the remaining amount changes, and it when the Agent receives a grant, when the remaining amount changes, and
when it is depleted to zero. Every event requires the boolean when it is depleted to zero. Every event requires the boolean
@@ -35,8 +35,8 @@ Use these event boundaries:
`values.currentExtraHealth`, which must agree with that status. This state `values.currentExtraHealth`, which must agree with that status. This state
event is distinct from the `extra_health_granted` grant action. event is distinct from the `extra_health_granted` grant action.
- **Reloading:** Trigger on reload completion when the weapon had at least one - **Reloading:** Trigger on reload completion when the weapon had at least one
round to load. A canceled reload does not count. A weapon that loads one round to load. A canceled reload does not count. A weapon that loads one round
round at a time produces one completed-reload event for each round. at a time produces one completed-reload event for each round.
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does - **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
not count as taking damage. not count as taking damage.
@@ -59,8 +59,8 @@ Inspiration and Crossfire use a binary deployed-Skill state:
- The benefit remains active while the Agent has at least one persistent - The benefit remains active while the Agent has at least one persistent
deployed Skill object. deployed Skill object.
- Destruction or timed expiration removes that object from the active count. - Destruction or timed expiration removes that object from the active count. The
The benefit ends when no qualifying objects remain. benefit ends when no qualifying objects remain.
- Instant-use Skills do not qualify. - Instant-use Skills do not qualify.
- Multiple deployed Skill objects do not multiply the benefit. - Multiple deployed Skill objects do not multiply the benefit.
@@ -75,8 +75,8 @@ meters:
- Moving within 30 meters of an ally who is already downed qualifies. - Moving within 30 meters of an ally who is already downed qualifies.
- Each qualifying presence refreshes the ten-second duration. - Each qualifying presence refreshes the ten-second duration.
- Leaving the radius or reviving the ally does not cancel the current - Leaving the radius or reviving the ally does not cancel the current ten-second
ten-second duration. duration.
- The wearer does not count as an ally. - The wearer does not count as an ally.
## Emergency Retreat ## Emergency Retreat
@@ -93,9 +93,9 @@ qualifies when the weapon had at least one round to load. This includes each
round inserted by a weapon that reloads one round at a time. A canceled reload round inserted by a weapon that reloads one round at a time. A canceled reload
does not qualify. does not qualify.
Resolve the Extra Health amount from the Agent's displayed Firepower when Resolve the Extra Health amount from the Agent's displayed Firepower when Second
Second Wind activates. Include all equipped-item and active-buff contributions Wind activates. Include all equipped-item and active-buff contributions in that
in that Firepower value: Firepower value:
- Tier I grants Extra Health equal to 600% of displayed Firepower. - Tier I grants Extra Health equal to 600% of displayed Firepower.
- Tier II grants Extra Health equal to 840% of displayed Firepower. - Tier II grants Extra Health equal to 840% of displayed Firepower.
@@ -103,19 +103,39 @@ in that Firepower value:
Second Wind adds its Extra Health to Extra Health from other sources. Its Second Wind adds its Extra Health to Extra Health from other sources. Its
five-second duration and five-second cooldown start when it activates. Expiry five-second duration and five-second cooldown start when it activates. Expiry
removes only the Second Wind contribution. At the five-second boundary, the removes only the Second Wind contribution. At the five-second boundary, the
prior grant can expire as a qualifying reload completion grants a new amount. prior grant can expire as a qualifying reload completion grants a new amount. If
If Second Wind supplies the Agent's only Extra Health, the old amount expires Second Wind supplies the Agent's only Extra Health, the old amount expires and
and the new amount begins at that boundary. the new amount begins at that boundary.
## Nothing to Lose ## Nothing to Lose
Nothing to Lose uses a forward-looking deployment window: Nothing to Lose uses a forward-looking deployment window:
- Each Health-damage instance makes its own 50% activation roll. Damage - Each Health-damage instance makes its own 50% activation roll. Damage absorbed
absorbed by Extra Health does not roll. by Extra Health does not roll.
- A successful activation applies to every Skill deployed during the five - A successful activation applies to every Skill deployed during the five
seconds after that damage instance. Several Skills can qualify during the seconds after that damage instance. Several Skills can qualify during the same
same window. window.
- Calculate each reduction from the Skill's total cooldown after equipment - Calculate each reduction from the Skill's total cooldown after equipment
modifiers. modifiers.
- A Skill can receive no more than three reductions during one cooldown cycle. - A Skill can receive no more than three reductions during one cooldown cycle.
## OS Protocol Talents
Happy Survival restores Health once per second while it has a qualifying nearby
enemy count. Each restoration resolves 0.5% of maximum Health per enemy within
10 meters, capped at 2.5% before OS Protocol upgrade scaling.
Explosive Charge requires three seconds of uninterrupted firing. Completion
opens an eight-second window to deploy the next Skill. The window ends when the
Agent deploys that Skill. Only the enhanced Skill's first damaging hit creates
the five-meter explosion. The shared Talent cooldown rule starts its five-second
cooldown when that explosion activates.
Heavy Armor and Pummeling Shield replace and refresh their own active Extra
Health grant when the same Talent activates again. Extra Health grants from
different sources remain separate.
Pummeling Shield counts confirmed weapon hits while the Agent possesses Extra
Health. Losing all Extra Health resets the hit count. The full-Health condition
checks Health immediately before the incoming hit resolves.
+52 -24
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 Server code checks authorization for each mutation. UI visibility has no effect
on server authorization. on server authorization.
Builder and shared Build pages currently expose only deterministic totals from Builder and shared Build pages expose deterministic totals from the equipped
the equipped items, selected rolls, fixed bonuses, inherited profiles, augments, items, selected rolls, fixed bonuses, inherited profiles, augments, and active
and active set bonuses. Conditional Talent outcomes, weapon and Skill DPS, set bonuses. Builder additionally shows an experimental catalog baseline for
encounter assumptions, activation profiles, rotations, and simulation remain each selected weapon: body-hit throughput from selected weapon damage and
planned. Explicit `COMING SOON` panels identify those player-facing catalog RPM, magazine, and reload values only. It is not expected or encounter
capabilities. DPS, does not infer or substitute missing values, reports blocked metrics with
their source fields, and does not change shared Build behavior. Conditional
Talent outcomes, full weapon and Skill DPS, encounter assumptions, activation
profiles, rotations, and simulation remain planned. Explicit `COMING SOON`
panels identify those player-facing capabilities.
Weapon and gear range calculations must preserve decimal endpoints and follow Weapon and gear range calculations must preserve decimal endpoints and follow
the tested progression rules in the tested progression rules in
@@ -553,6 +557,24 @@ builder-ready only when each of its three assigned abilities has all three
active Skill Chip definitions. Skill Chip mutations refresh readiness for active Skill Chip definitions. Skill Chip mutations refresh readiness for
focuses that reference both the previous and resulting ability. focuses that reference both the previous and resulting ability.
`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 Every equipped quality counts toward the two-piece threshold. After the loadout
meets that threshold, only equipped High-end chips increase the interpolation 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 step. For `h` High-end chips, where `h` is zero through three, a configured
@@ -584,6 +606,9 @@ state, with sprinting taking precedence. Enabled Skill activation profiles emit
the deployed ability. An `on_event` Skill modifier applies to that activation the deployed ability. An `on_event` Skill modifier applies to that activation
until the ability's next deployment or the encounter ends. until the ability's next deployment or the encounter ends.
`skill_deployed.values.abilityId` and `skill_hit.values.abilityId` identify the
same ability for mechanics that bind an effect to the next deployed Skill.
Each source supplies a fixed value-variant contract: Each source supplies a fixed value-variant contract:
- Standard Talents require Tier I and Tier II values. Exotic Talents require - Standard Talents require Tier I and Tier II values. Exotic Talents require
@@ -631,11 +656,11 @@ timestamp.
the hit resolved. A producer omits the field when it lacks authoritative aiming 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. state; the runtime does not replace missing evidence with a scenario assumption.
Predicates compare this field explicitly with `true` or `false`; negation and Predicates compare this field explicitly with `true` or `false`; negation and
inequality cannot turn missing evidence into a match. A weapon-hit event may inequality cannot turn missing evidence into a match. A `weapon_hit` or
also include an event-target-centered area sample. The bounded sample lists up `skill_hit` event may include an event-target-centered area sample. The bounded
to 256 valid ally and enemy entity IDs with their distances from the hit target sample lists up to 256 valid ally and enemy entity IDs with their distances from
and includes that target as an enemy at zero meters. Duplicate IDs, negative the hit target and includes that target as an enemy at zero meters. Duplicate
distances, or a missing center target invalidate the evaluation. IDs, negative distances, or a missing center target invalidate the evaluation.
`extra_health_changed` is Agent-scoped. Normalized timeline producers emit it `extra_health_changed` is Agent-scoped. Normalized timeline producers emit it
when the Agent receives an Extra Health grant, when the remaining amount when the Agent receives an Extra Health grant, when the remaining amount
@@ -730,11 +755,13 @@ substitute zero.
The read-only `combat.currentHealthPercent` reference divides resolved current The read-only `combat.currentHealthPercent` reference divides resolved current
Health by resolved maximum Health and returns a value from 0 through 100. The Health by resolved maximum Health and returns a value from 0 through 100. The
runtime derives it when a predicate is evaluated instead of storing a second runtime derives it when a predicate is evaluated instead of storing a second
Health value that could become stale. Current timing observes deterministic Health value that could become stale. Pre-event timing reads the combat snapshot
resource changes from earlier branches at the same event. Health-band mechanics before mechanic branches process the event. Current timing observes
use strict upper bounds and inclusive lower bounds, so bands do not overlap and deterministic resource changes from earlier branches at the same event.
an exact threshold enters the next weaker band. Maximum-potential evaluation Health-band mechanics use strict upper bounds and inclusive lower bounds, so
applies the same predicates before emitting passive outcomes. bands do not overlap and an exact threshold enters the next weaker band.
Maximum-potential evaluation applies the same predicates before emitting passive
outcomes.
Continuous restoration resolves the outcome's total when the branch activates, Continuous restoration resolves the outcome's total when the branch activates,
then delivers that total at a constant rate over the configured duration. then delivers that total at a constant rate over the configured duration.
@@ -876,17 +903,18 @@ applicable, default value, and best-case value. ISAC retains these definitions
for continued mechanic authoring. Current Builder and shared Build pages do not for continued mechanic authoring. Current Builder and shared Build pages do not
render or evaluate them. Their calculation panels display `COMING SOON` instead. render or evaluate them. Their calculation panels display `COMING SOON` instead.
Loadout schema version 5 stores one focus, selected tree ranks, nine Skill Loadout schema version 6 stores one focus, selected tree ranks, nine Skill
Chips, a unified conditional-effect state map, encounter assumptions, Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
per-ability activation controls, and `calculationMode`. Runtime readers accept unified conditional-effect state map, encounter assumptions, per-ability
the exact version 5 structure. The schema retains its simulation fields, but the activation controls, and `calculationMode`. Runtime readers accept the exact
current player UI ignores them. Version 4 is obsolete prototype data. A version 6 structure. A backed-up migration translated version 5 equipment
backed-up prototype migration removes version 4 drafts, saved builds, and Talents from their previous equipment-tier-derived behavior. The schema retains
calibration snapshots instead of translating them. its simulation fields, but the current player UI ignores them. Version 4 remains
obsolete prototype data.
## Planned player calculation modes ## Planned player calculation modes
Loadout v5 defines `maximum` and `custom` modes for the planned simulation UI: Loadout v6 defines `maximum` and `custom` modes for the planned simulation UI:
- Maximum mode supplies each scenario input's best-case value, selects peak - Maximum mode supplies each scenario input's best-case value, selects peak
conditional-effect state, and runs selected abilities on cooldown against conditional-effect state, and runs selected abilities on cooldown against
+229 -5
View File
@@ -45,7 +45,8 @@ const isRoll = (value) =>
isFiniteNumber(value.value); isFiniteNumber(value.value);
const isRollPair = (value) => const isRollPair = (value) =>
Array.isArray(value) && value.length === 2 && value.every(isRoll); Array.isArray(value) && value.length === 2 && value.every(isRoll);
const isGearSelection = (value) => const isTalentTier = (value) => value === 1 || value === 2;
const isLegacyGearSelection = (value) =>
hasExactKeys(value, [ hasExactKeys(value, [
'itemId', 'itemId',
'level', 'level',
@@ -68,7 +69,7 @@ const isGearSelection = (value) =>
isRollPair(value.rolls) && isRollPair(value.rolls) &&
Array.isArray(value.augmentSlots) && Array.isArray(value.augmentSlots) &&
value.augmentSlots.every(isFiniteNumber); value.augmentSlots.every(isFiniteNumber);
const isWeaponSelection = (value) => const isLegacyWeaponSelection = (value) =>
hasExactKeys(value, [ hasExactKeys(value, [
'itemId', 'itemId',
'level', 'level',
@@ -88,6 +89,57 @@ const isWeaponSelection = (value) =>
(key) => isFiniteNumber(value[key]), (key) => isFiniteNumber(value[key]),
) && ) &&
isRollPair(value.rolls); isRollPair(value.rolls);
const isGearSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
'brandId',
'talentId',
'talentTier',
'health',
'armor',
'firepower',
'toughness',
'engineering',
'rolls',
'augmentSlots',
]) &&
['itemId', 'brandId', 'talentId'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talentTier) &&
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls) &&
Array.isArray(value.augmentSlots) &&
value.augmentSlots.every(isFiniteNumber);
const isWeaponSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
'tier',
'damage',
'firepower',
'toughness',
'engineering',
'talent1Id',
'talent1Tier',
'talent2Id',
'talent2Tier',
'exoticTalentTier',
'rolls',
]) &&
['itemId', 'talent1Id', 'talent2Id'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talent1Tier) &&
isTalentTier(value.talent2Tier) &&
isTalentTier(value.exoticTalentTier) &&
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls);
const isChipSelection = (value) => const isChipSelection = (value) =>
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) && hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
typeof value.setId === 'string' && typeof value.setId === 'string' &&
@@ -190,7 +242,7 @@ const isCurrentLoadoutSnapshot = (raw) => {
'skillActivations', 'skillActivations',
'specialization', 'specialization',
]) && ]) &&
value.version === 5 && value.version === 6 &&
typeof value.buildName === 'string' && typeof value.buildName === 'string' &&
['maximum', 'custom'].includes(value.calculationMode) && ['maximum', 'custom'].includes(value.calculationMode) &&
hasExactKeys(value.gear, gearSlots) && hasExactKeys(value.gear, gearSlots) &&
@@ -212,6 +264,49 @@ const isCurrentLoadoutSnapshot = (raw) => {
); );
}; };
const isLegacyLoadoutSnapshot = (raw) => {
let value;
try {
value = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
return false;
}
return (
hasExactKeys(value, [
'version',
'buildName',
'gear',
'weapons',
'chips',
'protocol',
'conditionalEffects',
'combatProfile',
'calculationMode',
'skillActivations',
'specialization',
]) &&
value.version === 5 &&
typeof value.buildName === 'string' &&
['maximum', 'custom'].includes(value.calculationMode) &&
hasExactKeys(value.gear, gearSlots) &&
gearSlots.every((slot) => isLegacyGearSelection(value.gear[slot])) &&
Array.isArray(value.weapons) &&
value.weapons.length === 2 &&
value.weapons.every(isLegacyWeaponSelection) &&
Array.isArray(value.chips) &&
value.chips.length === 9 &&
value.chips.every(isChipSelection) &&
isProtocolSelection(value.protocol) &&
isEffectStates(value.conditionalEffects) &&
isCombatProfile(value.combatProfile) &&
isSkillActivations(value.skillActivations) &&
hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) &&
typeof value.specialization.focusId === 'string' &&
isRecord(value.specialization.nodeRanks) &&
Object.values(value.specialization.nodeRanks).every(isFiniteNumber)
);
};
const databasePath = resolve( const databasePath = resolve(
process.env.TDR_DATABASE_PATH || './data/shd-build.db', process.env.TDR_DATABASE_PATH || './data/shd-build.db',
); );
@@ -674,6 +769,44 @@ for (const statement of statements) {
await database.exec(statement); await database.exec(statement);
} }
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_insert
BEFORE INSERT ON specialization_ability_skill_chip_links
BEGIN
SELECT CASE WHEN EXISTS (
SELECT 1 FROM specialization_ability_skill_chip_links link
WHERE link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM specialization_abilities ability
JOIN specialization_abilities compatible
ON compatible.id=NEW.compatible_ability_id
WHERE ability.id=NEW.ability_id
AND ability.active=1
AND compatible.active=1
AND ability.specialization_id=compatible.specialization_id
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
END`);
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_update
BEFORE UPDATE ON specialization_ability_skill_chip_links
BEGIN
SELECT CASE WHEN EXISTS (
SELECT 1 FROM specialization_ability_skill_chip_links link
WHERE link.rowid<>OLD.rowid
AND (link.ability_id IN (NEW.ability_id,NEW.compatible_ability_id)
OR link.compatible_ability_id IN (NEW.ability_id,NEW.compatible_ability_id))
) THEN RAISE(ABORT,'Each ability can share one Skill Chip pool') END;
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM specialization_abilities ability
JOIN specialization_abilities compatible
ON compatible.id=NEW.compatible_ability_id
WHERE ability.id=NEW.ability_id
AND ability.active=1
AND compatible.active=1
AND ability.specialization_id=compatible.specialization_id
) THEN RAISE(ABORT,'Skill Chip sharing requires active abilities from one specialization') END;
END`);
const articleRevisionColumns = await ( const articleRevisionColumns = await (
await database.prepare("PRAGMA table_info('article_revisions')") await database.prepare("PRAGMA table_info('article_revisions')")
).all(); ).all();
@@ -3223,6 +3356,41 @@ if (
} }
} }
const [loadoutGearRows, loadoutWeaponRows, loadoutTalentTierRows] =
await Promise.all([
(await database.prepare('SELECT id,tier FROM gear')).all(),
(await database.prepare('SELECT id,exotic_talent_id FROM weapons')).all(),
(await database.prepare('SELECT talent_id,tier FROM talent_tiers')).all(),
]);
const migrateLegacyLoadoutSnapshot = (raw) => {
if (!isLegacyLoadoutSnapshot(raw)) return null;
const value =
typeof raw === 'string' ? JSON.parse(raw) : structuredClone(raw);
for (const slot of gearSlots) {
const item = loadoutGearRows.find(
(entry) => String(entry.id) === String(value.gear[slot].itemId),
);
value.gear[slot].talentTier = Number(item?.tier) >= 2 ? 2 : 1;
}
for (const selection of value.weapons) {
const inheritedTier = Number(selection.tier) >= 2 ? 2 : 1;
selection.talent1Tier = inheritedTier;
selection.talent2Tier = inheritedTier;
const weapon = loadoutWeaponRows.find(
(entry) => String(entry.id) === String(selection.itemId),
);
const exoticTalentId = String(weapon?.exotic_talent_id ?? '');
const hasExoticTierTwo = loadoutTalentTierRows.some(
(entry) =>
String(entry.talent_id) === exoticTalentId && entry.tier === 'II',
);
selection.exoticTalentTier =
inheritedTier === 2 && hasExoticTierTwo ? 2 : 1;
}
value.version = 6;
return isCurrentLoadoutSnapshot(value) ? JSON.stringify(value) : null;
};
const buildColumns = await ( const buildColumns = await (
await database.prepare("PRAGMA table_info('builds')") await database.prepare("PRAGMA table_info('builds')")
).all(); ).all();
@@ -3237,9 +3405,34 @@ if (buildColumns.length) {
await database.exec( await database.exec(
"UPDATE builds SET visibility='private' WHERE visibility='draft'", "UPDATE builds SET visibility='private' WHERE visibility='draft'",
); );
const builds = await ( let builds = await (
await database.prepare('SELECT id,loadout_json FROM builds') await database.prepare('SELECT id,loadout_json FROM builds')
).all(); ).all();
const migratedBuilds = builds
.map((build) => ({
id: String(build.id),
loadout: migrateLegacyLoadoutSnapshot(build.loadout_json),
}))
.filter((build) => build.loadout);
if (migratedBuilds.length) {
console.log(
`Migrating ${migratedBuilds.length} build loadout snapshot(s) to version 6 Talent tiers.`,
);
await database.exec('BEGIN');
try {
for (const build of migratedBuilds)
await (
await database.prepare('UPDATE builds SET loadout_json=? WHERE id=?')
).run(build.loadout, build.id);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
builds = await (
await database.prepare('SELECT id,loadout_json FROM builds')
).all();
}
const obsoleteBuildIds = builds const obsoleteBuildIds = builds
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json)) .filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
.map((build) => String(build.id)); .map((build) => String(build.id));
@@ -3629,11 +3822,42 @@ if (
); );
} }
const calibrationCases = await ( let calibrationCases = await (
await database.prepare( await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases', 'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
) )
).all(); ).all();
const migratedCalibrationCases = calibrationCases
.map((calibrationCase) => ({
id: String(calibrationCase.id),
loadout: migrateLegacyLoadoutSnapshot(
calibrationCase.loadout_snapshot_json,
),
}))
.filter((calibrationCase) => calibrationCase.loadout);
if (migratedCalibrationCases.length) {
console.log(
`Migrating ${migratedCalibrationCases.length} calibration snapshot(s) to version 6 Talent tiers.`,
);
await database.exec('BEGIN');
try {
for (const calibrationCase of migratedCalibrationCases)
await (
await database.prepare(
'UPDATE calculation_calibration_cases SET loadout_snapshot_json=? WHERE id=?',
)
).run(calibrationCase.loadout, calibrationCase.id);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
calibrationCases = await (
await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
)
).all();
}
const obsoleteCalibrationIds = calibrationCases const obsoleteCalibrationIds = calibrationCases
.filter( .filter(
(calibrationCase) => (calibrationCase) =>
+6 -2
View File
@@ -2,7 +2,11 @@ import { connect } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path'; import { basename, dirname, join, resolve } from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs'; import {
ISAC_DATA_FORMAT_VERSION,
ISAC_DATA_TABLES,
quoteIdentifier,
} from './isac-data-tables.mjs';
const databasePath = resolve( const databasePath = resolve(
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db', process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
@@ -67,7 +71,7 @@ for (const row of [
const snapshot = { const snapshot = {
format: 'shd-build-isac-data', format: 'shd-build-isac-data',
version: 4, version: ISAC_DATA_FORMAT_VERSION,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath), sourceDatabase: basename(databasePath),
totalRows, totalRows,
+87 -2
View File
@@ -3,7 +3,11 @@ import { spawnSync } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path'; import { basename, join, resolve } from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs'; import {
ISAC_DATA_FORMAT_VERSION,
ISAC_DATA_TABLES,
quoteIdentifier,
} from './isac-data-tables.mjs';
const snapshotArgument = process.argv const snapshotArgument = process.argv
.slice(2) .slice(2)
@@ -22,7 +26,7 @@ const snapshotPath = resolve(snapshotArgument);
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8')); const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
if ( if (
snapshot?.format !== 'shd-build-isac-data' || snapshot?.format !== 'shd-build-isac-data' ||
snapshot?.version !== 4 || snapshot?.version !== ISAC_DATA_FORMAT_VERSION ||
!Array.isArray(snapshot.tables) || !Array.isArray(snapshot.tables) ||
!Array.isArray(snapshot.icons) !Array.isArray(snapshot.icons)
) { ) {
@@ -39,6 +43,87 @@ if (
'The ISAC snapshot table manifest is incomplete or out of order.', 'The ISAC snapshot table manifest is incomplete or out of order.',
); );
} }
const abilityRows = snapshot.tables.find(
(table) => table.name === 'specialization_abilities',
).rows;
const abilitiesById = new Map(
abilityRows.map((row) => [String(row.id ?? ''), row]),
);
const skillChipRows = snapshot.tables.find(
(table) => table.name === 'skill_chip_sets',
).rows;
const activeSkillChipCounts = new Map();
for (const row of skillChipRows) {
const abilityId = String(row.specialization_ability_id ?? '');
if (!abilityId || !abilitiesById.has(abilityId)) {
throw new Error(
`The ISAC snapshot contains a Skill Chip definition with a missing ability reference "${abilityId}".`,
);
}
if (Number(row.active) !== 1) continue;
const count = (activeSkillChipCounts.get(abilityId) ?? 0) + 1;
if (count > 3) {
throw new Error(
`The ISAC snapshot assigns more than three active Skill Chip definitions to ability "${abilityId}".`,
);
}
activeSkillChipCounts.set(abilityId, count);
}
const skillChipLinks = snapshot.tables.find(
(table) => table.name === 'specialization_ability_skill_chip_links',
).rows;
const linkedAbilityIds = new Set();
for (const row of skillChipLinks) {
const abilityId = row.ability_id;
const compatibleAbilityId = row.compatible_ability_id;
if (
typeof abilityId !== 'string' ||
!abilityId ||
typeof compatibleAbilityId !== 'string' ||
!compatibleAbilityId
) {
throw new Error(
'The ISAC snapshot contains a Skill Chip sharing link with malformed ability references.',
);
}
if (abilityId === compatibleAbilityId) {
throw new Error(
`The ISAC snapshot contains a self-referencing Skill Chip sharing link for ability "${abilityId}".`,
);
}
if (abilityId >= compatibleAbilityId) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link that is not in canonical ability order: "${abilityId}", "${compatibleAbilityId}".`,
);
}
const ability = abilitiesById.get(abilityId);
const compatibleAbility = abilitiesById.get(compatibleAbilityId);
if (!ability || !compatibleAbility) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link with a missing ability reference: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (Number(ability.active) !== 1 || Number(compatibleAbility.active) !== 1) {
throw new Error(
`The ISAC snapshot contains a Skill Chip sharing link with an inactive ability: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (ability.specialization_id !== compatibleAbility.specialization_id) {
throw new Error(
`The ISAC snapshot contains a cross-specialization Skill Chip sharing link: "${abilityId}", "${compatibleAbilityId}".`,
);
}
if (
linkedAbilityIds.has(abilityId) ||
linkedAbilityIds.has(compatibleAbilityId)
) {
throw new Error(
'The ISAC snapshot contains more than one Skill Chip sharing link touching an ability.',
);
}
linkedAbilityIds.add(abilityId);
linkedAbilityIds.add(compatibleAbilityId);
}
for (const kind of ['weapon', 'gear']) { for (const kind of ['weapon', 'gear']) {
const options = snapshot.tables.find( const options = snapshot.tables.find(
(table) => table.name === `${kind}_mod_secondary_options`, (table) => table.name === `${kind}_mod_secondary_options`,
+3
View File
@@ -57,6 +57,7 @@ export const ISAC_DATA_TABLES = [
'specialization_combat_effects', 'specialization_combat_effects',
'specialization_focuses', 'specialization_focuses',
'specialization_abilities', 'specialization_abilities',
'specialization_ability_skill_chip_links',
'specialization_ability_attacks', 'specialization_ability_attacks',
'specialization_ability_effects', 'specialization_ability_effects',
'specialization_focus_abilities', 'specialization_focus_abilities',
@@ -75,5 +76,7 @@ export const ISAC_DATA_TABLES = [
'managed_images', 'managed_images',
]; ];
export const ISAC_DATA_FORMAT_VERSION = 5;
export const quoteIdentifier = (identifier) => export const quoteIdentifier = (identifier) =>
`"${String(identifier).replaceAll('"', '""')}"`; `"${String(identifier).replaceAll('"', '""')}"`;
+296 -30
View File
@@ -9006,6 +9006,47 @@ a.secondary:active {
padding: 0 9px !important; padding: 0 9px !important;
font-size: var(--shd-type-telemetry) !important; font-size: var(--shd-type-telemetry) !important;
} }
.builder-radio-toggle {
width: 100%;
min-height: 48px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
margin-bottom: 5px;
padding: 6px 8px;
border: 1px solid var(--shd-border);
background: var(--shd-builder-canvas);
color: var(--shd-text);
text-align: left;
}
.builder-radio-toggle > img,
.builder-radio-toggle > i {
width: 32px;
height: 32px;
object-fit: contain;
}
.builder-radio-toggle > span {
min-width: 0;
display: grid;
gap: 2px;
}
.builder-radio-toggle b,
.builder-radio-toggle small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.builder-radio-toggle em {
color: var(--shd-orange);
font-size: var(--shd-type-telemetry);
font-style: normal;
font-weight: 900;
}
.builder-radio-toggle:hover,
.builder-radio-toggle:focus-visible {
border-color: var(--shd-orange);
}
.builder-radio-grid > div { .builder-radio-grid > div {
display: grid; display: grid;
grid-template-columns: repeat(var(--builder-radio-columns), minmax(0, 1fr)); grid-template-columns: repeat(var(--builder-radio-columns), minmax(0, 1fr));
@@ -9131,6 +9172,40 @@ a.secondary:active {
gap: 3px; gap: 3px;
margin-top: 6px; margin-top: 6px;
} }
.builder-talent-slot {
min-width: 0;
padding-top: 8px;
border-top: 1px solid var(--shd-border-soft);
}
.builder-talent-slot:first-child {
padding-top: 0;
border-top: 0;
}
.builder-exotic-talent > div:first-child {
min-height: 48px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--shd-border);
background: var(--shd-builder-canvas);
}
.builder-exotic-talent > div:first-child > .item-icon {
width: 36px;
height: 36px;
}
.builder-exotic-talent > div:first-child > span {
min-width: 0;
display: grid;
gap: 2px;
}
.builder-exotic-talent small {
color: var(--shd-orange);
font-size: var(--shd-type-telemetry);
font-weight: 900;
letter-spacing: 0.07em;
}
.builder-rolls.required > div { .builder-rolls.required > div {
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.65fr); grid-template-columns: minmax(0, 1fr) minmax(180px, 0.65fr);
} }
@@ -9178,7 +9253,7 @@ a.secondary:active {
} }
.builder-core-grid { .builder-core-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 7px; gap: 7px;
} }
.builder-range-grid.weapon-damage { .builder-range-grid.weapon-damage {
@@ -9278,6 +9353,8 @@ a.secondary:active {
} }
.loadout-slots > header, .loadout-slots > header,
.summary-section > header { .summary-section > header {
height: auto;
min-height: 36px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -9356,6 +9433,136 @@ a.secondary:active {
min-height: 680px; min-height: 680px;
padding: 12px; padding: 12px;
border-top: 2px solid var(--shd-orange); border-top: 2px solid var(--shd-orange);
container-type: inline-size;
}
.builder-editor-columns,
.builder-specialization-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 10px;
}
.builder-editor-lane {
min-width: 0;
align-content: start;
}
.builder-editor-lane + .builder-editor-lane {
padding-left: 16px;
border-left: 1px solid var(--shd-border);
}
.builder-editor-lane > :first-child {
margin-top: 0;
}
.builder-editor-lane .builder-weapon-controls {
grid-template-columns: minmax(180px, 1fr) minmax(120px, 0.65fr);
}
.builder-editor-lane .builder-equipment-controls {
grid-template-columns: 1fr;
}
.builder-editor-lane
.builder-equipment-controls
> .builder-radio-grid:last-child:nth-child(3) {
grid-column: auto;
}
.builder-editor-lane .builder-weapon-controls .builder-fixed {
grid-column: 1/-1;
}
.builder-editor-lane .builder-range-grid {
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
.builder-editor-lane .range-value-inputs {
grid-template-columns: minmax(0, 1fr) 72px;
}
.builder-editor-lane .builder-rolls.required > div,
.builder-editor-lane .builder-chip-rolls > div {
grid-template-columns: minmax(0, 1fr) minmax(150px, 0.72fr);
}
.builder-specialization-layout .builder-ability-chip-panels {
grid-template-columns: 1fr;
}
.builder-focus-abilities {
display: grid;
gap: var(--shd-gap-xs);
}
.builder-focus-abilities > button {
min-width: 0;
min-height: 58px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: var(--shd-gap-sm);
padding: var(--shd-gap-sm);
border: 1px solid var(--shd-border);
background: var(--shd-builder-canvas);
color: var(--shd-text);
text-align: left;
}
.builder-focus-abilities > button:hover,
.builder-focus-abilities > button:focus-visible {
border-color: var(--shd-orange);
}
.builder-focus-abilities img {
width: 38px;
height: 38px;
}
.builder-focus-abilities .builder-ability-icon {
width: 34px;
height: 34px;
}
.builder-focus-abilities span {
min-width: 0;
display: grid;
gap: 2px;
}
.builder-focus-abilities small,
.builder-focus-abilities b {
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
letter-spacing: 0.6px;
}
.builder-focus-abilities strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.builder-focus-abilities b {
white-space: nowrap;
}
.builder-specialization-layout .builder-specialization-identity {
grid-template-columns: auto minmax(0, 1fr);
}
.builder-specialization-layout .builder-specialization-identity > b {
grid-column: 1/-1;
width: max-content;
}
.builder-specialization-identity .focus-composite-icon {
width: 40px;
height: 40px;
flex: 0 0 40px;
}
.builder-specialization-identity .focus-composite-icon > .item-icon {
width: 36px;
height: 36px;
}
.builder-specialization-identity .focus-composite-icon > .game-icon {
right: -1px;
bottom: -1px;
width: 17px;
height: 17px;
padding: 2px;
}
@container (max-width: 700px) {
.builder-editor-columns,
.builder-specialization-layout {
grid-template-columns: 1fr;
gap: 12px;
}
.builder-editor-lane + .builder-editor-lane {
padding-top: 12px;
padding-left: 0;
border-top: 1px solid var(--shd-border);
border-left: 0;
}
} }
.loadout-editor-head { .loadout-editor-head {
display: flex; display: flex;
@@ -9688,11 +9895,12 @@ a.secondary:active {
} }
.summary-core { .summary-core {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, minmax(0, 1fr));
border-top: 1px solid #303536; border-top: 1px solid #303536;
border-bottom: 1px solid #303536; border-bottom: 1px solid #303536;
} }
.summary-core > div { .summary-core > div {
min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
@@ -9707,13 +9915,18 @@ a.secondary:active {
height: 22px; height: 22px;
} }
.summary-core span { .summary-core span {
min-width: 0;
display: grid; display: grid;
color: #687072; color: #687072;
font-size: var(--shd-type-telemetry); font-size: var(--shd-type-telemetry);
overflow-wrap: anywhere;
} }
.summary-core b { .summary-core b {
min-width: 0;
overflow: hidden;
color: #d6d9da; color: #d6d9da;
font-size: var(--shd-type-telemetry); font-size: var(--shd-type-telemetry);
text-overflow: ellipsis;
} }
.summary-scroll { .summary-scroll {
max-height: calc(100vh - 340px); max-height: calc(100vh - 340px);
@@ -9726,14 +9939,15 @@ a.secondary:active {
} }
.summary-stat, .summary-stat,
.summary-bonus { .summary-bonus {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: minmax(0, 1fr) minmax(70px, auto);
gap: 8px; gap: 8px;
padding: 8px 10px; padding: 8px 10px;
border-top: 1px solid #252a2b; border-top: 1px solid #252a2b;
} }
.summary-stat > span, .summary-stat > span,
.summary-bonus > span { .summary-bonus > span {
min-width: 0;
display: grid; display: grid;
gap: 3px; gap: 3px;
font-size: var(--shd-type-telemetry); font-size: var(--shd-type-telemetry);
@@ -9744,10 +9958,14 @@ a.secondary:active {
font-size: var(--shd-type-telemetry); font-size: var(--shd-type-telemetry);
} }
.summary-stat > b { .summary-stat > b {
min-width: 0;
overflow-wrap: anywhere;
color: #d9dbdc; color: #d9dbdc;
font-size: var(--shd-type-telemetry); font-size: var(--shd-type-telemetry);
} }
.summary-bonus em { .summary-bonus em {
min-width: 0;
overflow-wrap: anywhere;
color: var(--shd-success); color: var(--shd-success);
font-size: var(--shd-type-telemetry); font-size: var(--shd-type-telemetry);
font-style: normal; font-style: normal;
@@ -12003,10 +12221,7 @@ a.secondary:active {
} }
.loadout-workspace { .loadout-workspace {
min-width: 0; min-width: 0;
grid-template-columns: minmax(300px, 350px) minmax(480px, 1fr) minmax( grid-template-columns: minmax(300px, 350px) minmax(0, 1fr);
290px,
340px
);
gap: 6px; gap: 6px;
} }
.loadout-workspace > *, .loadout-workspace > *,
@@ -12040,8 +12255,20 @@ a.secondary:active {
} }
.loadout-summary { .loadout-summary {
min-width: 0; min-width: 0;
position: static;
grid-column: 1/3;
max-height: none;
overflow: visible;
background: var(--shd-surface); background: var(--shd-surface);
} }
.loadout-summary .summary-scroll {
max-height: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
}
.builder-editor-lane .builder-radio-grid:not(.icon-only) > div {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.builder-picker-current > * { .builder-picker-current > * {
min-width: 0; min-width: 0;
} }
@@ -12070,11 +12297,11 @@ a.secondary:active {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.builder-specialization-identity.ability-identity { .builder-specialization-identity.ability-identity {
grid-template-columns: 54px minmax(0, 1fr) auto; grid-template-columns: 46px minmax(0, 1fr) auto;
} }
.builder-specialization-identity.ability-identity > .item-icon { .builder-specialization-identity.ability-identity > .item-icon {
width: 50px; width: 42px;
height: 50px; height: 42px;
} }
.shared-build-guide { .shared-build-guide {
margin-top: 10px; margin-top: 10px;
@@ -12118,11 +12345,13 @@ a.secondary:active {
.loadout-summary { .loadout-summary {
position: static; position: static;
grid-column: 1/3; grid-column: 1/3;
max-height: none;
overflow: visible;
} }
.summary-scroll { .summary-scroll {
max-height: none; max-height: none;
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
} }
} }
@media (max-width: 1050px) { @media (max-width: 1050px) {
@@ -12699,10 +12928,9 @@ a.secondary:active {
} }
.mod-secondary-pool-option { .mod-secondary-pool-option {
display: grid; display: grid;
grid-template-columns: 30px minmax(220px, 0.9fr) repeat( grid-template-columns:
3, 30px minmax(220px, 0.9fr) repeat(3, minmax(285px, 1fr))
minmax(285px, 1fr) 30px;
) 30px;
align-items: stretch; align-items: stretch;
gap: 6px; gap: 6px;
padding: 6px; padding: 6px;
@@ -12814,10 +13042,9 @@ a.secondary:active {
} }
.skill-chip-profile-option.selectable { .skill-chip-profile-option.selectable {
grid-template-columns: 32px minmax(210px, 1.2fr) repeat( grid-template-columns:
3, 32px minmax(210px, 1.2fr) repeat(3, minmax(130px, 1fr))
minmax(130px, 1fr) 34px;
) 34px;
} }
.skill-chip-profile-option > div:first-child, .skill-chip-profile-option > div:first-child,
@@ -15345,6 +15572,17 @@ a.secondary:active {
border-left: 2px solid var(--shd-info); border-left: 2px solid var(--shd-info);
background: var(--shd-model-surface); background: var(--shd-model-surface);
} }
.specialization-skill-chip-sharing {
display: grid;
gap: var(--shd-gap-sm);
margin-top: var(--shd-gap-md);
padding: var(--shd-gap-md);
border: 1px solid var(--shd-border);
background: var(--shd-surface);
}
.specialization-skill-chip-sharing .assignment-picker {
margin: 0;
}
.specialization-attack-profile > .segmented-choice { .specialization-attack-profile > .segmented-choice {
margin: 0; margin: 0;
} }
@@ -15494,18 +15732,23 @@ a.secondary:active {
.builder-ability-chip-panels > section { .builder-ability-chip-panels > section {
min-width: 0; min-width: 0;
display: grid; display: grid;
grid-template-rows: auto minmax(48px, auto) auto; grid-template-rows: auto auto;
gap: var(--shd-gap-sm); gap: var(--shd-gap-sm);
padding: var(--shd-gap-sm); padding: var(--shd-gap-sm);
border: 1px solid var(--shd-border); border: 1px solid var(--shd-border);
background: var(--shd-surface); background: var(--shd-surface);
} }
.builder-ability-chip-panels header { .builder-ability-chip-panels header {
height: auto;
position: static;
min-width: 0; min-width: 0;
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--shd-gap-sm); gap: var(--shd-gap-sm);
align-items: center; align-items: center;
padding: 0;
border: 0;
background: transparent;
} }
.builder-ability-chip-panels header img { .builder-ability-chip-panels header img {
width: 38px; width: 38px;
@@ -15528,13 +15771,6 @@ a.secondary:active {
.builder-ability-chip-panels header b { .builder-ability-chip-panels header b {
white-space: nowrap; white-space: nowrap;
} }
.builder-ability-chip-panels > section > p {
min-width: 0;
margin: 0;
color: var(--shd-text-muted);
font-size: var(--shd-type-supporting);
overflow-wrap: anywhere;
}
.builder-ability-chip-panels > section > div { .builder-ability-chip-panels > section > div {
min-width: 0; min-width: 0;
display: grid; display: grid;
@@ -15544,7 +15780,7 @@ a.secondary:active {
min-width: 0; min-width: 0;
min-height: 54px; min-height: 54px;
display: grid; display: grid;
grid-template-columns: 70px minmax(0, 1fr); grid-template-columns: 36px minmax(0, 1fr);
grid-template-rows: auto auto; grid-template-rows: auto auto;
gap: 1px 8px; gap: 1px 8px;
padding: 7px; padding: 7px;
@@ -15558,8 +15794,13 @@ a.secondary:active {
.builder-ability-chip-panels > section > div > button:focus-visible { .builder-ability-chip-panels > section > div > button:focus-visible {
border-color: var(--shd-orange); border-color: var(--shd-orange);
} }
.builder-ability-chip-panels button small { .builder-ability-chip-panels button > .builder-chip-icon {
grid-row: 1/3; grid-row: 1/3;
width: 32px;
height: 32px;
object-fit: contain;
}
.builder-ability-chip-panels button small {
color: var(--shd-orange); color: var(--shd-orange);
font-size: var(--shd-type-telemetry); font-size: var(--shd-type-telemetry);
font-weight: 900; font-weight: 900;
@@ -15575,6 +15816,28 @@ a.secondary:active {
color: var(--shd-text-muted); color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry); font-size: var(--shd-type-telemetry);
} }
.builder-ability-chip-panels button > span {
display: grid;
gap: 2px;
overflow: hidden;
white-space: normal;
}
.builder-ability-chip-panels button > span > strong,
.builder-ability-chip-panels button > span > em {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.builder-ability-chip-panels button em {
display: block;
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
font-style: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.builder-specialization-telemetry { .builder-specialization-telemetry {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -15600,6 +15863,9 @@ a.secondary:active {
background: var(--shd-model-surface); background: var(--shd-model-surface);
} }
.builder-focus-tree > section > header { .builder-focus-tree > section > header {
height: auto;
min-height: 32px;
position: static;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 6px 8px; padding: 6px 8px;
+2 -1
View File
@@ -259,7 +259,7 @@ describe('loadout mechanic timeline', () => {
).toEqual([1, 2]); ).toEqual([1, 2]);
}); });
it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => { it('falls back to a documented Tier I mechanic when exotic Tier II is absent', () => {
const persistedRule = { const persistedRule = {
rule: { rule: {
id: 'tier-i-only', id: 'tier-i-only',
@@ -327,6 +327,7 @@ describe('loadout mechanic timeline', () => {
const adapted = equippedConditionalProgram({ const adapted = equippedConditionalProgram({
key: 'weapon:0:exotic-talent', key: 'weapon:0:exotic-talent',
tier: 2, tier: 2,
isExotic: true,
rule: persistedRule, rule: persistedRule,
}); });
+1 -1
View File
@@ -283,7 +283,7 @@ export function equippedConditionalProgram(
: 'base'; : 'base';
return ( return (
conditionalProgram(equipped.rule, variant) ?? conditionalProgram(equipped.rule, variant) ??
(equipmentTalent && variant === 'II' (equipmentTalent && equipped.isExotic && variant === 'II'
? conditionalProgram(equipped.rule, 'I') ? conditionalProgram(equipped.rule, 'I')
: null) : null)
); );
+157 -9
View File
@@ -207,7 +207,7 @@ describe('summarizeLoadout', () => {
}); });
expect(summary.equippedTalents).toEqual([ expect(summary.equippedTalents).toEqual([
expect.objectContaining({ expect.objectContaining({
key: 'chips:chip-set', key: 'chips:chip-set:ability',
tier: 1, tier: 1,
abilityId: 'ability', abilityId: 'ability',
description: 'Gain 25% Skill Damage.', description: 'Gain 25% Skill Damage.',
@@ -217,6 +217,7 @@ describe('summarizeLoadout', () => {
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => { it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
const draft = createDraft(); const draft = createDraft();
draft.specialization.focusId = 'focus-1';
draft.chips[0] = { draft.chips[0] = {
setId: 'chip-set', setId: 'chip-set',
quality: 'Superior', quality: 'Superior',
@@ -239,11 +240,24 @@ describe('summarizeLoadout', () => {
{ {
id: 'chip-set', id: 'chip-set',
name: 'Signal', name: 'Signal',
specialization_ability_id: 'ability-1', specialization_ability_id: 'ability-2',
compatibleAbilityIds: ['ability-2', 'ability-1'],
twoPiece: null, twoPiece: null,
talent: null, talent: null,
}, },
], ],
specializations: [
{
id: 'specialization-1',
focuses: [
{
id: 'focus-1',
abilities: [{ id: 'ability-1' }],
nodes: [],
},
],
},
],
}), }),
); );
@@ -272,6 +286,50 @@ describe('summarizeLoadout', () => {
]); ]);
}); });
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([ it.each([
[0, 10], [0, 10],
[1, 20], [1, 20],
@@ -425,11 +483,58 @@ describe('summarizeLoadout', () => {
expect(stat(summary, 'mechanic')).toBeUndefined(); expect(stat(summary, 'mechanic')).toBeUndefined();
}); });
it('renders the selected Talent tier without applying its mechanic outcomes to stats', () => { it('uses a gear Talent tier independently from the gear tier', () => {
const draft = createDraft();
draft.gear.Holster.itemId = 'gear';
draft.gear.Holster.talentId = 'talent';
Object.assign(draft.gear.Holster, { talentTier: 2 });
const summary = summarizeLoadout(
draft,
catalog({
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
gear: [{ id: 'gear', tier: 0, base_armor_min: null }],
talents: [
{
id: 'talent',
name: 'Reinforced',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'bonus', value: 20 }],
},
],
},
],
}),
);
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent',
tier: 2,
tierLabel: 'TIER II',
description: 'Gain 20%.',
}),
]);
});
it('uses each weapon Talent tier independently without applying mechanic outcomes to stats', () => {
const draft = createDraft(); const draft = createDraft();
draft.weapons[0].itemId = 'weapon'; draft.weapons[0].itemId = 'weapon';
draft.weapons[0].tier = 2; draft.weapons[0].tier = 2;
draft.weapons[0].talent1Id = 'talent'; draft.weapons[0].talent1Id = 'talent-one';
draft.weapons[0].talent2Id = 'talent-two';
Object.assign(draft.weapons[0], {
talent1Tier: 1,
talent2Tier: 2,
exoticTalentTier: 1,
});
const rule = { const rule = {
clauses: [ clauses: [
@@ -455,13 +560,13 @@ describe('summarizeLoadout', () => {
{ {
id: 'weapon', id: 'weapon',
name: 'Test Weapon', name: 'Test Weapon',
exotic_talent_id: null, exotic_talent_id: 'exotic-talent',
baseBonuses: [], baseBonuses: [],
}, },
], ],
talents: [ talents: [
{ {
id: 'talent', id: 'talent-one',
name: 'Measured', name: 'Measured',
template: 'Gain {1} for {2}.', template: 'Gain {1} for {2}.',
tiers: [ tiers: [
@@ -482,17 +587,60 @@ describe('summarizeLoadout', () => {
], ],
rule, rule,
}, },
{
id: 'talent-two',
name: 'Calibrated',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'talent-stat', value: 30 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'talent-stat', value: 40 }],
},
],
},
{
id: 'exotic-talent',
name: 'Exotic Talent',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'talent-stat', value: 50 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'talent-stat', value: 60 }],
},
],
},
], ],
}), }),
); );
expect(summary.equippedTalents).toEqual([ expect(summary.equippedTalents).toEqual([
expect.objectContaining({ expect.objectContaining({
id: 'talent', id: 'talent-one',
tier: 2, tier: 1,
description: 'Gain 20% for 3 Shots.', tierLabel: 'TIER I',
description: 'Gain 10% for 2 Shots.',
rule, rule,
}), }),
expect.objectContaining({
id: 'talent-two',
tier: 2,
tierLabel: 'TIER II',
description: 'Gain 40%.',
}),
expect.objectContaining({
id: 'exotic-talent',
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 50%.',
}),
]); ]);
expect(stat(summary, 'talent-stat')).toBeUndefined(); expect(stat(summary, 'talent-stat')).toBeUndefined();
}); });
+71 -24
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 = { export type LoadoutCatalog = {
attributes: any[]; attributes: any[];
@@ -100,18 +105,21 @@ function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`; return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
} }
function talentTier(catalog: LoadoutCatalog, talentId: string, tier: number) { function talentTier(
catalog: LoadoutCatalog,
talentId: string,
tier: TalentTier,
) {
const item = catalog.talents.find((entry) => entry.id === talentId); const item = catalog.talents.find((entry) => entry.id === talentId);
return ( return item?.tiers.find(
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? (entry: any) => entry.tier === (tier === 2 ? 'II' : 'I'),
item?.tiers[0]
); );
} }
function talentDescription( function talentDescription(
catalog: LoadoutCatalog, catalog: LoadoutCatalog,
talentId: string, talentId: string,
tier: number, tier: TalentTier,
) { ) {
const item = catalog.talents.find((entry) => entry.id === talentId); const item = catalog.talents.find((entry) => entry.id === talentId);
const configured = talentTier(catalog, talentId, tier); const configured = talentTier(catalog, talentId, tier);
@@ -255,12 +263,14 @@ export function summarizeLoadout(
rule: rule:
catalog.talents.find((entry) => entry.id === selection.talentId) catalog.talents.find((entry) => entry.id === selection.talentId)
?.rule ?? null, ?.rule ?? null,
tier: Number(item.tier), tier: selection.talentTier,
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
isExotic: false,
source: slot, source: slot,
description: talentDescription( description: talentDescription(
catalog, catalog,
selection.talentId, selection.talentId,
Number(item.tier), selection.talentTier,
), ),
}); });
} }
@@ -305,27 +315,48 @@ export function summarizeLoadout(
roll.value, roll.value,
`${index ? 'Secondary' : 'Primary'} weapon roll`, `${index ? 'Secondary' : 'Primary'} weapon roll`,
); );
for (const talentId of [ const equippedWeaponTalents = [
selection.talent1Id, {
selection.talent2Id, id: selection.talent1Id,
item.exotic_talent_id, tier: selection.talent1Tier,
].filter(Boolean)) { isExotic: false,
},
{
id: selection.talent2Id,
tier: selection.talent2Tier,
isExotic: false,
},
{
id: item.exotic_talent_id,
tier: selection.exoticTalentTier,
isExotic: true,
},
].filter((entry) => entry.id);
for (const equippedTalent of equippedWeaponTalents) {
equippedTalents.push({ equippedTalents.push({
key: `weapon:${index}:${talentId}`, key: `weapon:${index}:${equippedTalent.id}`,
id: talentId, id: equippedTalent.id,
rule: rule:
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null, catalog.talents.find((entry) => entry.id === equippedTalent.id)
tier: selection.tier, ?.rule ?? null,
tier: equippedTalent.tier,
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
isExotic: equippedTalent.isExotic,
source: index ? 'Secondary weapon' : 'Primary weapon', source: index ? 'Secondary weapon' : 'Primary weapon',
description: talentDescription(catalog, talentId, selection.tier), description: talentDescription(
catalog,
equippedTalent.id,
equippedTalent.tier,
),
}); });
} }
} }
const chipGroups = new Map< const chipGroups = new Map<
string, string,
{ count: number; highEnd: number; item: any } { count: number; highEnd: number; item: any; abilityId: string }
>(); >();
const focus = selectedFocus(draft, catalog);
const validChipRoll = ( const validChipRoll = (
kind: 'primaryOptions' | 'secondaryOptions', kind: 'primaryOptions' | 'secondaryOptions',
quality: string, quality: string,
@@ -345,9 +376,19 @@ export function summarizeLoadout(
const selection = draft.chips[index]; const selection = draft.chips[index];
const item = catalog.chipSets.find((entry) => entry.id === selection.setId); const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
if (!item) continue; if (!item) continue;
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)) if (validChipRoll('primaryOptions', selection.quality, selection.primary))
addAbilityStat( addAbilityStat(
String(item.specialization_ability_id ?? ''), abilityId,
selection.primary.statId, selection.primary.statId,
selection.primary.value, selection.primary.value,
index, index,
@@ -356,10 +397,16 @@ export function summarizeLoadout(
for (const roll of selection.secondary) for (const roll of selection.secondary)
if (validChipRoll('secondaryOptions', selection.quality, roll)) if (validChipRoll('secondaryOptions', selection.quality, roll))
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`); add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item }; const groupKey = `${item.id}:${abilityId}`;
const group = chipGroups.get(groupKey) ?? {
count: 0,
highEnd: 0,
item,
abilityId,
};
group.count += 1; group.count += 1;
if (selection.quality === 'High-end') group.highEnd += 1; if (selection.quality === 'High-end') group.highEnd += 1;
chipGroups.set(item.id, group); chipGroups.set(groupKey, group);
} }
for (const group of chipGroups.values()) { for (const group of chipGroups.values()) {
if (group.count >= 2 && group.item.twoPiece) { if (group.count >= 2 && group.item.twoPiece) {
@@ -384,12 +431,12 @@ export function summarizeLoadout(
} }
if (group.count === 3 && group.item.talent) { if (group.count === 3 && group.item.talent) {
equippedTalents.push({ equippedTalents.push({
key: `chips:${group.item.id}`, key: `chips:${group.item.id}:${group.abilityId}`,
id: '', id: '',
custom: group.item.talent, custom: group.item.talent,
rule: group.item.talent.rule ?? null, rule: group.item.talent.rule ?? null,
tier: group.highEnd, tier: group.highEnd,
abilityId: group.item.specialization_ability_id ?? null, abilityId: group.abilityId || null,
source: '3 Skill Chips', source: '3 Skill Chips',
description: group.item.talent.description_template.replace( description: group.item.talent.description_template.replace(
/\{(\d+)\}/g, /\{(\d+)\}/g,
+125 -2
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
createDraft, createDraft,
migrateLoadoutDraftV5,
parseLoadoutDraft, parseLoadoutDraft,
skillChipSecondaryCapacity, skillChipSecondaryCapacity,
type SkillChipQuality, type SkillChipQuality,
@@ -8,13 +9,40 @@ import {
const copyDraft = () => structuredClone(createDraft()); const copyDraft = () => structuredClone(createDraft());
const talentTierFields = [
[
'gear talentTier',
(draft: ReturnType<typeof copyDraft>) =>
draft.gear.Holster as unknown as Record<string, unknown>,
'talentTier',
],
[
'weapon talent1Tier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'talent1Tier',
],
[
'weapon talent2Tier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'talent2Tier',
],
[
'weapon exoticTalentTier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'exoticTalentTier',
],
] as const;
describe('parseLoadoutDraft', () => { describe('parseLoadoutDraft', () => {
it('accepts a current draft and returns an independent clone', () => { it('accepts a current draft and returns an independent clone', () => {
const draft = createDraft(); const draft = createDraft();
const parsed = parseLoadoutDraft(draft); const parsed = parseLoadoutDraft(draft);
expect(parsed).toEqual(draft); expect(parsed).toEqual(draft);
expect(parsed?.version).toBe(5); expect(parsed?.version).toBe(6);
expect(parsed?.chips[0]).toEqual({ expect(parsed?.chips[0]).toEqual({
setId: '', setId: '',
quality: 'Standard', quality: 'Standard',
@@ -27,6 +55,37 @@ describe('parseLoadoutDraft', () => {
expect(draft.gear.Holster.level).toBe(50); expect(draft.gear.Holster.level).toBe(50);
}); });
it('defaults every equipment Talent tier to Tier I', () => {
const draft = createDraft();
expect(
Object.values(draft.gear).map(
(selection) =>
(selection as unknown as Record<string, unknown>).talentTier,
),
).toEqual(Array(6).fill(1));
expect(
draft.weapons.map((selection) => {
const weapon = selection as unknown as Record<string, unknown>;
return [
weapon.talent1Tier,
weapon.talent2Tier,
weapon.exoticTalentTier,
];
}),
).toEqual([
[1, 1, 1],
[1, 1, 1],
]);
});
it('accepts Tier II for every equipment Talent selection', () => {
const draft = copyDraft();
for (const [, select, key] of talentTierFields) select(draft)[key] = 2;
expect(parseLoadoutDraft(draft)).toEqual(draft);
});
it('rejects the retired version 1 three-chip shape', () => { it('rejects the retired version 1 three-chip shape', () => {
const retired = copyDraft() as unknown as Record<string, unknown>; const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 1; retired.version = 1;
@@ -42,13 +101,77 @@ describe('parseLoadoutDraft', () => {
expect(parseLoadoutDraft(retired)).toBeNull(); expect(parseLoadoutDraft(retired)).toBeNull();
}); });
it('rejects a version 5 draft that does not contain exactly nine chips', () => { it('rejects the retired version 5 schema', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 5;
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('migrates version 5 equipment-derived Talent tiers once', () => {
const legacy = copyDraft() as unknown as Record<string, any>;
legacy.version = 5;
for (const selection of Object.values(legacy.gear) as Record<
string,
any
>[]) {
delete selection.talentTier;
}
for (const selection of legacy.weapons as Record<string, any>[]) {
delete selection.talent1Tier;
delete selection.talent2Tier;
delete selection.exoticTalentTier;
}
legacy.gear.Holster.itemId = 'tier-two-gear';
legacy.weapons[0].itemId = 'exotic-weapon';
legacy.weapons[0].tier = 2;
const migrated = migrateLoadoutDraftV5(legacy, {
gear: [{ id: 'tier-two-gear', tier: 2 }],
weapons: [{ id: 'exotic-weapon', exotic_talent_id: 'exotic-talent' }],
talents: [
{
id: 'exotic-talent',
tiers: [{ tier: 'I' }, { tier: 'II' }],
},
],
});
expect(migrated?.version).toBe(6);
expect(migrated?.gear.Holster.talentTier).toBe(2);
expect(migrated?.gear.Gloves.talentTier).toBe(1);
expect(migrated?.weapons[0]).toMatchObject({
talent1Tier: 2,
talent2Tier: 2,
exoticTalentTier: 2,
});
});
it('rejects a current draft that does not contain exactly nine chips', () => {
const draft = copyDraft(); const draft = copyDraft();
draft.chips.pop(); draft.chips.pop();
expect(parseLoadoutDraft(draft)).toBeNull(); expect(parseLoadoutDraft(draft)).toBeNull();
}); });
it.each(talentTierFields)('rejects a draft missing %s', (_, select, key) => {
const draft = copyDraft();
delete select(draft)[key];
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each(
talentTierFields.flatMap(([name, select, key]) =>
[0, 1.5, 3].map((value) => [name, value, select, key] as const),
),
)('rejects %s value %s', (_, value, select, key) => {
const draft = copyDraft();
select(draft)[key] = value;
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each([ it.each([
['Standard', 0], ['Standard', 0],
['Specialized', 1], ['Specialized', 1],
+55 -3
View File
@@ -1,4 +1,5 @@
export type Roll = { statId: string; value: number }; export type Roll = { statId: string; value: number };
export type TalentTier = 1 | 2;
export const skillChipQualities = [ export const skillChipQualities = [
'Standard', 'Standard',
'Specialized', 'Specialized',
@@ -17,6 +18,7 @@ export type GearSelection = {
level: number; level: number;
brandId: string; brandId: string;
talentId: string; talentId: string;
talentTier: TalentTier;
health: number; health: number;
armor: number; armor: number;
firepower: number; firepower: number;
@@ -34,7 +36,10 @@ export type WeaponSelection = {
toughness: number; toughness: number;
engineering: number; engineering: number;
talent1Id: string; talent1Id: string;
talent1Tier: TalentTier;
talent2Id: string; talent2Id: string;
talent2Tier: TalentTier;
exoticTalentTier: TalentTier;
rolls: [Roll, Roll]; rolls: [Roll, Roll];
}; };
export type ChipSelection = { export type ChipSelection = {
@@ -81,7 +86,7 @@ export type SpecializationSelection = {
nodeRanks: Record<string, number>; nodeRanks: Record<string, number>;
}; };
export type LoadoutDraft = { export type LoadoutDraft = {
version: 5; version: 6;
buildName: string; buildName: string;
calculationMode: CalculationMode; calculationMode: CalculationMode;
gear: Record<string, GearSelection>; gear: Record<string, GearSelection>;
@@ -118,6 +123,7 @@ export const blankGear = (): GearSelection => ({
level: 50, level: 50,
brandId: '', brandId: '',
talentId: '', talentId: '',
talentTier: 1,
health: 0, health: 0,
armor: 0, armor: 0,
firepower: 0, firepower: 0,
@@ -135,7 +141,10 @@ export const blankWeapon = (): WeaponSelection => ({
toughness: 0, toughness: 0,
engineering: 0, engineering: 0,
talent1Id: '', talent1Id: '',
talent1Tier: 1,
talent2Id: '', talent2Id: '',
talent2Tier: 1,
exoticTalentTier: 1,
rolls: [blankRoll(), blankRoll()], rolls: [blankRoll(), blankRoll()],
}); });
export const blankChip = (): ChipSelection => ({ export const blankChip = (): ChipSelection => ({
@@ -161,7 +170,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
extraHealthActive: false, extraHealthActive: false,
}); });
export const createDraft = (): LoadoutDraft => ({ export const createDraft = (): LoadoutDraft => ({
version: 5, version: 6,
buildName: 'Untitled loadout', buildName: 'Untitled loadout',
calculationMode: 'maximum', calculationMode: 'maximum',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])), gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
@@ -187,6 +196,39 @@ export const createDraft = (): LoadoutDraft => ({
}, },
}); });
export function migrateLoadoutDraftV5(
value: unknown,
catalog: { gear: any[]; weapons: any[]; talents: any[] },
): LoadoutDraft | null {
if (!isRecord(value) || value.version !== 5) return null;
const migrated = structuredClone(value);
if (!isRecord(migrated.gear) || !Array.isArray(migrated.weapons)) return null;
for (const slot of gearSlots) {
const selection = migrated.gear[slot];
if (!isRecord(selection)) return null;
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
selection.talentTier = Number(item?.tier) >= 2 ? 2 : 1;
}
for (const selection of migrated.weapons) {
if (!isRecord(selection)) return null;
const inheritedTier: TalentTier = Number(selection.tier) >= 2 ? 2 : 1;
selection.talent1Tier = inheritedTier;
selection.talent2Tier = inheritedTier;
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
const exoticTalent = catalog.talents.find(
(entry) => entry.id === item?.exotic_talent_id,
);
selection.exoticTalentTier =
inheritedTier === 2 &&
exoticTalent?.tiers?.some((entry: any) => entry.tier === 'II')
? 2
: 1;
}
migrated.version = 6;
return parseLoadoutDraft(migrated);
}
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value); Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) => const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
@@ -194,6 +236,8 @@ const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
keys.every((key) => key in value); keys.every((key) => key in value);
const isFiniteNumber = (value: unknown): value is number => const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value); typeof value === 'number' && Number.isFinite(value);
const isTalentTier = (value: unknown): value is TalentTier =>
value === 1 || value === 2;
const isNumberRecord = (value: unknown) => const isNumberRecord = (value: unknown) =>
isRecord(value) && isRecord(value) &&
Object.values(value).every( Object.values(value).every(
@@ -213,6 +257,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
'level', 'level',
'brandId', 'brandId',
'talentId', 'talentId',
'talentTier',
'health', 'health',
'armor', 'armor',
'firepower', 'firepower',
@@ -224,6 +269,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
['itemId', 'brandId', 'talentId'].every( ['itemId', 'brandId', 'talentId'].every(
(key) => typeof value[key] === 'string', (key) => typeof value[key] === 'string',
) && ) &&
isTalentTier(value.talentTier) &&
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every( ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]), (key) => isFiniteNumber(value[key]),
) && ) &&
@@ -241,12 +287,18 @@ const isWeaponSelection = (value: unknown): value is WeaponSelection =>
'toughness', 'toughness',
'engineering', 'engineering',
'talent1Id', 'talent1Id',
'talent1Tier',
'talent2Id', 'talent2Id',
'talent2Tier',
'exoticTalentTier',
'rolls', 'rolls',
]) && ]) &&
['itemId', 'talent1Id', 'talent2Id'].every( ['itemId', 'talent1Id', 'talent2Id'].every(
(key) => typeof value[key] === 'string', (key) => typeof value[key] === 'string',
) && ) &&
isTalentTier(value.talent1Tier) &&
isTalentTier(value.talent2Tier) &&
isTalentTier(value.exoticTalentTier) &&
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every( ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]), (key) => isFiniteNumber(value[key]),
) && ) &&
@@ -369,7 +421,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
'skillActivations', 'skillActivations',
'specialization', 'specialization',
]) || ]) ||
value.version !== 5 || value.version !== 6 ||
typeof value.buildName !== 'string' || typeof value.buildName !== 'string' ||
!['maximum', 'custom'].includes(String(value.calculationMode)) !['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,
};
}
+7 -5
View File
@@ -791,9 +791,9 @@ describe('conditional mechanic authoring payload', () => {
}); });
}); });
it('rejects invalid pre-event timing for current Health percentage references', () => { it('preserves pre-event timing for current Health percentage references', () => {
const invalid = draft(); const value = draft();
invalid.clauses[0].if = { value.clauses[0].if = {
kind: 'compare', kind: 'compare',
left: { left: {
source: 'combat', source: 'combat',
@@ -803,11 +803,13 @@ describe('conditional mechanic authoring payload', () => {
operator: 'lt', operator: 'lt',
right: 50, right: 50,
}; };
const result = parseConditionalMechanicDraft(invalid, { const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'], variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']), allowedAttributeIds: new Set(['weapon-damage']),
}); });
expect(result.ok).toBe(false); expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.draft.clauses[0].if).toEqual(value.clauses[0].if);
}); });
it('accepts compatible radius targeting and rejects incompatible scopes', () => { it('accepts compatible radius targeting and rejects incompatible scopes', () => {
+1 -2
View File
@@ -426,8 +426,7 @@ function isReference(value: unknown): value is ValueReference {
Boolean(value.path.trim()) && Boolean(value.path.trim()) &&
(value.timing === undefined || (value.timing === undefined ||
value.timing === 'pre_event' || value.timing === 'pre_event' ||
value.timing === 'current') && value.timing === 'current')
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
); );
case 'equipment': case 'equipment':
return typeof value.path === 'string' && Boolean(value.path.trim()); return typeof value.path === 'string' && Boolean(value.path.trim());
@@ -850,7 +850,7 @@ describe('conditional-effect persistence adapter', () => {
left: { left: {
source: 'combat', source: 'combat',
path: 'currentHealthPercent', path: 'currentHealthPercent',
timing: 'current', timing: 'pre_event',
}, },
operator: 'lt', operator: 'lt',
right: 25, right: 25,
+1 -2
View File
@@ -216,8 +216,7 @@ function isValueReference(value: unknown) {
Boolean(value.path.trim()) && Boolean(value.path.trim()) &&
(value.timing === undefined || (value.timing === undefined ||
value.timing === 'pre_event' || value.timing === 'pre_event' ||
value.timing === 'current') && value.timing === 'current')
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
); );
} }
if (value.source === 'attribute') { if (value.source === 'attribute') {
+61 -18
View File
@@ -2834,13 +2834,13 @@ describe('mechanic validation and pseudo-script', () => {
]); ]);
}); });
it('rejects pre-event timing for the derived current Health percentage', () => { it('evaluates pre-event timing for the derived current Health percentage', () => {
const invalid: MechanicProgram = { const program: MechanicProgram = {
id: 'invalid-health-percent', id: 'pre-event-health-percent',
name: 'Invalid Health Percent', name: 'Pre-event Health Percent',
clauses: [ clauses: [
{ {
id: 'invalid', id: 'full-health-before-event',
when: 'tick', when: 'tick',
if: { if: {
kind: 'compare', kind: 'compare',
@@ -2849,25 +2849,32 @@ describe('mechanic validation and pseudo-script', () => {
path: 'currentHealthPercent', path: 'currentHealthPercent',
timing: 'pre_event', timing: 'pre_event',
}, },
operator: 'lt', operator: 'gte',
right: 50, right: 100,
}, },
reducer: { kind: 'none' }, reducer: { kind: 'none' },
mutations: [], mutations: [],
outcomes: [
{
id: 'matched',
label: 'Matched',
phase: 'on_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
value: 1,
},
],
}, },
], ],
}; };
const result = validateMechanicProgram(invalid); expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
expect(result.ok).toBe(false); expect(
if (!result.ok) { evaluateMechanicProgram(program, [{ kind: 'tick', at: 0 }], custom(1), {
expect(result.errors).toEqual( maxHealth: 100,
expect.arrayContaining([ currentHealth: 100,
expect.stringContaining( }).outcomes.map((outcome) => outcome.outcomeId),
'current Health percentage must use current timing', ).toEqual(['matched']);
),
]),
);
}
}); });
it('formats nested conditions and state operations deterministically', () => { it('formats nested conditions and state operations deterministically', () => {
@@ -3104,6 +3111,42 @@ describe('mechanic validation and pseudo-script', () => {
expect( expect(
maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0), maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0),
).toBe(true); ).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', () => { it('requires authoritative area evidence only when an area outcome activates', () => {
+13 -14
View File
@@ -97,6 +97,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<
description: description:
'The Agent deploys or activates a specialization ability. An assigned-ability deployment may supply its active duration.', 'The Agent deploys or activates a specialization ability. An assigned-ability deployment may supply its active duration.',
fields: [ fields: [
{
path: 'values.abilityId',
label: 'Deployed ability ID',
type: 'string',
},
{ {
path: 'values.assignedAbilityDurationSeconds', path: 'values.assignedAbilityDurationSeconds',
label: 'Assigned ability duration', label: 'Assigned ability duration',
@@ -123,8 +128,9 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<
key: 'skill_hit', key: 'skill_hit',
label: 'Skill hit', label: 'Skill hit',
description: description:
'A deployed skill connects with a target. Critical and distance details belong in event values.', 'A deployed skill connects with a target. Critical and distance details belong in event values. An optional area-target sample lists valid entities by distance from the hit target.',
fields: [ fields: [
{ path: 'values.abilityId', label: 'Source ability ID', type: 'string' },
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' }, { path: 'values.critical', label: 'Critical hit', type: 'boolean' },
{ {
path: 'values.assignedAbilityActive', path: 'values.assignedAbilityActive',
@@ -1892,15 +1898,8 @@ function validateReference(
} }
if (reference.source === 'event' && !reference.path.trim()) if (reference.source === 'event' && !reference.path.trim())
errors.push(`${path} requires an event path.`); errors.push(`${path} requires an event path.`);
if (reference.source === 'combat') { if (reference.source === 'combat' && !reference.path.trim())
if (!reference.path.trim()) errors.push(`${path} requires a combat path.`); errors.push(`${path} requires a combat path.`);
if (
reference.path === 'currentHealthPercent' &&
reference.timing === 'pre_event'
) {
errors.push(`${path} current Health percentage must use current timing.`);
}
}
if (reference.source === 'attribute' && !reference.attributeId.trim()) if (reference.source === 'attribute' && !reference.attributeId.trim())
errors.push(`${path} requires an Attribute ID.`); errors.push(`${path} requires an Attribute ID.`);
if (reference.source === 'equipment' && !reference.path.trim()) if (reference.source === 'equipment' && !reference.path.trim())
@@ -2067,7 +2066,7 @@ function validateMechanicEvents(events: MechanicEvent[]) {
if (sample === undefined) continue; if (sample === undefined) continue;
const samplePath = `Event ${index + 1} (${definition.label}) area-target sample`; const samplePath = `Event ${index + 1} (${definition.label}) area-target sample`;
if ( if (
event.kind !== 'weapon_hit' || !['weapon_hit', 'skill_hit'].includes(event.kind) ||
!objectValue(sample) || !objectValue(sample) ||
sample.center !== 'event_target' || sample.center !== 'event_target' ||
!Array.isArray(sample.validTargets) || !Array.isArray(sample.validTargets) ||
@@ -2075,7 +2074,7 @@ function validateMechanicEvents(events: MechanicEvent[]) {
!event.targetId.trim() !event.targetId.trim()
) { ) {
errors.push( errors.push(
`${samplePath} requires a weapon-hit event target and event-target-centered valid targets.`, `${samplePath} requires a weapon-hit or skill-hit event target and event-target-centered valid targets.`,
); );
continue; continue;
} }
@@ -2769,10 +2768,10 @@ export function validateMechanicProgram(
if (targetingError) errors.push(`${outcomePath} ${targetingError}`); if (targetingError) errors.push(`${outcomePath} ${targetingError}`);
if ( if (
outcome.targeting?.kind === 'event_target_area' && outcome.targeting?.kind === 'event_target_area' &&
clause.when !== 'weapon_hit' !['weapon_hit', 'skill_hit'].includes(clause.when)
) { ) {
errors.push( errors.push(
`${outcomePath} event-target area damage requires a weapon-hit clause.`, `${outcomePath} event-target area damage requires a weapon-hit or skill-hit clause.`,
); );
} }
if ( if (
+44 -24
View File
@@ -23,7 +23,9 @@
let coverUploading = $state(false); let coverUploading = $state(false);
let initialized = $state(false); let initialized = $state(false);
let lastContent = ''; let lastContent = '';
let saveSequence = 0; let saveTimer: number | undefined;
let saveInFlight = false;
let saveQueued = false;
const groupedTags = $derived.by(() => { const groupedTags = $derived.by(() => {
const groups: Record<string, ComposerTag[]> = {}; const groups: Record<string, ComposerTag[]> = {};
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag); for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
@@ -35,6 +37,40 @@
coverImageId, coverAlt, coverCaption, version }; coverImageId, coverAlt, coverCaption, version };
} }
function scheduleSave() {
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
saveTimer = undefined;
void saveDraft();
}, 900);
}
async function saveDraft() {
if (saveInFlight || !saveQueued) return;
saveInFlight = true;
saveQueued = false;
const draft = { ...payload(), tagIds: [...selectedTags] };
try {
const response = await fetch('/api/articles/draft', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(draft)
});
const result = await response.json();
if (response.status === 409) {
saveState = 'conflict';
return;
}
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
version = result.version;
saveState = saveQueued ? 'saving' : 'saved';
} catch (error) {
saveState = 'error';
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
} finally {
saveInFlight = false;
if (saveQueued && saveState !== 'conflict') scheduleSave();
}
}
$effect(() => { $effect(() => {
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption }); const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
if (!initialized) { if (!initialized) {
@@ -44,29 +80,13 @@
} }
if (content === lastContent) return; if (content === lastContent) return;
lastContent = content; lastContent = content;
const sequence = ++saveSequence; saveQueued = true;
saveState = 'saving'; saveState = 'saving';
const timer = window.setTimeout(async () => { if (!saveInFlight) scheduleSave();
try { return () => {
const response = await fetch('/api/articles/draft', { if (saveTimer !== undefined) window.clearTimeout(saveTimer);
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload()) saveTimer = undefined;
}); };
const result = await response.json();
if (sequence !== saveSequence) return;
if (response.status === 409) {
saveState = 'conflict';
return;
}
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
version = result.version;
saveState = 'saved';
} catch (error) {
if (sequence !== saveSequence) return;
saveState = 'error';
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
}
}, 900);
return () => window.clearTimeout(timer);
}); });
function toggleTag(id: string) { function toggleTag(id: string) {
@@ -115,7 +135,7 @@
<div class="article-publication-note"><p>{visibility === 'private' ? 'Only you can open this draft.' : visibility === 'unlisted' ? 'Anyone with the direct URL can read it.' : 'Listed in discovery and visible to everyone.'}</p>{#if data.article.pendingRevisionId}<strong>MODERATION REVIEW PENDING</strong>{/if}</div> <div class="article-publication-note"><p>{visibility === 'private' ? 'Only you can open this draft.' : visibility === 'unlisted' ? 'Anyone with the direct URL can read it.' : 'Listed in discovery and visible to everyone.'}</p>{#if data.article.pendingRevisionId}<strong>MODERATION REVIEW PENDING</strong>{/if}</div>
<PendingForm action="?/publish" class="article-publish-form"> <PendingForm action="?/publish" class="article-publish-form">
<input type="hidden" name="articleId" value={data.article.id}/><input type="hidden" name="title" value={title}/><input type="hidden" name="deck" value={deck}/><input type="hidden" name="document" value={document}/><input type="hidden" name="tagIds" value={JSON.stringify(selectedTags)}/><input type="hidden" name="channel" value={channel}/><input type="hidden" name="coverImageId" value={coverImageId}/><input type="hidden" name="coverAlt" value={coverAlt}/><input type="hidden" name="coverCaption" value={coverCaption}/><input type="hidden" name="version" value={version}/><input type="hidden" name="visibility" value={visibility}/> <input type="hidden" name="articleId" value={data.article.id}/><input type="hidden" name="title" value={title}/><input type="hidden" name="deck" value={deck}/><input type="hidden" name="document" value={document}/><input type="hidden" name="tagIds" value={JSON.stringify(selectedTags)}/><input type="hidden" name="channel" value={channel}/><input type="hidden" name="coverImageId" value={coverImageId}/><input type="hidden" name="coverAlt" value={coverAlt}/><input type="hidden" name="coverCaption" value={coverCaption}/><input type="hidden" name="version" value={version}/><input type="hidden" name="visibility" value={visibility}/>
<button class="primary">{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button> <button class="primary" disabled={saveState === 'saving' || coverUploading}>{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
</PendingForm> </PendingForm>
</section> </section>
+4 -4
View File
@@ -5,7 +5,7 @@
items, items,
value = $bindable(''), value = $bindable(''),
label, label,
placeholder = 'Search available items…', placeholder = 'Search…',
disabledIds = [], disabledIds = [],
onchange onchange
}: { }: {
@@ -27,19 +27,19 @@
<span>{label}</span> <span>{label}</span>
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}> <button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
<ItemIcon src={selected?.iconUrl} alt=""/> <ItemIcon src={selected?.iconUrl} alt=""/>
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em> <b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
</button> </button>
{#if open} {#if open}
<div class="builder-picker-panel"> <div class="builder-picker-panel">
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/> <input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
<div> <div>
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR SLOT</b><small>REMOVE CURRENT SELECTION</small></span><em>CLEAR</em></button>{/if} {#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR</b><small>REMOVE SELECTION</small></span><em>CLEAR</em></button>{/if}
{#each results as item} {#each results as item}
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}> <button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
<ItemIcon src={item.iconUrl} alt=""/> <ItemIcon src={item.iconUrl} alt=""/>
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em> <span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
</button> </button>
{:else}<p>NO MATCHING ITEMS</p>{/each} {:else}<p>NO MATCHES</p>{/each}
</div> </div>
</div> </div>
{/if} {/if}
+24 -15
View File
@@ -24,6 +24,8 @@
} = $props(); } = $props();
let query = $state(''); let query = $state('');
let open = $state(false);
const selected = $derived(items.find((item) => item.id === value));
const visible = $derived(items.filter((item) => const visible = $derived(items.filter((item) =>
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()) !query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
)); ));
@@ -31,26 +33,33 @@
function choose(id: string) { function choose(id: string) {
value = id; value = id;
onchange?.(id); onchange?.(id);
if (searchable) {
open = false;
query = '';
}
} }
</script> </script>
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}> <fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
<legend>{label}</legend> <legend>{label}</legend>
{#if searchable} {#if searchable}<button type="button" class="builder-radio-toggle" aria-expanded={open} onclick={() => open = !open}><ItemIcon src={selected?.iconUrl} alt=""/><span><b>{selected?.name ?? 'SELECT OPTION'}</b><small>{selected?.category ?? 'NOT CONFIGURED'}</small></span><em>{open ? 'CLOSE' : 'CHANGE'}</em></button>{/if}
{#if searchable && open}
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/> <input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
{/if} {/if}
<div> {#if !searchable || open}
{#each visible as item} <div>
<label title={item.name}> {#each visible as item}
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/> <label title={item.name}>
<span> <input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
<ItemIcon src={item.iconUrl} alt=""/> <span>
<b>{item.name}</b> <ItemIcon src={item.iconUrl} alt=""/>
{#if item.category}<small>{item.category}</small>{/if} <b>{item.name}</b>
</span> {#if item.category}<small>{item.category}</small>{/if}
</label> </span>
{:else} </label>
<p>NO MATCHING OPTIONS</p> {:else}
{/each} <p>NO MATCHING OPTIONS</p>
</div> {/each}
</div>
{/if}
</fieldset> </fieldset>
+1 -1
View File
@@ -14,4 +14,4 @@
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`); const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
</script> </script>
<img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={className}/> <img src={src || MISSING_ITEM_ICON_URL} alt={resolvedAlt} class:missing-item-icon={!src} class={`item-icon ${className}`.trim()}/>
@@ -78,6 +78,7 @@
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId }, { label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds }, { label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds }, { label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
{ label: 'SHARED SKILL CHIP ABILITY', current: (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id === specializationRecord?.id ? (currentSnapshot?.skillChipLinks as any[])?.[0]?.compatible_ability_id : (currentSnapshot?.skillChipLinks as any[])?.[0]?.ability_id, proposed: proposed.skillChipCompatibleAbilityId },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description } { label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
] ]
: action === 'saveCombatEffect' : action === 'saveCombatEffect'
+31 -31
View File
@@ -37,20 +37,20 @@
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1])); && Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
const issueLabel = (issue: string) => { const issueLabel = (issue: string) => {
if (/not configured|required/i.test(issue)) return 'ACTION REQUIRED'; if (/not configured|required|select|choose/i.test(issue)) return 'REQUIRED';
if (/incomplete/i.test(issue)) return 'DATA INCOMPLETE'; if (/incomplete/i.test(issue)) return 'INCOMPLETE';
return 'CHECK CONFIGURATION'; return 'CHECK';
}; };
</script> </script>
<section class="loadout-matrix" aria-label="Loadout matrix"> <section class="loadout-matrix" aria-label="Loadout matrix">
<header> <header>
<span>LOADOUT MATRIX</span> <span>LOADOUT</span>
<b class:ready={configured}>{configured ? 'COMBAT READY' : 'CONFIGURATION INCOMPLETE'}</b> <b class:ready={configured}>{configured ? 'COMPLETE' : 'INCOMPLETE'}</b>
</header> </header>
<div class="matrix-section doctrine-section"> <div class="matrix-section doctrine-section">
<small class="section-label">DOCTRINE · ROOT CHOICE</small> <small class="section-label">SPECIALIZATION FOCUS</small>
<button <button
type="button" type="button"
class="doctrine-cell" class="doctrine-cell"
@@ -60,27 +60,15 @@
onclick={() => onselect(doctrine.id)} onclick={() => onselect(doctrine.id)}
> >
<ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" /> <ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" />
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span> <span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em class:visible={selected(doctrine)}>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
<i aria-hidden="true"></i> <i aria-hidden="true"></i>
</button> </button>
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div> <div class="supply-line"><span>ABILITIES</span></div>
<div class="ability-cells"> <div class="ability-cells">
{#each abilities as cell} {#each abilities as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}> <button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" /> <ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span> <span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
</button>
{/each}
</div>
</div>
<div class="matrix-section">
<small class="section-label">WEAPONS · PRIMARY + SECONDARY</small>
<div class="equipment-cells">
{#each weapons as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i> <i aria-hidden="true"></i>
</button> </button>
{/each} {/each}
@@ -88,12 +76,25 @@
</div> </div>
<div class="matrix-section"> <div class="matrix-section">
<small class="section-label">GEAR · 6 POSITIONS</small> <small class="section-label">WEAPONS</small>
<div class="equipment-cells">
{#each weapons as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
</div>
</div>
<div class="matrix-section">
<small class="section-label">GEAR</small>
<div class="equipment-cells gear-cells"> <div class="equipment-cells gear-cells">
{#each gear as cell} {#each gear as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}> <button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" /> <ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span> <span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i> <i aria-hidden="true"></i>
</button> </button>
{/each} {/each}
@@ -101,10 +102,10 @@
</div> </div>
<div class="matrix-section protocol-section"> <div class="matrix-section protocol-section">
<small class="section-label">AGENT SYSTEM · ONE OS PROTOCOL</small> <small class="section-label">OS PROTOCOL</small>
<button type="button" class:active={selected(protocol)} class:needs-attention={Boolean(protocol.issue)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}> <button type="button" class:active={selected(protocol)} class:needs-attention={Boolean(protocol.issue)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}>
<ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" /> <ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" />
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span> <span><small>{protocol.label}</small><strong>{protocol.name}</strong><em class:visible={selected(protocol)}>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
<i aria-hidden="true"></i> <i aria-hidden="true"></i>
</button> </button>
</div> </div>
@@ -112,21 +113,20 @@
<style> <style>
.loadout-matrix{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)} .loadout-matrix{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
.loadout-matrix>header{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em} .loadout-matrix>header{height:auto;min-height:40px;position:static;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
.loadout-matrix>header b{color:var(--shd-warning);font-size:9px;text-align:right}.loadout-matrix>header b.ready{color:var(--shd-success)} .loadout-matrix>header b{color:var(--shd-warning);font-size:9px;text-align:right}.loadout-matrix>header b.ready{color:var(--shd-success)}
.matrix-section{padding:5px;border-bottom:1px solid var(--shd-border)}.matrix-section:last-child{border-bottom:0} .matrix-section{padding:5px;border-bottom:1px solid var(--shd-border)}.matrix-section:last-child{border-bottom:0}
.section-label{display:block;padding:2px 3px 5px;color:var(--shd-text-dim);font-size:9px;font-weight:900;letter-spacing:.09em} .section-label{display:block;padding:2px 3px 5px;color:var(--shd-text-dim);font-size:9px;font-weight:900;letter-spacing:.09em}
.doctrine-cell,.equipment-cells button,.ability-cells button,.protocol-section>button{min-width:0;display:grid;grid-template-columns:34px minmax(0,1fr) 3px;align-items:center;gap:7px;padding:7px;border:1px solid var(--shd-border-soft);background:var(--shd-builder-canvas);color:var(--shd-text);text-align:left} .doctrine-cell,.equipment-cells button,.ability-cells button,.protocol-section>button{min-width:0;display:grid;grid-template-columns:34px minmax(0,1fr) 3px;align-items:center;gap:7px;padding:7px;border:1px solid var(--shd-border-soft);background:var(--shd-builder-canvas);color:var(--shd-text);text-align:left}
.doctrine-cell{width:100%;min-height:76px;grid-template-columns:42px minmax(0,1fr) 3px}.doctrine-cell :global(.matrix-icon){width:40px;height:40px} .doctrine-cell{width:100%;min-height:76px;grid-template-columns:42px minmax(0,1fr) 3px}.doctrine-cell :global(.matrix-icon){width:40px;height:40px}
:global(.matrix-icon){width:30px;height:30px;object-fit:contain} :global(.matrix-icon){width:30px;height:30px;object-fit:contain}
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)} button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button em{display:none}button em.visible{display:block}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)} button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)}
button.needs-attention{border-bottom-color:color-mix(in srgb,var(--shd-danger) 68%,var(--shd-border-soft))}button.needs-attention>i{background:var(--shd-danger)}button mark{width:fit-content;max-width:100%;overflow:hidden;margin-top:2px;padding:2px 4px;border:1px solid color-mix(in srgb,var(--shd-danger) 58%,transparent);background:#1d1212;color:var(--shd-danger);font-size:8px;font-weight:900;letter-spacing:.07em;line-height:1.2;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap} button.needs-attention{border-bottom-color:color-mix(in srgb,var(--shd-danger) 68%,var(--shd-border-soft))}button.needs-attention>i{background:var(--shd-danger)}button mark{width:fit-content;max-width:100%;overflow:hidden;margin-top:2px;padding:2px 4px;border:1px solid color-mix(in srgb,var(--shd-danger) 58%,transparent);background:#1d1212;color:var(--shd-danger);font-size:8px;font-weight:900;letter-spacing:.07em;line-height:1.2;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}
.supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em} .supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em}
.ability-cells{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:3px}.ability-cells button{min-height:68px;grid-template-columns:25px minmax(0,1fr);padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px} .ability-cells{display:grid;grid-template-columns:1fr;gap:3px}.ability-cells button{min-height:48px;grid-template-columns:25px minmax(0,1fr) 3px;padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
.equipment-cells{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px} .equipment-cells{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px}
button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px} button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
@media(max-width:1240px){.ability-cells{grid-template-columns:1fr}.ability-cells button{min-height:48px}} @media(max-width:800px) and (min-width:651px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}.ability-cells button{min-height:68px}}
@media(max-width:800px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}} @media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}.gear-cells{grid-template-columns:1fr}}
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}}
</style> </style>
@@ -37,10 +37,9 @@
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}> <section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
<div class="summary-head"> <div class="summary-head">
<div> <div>
<p class="eyebrow"><span></span> KNOWN EQUIPMENT TOTALS</p> <p class="eyebrow"><span></span> KNOWN STATS</p>
<h2 id={headingId}>Agent stats</h2> <h2 id={headingId}>Agent stats</h2>
</div> </div>
<b>{summary.stats.length}</b>
</div> </div>
<div class="summary-core"> <div class="summary-core">
{#each ['Firepower', 'Toughness', 'Engineering'] as name} {#each ['Firepower', 'Toughness', 'Engineering'] as name}
@@ -53,49 +52,57 @@
</div> </div>
<div class="summary-scroll"> <div class="summary-scroll">
<div class="summary-section"> <div class="summary-section">
<header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header> <header><span>ATTRIBUTES</span><b>{summary.stats.length}</b></header>
{#each summary.stats as stat} {#each summary.stats as stat}
<div class="summary-stat"> <div class="summary-stat">
<span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span> <span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span>
<b>{unitValue(stat.value, stat.id)}</b> <b>{unitValue(stat.value, stat.id)}</b>
</div> </div>
{:else} {:else}
<p>Equip and configure items to calculate their known Attributes.</p> <p>Equip items to see known Attributes.</p>
{/each} {/each}
</div> </div>
<div class="summary-section"> <div class="summary-section">
<header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header> <header><span>SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
{#each summary.activeBonuses as bonus} {#each summary.activeBonuses as bonus}
<div class="summary-bonus"> <div class="summary-bonus">
<span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span> <span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span>
<em>+{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}</em> <em>+{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}</em>
</div> </div>
{:else} {:else}
<p>Matching Brand Sets and Skill Chips activate bonuses here.</p> <p>No active bonuses.</p>
{/each} {/each}
</div> </div>
<div class="summary-section"> <div class="summary-section">
<header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header> <header><span>TALENTS</span><b>{summary.equippedTalents.length}</b></header>
{#each summary.equippedTalents as equipped} {#each summary.equippedTalents as equipped}
<div class="summary-talent"> <div class="summary-talent">
<span> <details open={layout === 'page'}>
<b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b> <summary><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source}{equipped.tierLabel ? ` · ${equipped.tierLabel}` : ''}</small></summary>
<small>{equipped.source} · CONDITIONAL EFFECT NOT INCLUDED</small> <p>{equipped.description}</p>
<em>{equipped.description}</em>
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if} {#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
</span> </details>
</div> </div>
{:else} {:else}
<p>Selected equipment Talents appear here.</p> <p>No Talents equipped.</p>
{/each} {/each}
</div> </div>
</div> </div>
<p class="known-stats-note">Totals reflect this saved loadout and the current canonical item definitions. They include fixed values, rolls, inherited profiles, enabled augments, set bonuses, Protocol effects, and static Specialization rank effects. Conditional Talent outcomes are reserved for the advanced combat model.</p> <p class="known-stats-note">Known values only. Advanced effects are planned.</p>
</section> </section>
<style> <style>
.loadout-known-stats{background:var(--shd-surface)} .loadout-known-stats{background:var(--shd-surface)}
.summary-talent{grid-template-columns:1fr;cursor:default} .summary-talent{grid-template-columns:1fr;cursor:default}
.summary-talent details,.summary-talent summary{min-width:0}
.summary-talent summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;cursor:pointer;list-style:none}
.summary-talent summary::-webkit-details-marker{display:none}
.summary-talent summary:after{content:'+';grid-column:2;grid-row:1/3;color:var(--shd-orange);font-size:14px}
.summary-talent details[open] summary:after{content:'-'}
.summary-talent summary b,.summary-talent summary small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.summary-talent summary small{grid-column:1;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}
.summary-talent details>p{margin:8px 0 0;color:var(--shd-text-muted);font:var(--shd-type-telemetry)/1.45 var(--shd-font-editorial)}
.summary-talent summary:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:3px}
.known-stats-note{margin:0;padding:11px;border-top:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.45} .known-stats-note{margin:0;padding:11px;border-top:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.45}
.page{border:1px solid var(--shd-border);border-top:2px solid var(--shd-success)} .page{border:1px solid var(--shd-border);border-top:2px solid var(--shd-success)}
.page .summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))} .page .summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}
@@ -10,20 +10,20 @@
} = $props(); } = $props();
const capabilities = [ const capabilities = [
{ label: 'WEAPON OUTPUT', detail: 'Hit values, burst DPS, sustained DPS, and reload-aware firing cycles' }, { label: 'WEAPON OUTPUT', detail: 'Hits and firing cycles' },
{ label: 'SKILL SIMULATION', detail: 'Ability cadence, target counts, critical hits, and multi-shot behavior' }, { label: 'SKILL SIMULATION', detail: 'Timing and target caps' },
{ label: 'ENCOUNTER MODEL', detail: 'Duration, accuracy, movement, target flow, rotations, and downtime' }, { label: 'ENCOUNTER MODEL', detail: 'Duration and rotations' },
{ label: 'CONDITIONAL EFFECTS', detail: 'Talent triggers, stack buildup, cooldowns, uptime, and resource changes' } { label: 'CONDITIONAL EFFECTS', detail: 'Triggers and uptime' }
]; ];
</script> </script>
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}> <section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
<header> <header>
<div><span>ADVANCED COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div> <div><span>COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
<b>COMING SOON</b> <b>PLANNED</b>
</header> </header>
{#if variant === 'full'} {#if variant === 'full'}
<p>Known equipment totals remain available now. Advanced damage projections will return after their calculation rules and scenario controls are ready for player use.</p> <p>Known stats are live. Damage projections are planned.</p>
{/if} {/if}
<div class="planned-capabilities" aria-label="Planned combat-model capabilities"> <div class="planned-capabilities" aria-label="Planned combat-model capabilities">
{#each capabilities as capability} {#each capabilities as capability}
@@ -0,0 +1,61 @@
<script lang="ts">
import ItemIcon from '$lib/components/ItemIcon.svelte';
import type { TalentTier } from '$lib/builder/loadout';
let {
label = 'TALENT TIER',
name,
talent,
value = $bindable(1),
onchange
}: {
label?: string;
name: string;
talent: { name: string; iconUrl?: string; tiers?: Array<{ tier: string }> };
value?: TalentTier;
onchange?: (tier: TalentTier) => void;
} = $props();
const available = (tier: TalentTier) => talent.tiers?.some((entry) => entry.tier === (tier === 2 ? 'II' : 'I')) ?? false;
const choose = (tier: TalentTier) => {
value = tier;
onchange?.(tier);
};
</script>
<fieldset class="talent-tier-choice">
<legend>{label}</legend>
<div>
{#each [1, 2] as tier}
<label class:tier-two={tier === 2}>
<input type="radio" {name} value={tier} checked={value === tier} disabled={!available(tier as TalentTier)} onchange={() => choose(tier as TalentTier)}/>
<span>
<ItemIcon src={talent.iconUrl} alt="" className="talent-tier-icon"/>
<small>TIER {tier === 2 ? 'II' : 'I'}</small>
<b>T{tier}</b>
<em>{available(tier as TalentTier) ? value === tier ? 'SELECTED' : 'SELECT' : 'UNAVAILABLE'}</em>
</span>
</label>
{/each}
</div>
</fieldset>
<style>
.talent-tier-choice{min-width:0;margin:8px 0 0;padding:0;border:0}
.talent-tier-choice legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.07em}
.talent-tier-choice>div{display:grid;grid-template-columns:1fr 1fr;gap:5px}
.talent-tier-choice label{min-width:0;cursor:pointer}
.talent-tier-choice input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}
.talent-tier-choice span{min-height:64px;display:grid;grid-template-columns:42px minmax(0,1fr) auto;grid-template-rows:auto auto;align-items:center;gap:1px 8px;padding:7px;border:1px solid var(--shd-border);background:var(--shd-builder-canvas);color:var(--shd-text);transition:var(--shd-transition)}
.talent-tier-choice :global(.talent-tier-icon){grid-row:1/3;width:40px;height:40px;object-fit:contain}
.talent-tier-choice .tier-two :global(.talent-tier-icon){filter:sepia(.3) saturate(1.45) hue-rotate(335deg) brightness(1.12)}
.talent-tier-choice small{align-self:end;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.06em}
.talent-tier-choice b{grid-column:3;grid-row:1/3;color:var(--shd-text-muted);font-size:18px}
.talent-tier-choice em{align-self:start;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900}
.talent-tier-choice label:hover span{border-color:var(--shd-border-strong);background:var(--shd-surface-active)}
.talent-tier-choice input:checked+span{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 0 -2px var(--shd-orange)}
.talent-tier-choice input:checked+span b,.talent-tier-choice input:checked+span em{color:var(--shd-orange)}
.talent-tier-choice input:focus-visible+span{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
.talent-tier-choice input:disabled+span{opacity:.38;cursor:not-allowed;filter:saturate(.35)}
@media(max-width:430px){.talent-tier-choice span{grid-template-columns:36px minmax(0,1fr);}.talent-tier-choice :global(.talent-tier-icon){width:34px;height:34px}.talent-tier-choice b{display:none}}
</style>
@@ -0,0 +1,97 @@
<script lang="ts">
import type { WeaponBaseline } from '$lib/builder/weapon-baseline';
type WeaponBaselineEntry = {
slot: 'PRIMARY' | 'SECONDARY';
id: string;
name: string;
damage: number | null;
rpm: number | null;
magazine: number | null;
reloadSpeed: number | null;
baseline: WeaponBaseline;
};
let { entries, headingId }: { entries: WeaponBaselineEntry[]; headingId: string } = $props();
const numberValue = (value: number | null, suffix = '') =>
value === null || !Number.isFinite(value) || value <= 0 ? 'NOT AVAILABLE' : `${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}${suffix}`;
const metricValue = (metric: { value: number | null; missing: string[] }) => numberValue(metric.value, ' /s');
</script>
<section class="weapon-baseline" aria-labelledby={headingId}>
<header class="baseline-head">
<div>
<p class="eyebrow"><span></span> WEAPON OUTPUT / CATALOG BASELINE</p>
<h2 id={headingId}>Weapon output</h2>
</div>
<strong>EXPERIMENTAL</strong>
</header>
<p class="baseline-intro">Strict evidence-only throughput from the selected catalog weapon and selected damage.</p>
<div class="baseline-results" aria-live="polite" aria-atomic="false">
{#each entries as entry}
<article class="weapon-result" aria-labelledby={`${headingId}-${entry.slot.toLowerCase()}`}>
<header class="weapon-result-head">
<div>
<span>{entry.slot}</span>
<h3 id={`${headingId}-${entry.slot.toLowerCase()}`}>{entry.name}</h3>
</div>
<code>{entry.id || 'NO CATALOG ID'}</code>
</header>
<div class="baseline-inputs" aria-label={`${entry.slot} baseline inputs`}>
<span><small>SELECTED DAMAGE</small><b>{numberValue(entry.baseline.selectedDamage.value)}</b></span>
<span><small>RPM</small><b>{numberValue(entry.rpm)}</b></span>
<span><small>MAGAZINE</small><b>{numberValue(entry.magazine, ' SHOTS')}</b></span>
<span><small>RELOAD</small><b>{numberValue(entry.reloadSpeed, ' SEC')}</b></span>
</div>
<div class="baseline-metrics">
{#each [
{ label: 'RATED BODY-HIT THROUGHPUT', metric: entry.baseline.ratedBodyHitThroughput },
{ label: 'MAGAZINE-CYCLE BODY-HIT THROUGHPUT', metric: entry.baseline.magazineCycleBodyHitThroughput }
] as output}
<div class:unavailable={output.metric.value === null}>
<span>{output.label}<small>PER SECOND</small></span>
<strong>{metricValue(output.metric)}</strong>
{#if output.metric.value === null}
<p class="missing-label">MISSING FIELDS</p>
<ul>{#each output.metric.missing as field}<li>{field}</li>{/each}</ul>
{/if}
</div>
{/each}
</div>
</article>
{/each}
</div>
<div class="baseline-contract">
<p><b>FORMULAS</b> Selected damage × (RPM ÷ 60)</p>
<p>Magazine × selected damage ÷ ((Magazine ÷ (RPM ÷ 60)) + reload seconds)</p>
<p>This does not calculate accuracy, critical hits, headshots, Talents, enemy mitigation, rotations, target count, or encounter uptime.</p>
</div>
<p class="baseline-source">Source provenance: each result shows its catalog weapon ID above.</p>
</section>
<style>
.weapon-baseline{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-model-border);border-top:2px solid var(--shd-orange);background:var(--shd-model-surface);min-width:0}
.baseline-head,.weapon-result-head{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--shd-gap-md)}
.baseline-head>div,.weapon-result-head>div{display:grid;gap:3px;min-width:0}
.eyebrow,.baseline-head p{margin:0;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}
.eyebrow span{display:inline-block;width:6px;height:6px;margin-right:4px;background:var(--shd-orange)}
h2,h3{margin:0;text-transform:uppercase;letter-spacing:.7px;color:var(--shd-text)}
h2{font-size:var(--shd-type-body)}h3{font-size:var(--shd-type-supporting)}
.baseline-head>strong{padding:5px 7px;border:1px solid var(--shd-orange);color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}
.baseline-intro,.baseline-contract p,.baseline-source{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
.baseline-results{display:grid;gap:var(--shd-gap-sm)}
.weapon-result{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}
.weapon-result-head span,.baseline-inputs small,.baseline-metrics span,.missing-label{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.weapon-result-head code{max-width:45%;overflow:hidden;color:var(--shd-text-dim);font:var(--shd-type-telemetry) var(--shd-font-mono);text-overflow:ellipsis;white-space:nowrap}
.baseline-inputs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border-block:1px solid var(--shd-border-soft)}
.baseline-inputs span{display:grid;gap:3px;padding:7px;border-left:1px solid var(--shd-border-soft);min-width:0}.baseline-inputs span:first-child{border-left:0}
.baseline-inputs b{font-size:var(--shd-type-supporting);font-weight:700;overflow-wrap:anywhere}
.baseline-metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-sm)}
.baseline-metrics>div{display:grid;gap:5px;padding:9px;border-left:2px solid var(--shd-success);background:var(--shd-bg);min-width:0}.baseline-metrics>div.unavailable{border-left-color:var(--shd-danger)}
.baseline-metrics span{display:grid;gap:2px}.baseline-metrics span small{color:var(--shd-text-dim);font-size:10px}.baseline-metrics strong{font-size:18px;color:var(--shd-success)}.baseline-metrics .unavailable strong{color:var(--shd-danger)}
.missing-label{margin:2px 0 0;color:var(--shd-danger)}.baseline-metrics ul{margin:0;padding-left:17px;color:var(--shd-danger);font:var(--shd-type-telemetry)/1.4 var(--shd-font-mono)}
.baseline-contract{display:grid;gap:5px;padding-top:var(--shd-gap-sm);border-top:1px solid var(--shd-border)}.baseline-contract b{color:var(--shd-text);letter-spacing:.7px}.baseline-source{font-size:var(--shd-type-telemetry)}
@media(max-width:620px){.baseline-head{display:grid}.baseline-inputs{grid-template-columns:repeat(2,minmax(0,1fr))}.baseline-inputs span:nth-child(odd){border-left:0}.baseline-inputs span:nth-child(n+3){border-top:1px solid var(--shd-border-soft)}.baseline-metrics{grid-template-columns:1fr}.weapon-result-head code{max-width:50%}}
@media(prefers-reduced-motion:reduce){.weapon-baseline *{scroll-behavior:auto;transition:none!important;animation:none!important}}
</style>
+2 -2
View File
@@ -77,7 +77,7 @@ export async function syncArticleTags(database: Database) {
`specialization-${item.id}`, `specialization-${item.id}`,
'specialization', 'specialization',
String(item.name), String(item.name),
`specialization-${slugify(String(item.name))}`, `specialization-${slugify(String(item.name))}-${item.id}`,
'specialization', 'specialization',
item.id, item.id,
Number(item.active) ? 1 : 0, Number(item.active) ? 1 : 0,
@@ -93,7 +93,7 @@ export async function syncArticleTags(database: Database) {
`focus-${item.id}`, `focus-${item.id}`,
'focus', 'focus',
name, name,
`focus-${slugify(name)}`, `focus-${slugify(name)}-${item.id}`,
'focus', 'focus',
item.id, item.id,
Number(item.active) ? 1 : 0, Number(item.active) ? 1 : 0,
+43
View File
@@ -16,6 +16,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
talentEffects, talentEffects,
talentTemplates, talentTemplates,
chipSets, chipSets,
abilitySkillChipLinks,
chipTwoPiece, chipTwoPiece,
chipPrimaryOptions, chipPrimaryOptions,
chipPrimaryQualityValues, chipPrimaryQualityValues,
@@ -31,6 +32,8 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
protocolTalentEffects, protocolTalentEffects,
specializations, specializations,
specializationFocuses, specializationFocuses,
specializationAbilities,
focusAbilities,
specializationNodes, specializationNodes,
specializationRanks, specializationRanks,
specializationRankEffects, specializationRankEffects,
@@ -85,6 +88,14 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE', 'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE',
) )
).all(), ).all(),
(
await database.prepare(
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
( (
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses') await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(), ).all(),
@@ -136,6 +147,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE', 'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
) )
).all(), ).all(),
(
await database.prepare(
'SELECT * FROM specialization_abilities ORDER BY specialization_id,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot',
)
).all(),
( (
await database.prepare( await database.prepare(
'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number', 'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number',
@@ -194,6 +215,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
); );
return { return {
...set, ...set,
compatibleAbilityIds: [
String(set.specialization_ability_id),
...abilitySkillChipLinks.flatMap((link) =>
link.ability_id === set.specialization_ability_id
? [String(link.compatible_ability_id)]
: link.compatible_ability_id === set.specialization_ability_id
? [String(link.ability_id)]
: [],
),
],
twoPiece: twoPiece:
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ?? chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
null, null,
@@ -242,6 +273,18 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
.filter((focus) => focus.specialization_id === specialization.id) .filter((focus) => focus.specialization_id === specialization.id)
.map((focus) => ({ .map((focus) => ({
...focus, ...focus,
abilities: focusAbilities
.filter((link) => link.focus_id === focus.id)
.sort(
(left, right) =>
Number(left.ability_slot) - Number(right.ability_slot),
)
.map((link) =>
specializationAbilities.find(
(ability) => ability.id === link.ability_id,
),
)
.filter(Boolean),
nodes: specializationNodes nodes: specializationNodes
.filter((node) => node.focus_id === focus.id) .filter((node) => node.focus_id === focus.id)
.map((node) => ({ .map((node) => ({
+1 -1
View File
@@ -59,7 +59,7 @@ export async function loadSharedBuildProjection(
).all(), ).all(),
( (
await database.prepare( await database.prepare(
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons', 'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type,exotic_talent_id FROM weapons',
) )
).all(), ).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(), (await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
+127 -4
View File
@@ -36,6 +36,7 @@ async function validateSkillChipSelections(
if (!loadout.chips.some((chip) => chip.setId)) return null; if (!loadout.chips.some((chip) => chip.setId)) return null;
const [ const [
sets, sets,
abilityLinks,
focusAbilities, focusAbilities,
primaryOptions, primaryOptions,
primaryValues, primaryValues,
@@ -44,12 +45,28 @@ async function validateSkillChipSelections(
] = await Promise.all([ ] = await Promise.all([
( (
await database.prepare( await database.prepare(
'SELECT id,specialization_ability_id FROM skill_chip_sets WHERE active=1', `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(), ).all(),
( (
await database.prepare( await database.prepare(
'SELECT ability_id,ability_slot FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot', `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), ).all(loadout.specialization.focusId),
( (
@@ -94,11 +111,20 @@ async function validateSkillChipSelections(
const expectedAbility = focusAbilities.find( const expectedAbility = focusAbilities.find(
(row) => Number(row.ability_slot) === Math.floor(index / 3) + 1, (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 ( if (
!definition || !definition ||
!expectedAbility || !expectedAbility ||
String(definition.specialization_ability_id) !== (ownerAbilityId !== expectedAbilityId && !sharedWithExpectedAbility)
String(expectedAbility.ability_id)
) )
return `Skill Chip ${index + 1} is not compatible with its assigned ability.`; return `Skill Chip ${index + 1} is not compatible with its assigned ability.`;
const primaryRange = rangeFor( const primaryRange = rangeFor(
@@ -125,6 +151,74 @@ async function validateSkillChipSelections(
return null; return null;
} }
async function validateEquipmentTalentSelections(
database: Awaited<ReturnType<typeof getDatabase>>,
loadout: LoadoutDraft,
) {
const [gearTalents, weaponTalents, weapons, talentTiers] = await Promise.all([
(
await database.prepare('SELECT gear_id,talent_id FROM gear_talents')
).all(),
(
await database.prepare(
'SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options',
)
).all(),
(await database.prepare('SELECT id,exotic_talent_id FROM weapons')).all(),
(await database.prepare('SELECT talent_id,tier FROM talent_tiers')).all(),
]);
const hasTier = (talentId: string, tier: 1 | 2) =>
talentTiers.some(
(entry) =>
String(entry.talent_id) === talentId &&
String(entry.tier) === (tier === 2 ? 'II' : 'I'),
);
for (const slot of Object.keys(loadout.gear)) {
const selection = loadout.gear[slot];
if (!selection.talentId) continue;
if (
!gearTalents.some(
(entry) =>
String(entry.gear_id) === selection.itemId &&
String(entry.talent_id) === selection.talentId,
)
)
return `${slot} has a Talent that is not available for the equipped item.`;
if (!hasTier(selection.talentId, selection.talentTier))
return `${slot} Talent T${selection.talentTier} is not configured.`;
}
for (let index = 0; index < loadout.weapons.length; index += 1) {
const selection = loadout.weapons[index];
const label = index ? 'Secondary weapon' : 'Primary weapon';
for (const configured of [
{ id: selection.talent1Id, tier: selection.talent1Tier, slot: 1 },
{ id: selection.talent2Id, tier: selection.talent2Tier, slot: 2 },
]) {
if (!configured.id) continue;
if (
!weaponTalents.some(
(entry) =>
String(entry.weapon_id) === selection.itemId &&
Number(entry.talent_slot) === configured.slot &&
String(entry.talent_id) === configured.id,
)
)
return `${label} has a Talent that is not available in slot ${configured.slot}.`;
if (!hasTier(configured.id, configured.tier))
return `${label} Talent ${configured.slot} T${configured.tier} is not configured.`;
}
const weapon = weapons.find(
(entry) => String(entry.id) === selection.itemId,
);
const exoticTalentId = String(weapon?.exotic_talent_id ?? '');
if (exoticTalentId && !hasTier(exoticTalentId, selection.exoticTalentTier))
return `${label} exotic Talent T${selection.exoticTalentTier} is not configured.`;
}
return null;
}
export const load: PageServerLoad = async ({ cookies, url }) => { export const load: PageServerLoad = async ({ cookies, url }) => {
const database = await getDatabase(); const database = await getDatabase();
const viewer = await getSessionUser(cookies); const viewer = await getSessionUser(cookies);
@@ -166,6 +260,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talentEffects, talentEffects,
talentTemplates, talentTemplates,
chipSets, chipSets,
abilitySkillChipLinks,
chipTwoPiece, chipTwoPiece,
chipPrimaryOptions, chipPrimaryOptions,
chipPrimaryQualityValues, chipPrimaryQualityValues,
@@ -258,6 +353,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE', 'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
) )
).all(), ).all(),
(
await database.prepare(
`SELECT link.ability_id,link.compatible_ability_id
FROM specialization_ability_skill_chip_links link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
JOIN specialization_abilities compatible ON compatible.id=link.compatible_ability_id AND compatible.active=1`,
)
).all(),
( (
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses') await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(), ).all(),
@@ -460,6 +563,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
); );
return { return {
...set, ...set,
category:
specializationAbilities.find(
(ability) => ability.id === set.specialization_ability_id,
)?.name ?? 'SKILL CHIP SET',
compatibleAbilityIds: [
String(set.specialization_ability_id),
...abilitySkillChipLinks.flatMap((link) =>
link.ability_id === set.specialization_ability_id
? [String(link.compatible_ability_id)]
: link.compatible_ability_id === set.specialization_ability_id
? [String(link.ability_id)]
: [],
),
],
iconUrl: iconUrl('skill_chip', set.id), iconUrl: iconUrl('skill_chip', set.id),
twoPiece: twoPiece:
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ?? chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
@@ -592,6 +709,12 @@ export const actions: Actions = {
return fail(400, { buildError: 'Choose a valid build visibility.' }); return fail(400, { buildError: 'Choose a valid build visibility.' });
const database = await getDatabase(); const database = await getDatabase();
const equipmentTalentError = await validateEquipmentTalentSelections(
database,
loadout,
);
if (equipmentTalentError)
return fail(400, { buildError: equipmentTalentError });
const skillChipError = await validateSkillChipSelections(database, loadout); const skillChipError = await validateSkillChipSelections(database, loadout);
if (skillChipError) return fail(400, { buildError: skillChipError }); if (skillChipError) return fail(400, { buildError: skillChipError });
const now = new Date().toISOString(); const now = new Date().toISOString();
+180 -126
View File
@@ -11,12 +11,15 @@
import BuildShareControl from '$lib/components/BuildShareControl.svelte'; import BuildShareControl from '$lib/components/BuildShareControl.svelte';
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte'; import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte'; import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
import WeaponBaselineThroughput from '$lib/components/builder/WeaponBaselineThroughput.svelte';
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte'; import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
import TalentTierChoice from '$lib/components/builder/TalentTierChoice.svelte';
import { summarizeLoadout } from '$lib/builder/loadout-summary'; import { summarizeLoadout } from '$lib/builder/loadout-summary';
import { calculateWeaponBaseline } from '$lib/builder/weapon-baseline';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import { import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange, blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, migrateLoadoutDraftV5, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
type LoadoutDraft, type Roll, type SkillChipQuality type LoadoutDraft, type Roll, type SkillChipQuality, type TalentTier
} from '$lib/builder/loadout'; } from '$lib/builder/loadout';
let { data, form } = $props(); let { data, form } = $props();
@@ -55,6 +58,9 @@
const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId); const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId); const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId); const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
const chipSupportsAbility = (item: any, abilityId: string) =>
item?.compatibleAbilityIds?.includes(abilityId)
?? item?.specialization_ability_id === abilityId;
const protocolItem = () => data.protocols.find((item) => item.id === draft.protocol.itemId); const protocolItem = () => data.protocols.find((item) => item.id === draft.protocol.itemId);
const allFocuses = $derived(data.specializations.flatMap((specialization: any) => const allFocuses = $derived(data.specializations.flatMap((specialization: any) =>
specialization.focuses.map((focus: any) => ({ ...focus, specialization })) specialization.focuses.map((focus: any) => ({ ...focus, specialization }))
@@ -71,6 +77,8 @@
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : []; return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
}) ?? []); }) ?? []);
const talent = (id: string) => data.talents.find((item) => item.id === id); const talent = (id: string) => data.talents.find((item) => item.id === id);
const supportedTalentTier = (id: string, tier: TalentTier): TalentTier =>
talent(id)?.tiers.some((entry: any) => entry.tier === (tier === 2 ? 'II' : 'I')) ? tier : 1;
const brand = (id: string) => data.brands.find((item) => item.id === id); const brand = (id: string) => data.brands.find((item) => item.id === id);
const unitValue = (value: unknown, statId: string) => { const unitValue = (value: unknown, statId: string) => {
const numeric = Number(value); const numeric = Number(value);
@@ -115,10 +123,11 @@
&& value >= Number(range.min) && value >= Number(range.min)
&& value <= Number(range.max); && value <= Number(range.max);
}; };
const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number]) => { const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number], abilityId: string) => {
const capacity = skillChipSecondaryCapacity[chip.quality]; const capacity = skillChipSecondaryCapacity[chip.quality];
const secondaryIds = chip.secondary.map((roll) => roll.statId); const secondaryIds = chip.secondary.map((roll) => roll.statId);
return Boolean(data.chipSets.some((item: any) => item.id === chip.setId)) const definition = data.chipSets.find((item: any) => item.id === chip.setId);
return Boolean(definition && chipSupportsAbility(definition, abilityId))
&& chipProfileConfigured && chipProfileConfigured
&& chipRollConfigured(chip.primary, 'primary', chip.quality) && chipRollConfigured(chip.primary, 'primary', chip.quality)
&& chip.secondary.length === capacity && chip.secondary.length === capacity
@@ -168,14 +177,14 @@
const chip = draft.chips[index]; const chip = draft.chips[index];
const ability = next.abilities[Math.floor(index / 3)]; const ability = next.abilities[Math.floor(index / 3)];
const definition = data.chipSets.find((item: any) => item.id === chip.setId); const definition = data.chipSets.find((item: any) => item.id === chip.setId);
if (chip.setId && (!ability || definition?.specialization_ability_id !== ability.id)) { if (chip.setId && (!ability || !chipSupportsAbility(definition, ability.id))) {
displaced.push(structuredClone(chip)); displaced.push(structuredClone(chip));
draft.chips[index] = createDraft().chips[index]; draft.chips[index] = createDraft().chips[index];
} }
} }
for (const chip of displaced) { for (const chip of displaced) {
const definition = data.chipSets.find((item: any) => item.id === chip.setId); const definition = data.chipSets.find((item: any) => item.id === chip.setId);
const abilityIndex = next.abilities.findIndex((ability: any) => ability.id === definition?.specialization_ability_id); const abilityIndex = next.abilities.findIndex((ability: any) => chipSupportsAbility(definition, ability.id));
if (abilityIndex < 0) continue; if (abilityIndex < 0) continue;
const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId); const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId);
if (targetIndex === undefined) continue; if (targetIndex === undefined) continue;
@@ -207,6 +216,7 @@
selection.level = 50; selection.level = 50;
selection.brandId = item?.brandIds[0] ?? ''; selection.brandId = item?.brandIds[0] ?? '';
selection.talentId = item?.talentIds[0] ?? ''; selection.talentId = item?.talentIds[0] ?? '';
selection.talentTier = supportedTalentTier(selection.talentId, Number(item?.tier) >= 2 ? 2 : 1);
selection.health = Number(item?.base_health_min ?? 0); selection.health = Number(item?.base_health_min ?? 0);
selection.armor = Number(item?.base_armor_min ?? 0); selection.armor = Number(item?.base_armor_min ?? 0);
const core = coreAllocationRange(Number(item?.tier ?? 0), selection.level, 'gear'); const core = coreAllocationRange(Number(item?.tier ?? 0), selection.level, 'gear');
@@ -255,7 +265,10 @@
const core = coreAllocationRange(selection.tier, selection.level, 'weapon'); const core = coreAllocationRange(selection.tier, selection.level, 'weapon');
for (const key of coreKeys) selection[key] = core.min; for (const key of coreKeys) selection[key] = core.min;
selection.talent1Id = item?.talent1Ids[0] ?? ''; selection.talent1Id = item?.talent1Ids[0] ?? '';
selection.talent1Tier = supportedTalentTier(selection.talent1Id, selection.tier >= 2 ? 2 : 1);
selection.talent2Id = item?.talent2Ids[0] ?? ''; selection.talent2Id = item?.talent2Ids[0] ?? '';
selection.talent2Tier = supportedTalentTier(selection.talent2Id, selection.tier >= 2 ? 2 : 1);
selection.exoticTalentTier = supportedTalentTier(item?.exotic_talent_id ?? '', selection.tier >= 2 ? 2 : 1);
selection.rolls = [blankRoll(), blankRoll()]; selection.rolls = [blankRoll(), blankRoll()];
}; };
const configureChip = (index: number, id: string) => { const configureChip = (index: number, id: string) => {
@@ -329,6 +342,27 @@
}; };
const activeParts = $derived(active.split(':')); const activeParts = $derived(active.split(':'));
const summary = $derived(summarizeLoadout(draft, data)); const summary = $derived(summarizeLoadout(draft, data));
const weaponBaselines = $derived(draft.weapons.map((selection, index) => {
const item = data.weapons.find((entry) => entry.id === selection.itemId);
return {
slot: index ? 'SECONDARY' as const : 'PRIMARY' as const,
id: String(item?.id ?? ''),
name: item?.name ?? 'UNASSIGNED',
damage: Number.isFinite(Number(selection.damage)) ? Number(selection.damage) : null,
rpm: item?.rpm == null ? null : Number(item.rpm),
magazine: item?.magazine == null ? null : Number(item.magazine),
reloadSpeed: item?.reload_speed == null ? null : Number(item.reload_speed),
baseline: calculateWeaponBaseline({
weaponId: String(item?.id ?? 'NO CATALOG ID'),
weaponName: item?.name,
weaponLabel: index ? 'SECONDARY' : 'PRIMARY',
selectedDamage: selection.damage,
rpm: item?.rpm,
magazine: item?.magazine,
reloadSeconds: item?.reload_speed
})
};
}));
const rollSummary = (rolls: Roll[]) => rolls const rollSummary = (rolls: Roll[]) => rolls
.filter((roll) => roll.statId) .filter((roll) => roll.statId)
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`) .map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`)
@@ -345,18 +379,21 @@
const value = String(category ?? '').toLowerCase(); const value = String(category ?? '').toLowerCase();
if (value.includes('shotgun')) return '/shotgun.png'; if (value.includes('shotgun')) return '/shotgun.png';
if (value.includes('smg') || value.includes('submachine')) return '/smg.png'; if (value.includes('smg') || value.includes('submachine')) return '/smg.png';
if (value.includes('mmr') || value.includes('marksman')) return '/mmr.png';
if (value.includes('lmg') || value.includes('light machine')) return '/lmg.png';
if (value.includes('ar') || value.includes('assault')) return '/ar.png';
return '/rifle.png'; return '/rifle.png';
}; };
const doctrineCell = $derived<MatrixCell>({ const doctrineCell = $derived<MatrixCell>({
id: 'specialization:focus', id: 'specialization:focus',
label: 'SPECIALIZATION + FOCUS', label: 'FOCUS',
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT DOCTRINE', name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT FOCUS',
meta: selectedFocus?.primary_attribute ?? '', meta: selectedFocus?.primary_attribute ?? '',
summary: selectedFocus summary: selectedFocus
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` ? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES', : 'SETS 3 ABILITIES',
iconUrl: selectedFocus?.specialization.iconUrl, iconUrl: selectedFocus?.specialization.iconUrl,
issue: !selectedFocus ? 'SELECT TO COMPLETE LOADOUT' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined issue: !selectedFocus ? 'Select a focus.' : selectedFocus.abilities.length !== 3 ? 'Focus needs 3 abilities.' : undefined
}); });
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => { const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
const ability = selectedFocus?.abilities[index]; const ability = selectedFocus?.abilities[index];
@@ -365,26 +402,26 @@
const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean); const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean);
return { return {
id: `ability:${index}`, id: `ability:${index}`,
label: `ABILITY 0${index + 1}`, label: `ABILITY ${index + 1}`,
name: ability?.name ?? 'NOT ASSIGNED', name: ability?.name ?? 'UNASSIGNED',
meta: ability?.attack?.damage_type ?? '', meta: ability?.attack?.damage_type ?? '',
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`, summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
iconUrl: ability?.iconUrl, iconUrl: ability?.iconUrl,
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : abilityChips.some((chip) => !chipSelectionConfigured(chip)) ? 'CONFIG REQUIRED' : undefined issue: !ability ? 'Ability required.' : configuredChips.length < 3 ? `${3 - configuredChips.length} chip${configuredChips.length === 2 ? '' : 's'} required.` : abilityChips.some((chip) => !chipSelectionConfigured(chip, ability.id)) ? 'Chip setup required.' : undefined
}; };
})); }));
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => { const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
const item = weaponItem(index); const item = weaponItem(index);
const talents = [selection.talent1Id, selection.talent2Id].map((id) => talent(id)?.name).filter(Boolean); const talents = [[selection.talent1Id, selection.talent1Tier], [selection.talent2Id, selection.talent2Tier]].map(([id,tier]) => id ? `${talent(String(id))?.name ?? 'Talent'} T${tier}` : '').filter(Boolean);
const details = [...talents, rollSummary(selection.rolls)].filter(Boolean).join(' · '); const details = [...talents, rollSummary(selection.rolls)].filter(Boolean).join(' · ');
return { return {
id: `weapon:${index}`, id: `weapon:${index}`,
label: `${index ? 'SECONDARY' : 'PRIMARY'} WEAPON`, label: index ? 'SECONDARY' : 'PRIMARY',
name: item?.name ?? 'EMPTY SLOT', name: item?.name ?? 'EMPTY',
meta: item?.category ?? '', meta: item?.category ?? '',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON', summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'CHOOSE WEAPON',
iconUrl: weaponIcon(item?.category), iconUrl: weaponIcon(item?.category),
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT' issue: item ? undefined : 'Choose a weapon.'
}; };
})); }));
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => { const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
@@ -392,36 +429,39 @@
const item = gearItem(slot); const item = gearItem(slot);
const details = [ const details = [
brand(selection.brandId)?.name, brand(selection.brandId)?.name,
talent(selection.talentId)?.name, selection.talentId ? `${talent(selection.talentId)?.name ?? 'Talent'} T${selection.talentTier}` : '',
rollSummary(selection.rolls) rollSummary(selection.rolls)
].filter(Boolean).join(' · '); ].filter(Boolean).join(' · ');
return { return {
id: `gear:${slot}`, id: `gear:${slot}`,
label: slot.toUpperCase(), label: slot.toUpperCase(),
name: item?.name ?? 'EMPTY SLOT', name: item?.name ?? 'EMPTY',
meta: item ? `T${item.tier}` : '', meta: item ? `T${item.tier}` : '',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR', summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'CHOOSE GEAR',
iconUrl: gearIcon(slot), iconUrl: gearIcon(slot),
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT' issue: item ? undefined : 'Choose gear.'
}; };
})); }));
const protocolCell = $derived<MatrixCell>({ const protocolCell = $derived<MatrixCell>({
id: 'protocol:0', id: 'protocol:0',
label: 'OS PROTOCOL', label: 'PROTOCOL',
name: protocolItem()?.name ?? 'EMPTY SLOT', name: protocolItem()?.name ?? 'EMPTY',
meta: protocolItem()?.quality ?? '', meta: protocolItem()?.quality ?? '',
summary: protocolItem() summary: protocolItem()
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES` ? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
: 'SELECT ONE PROTOCOL', : 'CHOOSE PROTOCOL',
iconUrl: protocolItem()?.iconUrl, iconUrl: protocolItem()?.iconUrl,
issue: protocolItem() ? undefined : 'SELECT TO COMPLETE LOADOUT' issue: protocolItem() ? undefined : 'Choose a protocol.'
}); });
const loadoutConfigured = $derived(Boolean( const loadoutConfigured = $derived(Boolean(
selectedFocus selectedFocus
&& selectedFocus.abilities.length === 3 && selectedFocus.abilities.length === 3
&& draft.weapons.every((item) => item.itemId) && draft.weapons.every((item) => item.itemId)
&& gearSlots.every((slot) => draft.gear[slot].itemId) && gearSlots.every((slot) => draft.gear[slot].itemId)
&& draft.chips.every(chipSelectionConfigured) && draft.chips.every((chip, index) => chipSelectionConfigured(
chip,
selectedFocus.abilities[Math.floor(index / 3)]?.id ?? ''
))
&& draft.protocol.itemId && draft.protocol.itemId
)); ));
@@ -429,17 +469,24 @@
localStorage.removeItem('shd-build-loadout-draft-v3'); localStorage.removeItem('shd-build-loadout-draft-v3');
localStorage.removeItem('shd-build-loadout-draft-v4'); localStorage.removeItem('shd-build-loadout-draft-v4');
if (data.initialBuild) return; if (data.initialBuild) return;
const stored = localStorage.getItem('shd-build-loadout-draft-v5'); const current = localStorage.getItem('shd-build-loadout-draft-v6');
const legacy = current ? null : localStorage.getItem('shd-build-loadout-draft-v5');
const stored = current ?? legacy;
if (!stored) return; if (!stored) return;
try { try {
const parsed = parseLoadoutDraft(JSON.parse(stored)); const raw = JSON.parse(stored);
const parsed = current ? parseLoadoutDraft(raw) : migrateLoadoutDraftV5(raw, data);
if (parsed) { if (parsed) {
draft = parsed; draft = parsed;
if (legacy) {
localStorage.setItem('shd-build-loadout-draft-v6', JSON.stringify(parsed));
localStorage.removeItem('shd-build-loadout-draft-v5');
}
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = ''; if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true; saved = true;
} else localStorage.removeItem('shd-build-loadout-draft-v5'); } else localStorage.removeItem(current ? 'shd-build-loadout-draft-v6' : 'shd-build-loadout-draft-v5');
} catch { } catch {
localStorage.removeItem('shd-build-loadout-draft-v5'); localStorage.removeItem(current ? 'shd-build-loadout-draft-v6' : 'shd-build-loadout-draft-v5');
} }
}); });
$effect(() => { $effect(() => {
@@ -457,7 +504,7 @@
: 'Build published to the SHD archive.'; : 'Build published to the SHD archive.';
}); });
function saveDraft() { function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v5', JSON.stringify(draft)); localStorage.setItem('shd-build-loadout-draft-v6', JSON.stringify(draft));
saved = true; saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.'); toast.success('Draft saved locally', 'This loadout is available on this device.');
} }
@@ -467,10 +514,10 @@
<main class="app-page builder-page"> <main class="app-page builder-page">
<div class="builder-command"> <div class="builder-command">
<div class="page-title"> <div class="page-title">
<div><p class="eyebrow"><span></span> COMMAND GRID / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div> <div><p class="eyebrow"><span></span> BUILDER / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
<div class="actions"> <div class="actions">
<span class:visible={saved}>SAVED LOCALLY</span> <span class:visible={saved}>LOCAL SAVED</span>
<button class="secondary" onclick={saveDraft}>SAVE LOCALLY</button> <button class="secondary" onclick={saveDraft}>SAVE</button>
{#if data.viewer} {#if data.viewer}
<PendingForm action="?/save" class="builder-save-form"> <PendingForm action="?/save" class="builder-save-form">
{#snippet children(pending)} {#snippet children(pending)}
@@ -486,21 +533,21 @@
</div> </div>
{#if data.viewer} {#if data.viewer}
<div class="builder-persistence"> <div class="builder-persistence">
<SegmentedChoice legend="ACCOUNT VISIBILITY" name="build-visibility" options={[ <SegmentedChoice legend="VISIBILITY" name="build-visibility" options={[
{ value:'private', label:'PRIVATE' }, { value:'private', label:'PRIVATE' },
{ value:'unlisted', label:'UNLISTED' }, { value:'unlisted', label:'UNLISTED' },
{ value:'public', label:'PUBLIC' } { value:'public', label:'PUBLIC' }
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} required/> ]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} required/>
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p> <p>{persistedVisibility === 'private' ? 'Private to you.' : persistedVisibility === 'unlisted' ? 'Direct link only.' : 'Public listing and link.'}</p>
{#if persistedSlug && savedBuildVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if} {#if persistedSlug && savedBuildVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div> </div>
{/if} {/if}
<div class="builder-status"><span><i></i> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div> <div class="builder-status"><span><i></i> STATS ONLINE</span></div>
</div> </div>
{#if message}<p class="success" role="status">{message}</p>{/if} {#if message}<p class="success" role="status">{message}</p>{/if}
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if} {#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
{#if data.viewer && persistedSlug}<BuildShareControl title={savedBuildTitle} slugOrUrl={new URL(`/builds/${encodeURIComponent(persistedSlug)}`, data.publicOrigin).toString()} visibility={savedBuildVisibility}/>{/if} {#if data.viewer && persistedSlug}<BuildShareControl title={savedBuildTitle} slugOrUrl={new URL(`/builds/${encodeURIComponent(persistedSlug)}`, data.publicOrigin).toString()} visibility={savedBuildVisibility}/>{/if}
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if} {#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>NO BUILDER DATA</strong><span>Add active items in ISAC.</span></div>{/if}
<div class="loadout-workspace"> <div class="loadout-workspace">
<LoadoutMatrix <LoadoutMatrix
{active} {active}
@@ -515,135 +562,142 @@
<section class="loadout-editor"> <section class="loadout-editor">
{#if activeParts[0] === 'specialization'} {#if activeParts[0] === 'specialization'}
<div class="loadout-editor-head"><div><span>AGENT CLASS</span><h2>SPECIALIZATION FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div> <div class="loadout-editor-head"><div><span>SPECIALIZATION</span><h2>FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + FOCUS"/> <BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="FOCUS"/>
{#if selectedFocus} {#if selectedFocus}
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt={selectedFocus.specialization.name}/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description has been entered.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}</b></div> <div class="builder-specialization-layout">
<div class="builder-ability-chip-panels"> <div class="builder-editor-lane">
{#each [0,1,2] as abilityIndex} <div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt=""/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'READY' : 'NEEDS DATA'}</b></div>
{@const ability = selectedFocus.abilities[abilityIndex]} <div class="builder-focus-abilities" aria-label="Assigned abilities">
<section> {#each [0,1,2] as abilityIndex}
<header><ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability ${abilityIndex + 1}`}/><div><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'NOT CONFIGURED'}</strong></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></header> {@const ability = selectedFocus.abilities[abilityIndex]}
<p>{ability?.description ?? 'ISAC requires this class ability before compatible Skill Chips can be equipped.'}</p> <button type="button" onclick={() => active = `ability:${abilityIndex}`}><ItemIcon src={ability?.iconUrl} alt="" className="builder-ability-icon"/><span><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'UNASSIGNED'}</strong></span><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3</b></button>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[chipIndex].quality : 'SELECT CHIP'}</span></button>{/each}</div>
</section>
{/each}
</div>
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
<div class="builder-focus-tree">
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>PREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'ENTRY ROW' : 'LOCKED BY PREVIOUS ROWS'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
{/each} {/each}
</div></section> </div>
{/each} <div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
</div>
<div class="builder-focus-tree builder-editor-lane">
<div class="builder-section-title"><div><span>UPGRADE TREE</span><small>UNLOCK IN ORDER</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'START' : 'PRIOR ROWS'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
{/each}
</div></section>
{/each}
</div>
</div> </div>
<PlannedCombatModel headingId="builder-combat-model-heading" />
{/if} {/if}
{:else if activeParts[0] === 'ability'} {:else if activeParts[0] === 'ability'}
{@const abilityIndex = Number(activeParts[1])} {@const abilityIndex = Number(activeParts[1])}
{@const ability = selectedFocus?.abilities[abilityIndex]} {@const ability = selectedFocus?.abilities[abilityIndex]}
<div class="loadout-editor-head"><div><span>DOCTRINE DEPENDENCY · ABILITY 0{abilityIndex + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'ABILITY NOT ASSIGNED'}</h2></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></div> <div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'UNASSIGNED'}</h2></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></div>
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← CHANGE SPECIALIZATION + FOCUS</button> <button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← BACK TO FOCUS</button>
{#if ability} {#if ability}
<div class="builder-specialization-identity ability-identity"><ItemIcon src={ability.iconUrl} alt={ability.name}/><div><small>{selectedFocus.specialization.name} · {selectedFocus.name}</small><h3>{ability.name}</h3><p>{ability.description || 'No ability description has been entered.'}</p></div><b>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK DATA INCOMPLETE'}</b></div> <div class="builder-specialization-identity ability-identity"><ItemIcon src={ability.iconUrl} alt="" className="ability-icon"/><div><small>{selectedFocus.specialization.name} · {selectedFocus.name}</small><h3>{ability.name}</h3><p>{ability.description || 'No ability description.'}</p></div><b>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK DATA NEEDED'}</b></div>
<div class="builder-section-title"><div><span>ATTACHED SKILL CHIPS</span><small>CHIPS ARE COMPATIBLE WITH THIS ABILITY AND MOVE WITH ITS FOCUS</small></div><b>03 POSITIONS</b></div> <div class="builder-section-title"><div><span>SKILL CHIPS</span><small>3 SETS</small></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3</b></div>
<div class="builder-ability-chip-panels single"> <div class="builder-ability-chip-panels single">
<section> <section>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT CHIP'}</span></button>{/each}</div> <div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><ItemIcon src={item?.iconUrl} alt="" className="builder-chip-icon"/><span><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY'}</strong><em>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT SET'}</em></span></button>{/each}</div>
</section> </section>
</div> </div>
{:else} {:else}
<p class="error">Select a Specialization and Focus with configured abilities before attaching Skill Chips.</p> <p class="error">Choose a focus with 3 abilities first.</p>
{/if} {/if}
{:else if activeParts[0] === 'gear'} {:else if activeParts[0] === 'gear'}
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)} {@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
<div class="loadout-editor-head"><div><span>GEAR SLOT</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `TIER ${item.tier} · BUILD LEVEL ${selection.level}` : 'UNASSIGNED'}</b></div> <div class="loadout-editor-head"><div><span>GEAR</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `T${item.tier} · LV ${selection.level}` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/> <BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="GEAR ITEM"/>
{#if item} {#if item}
{@const healthRange = gearBaseRange(item, 'health', selection.level)} {@const healthRange = gearBaseRange(item, 'health', selection.level)}
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null} {@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
<div class="builder-equipment-controls">
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talentId} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
</div>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional while additional game samples are collected.' : 'Base Health and Armor are calculated from the stored gear base ranges at the selected item level. Display values are truncated to match the game.'}</p>
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
{@const gearCore = coreAllocationRange(Number(item.tier), selection.level, 'gear')} {@const gearCore = coreAllocationRange(Number(item.tier), selection.level, 'gear')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{item.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO GEAR RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
{@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)} {@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)}
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>ENABLE COLLECTED UPGRADES · BASE BONUSES APPLY TO THIS ITEM ONLY</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if} <div class="builder-editor-columns">
<section class="builder-editor-lane" aria-label="Gear identity and base values">
<div class="builder-equipment-controls">
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} value={selection.talentId} onchange={(id) => { selection.talentId = id; selection.talentTier = supportedTalentTier(id, selection.talentTier); }} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
{#if selection.talentId && talent(selection.talentId)}<TalentTierChoice name={`gear-${slot}-talent-tier`} talent={talent(selection.talentId)} bind:value={selection.talentTier}/>{/if}
</div>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional.' : 'Base values scale with item level and use game-matching truncated display values.'}</p>
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
</section>
<section class="builder-editor-lane" aria-label="Gear attributes and augments">
<div class="builder-section-title"><div><span>CORE ALLOCATION</span><small>SHARED T{item.tier} POOL</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>OPTIONAL ITEM UPGRADES</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
</section>
</div>
{/if} {/if}
{:else if activeParts[0] === 'weapon'} {:else if activeParts[0] === 'weapon'}
{@const index = Number(activeParts[1])}{@const selection = draft.weapons[index]}{@const item = weaponItem(index)}{@const other = weaponItem(index ? 0 : 1)} {@const index = Number(activeParts[1])}{@const selection = draft.weapons[index]}{@const item = weaponItem(index)}{@const other = weaponItem(index ? 0 : 1)}
<div class="loadout-editor-head"><div><span>WEAPON SLOT</span><h2>{index ? 'SECONDARY' : 'PRIMARY'} WEAPON</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div> <div class="loadout-editor-head"><div><span>WEAPON</span><h2>{index ? 'SECONDARY' : 'PRIMARY'}</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON DEFINITION"/> <BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON"/>
{#if item} {#if item}
{#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if} {#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if}
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((range: any) => ({ value: String(range.tier), label: `T${range.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)} {@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
{#if range} {#if range}{@const damageRange = weaponStatRange(range,'damage',selection.level)}{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range: the weapon curve below level 40 is provisional and may change as observations are added.' : 'Damage is calculated from the stored weapon base range at the selected item level. Core attributes use the shared level-50 tier allocation pool.'}</p> <div class="builder-editor-columns">
{@const damageRange = weaponStatRange(range,'damage',selection.level)} <section class="builder-editor-lane" aria-label="Weapon identity and base values">
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div> <div class="builder-weapon-controls">
{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')} <RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{selection.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div> <SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((entry: any) => ({ value: String(entry.tier), label: `T${entry.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div> <div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range below level 40.' : 'Damage scales with the selected item level.'}</p>
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
</section>
<section class="builder-editor-lane" aria-label="Weapon attributes and talents">
<div class="builder-section-title"><div><span>CORE ALLOCATION</span><small>SHARED T{selection.tier} POOL</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
<div class="builder-talent-grids">
{#if item.talent1Ids.length}<section class="builder-talent-slot"><BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} value={selection.talent1Id} onchange={(id) => { selection.talent1Id = id; selection.talent1Tier = supportedTalentTier(id, selection.talent1Tier); }} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{#if selection.talent1Id && talent(selection.talent1Id)}<TalentTierChoice name={`weapon-${index}-talent-1-tier`} talent={talent(selection.talent1Id)} bind:value={selection.talent1Tier}/>{/if}</section>{/if}
{#if item.talent2Ids.length}<section class="builder-talent-slot"><BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} value={selection.talent2Id} onchange={(id) => { selection.talent2Id = id; selection.talent2Tier = supportedTalentTier(id, selection.talent2Tier); }} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{#if selection.talent2Id && talent(selection.talent2Id)}<TalentTierChoice name={`weapon-${index}-talent-2-tier`} talent={talent(selection.talent2Id)} bind:value={selection.talent2Tier}/>{/if}</section>{/if}
{#if item.exotic_talent_id && talent(item.exotic_talent_id)}<section class="builder-talent-slot builder-exotic-talent"><div><ItemIcon src={talent(item.exotic_talent_id).iconUrl} alt=""/><span><small>EXOTIC TALENT</small><b>{talent(item.exotic_talent_id).name}</b></span></div><TalentTierChoice name={`weapon-${index}-exotic-talent-tier`} talent={talent(item.exotic_talent_id)} bind:value={selection.exoticTalentTier}/></section>{/if}
</div>
</section>
</div>
{/if} {/if}
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO WEAPON RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
<div class="builder-talent-grids">
{#if item.talent1Ids.length}<BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent1Id} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{/if}
{#if item.talent2Ids.length}<BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent2Id} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{/if}
</div>
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if} {/if}
{:else if activeParts[0] === 'chip'} {:else if activeParts[0] === 'chip'}
{@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}{@const abilityIndex = Math.floor(index / 3)}{@const ability = selectedFocus?.abilities[abilityIndex]} {@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}{@const abilityIndex = Math.floor(index / 3)}{@const ability = selectedFocus?.abilities[abilityIndex]}
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div> <div class="loadout-editor-head"><div><span>CHIP {(index % 3) + 1} / 3</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← RETURN TO {ability?.name?.toUpperCase() ?? 'ABILITY'}</button> <button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← BACK TO ABILITY</button>
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if} {#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => chipSupportsAbility(entry, ability.id))} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="CHIP SET"/>{:else}<p class="error">Choose a focus with 3 abilities first.</p>{/if}
{#if !chipProfileConfigured}<p class="error">SHARED SKILL CHIP PROFILE · CONFIG REQUIRED. Complete every global quality range in ISAC before this loadout can be complete.</p>{/if} {#if !chipProfileConfigured}<p class="error">CHIP PROFILE REQUIRED. Configure shared ranges in ISAC.</p>{/if}
{#if item} {#if item}
<div class="builder-field-grid"> <div class="builder-editor-columns builder-chip-editor">
<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/> <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> </div>
<div class="builder-section-title"><div><span>ABILITY-SCOPED PRIMARY</span><small>{ability?.name?.toUpperCase() ?? 'ASSIGNED ABILITY'} ONLY · EXCLUDED FROM KNOWN AGENT STATS</small></div><b>NOT YET DEFINED</b></div>
<p class="builder-scaling-note">The primary roll belongs to this ability. Its calculation application is NOT YET DEFINED, so it does not contribute to current Agent totals.</p>
<div class="builder-chip-rolls">
<div>
<BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY ATTRIBUTE"/>
{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE · CONFIG REQUIRED</p>{/if}
</div>
{#each chip.secondary as roll, secondaryIndex}
<div>
<BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ATTRIBUTE ${secondaryIndex + 1}`}/>
{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE · CONFIG REQUIRED</p>{/if}
</div>
{/each}
</div>
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">STANDARD QUALITY · NO SECONDARY ATTRIBUTES</p>{/if}
{/if} {/if}
{:else} {:else}
{@const protocol = protocolItem()} {@const protocol = protocolItem()}
<div class="loadout-editor-head"><div><span>AGENT SYSTEM</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div> <div class="loadout-editor-head"><div><span>PROTOCOL</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="OS PROTOCOL"/> <BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="PROTOCOL"/>
{#if protocol}<div class="builder-range-grid"><label>PROTOCOL LEVEL <span>01–50</span><input type="number" min="1" max="50" bind:value={draft.protocol.level} onblur={() => draft.protocol.level = clamp(draft.protocol.level,1,50)}/></label><label>TALENT UPGRADES <span>0–5</span><input type="number" min="0" max="5" bind:value={draft.protocol.talentUpgrades} onblur={() => draft.protocol.talentUpgrades = clamp(draft.protocol.talentUpgrades,0,5)}/></label></div><div class="builder-inherited">{#if protocol.primaryEffect}<span><small>{attribute(protocol.primaryEffect.stat_attribute_id)?.name} · LEVEL {draft.protocol.level}</small><b>{unitValue(Number(protocol.primaryEffect.base_value)*(1+(draft.protocol.level-1)/49),protocol.primaryEffect.stat_attribute_id)}</b></span>{/if}{#each protocol.secondaryEffects as effect}<span><small>{attribute(effect.stat_attribute_id)?.name}</small><b>{unitValue(effect.value,effect.stat_attribute_id)}</b></span>{/each}</div>{/if} {#if protocol}<div class="builder-range-grid"><label>PROTOCOL LEVEL <span>01–50</span><input type="number" min="1" max="50" bind:value={draft.protocol.level} onblur={() => draft.protocol.level = clamp(draft.protocol.level,1,50)}/></label><label>TALENT UPGRADES <span>0–5</span><input type="number" min="0" max="5" bind:value={draft.protocol.talentUpgrades} onblur={() => draft.protocol.talentUpgrades = clamp(draft.protocol.talentUpgrades,0,5)}/></label></div><div class="builder-inherited">{#if protocol.primaryEffect}<span><small>{attribute(protocol.primaryEffect.stat_attribute_id)?.name} · LEVEL {draft.protocol.level}</small><b>{unitValue(Number(protocol.primaryEffect.base_value)*(1+(draft.protocol.level-1)/49),protocol.primaryEffect.stat_attribute_id)}</b></span>{/if}{#each protocol.secondaryEffects as effect}<span><small>{attribute(effect.stat_attribute_id)?.name}</small><b>{unitValue(effect.value,effect.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if} {/if}
</section> </section>
<aside class="loadout-summary"> <aside class="loadout-summary">
<LoadoutStatsSummary {summary} attributes={data.attributes} talents={data.talents} headingId="builder-known-stats-heading" /> <LoadoutStatsSummary {summary} attributes={data.attributes} talents={data.talents} headingId="builder-known-stats-heading" />
<WeaponBaselineThroughput entries={weaponBaselines} headingId="builder-weapon-output-heading" />
<div class="builder-planned-model"><PlannedCombatModel headingId="builder-planned-output-heading" variant="compact" /></div> <div class="builder-planned-model"><PlannedCombatModel headingId="builder-planned-output-heading" variant="compact" /></div>
<p class="notice">Anonymous drafts remain on this device. Account saves preserve the equipped-item configuration and current visibility.</p> <p class="notice">Anonymous drafts remain on this device. Account saves preserve the equipped-item configuration and current visibility.</p>
</aside> </aside>
+104
View File
@@ -127,6 +127,58 @@ describe('Builder Skill Chip save validation', () => {
expect(mocks.insertBuild).not.toHaveBeenCalled(); 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([ it.each([
{ {
label: 'primary', label: 'primary',
@@ -183,4 +235,56 @@ describe('Builder Skill Chip save validation', () => {
}); });
expect(mocks.insertBuild).toHaveBeenCalledOnce(); 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();
});
}); });
+3 -2
View File
@@ -10,6 +10,7 @@
const chip = (id: string) => data.chips.find((item) => item.id === id); const chip = (id: string) => data.chips.find((item) => item.id === id);
const protocol = (id: string) => data.protocols.find((item) => item.id === id); const protocol = (id: string) => data.protocols.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id); const brand = (id: string) => data.brands.find((item) => item.id === id);
const talent = (id: string) => data.talents.find((item) => item.id === id);
const attribute = (id: string) => data.attributes.find((item) => item.id === id); const attribute = (id: string) => data.attributes.find((item) => item.id === id);
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }); const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
const rollLine = (rolls: Array<{ statId: string; value: number }>) => rolls const rollLine = (rolls: Array<{ statId: string; value: number }>) => rolls
@@ -56,11 +57,11 @@
<div class="shared-build-grid"> <div class="shared-build-grid">
<section> <section>
<header><span>WEAPONS</span><b>02 SLOTS</b></header> <header><span>WEAPONS</span><b>02 SLOTS</b></header>
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · ${item.damage_type} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.damage)} DAMAGE</b><b>{item.rpm} RPM</b><b>{item.magazine} ROUNDS</b><b>{item.reload_speed}s RELOAD</b></div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each} {#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · ${item.damage_type} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.damage)} DAMAGE</b><b>{item.rpm} RPM</b><b>{item.magazine} ROUNDS</b><b>{item.reload_speed}s RELOAD</b>{#if selection.talent1Id}<b>{talent(selection.talent1Id)?.name ?? 'TALENT 1'} · T{selection.talent1Tier}</b>{/if}{#if selection.talent2Id}<b>{talent(selection.talent2Id)?.name ?? 'TALENT 2'} · T{selection.talent2Tier}</b>{/if}{#if item.exotic_talent_id}<b>{talent(item.exotic_talent_id)?.name ?? 'EXOTIC TALENT'} · T{selection.exoticTalentTier}</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
</section> </section>
<section> <section>
<header><span>GEAR</span><b>06 SLOTS</b></header> <header><span>GEAR</span><b>06 SLOTS</b></header>
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.health)} HEALTH</b>{#if item.base_armor_min !== null}<b>{number.format(selection.armor)} ARMOR</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each} {#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.health)} HEALTH</b>{#if item.base_armor_min !== null}<b>{number.format(selection.armor)} ARMOR</b>{/if}{#if selection.talentId}<b>{talent(selection.talentId)?.name ?? 'TALENT'} · T{selection.talentTier}</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
</section> </section>
<section> <section>
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header> <header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
@@ -98,6 +98,14 @@ async function specializationProposalSnapshot(database: any, proposal: any) {
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order', 'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order',
) )
).all(id), ).all(id),
skillChipLinks: await (
await database.prepare(
`SELECT ability_id,compatible_ability_id
FROM specialization_ability_skill_chip_links
WHERE ability_id=? OR compatible_ability_id=?
ORDER BY ability_id,compatible_ability_id`,
)
).all(id, id),
}; };
} }
if (proposal.action === 'saveCombatEffect') { if (proposal.action === 'saveCombatEffect') {
+2 -2
View File
@@ -124,7 +124,7 @@
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/> <input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b>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> <div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b>STANDARD</b><b>SPECIALIZED</b><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Shared profiles define primary and secondary ranges.</small></div></div>
<section class="skill-chip-ability-assignment"> <section class="skill-chip-ability-assignment">
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div> <div class="selection-head"><div><span>OWNING ABILITY</span><small>THREE ACTIVE DEFINITIONS · ABILITY SHARING IS CONFIGURED IN SPECIALIZATIONS</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 OWNED` : 'REQUIRED'}</b></div>
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/> <SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
</section> </section>
@@ -169,7 +169,7 @@
<div class="dashboard-table"> <div class="dashboard-table">
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><span>ROLL PROFILE</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div> <div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><span>ROLL PROFILE</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.sets as item} {#each data.sets as item}
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div> <div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>OWNING ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if} {#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first ability-linked Skill Chip definition.</span></div>{/each} {:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first ability-linked Skill Chip definition.</span></div>{/each}
</div> </div>
@@ -187,6 +187,14 @@ async function focusSnapshot(database: Database, id: string) {
async function abilitySnapshot(database: Database, id: string) { async function abilitySnapshot(database: Database, id: string) {
return { return {
record: await auditRecord('specialization_abilities', id), record: await auditRecord('specialization_abilities', 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),
attack: attack:
(await ( (await (
await database.prepare( await database.prepare(
@@ -398,6 +406,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'cooldownSeconds', 'cooldownSeconds',
'effectsJson', 'effectsJson',
'attackJson', 'attackJson',
'skillChipCompatibleAbilityId',
], ],
}, },
{ {
@@ -470,6 +479,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
specializations, specializations,
focuses, focuses,
abilities, abilities,
abilitySkillChipLinks,
abilityAttacks, abilityAttacks,
abilityEffects, abilityEffects,
focusAbilities, focusAbilities,
@@ -506,6 +516,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM specialization_abilities ORDER BY specialization_id,active DESC,name COLLATE NOCASE', 'SELECT * FROM specialization_abilities ORDER BY specialization_id,active DESC,name COLLATE NOCASE',
) )
).all(), ).all(),
(
await database.prepare(
'SELECT * FROM specialization_ability_skill_chip_links ORDER BY ability_id,compatible_ability_id',
)
).all(),
( (
await database.prepare('SELECT * FROM specialization_ability_attacks') await database.prepare('SELECT * FROM specialization_ability_attacks')
).all(), ).all(),
@@ -672,6 +687,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
})), })),
...focuses, ...focuses,
]; ];
const linkedAbilityById = new Map<string, string>();
for (const link of abilitySkillChipLinks) {
linkedAbilityById.set(
String(link.ability_id),
String(link.compatible_ability_id),
);
linkedAbilityById.set(
String(link.compatible_ability_id),
String(link.ability_id),
);
}
const loadedAbilities = [ const loadedAbilities = [
...pendingAbilities.map((item) => ({ ...pendingAbilities.map((item) => ({
...item, ...item,
@@ -680,6 +706,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
cooldown_seconds: item.cooldownSeconds || null, cooldown_seconds: item.cooldownSeconds || null,
attack: item.attackJson ? JSON.parse(String(item.attackJson)) : null, attack: item.attackJson ? JSON.parse(String(item.attackJson)) : null,
effects: parseJsonArray<AbilityEffectInput>(item.effectsJson), effects: parseJsonArray<AbilityEffectInput>(item.effectsJson),
skillChipCompatibleAbilityId: item.skillChipCompatibleAbilityId || '',
active: 1, active: 1,
pending: true, pending: true,
skillChipCount: 0, skillChipCount: 0,
@@ -698,6 +725,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
chipCounts.find((item) => item.specialization_ability_id === ability.id) chipCounts.find((item) => item.specialization_ability_id === ability.id)
?.count ?? 0, ?.count ?? 0,
), ),
skillChipCompatibleAbilityId:
linkedAbilityById.get(String(ability.id)) ?? '',
iconUrl: iconUrl('specialization_ability', String(ability.id)), iconUrl: iconUrl('specialization_ability', String(ability.id)),
renderUrl: renderUrl('specialization_ability', String(ability.id)), renderUrl: renderUrl('specialization_ability', String(ability.id)),
})), })),
@@ -1160,7 +1189,12 @@ export const actions: Actions = {
); );
const form = await request.formData(); const form = await request.formData();
const id = String(form.get('id') ?? ''); const id = String(form.get('id') ?? '');
const abilityId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const specializationId = String(form.get('specializationId') ?? ''); const specializationId = String(form.get('specializationId') ?? '');
const skillChipCompatibleAbilityId = String(
form.get('skillChipCompatibleAbilityId') ?? '',
);
const name = cleanName(form.get('name')); const name = cleanName(form.get('name'));
const description = cleanName(form.get('description')); const description = cleanName(form.get('description'));
const durationSeconds = optionalPositive(form.get('durationSeconds')); const durationSeconds = optionalPositive(form.get('durationSeconds'));
@@ -1312,6 +1346,46 @@ export const actions: Actions = {
) { ) {
return fail(400, { abilityError: 'Choose an active specialization.' }); return fail(400, { abilityError: 'Choose an active specialization.' });
} }
if (skillChipCompatibleAbilityId) {
if (skillChipCompatibleAbilityId === abilityId) {
return fail(400, {
abilityError: 'An ability cannot share Skill Chips with itself.',
});
}
const compatibleAbility = await (
await database.prepare(
'SELECT id,specialization_id FROM specialization_abilities WHERE id=? AND active=1',
)
).get(skillChipCompatibleAbilityId);
if (
!compatibleAbility ||
String(compatibleAbility.specialization_id) !== specializationId
) {
return fail(400, {
abilityError:
'Choose an active Skill Chip-compatible ability from the same specialization.',
});
}
const conflictingLink = await (
await database.prepare(
`SELECT ability_id,compatible_ability_id
FROM specialization_ability_skill_chip_links
WHERE (ability_id=? OR compatible_ability_id=?)
AND ability_id<>? AND compatible_ability_id<>?`,
)
).get(
skillChipCompatibleAbilityId,
skillChipCompatibleAbilityId,
abilityId,
abilityId,
);
if (conflictingLink) {
return fail(409, {
abilityError:
'Each ability can share one Skill Chip pool. Clear the existing link first.',
});
}
}
if ( if (
!(await validateAttributeIds( !(await validateAttributeIds(
database, database,
@@ -1387,8 +1461,6 @@ export const actions: Actions = {
: 'The ability icon could not be processed.', : 'The ability icon could not be processed.',
}); });
} }
const abilityId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await abilitySnapshot(database, id) : null; const before = id ? await abilitySnapshot(database, id) : null;
const iconChanged = Boolean(iconMutation.output || iconMutation.remove); const iconChanged = Boolean(iconMutation.output || iconMutation.remove);
const previousIcon = iconChanged const previousIcon = iconChanged
@@ -1426,6 +1498,24 @@ export const actions: Actions = {
now, now,
); );
} }
await (
await database.prepare(
`DELETE FROM specialization_ability_skill_chip_links
WHERE ability_id=? OR compatible_ability_id=?`,
)
).run(abilityId, abilityId);
if (skillChipCompatibleAbilityId) {
const [leftAbilityId, rightAbilityId] = [
abilityId,
skillChipCompatibleAbilityId,
].sort();
await (
await database.prepare(
`INSERT INTO specialization_ability_skill_chip_links
(ability_id,compatible_ability_id) VALUES(?,?)`,
)
).run(leftAbilityId, rightAbilityId);
}
await ( await (
await database.prepare( await database.prepare(
'DELETE FROM specialization_ability_effects WHERE ability_id=?', 'DELETE FROM specialization_ability_effects WHERE ability_id=?',
@@ -2326,6 +2416,12 @@ export const actions: Actions = {
'UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?', 'UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?',
) )
).run(new Date().toISOString(), id); ).run(new Date().toISOString(), id);
await (
await database.prepare(
`DELETE FROM specialization_ability_skill_chip_links
WHERE ability_id=? OR compatible_ability_id=?`,
)
).run(id, id);
if (ability) if (ability)
await refreshSpecializationFocuses( await refreshSpecializationFocuses(
database, database,
+34 -3
View File
@@ -32,6 +32,7 @@
description: string; description: string;
durationSeconds: number | string; durationSeconds: number | string;
cooldownSeconds: number | string; cooldownSeconds: number | string;
skillChipCompatibleAbilityId: string;
attack: { attack: {
enabled: boolean; enabled: boolean;
baseDamage: number | string; baseDamage: number | string;
@@ -97,7 +98,7 @@
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1 id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
}); });
const blankAbility = (specializationId = ''): AbilityEditor => ({ const blankAbility = (specializationId = ''): AbilityEditor => ({
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '', id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '', skillChipCompatibleAbilityId: '',
attack: { attack: {
enabled: false, baseDamage: '', damageType: 'Blast', delivery: 'direct', enabled: false, baseDamage: '', damageType: 'Blast', delivery: 'direct',
hitsPerActivation: 1, tickIntervalSeconds: '', damageDurationSeconds: '', hitsPerActivation: 1, tickIntervalSeconds: '', damageDurationSeconds: '',
@@ -161,10 +162,25 @@
const abilityById = (id: string) => data.abilities.find((item: any) => item.id === id); const abilityById = (id: string) => data.abilities.find((item: any) => item.id === id);
const combatEffectById = (id: string) => data.combatEffects.find((item: any) => item.id === id); const combatEffectById = (id: string) => data.combatEffects.find((item: any) => item.id === id);
const abilityChipCount = (ability: any) => Number(ability?.skillChipCount ?? 0); const abilityChipCount = (ability: any) => Number(ability?.skillChipCount ?? 0);
const abilityEligibleChipCount = (ability: any) => abilityChipCount(ability)
+ abilityChipCount(abilityById(String(ability?.skillChipCompatibleAbilityId ?? '')));
const focusList = (specializationId: string) => data.focuses.filter((focus: any) => focus.specialization_id === specializationId); const focusList = (specializationId: string) => data.focuses.filter((focus: any) => focus.specialization_id === specializationId);
const abilityList = (specializationId: string) => data.abilities.filter((ability: any) => const abilityList = (specializationId: string) => data.abilities.filter((ability: any) =>
ability.specialization_id === specializationId && Number(ability.active) === 1 ability.specialization_id === specializationId && Number(ability.active) === 1
); );
const skillChipCompatibilityOptions = (editor: AbilityEditor) => abilityList(editor.specializationId)
.filter((ability: any) => ability.id !== editor.id)
.map((ability: any) => ({
...ability,
detail: `${abilityChipCount(ability)} OWN CHIP SETS`,
disabled: Boolean(
ability.skillChipCompatibleAbilityId
&& ability.skillChipCompatibleAbilityId !== editor.id
),
disabledReason: ability.skillChipCompatibleAbilityId && ability.skillChipCompatibleAbilityId !== editor.id
? 'ALREADY SHARES ANOTHER POOL'
: ''
}));
const combatEffectList = (specializationId: string) => data.combatEffects.filter((effect: any) => const combatEffectList = (specializationId: string) => data.combatEffects.filter((effect: any) =>
effect.specialization_id === specializationId && Number(effect.active) === 1 effect.specialization_id === specializationId && Number(effect.active) === 1
); );
@@ -234,6 +250,7 @@
description: item.description, description: item.description,
durationSeconds: item.duration_seconds ?? '', durationSeconds: item.duration_seconds ?? '',
cooldownSeconds: item.cooldown_seconds ?? '', cooldownSeconds: item.cooldown_seconds ?? '',
skillChipCompatibleAbilityId: item.skillChipCompatibleAbilityId ?? '',
attack: item.attack ? { attack: item.attack ? {
enabled: true, enabled: true,
baseDamage: item.attack.base_damage, baseDamage: item.attack.base_damage,
@@ -499,6 +516,20 @@
<label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={abilityEditing.cooldownSeconds} placeholder="OPTIONAL"/></label> <label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={abilityEditing.cooldownSeconds} placeholder="OPTIONAL"/></label>
<div><span>AVAILABILITY</span><strong>IMMEDIATE · NO POINT OR MATERIAL COST</strong><small>Assigned ability slots 1 and 3 are mirrored into the top-left of the focus tree.</small></div> <div><span>AVAILABILITY</span><strong>IMMEDIATE · NO POINT OR MATERIAL COST</strong><small>Assigned ability slots 1 and 3 are mirrored into the top-left of the focus tree.</small></div>
</div> </div>
<div class="specialization-skill-chip-sharing">
<div class="selection-head"><div><span>SHARED SKILL CHIP POOL</span><small>OPTIONAL · SAME SPECIALIZATION · 3 EQUIPPED SLOTS, UP TO 6 ELIGIBLE SETS</small></div><b>{abilityEditing.skillChipCompatibleAbilityId ? 'SHARED' : 'ISOLATED'}</b></div>
<SearchableAssignment
items={skillChipCompatibilityOptions(abilityEditing)}
mode="single"
compact
fieldName="skillChipCompatibleAbilityId"
subjectLabel="COMPATIBLE ABILITY"
searchPlaceholder="Search this specialization…"
emptySelectionLabel="NO SHARED SKILL CHIP POOL"
bind:value={abilityEditing.skillChipCompatibleAbilityId}
/>
<p class="configuration-note">Link only abilities that can equip each other's Skill Chips in game. Unlinked abilities remain isolated.</p>
</div>
<div class="specialization-attack-profile"> <div class="specialization-attack-profile">
<div class="selection-head"><div><span>INTRINSIC ATTACK PROFILE</span><small>UNMODIFIED DAMAGE BEFORE ENGINEERING, ATTRIBUTES, TALENTS, AND TREE UNLOCKS</small></div><b>{abilityEditing.attack.enabled ? 'DAMAGING ABILITY' : 'NO DIRECT DAMAGE'}</b></div> <div class="selection-head"><div><span>INTRINSIC ATTACK PROFILE</span><small>UNMODIFIED DAMAGE BEFORE ENGINEERING, ATTRIBUTES, TALENTS, AND TREE UNLOCKS</small></div><b>{abilityEditing.attack.enabled ? 'DAMAGING ABILITY' : 'NO DIRECT DAMAGE'}</b></div>
<SegmentedChoice legend="DAMAGE MODEL" name="ability-damage-model" options={[{value:'disabled',label:'NO DAMAGE'},{value:'enabled',label:'MODELS DAMAGE'}]} value={abilityEditing.attack.enabled ? 'enabled' : 'disabled'} onchange={(values) => setAbilityAttackEnabled(values[0] === 'enabled')}/> <SegmentedChoice legend="DAMAGE MODEL" name="ability-damage-model" options={[{value:'disabled',label:'NO DAMAGE'},{value:'enabled',label:'MODELS DAMAGE'}]} value={abilityEditing.attack.enabled ? 'enabled' : 'disabled'} onchange={(values) => setAbilityAttackEnabled(values[0] === 'enabled')}/>
@@ -728,7 +759,7 @@
<span>ABILITY {slot} · {slot === 2 ? 'COMBAT SKILLS' : 'TREE + COMBAT SKILLS'}</span> <span>ABILITY {slot} · {slot === 2 ? 'COMBAT SKILLS' : 'TREE + COMBAT SKILLS'}</span>
<strong>{ability?.name ?? 'NOT ASSIGNED'}</strong> <strong>{ability?.name ?? 'NOT ASSIGNED'}</strong>
{#if ability} {#if ability}
<small class:ready={abilityChipCount(ability) === 3} class="ability-chip-readiness">{abilityChipCount(ability)} / 3 SKILL CHIPS · {abilityChipCount(ability) === 3 ? 'READY' : 'INCOMPLETE'}</small> <small class:ready={abilityChipCount(ability) === 3} class="ability-chip-readiness">{abilityChipCount(ability)} OWNED · {abilityEligibleChipCount(ability)} ELIGIBLE · {abilityChipCount(ability) === 3 ? 'READY' : 'INCOMPLETE'}</small>
{/if} {/if}
<p>{ability?.description ?? 'Assign an active class ability to complete this focus.'}</p> <p>{ability?.description ?? 'Assign an active class ability to complete this focus.'}</p>
</div> </div>
@@ -746,7 +777,7 @@
{#each data.abilities.filter((ability: any) => ability.specialization_id === selectedSpecialization.id) as ability} {#each data.abilities.filter((ability: any) => ability.specialization_id === selectedSpecialization.id) as ability}
<article class:archived={Number(ability.active) === 0}> <article class:archived={Number(ability.active) === 0}>
<ItemIcon src={ability.iconUrl} alt={ability.name}/> <ItemIcon src={ability.iconUrl} alt={ability.name}/>
<div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? `${abilityChipCount(ability)} / 3 SKILL CHIPS` : 'ARCHIVED CLASS ABILITY'}</small></div> <div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? `${abilityChipCount(ability)} OWNED · ${abilityEligibleChipCount(ability)} ELIGIBLE` : 'ARCHIVED CLASS ABILITY'}</small></div>
<button class="secondary specialization-mutation" onclick={() => editAbility(ability)}>{Number(ability.active) === 1 ? 'EDIT' : 'REVIEW + RESTORE'} →</button> <button class="secondary specialization-mutation" onclick={() => editAbility(ability)}>{Number(ability.active) === 1 ? 'EDIT' : 'REVIEW + RESTORE'} →</button>
</article> </article>
{:else} {:else}
@@ -0,0 +1,227 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => {
const links: Array<{ ability_id: string; compatible_ability_id: string }> =
[];
return {
links,
auditRecord: vi.fn(async () => ({
id: 'ability-1',
specialization_id: 'specialization-1',
name: 'Tactical Link',
})),
recordIsacAudit: vi.fn(),
database: {
exec: vi.fn(),
prepare: vi.fn(),
},
};
});
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: vi.fn(async (user) => user),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/server/audit', () => ({
auditRecord: mocks.auditRecord,
recordIsacAudit: mocks.recordIsacAudit,
}));
vi.mock('$lib/server/data-moderation', () => ({
deserializeProposal: vi.fn(),
getPendingEntities: vi.fn(async () => []),
}));
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: vi.fn(),
gameDataIconStorageDirectory: vi.fn(),
gameDataIconMetadata: vi.fn(async () => null),
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
readGameDataIcon: vi.fn(async () => null),
readIconMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: vi.fn(),
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/specialization-readiness', () => ({
refreshSpecializationFocusReady: vi.fn(),
refreshSpecializationFocuses: 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 saveRequest = (compatibleAbilityId: string) => {
const form = new FormData();
form.set('id', 'ability-1');
form.set('specializationId', 'specialization-1');
form.set('skillChipCompatibleAbilityId', compatibleAbilityId);
form.set('name', 'Tactical Link');
form.set('description', 'Deploy a tactical support link.');
form.set('effectsJson', '[]');
form.set('attackJson', '{"enabled":false}');
return new Request('http://localhost/isac/specializations?/saveAbility', {
method: 'POST',
body: form,
});
};
const runSave = (compatibleAbilityId: string) =>
actions.saveAbility?.({
cookies: {} as never,
request: saveRequest(compatibleAbilityId),
} as never);
const runArchive = () => {
const form = new FormData();
form.set('id', 'ability-1');
return actions.archiveAbility?.({
cookies: {} as never,
request: new Request(
'http://localhost/isac/specializations?/archiveAbility',
{ method: 'POST', body: form },
),
} as never);
};
describe('Specialization ability Skill Chip sharing', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.links.length = 0;
mocks.database.prepare.mockImplementation(async (sql: string) => ({
get: vi.fn(async (...args: unknown[]) => {
if (
sql.includes('SELECT specialization_id FROM specialization_abilities')
)
return { specialization_id: 'specialization-1' };
if (sql.includes('SELECT id FROM specializations'))
return { id: 'specialization-1' };
if (
sql.includes(
'SELECT id,specialization_id FROM specialization_abilities',
)
)
return {
id: String(args[0]),
specialization_id:
args[0] === 'cross-specialization-ability'
? 'specialization-2'
: 'specialization-1',
};
if (sql.includes('FROM specialization_ability_skill_chip_links'))
return null;
if (
sql.includes('FROM specialization_abilities WHERE specialization_id=')
)
return null;
if (sql.includes('FROM specialization_ability_attacks')) return null;
if (sql.includes('FROM managed_images')) return null;
return null;
}),
all: vi.fn(async () => {
if (sql.includes('FROM specialization_ability_skill_chip_links'))
return [...mocks.links];
return [];
}),
run: vi.fn(async (...args: unknown[]) => {
if (sql.includes('DELETE FROM specialization_ability_skill_chip_links'))
mocks.links.length = 0;
if (sql.includes('INSERT INTO specialization_ability_skill_chip_links'))
mocks.links.push({
ability_id: String(args[0]),
compatible_ability_id: String(args[1]),
});
}),
}));
});
it('saves and audits an explicit same-specialization ability pair', async () => {
const result = await runSave('ability-2');
expect(result).toMatchObject({
abilitySaved: true,
abilityMode: 'edit',
resultToken: expect.any(String),
});
expect(mocks.links).toEqual([
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
]);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
actorId: 'operator-1',
category: 'game_data',
action: 'specialization_ability.updated',
entityId: 'ability-1',
before: expect.objectContaining({ record: expect.any(Object) }),
after: expect.objectContaining({
skillChipLinks: [
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
],
}),
});
});
it('rejects cross-specialization sharing without writing or auditing', async () => {
const result = await runSave('cross-specialization-ability');
expect(result).toMatchObject({
status: 400,
data: {
abilityError:
'Choose an active Skill Chip-compatible ability from the same specialization.',
},
});
expect(mocks.database.exec).not.toHaveBeenCalled();
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('clears the relationship when either ability is archived', async () => {
mocks.links.push({
ability_id: 'ability-1',
compatible_ability_id: 'ability-2',
});
const result = await runArchive();
expect(result).toMatchObject({
abilityArchived: true,
resultToken: expect.any(String),
});
expect(mocks.links).toEqual([]);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'operator-1',
category: 'game_data',
action: 'specialization_ability.archived',
entityId: 'ability-1',
before: expect.objectContaining({
skillChipLinks: [
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
],
}),
after: expect.objectContaining({ skillChipLinks: [] }),
}),
);
});
});