Updating mechanics for OS protocols
This commit is contained in:
+32
-21
@@ -341,17 +341,29 @@ 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.
|
||||||
|
|
||||||
|
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 +373,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
|
||||||
@@ -2575,7 +2586,7 @@ conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
|
|||||||
activation profiles, rotations, and simulation behind explicit `COMING SOON`
|
activation profiles, rotations, and simulation behind explicit `COMING SOON`
|
||||||
panels.
|
panels.
|
||||||
|
|
||||||
Loadout schema version 5 retains `maximum`, `custom`, conditional state,
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+24
-18
@@ -584,6 +584,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 +634,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 +733,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 +881,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
|
||||||
|
|||||||
+191
-5
@@ -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',
|
||||||
);
|
);
|
||||||
@@ -3223,6 +3318,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 +3367,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 +3784,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) =>
|
||||||
|
|||||||
+233
-25
@@ -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,116 @@ 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 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;
|
||||||
|
}
|
||||||
|
@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 +9875,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 +9895,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 +9919,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 +9938,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 +12201,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 +12235,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;
|
||||||
}
|
}
|
||||||
@@ -12118,11 +12325,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 +12908,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 +13022,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,
|
||||||
@@ -15494,18 +15701,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 +15740,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;
|
||||||
@@ -15600,6 +15805,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;
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ describe('loadout mechanic timeline', () => {
|
|||||||
).toEqual([1, 2]);
|
).toEqual([1, 2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => {
|
it('falls back to a documented Tier I mechanic when exotic Tier II is absent', () => {
|
||||||
const persistedRule = {
|
const persistedRule = {
|
||||||
rule: {
|
rule: {
|
||||||
id: 'tier-i-only',
|
id: 'tier-i-only',
|
||||||
@@ -327,6 +327,7 @@ describe('loadout mechanic timeline', () => {
|
|||||||
const adapted = equippedConditionalProgram({
|
const adapted = equippedConditionalProgram({
|
||||||
key: 'weapon:0:exotic-talent',
|
key: 'weapon:0:exotic-talent',
|
||||||
tier: 2,
|
tier: 2,
|
||||||
|
isExotic: true,
|
||||||
rule: persistedRule,
|
rule: persistedRule,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export function equippedConditionalProgram(
|
|||||||
: 'base';
|
: 'base';
|
||||||
return (
|
return (
|
||||||
conditionalProgram(equipped.rule, variant) ??
|
conditionalProgram(equipped.rule, variant) ??
|
||||||
(equipmentTalent && variant === 'II'
|
(equipmentTalent && equipped.isExotic && variant === 'II'
|
||||||
? conditionalProgram(equipped.rule, 'I')
|
? conditionalProgram(equipped.rule, 'I')
|
||||||
: null)
|
: null)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -425,11 +425,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 +502,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 +529,60 @@ describe('summarizeLoadout', () => {
|
|||||||
],
|
],
|
||||||
rule,
|
rule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'talent-two',
|
||||||
|
name: 'Calibrated',
|
||||||
|
template: 'Gain {1}.',
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
tier: 'I',
|
||||||
|
effects: [{ stat_attribute_id: 'talent-stat', value: 30 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: 'II',
|
||||||
|
effects: [{ stat_attribute_id: 'talent-stat', value: 40 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'exotic-talent',
|
||||||
|
name: 'Exotic Talent',
|
||||||
|
template: 'Gain {1}.',
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
tier: 'I',
|
||||||
|
effects: [{ stat_attribute_id: 'talent-stat', value: 50 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: 'II',
|
||||||
|
effects: [{ stat_attribute_id: 'talent-stat', value: 60 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(summary.equippedTalents).toEqual([
|
expect(summary.equippedTalents).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'talent',
|
id: 'talent-one',
|
||||||
tier: 2,
|
tier: 1,
|
||||||
description: 'Gain 20% for 3 Shots.',
|
tierLabel: 'TIER I',
|
||||||
|
description: 'Gain 10% for 2 Shots.',
|
||||||
rule,
|
rule,
|
||||||
}),
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'talent-two',
|
||||||
|
tier: 2,
|
||||||
|
tierLabel: 'TIER II',
|
||||||
|
description: 'Gain 40%.',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'exotic-talent',
|
||||||
|
tier: 1,
|
||||||
|
tierLabel: 'TIER I',
|
||||||
|
description: 'Gain 50%.',
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
expect(stat(summary, 'talent-stat')).toBeUndefined();
|
expect(stat(summary, 'talent-stat')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
|
import {
|
||||||
|
gearSlots,
|
||||||
|
scaled,
|
||||||
|
type LoadoutDraft,
|
||||||
|
type TalentTier,
|
||||||
|
} from '$lib/builder/loadout';
|
||||||
|
|
||||||
export type LoadoutCatalog = {
|
export type LoadoutCatalog = {
|
||||||
attributes: any[];
|
attributes: any[];
|
||||||
@@ -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,19 +315,39 @@ 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,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -37,9 +37,9 @@
|
|||||||
&& 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/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>
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
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>SUPPLIES 3 ABILITIES</span></div>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
{#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>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
{#each weapons as cell}
|
{#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)}>
|
<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}
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
{#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}
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
<small class="section-label">AGENT SYSTEM · ONE OS PROTOCOL</small>
|
<small class="section-label">AGENT SYSTEM · ONE 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 +112,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);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}}
|
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -40,7 +40,6 @@
|
|||||||
<p class="eyebrow"><span></span> KNOWN EQUIPMENT TOTALS</p>
|
<p class="eyebrow"><span></span> KNOWN EQUIPMENT TOTALS</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}
|
||||||
@@ -78,24 +77,32 @@
|
|||||||
<header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>
|
<header><span>EQUIPPED 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>Selected equipment Talents appear here.</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">Includes deterministic equipment values only. Conditional Talent effects remain outside current totals.</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))}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -125,6 +125,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);
|
||||||
@@ -592,6 +660,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();
|
||||||
|
|||||||
@@ -12,11 +12,12 @@
|
|||||||
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 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 { 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();
|
||||||
@@ -71,6 +72,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);
|
||||||
@@ -207,6 +210,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 +259,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) => {
|
||||||
@@ -375,7 +382,7 @@
|
|||||||
}));
|
}));
|
||||||
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}`,
|
||||||
@@ -392,7 +399,7 @@
|
|||||||
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 {
|
||||||
@@ -429,17 +436,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 +471,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.');
|
||||||
}
|
}
|
||||||
@@ -495,7 +509,7 @@
|
|||||||
{#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> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</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}
|
||||||
@@ -518,29 +532,28 @@
|
|||||||
<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>AGENT CLASS</span><h2>SPECIALIZATION FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
|
||||||
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + FOCUS"/>
|
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + 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={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>
|
||||||
{@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=""/><span><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'NOT CONFIGURED'}</strong></span><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</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>FOCUS UPGRADE TREE</span><small>COMPLETE EARLIER ROWS TO ADVANCE</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' : 'PREVIOUS ROWS REQUIRED'}</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])}
|
||||||
@@ -565,20 +578,27 @@
|
|||||||
{#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)}
|
||||||
@@ -586,27 +606,32 @@
|
|||||||
<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 DEFINITION"/>
|
||||||
{#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]}
|
||||||
@@ -615,24 +640,19 @@
|
|||||||
{#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) => 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 !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">SHARED SKILL CHIP PROFILE · CONFIG REQUIRED. Complete every global quality range in ISAC before this loadout can be complete.</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>ABILITY-SCOPED PRIMARY</span><small>{ability?.name?.toUpperCase() ?? 'ASSIGNED ABILITY'} ONLY</small></div><b>NOT YET DEFINED</b></div>
|
||||||
|
<p class="builder-scaling-note">Excluded from 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></div>
|
||||||
|
</section>
|
||||||
|
<section class="builder-editor-lane" aria-label="Skill Chip secondary Attributes">
|
||||||
|
<div class="builder-section-title"><div><span>SECONDARY ATTRIBUTES</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 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}
|
||||||
|
</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()}
|
||||||
|
|||||||
@@ -183,4 +183,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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user