feat: defer advanced build calculations
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 27s
Validate and deploy staging / deploy (push) Successful in 54s

This commit is contained in:
jb
2026-08-02 03:07:12 -04:00
parent c61b9832dd
commit d09b6220ee
19 changed files with 1485 additions and 1267 deletions
+74 -58
View File
@@ -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 configuration controls. Do not display a prototype option that the current
loadout schema cannot save. 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 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 open the editor that resolves it. The live totals rail summarizes deterministic
output in one compact row. Do not repeat every missing slot in a large error equipped-item totals. Separate panels label conditional Talent outcomes, weapon
panel. Keep system-owned blockers, such as an unpublished calculation profile, and Skill DPS, encounter assumptions, activation profiles, rotations, and
visible in that compact summary because the player cannot resolve them from a simulation as `COMING SOON`; they do not report calculation-profile blockers as
loadout cell. current player errors.
The builder uses three workbench columns on wide screens, moves the live summary 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 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 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 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 totals summary on the right. At medium widths the summary becomes a full-width
full-width lower rail; on mobile all three rails stack, with equipment first, lower rail; on mobile all three rails stack, with equipment first, editing
editing second, and results third. Equipment positions remain explicit even when second, and results third. Equipment positions remain explicit even when empty
empty so loadout completeness is always visible. so players can see loadout completeness.
Use the shared `BuilderPicker` for builder catalogs and Attribute pools. It Use the shared `BuilderPicker` for builder catalogs and Attribute pools. It
combines an always-visible current selection with a searchable overlay, icon or 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 Numeric instance values display their authoritative minimum and maximum beside
the field and clamp to that range on completion. Always-on item rolls, fixed the field and clamp to that range on completion. Always-on item rolls, fixed
bonuses, inherited profiles, enabled augments, and activated set bonuses feed bonuses, inherited profiles, enabled augments, and activated set bonuses feed
the live totals immediately. Conditional mechanics state their calculation mode the live totals immediately. Current Builder and shared Build pages stop at
and readiness. Maximum Potential uses their best-case state; Custom Scenario these deterministic equipped-item totals. Conditional Talent outcomes, weapon
uses the source-defined advanced assumptions saved with the loadout. Exclude a and Skill DPS, encounter assumptions, activation profiles, rotations, and
mechanic that lacks a required assumption and label it `REQUIRES ASSUMPTIONS`. simulation remain planned. Render those capabilities as separate `COMING SOON`
This distinction must remain clear without relying on color. panels, with visible text rather than color alone.
Loadout summaries name selected Talents, Attributes and their exact values, Loadout summaries name selected Talents, Attributes and their exact values,
nodes, and Skill Chips. Do not replace these selections with configured item 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 controls. The recipe label records provenance only. `REPLACE PROGRAM` requires
an inline confirmation that states all existing branches will be discarded. an inline confirmation that states all existing branches will be discarded.
Builder Controls define source-specific advanced scenario assumptions. Authors Builder Controls define source-specific scenario assumptions for the planned
choose toggle, slider, or numeric input and provide a label, Agent guidance, player simulation. ISAC authors choose toggle, slider, or numeric input and
default value, best-case value, and numeric bounds when applicable. Group these provide a label, Agent guidance, default value, best-case value, and numeric
controls behind the builder's advanced scenario surface. Maximum Potential uses bounds when applicable. The current player UI does not render these controls or
best-case values. Custom Scenario uses saved Agent values. A required cadence or read their saved values. It uses an explicit `COMING SOON` panel for the future
environmental value with no supplied assumption keeps the mechanic out of the advanced scenario surface. The authored contract still requires the runtime to
calculation and displays `REQUIRES ASSUMPTIONS`; the editor and runtime never report `REQUIRES ASSUMPTIONS` when a required cadence or environmental value is
invent a cadence. missing; the editor and runtime never invent a cadence.
Use `--shd-model-surface` and `--shd-model-border` for mechanic programs, Use `--shd-model-surface` and `--shd-model-border` for mechanic programs,
calculation revisions, and field calibration. The blue information rail marks calculation revisions, and field calibration. The blue information rail marks
@@ -2482,9 +2482,16 @@ anchor scrolling and sticky-state transitions.
#### Builder calculation modes #### Builder calculation modes
The loadout builder places one required two-option `SegmentedChoice` above The current Builder and shared Build pages do not expose calculation modes or
combat assumptions. The persisted `maximum` and `custom` values use these combat assumptions. They show deterministic equipped-item totals and reserve
visible labels: 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 - `MAXIMUM POTENTIAL` calculates a configured ceiling. It applies conditional
effects at peak state and treats selected abilities as firing on cooldown 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 - `CUSTOM SCENARIO` exposes encounter assumptions and source-defined advanced
controls for a player-defined scenario. controls for a player-defined scenario.
New loadouts select `MAXIMUM POTENTIAL`. The interface labels Maximum results as The planned interface selects `MAXIMUM POTENTIAL` for new loadouts and labels
a modeled ceiling so players do not read them as typical encounter performance. its results as a modeled ceiling. Maximum will hide granular assumption fields
Maximum hides granular assumption fields without deleting their saved values. without deleting their saved values. Returning to Custom will restore those
Returning to Custom restores those values. 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. - `OFF` excludes the effect.
- `PEAK` applies its configured maximum stacks. - `PEAK` applies its configured maximum stacks.
- `ENCOUNTER` models stack buildup and expiry from weapon RPM, magazine size, - `ENCOUNTER` models stack buildup and expiry from weapon RPM, magazine size,
reload time, hit rate, firing activity, and encounter duration. reload time, hit rate, firing activity, and encounter duration.
Users may override encounter assumptions on one effect without changing the The planned advanced scenario surface will let users override one effect without
global Custom profile. Open the advanced scenario surface to show only the changing the global Custom profile. It will show only the toggle, slider, and
toggle, slider, and numeric controls defined by that effect's owning source. The numeric controls defined by that effect's owning source. The effect card will
effect card shows uptime, activation count, and applied stack state beside the show uptime, activation count, and applied stack state beside the mode. A
mode. A mechanic that needs an undefined cadence or another missing scenario mechanic that needs an undefined cadence or another missing scenario input will
input displays `REQUIRES ASSUMPTIONS`; the builder withholds its calculated display `REQUIRES ASSUMPTIONS`; the simulation will withhold its contribution
contribution until the author or player supplies that input. until the author or player supplies that input.
Maximum and Custom use radio semantics, an explicit selected label, keyboard The future Maximum and Custom control will use radio semantics, an explicit
arrow navigation, and the standard orange selected rail. Results repeat the selected label, keyboard arrow navigation, and the standard orange selected
active calculation mode in text. On mobile, the global mode control and Custom rail. Results will repeat the active calculation mode in text. On mobile, the
assumption fields stack into full-width targets of at least 44 pixels. Mode global mode control and Custom assumption fields will stack into full-width
changes use the standard pressed feedback; reduced-motion preferences remove targets of at least 44 pixels. Mode changes will use the standard pressed
transitions without hiding the state change. feedback; reduced-motion preferences will remove transitions without hiding the
state change.
Combat result panels show normal, critical, headshot, and critical-headshot The future combat result panels will show normal, critical, headshot, and
values with burst, sustained, and encounter DPS. Conditional stack telemetry critical-headshot values with burst, sustained, and encounter DPS. Conditional
lists the mode, uptime, activations, average and peak stacks, ending stacks, stack telemetry will list the mode, uptime, activations, average and peak
buildup time, and maintained state. A result with missing equipment, Attribute stacks, ending stacks, buildup time, and maintained state. A result with missing
bindings, rules, or assumptions displays `INSUFFICIENT DATA` and a concrete equipment, Attribute bindings, rules, or assumptions will display
remediation list. The interface withholds combat numbers until it has the `INSUFFICIENT DATA` and a concrete remediation list. Until this interface ships,
required inputs. the player pages show `COMING SOON` instead of combat numbers.
### Specialization loadout composer ### 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 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. 49-point limit. Cost telemetry lists Credits and components beside each rank.
Each ability panel exposes one compact activation control: Off, On Cooldown, or Player ability panels currently expose equipped definitions and deterministic
Custom Rate. Custom Rate adds uses per minute. Every enabled ability has its own item contributions. Planned simulation panels display `COMING SOON` instead of
target count. Skill result cards show the ability name, activation count, activation controls or Skill DPS. The future interface will offer Off, On
single-target DPS, total-target DPS, and a combined summary. They state that Cooldown, or Custom Rate, with uses per minute and a target count for each
Skills can critically hit and multi-shot but cannot headshot. 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, Saved loadouts store focus, node ranks, nine Skill Chips, conditional modes,
encounter assumptions, and per-ability activation profiles. The builder accepts encounter assumptions, and per-ability activation profiles. The builder accepts
only the current complete structure. Invalid local drafts are discarded; invalid only the current complete structure for compatibility. Current player pages
server records are removed through a backed-up database migration. ignore the simulation fields. Invalid local drafts are discarded; invalid server
records are removed through a backed-up database migration.
### Calculation revision controls ### 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 language, identifies the active or edited base revision, and previews changes to
stages, weapon profiles, and Skill profiles. 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` Agents without `ai.calculation.generate` see the collapsed `PERMISSION REQUIRED`
state. Agents with the permission may request and apply a draft, but the panel 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 leaves every manual calculation control available. Applying a draft fills the
+53 -34
View File
@@ -25,6 +25,13 @@ The team builds the project in vertical slices.
Server code checks authorization for each mutation. UI visibility has no effect Server code checks authorization for each mutation. UI visibility has no effect
on server authorization. on server authorization.
Builder and shared Build pages currently expose only deterministic totals from
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 Weapon and gear range calculations must preserve decimal endpoints and follow
the tested progression rules in the tested progression rules in
[`STAT_LEVEL_SCALING.md`](STAT_LEVEL_SCALING.md). Leveling is linear rather than [`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 Turnstile result. Guide reports reference the exact published revision so a
report against an older revision cannot hide newer work. report against an older revision cannot hide newer work.
The Builder stores loadout data only. It has no summary or Guide body. An owned The Builder stores loadout data and displays deterministic equipped-item totals.
Build page links to `/guides/new?build=<id>`, where the server verifies It has no Guide body. Shared Build pages use the same player-facing calculation
ownership and initializes a removable Build block in a new private Guide. 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 ## 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 `source_type` and `source_id` pair identifies that owner. No standalone Trigger
record or shared Trigger relationship participates in execution. record or shared Trigger relationship participates in execution.
The builder supplies normalized timeline events to each equipped mechanic. The mechanic runtime accepts normalized timeline events for each equipped
`movement_snapshot.values.state` is `immobile`, `moving`, or `sprinting`; the mechanic. Current player pages do not construct this timeline or invoke the
mechanic's moving and sprinting scenario controls select that state, with runtime. `movement_snapshot.values.state` is `immobile`, `moving`, or
sprinting taking precedence. Enabled Skill activation profiles emit `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 `skill_deployed` events on the same cadence as Skill calculations and identify
the deployed ability. An `on_event` Skill modifier applies to that activation the deployed ability. An `on_event` Skill modifier applies to that activation
until the ability's next deployment or the encounter ends. until the ability's next deployment or the encounter ends.
@@ -502,7 +512,7 @@ Each source supplies a fixed value-variant contract:
- Standard Talents require Tier I and Tier II values. Exotic Talents require - Standard Talents require Tier I and Tier II values. Exotic Talents require
Tier I and may omit Tier II until contributors document its game values. 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. mechanic variant.
- OS Protocol Talents store a base value. An outcome may use an expression that - OS Protocol Talents store a base value. An outcome may use an expression that
reads `equipment.protocolTalentUpgrades`, from zero through five, so selected 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. calculation until an operator or builder scenario supplies it.
The runtime derives a read-only pseudo-language listing from the typed program. 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, ISAC uses that listing to explain events, predicates, reducers, state changes,
state changes, phases, and formulas. No route accepts the generated text as phases, and formulas. Planned player calculations can reuse it when those pages
input, and no server process executes it. Operators edit structured fields; the consume mechanic outcomes. No route accepts the generated text as input, and no
formatter regenerates the listing after each change. server process executes it. Operators edit structured fields; the formatter
regenerates the listing after each change.
## AI-assisted mechanic drafting ## 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 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 must review and save that draft through the existing revision action. The
separate `calculation.publish` permission and activation confirmation remain 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 Successful calculation-generation requests append sanitized events under
`ai_assistance`. These events use calculation-specific actions, identify the `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` Applying a draft creates no server event. Existing `calculation_configuration`
events remain the evidence for revision saves and activation. events remain the evidence for revision saves and activation.
Source authors define the builder controls required by their mechanic as typed Source authors define planned player scenario controls as typed toggle, slider,
toggle, slider, or numeric scenario inputs. Each input records its label, or numeric inputs. Each input records its label, guidance, bounds when
guidance, bounds when applicable, default value, and best-case value. These applicable, default value, and best-case value. ISAC retains these definitions
controls appear only in advanced scenario configuration. Maximum mode supplies for continued mechanic authoring. Current Builder and shared Build pages do not
best-case values. Custom mode uses the values saved with the loadout. A mechanic render or evaluate them. Their calculation panels display `COMING SOON` instead.
with an undefined required assumption remains excluded and reports
`REQUIRES_ASSUMPTIONS`.
Loadout schema version 4 stores one focus, selected tree ranks, nine Skill Loadout schema version 4 stores one focus, selected tree ranks, nine Skill
Chips, a unified conditional-effect state map, encounter assumptions, Chips, a unified conditional-effect state map, encounter assumptions,
per-ability activation controls, and `calculationMode`. Runtime readers accept per-ability activation controls, and `calculationMode`. Runtime readers accept
the exact version 4 structure. A backed-up prototype migration removes drafts, the exact version 4 structure. The schema retains its simulation fields for
saved builds, and calibration snapshots that use incompatible payloads. 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 - Maximum mode supplies each scenario input's best-case value, selects peak
conditional-effect state, and runs selected abilities on cooldown against conditional-effect state, and runs selected abilities on cooldown against
@@ -767,9 +779,10 @@ The builder offers `maximum` and `custom` modes.
rates, target counts, and other supported assumptions. A missing required rates, target counts, and other supported assumptions. A missing required
input produces `REQUIRES_ASSUMPTIONS` rather than a fabricated value. input produces `REQUIRES_ASSUMPTIONS` rather than a fabricated value.
Saved and shared loadouts preserve the selected mode. Calculation results label Saved and shared loadouts preserve the selected mode as compatibility data. The
the mode so readers can distinguish a best-case ceiling from a scenario-based current Builder and shared Build UI does not expose a mode selector, read
estimate. encounter assumptions, or execute either mode. Explicit `COMING SOON` panels
reserve the player-facing space for these calculations.
## Specialization ability attacks ## 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 `attribute_calculation_profiles` maps an Attribute to a weapon stage with its
weight, optional caps, curve, and weapon-category scope. 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, The shared combat engine consumes weapon facts, accumulated Attributes,
calculation profiles, and mechanic outcomes. It produces four hit values, burst calculation profiles, and mechanic outcomes. It produces four hit values, burst
DPS, reload-aware sustained DPS, encounter DPS, and stack telemetry. Custom mode 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 resolves the active Attribute state at each landed shot before calculating that
shot's expected damage. shot's expected damage.
The builder scales a weapon definition's level-1 damage range to the equipped The deterministic equipment path scales a weapon definition's level-1 damage
item level, then the agent selects one damage value inside that valid range. range to the equipped item level, then the agent selects one damage value inside
Combat receives `equipped_weapon.damage` as its base damage and consumes that that valid range. A combat invocation receives `equipped_weapon.damage` as its
number once. The combat pipeline has no `level_scaled_damage` value and must not base damage and consumes that number once. The combat pipeline has no
apply item-level scaling a second time. `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 The generated calculation listing names each typed pipeline stage and its bound
Attributes. The listing explains the active revision with statements such as 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 The engine returns `INSUFFICIENT_DATA` with missing requirement keys when a
calculation lacks required inputs or an Attribute binding. Callers must render 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 that state instead of a partial result. Current player routes do not call the
active revision for each request. engine; their planned calculation surfaces display `COMING SOON`.
`calculation_calibration_cases` stores a versioned loadout snapshot, observed `calculation_calibration_cases` stores a versioned loadout snapshot, observed
hit values, timing values, optional encounter DPS, and the model prediction. hit values, timing values, optional encounter DPS, and the model prediction.
+32 -2
View File
@@ -11754,7 +11754,7 @@ a.secondary:active {
padding: 22px; padding: 22px;
border: 1px solid var(--shd-border); border: 1px solid var(--shd-border);
border-top: 2px solid var(--shd-orange); border-top: 2px solid var(--shd-orange);
background: var(--shd-panel); background: var(--shd-surface);
} }
.shared-build-command h1 { .shared-build-command h1 {
margin: 8px 0; margin: 8px 0;
@@ -11777,7 +11777,12 @@ a.secondary:active {
} }
.shared-build-grid > section { .shared-build-grid > section {
border: 1px solid var(--shd-border); 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 { .shared-build-grid > section > header {
display: flex; display: flex;
@@ -11810,6 +11815,26 @@ a.secondary:active {
color: var(--shd-text-muted); color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry); font-size: var(--shd-type-telemetry);
} }
.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) { @media (max-width: 760px) {
.builder-command .page-title, .builder-command .page-title,
.shared-build-command { .shared-build-command {
@@ -12050,6 +12075,7 @@ a.secondary:active {
flex-direction: column; flex-direction: column;
} }
.builder-command .page-title > div:first-child { .builder-command .page-title > div:first-child {
flex: 0 0 auto;
min-width: 0; min-width: 0;
} }
.builder-command .actions { .builder-command .actions {
@@ -15104,6 +15130,10 @@ a.secondary:active {
padding: var(--shd-gap-sm); padding: var(--shd-gap-sm);
border-bottom: 1px solid var(--shd-border); 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 { .builder-return-link {
width: max-content; width: max-content;
min-height: 36px; min-height: 36px;
+38 -400
View File
@@ -25,20 +25,20 @@ import {
type SkillCalculationRules, type SkillCalculationRules,
type SkillModifierWindow, type SkillModifierWindow,
} from '$lib/calculations/skill'; } 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'; import type { WeaponCalculationResult } from '$lib/components/builder/WeaponCalculationResult.svelte';
type Catalog = { type Catalog = LoadoutCatalog & {
attributes: any[]; calculation?: {
gear: any[];
gearAugments: any[];
brands: any[];
weapons: any[];
talents: any[];
chipSets: any[];
protocols: any[];
specializations: any[];
calculation: {
revision: any | null; revision: any | null;
stages: any[]; stages: any[];
profiles: any[]; profiles: any[];
@@ -46,381 +46,17 @@ type Catalog = {
}; };
}; };
export type LoadoutSummary = { export { summarizeLoadout } from '$lib/builder/loadout-summary';
stats: Array<{ id: string; value: number; sourceCount: number }>; export type { LoadoutSummary } from '$lib/builder/loadout-summary';
activeBonuses: any[];
equippedTalents: any[]; const calculationCatalog = (catalog: Catalog) =>
catalog.calculation ?? {
revision: null,
stages: [],
profiles: [],
skillProfiles: [],
}; };
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 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( function calculationStage(
catalog: Catalog, catalog: Catalog,
stageKey: string, stageKey: string,
@@ -441,7 +77,7 @@ function calculationStage(
) { ) {
return stageKey as CalculationStage; return stageKey as CalculationStage;
} }
const stage = catalog.calculation.stages.find( const stage = calculationCatalog(catalog).stages.find(
(item) => item.stage_key === stageKey, (item) => item.stage_key === stageKey,
); );
if (!stage) return null; if (!stage) return null;
@@ -462,8 +98,8 @@ function calculationBindings(
catalog: Catalog, catalog: Catalog,
weapon: any, weapon: any,
): AttributeBinding[] { ): AttributeBinding[] {
return catalog.calculation.profiles return calculationCatalog(catalog)
.filter( .profiles.filter(
(item) => (item) =>
item.weapon_category === '*' || item.weapon_category === '*' ||
item.weapon_category === weapon?.category, item.weapon_category === weapon?.category,
@@ -1211,7 +847,8 @@ function skillCalculationBindings(catalog: Catalog): SkillAttributeBinding[] {
'cooldown_recovery', 'cooldown_recovery',
'duration', 'duration',
]); ]);
return (catalog.calculation.skillProfiles ?? []).flatMap((profile: any) => { return (calculationCatalog(catalog).skillProfiles ?? []).flatMap(
(profile: any) => {
const stage = const stage =
profile.stage_key === 'engineering' profile.stage_key === 'engineering'
? 'skill_damage' ? 'skill_damage'
@@ -1229,7 +866,8 @@ function skillCalculationBindings(catalog: Catalog): SkillAttributeBinding[] {
}, },
] ]
: []; : [];
}); },
);
} }
function loadoutSkillAbilities( function loadoutSkillAbilities(
@@ -1348,7 +986,7 @@ function loadoutSkillDeploymentEvents(
Math.max(1, Number(draft.combatProfile.encounterSeconds)), Math.max(1, Number(draft.combatProfile.encounterSeconds)),
{ {
revisionId: String( revisionId: String(
catalog.calculation.revision?.id ?? 'mechanic-timeline', calculationCatalog(catalog).revision?.id ?? 'mechanic-timeline',
), ),
bindings, bindings,
}, },
@@ -1539,9 +1177,9 @@ export function evaluateLoadoutCombat(
); );
const required = (weaponIndex: number) => { const required = (weaponIndex: number) => {
const missing: string[] = []; const missing: string[] = [];
if (!catalog.calculation.revision) if (!calculationCatalog(catalog).revision)
missing.push('An active ISAC calculation 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'); missing.push('Published attribute calculation profiles');
if (!draft.weapons[weaponIndex].itemId) if (!draft.weapons[weaponIndex].itemId)
missing.push(`${weaponIndex ? 'Secondary' : 'Primary'} weapon`); missing.push(`${weaponIndex ? 'Secondary' : 'Primary'} weapon`);
@@ -1570,8 +1208,8 @@ export function evaluateLoadoutCombat(
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON', weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
status: 'insufficient', status: 'insufficient',
missing, missing,
revisionLabel: catalog.calculation.revision revisionLabel: calculationCatalog(catalog).revision
? `RULES R${catalog.calculation.revision.version}` ? `RULES R${calculationCatalog(catalog).revision.version}`
: undefined, : undefined,
}; };
} }
@@ -1660,8 +1298,8 @@ export function evaluateLoadoutCombat(
directDamageTotal += outcome.appliedValue; directDamageTotal += outcome.appliedValue;
} }
} }
const calculationStages = catalog.calculation.stages const calculationStages = calculationCatalog(catalog)
.map((stage) => calculationStage(catalog, String(stage.stage_key))) .stages.map((stage) => calculationStage(catalog, String(stage.stage_key)))
.filter((stage): stage is CalculationStage => stage !== null); .filter((stage): stage is CalculationStage => stage !== null);
const result = calculateCombat({ const result = calculateCombat({
weapon: { weapon: {
@@ -1693,7 +1331,7 @@ export function evaluateLoadoutCombat(
? `Calculation profile for ${attribute(catalog, item.slice(8))?.name ?? item.slice(8)}` ? `Calculation profile for ${attribute(catalog, item.slice(8))?.name ?? item.slice(8)}`
: item.replaceAll('.', ' '), : item.replaceAll('.', ' '),
), ),
revisionLabel: `RULES R${catalog.calculation.revision.version}`, revisionLabel: `RULES R${calculationCatalog(catalog).revision.version}`,
}; };
} }
const timeline = buildWeaponMechanicTimeline( const timeline = buildWeaponMechanicTimeline(
@@ -1754,7 +1392,7 @@ export function evaluateLoadoutCombat(
mode: source.state.mode, mode: source.state.mode,
...mechanicSummary(source.evaluation), ...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, catalog: Catalog,
summary = summarizeLoadout(draft, catalog), summary = summarizeLoadout(draft, catalog),
): SkillCalculation { ): SkillCalculation {
const revisionId = String(catalog.calculation.revision?.id ?? ''); const revisionId = String(calculationCatalog(catalog).revision?.id ?? '');
const bindings = skillCalculationBindings(catalog); const bindings = skillCalculationBindings(catalog);
const boundIds = new Set(bindings.map((binding) => binding.attributeId)); const boundIds = new Set(bindings.map((binding) => binding.attributeId));
const attributes = Object.fromEntries( const attributes = Object.fromEntries(
+435
View File
@@ -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.',
);
});
});
+396
View File
@@ -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"> <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> <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} {#each rule.scenarioInputs as input, index}
<div class="scenario-row"> <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> <label class="field">LABEL<input value={input.label} oninput={(event) => updateScenarioInput(index,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
-388
View File
@@ -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,
},
};
}
+236
View File
@@ -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),
),
})),
})),
})),
})),
};
}
+1 -1
View File
@@ -230,7 +230,7 @@
<header><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>PUBLIC BUILDS</h2></div><span>{data.counts.builds} TOTAL</span></header> <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"> <div class="public-content-grid">
{#each data.builds as build} {#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} {: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> </div>
</section> </section>
-113
View File
@@ -67,10 +67,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talentTiers, talentTiers,
talentEffects, talentEffects,
talentTemplates, talentTemplates,
conditionalRules,
conditionalPools,
conditionalClauses,
conditionalOutcomes,
chipSets, chipSets,
chipTwoPiece, chipTwoPiece,
chipPrimary, chipPrimary,
@@ -94,15 +90,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
specializationRanks, specializationRanks,
specializationRankEffects, specializationRankEffects,
specializationRankCosts, specializationRankCosts,
specializationCombatEffects,
specializationRankCombatEffects,
specializationCombatModifiers,
progression, progression,
rankProfiles, rankProfiles,
activeCalculationRevision,
calculationStages,
calculationProfiles,
skillCalculationProfiles,
] = await Promise.all([ ] = await Promise.all([
( (
await database.prepare( await database.prepare(
@@ -164,26 +153,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
( (
await database.prepare('SELECT * FROM talent_description_templates') await database.prepare('SELECT * FROM talent_description_templates')
).all(), ).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( await database.prepare(
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE', 'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
@@ -271,21 +240,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id', 'SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id',
) )
).all(), ).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( await database.prepare(
"SELECT * FROM specialization_progression_config WHERE id='default'", "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', 'SELECT * FROM specialization_rank_cost_profiles ORDER BY rank',
) )
).all(), ).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 = ( const iconUrl = (
type: type:
@@ -333,26 +267,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
); );
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : ''; 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) => ({ const configuredTalents = talents.map((talent) => ({
...talent, ...talent,
iconUrl: iconUrl('talent', talent.id), iconUrl: iconUrl('talent', talent.id),
@@ -367,7 +281,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(effect) => effect.talent_tier_id === tier.id, (effect) => effect.talent_tier_id === tier.id,
), ),
})), })),
rule: hydrateRule('talent', talent.id),
})); }));
return { return {
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null, viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
@@ -436,7 +349,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
effects: chipTalentEffects.filter( effects: chipTalentEffects.filter(
(effect) => effect.talent_id === talent.id, (effect) => effect.talent_id === talent.id,
), ),
rule: hydrateRule('skill_chip_talent', talent.id),
} }
: null, : null,
}; };
@@ -467,7 +379,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
effects: protocolTalentEffects.filter( effects: protocolTalentEffects.filter(
(effect) => effect.talent_id === talent.id, (effect) => effect.talent_id === talent.id,
), ),
rule: hydrateRule('os_protocol_talent', talent.id),
} }
: null, : null,
}; };
@@ -520,24 +431,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
cost.node_id === node.id && cost.node_id === node.id &&
Number(cost.rank) === Number(rank.rank), 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 }; return { ...focus, abilities: assignedAbilities, nodes };
@@ -550,12 +443,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
Number(progression?.shd_cache_points ?? 19), Number(progression?.shd_cache_points ?? 19),
rankProfiles, rankProfiles,
}, },
calculation: {
revision: activeCalculationRevision,
stages: calculationStages,
profiles: calculationProfiles,
skillProfiles: skillCalculationProfiles,
},
}; };
}; };
+12 -217
View File
@@ -8,14 +8,11 @@
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import PendingForm from '$lib/components/PendingForm.svelte'; import PendingForm from '$lib/components/PendingForm.svelte';
import ItemIcon from '$lib/components/ItemIcon.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 LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte'; import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
import SkillCalculationResult from '$lib/components/builder/SkillCalculationResult.svelte'; import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
import { evaluateLoadoutCombat, evaluateLoadoutSkillCombat, summarizeLoadout } from '$lib/builder/loadout-combat'; import { summarizeLoadout } from '$lib/builder/loadout-summary';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import { MECHANIC_EVENT_REGISTRY, type MechanicEventKind } from '$lib/calculations/mechanic';
import { import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange, blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
type LoadoutDraft, type Roll type LoadoutDraft, type Roll
@@ -54,11 +51,6 @@
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0); const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : []; return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
}) ?? []); }) ?? []);
const 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 talent = (id: string) => data.talents.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id); const brand = (id: string) => data.brands.find((item) => item.id === id);
const unitValue = (value: unknown, statId: string) => { const unitValue = (value: unknown, statId: string) => {
@@ -67,69 +59,6 @@
const unit = attribute(statId)?.unit ?? ''; const unit = attribute(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${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') => const scaledRange = (minimum: unknown, maximum: unknown, level: unknown, referenceLevel: unknown, source: 'gear' | 'weapon') =>
levelScaledRange(minimum, maximum, level, referenceLevel ?? 50, source); levelScaledRange(minimum, maximum, level, referenceLevel ?? 50, source);
const gearBaseRange = (item: any, key: 'health' | 'armor', level: number) => const gearBaseRange = (item: any, key: 'health' | 'armor', level: number) =>
@@ -218,20 +147,6 @@
}; };
active = 'specialization:focus'; 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 talentTier = (talentId: string, tier: number) => {
const item = talent(talentId); const item = talent(talentId);
return item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? item?.tiers[0]; return item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? item?.tiers[0];
@@ -346,43 +261,7 @@
selection.level = nextLevel; selection.level = nextLevel;
}; };
const activeParts = $derived(active.split(':')); 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 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 const rollSummary = (rolls: Roll[]) => rolls
.filter((roll) => roll.statId) .filter((roll) => roll.statId)
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`) .map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`)
@@ -410,7 +289,7 @@
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` ? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES', : 'ROOT CHOICE · DEFINES ALL THREE ABILITIES',
iconUrl: selectedFocus?.specialization.iconUrl, 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 abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
const ability = selectedFocus?.abilities[index]; const ability = selectedFocus?.abilities[index];
@@ -437,7 +316,7 @@
meta: item?.category ?? '', meta: item?.category ?? '',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON', summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON',
iconUrl: weaponIcon(item?.category), 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) => { const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
@@ -455,7 +334,7 @@
meta: item ? `T${item.tier}` : '', meta: item ? `T${item.tier}` : '',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR', summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR',
iconUrl: gearIcon(slot), iconUrl: gearIcon(slot),
issue: item ? undefined : 'REQUIRED FOR CALCULATION' issue: item ? undefined : 'SELECT TO COMPLETE LOADOUT'
}; };
})); }));
const protocolCell = $derived<MatrixCell>({ const protocolCell = $derived<MatrixCell>({
@@ -467,7 +346,7 @@
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES` ? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
: 'SELECT ONE PROTOCOL', : 'SELECT ONE PROTOCOL',
iconUrl: protocolItem()?.iconUrl, iconUrl: protocolItem()?.iconUrl,
issue: protocolItem() ? undefined : 'REQUIRED FOR CALCULATION' issue: protocolItem() ? undefined : 'SELECT TO COMPLETE LOADOUT'
}); });
const loadoutConfigured = $derived(Boolean( const loadoutConfigured = $derived(Boolean(
selectedFocus selectedFocus
@@ -478,22 +357,6 @@
&& draft.protocol.itemId && 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(() => { onMount(() => {
if (data.initialBuild) return; if (data.initialBuild) return;
localStorage.removeItem('shd-build-loadout-draft-v3'); 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} {#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div> </div>
{/if} {/if}
<div class="builder-status"><span><i></i> 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> </div>
{#if message}<p class="success" role="status">{message}</p>{/if} {#if message}<p class="success" role="status">{message}</p>{/if}
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if} {#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
@@ -594,20 +457,6 @@
</section> </section>
{/each} {/each}
</div> </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-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-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> <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> </div></section>
{/each} {/each}
</div> </div>
<CombatAssumptions bind:mode={draft.calculationMode} bind:profile={draft.combatProfile}/> <PlannedCombatModel headingId="builder-combat-model-heading" />
{#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}
{/if} {/if}
{:else if activeParts[0] === 'ability'} {:else if activeParts[0] === 'ability'}
{@const abilityIndex = Number(activeParts[1])} {@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> <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> </section>
</div> </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} {:else}
<p class="error">Select a Specialization and Focus with configured abilities before attaching Skill Chips.</p> <p class="error">Select a Specialization and Focus with configured abilities before attaching Skill Chips.</p>
{/if} {/if}
@@ -749,16 +551,9 @@
</section> </section>
<aside class="loadout-summary"> <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> <LoadoutStatsSummary {summary} attributes={data.attributes} talents={data.talents} headingId="builder-known-stats-heading" />
<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-planned-model"><PlannedCombatModel headingId="builder-planned-output-heading" variant="compact" /></div>
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result} resolverHint="Use the warning markers in the loadout matrix."/>{/each}</div> <p class="notice">Anonymous drafts remain on this device. Account saves preserve the equipped-item configuration and current visibility.</p>
<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>
</aside> </aside>
</div> </div>
</main> </main>
+16 -15
View File
@@ -2,11 +2,8 @@ import { error } from '@sveltejs/kit';
import { getDatabase } from '$lib/server/database'; import { getDatabase } from '$lib/server/database';
import { getSessionUser } from '$lib/server/auth/session'; import { getSessionUser } from '$lib/server/auth/session';
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout'; import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
import { import { summarizeLoadout } from '$lib/builder/loadout-summary';
evaluateLoadoutCombat, import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
summarizeLoadout,
} from '$lib/builder/loadout-combat';
import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat';
import { avatarUrl } from '$lib/avatar'; import { avatarUrl } from '$lib/avatar';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
@@ -41,24 +38,26 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
} catch { } catch {
error(404, 'Build data is unavailable'); error(404, 'Build data is unavailable');
} }
const [gear, weapons, chips, protocols, brands, combatCatalog] = const [gear, weapons, chips, protocols, brands, summaryCatalog] =
await Promise.all([ 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(), ).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(), (await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
( (
await database.prepare('SELECT id,name,quality FROM os_protocols') await database.prepare('SELECT id,name,quality FROM os_protocols')
).all(), ).all(),
(await database.prepare('SELECT id,name FROM brand_sets')).all(), (await database.prepare('SELECT id,name FROM brand_sets')).all(),
loadSharedBuildCombatCatalog(database), loadSharedBuildSummaryCatalog(database),
]); ]);
const combatResults = evaluateLoadoutCombat( const summary = summarizeLoadout(loadout, summaryCatalog);
loadout,
combatCatalog,
summarizeLoadout(loadout, combatCatalog),
);
return { return {
build: { build: {
id: String(build.id), id: String(build.id),
@@ -85,6 +84,8 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
chips, chips,
protocols, protocols,
brands, brands,
combatResults, summary,
attributes: summaryCatalog.attributes,
talents: summaryCatalog.talents,
}; };
}; };
+16 -15
View File
@@ -1,13 +1,22 @@
<script lang="ts"> <script lang="ts">
import { gearSlots } from '$lib/builder/loadout'; import { gearSlots } from '$lib/builder/loadout';
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte'; 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(); let { data } = $props();
const gear = (id: string) => data.gear.find((item) => item.id === id); const gear = (id: string) => data.gear.find((item) => item.id === id);
const weapon = (id: string) => data.weapons.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 chip = (id: string) => data.chips.find((item) => item.id === id);
const protocol = (id: string) => data.protocols.find((item) => item.id === id); const protocol = (id: string) => data.protocols.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id); const brand = (id: string) => data.brands.find((item) => item.id === id);
const 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> </script>
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head> <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><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> <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> </header>
<section class="builder-combat-results" aria-labelledby="shared-combat-heading"> <div class="shared-build-intelligence">
<div class="builder-section-title"> <LoadoutStatsSummary summary={data.summary} attributes={data.attributes} talents={data.talents} headingId="shared-known-stats-heading" layout="page" />
<div> <PlannedCombatModel headingId="shared-planned-model-heading" headingLevel="h2" variant="compact" />
<span>LIVE COMBAT MODEL</span>
<h2 id="shared-combat-heading">CURRENT WEAPON OUTPUT</h2>
</div> </div>
<b>CURRENT ISAC RULES</b>
</div>
{#each data.combatResults as result}
<WeaponCalculationResult {result}/>
{/each}
</section>
<div class="shared-build-grid"> <div class="shared-build-grid">
<section> <section>
<header><span>WEAPONS</span><b>02 SLOTS</b></header> <header><span>WEAPONS</span><b>02 SLOTS</b></header>
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · 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>
<section> <section>
<header><span>GEAR</span><b>06 SLOTS</b></header> <header><span>GEAR</span><b>06 SLOTS</b></header>
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span></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>
<section> <section>
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header> <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} {#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> </section>
</div> </div>
+1 -1
View File
@@ -203,7 +203,7 @@
<svelte:head><title>Combat calculations — ISAC</title></svelte:head> <svelte:head><title>Combat calculations — ISAC</title></svelte:head>
<div class="module-toolbar"> <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 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> </div>
@@ -380,20 +380,20 @@
</section> </section>
<section id="scenarios" class="reference-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"> <div class="scenario-grid">
<article> <article>
<strong>MAXIMUM POTENTIAL</strong> <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>
<article> <article>
<strong>CUSTOM SCENARIO</strong> <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> </article>
</div> </div>
<aside class="contract"> <aside class="contract">
<strong>REQUIRES ASSUMPTIONS</strong> <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>
<aside class="contract protocol-contract"> <aside class="contract protocol-contract">
<strong>OS PROTOCOL TALENT UPGRADES</strong> <strong>OS PROTOCOL TALENT UPGRADES</strong>
+1 -1
View File
@@ -19,7 +19,7 @@
{ key: 'database', label: 'DATABASE', description: 'Public game-data registries and tactical lookup.' }, { 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: 'builds', label: 'BUILDS', description: 'Published loadout discovery and shared build views.' },
{ key: 'guides', label: 'GUIDES', description: 'Long-form field manuals and build walkthroughs.' }, { 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; ] as const;
const moderationModelOptions = [ const moderationModelOptions = [
{ value: 'omni-moderation-latest', label: 'LATEST' }, { value: 'omni-moderation-latest', label: 'LATEST' },