feat: defer advanced build calculations
This commit is contained in:
+74
-58
@@ -353,13 +353,13 @@ equipment opens the searchable real-data catalog and its compatible
|
||||
configuration controls. Do not display a prototype option that the current
|
||||
loadout schema cannot save.
|
||||
|
||||
Calculation 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
|
||||
open the editor that resolves it. The live calculation rail summarizes blocked
|
||||
output in one compact row. Do not repeat every missing slot in a large error
|
||||
panel. Keep system-owned blockers, such as an unpublished calculation profile,
|
||||
visible in that compact summary because the player cannot resolve them from a
|
||||
loadout cell.
|
||||
open the editor that resolves it. The live totals rail summarizes deterministic
|
||||
equipped-item totals. Separate panels label conditional Talent outcomes, weapon
|
||||
and Skill DPS, encounter assumptions, activation profiles, rotations, and
|
||||
simulation as `COMING SOON`; they do not report calculation-profile blockers as
|
||||
current player errors.
|
||||
|
||||
The builder uses three workbench columns on wide screens, moves the live summary
|
||||
below the matrix and editor at intermediate widths, and stacks all three regions
|
||||
@@ -1707,10 +1707,10 @@ cards and augment editor cards reduce to a single column while retaining
|
||||
|
||||
The builder is a three-rail tactical workbench: compact equipment positions on
|
||||
the left, the currently selected item editor in the center, and a sticky live
|
||||
calculation summary on the right. At medium widths the summary becomes a
|
||||
full-width lower rail; on mobile all three rails stack, with equipment first,
|
||||
editing second, and results third. Equipment positions remain explicit even when
|
||||
empty so loadout completeness is always visible.
|
||||
totals summary on the right. At medium widths the summary becomes a full-width
|
||||
lower rail; on mobile all three rails stack, with equipment first, editing
|
||||
second, and results third. Equipment positions remain explicit even when empty
|
||||
so players can see loadout completeness.
|
||||
|
||||
Use the shared `BuilderPicker` for builder catalogs and Attribute pools. It
|
||||
combines an always-visible current selection with a searchable overlay, icon or
|
||||
@@ -1721,11 +1721,11 @@ such as the single-exotic-weapon rule are explained rather than hidden.
|
||||
Numeric instance values display their authoritative minimum and maximum beside
|
||||
the field and clamp to that range on completion. Always-on item rolls, fixed
|
||||
bonuses, inherited profiles, enabled augments, and activated set bonuses feed
|
||||
the live totals immediately. Conditional mechanics state their calculation mode
|
||||
and readiness. Maximum Potential uses their best-case state; Custom Scenario
|
||||
uses the source-defined advanced assumptions saved with the loadout. Exclude a
|
||||
mechanic that lacks a required assumption and label it `REQUIRES ASSUMPTIONS`.
|
||||
This distinction must remain clear without relying on color.
|
||||
the live totals immediately. Current Builder and shared Build pages stop at
|
||||
these deterministic equipped-item totals. Conditional Talent outcomes, weapon
|
||||
and Skill DPS, encounter assumptions, activation profiles, rotations, and
|
||||
simulation remain planned. Render those capabilities as separate `COMING SOON`
|
||||
panels, with visible text rather than color alone.
|
||||
|
||||
Loadout summaries name selected Talents, Attributes and their exact values,
|
||||
nodes, and Skill Chips. Do not replace these selections with configured item
|
||||
@@ -2309,14 +2309,14 @@ source program, where authors may edit its exposed branch, state, and outcome
|
||||
controls. The recipe label records provenance only. `REPLACE PROGRAM` requires
|
||||
an inline confirmation that states all existing branches will be discarded.
|
||||
|
||||
Builder Controls define source-specific advanced scenario assumptions. Authors
|
||||
choose toggle, slider, or numeric input and provide a label, Agent guidance,
|
||||
default value, best-case value, and numeric bounds when applicable. Group these
|
||||
controls behind the builder's advanced scenario surface. Maximum Potential uses
|
||||
best-case values. Custom Scenario uses saved Agent values. A required cadence or
|
||||
environmental value with no supplied assumption keeps the mechanic out of the
|
||||
calculation and displays `REQUIRES ASSUMPTIONS`; the editor and runtime never
|
||||
invent a cadence.
|
||||
Builder Controls define source-specific scenario assumptions for the planned
|
||||
player simulation. ISAC authors choose toggle, slider, or numeric input and
|
||||
provide a label, Agent guidance, default value, best-case value, and numeric
|
||||
bounds when applicable. The current player UI does not render these controls or
|
||||
read their saved values. It uses an explicit `COMING SOON` panel for the future
|
||||
advanced scenario surface. The authored contract still requires the runtime to
|
||||
report `REQUIRES ASSUMPTIONS` when a required cadence or environmental value is
|
||||
missing; the editor and runtime never invent a cadence.
|
||||
|
||||
Use `--shd-model-surface` and `--shd-model-border` for mechanic programs,
|
||||
calculation revisions, and field calibration. The blue information rail marks
|
||||
@@ -2482,9 +2482,16 @@ anchor scrolling and sticky-state transitions.
|
||||
|
||||
#### Builder calculation modes
|
||||
|
||||
The loadout builder places one required two-option `SegmentedChoice` above
|
||||
combat assumptions. The persisted `maximum` and `custom` values use these
|
||||
visible labels:
|
||||
The current Builder and shared Build pages do not expose calculation modes or
|
||||
combat assumptions. They show deterministic equipped-item totals and reserve
|
||||
conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
|
||||
activation profiles, rotations, and simulation behind explicit `COMING SOON`
|
||||
panels.
|
||||
|
||||
Loadout schema version 4 retains `maximum`, `custom`, conditional state,
|
||||
encounter-assumption, and per-ability activation fields for compatibility. The
|
||||
current player UI ignores those fields. When player simulation ships, one
|
||||
required two-option `SegmentedChoice` will use these visible labels:
|
||||
|
||||
- `MAXIMUM POTENTIAL` calculates a configured ceiling. It applies conditional
|
||||
effects at peak state and treats selected abilities as firing on cooldown
|
||||
@@ -2492,40 +2499,42 @@ visible labels:
|
||||
- `CUSTOM SCENARIO` exposes encounter assumptions and source-defined advanced
|
||||
controls for a player-defined scenario.
|
||||
|
||||
New loadouts select `MAXIMUM POTENTIAL`. The interface labels Maximum results as
|
||||
a modeled ceiling so players do not read them as typical encounter performance.
|
||||
Maximum hides granular assumption fields without deleting their saved values.
|
||||
Returning to Custom restores those values.
|
||||
The planned interface selects `MAXIMUM POTENTIAL` for new loadouts and labels
|
||||
its results as a modeled ceiling. Maximum will hide granular assumption fields
|
||||
without deleting their saved values. Returning to Custom will restore those
|
||||
values.
|
||||
|
||||
Custom mode gives each unlocked conditional effect three state controls:
|
||||
The planned Custom mode gives each unlocked conditional effect three state
|
||||
controls:
|
||||
|
||||
- `OFF` excludes the effect.
|
||||
- `PEAK` applies its configured maximum stacks.
|
||||
- `ENCOUNTER` models stack buildup and expiry from weapon RPM, magazine size,
|
||||
reload time, hit rate, firing activity, and encounter duration.
|
||||
|
||||
Users may override encounter assumptions on one effect without changing the
|
||||
global Custom profile. Open the advanced scenario surface to show only the
|
||||
toggle, slider, and numeric controls defined by that effect's owning source. The
|
||||
effect card shows uptime, activation count, and applied stack state beside the
|
||||
mode. A mechanic that needs an undefined cadence or another missing scenario
|
||||
input displays `REQUIRES ASSUMPTIONS`; the builder withholds its calculated
|
||||
contribution until the author or player supplies that input.
|
||||
The planned advanced scenario surface will let users override one effect without
|
||||
changing the global Custom profile. It will show only the toggle, slider, and
|
||||
numeric controls defined by that effect's owning source. The effect card will
|
||||
show uptime, activation count, and applied stack state beside the mode. A
|
||||
mechanic that needs an undefined cadence or another missing scenario input will
|
||||
display `REQUIRES ASSUMPTIONS`; the simulation will withhold its contribution
|
||||
until the author or player supplies that input.
|
||||
|
||||
Maximum and Custom use radio semantics, an explicit selected label, keyboard
|
||||
arrow navigation, and the standard orange selected rail. Results repeat the
|
||||
active calculation mode in text. On mobile, the global mode control and Custom
|
||||
assumption fields stack into full-width targets of at least 44 pixels. Mode
|
||||
changes use the standard pressed feedback; reduced-motion preferences remove
|
||||
transitions without hiding the state change.
|
||||
The future Maximum and Custom control will use radio semantics, an explicit
|
||||
selected label, keyboard arrow navigation, and the standard orange selected
|
||||
rail. Results will repeat the active calculation mode in text. On mobile, the
|
||||
global mode control and Custom assumption fields will stack into full-width
|
||||
targets of at least 44 pixels. Mode changes will use the standard pressed
|
||||
feedback; reduced-motion preferences will remove transitions without hiding the
|
||||
state change.
|
||||
|
||||
Combat result panels show normal, critical, headshot, and critical-headshot
|
||||
values with burst, sustained, and encounter DPS. Conditional stack telemetry
|
||||
lists the mode, uptime, activations, average and peak stacks, ending stacks,
|
||||
buildup time, and maintained state. A result with missing equipment, Attribute
|
||||
bindings, rules, or assumptions displays `INSUFFICIENT DATA` and a concrete
|
||||
remediation list. The interface withholds combat numbers until it has the
|
||||
required inputs.
|
||||
The future combat result panels will show normal, critical, headshot, and
|
||||
critical-headshot values with burst, sustained, and encounter DPS. Conditional
|
||||
stack telemetry will list the mode, uptime, activations, average and peak
|
||||
stacks, ending stacks, buildup time, and maintained state. A result with missing
|
||||
equipment, Attribute bindings, rules, or assumptions will display
|
||||
`INSUFFICIENT DATA` and a concrete remediation list. Until this interface ships,
|
||||
the player pages show `COMING SOON` instead of combat numbers.
|
||||
|
||||
### Specialization loadout composer
|
||||
|
||||
@@ -2541,16 +2550,19 @@ ranks remain visible with their lock state. The builder permits a row after the
|
||||
user buys every available Rank 1 and Rank 2 in preceding rows and enforces the
|
||||
49-point limit. Cost telemetry lists Credits and components beside each rank.
|
||||
|
||||
Each ability panel exposes one compact activation control: Off, On Cooldown, or
|
||||
Custom Rate. Custom Rate adds uses per minute. Every enabled ability has its own
|
||||
target count. Skill result cards show the ability name, activation count,
|
||||
single-target DPS, total-target DPS, and a combined summary. They state that
|
||||
Skills can critically hit and multi-shot but cannot headshot.
|
||||
Player ability panels currently expose equipped definitions and deterministic
|
||||
item contributions. Planned simulation panels display `COMING SOON` instead of
|
||||
activation controls or Skill DPS. The future interface will offer Off, On
|
||||
Cooldown, or Custom Rate, with uses per minute and a target count for each
|
||||
enabled ability. Future Skill result cards will show activation count,
|
||||
single-target DPS, total-target DPS, and a combined summary. They will state
|
||||
that Skills can critically hit and multi-shot but cannot headshot.
|
||||
|
||||
Saved loadouts store focus, node ranks, nine Skill Chips, conditional modes,
|
||||
encounter assumptions, and per-ability activation profiles. The builder accepts
|
||||
only the current complete structure. Invalid local drafts are discarded; invalid
|
||||
server records are removed through a backed-up database migration.
|
||||
only the current complete structure for compatibility. Current player pages
|
||||
ignore the simulation fields. Invalid local drafts are discarded; invalid server
|
||||
records are removed through a backed-up database migration.
|
||||
|
||||
### Calculation revision controls
|
||||
|
||||
@@ -2581,6 +2593,10 @@ revision identity and before the manual stage and profile controls. Reuse the
|
||||
language, identifies the active or edited base revision, and previews changes to
|
||||
stages, weapon profiles, and Skill profiles.
|
||||
|
||||
ISAC keeps revision editing, calibration, assisted suggestions, publication, and
|
||||
the calculation AI assistant available for continued model authoring. The
|
||||
current Builder and shared Build pages do not consume their output.
|
||||
|
||||
Agents without `ai.calculation.generate` see the collapsed `PERMISSION REQUIRED`
|
||||
state. Agents with the permission may request and apply a draft, but the panel
|
||||
leaves every manual calculation control available. Applying a draft fills the
|
||||
|
||||
+53
-34
@@ -25,6 +25,13 @@ The team builds the project in vertical slices.
|
||||
Server code checks authorization for each mutation. UI visibility has no effect
|
||||
on server authorization.
|
||||
|
||||
Builder and shared Build pages currently expose only deterministic totals from
|
||||
the equipped items, selected rolls, fixed bonuses, inherited profiles, augments,
|
||||
and active set bonuses. Conditional Talent outcomes, weapon and Skill DPS,
|
||||
encounter assumptions, activation profiles, rotations, and simulation remain
|
||||
planned. Explicit `COMING SOON` panels identify those player-facing
|
||||
capabilities.
|
||||
|
||||
Weapon and gear range calculations must preserve decimal endpoints and follow
|
||||
the tested progression rules in
|
||||
[`STAT_LEVEL_SCALING.md`](STAT_LEVEL_SCALING.md). Leveling is linear rather than
|
||||
@@ -174,9 +181,11 @@ stores only its domain-separated hash. Repeated anonymous activity requires a
|
||||
Turnstile result. Guide reports reference the exact published revision so a
|
||||
report against an older revision cannot hide newer work.
|
||||
|
||||
The Builder stores loadout data only. It has no summary or Guide body. An owned
|
||||
Build page links to `/guides/new?build=<id>`, where the server verifies
|
||||
ownership and initializes a removable Build block in a new private Guide.
|
||||
The Builder stores loadout data and displays deterministic equipped-item totals.
|
||||
It has no Guide body. Shared Build pages use the same player-facing calculation
|
||||
boundary. An owned Build page links to `/guides/new?build=<id>`, where the
|
||||
server verifies ownership and initializes a removable Build block in a new
|
||||
private Guide.
|
||||
|
||||
## Public data corrections and discussion
|
||||
|
||||
@@ -490,10 +499,11 @@ Talent, OS Protocol Talent, Skill Chip Talent, or Specialization tree rank. The
|
||||
`source_type` and `source_id` pair identifies that owner. No standalone Trigger
|
||||
record or shared Trigger relationship participates in execution.
|
||||
|
||||
The builder supplies normalized timeline events to each equipped mechanic.
|
||||
`movement_snapshot.values.state` is `immobile`, `moving`, or `sprinting`; the
|
||||
mechanic's moving and sprinting scenario controls select that state, with
|
||||
sprinting taking precedence. Enabled Skill activation profiles emit
|
||||
The mechanic runtime accepts normalized timeline events for each equipped
|
||||
mechanic. Current player pages do not construct this timeline or invoke the
|
||||
runtime. `movement_snapshot.values.state` is `immobile`, `moving`, or
|
||||
`sprinting`; the mechanic's moving and sprinting scenario controls select that
|
||||
state, with sprinting taking precedence. Enabled Skill activation profiles emit
|
||||
`skill_deployed` events on the same cadence as Skill calculations and identify
|
||||
the deployed ability. An `on_event` Skill modifier applies to that activation
|
||||
until the ability's next deployment or the encounter ends.
|
||||
@@ -502,7 +512,7 @@ Each source supplies a fixed value-variant contract:
|
||||
|
||||
- Standard Talents require Tier I and Tier II values. Exotic Talents require
|
||||
Tier I and may omit Tier II until contributors document its game values.
|
||||
Builder calculations use Tier I while an equipped exotic Talent has no Tier II
|
||||
Runtime calculations use Tier I while an equipped exotic Talent has no Tier II
|
||||
mechanic variant.
|
||||
- OS Protocol Talents store a base value. An outcome may use an expression that
|
||||
reads `equipment.protocolTalentUpgrades`, from zero through five, so selected
|
||||
@@ -622,10 +632,11 @@ uses `requires_assumptions`, identifies the missing input, and stays out of the
|
||||
calculation until an operator or builder scenario supplies it.
|
||||
|
||||
The runtime derives a read-only pseudo-language listing from the typed program.
|
||||
ISAC and the builder use that listing to explain events, predicates, reducers,
|
||||
state changes, phases, and formulas. No route accepts the generated text as
|
||||
input, and no server process executes it. Operators edit structured fields; the
|
||||
formatter regenerates the listing after each change.
|
||||
ISAC uses that listing to explain events, predicates, reducers, state changes,
|
||||
phases, and formulas. Planned player calculations can reuse it when those pages
|
||||
consume mechanic outcomes. No route accepts the generated text as input, and no
|
||||
server process executes it. Operators edit structured fields; the formatter
|
||||
regenerates the listing after each change.
|
||||
|
||||
## AI-assisted mechanic drafting
|
||||
|
||||
@@ -732,7 +743,8 @@ clarification requests. It does not grant revision mutation or publication.
|
||||
Applying a result changes the open revision form in the client. The operator
|
||||
must review and save that draft through the existing revision action. The
|
||||
separate `calculation.publish` permission and activation confirmation remain
|
||||
mandatory before the saved draft affects loadout results.
|
||||
mandatory before the saved draft becomes the active authored model. Current
|
||||
player pages do not consume that model.
|
||||
|
||||
Successful calculation-generation requests append sanitized events under
|
||||
`ai_assistance`. These events use calculation-specific actions, identify the
|
||||
@@ -741,23 +753,23 @@ token-usage, result, and secret-exclusion rules as mechanic-assistance events.
|
||||
Applying a draft creates no server event. Existing `calculation_configuration`
|
||||
events remain the evidence for revision saves and activation.
|
||||
|
||||
Source authors define the builder controls required by their mechanic as typed
|
||||
toggle, slider, or numeric scenario inputs. Each input records its label,
|
||||
guidance, bounds when applicable, default value, and best-case value. These
|
||||
controls appear only in advanced scenario configuration. Maximum mode supplies
|
||||
best-case values. Custom mode uses the values saved with the loadout. A mechanic
|
||||
with an undefined required assumption remains excluded and reports
|
||||
`REQUIRES_ASSUMPTIONS`.
|
||||
Source authors define planned player scenario controls as typed toggle, slider,
|
||||
or numeric inputs. Each input records its label, guidance, bounds when
|
||||
applicable, default value, and best-case value. ISAC retains these definitions
|
||||
for continued mechanic authoring. Current Builder and shared Build pages do not
|
||||
render or evaluate them. Their calculation panels display `COMING SOON` instead.
|
||||
|
||||
Loadout schema version 4 stores one focus, selected tree ranks, nine Skill
|
||||
Chips, a unified conditional-effect state map, encounter assumptions,
|
||||
per-ability activation controls, and `calculationMode`. Runtime readers accept
|
||||
the exact version 4 structure. A backed-up prototype migration removes drafts,
|
||||
saved builds, and calibration snapshots that use incompatible payloads.
|
||||
the exact version 4 structure. The schema retains its simulation fields for
|
||||
compatibility, but the current player UI ignores them. A backed-up prototype
|
||||
migration removes drafts, saved builds, and calibration snapshots that use
|
||||
incompatible payloads.
|
||||
|
||||
## Calculation modes
|
||||
## Planned player calculation modes
|
||||
|
||||
The builder offers `maximum` and `custom` modes.
|
||||
Loadout v4 defines `maximum` and `custom` modes for the planned simulation UI:
|
||||
|
||||
- Maximum mode supplies each scenario input's best-case value, selects peak
|
||||
conditional-effect state, and runs selected abilities on cooldown against
|
||||
@@ -767,9 +779,10 @@ The builder offers `maximum` and `custom` modes.
|
||||
rates, target counts, and other supported assumptions. A missing required
|
||||
input produces `REQUIRES_ASSUMPTIONS` rather than a fabricated value.
|
||||
|
||||
Saved and shared loadouts preserve the selected mode. Calculation results label
|
||||
the mode so readers can distinguish a best-case ceiling from a scenario-based
|
||||
estimate.
|
||||
Saved and shared loadouts preserve the selected mode as compatibility data. The
|
||||
current Builder and shared Build UI does not expose a mode selector, read
|
||||
encounter assumptions, or execute either mode. Explicit `COMING SOON` panels
|
||||
reserve the player-facing space for these calculations.
|
||||
|
||||
## Specialization ability attacks
|
||||
|
||||
@@ -795,6 +808,12 @@ holds the first position, and the firing-cycle stage holds the last.
|
||||
`attribute_calculation_profiles` maps an Attribute to a weapon stage with its
|
||||
weight, optional caps, curve, and weapon-category scope.
|
||||
|
||||
ISAC keeps combat revision editing, publication, calibration, assisted
|
||||
suggestions, and the calculation AI assistant available so operators can
|
||||
continue authoring the model. Builder and shared Build pages do not load the
|
||||
active revision or consume revision, calibration, assistant, mechanic-runtime,
|
||||
or combat-engine output at the current player-facing boundary.
|
||||
|
||||
The shared combat engine consumes weapon facts, accumulated Attributes,
|
||||
calculation profiles, and mechanic outcomes. It produces four hit values, burst
|
||||
DPS, reload-aware sustained DPS, encounter DPS, and stack telemetry. Custom mode
|
||||
@@ -803,11 +822,11 @@ configured burst size, pauses between bursts, and encounter duration. It
|
||||
resolves the active Attribute state at each landed shot before calculating that
|
||||
shot's expected damage.
|
||||
|
||||
The builder scales a weapon definition's level-1 damage range to the equipped
|
||||
item level, then the agent selects one damage value inside that valid range.
|
||||
Combat receives `equipped_weapon.damage` as its base damage and consumes that
|
||||
number once. The combat pipeline has no `level_scaled_damage` value and must not
|
||||
apply item-level scaling a second time.
|
||||
The deterministic equipment path scales a weapon definition's level-1 damage
|
||||
range to the equipped item level, then the agent selects one damage value inside
|
||||
that valid range. A combat invocation receives `equipped_weapon.damage` as its
|
||||
base damage and consumes that number once. The combat pipeline has no
|
||||
`level_scaled_damage` value and must not apply item-level scaling a second time.
|
||||
|
||||
The generated calculation listing names each typed pipeline stage and its bound
|
||||
Attributes. The listing explains the active revision with statements such as
|
||||
@@ -829,8 +848,8 @@ the builder supports a combined weapon-rotation model.
|
||||
|
||||
The engine returns `INSUFFICIENT_DATA` with missing requirement keys when a
|
||||
calculation lacks required inputs or an Attribute binding. Callers must render
|
||||
that state instead of a partial result. Saved and shared builds load the current
|
||||
active revision for each request.
|
||||
that state instead of a partial result. Current player routes do not call the
|
||||
engine; their planned calculation surfaces display `COMING SOON`.
|
||||
|
||||
`calculation_calibration_cases` stores a versioned loadout snapshot, observed
|
||||
hit values, timing values, optional encounter DPS, and the model prediction.
|
||||
|
||||
+32
-2
@@ -11754,7 +11754,7 @@ a.secondary:active {
|
||||
padding: 22px;
|
||||
border: 1px solid var(--shd-border);
|
||||
border-top: 2px solid var(--shd-orange);
|
||||
background: var(--shd-panel);
|
||||
background: var(--shd-surface);
|
||||
}
|
||||
.shared-build-command h1 {
|
||||
margin: 8px 0;
|
||||
@@ -11777,7 +11777,12 @@ a.secondary:active {
|
||||
}
|
||||
.shared-build-grid > section {
|
||||
border: 1px solid var(--shd-border);
|
||||
background: var(--shd-panel);
|
||||
background: var(--shd-surface);
|
||||
}
|
||||
.shared-build-intelligence {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.shared-build-grid > section > header {
|
||||
display: flex;
|
||||
@@ -11810,6 +11815,26 @@ a.secondary:active {
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
}
|
||||
.shared-build-grid article > em {
|
||||
color: var(--shd-text-dim);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-style: normal;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.shared-item-facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.shared-item-facts b {
|
||||
padding: 4px 5px;
|
||||
border: 1px solid var(--shd-border-soft);
|
||||
background: var(--shd-bg);
|
||||
color: var(--shd-text);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
letter-spacing: 0.35px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.builder-command .page-title,
|
||||
.shared-build-command {
|
||||
@@ -12050,6 +12075,7 @@ a.secondary:active {
|
||||
flex-direction: column;
|
||||
}
|
||||
.builder-command .page-title > div:first-child {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.builder-command .actions {
|
||||
@@ -15104,6 +15130,10 @@ a.secondary:active {
|
||||
padding: var(--shd-gap-sm);
|
||||
border-bottom: 1px solid var(--shd-border);
|
||||
}
|
||||
.builder-planned-model {
|
||||
padding: var(--shd-gap-sm);
|
||||
border-top: 1px solid var(--shd-border);
|
||||
}
|
||||
.builder-return-link {
|
||||
width: max-content;
|
||||
min-height: 36px;
|
||||
|
||||
@@ -25,20 +25,20 @@ import {
|
||||
type SkillCalculationRules,
|
||||
type SkillModifierWindow,
|
||||
} from '$lib/calculations/skill';
|
||||
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
import { gearSlots, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
import {
|
||||
applyAttributeValue,
|
||||
catalogAttribute as attribute,
|
||||
selectedFocus,
|
||||
selectedNodeRanks,
|
||||
summarizeLoadout,
|
||||
type LoadoutCatalog,
|
||||
type LoadoutSummary,
|
||||
} from '$lib/builder/loadout-summary';
|
||||
import type { WeaponCalculationResult } from '$lib/components/builder/WeaponCalculationResult.svelte';
|
||||
|
||||
type Catalog = {
|
||||
attributes: any[];
|
||||
gear: any[];
|
||||
gearAugments: any[];
|
||||
brands: any[];
|
||||
weapons: any[];
|
||||
talents: any[];
|
||||
chipSets: any[];
|
||||
protocols: any[];
|
||||
specializations: any[];
|
||||
calculation: {
|
||||
type Catalog = LoadoutCatalog & {
|
||||
calculation?: {
|
||||
revision: any | null;
|
||||
stages: any[];
|
||||
profiles: any[];
|
||||
@@ -46,380 +46,16 @@ type Catalog = {
|
||||
};
|
||||
};
|
||||
|
||||
export type LoadoutSummary = {
|
||||
stats: Array<{ id: string; value: number; sourceCount: number }>;
|
||||
activeBonuses: any[];
|
||||
equippedTalents: any[];
|
||||
};
|
||||
export { summarizeLoadout } from '$lib/builder/loadout-summary';
|
||||
export type { LoadoutSummary } from '$lib/builder/loadout-summary';
|
||||
|
||||
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
|
||||
|
||||
function selectedFocus(draft: LoadoutDraft, catalog: Catalog) {
|
||||
return catalog.specializations
|
||||
.flatMap((specialization) =>
|
||||
specialization.focuses.map((focus: any) => ({
|
||||
...focus,
|
||||
specialization,
|
||||
})),
|
||||
)
|
||||
.find((focus: any) => focus.id === draft.specialization.focusId);
|
||||
}
|
||||
|
||||
function selectedNodeRanks(draft: LoadoutDraft, catalog: Catalog) {
|
||||
const focus = selectedFocus(draft, catalog);
|
||||
return (
|
||||
focus?.nodes.flatMap((node: any) => {
|
||||
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
|
||||
return selectedRank
|
||||
? [
|
||||
{
|
||||
node,
|
||||
rank: node.ranks.find(
|
||||
(entry: any) => Number(entry.rank) === selectedRank,
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function attribute(catalog: Catalog, id: string) {
|
||||
return catalog.attributes.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
function applyAttributeValue(
|
||||
catalog: Catalog,
|
||||
current: number,
|
||||
statId: string,
|
||||
incoming: number,
|
||||
) {
|
||||
const stat = attribute(catalog, statId);
|
||||
const combined =
|
||||
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
||||
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
|
||||
: current + incoming;
|
||||
return stat?.cap_value === null || stat?.cap_value === undefined
|
||||
? combined
|
||||
: Math.min(combined, Number(stat.cap_value));
|
||||
}
|
||||
|
||||
function unitValue(catalog: Catalog, value: unknown, statId: string) {
|
||||
const numeric = Number(value);
|
||||
const rendered = Number.isInteger(numeric)
|
||||
? String(numeric)
|
||||
: numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||
const unit = attribute(catalog, statId)?.unit ?? '';
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||
}
|
||||
|
||||
function talentTier(catalog: Catalog, talentId: string, tier: number) {
|
||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||
return (
|
||||
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
|
||||
item?.tiers[0]
|
||||
);
|
||||
}
|
||||
|
||||
function talentDescription(catalog: Catalog, talentId: string, tier: number) {
|
||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||
const configured = talentTier(catalog, talentId, tier);
|
||||
if (!item || !configured) return '';
|
||||
const template = String(item.template || item.name);
|
||||
return template.replace(/\{(\d+)\}/g, (placeholder, raw) => {
|
||||
const effect = configured.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(catalog, effect.value, effect.stat_attribute_id)
|
||||
: placeholder;
|
||||
});
|
||||
}
|
||||
|
||||
export function summarizeLoadout(
|
||||
draft: LoadoutDraft,
|
||||
catalog: Catalog,
|
||||
): LoadoutSummary {
|
||||
const values = new Map<string, number>();
|
||||
const sources = new Map<string, Set<string>>();
|
||||
const add = (statId: string, value: unknown, source: string) => {
|
||||
if (!statId || !Number.isFinite(Number(value))) return;
|
||||
const stat = attribute(catalog, statId);
|
||||
const current = values.get(statId) ?? 0;
|
||||
const incoming = Number(value);
|
||||
const combined =
|
||||
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
||||
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
|
||||
: current + incoming;
|
||||
values.set(
|
||||
statId,
|
||||
stat?.cap_value === null || stat?.cap_value === undefined
|
||||
? combined
|
||||
: Math.min(combined, Number(stat.cap_value)),
|
||||
);
|
||||
if (!sources.has(statId)) sources.set(statId, new Set());
|
||||
sources.get(statId)!.add(source);
|
||||
const calculationCatalog = (catalog: Catalog) =>
|
||||
catalog.calculation ?? {
|
||||
revision: null,
|
||||
stages: [],
|
||||
profiles: [],
|
||||
skillProfiles: [],
|
||||
};
|
||||
const byName = (name: string) =>
|
||||
catalog.attributes.find(
|
||||
(item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase(),
|
||||
)?.id ?? '';
|
||||
const brandCounts = new Map<string, number>();
|
||||
const activeBonuses: any[] = [];
|
||||
const equippedTalents: any[] = [];
|
||||
|
||||
for (const slot of gearSlots) {
|
||||
const selection = draft.gear[slot];
|
||||
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
|
||||
if (!item) continue;
|
||||
const enabledAugments = catalog.gearAugments.filter(
|
||||
(entry) =>
|
||||
entry.gear_slot === slot &&
|
||||
selection.augmentSlots.includes(Number(entry.augment_slot)),
|
||||
);
|
||||
const basePercentage = enabledAugments
|
||||
.filter((entry) => entry.effect_type === 'Base Percentage')
|
||||
.reduce((total, entry) => total + Number(entry.value), 0);
|
||||
add(
|
||||
byName('Health'),
|
||||
selection.health * (1 + basePercentage / 100),
|
||||
`${slot} base`,
|
||||
);
|
||||
if (item.base_armor_min !== null)
|
||||
add(
|
||||
byName('Armor'),
|
||||
selection.armor * (1 + basePercentage / 100),
|
||||
`${slot} base`,
|
||||
);
|
||||
for (const roll of selection.rolls)
|
||||
add(roll.statId, roll.value, `${slot} roll`);
|
||||
for (const key of coreKeys)
|
||||
add(byName(key), selection[key], `${slot} core`);
|
||||
if (selection.brandId)
|
||||
brandCounts.set(
|
||||
selection.brandId,
|
||||
(brandCounts.get(selection.brandId) ?? 0) + 1,
|
||||
);
|
||||
for (const augment of enabledAugments.filter(
|
||||
(entry) => entry.effect_type === 'Custom',
|
||||
)) {
|
||||
add(
|
||||
augment.stat_attribute_id,
|
||||
augment.value,
|
||||
`${slot} augment ${augment.augment_slot}`,
|
||||
);
|
||||
}
|
||||
if (selection.talentId) {
|
||||
equippedTalents.push({
|
||||
key: `gear:${slot}:${selection.talentId}`,
|
||||
id: selection.talentId,
|
||||
rule:
|
||||
catalog.talents.find((entry) => entry.id === selection.talentId)
|
||||
?.rule ?? null,
|
||||
tier: Number(item.tier),
|
||||
source: slot,
|
||||
description: talentDescription(
|
||||
catalog,
|
||||
selection.talentId,
|
||||
Number(item.tier),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [brandId, count] of brandCounts) {
|
||||
const set = catalog.brands.find((entry) => entry.id === brandId);
|
||||
for (const effect of set?.effects.filter(
|
||||
(entry: any) => Number(entry.pieces_required) <= count,
|
||||
) ?? []) {
|
||||
add(
|
||||
effect.stat_attribute_id,
|
||||
effect.value,
|
||||
`${set.name} ${effect.pieces_required}-piece`,
|
||||
);
|
||||
activeBonuses.push({
|
||||
name: set.name,
|
||||
pieces: effect.pieces_required,
|
||||
count,
|
||||
statId: effect.stat_attribute_id,
|
||||
value: effect.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const selection = draft.weapons[index];
|
||||
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
|
||||
if (!item) continue;
|
||||
for (const name of ['Firepower', 'Toughness', 'Engineering'] as const) {
|
||||
add(
|
||||
byName(name),
|
||||
selection[name.toLowerCase() as 'firepower'],
|
||||
`${index ? 'Secondary' : 'Primary'} weapon`,
|
||||
);
|
||||
}
|
||||
for (const bonus of item.baseBonuses ?? [])
|
||||
add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
|
||||
for (const roll of selection.rolls)
|
||||
add(
|
||||
roll.statId,
|
||||
roll.value,
|
||||
`${index ? 'Secondary' : 'Primary'} weapon roll`,
|
||||
);
|
||||
for (const talentId of [
|
||||
selection.talent1Id,
|
||||
selection.talent2Id,
|
||||
item.exotic_talent_id,
|
||||
].filter(Boolean)) {
|
||||
equippedTalents.push({
|
||||
key: `weapon:${index}:${talentId}`,
|
||||
id: talentId,
|
||||
rule:
|
||||
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
|
||||
tier: selection.tier,
|
||||
source: index ? 'Secondary weapon' : 'Primary weapon',
|
||||
description: talentDescription(catalog, talentId, selection.tier),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const chipGroups = new Map<
|
||||
string,
|
||||
{ count: number; highEnd: number; item: any }
|
||||
>();
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const selection = draft.chips[index];
|
||||
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
||||
if (!item) continue;
|
||||
add(
|
||||
selection.primary.statId,
|
||||
selection.primary.value,
|
||||
`Skill Chip ${index + 1} primary`,
|
||||
);
|
||||
for (const roll of selection.secondary)
|
||||
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
||||
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
|
||||
group.count += 1;
|
||||
if (selection.quality === 'High-end') group.highEnd += 1;
|
||||
chipGroups.set(item.id, group);
|
||||
}
|
||||
for (const group of chipGroups.values()) {
|
||||
if (group.count >= 2 && group.item.twoPiece) {
|
||||
const value = scaled(
|
||||
group.item.twoPiece.min_value,
|
||||
group.item.twoPiece.max_value,
|
||||
group.highEnd,
|
||||
3,
|
||||
);
|
||||
add(
|
||||
group.item.twoPiece.stat_attribute_id,
|
||||
value,
|
||||
`${group.item.name} 2-piece`,
|
||||
);
|
||||
activeBonuses.push({
|
||||
name: group.item.name,
|
||||
pieces: 2,
|
||||
count: group.count,
|
||||
statId: group.item.twoPiece.stat_attribute_id,
|
||||
value,
|
||||
});
|
||||
}
|
||||
if (group.count === 3 && group.item.talent) {
|
||||
equippedTalents.push({
|
||||
key: `chips:${group.item.id}`,
|
||||
id: '',
|
||||
custom: group.item.talent,
|
||||
rule: group.item.talent.rule ?? null,
|
||||
tier: group.highEnd,
|
||||
abilityId: group.item.specialization_ability_id ?? null,
|
||||
source: '3 Skill Chips',
|
||||
description: group.item.talent.description_template.replace(
|
||||
/\{(\d+)\}/g,
|
||||
(placeholder: string, raw: string) => {
|
||||
const effect = group.item.talent.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(
|
||||
catalog,
|
||||
scaled(effect.min_value, effect.max_value, group.highEnd, 3),
|
||||
effect.stat_attribute_id,
|
||||
)
|
||||
: placeholder;
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { node, rank } of selectedNodeRanks(draft, catalog)) {
|
||||
for (const effect of rank?.effects ?? []) {
|
||||
if (effect.effect_type === 'Attribute')
|
||||
add(
|
||||
effect.stat_attribute_id,
|
||||
effect.value,
|
||||
`${node.name} rank ${rank.rank}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = catalog.protocols.find(
|
||||
(entry) => entry.id === draft.protocol.itemId,
|
||||
);
|
||||
if (protocol) {
|
||||
if (protocol.primaryEffect) {
|
||||
add(
|
||||
protocol.primaryEffect.stat_attribute_id,
|
||||
Number(protocol.primaryEffect.base_value) *
|
||||
(1 + (draft.protocol.level - 1) / 49),
|
||||
`${protocol.name} primary`,
|
||||
);
|
||||
}
|
||||
for (const effect of protocol.secondaryEffects ?? []) {
|
||||
add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`);
|
||||
}
|
||||
if (protocol.talent) {
|
||||
equippedTalents.push({
|
||||
key: `protocol:${protocol.id}`,
|
||||
id: '',
|
||||
custom: protocol.talent,
|
||||
rule: protocol.talent.rule ?? null,
|
||||
tier: draft.protocol.talentUpgrades,
|
||||
source: 'OS Protocol',
|
||||
description: protocol.talent.description_template.replace(
|
||||
/\{(\d+)\}/g,
|
||||
(placeholder: string, raw: string) => {
|
||||
const effect = protocol.talent.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(
|
||||
catalog,
|
||||
Number(effect.base_value) *
|
||||
(1 + draft.protocol.talentUpgrades / 5),
|
||||
effect.stat_attribute_id,
|
||||
)
|
||||
: placeholder;
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stats: [...values]
|
||||
.map(([id, value]) => ({
|
||||
id,
|
||||
value,
|
||||
sourceCount: sources.get(id)?.size ?? 0,
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(attribute(catalog, left.id)?.category ?? '').localeCompare(
|
||||
attribute(catalog, right.id)?.category ?? '',
|
||||
) ||
|
||||
(attribute(catalog, left.id)?.name ?? '').localeCompare(
|
||||
attribute(catalog, right.id)?.name ?? '',
|
||||
),
|
||||
),
|
||||
activeBonuses,
|
||||
equippedTalents,
|
||||
};
|
||||
}
|
||||
|
||||
function calculationStage(
|
||||
catalog: Catalog,
|
||||
@@ -441,7 +77,7 @@ function calculationStage(
|
||||
) {
|
||||
return stageKey as CalculationStage;
|
||||
}
|
||||
const stage = catalog.calculation.stages.find(
|
||||
const stage = calculationCatalog(catalog).stages.find(
|
||||
(item) => item.stage_key === stageKey,
|
||||
);
|
||||
if (!stage) return null;
|
||||
@@ -462,8 +98,8 @@ function calculationBindings(
|
||||
catalog: Catalog,
|
||||
weapon: any,
|
||||
): AttributeBinding[] {
|
||||
return catalog.calculation.profiles
|
||||
.filter(
|
||||
return calculationCatalog(catalog)
|
||||
.profiles.filter(
|
||||
(item) =>
|
||||
item.weapon_category === '*' ||
|
||||
item.weapon_category === weapon?.category,
|
||||
@@ -1211,25 +847,27 @@ function skillCalculationBindings(catalog: Catalog): SkillAttributeBinding[] {
|
||||
'cooldown_recovery',
|
||||
'duration',
|
||||
]);
|
||||
return (catalog.calculation.skillProfiles ?? []).flatMap((profile: any) => {
|
||||
const stage =
|
||||
profile.stage_key === 'engineering'
|
||||
? 'skill_damage'
|
||||
: String(profile.stage_key);
|
||||
return supportedStages.has(stage)
|
||||
? [
|
||||
{
|
||||
attributeId: String(profile.stat_attribute_id),
|
||||
stage: stage as SkillAttributeBinding['stage'],
|
||||
operator:
|
||||
profile.operator === 'multiply'
|
||||
? ('multiply' as const)
|
||||
: ('add' as const),
|
||||
weight: Number(profile.weight ?? 1),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
return (calculationCatalog(catalog).skillProfiles ?? []).flatMap(
|
||||
(profile: any) => {
|
||||
const stage =
|
||||
profile.stage_key === 'engineering'
|
||||
? 'skill_damage'
|
||||
: String(profile.stage_key);
|
||||
return supportedStages.has(stage)
|
||||
? [
|
||||
{
|
||||
attributeId: String(profile.stat_attribute_id),
|
||||
stage: stage as SkillAttributeBinding['stage'],
|
||||
operator:
|
||||
profile.operator === 'multiply'
|
||||
? ('multiply' as const)
|
||||
: ('add' as const),
|
||||
weight: Number(profile.weight ?? 1),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function loadoutSkillAbilities(
|
||||
@@ -1348,7 +986,7 @@ function loadoutSkillDeploymentEvents(
|
||||
Math.max(1, Number(draft.combatProfile.encounterSeconds)),
|
||||
{
|
||||
revisionId: String(
|
||||
catalog.calculation.revision?.id ?? 'mechanic-timeline',
|
||||
calculationCatalog(catalog).revision?.id ?? 'mechanic-timeline',
|
||||
),
|
||||
bindings,
|
||||
},
|
||||
@@ -1539,9 +1177,9 @@ export function evaluateLoadoutCombat(
|
||||
);
|
||||
const required = (weaponIndex: number) => {
|
||||
const missing: string[] = [];
|
||||
if (!catalog.calculation.revision)
|
||||
if (!calculationCatalog(catalog).revision)
|
||||
missing.push('An active ISAC calculation revision');
|
||||
else if (!catalog.calculation.profiles.length)
|
||||
else if (!calculationCatalog(catalog).profiles.length)
|
||||
missing.push('Published attribute calculation profiles');
|
||||
if (!draft.weapons[weaponIndex].itemId)
|
||||
missing.push(`${weaponIndex ? 'Secondary' : 'Primary'} weapon`);
|
||||
@@ -1570,8 +1208,8 @@ export function evaluateLoadoutCombat(
|
||||
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
|
||||
status: 'insufficient',
|
||||
missing,
|
||||
revisionLabel: catalog.calculation.revision
|
||||
? `RULES R${catalog.calculation.revision.version}`
|
||||
revisionLabel: calculationCatalog(catalog).revision
|
||||
? `RULES R${calculationCatalog(catalog).revision.version}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -1660,8 +1298,8 @@ export function evaluateLoadoutCombat(
|
||||
directDamageTotal += outcome.appliedValue;
|
||||
}
|
||||
}
|
||||
const calculationStages = catalog.calculation.stages
|
||||
.map((stage) => calculationStage(catalog, String(stage.stage_key)))
|
||||
const calculationStages = calculationCatalog(catalog)
|
||||
.stages.map((stage) => calculationStage(catalog, String(stage.stage_key)))
|
||||
.filter((stage): stage is CalculationStage => stage !== null);
|
||||
const result = calculateCombat({
|
||||
weapon: {
|
||||
@@ -1693,7 +1331,7 @@ export function evaluateLoadoutCombat(
|
||||
? `Calculation profile for ${attribute(catalog, item.slice(8))?.name ?? item.slice(8)}`
|
||||
: item.replaceAll('.', ' '),
|
||||
),
|
||||
revisionLabel: `RULES R${catalog.calculation.revision.version}`,
|
||||
revisionLabel: `RULES R${calculationCatalog(catalog).revision.version}`,
|
||||
};
|
||||
}
|
||||
const timeline = buildWeaponMechanicTimeline(
|
||||
@@ -1754,7 +1392,7 @@ export function evaluateLoadoutCombat(
|
||||
mode: source.state.mode,
|
||||
...mechanicSummary(source.evaluation),
|
||||
})),
|
||||
revisionLabel: `RULES R${catalog.calculation.revision.version}`,
|
||||
revisionLabel: `RULES R${calculationCatalog(catalog).revision.version}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1764,7 +1402,7 @@ export function evaluateLoadoutSkillCombat(
|
||||
catalog: Catalog,
|
||||
summary = summarizeLoadout(draft, catalog),
|
||||
): SkillCalculation {
|
||||
const revisionId = String(catalog.calculation.revision?.id ?? '');
|
||||
const revisionId = String(calculationCatalog(catalog).revision?.id ?? '');
|
||||
const bindings = skillCalculationBindings(catalog);
|
||||
const boundIds = new Set(bindings.map((binding) => binding.attributeId));
|
||||
const attributes = Object.fromEntries(
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createDraft } from './loadout.js';
|
||||
import { summarizeLoadout, type LoadoutCatalog } from './loadout-summary.js';
|
||||
|
||||
const attribute = (
|
||||
id: string,
|
||||
name: string,
|
||||
options: {
|
||||
unit?: string;
|
||||
calculationType?: string;
|
||||
cap?: number | null;
|
||||
} = {},
|
||||
) => ({
|
||||
id,
|
||||
name,
|
||||
category: 'Combat',
|
||||
unit: options.unit ?? '',
|
||||
calculation_type: options.calculationType ?? 'Additive',
|
||||
cap_value: options.cap ?? null,
|
||||
});
|
||||
|
||||
const catalog = (overrides: Partial<LoadoutCatalog> = {}): LoadoutCatalog => ({
|
||||
attributes: [],
|
||||
gear: [],
|
||||
gearAugments: [],
|
||||
brands: [],
|
||||
weapons: [],
|
||||
talents: [],
|
||||
chipSets: [],
|
||||
protocols: [],
|
||||
specializations: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const stat = (summary: ReturnType<typeof summarizeLoadout>, id: string) =>
|
||||
summary.stats.find((entry) => entry.id === id);
|
||||
|
||||
describe('summarizeLoadout', () => {
|
||||
it('returns an empty deterministic summary for a partial draft', () => {
|
||||
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
|
||||
stats: [],
|
||||
activeBonuses: [],
|
||||
equippedTalents: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores saved rolls that do not reference a known Attribute', () => {
|
||||
const draft = createDraft();
|
||||
draft.weapons[0].itemId = 'weapon';
|
||||
draft.weapons[0].rolls[0] = { statId: 'unknown', value: 999 };
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
weapons: [
|
||||
{
|
||||
id: 'weapon',
|
||||
name: 'Test Weapon',
|
||||
exotic_talent_id: null,
|
||||
baseBonuses: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.stats).toEqual([]);
|
||||
});
|
||||
|
||||
it('combines additive and multiplicative Attributes, applies caps, and counts distinct sources', () => {
|
||||
const draft = createDraft();
|
||||
draft.weapons[0].itemId = 'weapon';
|
||||
draft.weapons[0].rolls = [
|
||||
{ statId: 'additive', value: 5 },
|
||||
{ statId: 'multiplicative', value: 20 },
|
||||
];
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('additive', 'Additive'),
|
||||
attribute('multiplicative', 'Multiplicative', {
|
||||
unit: '%',
|
||||
calculationType: 'Multiplicative',
|
||||
}),
|
||||
attribute('capped', 'Capped', { cap: 25 }),
|
||||
],
|
||||
weapons: [
|
||||
{
|
||||
id: 'weapon',
|
||||
name: 'Test Weapon',
|
||||
exotic_talent_id: null,
|
||||
baseBonuses: [
|
||||
{ stat_attribute_id: 'additive', value: 10 },
|
||||
{ stat_attribute_id: 'multiplicative', value: 10 },
|
||||
{ stat_attribute_id: 'capped', value: 30 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'additive')).toEqual({
|
||||
id: 'additive',
|
||||
value: 15,
|
||||
sourceCount: 2,
|
||||
});
|
||||
expect(stat(summary, 'multiplicative')?.value).toBeCloseTo(32);
|
||||
expect(stat(summary, 'multiplicative')?.sourceCount).toBe(2);
|
||||
expect(stat(summary, 'capped')).toEqual({
|
||||
id: 'capped',
|
||||
value: 25,
|
||||
sourceCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('activates every earned set bonus and records it as a stat source', () => {
|
||||
const draft = createDraft();
|
||||
for (const slot of ['Holster', 'Gloves'] as const) {
|
||||
draft.gear[slot].itemId = `${slot}-gear`;
|
||||
draft.gear[slot].brandId = 'brand';
|
||||
}
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [attribute('set-stat', 'Set Stat', { unit: '%' })],
|
||||
gear: [
|
||||
{ id: 'Holster-gear', tier: 0, base_armor_min: null },
|
||||
{ id: 'Gloves-gear', tier: 0, base_armor_min: null },
|
||||
],
|
||||
brands: [
|
||||
{
|
||||
id: 'brand',
|
||||
name: 'Determinist',
|
||||
effects: [
|
||||
{
|
||||
pieces_required: 1,
|
||||
stat_attribute_id: 'set-stat',
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
pieces_required: 2,
|
||||
stat_attribute_id: 'set-stat',
|
||||
value: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'set-stat')).toEqual({
|
||||
id: 'set-stat',
|
||||
value: 15,
|
||||
sourceCount: 2,
|
||||
});
|
||||
expect(summary.activeBonuses).toEqual([
|
||||
expect.objectContaining({ pieces: 1, count: 2, value: 5 }),
|
||||
expect.objectContaining({ pieces: 2, count: 2, value: 10 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('interpolates Skill Chip bonuses and Talent descriptions from High-end count', () => {
|
||||
const draft = createDraft();
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
draft.chips[index].setId = 'chip-set';
|
||||
draft.chips[index].quality = index === 0 ? 'High-end' : 'Superior';
|
||||
}
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [attribute('skill-damage', 'Skill Damage', { unit: '%' })],
|
||||
chipSets: [
|
||||
{
|
||||
id: 'chip-set',
|
||||
name: 'Signal',
|
||||
specialization_ability_id: 'ability',
|
||||
twoPiece: {
|
||||
stat_attribute_id: 'skill-damage',
|
||||
min_value: 10,
|
||||
max_value: 40,
|
||||
},
|
||||
talent: {
|
||||
name: 'Signal Talent',
|
||||
description_template: 'Gain {1} Skill Damage.',
|
||||
effects: [
|
||||
{
|
||||
stat_attribute_id: 'skill-damage',
|
||||
min_value: 15,
|
||||
max_value: 45,
|
||||
},
|
||||
],
|
||||
rule: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'skill-damage')).toEqual({
|
||||
id: 'skill-damage',
|
||||
value: 20,
|
||||
sourceCount: 1,
|
||||
});
|
||||
expect(summary.equippedTalents).toEqual([
|
||||
expect.objectContaining({
|
||||
key: 'chips:chip-set',
|
||||
tier: 1,
|
||||
abilityId: 'ability',
|
||||
description: 'Gain 25% Skill Damage.',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('scales Protocol primary stats and Talent descriptions independently', () => {
|
||||
const draft = createDraft();
|
||||
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('health', 'Health'),
|
||||
attribute('healing', 'Healing', { unit: '%' }),
|
||||
],
|
||||
protocols: [
|
||||
{
|
||||
id: 'protocol',
|
||||
name: 'Survival',
|
||||
primaryEffect: {
|
||||
stat_attribute_id: 'health',
|
||||
base_value: 100,
|
||||
},
|
||||
secondaryEffects: [{ stat_attribute_id: 'healing', value: 5 }],
|
||||
talent: {
|
||||
name: 'Recovery',
|
||||
description_template: 'Restore {1}.',
|
||||
effects: [{ stat_attribute_id: 'healing', base_value: 2.5 }],
|
||||
rule: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'health')?.value).toBe(200);
|
||||
expect(stat(summary, 'healing')?.value).toBe(5);
|
||||
expect(summary.equippedTalents[0]).toMatchObject({
|
||||
key: 'protocol:protocol',
|
||||
tier: 5,
|
||||
description: 'Restore 5%.',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies static specialization rank effects but excludes combat mechanic outcomes', () => {
|
||||
const draft = createDraft();
|
||||
draft.specialization.focusId = 'focus';
|
||||
draft.specialization.nodeRanks.node = 2;
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('static', 'Static'),
|
||||
attribute('mechanic', 'Mechanic'),
|
||||
],
|
||||
specializations: [
|
||||
{
|
||||
id: 'specialization',
|
||||
focuses: [
|
||||
{
|
||||
id: 'focus',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node',
|
||||
name: 'Steady Hand',
|
||||
ranks: [
|
||||
{
|
||||
rank: 2,
|
||||
effects: [
|
||||
{
|
||||
effect_type: 'Attribute',
|
||||
stat_attribute_id: 'static',
|
||||
value: 12,
|
||||
},
|
||||
],
|
||||
combatEffects: [
|
||||
{
|
||||
rule: {
|
||||
clauses: [
|
||||
{
|
||||
outcomes: [
|
||||
{
|
||||
kind: 'attribute_modifier',
|
||||
attributeId: 'mechanic',
|
||||
value: 99,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'static')).toEqual({
|
||||
id: 'static',
|
||||
value: 12,
|
||||
sourceCount: 1,
|
||||
});
|
||||
expect(stat(summary, 'mechanic')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders the selected Talent tier without applying its mechanic outcomes to stats', () => {
|
||||
const draft = createDraft();
|
||||
draft.weapons[0].itemId = 'weapon';
|
||||
draft.weapons[0].tier = 2;
|
||||
draft.weapons[0].talent1Id = 'talent';
|
||||
|
||||
const rule = {
|
||||
clauses: [
|
||||
{
|
||||
outcomes: [
|
||||
{
|
||||
kind: 'attribute_modifier',
|
||||
attributeId: 'talent-stat',
|
||||
value: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('talent-stat', 'Talent Stat', { unit: '%' }),
|
||||
attribute('shots', 'Shots', { unit: 'Shots' }),
|
||||
],
|
||||
weapons: [
|
||||
{
|
||||
id: 'weapon',
|
||||
name: 'Test Weapon',
|
||||
exotic_talent_id: null,
|
||||
baseBonuses: [],
|
||||
},
|
||||
],
|
||||
talents: [
|
||||
{
|
||||
id: 'talent',
|
||||
name: 'Measured',
|
||||
template: 'Gain {1} for {2}.',
|
||||
tiers: [
|
||||
{
|
||||
tier: 'I',
|
||||
effects: [
|
||||
{ stat_attribute_id: 'talent-stat', value: 10 },
|
||||
{ stat_attribute_id: 'shots', value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
tier: 'II',
|
||||
effects: [
|
||||
{ stat_attribute_id: 'talent-stat', value: 20 },
|
||||
{ stat_attribute_id: 'shots', value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
rule,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.equippedTalents).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'talent',
|
||||
tier: 2,
|
||||
description: 'Gain 20% for 3 Shots.',
|
||||
rule,
|
||||
}),
|
||||
]);
|
||||
expect(stat(summary, 'talent-stat')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the authored tier description when a Talent has no template row', () => {
|
||||
const draft = createDraft();
|
||||
draft.weapons[0].itemId = 'weapon';
|
||||
draft.weapons[0].talent1Id = 'talent';
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
|
||||
weapons: [
|
||||
{
|
||||
id: 'weapon',
|
||||
name: 'Test Weapon',
|
||||
exotic_talent_id: null,
|
||||
baseBonuses: [],
|
||||
},
|
||||
],
|
||||
talents: [
|
||||
{
|
||||
id: 'talent',
|
||||
name: 'Measured',
|
||||
template: '',
|
||||
tiers: [
|
||||
{
|
||||
tier: 'I',
|
||||
description: 'Gain {1} while active.',
|
||||
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.equippedTalents[0]?.description).toBe(
|
||||
'Gain 10% while active.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
|
||||
export type LoadoutCatalog = {
|
||||
attributes: any[];
|
||||
gear: any[];
|
||||
gearAugments: any[];
|
||||
brands: any[];
|
||||
weapons: any[];
|
||||
talents: any[];
|
||||
chipSets: any[];
|
||||
protocols: any[];
|
||||
specializations: any[];
|
||||
};
|
||||
|
||||
export type LoadoutSummary = {
|
||||
stats: Array<{ id: string; value: number; sourceCount: number }>;
|
||||
activeBonuses: any[];
|
||||
equippedTalents: any[];
|
||||
};
|
||||
|
||||
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
|
||||
|
||||
export function selectedFocus(draft: LoadoutDraft, catalog: LoadoutCatalog) {
|
||||
return catalog.specializations
|
||||
.flatMap((specialization) =>
|
||||
specialization.focuses.map((focus: any) => ({
|
||||
...focus,
|
||||
specialization,
|
||||
})),
|
||||
)
|
||||
.find((focus: any) => focus.id === draft.specialization.focusId);
|
||||
}
|
||||
|
||||
export function selectedNodeRanks(
|
||||
draft: LoadoutDraft,
|
||||
catalog: LoadoutCatalog,
|
||||
) {
|
||||
const focus = selectedFocus(draft, catalog);
|
||||
return (
|
||||
focus?.nodes.flatMap((node: any) => {
|
||||
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
|
||||
return selectedRank
|
||||
? [
|
||||
{
|
||||
node,
|
||||
rank: node.ranks.find(
|
||||
(entry: any) => Number(entry.rank) === selectedRank,
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
export function catalogAttribute(catalog: LoadoutCatalog, id: string) {
|
||||
return catalog.attributes.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
export function applyAttributeValue(
|
||||
catalog: LoadoutCatalog,
|
||||
current: number,
|
||||
statId: string,
|
||||
incoming: number,
|
||||
) {
|
||||
const stat = catalogAttribute(catalog, statId);
|
||||
const combined =
|
||||
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
||||
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
|
||||
: current + incoming;
|
||||
return stat?.cap_value === null || stat?.cap_value === undefined
|
||||
? combined
|
||||
: Math.min(combined, Number(stat.cap_value));
|
||||
}
|
||||
|
||||
function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
|
||||
const numeric = Number(value);
|
||||
const rendered = Number.isInteger(numeric)
|
||||
? String(numeric)
|
||||
: numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||
const unit = catalogAttribute(catalog, statId)?.unit ?? '';
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||
}
|
||||
|
||||
function talentTier(catalog: LoadoutCatalog, talentId: string, tier: number) {
|
||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||
return (
|
||||
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
|
||||
item?.tiers[0]
|
||||
);
|
||||
}
|
||||
|
||||
function talentDescription(
|
||||
catalog: LoadoutCatalog,
|
||||
talentId: string,
|
||||
tier: number,
|
||||
) {
|
||||
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||
const configured = talentTier(catalog, talentId, tier);
|
||||
if (!item || !configured) return '';
|
||||
const template = String(item.template || configured.description || item.name);
|
||||
return template.replace(/\{(\d+)\}/g, (placeholder, raw) => {
|
||||
const effect = configured.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(catalog, effect.value, effect.stat_attribute_id)
|
||||
: placeholder;
|
||||
});
|
||||
}
|
||||
|
||||
export function summarizeLoadout(
|
||||
draft: LoadoutDraft,
|
||||
catalog: LoadoutCatalog,
|
||||
): LoadoutSummary {
|
||||
const values = new Map<string, number>();
|
||||
const sources = new Map<string, Set<string>>();
|
||||
const add = (statId: string, value: unknown, source: string) => {
|
||||
if (
|
||||
!statId ||
|
||||
!catalogAttribute(catalog, statId) ||
|
||||
!Number.isFinite(Number(value))
|
||||
)
|
||||
return;
|
||||
values.set(
|
||||
statId,
|
||||
applyAttributeValue(
|
||||
catalog,
|
||||
values.get(statId) ?? 0,
|
||||
statId,
|
||||
Number(value),
|
||||
),
|
||||
);
|
||||
if (!sources.has(statId)) sources.set(statId, new Set());
|
||||
sources.get(statId)!.add(source);
|
||||
};
|
||||
const byName = (name: string) =>
|
||||
catalog.attributes.find(
|
||||
(item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase(),
|
||||
)?.id ?? '';
|
||||
const brandCounts = new Map<string, number>();
|
||||
const activeBonuses: any[] = [];
|
||||
const equippedTalents: any[] = [];
|
||||
|
||||
for (const slot of gearSlots) {
|
||||
const selection = draft.gear[slot];
|
||||
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
|
||||
if (!item) continue;
|
||||
const enabledAugments = catalog.gearAugments.filter(
|
||||
(entry) =>
|
||||
entry.gear_slot === slot &&
|
||||
selection.augmentSlots.includes(Number(entry.augment_slot)),
|
||||
);
|
||||
const basePercentage = enabledAugments
|
||||
.filter((entry) => entry.effect_type === 'Base Percentage')
|
||||
.reduce((total, entry) => total + Number(entry.value), 0);
|
||||
add(
|
||||
byName('Health'),
|
||||
selection.health * (1 + basePercentage / 100),
|
||||
`${slot} base`,
|
||||
);
|
||||
if (item.base_armor_min !== null)
|
||||
add(
|
||||
byName('Armor'),
|
||||
selection.armor * (1 + basePercentage / 100),
|
||||
`${slot} base`,
|
||||
);
|
||||
for (const roll of selection.rolls)
|
||||
add(roll.statId, roll.value, `${slot} roll`);
|
||||
for (const key of coreKeys)
|
||||
add(byName(key), selection[key], `${slot} core`);
|
||||
if (selection.brandId)
|
||||
brandCounts.set(
|
||||
selection.brandId,
|
||||
(brandCounts.get(selection.brandId) ?? 0) + 1,
|
||||
);
|
||||
for (const augment of enabledAugments.filter(
|
||||
(entry) => entry.effect_type === 'Custom',
|
||||
)) {
|
||||
add(
|
||||
augment.stat_attribute_id,
|
||||
augment.value,
|
||||
`${slot} augment ${augment.augment_slot}`,
|
||||
);
|
||||
}
|
||||
if (selection.talentId) {
|
||||
equippedTalents.push({
|
||||
key: `gear:${slot}:${selection.talentId}`,
|
||||
id: selection.talentId,
|
||||
rule:
|
||||
catalog.talents.find((entry) => entry.id === selection.talentId)
|
||||
?.rule ?? null,
|
||||
tier: Number(item.tier),
|
||||
source: slot,
|
||||
description: talentDescription(
|
||||
catalog,
|
||||
selection.talentId,
|
||||
Number(item.tier),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [brandId, count] of brandCounts) {
|
||||
const set = catalog.brands.find((entry) => entry.id === brandId);
|
||||
for (const effect of set?.effects.filter(
|
||||
(entry: any) => Number(entry.pieces_required) <= count,
|
||||
) ?? []) {
|
||||
add(
|
||||
effect.stat_attribute_id,
|
||||
effect.value,
|
||||
`${set.name} ${effect.pieces_required}-piece`,
|
||||
);
|
||||
activeBonuses.push({
|
||||
name: set.name,
|
||||
pieces: effect.pieces_required,
|
||||
count,
|
||||
statId: effect.stat_attribute_id,
|
||||
value: effect.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const selection = draft.weapons[index];
|
||||
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
|
||||
if (!item) continue;
|
||||
for (const name of ['Firepower', 'Toughness', 'Engineering'] as const) {
|
||||
add(
|
||||
byName(name),
|
||||
selection[name.toLowerCase() as 'firepower'],
|
||||
`${index ? 'Secondary' : 'Primary'} weapon`,
|
||||
);
|
||||
}
|
||||
for (const bonus of item.baseBonuses ?? [])
|
||||
add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
|
||||
for (const roll of selection.rolls)
|
||||
add(
|
||||
roll.statId,
|
||||
roll.value,
|
||||
`${index ? 'Secondary' : 'Primary'} weapon roll`,
|
||||
);
|
||||
for (const talentId of [
|
||||
selection.talent1Id,
|
||||
selection.talent2Id,
|
||||
item.exotic_talent_id,
|
||||
].filter(Boolean)) {
|
||||
equippedTalents.push({
|
||||
key: `weapon:${index}:${talentId}`,
|
||||
id: talentId,
|
||||
rule:
|
||||
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
|
||||
tier: selection.tier,
|
||||
source: index ? 'Secondary weapon' : 'Primary weapon',
|
||||
description: talentDescription(catalog, talentId, selection.tier),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const chipGroups = new Map<
|
||||
string,
|
||||
{ count: number; highEnd: number; item: any }
|
||||
>();
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const selection = draft.chips[index];
|
||||
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
||||
if (!item) continue;
|
||||
add(
|
||||
selection.primary.statId,
|
||||
selection.primary.value,
|
||||
`Skill Chip ${index + 1} primary`,
|
||||
);
|
||||
for (const roll of selection.secondary)
|
||||
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
||||
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
|
||||
group.count += 1;
|
||||
if (selection.quality === 'High-end') group.highEnd += 1;
|
||||
chipGroups.set(item.id, group);
|
||||
}
|
||||
for (const group of chipGroups.values()) {
|
||||
if (group.count >= 2 && group.item.twoPiece) {
|
||||
const value = scaled(
|
||||
group.item.twoPiece.min_value,
|
||||
group.item.twoPiece.max_value,
|
||||
group.highEnd,
|
||||
3,
|
||||
);
|
||||
add(
|
||||
group.item.twoPiece.stat_attribute_id,
|
||||
value,
|
||||
`${group.item.name} 2-piece`,
|
||||
);
|
||||
activeBonuses.push({
|
||||
name: group.item.name,
|
||||
pieces: 2,
|
||||
count: group.count,
|
||||
statId: group.item.twoPiece.stat_attribute_id,
|
||||
value,
|
||||
});
|
||||
}
|
||||
if (group.count === 3 && group.item.talent) {
|
||||
equippedTalents.push({
|
||||
key: `chips:${group.item.id}`,
|
||||
id: '',
|
||||
custom: group.item.talent,
|
||||
rule: group.item.talent.rule ?? null,
|
||||
tier: group.highEnd,
|
||||
abilityId: group.item.specialization_ability_id ?? null,
|
||||
source: '3 Skill Chips',
|
||||
description: group.item.talent.description_template.replace(
|
||||
/\{(\d+)\}/g,
|
||||
(placeholder: string, raw: string) => {
|
||||
const effect = group.item.talent.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(
|
||||
catalog,
|
||||
scaled(effect.min_value, effect.max_value, group.highEnd, 3),
|
||||
effect.stat_attribute_id,
|
||||
)
|
||||
: placeholder;
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { node, rank } of selectedNodeRanks(draft, catalog)) {
|
||||
for (const effect of rank?.effects ?? []) {
|
||||
if (effect.effect_type === 'Attribute')
|
||||
add(
|
||||
effect.stat_attribute_id,
|
||||
effect.value,
|
||||
`${node.name} rank ${rank.rank}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = catalog.protocols.find(
|
||||
(entry) => entry.id === draft.protocol.itemId,
|
||||
);
|
||||
if (protocol) {
|
||||
if (protocol.primaryEffect) {
|
||||
add(
|
||||
protocol.primaryEffect.stat_attribute_id,
|
||||
Number(protocol.primaryEffect.base_value) *
|
||||
(1 + (draft.protocol.level - 1) / 49),
|
||||
`${protocol.name} primary`,
|
||||
);
|
||||
}
|
||||
for (const effect of protocol.secondaryEffects ?? []) {
|
||||
add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`);
|
||||
}
|
||||
if (protocol.talent) {
|
||||
equippedTalents.push({
|
||||
key: `protocol:${protocol.id}`,
|
||||
id: '',
|
||||
custom: protocol.talent,
|
||||
rule: protocol.talent.rule ?? null,
|
||||
tier: draft.protocol.talentUpgrades,
|
||||
source: 'OS Protocol',
|
||||
description: protocol.talent.description_template.replace(
|
||||
/\{(\d+)\}/g,
|
||||
(placeholder: string, raw: string) => {
|
||||
const effect = protocol.talent.effects[Number(raw) - 1];
|
||||
return effect
|
||||
? unitValue(
|
||||
catalog,
|
||||
Number(effect.base_value) *
|
||||
(1 + draft.protocol.talentUpgrades / 5),
|
||||
effect.stat_attribute_id,
|
||||
)
|
||||
: placeholder;
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stats: [...values]
|
||||
.map(([id, value]) => ({
|
||||
id,
|
||||
value,
|
||||
sourceCount: sources.get(id)?.size ?? 0,
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
|
||||
catalogAttribute(catalog, right.id)?.category ?? '',
|
||||
) ||
|
||||
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
|
||||
catalogAttribute(catalog, right.id)?.name ?? '',
|
||||
),
|
||||
),
|
||||
activeBonuses,
|
||||
equippedTalents,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import type { LoadoutSummary } from '$lib/builder/loadout-summary';
|
||||
|
||||
let {
|
||||
summary,
|
||||
attributes,
|
||||
talents = [],
|
||||
headingId,
|
||||
layout = 'rail'
|
||||
}: {
|
||||
summary: LoadoutSummary;
|
||||
attributes: any[];
|
||||
talents?: any[];
|
||||
headingId: string;
|
||||
layout?: 'rail' | 'page';
|
||||
} = $props();
|
||||
|
||||
const attribute = (id: string) => attributes.find((item) => item.id === id);
|
||||
const talent = (id: string) => talents.find((item) => item.id === id);
|
||||
const unitValue = (value: unknown, statId: string) => {
|
||||
const numeric = Number(value);
|
||||
const rendered = Number.isInteger(numeric)
|
||||
? numeric.toLocaleString()
|
||||
: numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||
const unit = attribute(statId)?.unit ?? '';
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||
};
|
||||
const talentCooldown = (id: string) => {
|
||||
const value = talent(id)?.cooldown_seconds;
|
||||
if (value === null || value === undefined || value === '') return '';
|
||||
return `Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
|
||||
<div class="summary-head">
|
||||
<div>
|
||||
<p class="eyebrow"><span></span> KNOWN EQUIPMENT TOTALS</p>
|
||||
<h2 id={headingId}>Agent stats</h2>
|
||||
</div>
|
||||
<b>{summary.stats.length}</b>
|
||||
</div>
|
||||
<div class="summary-core">
|
||||
{#each ['Firepower', 'Toughness', 'Engineering'] as name}
|
||||
{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}
|
||||
<div>
|
||||
<GameIcon name={name.toLowerCase() as GameIconName} decorative />
|
||||
<span>{name}<b>{stat ? unitValue(stat.value, stat.id) : '0'}</b></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="summary-scroll">
|
||||
<div class="summary-section">
|
||||
<header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>
|
||||
{#each summary.stats as stat}
|
||||
<div class="summary-stat">
|
||||
<span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span>
|
||||
<b>{unitValue(stat.value, stat.id)}</b>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Equip and configure items to calculate their known Attributes.</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="summary-section">
|
||||
<header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
|
||||
{#each summary.activeBonuses as bonus}
|
||||
<div class="summary-bonus">
|
||||
<span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span>
|
||||
<em>+{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}</em>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="summary-section">
|
||||
<header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>
|
||||
{#each summary.equippedTalents as equipped}
|
||||
<div class="summary-talent">
|
||||
<span>
|
||||
<b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b>
|
||||
<small>{equipped.source} · CONDITIONAL EFFECT NOT INCLUDED</small>
|
||||
<em>{equipped.description}</em>
|
||||
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Selected equipment Talents appear here.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<p class="known-stats-note">Totals reflect this saved loadout and the current canonical item definitions. They include fixed values, rolls, inherited profiles, enabled augments, set bonuses, Protocol effects, and static Specialization rank effects. Conditional Talent outcomes are reserved for the advanced combat model.</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.loadout-known-stats{background:var(--shd-surface)}
|
||||
.summary-talent{grid-template-columns:1fr;cursor:default}
|
||||
.known-stats-note{margin:0;padding:11px;border-top:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.45}
|
||||
.page{border:1px solid var(--shd-border);border-top:2px solid var(--shd-success)}
|
||||
.page .summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.page .summary-section{border-left:1px solid var(--shd-border)}
|
||||
.page .summary-section:first-child{border-left:0}
|
||||
@media(max-width:900px){.page .summary-scroll{grid-template-columns:1fr}.page .summary-section{border-left:0;border-top:1px solid var(--shd-border)}.page .summary-section:first-child{border-top:0}}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
headingId,
|
||||
variant = 'full',
|
||||
headingLevel = 'h3'
|
||||
}: {
|
||||
headingId: string;
|
||||
variant?: 'full' | 'compact';
|
||||
headingLevel?: 'h2' | 'h3';
|
||||
} = $props();
|
||||
|
||||
const capabilities = [
|
||||
{ label: 'WEAPON OUTPUT', detail: 'Hit values, burst DPS, sustained DPS, and reload-aware firing cycles' },
|
||||
{ label: 'SKILL SIMULATION', detail: 'Ability cadence, target counts, critical hits, and multi-shot behavior' },
|
||||
{ label: 'ENCOUNTER MODEL', detail: 'Duration, accuracy, movement, target flow, rotations, and downtime' },
|
||||
{ label: 'CONDITIONAL EFFECTS', detail: 'Talent triggers, stack buildup, cooldowns, uptime, and resource changes' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
|
||||
<header>
|
||||
<div><span>ADVANCED COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
|
||||
<b>COMING SOON</b>
|
||||
</header>
|
||||
{#if variant === 'full'}
|
||||
<p>Known equipment totals remain available now. Advanced damage projections will return after their calculation rules and scenario controls are ready for player use.</p>
|
||||
{/if}
|
||||
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
|
||||
{#each capabilities as capability}
|
||||
<div><span><strong>{capability.label}</strong><small>{capability.detail}</small></span><em>PLANNED</em></div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.planned-combat-model{min-width:0;display:grid;grid-template-columns:minmax(0,1fr);gap:var(--shd-gap-md);padding:var(--shd-gap-md);overflow:hidden;border:1px solid var(--shd-model-border);border-top:2px solid var(--shd-info);background:var(--shd-model-surface)}
|
||||
.planned-combat-model>*{min-width:0}
|
||||
header{display:flex;align-items:start;justify-content:space-between;gap:var(--shd-gap-md);padding:0}
|
||||
header div{display:grid;gap:3px}header span{color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}h2,h3{margin:0;color:var(--shd-text);font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header b{padding:5px 7px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}
|
||||
p{max-width:72ch;margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.5}
|
||||
.planned-capabilities{width:100%;min-width:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid var(--shd-model-border)}
|
||||
.planned-capabilities>div{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);min-width:0;padding:9px;border-top:1px solid var(--shd-model-border);border-left:1px solid var(--shd-model-border)}
|
||||
.planned-capabilities>div:nth-child(-n+2){border-top:0}.planned-capabilities>div:nth-child(odd){border-left:0}
|
||||
.planned-capabilities span{display:grid;gap:3px;min-width:0}.planned-capabilities strong,.planned-capabilities em{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.planned-capabilities small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.planned-capabilities em{color:var(--shd-text-dim);font-style:normal;white-space:nowrap}
|
||||
.compact{gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}.compact .planned-capabilities{grid-template-columns:minmax(0,1fr)}.compact .planned-capabilities>div{border-left:0}.compact .planned-capabilities>div:nth-child(2){border-top:1px solid var(--shd-model-border)}
|
||||
@media(max-width:620px){header{display:grid}.planned-capabilities{grid-template-columns:1fr}.planned-capabilities>div{min-height:44px;border-left:0}.planned-capabilities>div:nth-child(2){border-top:1px solid var(--shd-model-border)}}
|
||||
</style>
|
||||
@@ -793,7 +793,7 @@
|
||||
|
||||
<section class="scenario-panel">
|
||||
<header><div><span>BUILDER CONTROLS</span><strong>SCENARIO ASSUMPTIONS</strong></div><button type="button" class="secondary compact-action" onclick={addScenarioInput}>ADD CONTROL +</button></header>
|
||||
<p>These controls appear in a build’s advanced mechanic settings. Maximum Potential uses the best-case value; Custom Scenario uses the Agent’s saved value.</p>
|
||||
<p>These controls are retained for the planned advanced build model. Maximum Potential will use the best-case value; Custom Scenario will use the Agent’s saved value.</p>
|
||||
{#each rule.scenarioInputs as input, index}
|
||||
<div class="scenario-row">
|
||||
<label class="field">LABEL<input value={input.label} oninput={(event) => updateScenarioInput(index,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||
|
||||
export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
const [
|
||||
attributes,
|
||||
gear,
|
||||
gearAugments,
|
||||
brands,
|
||||
brandEffects,
|
||||
weapons,
|
||||
weaponBonuses,
|
||||
talents,
|
||||
talentTiers,
|
||||
talentEffects,
|
||||
talentTemplates,
|
||||
conditionalRules,
|
||||
conditionalPools,
|
||||
conditionalClauses,
|
||||
conditionalOutcomes,
|
||||
chipSets,
|
||||
chipTwoPiece,
|
||||
chipTalents,
|
||||
chipTalentEffects,
|
||||
protocols,
|
||||
protocolPrimary,
|
||||
protocolProfiles,
|
||||
protocolSecondary,
|
||||
protocolTalents,
|
||||
protocolTalentEffects,
|
||||
specializations,
|
||||
specializationFocuses,
|
||||
specializationAbilities,
|
||||
specializationAbilityAttacks,
|
||||
focusAbilities,
|
||||
specializationNodes,
|
||||
specializationRanks,
|
||||
specializationRankEffects,
|
||||
specializationCombatEffects,
|
||||
specializationRankCombatEffects,
|
||||
specializationCombatModifiers,
|
||||
activeCalculationRevision,
|
||||
calculationStages,
|
||||
calculationProfiles,
|
||||
skillCalculationProfiles,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM brand_set_effects ORDER BY pieces_required',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM talent_description_templates')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_rules WHERE configured=1',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_chip_talent_effects ORDER BY sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM os_protocols WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM os_protocol_secondary_profiles')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM os_protocol_talent_effects ORDER BY sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_focuses WHERE active=1 ORDER BY specialization_id,display_order,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_abilities WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM specialization_ability_attacks')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_nodes WHERE active=1 ORDER BY focus_id,row_number,column_number',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1",
|
||||
)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(`SELECT stage.* FROM calculation_stages stage
|
||||
JOIN calculation_revisions revision ON revision.id=stage.revision_id
|
||||
WHERE revision.status='active' ORDER BY stage.display_order`)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile
|
||||
JOIN calculation_revisions revision ON revision.id=profile.revision_id
|
||||
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT profile.* FROM skill_attribute_calculation_profiles profile
|
||||
JOIN calculation_revisions revision ON revision.id=profile.revision_id
|
||||
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
|
||||
).all(),
|
||||
]);
|
||||
|
||||
const hydrateRule = (sourceType: string, sourceId: unknown) => {
|
||||
const rule = conditionalRules.find(
|
||||
(item) => item.source_type === sourceType && item.source_id === sourceId,
|
||||
);
|
||||
return rule
|
||||
? {
|
||||
rule,
|
||||
statePools: conditionalPools.filter(
|
||||
(pool) => pool.rule_id === rule.id,
|
||||
),
|
||||
clauses: conditionalClauses.filter(
|
||||
(clause) => clause.rule_id === rule.id,
|
||||
),
|
||||
outcomes: conditionalOutcomes.filter(
|
||||
(outcome) => outcome.rule_id === rule.id,
|
||||
),
|
||||
triggers: [],
|
||||
}
|
||||
: null;
|
||||
};
|
||||
const configuredTalents = talents.map((talent) => ({
|
||||
...talent,
|
||||
template:
|
||||
talentTemplates.find((item) => item.talent_id === talent.id)?.template ??
|
||||
'',
|
||||
tiers: talentTiers
|
||||
.filter((tier) => tier.talent_id === talent.id)
|
||||
.map((tier) => ({
|
||||
...tier,
|
||||
effects: talentEffects.filter(
|
||||
(effect) => effect.talent_tier_id === tier.id,
|
||||
),
|
||||
})),
|
||||
rule: hydrateRule('talent', talent.id),
|
||||
}));
|
||||
|
||||
return {
|
||||
attributes,
|
||||
gear,
|
||||
gearAugments,
|
||||
brands: brands.map((brand) => ({
|
||||
...brand,
|
||||
effects: brandEffects.filter(
|
||||
(effect) => effect.brand_set_id === brand.id,
|
||||
),
|
||||
})),
|
||||
weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
|
||||
talents: configuredTalents,
|
||||
chipSets: chipSets.map((set) => {
|
||||
const talent = chipTalents.find(
|
||||
(item) => item.skill_chip_set_id === set.id,
|
||||
);
|
||||
return {
|
||||
...set,
|
||||
twoPiece:
|
||||
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
||||
null,
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
effects: chipTalentEffects.filter(
|
||||
(effect) => effect.talent_id === talent.id,
|
||||
),
|
||||
rule: hydrateRule('skill_chip_talent', talent.id),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
protocols: protocols.map((protocol) => {
|
||||
const profile = protocolProfiles.find(
|
||||
(item) =>
|
||||
item.quality === protocol.quality &&
|
||||
item.primary_attribute_type === protocol.primary_attribute_type,
|
||||
);
|
||||
const talent = protocolTalents.find(
|
||||
(item) => item.protocol_id === protocol.id,
|
||||
);
|
||||
return {
|
||||
...protocol,
|
||||
primaryEffect:
|
||||
protocolPrimary.find((item) => item.protocol_id === protocol.id) ??
|
||||
null,
|
||||
secondaryEffects: profile
|
||||
? protocolSecondary.filter(
|
||||
(effect) => effect.profile_id === profile.id,
|
||||
)
|
||||
: [],
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
effects: protocolTalentEffects.filter(
|
||||
(effect) => effect.talent_id === talent.id,
|
||||
),
|
||||
rule: hydrateRule('os_protocol_talent', talent.id),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
specializations: specializations.map((specialization) => ({
|
||||
...specialization,
|
||||
focuses: specializationFocuses
|
||||
.filter((focus) => focus.specialization_id === specialization.id)
|
||||
.map((focus) => {
|
||||
const abilities = focusAbilities
|
||||
.filter((link) => link.focus_id === focus.id)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Number(left.ability_slot) - Number(right.ability_slot),
|
||||
)
|
||||
.map((link) => {
|
||||
const ability = specializationAbilities.find(
|
||||
(entry) => entry.id === link.ability_id,
|
||||
);
|
||||
return ability
|
||||
? {
|
||||
...ability,
|
||||
attack:
|
||||
specializationAbilityAttacks.find(
|
||||
(attack) => attack.ability_id === ability.id,
|
||||
) ?? null,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
const nodes = specializationNodes
|
||||
.filter((node) => node.focus_id === focus.id)
|
||||
.map((node) => ({
|
||||
...node,
|
||||
ranks: specializationRanks
|
||||
.filter((rank) => rank.node_id === node.id)
|
||||
.map((rank) => ({
|
||||
...rank,
|
||||
effects: specializationRankEffects.filter(
|
||||
(effect) =>
|
||||
effect.node_id === node.id &&
|
||||
Number(effect.rank) === Number(rank.rank),
|
||||
),
|
||||
combatEffects: specializationRankCombatEffects
|
||||
.filter(
|
||||
(effect) =>
|
||||
effect.node_id === node.id &&
|
||||
Number(effect.rank) === Number(rank.rank),
|
||||
)
|
||||
.map((effect) => ({
|
||||
...effect,
|
||||
definition: specializationCombatEffects.find(
|
||||
(definition) =>
|
||||
definition.id === effect.combat_effect_id,
|
||||
),
|
||||
modifiers: specializationCombatModifiers.filter(
|
||||
(modifier) =>
|
||||
modifier.rank_combat_effect_id === effect.id,
|
||||
),
|
||||
rule: hydrateRule('specialization_tree_rank', effect.id),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
return { ...focus, abilities, nodes };
|
||||
}),
|
||||
})),
|
||||
calculation: {
|
||||
revision: activeCalculationRevision ?? null,
|
||||
stages: calculationStages,
|
||||
profiles: calculationProfiles,
|
||||
skillProfiles: skillCalculationProfiles,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||
|
||||
export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
const [
|
||||
attributes,
|
||||
gear,
|
||||
gearAugments,
|
||||
brands,
|
||||
brandEffects,
|
||||
weapons,
|
||||
weaponBonuses,
|
||||
talents,
|
||||
talentTiers,
|
||||
talentEffects,
|
||||
talentTemplates,
|
||||
chipSets,
|
||||
chipTwoPiece,
|
||||
chipTalents,
|
||||
chipTalentEffects,
|
||||
protocols,
|
||||
protocolPrimary,
|
||||
protocolProfiles,
|
||||
protocolSecondary,
|
||||
protocolTalents,
|
||||
protocolTalentEffects,
|
||||
specializations,
|
||||
specializationFocuses,
|
||||
specializationNodes,
|
||||
specializationRanks,
|
||||
specializationRankEffects,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM stat_attributes ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear ORDER BY slot,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM brand_sets ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM brand_set_effects ORDER BY pieces_required',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapons ORDER BY category,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM talents ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM talent_description_templates')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_chip_talent_effects ORDER BY sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM os_protocols ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM os_protocol_secondary_profiles')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM os_protocol_talent_effects ORDER BY sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specializations ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
|
||||
return {
|
||||
attributes,
|
||||
gear,
|
||||
gearAugments,
|
||||
brands: brands.map((brand) => ({
|
||||
...brand,
|
||||
effects: brandEffects.filter(
|
||||
(effect) => effect.brand_set_id === brand.id,
|
||||
),
|
||||
})),
|
||||
weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
|
||||
talents: talents.map((talent) => ({
|
||||
...talent,
|
||||
template:
|
||||
talentTemplates.find((item) => item.talent_id === talent.id)
|
||||
?.template ?? '',
|
||||
tiers: talentTiers
|
||||
.filter((tier) => tier.talent_id === talent.id)
|
||||
.map((tier) => ({
|
||||
...tier,
|
||||
effects: talentEffects.filter(
|
||||
(effect) => effect.talent_tier_id === tier.id,
|
||||
),
|
||||
})),
|
||||
})),
|
||||
chipSets: chipSets.map((set) => {
|
||||
const talent = chipTalents.find(
|
||||
(item) => item.skill_chip_set_id === set.id,
|
||||
);
|
||||
return {
|
||||
...set,
|
||||
twoPiece:
|
||||
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
||||
null,
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
effects: chipTalentEffects.filter(
|
||||
(effect) => effect.talent_id === talent.id,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
protocols: protocols.map((protocol) => {
|
||||
const profile = protocolProfiles.find(
|
||||
(item) =>
|
||||
item.quality === protocol.quality &&
|
||||
item.primary_attribute_type === protocol.primary_attribute_type,
|
||||
);
|
||||
const talent = protocolTalents.find(
|
||||
(item) => item.protocol_id === protocol.id,
|
||||
);
|
||||
return {
|
||||
...protocol,
|
||||
primaryEffect:
|
||||
protocolPrimary.find((item) => item.protocol_id === protocol.id) ??
|
||||
null,
|
||||
secondaryEffects: profile
|
||||
? protocolSecondary.filter(
|
||||
(effect) => effect.profile_id === profile.id,
|
||||
)
|
||||
: [],
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
effects: protocolTalentEffects.filter(
|
||||
(effect) => effect.talent_id === talent.id,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
specializations: specializations.map((specialization) => ({
|
||||
...specialization,
|
||||
focuses: specializationFocuses
|
||||
.filter((focus) => focus.specialization_id === specialization.id)
|
||||
.map((focus) => ({
|
||||
...focus,
|
||||
nodes: specializationNodes
|
||||
.filter((node) => node.focus_id === focus.id)
|
||||
.map((node) => ({
|
||||
...node,
|
||||
ranks: specializationRanks
|
||||
.filter((rank) => rank.node_id === node.id)
|
||||
.map((rank) => ({
|
||||
...rank,
|
||||
effects: specializationRankEffects.filter(
|
||||
(effect) =>
|
||||
effect.node_id === node.id &&
|
||||
Number(effect.rank) === Number(rank.rank),
|
||||
),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -230,7 +230,7 @@
|
||||
<header><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>PUBLIC BUILDS</h2></div><span>{data.counts.builds} TOTAL</span></header>
|
||||
<div class="public-content-grid">
|
||||
{#each data.builds as build}
|
||||
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}><span>LOADOUT</span><h3>{build.title}</h3><p>Open this loadout to inspect its equipment, Attributes, and calculated performance.</p><footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer></a>
|
||||
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}><span>LOADOUT</span><h3>{build.title}</h3><p>Open this loadout to inspect its equipment, known Attributes, and active set bonuses.</p><footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer></a>
|
||||
{:else}<div class="public-empty"><span>◇</span><strong>NO PUBLIC BUILDS</strong><p>This Agent has not shared a public loadout.</p></div>{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -67,10 +67,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
talentTiers,
|
||||
talentEffects,
|
||||
talentTemplates,
|
||||
conditionalRules,
|
||||
conditionalPools,
|
||||
conditionalClauses,
|
||||
conditionalOutcomes,
|
||||
chipSets,
|
||||
chipTwoPiece,
|
||||
chipPrimary,
|
||||
@@ -94,15 +90,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
specializationRanks,
|
||||
specializationRankEffects,
|
||||
specializationRankCosts,
|
||||
specializationCombatEffects,
|
||||
specializationRankCombatEffects,
|
||||
specializationCombatModifiers,
|
||||
progression,
|
||||
rankProfiles,
|
||||
activeCalculationRevision,
|
||||
calculationStages,
|
||||
calculationProfiles,
|
||||
skillCalculationProfiles,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
@@ -164,26 +153,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(
|
||||
await database.prepare('SELECT * FROM talent_description_templates')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_rules WHERE configured=1',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
@@ -271,21 +240,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
'SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT * FROM specialization_progression_config WHERE id='default'",
|
||||
@@ -296,26 +250,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
'SELECT * FROM specialization_rank_cost_profiles ORDER BY rank',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1",
|
||||
)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(`SELECT stage.* FROM calculation_stages stage
|
||||
JOIN calculation_revisions revision ON revision.id=stage.revision_id
|
||||
WHERE revision.status='active' ORDER BY stage.display_order`)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile
|
||||
JOIN calculation_revisions revision ON revision.id=profile.revision_id
|
||||
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT profile.* FROM skill_attribute_calculation_profiles profile
|
||||
JOIN calculation_revisions revision ON revision.id=profile.revision_id
|
||||
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
|
||||
).all(),
|
||||
]);
|
||||
const iconUrl = (
|
||||
type:
|
||||
@@ -333,26 +267,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
);
|
||||
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : '';
|
||||
};
|
||||
const hydrateRule = (sourceType: string, sourceId: unknown) => {
|
||||
const rule = conditionalRules.find(
|
||||
(item) => item.source_type === sourceType && item.source_id === sourceId,
|
||||
);
|
||||
return rule
|
||||
? {
|
||||
rule,
|
||||
statePools: conditionalPools.filter(
|
||||
(pool) => pool.rule_id === rule.id,
|
||||
),
|
||||
clauses: conditionalClauses.filter(
|
||||
(clause) => clause.rule_id === rule.id,
|
||||
),
|
||||
outcomes: conditionalOutcomes.filter(
|
||||
(outcome) => outcome.rule_id === rule.id,
|
||||
),
|
||||
triggers: [],
|
||||
}
|
||||
: null;
|
||||
};
|
||||
const configuredTalents = talents.map((talent) => ({
|
||||
...talent,
|
||||
iconUrl: iconUrl('talent', talent.id),
|
||||
@@ -367,7 +281,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(effect) => effect.talent_tier_id === tier.id,
|
||||
),
|
||||
})),
|
||||
rule: hydrateRule('talent', talent.id),
|
||||
}));
|
||||
return {
|
||||
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
|
||||
@@ -436,7 +349,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
effects: chipTalentEffects.filter(
|
||||
(effect) => effect.talent_id === talent.id,
|
||||
),
|
||||
rule: hydrateRule('skill_chip_talent', talent.id),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
@@ -467,7 +379,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
effects: protocolTalentEffects.filter(
|
||||
(effect) => effect.talent_id === talent.id,
|
||||
),
|
||||
rule: hydrateRule('os_protocol_talent', talent.id),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
@@ -520,24 +431,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
cost.node_id === node.id &&
|
||||
Number(cost.rank) === Number(rank.rank),
|
||||
),
|
||||
combatEffects: specializationRankCombatEffects
|
||||
.filter(
|
||||
(effect) =>
|
||||
effect.node_id === node.id &&
|
||||
Number(effect.rank) === Number(rank.rank),
|
||||
)
|
||||
.map((effect) => ({
|
||||
...effect,
|
||||
definition: specializationCombatEffects.find(
|
||||
(definition) =>
|
||||
definition.id === effect.combat_effect_id,
|
||||
),
|
||||
modifiers: specializationCombatModifiers.filter(
|
||||
(modifier) =>
|
||||
modifier.rank_combat_effect_id === effect.id,
|
||||
),
|
||||
rule: hydrateRule('specialization_tree_rank', effect.id),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
return { ...focus, abilities: assignedAbilities, nodes };
|
||||
@@ -550,12 +443,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
Number(progression?.shd_cache_points ?? 19),
|
||||
rankProfiles,
|
||||
},
|
||||
calculation: {
|
||||
revision: activeCalculationRevision,
|
||||
stages: calculationStages,
|
||||
profiles: calculationProfiles,
|
||||
skillProfiles: skillCalculationProfiles,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+12
-217
@@ -8,14 +8,11 @@
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import CombatAssumptions from '$lib/components/builder/CombatAssumptions.svelte';
|
||||
import ConditionalEffectCard from '$lib/components/builder/ConditionalEffectCard.svelte';
|
||||
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
|
||||
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
|
||||
import SkillCalculationResult from '$lib/components/builder/SkillCalculationResult.svelte';
|
||||
import { evaluateLoadoutCombat, evaluateLoadoutSkillCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
|
||||
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
|
||||
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
|
||||
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
||||
import { toast } from '$lib/toast';
|
||||
import { MECHANIC_EVENT_REGISTRY, type MechanicEventKind } from '$lib/calculations/mechanic';
|
||||
import {
|
||||
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
|
||||
type LoadoutDraft, type Roll
|
||||
@@ -54,11 +51,6 @@
|
||||
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
|
||||
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
|
||||
}) ?? []);
|
||||
const selectedCombatEffects = $derived(selectedNodeRanks.flatMap(({ node, rank }: any) =>
|
||||
(rank?.combatEffects ?? [])
|
||||
.filter((effect: any) => Number(effect.rule?.configured) === 1)
|
||||
.map((effect: any) => ({ ...effect, nodeName: node.name }))
|
||||
));
|
||||
const talent = (id: string) => data.talents.find((item) => item.id === id);
|
||||
const brand = (id: string) => data.brands.find((item) => item.id === id);
|
||||
const unitValue = (value: unknown, statId: string) => {
|
||||
@@ -67,69 +59,6 @@
|
||||
const unit = attribute(statId)?.unit ?? '';
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||
};
|
||||
const jsonObject = (value: unknown) => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, any>;
|
||||
if (typeof value !== 'string') return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, any>
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
const mechanicScenarioInputs = (rule: any) => {
|
||||
const raw = rule?.rule?.scenario_inputs_json;
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (typeof raw !== 'string') return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const combatEffectDescription = (effect: any) => {
|
||||
const template = String(effect.definition?.description_template ?? effect.definition?.name ?? 'Conditional effect');
|
||||
const rule = effect.rule;
|
||||
const outcomes = (rule?.outcomes ?? []).filter((outcome: any) => outcome.variant_key === 'base');
|
||||
const firstReducer = jsonObject(rule?.clauses?.[0]?.reducer_json);
|
||||
const primaryPool = rule?.statePools?.[0];
|
||||
return template
|
||||
.replaceAll('{triggerCount}', String(firstReducer.threshold ?? ''))
|
||||
.replaceAll('{triggerWindow}', firstReducer.windowSeconds ? `${firstReducer.windowSeconds}s` : '')
|
||||
.replaceAll('{duration}', String(primaryPool?.duration_seconds ?? 0))
|
||||
.replaceAll('{maxStacks}', String(primaryPool?.maximum_value ?? 1))
|
||||
.replaceAll('{effectName}', String(effect.definition?.name ?? 'Effect'))
|
||||
.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
|
||||
const outcome = outcomes[Number(raw) - 1];
|
||||
return outcome?.stat_attribute_id
|
||||
? unitValue(outcome.value, outcome.stat_attribute_id)
|
||||
: placeholder;
|
||||
});
|
||||
};
|
||||
const conditionalFacts = (rule: any) => {
|
||||
const clauses = rule?.clauses ?? [];
|
||||
const eventLabels = [...new Set(clauses.map((clause: any) => {
|
||||
const event = String(clause.event_kind ?? '') as MechanicEventKind;
|
||||
return MECHANIC_EVENT_REGISTRY[event]?.label;
|
||||
}).filter(Boolean))];
|
||||
const readiness = String(rule?.rule?.simulation_readiness ?? 'captured_only')
|
||||
.replaceAll('_', ' ')
|
||||
.toUpperCase();
|
||||
const cooldowns = clauses
|
||||
.map((clause: any) => Number(clause.cooldown_seconds ?? 0))
|
||||
.filter((value: number) => value > 0);
|
||||
const poolMaximum = Math.max(0, ...(rule?.statePools ?? [])
|
||||
.map((pool: any) => Number(pool.maximum_value ?? 0)));
|
||||
return [
|
||||
`${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'}`,
|
||||
eventLabels.length ? eventLabels.join(' · ').toUpperCase() : 'NO EVENT BRANCHES',
|
||||
poolMaximum > 1 ? `MAX ${poolMaximum} STACKS` : readiness,
|
||||
cooldowns.length ? `${Math.max(...cooldowns)}s COOLDOWN` : 'NO COOLDOWN'
|
||||
];
|
||||
};
|
||||
const scaledRange = (minimum: unknown, maximum: unknown, level: unknown, referenceLevel: unknown, source: 'gear' | 'weapon') =>
|
||||
levelScaledRange(minimum, maximum, level, referenceLevel ?? 50, source);
|
||||
const gearBaseRange = (item: any, key: 'health' | 'armor', level: number) =>
|
||||
@@ -218,20 +147,6 @@
|
||||
};
|
||||
active = 'specialization:focus';
|
||||
};
|
||||
const toggleTalent = (key: string) => {
|
||||
const current = draft.conditionalEffects[key]?.mode ?? 'off';
|
||||
draft.conditionalEffects = {
|
||||
...draft.conditionalEffects,
|
||||
[key]: { mode: current === 'off' ? 'peak' : 'off' }
|
||||
};
|
||||
};
|
||||
const setSkillActivation = (abilityId: string, patch: Record<string, unknown>) => {
|
||||
const current = draft.skillActivations[abilityId] ?? { mode: 'off', targets: 1 };
|
||||
draft.skillActivations = {
|
||||
...draft.skillActivations,
|
||||
[abilityId]: { ...current, ...patch }
|
||||
};
|
||||
};
|
||||
const talentTier = (talentId: string, tier: number) => {
|
||||
const item = talent(talentId);
|
||||
return item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? item?.tiers[0];
|
||||
@@ -346,43 +261,7 @@
|
||||
selection.level = nextLevel;
|
||||
};
|
||||
const activeParts = $derived(active.split(':'));
|
||||
const talentCooldown = (id: string) => {
|
||||
const value = talent(id)?.cooldown_seconds;
|
||||
if (value === null || value === undefined || value === '') return '';
|
||||
return `Talent Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`;
|
||||
};
|
||||
const summary = $derived(summarizeLoadout(draft, data));
|
||||
const modeledTalents = $derived(summary.equippedTalents.filter(
|
||||
(equipped: any) => Number(equipped.rule?.rule?.configured) === 1
|
||||
&& (equipped.rule?.clauses?.length ?? 0) > 0
|
||||
));
|
||||
const calculationDraft = $derived.by(() => {
|
||||
if (draft.calculationMode === 'custom') return draft;
|
||||
const effectKeys = [
|
||||
...selectedCombatEffects.map((effect: any) => String(effect.id)),
|
||||
...modeledTalents.map((effect: any) => String(effect.key))
|
||||
];
|
||||
const conditionalEffects = {
|
||||
...draft.conditionalEffects,
|
||||
...Object.fromEntries(effectKeys.map((key) => [
|
||||
key,
|
||||
{ ...draft.conditionalEffects[key], mode: 'peak' as const }
|
||||
]))
|
||||
};
|
||||
const skillActivations = { ...draft.skillActivations };
|
||||
for (const ability of selectedFocus?.abilities ?? []) {
|
||||
skillActivations[ability.id] = {
|
||||
mode: 'on_cooldown',
|
||||
targets: Math.max(1, Number(ability.attack?.target_cap ?? 1))
|
||||
};
|
||||
}
|
||||
return { ...draft, conditionalEffects, skillActivations };
|
||||
});
|
||||
const combatResults = $derived(evaluateLoadoutCombat(calculationDraft, data, summary));
|
||||
const skillCombatResult = $derived(evaluateLoadoutSkillCombat(calculationDraft, data, summary));
|
||||
const skillAbilityNames = $derived(Object.fromEntries(
|
||||
(selectedFocus?.abilities ?? []).map((ability: any) => [String(ability.id), String(ability.name)])
|
||||
));
|
||||
const rollSummary = (rolls: Roll[]) => rolls
|
||||
.filter((roll) => roll.statId)
|
||||
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`)
|
||||
@@ -410,7 +289,7 @@
|
||||
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
|
||||
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES',
|
||||
iconUrl: selectedFocus?.specialization.iconUrl,
|
||||
issue: !selectedFocus ? 'REQUIRED FOR CALCULATION' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined
|
||||
issue: !selectedFocus ? 'SELECT TO COMPLETE LOADOUT' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined
|
||||
});
|
||||
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
|
||||
const ability = selectedFocus?.abilities[index];
|
||||
@@ -437,7 +316,7 @@
|
||||
meta: item?.category ?? '',
|
||||
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON',
|
||||
iconUrl: weaponIcon(item?.category),
|
||||
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
|
||||
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
|
||||
};
|
||||
}));
|
||||
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
|
||||
@@ -455,7 +334,7 @@
|
||||
meta: item ? `T${item.tier}` : '',
|
||||
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR',
|
||||
iconUrl: gearIcon(slot),
|
||||
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
|
||||
issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
|
||||
};
|
||||
}));
|
||||
const protocolCell = $derived<MatrixCell>({
|
||||
@@ -467,7 +346,7 @@
|
||||
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
|
||||
: 'SELECT ONE PROTOCOL',
|
||||
iconUrl: protocolItem()?.iconUrl,
|
||||
issue: protocolItem() ? undefined : 'REQUIRED FOR CALCULATION'
|
||||
issue: protocolItem() ? undefined : 'SELECT TO COMPLETE LOADOUT'
|
||||
});
|
||||
const loadoutConfigured = $derived(Boolean(
|
||||
selectedFocus
|
||||
@@ -478,22 +357,6 @@
|
||||
&& draft.protocol.itemId
|
||||
));
|
||||
|
||||
$effect(() => {
|
||||
const sources = [
|
||||
...selectedCombatEffects.map((effect: any) => ({ key: String(effect.id) })),
|
||||
...modeledTalents
|
||||
];
|
||||
const missing = sources.filter((source: any) => !draft.conditionalEffects[source.key]);
|
||||
if (missing.length) draft.conditionalEffects = {
|
||||
...draft.conditionalEffects,
|
||||
...Object.fromEntries(missing.map((effect: any) => [effect.key, { mode: 'off' }]))
|
||||
};
|
||||
const missingAbilities = (selectedFocus?.abilities ?? []).filter((ability: any) => !draft.skillActivations[ability.id]);
|
||||
if (missingAbilities.length) draft.skillActivations = {
|
||||
...draft.skillActivations,
|
||||
...Object.fromEntries(missingAbilities.map((ability: any) => [ability.id, { mode: 'off', targets: 1 }]))
|
||||
};
|
||||
});
|
||||
onMount(() => {
|
||||
if (data.initialBuild) return;
|
||||
localStorage.removeItem('shd-build-loadout-draft-v3');
|
||||
@@ -561,7 +424,7 @@
|
||||
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>{draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : 'CUSTOM SCENARIO'}</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><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>
|
||||
{#if message}<p class="success" role="status">{message}</p>{/if}
|
||||
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
|
||||
@@ -594,20 +457,6 @@
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{#if draft.calculationMode === 'custom'}<div class="builder-skill-activation">
|
||||
<div class="builder-section-title"><div><span>SKILL ACTIVATION PROFILE</span><small>ALL ENABLED ABILITIES CONTRIBUTE TO COMBINED SKILL DPS</small></div><b>{selectedFocus.abilities.filter((ability: any) => (draft.skillActivations[ability.id]?.mode ?? 'off') !== 'off').length} ENABLED</b></div>
|
||||
{#each selectedFocus.abilities as ability}
|
||||
{@const activation = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }}
|
||||
<section>
|
||||
<header><ItemIcon src={ability.iconUrl} alt=""/><div><strong>{ability.name}</strong><small>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK PROFILE NOT CONFIGURED'}</small></div></header>
|
||||
<SegmentedChoice legend={`${ability.name} activation`} name={`skill-${ability.id}-activation`} options={[{value:'off',label:'OFF'},{value:'on_cooldown',label:'ON COOLDOWN'},{value:'custom_per_minute',label:'CUSTOM / MIN'}]} value={activation.mode} onchange={(values) => setSkillActivation(ability.id,{mode:values[0]})}/>
|
||||
<div class="builder-skill-assumptions">
|
||||
{#if activation.mode === 'custom_per_minute'}<RangeValueControl label="Uses per minute" min={0.1} max={60} step={0.1} value={activation.usesPerMinute ?? 1} onchange={(value) => setSkillActivation(ability.id,{usesPerMinute:value})}/>{/if}
|
||||
<RangeValueControl label="Targets hit" min={1} max={Math.max(1,Number(ability.attack?.target_cap ?? 1))} step={1} value={activation.targets} onchange={(value) => setSkillActivation(ability.id,{targets:value})}/>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>{/if}
|
||||
<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>
|
||||
@@ -619,41 +468,7 @@
|
||||
</div></section>
|
||||
{/each}
|
||||
</div>
|
||||
<CombatAssumptions bind:mode={draft.calculationMode} bind:profile={draft.combatProfile}/>
|
||||
{#if selectedCombatEffects.length}
|
||||
<div class="builder-conditional-effects">
|
||||
<div class="builder-section-title"><div><span>CONDITIONAL EFFECT PROFILES</span><small>CHOOSE HOW EACH UNLOCKED EFFECT CONTRIBUTES TO DPS</small></div><b>{selectedCombatEffects.length} EFFECTS</b></div>
|
||||
{#each selectedCombatEffects as effect}
|
||||
<ConditionalEffectCard
|
||||
id={effect.id}
|
||||
name={effect.definition?.name ?? effect.nodeName}
|
||||
description={combatEffectDescription(effect)}
|
||||
facts={conditionalFacts(effect.rule)}
|
||||
scenarioInputs={mechanicScenarioInputs(effect.rule)}
|
||||
appliedState={draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : draft.conditionalEffects[effect.id]?.mode === 'peak' ? 'PEAK MODE' : draft.conditionalEffects[effect.id]?.mode === 'encounter' ? 'ENCOUNTER MODE' : 'NOT APPLIED'}
|
||||
bind:effectState={draft.conditionalEffects[effect.id]}
|
||||
calculationMode={draft.calculationMode}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if modeledTalents.length}
|
||||
<div class="builder-conditional-effects">
|
||||
<div class="builder-section-title"><div><span>EQUIPPED TALENT MODELING</span><small>MODEL EACH MECHANIC PROGRAM OR EXCLUDE IT FROM CALCULATIONS</small></div><b>{modeledTalents.length} TALENTS</b></div>
|
||||
{#each modeledTalents as equipped}
|
||||
<ConditionalEffectCard
|
||||
id={equipped.key}
|
||||
name={equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}
|
||||
description={equipped.description}
|
||||
facts={conditionalFacts(equipped.rule)}
|
||||
scenarioInputs={mechanicScenarioInputs(equipped.rule)}
|
||||
appliedState={draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : draft.conditionalEffects[equipped.key]?.mode === 'peak' ? 'PEAK MODE' : draft.conditionalEffects[equipped.key]?.mode === 'encounter' ? 'ENCOUNTER MODE' : 'NOT APPLIED'}
|
||||
bind:effectState={draft.conditionalEffects[equipped.key]}
|
||||
calculationMode={draft.calculationMode}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<PlannedCombatModel headingId="builder-combat-model-heading" />
|
||||
{/if}
|
||||
{:else if activeParts[0] === 'ability'}
|
||||
{@const abilityIndex = Number(activeParts[1])}
|
||||
@@ -668,19 +483,6 @@
|
||||
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT CHIP'}</span></button>{/each}</div>
|
||||
</section>
|
||||
</div>
|
||||
{#if draft.calculationMode === 'custom'}
|
||||
{@const activation = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }}
|
||||
<div class="builder-skill-activation">
|
||||
<div class="builder-section-title"><div><span>ACTIVATION PROFILE</span><small>CONTROLS THIS ABILITY'S CONTRIBUTION TO COMBINED SKILL DPS</small></div></div>
|
||||
<section>
|
||||
<SegmentedChoice legend={`${ability.name} activation`} name={`ability-${ability.id}-activation`} options={[{value:'off',label:'OFF'},{value:'on_cooldown',label:'ON COOLDOWN'},{value:'custom_per_minute',label:'CUSTOM / MIN'}]} value={activation.mode} onchange={(values) => setSkillActivation(ability.id,{mode:values[0]})}/>
|
||||
<div class="builder-skill-assumptions">
|
||||
{#if activation.mode === 'custom_per_minute'}<RangeValueControl label="Uses per minute" min={0.1} max={60} step={0.1} value={activation.usesPerMinute ?? 1} onchange={(value) => setSkillActivation(ability.id,{usesPerMinute:value})}/>{/if}
|
||||
<RangeValueControl label="Targets hit" min={1} max={Math.max(1,Number(ability.attack?.target_cap ?? 1))} step={1} value={activation.targets} onchange={(value) => setSkillActivation(ability.id,{targets:value})}/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="error">Select a Specialization and Focus with configured abilities before attaching Skill Chips.</p>
|
||||
{/if}
|
||||
@@ -749,16 +551,9 @@
|
||||
</section>
|
||||
|
||||
<aside class="loadout-summary">
|
||||
<div class="summary-head"><div><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2></div><b>{summary.stats.length}</b></div>
|
||||
<div class="summary-core">{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}<div><GameIcon name={name.toLowerCase() as GameIconName} decorative/><span>{name}<b>{stat ? unitValue(stat.value,stat.id) : '0'}</b></span></div>{/each}</div>
|
||||
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result} resolverHint="Use the warning markers in the loadout matrix."/>{/each}</div>
|
||||
<div class="builder-combat-results"><SkillCalculationResult result={skillCombatResult} abilityNames={skillAbilityNames} resolverHint="Open the marked doctrine or ability cell."/></div>
|
||||
<div class="summary-scroll">
|
||||
<div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div>
|
||||
<div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div>
|
||||
<div class="summary-section"><header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>{#each summary.equippedTalents as equipped}<label class="summary-talent">{#if draft.calculationMode === 'custom'}<input type="checkbox" checked={(draft.conditionalEffects[equipped.key]?.mode ?? 'off') !== 'off'} onchange={() => toggleTalent(equipped.key)}/>{/if}<span><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source} · {draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : (draft.conditionalEffects[equipped.key]?.mode ?? 'off') !== 'off' ? 'INCLUDED' : 'CONDITIONAL'}</small><em>{equipped.description}</em>{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}</span></label>{:else}<p>Selected equipment talents appear here.</p>{/each}</div>
|
||||
</div>
|
||||
<p class="notice">{draft.calculationMode === 'maximum' ? 'Maximum Potential applies every configured effect at best-case uptime. ' : 'Custom Scenario uses each mechanic program and the encounter settings saved with this loadout. '}Anonymous drafts remain on this device.</p>
|
||||
<LoadoutStatsSummary {summary} attributes={data.attributes} talents={data.talents} headingId="builder-known-stats-heading" />
|
||||
<div class="builder-planned-model"><PlannedCombatModel headingId="builder-planned-output-heading" variant="compact" /></div>
|
||||
<p class="notice">Anonymous drafts remain on this device. Account saves preserve the equipped-item configuration and current visibility.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -2,11 +2,8 @@ import { error } from '@sveltejs/kit';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
import {
|
||||
evaluateLoadoutCombat,
|
||||
summarizeLoadout,
|
||||
} from '$lib/builder/loadout-combat';
|
||||
import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat';
|
||||
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
||||
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
|
||||
import { avatarUrl } from '$lib/avatar';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
@@ -41,24 +38,26 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
} catch {
|
||||
error(404, 'Build data is unavailable');
|
||||
}
|
||||
const [gear, weapons, chips, protocols, brands, combatCatalog] =
|
||||
const [gear, weapons, chips, protocols, brands, summaryCatalog] =
|
||||
await Promise.all([
|
||||
(await database.prepare('SELECT id,name,slot,tier FROM gear')).all(),
|
||||
(
|
||||
await database.prepare('SELECT id,name,category,is_exotic FROM weapons')
|
||||
await database.prepare(
|
||||
'SELECT id,name,slot,tier,base_armor_min FROM gear',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
||||
(
|
||||
await database.prepare('SELECT id,name,quality FROM os_protocols')
|
||||
).all(),
|
||||
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
|
||||
loadSharedBuildCombatCatalog(database),
|
||||
loadSharedBuildSummaryCatalog(database),
|
||||
]);
|
||||
const combatResults = evaluateLoadoutCombat(
|
||||
loadout,
|
||||
combatCatalog,
|
||||
summarizeLoadout(loadout, combatCatalog),
|
||||
);
|
||||
const summary = summarizeLoadout(loadout, summaryCatalog);
|
||||
return {
|
||||
build: {
|
||||
id: String(build.id),
|
||||
@@ -85,6 +84,8 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
chips,
|
||||
protocols,
|
||||
brands,
|
||||
combatResults,
|
||||
summary,
|
||||
attributes: summaryCatalog.attributes,
|
||||
talents: summaryCatalog.talents,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { gearSlots } from '$lib/builder/loadout';
|
||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
|
||||
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
|
||||
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
|
||||
let { data } = $props();
|
||||
const gear = (id: string) => data.gear.find((item) => item.id === id);
|
||||
const weapon = (id: string) => data.weapons.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 brand = (id: string) => data.brands.find((item) => item.id === id);
|
||||
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
|
||||
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
||||
const rollLine = (rolls: Array<{ statId: string; value: number }>) => rolls
|
||||
.filter((roll) => roll.statId)
|
||||
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${number.format(roll.value)}${attribute(roll.statId)?.unit ?? ''}`)
|
||||
.join(' · ');
|
||||
const coreLine = (selection: { firepower: number; toughness: number; engineering: number }) =>
|
||||
`FP ${number.format(selection.firepower)} · TGH ${number.format(selection.toughness)} · ENG ${number.format(selection.engineering)}`;
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
|
||||
@@ -16,30 +25,22 @@
|
||||
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
|
||||
<div>{#if data.build.owner}<a class="secondary" href={`/guides/new?build=${encodeURIComponent(data.build.id)}`}>CREATE GUIDE FOR BUILD</a><a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
|
||||
</header>
|
||||
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
|
||||
<div class="builder-section-title">
|
||||
<div>
|
||||
<span>LIVE COMBAT MODEL</span>
|
||||
<h2 id="shared-combat-heading">CURRENT WEAPON OUTPUT</h2>
|
||||
</div>
|
||||
<b>CURRENT ISAC RULES</b>
|
||||
</div>
|
||||
{#each data.combatResults as result}
|
||||
<WeaponCalculationResult {result}/>
|
||||
{/each}
|
||||
</section>
|
||||
<div class="shared-build-intelligence">
|
||||
<LoadoutStatsSummary summary={data.summary} attributes={data.attributes} talents={data.talents} headingId="shared-known-stats-heading" layout="page" />
|
||||
<PlannedCombatModel headingId="shared-planned-model-heading" headingLevel="h2" variant="compact" />
|
||||
</div>
|
||||
<div class="shared-build-grid">
|
||||
<section>
|
||||
<header><span>WEAPONS</span><b>02 SLOTS</b></header>
|
||||
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span></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></div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
|
||||
</section>
|
||||
<section>
|
||||
<header><span>GEAR</span><b>06 SLOTS</b></header>
|
||||
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span></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}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
|
||||
</section>
|
||||
<section>
|
||||
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
|
||||
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span></article>{/each}
|
||||
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span>{#if item}<em>{rollLine([selection.primary, ...selection.secondary]) || 'ATTRIBUTES NOT CONFIGURED'}</em>{/if}</article>{/each}
|
||||
{#if protocol(data.loadout.protocol.itemId)}{@const protocolItem=protocol(data.loadout.protocol.itemId)}<article><small>OS PROTOCOL</small><strong>{protocolItem.name}</strong><span>LEVEL {data.loadout.protocol.level} · {data.loadout.protocol.talentUpgrades}/5 UPGRADES</span></article>{:else}<article><small>OS PROTOCOL</small><strong>EMPTY SLOT</strong><span>NOT CONFIGURED</span></article>{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
<svelte:head><title>Combat calculations — ISAC</title></svelte:head>
|
||||
|
||||
<div class="module-toolbar">
|
||||
<div><p class="eyebrow">MODELED DAMAGE + EMPIRICAL CALIBRATION</p><h2>COMBAT CALCULATIONS</h2><p>Configure typed calculation stages, Attribute bindings, and field observations used by the loadout builder.</p></div>
|
||||
<div><p class="eyebrow">MODELED DAMAGE + EMPIRICAL CALIBRATION</p><h2>COMBAT CALCULATIONS</h2><p>Configure typed calculation stages, Attribute bindings, and field observations for the planned advanced loadout model.</p></div>
|
||||
<div class="calculation-actions"><button class="secondary" onclick={() => calibrationEditing = blankCalibration()}>ADD CALIBRATION CASE +</button><button class="primary" onclick={() => revisionEditing = revisionFrom(activeRevision)}>NEW REVISION +</button></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -380,20 +380,20 @@
|
||||
</section>
|
||||
|
||||
<section id="scenarios" class="reference-section">
|
||||
<header><div><span>09</span><h3>BUILDER SCENARIOS</h3></div></header>
|
||||
<header><div><span>09</span><h3>PLANNED BUILDER SCENARIOS</h3></div></header>
|
||||
<div class="scenario-grid">
|
||||
<article>
|
||||
<strong>MAXIMUM POTENTIAL</strong>
|
||||
<p>Uses each mechanic’s configured best-case values to calculate a ceiling. It applies peak stacks and maximum supported target or ally counts.</p>
|
||||
<p>The planned mode uses each mechanic’s configured best-case values to calculate a ceiling. It applies peak stacks and maximum supported target or ally counts.</p>
|
||||
</article>
|
||||
<article>
|
||||
<strong>CUSTOM SCENARIO</strong>
|
||||
<p>Uses player-selected toggles, counts, percentages, and cadence inputs to model a specific combat situation.</p>
|
||||
<p>The planned mode uses player-selected toggles, counts, percentages, and cadence inputs to model a specific combat situation.</p>
|
||||
</article>
|
||||
</div>
|
||||
<aside class="contract">
|
||||
<strong>REQUIRES ASSUMPTIONS</strong>
|
||||
<span>ISAC withholds a branch when the mechanic needs an undefined scenario value. The author or builder must supply it before the runtime includes that contribution.</span>
|
||||
<span>The runtime withholds a branch when the mechanic needs an undefined scenario value. An author or future scenario control must supply it before the runtime includes that contribution.</span>
|
||||
</aside>
|
||||
<aside class="contract protocol-contract">
|
||||
<strong>OS PROTOCOL TALENT UPGRADES</strong>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
{ key: 'database', label: 'DATABASE', description: 'Public game-data registries and tactical lookup.' },
|
||||
{ key: 'builds', label: 'BUILDS', description: 'Published loadout discovery and shared build views.' },
|
||||
{ key: 'guides', label: 'GUIDES', description: 'Long-form field manuals and build walkthroughs.' },
|
||||
{ key: 'builder', label: 'BUILDER', description: 'Loadout creation, calculation, and account saves.' }
|
||||
{ key: 'builder', label: 'BUILDER', description: 'Loadout creation, known-stat totals, and account saves.' }
|
||||
] as const;
|
||||
const moderationModelOptions = [
|
||||
{ value: 'omni-moderation-latest', label: 'LATEST' },
|
||||
|
||||
Reference in New Issue
Block a user