Updating mechanics for OS protocols
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Successful in 1m9s

This commit is contained in:
jb
2026-08-03 16:12:20 -04:00
parent 57068b61cd
commit 7ca6cb8458
27 changed files with 1272 additions and 280 deletions
+233 -25
View File
@@ -9006,6 +9006,47 @@ a.secondary:active {
padding: 0 9px !important;
font-size: var(--shd-type-telemetry) !important;
}
.builder-radio-toggle {
width: 100%;
min-height: 48px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
margin-bottom: 5px;
padding: 6px 8px;
border: 1px solid var(--shd-border);
background: var(--shd-builder-canvas);
color: var(--shd-text);
text-align: left;
}
.builder-radio-toggle > img,
.builder-radio-toggle > i {
width: 32px;
height: 32px;
object-fit: contain;
}
.builder-radio-toggle > span {
min-width: 0;
display: grid;
gap: 2px;
}
.builder-radio-toggle b,
.builder-radio-toggle small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.builder-radio-toggle em {
color: var(--shd-orange);
font-size: var(--shd-type-telemetry);
font-style: normal;
font-weight: 900;
}
.builder-radio-toggle:hover,
.builder-radio-toggle:focus-visible {
border-color: var(--shd-orange);
}
.builder-radio-grid > div {
display: grid;
grid-template-columns: repeat(var(--builder-radio-columns), minmax(0, 1fr));
@@ -9131,6 +9172,40 @@ a.secondary:active {
gap: 3px;
margin-top: 6px;
}
.builder-talent-slot {
min-width: 0;
padding-top: 8px;
border-top: 1px solid var(--shd-border-soft);
}
.builder-talent-slot:first-child {
padding-top: 0;
border-top: 0;
}
.builder-exotic-talent > div:first-child {
min-height: 48px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--shd-border);
background: var(--shd-builder-canvas);
}
.builder-exotic-talent > div:first-child > .item-icon {
width: 36px;
height: 36px;
}
.builder-exotic-talent > div:first-child > span {
min-width: 0;
display: grid;
gap: 2px;
}
.builder-exotic-talent small {
color: var(--shd-orange);
font-size: var(--shd-type-telemetry);
font-weight: 900;
letter-spacing: 0.07em;
}
.builder-rolls.required > div {
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.65fr);
}
@@ -9178,7 +9253,7 @@ a.secondary:active {
}
.builder-core-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 7px;
}
.builder-range-grid.weapon-damage {
@@ -9278,6 +9353,8 @@ a.secondary:active {
}
.loadout-slots > header,
.summary-section > header {
height: auto;
min-height: 36px;
display: flex;
justify-content: space-between;
align-items: center;
@@ -9356,6 +9433,116 @@ a.secondary:active {
min-height: 680px;
padding: 12px;
border-top: 2px solid var(--shd-orange);
container-type: inline-size;
}
.builder-editor-columns,
.builder-specialization-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 10px;
}
.builder-editor-lane {
min-width: 0;
align-content: start;
}
.builder-editor-lane + .builder-editor-lane {
padding-left: 16px;
border-left: 1px solid var(--shd-border);
}
.builder-editor-lane > :first-child {
margin-top: 0;
}
.builder-editor-lane .builder-weapon-controls {
grid-template-columns: minmax(180px, 1fr) minmax(120px, 0.65fr);
}
.builder-editor-lane .builder-equipment-controls {
grid-template-columns: 1fr;
}
.builder-editor-lane
.builder-equipment-controls
> .builder-radio-grid:last-child:nth-child(3) {
grid-column: auto;
}
.builder-editor-lane .builder-weapon-controls .builder-fixed {
grid-column: 1/-1;
}
.builder-editor-lane .builder-range-grid {
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
.builder-editor-lane .range-value-inputs {
grid-template-columns: minmax(0, 1fr) 72px;
}
.builder-editor-lane .builder-rolls.required > div,
.builder-editor-lane .builder-chip-rolls > div {
grid-template-columns: minmax(0, 1fr) minmax(150px, 0.72fr);
}
.builder-specialization-layout .builder-ability-chip-panels {
grid-template-columns: 1fr;
}
.builder-focus-abilities {
display: grid;
gap: var(--shd-gap-xs);
}
.builder-focus-abilities > button {
min-width: 0;
min-height: 58px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: var(--shd-gap-sm);
padding: var(--shd-gap-sm);
border: 1px solid var(--shd-border);
background: var(--shd-builder-canvas);
color: var(--shd-text);
text-align: left;
}
.builder-focus-abilities > button:hover,
.builder-focus-abilities > button:focus-visible {
border-color: var(--shd-orange);
}
.builder-focus-abilities img {
width: 38px;
height: 38px;
}
.builder-focus-abilities span {
min-width: 0;
display: grid;
gap: 2px;
}
.builder-focus-abilities small,
.builder-focus-abilities b {
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
letter-spacing: 0.6px;
}
.builder-focus-abilities strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.builder-focus-abilities b {
white-space: nowrap;
}
.builder-specialization-layout .builder-specialization-identity {
grid-template-columns: auto minmax(0, 1fr);
}
.builder-specialization-layout .builder-specialization-identity > b {
grid-column: 1/-1;
width: max-content;
}
@container (max-width: 700px) {
.builder-editor-columns,
.builder-specialization-layout {
grid-template-columns: 1fr;
gap: 12px;
}
.builder-editor-lane + .builder-editor-lane {
padding-top: 12px;
padding-left: 0;
border-top: 1px solid var(--shd-border);
border-left: 0;
}
}
.loadout-editor-head {
display: flex;
@@ -9688,11 +9875,12 @@ a.secondary:active {
}
.summary-core {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
border-top: 1px solid #303536;
border-bottom: 1px solid #303536;
}
.summary-core > div {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
@@ -9707,13 +9895,18 @@ a.secondary:active {
height: 22px;
}
.summary-core span {
min-width: 0;
display: grid;
color: #687072;
font-size: var(--shd-type-telemetry);
overflow-wrap: anywhere;
}
.summary-core b {
min-width: 0;
overflow: hidden;
color: #d6d9da;
font-size: var(--shd-type-telemetry);
text-overflow: ellipsis;
}
.summary-scroll {
max-height: calc(100vh - 340px);
@@ -9726,14 +9919,15 @@ a.secondary:active {
}
.summary-stat,
.summary-bonus {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(70px, auto);
gap: 8px;
padding: 8px 10px;
border-top: 1px solid #252a2b;
}
.summary-stat > span,
.summary-bonus > span {
min-width: 0;
display: grid;
gap: 3px;
font-size: var(--shd-type-telemetry);
@@ -9744,10 +9938,14 @@ a.secondary:active {
font-size: var(--shd-type-telemetry);
}
.summary-stat > b {
min-width: 0;
overflow-wrap: anywhere;
color: #d9dbdc;
font-size: var(--shd-type-telemetry);
}
.summary-bonus em {
min-width: 0;
overflow-wrap: anywhere;
color: var(--shd-success);
font-size: var(--shd-type-telemetry);
font-style: normal;
@@ -12003,10 +12201,7 @@ a.secondary:active {
}
.loadout-workspace {
min-width: 0;
grid-template-columns: minmax(300px, 350px) minmax(480px, 1fr) minmax(
290px,
340px
);
grid-template-columns: minmax(300px, 350px) minmax(0, 1fr);
gap: 6px;
}
.loadout-workspace > *,
@@ -12040,8 +12235,20 @@ a.secondary:active {
}
.loadout-summary {
min-width: 0;
position: static;
grid-column: 1/3;
max-height: none;
overflow: visible;
background: var(--shd-surface);
}
.loadout-summary .summary-scroll {
max-height: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
}
.builder-editor-lane .builder-radio-grid:not(.icon-only) > div {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.builder-picker-current > * {
min-width: 0;
}
@@ -12118,11 +12325,13 @@ a.secondary:active {
.loadout-summary {
position: static;
grid-column: 1/3;
max-height: none;
overflow: visible;
}
.summary-scroll {
max-height: none;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
}
}
@media (max-width: 1050px) {
@@ -12699,10 +12908,9 @@ a.secondary:active {
}
.mod-secondary-pool-option {
display: grid;
grid-template-columns: 30px minmax(220px, 0.9fr) repeat(
3,
minmax(285px, 1fr)
) 30px;
grid-template-columns:
30px minmax(220px, 0.9fr) repeat(3, minmax(285px, 1fr))
30px;
align-items: stretch;
gap: 6px;
padding: 6px;
@@ -12814,10 +13022,9 @@ a.secondary:active {
}
.skill-chip-profile-option.selectable {
grid-template-columns: 32px minmax(210px, 1.2fr) repeat(
3,
minmax(130px, 1fr)
) 34px;
grid-template-columns:
32px minmax(210px, 1.2fr) repeat(3, minmax(130px, 1fr))
34px;
}
.skill-chip-profile-option > div:first-child,
@@ -15494,18 +15701,23 @@ a.secondary:active {
.builder-ability-chip-panels > section {
min-width: 0;
display: grid;
grid-template-rows: auto minmax(48px, auto) auto;
grid-template-rows: auto auto;
gap: var(--shd-gap-sm);
padding: var(--shd-gap-sm);
border: 1px solid var(--shd-border);
background: var(--shd-surface);
}
.builder-ability-chip-panels header {
height: auto;
position: static;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--shd-gap-sm);
align-items: center;
padding: 0;
border: 0;
background: transparent;
}
.builder-ability-chip-panels header img {
width: 38px;
@@ -15528,13 +15740,6 @@ a.secondary:active {
.builder-ability-chip-panels header b {
white-space: nowrap;
}
.builder-ability-chip-panels > section > p {
min-width: 0;
margin: 0;
color: var(--shd-text-muted);
font-size: var(--shd-type-supporting);
overflow-wrap: anywhere;
}
.builder-ability-chip-panels > section > div {
min-width: 0;
display: grid;
@@ -15600,6 +15805,9 @@ a.secondary:active {
background: var(--shd-model-surface);
}
.builder-focus-tree > section > header {
height: auto;
min-height: 32px;
position: static;
display: flex;
justify-content: space-between;
padding: 6px 8px;
+2 -1
View File
@@ -259,7 +259,7 @@ describe('loadout mechanic timeline', () => {
).toEqual([1, 2]);
});
it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => {
it('falls back to a documented Tier I mechanic when exotic Tier II is absent', () => {
const persistedRule = {
rule: {
id: 'tier-i-only',
@@ -327,6 +327,7 @@ describe('loadout mechanic timeline', () => {
const adapted = equippedConditionalProgram({
key: 'weapon:0:exotic-talent',
tier: 2,
isExotic: true,
rule: persistedRule,
});
+1 -1
View File
@@ -283,7 +283,7 @@ export function equippedConditionalProgram(
: 'base';
return (
conditionalProgram(equipped.rule, variant) ??
(equipmentTalent && variant === 'II'
(equipmentTalent && equipped.isExotic && variant === 'II'
? conditionalProgram(equipped.rule, 'I')
: null)
);
+97 -7
View File
@@ -425,11 +425,58 @@ describe('summarizeLoadout', () => {
expect(stat(summary, 'mechanic')).toBeUndefined();
});
it('renders the selected Talent tier without applying its mechanic outcomes to stats', () => {
it('uses a gear Talent tier independently from the gear tier', () => {
const draft = createDraft();
draft.gear.Holster.itemId = 'gear';
draft.gear.Holster.talentId = 'talent';
Object.assign(draft.gear.Holster, { talentTier: 2 });
const summary = summarizeLoadout(
draft,
catalog({
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
gear: [{ id: 'gear', tier: 0, base_armor_min: null }],
talents: [
{
id: 'talent',
name: 'Reinforced',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'bonus', value: 20 }],
},
],
},
],
}),
);
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent',
tier: 2,
tierLabel: 'TIER II',
description: 'Gain 20%.',
}),
]);
});
it('uses each weapon Talent tier independently without applying mechanic outcomes to stats', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
draft.weapons[0].tier = 2;
draft.weapons[0].talent1Id = 'talent';
draft.weapons[0].talent1Id = 'talent-one';
draft.weapons[0].talent2Id = 'talent-two';
Object.assign(draft.weapons[0], {
talent1Tier: 1,
talent2Tier: 2,
exoticTalentTier: 1,
});
const rule = {
clauses: [
@@ -455,13 +502,13 @@ describe('summarizeLoadout', () => {
{
id: 'weapon',
name: 'Test Weapon',
exotic_talent_id: null,
exotic_talent_id: 'exotic-talent',
baseBonuses: [],
},
],
talents: [
{
id: 'talent',
id: 'talent-one',
name: 'Measured',
template: 'Gain {1} for {2}.',
tiers: [
@@ -482,17 +529,60 @@ describe('summarizeLoadout', () => {
],
rule,
},
{
id: 'talent-two',
name: 'Calibrated',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'talent-stat', value: 30 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'talent-stat', value: 40 }],
},
],
},
{
id: 'exotic-talent',
name: 'Exotic Talent',
template: 'Gain {1}.',
tiers: [
{
tier: 'I',
effects: [{ stat_attribute_id: 'talent-stat', value: 50 }],
},
{
tier: 'II',
effects: [{ stat_attribute_id: 'talent-stat', value: 60 }],
},
],
},
],
}),
);
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent',
tier: 2,
description: 'Gain 20% for 3 Shots.',
id: 'talent-one',
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 10% for 2 Shots.',
rule,
}),
expect.objectContaining({
id: 'talent-two',
tier: 2,
tierLabel: 'TIER II',
description: 'Gain 40%.',
}),
expect.objectContaining({
id: 'exotic-talent',
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 50%.',
}),
]);
expect(stat(summary, 'talent-stat')).toBeUndefined();
});
+48 -18
View File
@@ -1,4 +1,9 @@
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
import {
gearSlots,
scaled,
type LoadoutDraft,
type TalentTier,
} from '$lib/builder/loadout';
export type LoadoutCatalog = {
attributes: any[];
@@ -100,18 +105,21 @@ function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
}
function talentTier(catalog: LoadoutCatalog, talentId: string, tier: number) {
function talentTier(
catalog: LoadoutCatalog,
talentId: string,
tier: TalentTier,
) {
const item = catalog.talents.find((entry) => entry.id === talentId);
return (
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
item?.tiers[0]
return item?.tiers.find(
(entry: any) => entry.tier === (tier === 2 ? 'II' : 'I'),
);
}
function talentDescription(
catalog: LoadoutCatalog,
talentId: string,
tier: number,
tier: TalentTier,
) {
const item = catalog.talents.find((entry) => entry.id === talentId);
const configured = talentTier(catalog, talentId, tier);
@@ -255,12 +263,14 @@ export function summarizeLoadout(
rule:
catalog.talents.find((entry) => entry.id === selection.talentId)
?.rule ?? null,
tier: Number(item.tier),
tier: selection.talentTier,
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
isExotic: false,
source: slot,
description: talentDescription(
catalog,
selection.talentId,
Number(item.tier),
selection.talentTier,
),
});
}
@@ -305,19 +315,39 @@ export function summarizeLoadout(
roll.value,
`${index ? 'Secondary' : 'Primary'} weapon roll`,
);
for (const talentId of [
selection.talent1Id,
selection.talent2Id,
item.exotic_talent_id,
].filter(Boolean)) {
const equippedWeaponTalents = [
{
id: selection.talent1Id,
tier: selection.talent1Tier,
isExotic: false,
},
{
id: selection.talent2Id,
tier: selection.talent2Tier,
isExotic: false,
},
{
id: item.exotic_talent_id,
tier: selection.exoticTalentTier,
isExotic: true,
},
].filter((entry) => entry.id);
for (const equippedTalent of equippedWeaponTalents) {
equippedTalents.push({
key: `weapon:${index}:${talentId}`,
id: talentId,
key: `weapon:${index}:${equippedTalent.id}`,
id: equippedTalent.id,
rule:
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
tier: selection.tier,
catalog.talents.find((entry) => entry.id === equippedTalent.id)
?.rule ?? null,
tier: equippedTalent.tier,
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
isExotic: equippedTalent.isExotic,
source: index ? 'Secondary weapon' : 'Primary weapon',
description: talentDescription(catalog, talentId, selection.tier),
description: talentDescription(
catalog,
equippedTalent.id,
equippedTalent.tier,
),
});
}
}
+125 -2
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
createDraft,
migrateLoadoutDraftV5,
parseLoadoutDraft,
skillChipSecondaryCapacity,
type SkillChipQuality,
@@ -8,13 +9,40 @@ import {
const copyDraft = () => structuredClone(createDraft());
const talentTierFields = [
[
'gear talentTier',
(draft: ReturnType<typeof copyDraft>) =>
draft.gear.Holster as unknown as Record<string, unknown>,
'talentTier',
],
[
'weapon talent1Tier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'talent1Tier',
],
[
'weapon talent2Tier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'talent2Tier',
],
[
'weapon exoticTalentTier',
(draft: ReturnType<typeof copyDraft>) =>
draft.weapons[0] as unknown as Record<string, unknown>,
'exoticTalentTier',
],
] as const;
describe('parseLoadoutDraft', () => {
it('accepts a current draft and returns an independent clone', () => {
const draft = createDraft();
const parsed = parseLoadoutDraft(draft);
expect(parsed).toEqual(draft);
expect(parsed?.version).toBe(5);
expect(parsed?.version).toBe(6);
expect(parsed?.chips[0]).toEqual({
setId: '',
quality: 'Standard',
@@ -27,6 +55,37 @@ describe('parseLoadoutDraft', () => {
expect(draft.gear.Holster.level).toBe(50);
});
it('defaults every equipment Talent tier to Tier I', () => {
const draft = createDraft();
expect(
Object.values(draft.gear).map(
(selection) =>
(selection as unknown as Record<string, unknown>).talentTier,
),
).toEqual(Array(6).fill(1));
expect(
draft.weapons.map((selection) => {
const weapon = selection as unknown as Record<string, unknown>;
return [
weapon.talent1Tier,
weapon.talent2Tier,
weapon.exoticTalentTier,
];
}),
).toEqual([
[1, 1, 1],
[1, 1, 1],
]);
});
it('accepts Tier II for every equipment Talent selection', () => {
const draft = copyDraft();
for (const [, select, key] of talentTierFields) select(draft)[key] = 2;
expect(parseLoadoutDraft(draft)).toEqual(draft);
});
it('rejects the retired version 1 three-chip shape', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 1;
@@ -42,13 +101,77 @@ describe('parseLoadoutDraft', () => {
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('rejects a version 5 draft that does not contain exactly nine chips', () => {
it('rejects the retired version 5 schema', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 5;
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('migrates version 5 equipment-derived Talent tiers once', () => {
const legacy = copyDraft() as unknown as Record<string, any>;
legacy.version = 5;
for (const selection of Object.values(legacy.gear) as Record<
string,
any
>[]) {
delete selection.talentTier;
}
for (const selection of legacy.weapons as Record<string, any>[]) {
delete selection.talent1Tier;
delete selection.talent2Tier;
delete selection.exoticTalentTier;
}
legacy.gear.Holster.itemId = 'tier-two-gear';
legacy.weapons[0].itemId = 'exotic-weapon';
legacy.weapons[0].tier = 2;
const migrated = migrateLoadoutDraftV5(legacy, {
gear: [{ id: 'tier-two-gear', tier: 2 }],
weapons: [{ id: 'exotic-weapon', exotic_talent_id: 'exotic-talent' }],
talents: [
{
id: 'exotic-talent',
tiers: [{ tier: 'I' }, { tier: 'II' }],
},
],
});
expect(migrated?.version).toBe(6);
expect(migrated?.gear.Holster.talentTier).toBe(2);
expect(migrated?.gear.Gloves.talentTier).toBe(1);
expect(migrated?.weapons[0]).toMatchObject({
talent1Tier: 2,
talent2Tier: 2,
exoticTalentTier: 2,
});
});
it('rejects a current draft that does not contain exactly nine chips', () => {
const draft = copyDraft();
draft.chips.pop();
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each(talentTierFields)('rejects a draft missing %s', (_, select, key) => {
const draft = copyDraft();
delete select(draft)[key];
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each(
talentTierFields.flatMap(([name, select, key]) =>
[0, 1.5, 3].map((value) => [name, value, select, key] as const),
),
)('rejects %s value %s', (_, value, select, key) => {
const draft = copyDraft();
select(draft)[key] = value;
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each([
['Standard', 0],
['Specialized', 1],
+55 -3
View File
@@ -1,4 +1,5 @@
export type Roll = { statId: string; value: number };
export type TalentTier = 1 | 2;
export const skillChipQualities = [
'Standard',
'Specialized',
@@ -17,6 +18,7 @@ export type GearSelection = {
level: number;
brandId: string;
talentId: string;
talentTier: TalentTier;
health: number;
armor: number;
firepower: number;
@@ -34,7 +36,10 @@ export type WeaponSelection = {
toughness: number;
engineering: number;
talent1Id: string;
talent1Tier: TalentTier;
talent2Id: string;
talent2Tier: TalentTier;
exoticTalentTier: TalentTier;
rolls: [Roll, Roll];
};
export type ChipSelection = {
@@ -81,7 +86,7 @@ export type SpecializationSelection = {
nodeRanks: Record<string, number>;
};
export type LoadoutDraft = {
version: 5;
version: 6;
buildName: string;
calculationMode: CalculationMode;
gear: Record<string, GearSelection>;
@@ -118,6 +123,7 @@ export const blankGear = (): GearSelection => ({
level: 50,
brandId: '',
talentId: '',
talentTier: 1,
health: 0,
armor: 0,
firepower: 0,
@@ -135,7 +141,10 @@ export const blankWeapon = (): WeaponSelection => ({
toughness: 0,
engineering: 0,
talent1Id: '',
talent1Tier: 1,
talent2Id: '',
talent2Tier: 1,
exoticTalentTier: 1,
rolls: [blankRoll(), blankRoll()],
});
export const blankChip = (): ChipSelection => ({
@@ -161,7 +170,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
extraHealthActive: false,
});
export const createDraft = (): LoadoutDraft => ({
version: 5,
version: 6,
buildName: 'Untitled loadout',
calculationMode: 'maximum',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
@@ -187,6 +196,39 @@ export const createDraft = (): LoadoutDraft => ({
},
});
export function migrateLoadoutDraftV5(
value: unknown,
catalog: { gear: any[]; weapons: any[]; talents: any[] },
): LoadoutDraft | null {
if (!isRecord(value) || value.version !== 5) return null;
const migrated = structuredClone(value);
if (!isRecord(migrated.gear) || !Array.isArray(migrated.weapons)) return null;
for (const slot of gearSlots) {
const selection = migrated.gear[slot];
if (!isRecord(selection)) return null;
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
selection.talentTier = Number(item?.tier) >= 2 ? 2 : 1;
}
for (const selection of migrated.weapons) {
if (!isRecord(selection)) return null;
const inheritedTier: TalentTier = Number(selection.tier) >= 2 ? 2 : 1;
selection.talent1Tier = inheritedTier;
selection.talent2Tier = inheritedTier;
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
const exoticTalent = catalog.talents.find(
(entry) => entry.id === item?.exotic_talent_id,
);
selection.exoticTalentTier =
inheritedTier === 2 &&
exoticTalent?.tiers?.some((entry: any) => entry.tier === 'II')
? 2
: 1;
}
migrated.version = 6;
return parseLoadoutDraft(migrated);
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
@@ -194,6 +236,8 @@ const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
keys.every((key) => key in value);
const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const isTalentTier = (value: unknown): value is TalentTier =>
value === 1 || value === 2;
const isNumberRecord = (value: unknown) =>
isRecord(value) &&
Object.values(value).every(
@@ -213,6 +257,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
'level',
'brandId',
'talentId',
'talentTier',
'health',
'armor',
'firepower',
@@ -224,6 +269,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
['itemId', 'brandId', 'talentId'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talentTier) &&
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
@@ -241,12 +287,18 @@ const isWeaponSelection = (value: unknown): value is WeaponSelection =>
'toughness',
'engineering',
'talent1Id',
'talent1Tier',
'talent2Id',
'talent2Tier',
'exoticTalentTier',
'rolls',
]) &&
['itemId', 'talent1Id', 'talent2Id'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talent1Tier) &&
isTalentTier(value.talent2Tier) &&
isTalentTier(value.exoticTalentTier) &&
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
@@ -369,7 +421,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
'skillActivations',
'specialization',
]) ||
value.version !== 5 ||
value.version !== 6 ||
typeof value.buildName !== 'string' ||
!['maximum', 'custom'].includes(String(value.calculationMode))
)
+7 -5
View File
@@ -791,9 +791,9 @@ describe('conditional mechanic authoring payload', () => {
});
});
it('rejects invalid pre-event timing for current Health percentage references', () => {
const invalid = draft();
invalid.clauses[0].if = {
it('preserves pre-event timing for current Health percentage references', () => {
const value = draft();
value.clauses[0].if = {
kind: 'compare',
left: {
source: 'combat',
@@ -803,11 +803,13 @@ describe('conditional mechanic authoring payload', () => {
operator: 'lt',
right: 50,
};
const result = parseConditionalMechanicDraft(invalid, {
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(result.ok).toBe(false);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.draft.clauses[0].if).toEqual(value.clauses[0].if);
});
it('accepts compatible radius targeting and rejects incompatible scopes', () => {
+1 -2
View File
@@ -426,8 +426,7 @@ function isReference(value: unknown): value is ValueReference {
Boolean(value.path.trim()) &&
(value.timing === undefined ||
value.timing === 'pre_event' ||
value.timing === 'current') &&
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
value.timing === 'current')
);
case 'equipment':
return typeof value.path === 'string' && Boolean(value.path.trim());
@@ -850,7 +850,7 @@ describe('conditional-effect persistence adapter', () => {
left: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current',
timing: 'pre_event',
},
operator: 'lt',
right: 25,
+1 -2
View File
@@ -216,8 +216,7 @@ function isValueReference(value: unknown) {
Boolean(value.path.trim()) &&
(value.timing === undefined ||
value.timing === 'pre_event' ||
value.timing === 'current') &&
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
value.timing === 'current')
);
}
if (value.source === 'attribute') {
+61 -18
View File
@@ -2834,13 +2834,13 @@ describe('mechanic validation and pseudo-script', () => {
]);
});
it('rejects pre-event timing for the derived current Health percentage', () => {
const invalid: MechanicProgram = {
id: 'invalid-health-percent',
name: 'Invalid Health Percent',
it('evaluates pre-event timing for the derived current Health percentage', () => {
const program: MechanicProgram = {
id: 'pre-event-health-percent',
name: 'Pre-event Health Percent',
clauses: [
{
id: 'invalid',
id: 'full-health-before-event',
when: 'tick',
if: {
kind: 'compare',
@@ -2849,25 +2849,32 @@ describe('mechanic validation and pseudo-script', () => {
path: 'currentHealthPercent',
timing: 'pre_event',
},
operator: 'lt',
right: 50,
operator: 'gte',
right: 100,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'matched',
label: 'Matched',
phase: 'on_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
value: 1,
},
],
},
],
};
const result = validateMechanicProgram(invalid);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toEqual(
expect.arrayContaining([
expect.stringContaining(
'current Health percentage must use current timing',
),
]),
);
}
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
expect(
evaluateMechanicProgram(program, [{ kind: 'tick', at: 0 }], custom(1), {
maxHealth: 100,
currentHealth: 100,
}).outcomes.map((outcome) => outcome.outcomeId),
).toEqual(['matched']);
});
it('formats nested conditions and state operations deterministically', () => {
@@ -3104,6 +3111,42 @@ describe('mechanic validation and pseudo-script', () => {
expect(
maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0),
).toBe(true);
const skillProgram: MechanicProgram = {
...program,
id: 'skill-hit-explosion',
clauses: program.clauses.map((clause) => ({
...clause,
when: 'skill_hit',
if: undefined,
})),
};
expect(validateMechanicProgram(skillProgram)).toMatchObject({ ok: true });
expect(
evaluateMechanicProgram(
skillProgram,
[
{
kind: 'skill_hit',
at: 1,
targetId: 'enemy-b',
areaTargetSample: {
center: 'event_target',
validTargets: [
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
],
},
},
],
custom(5),
{
maxHealth: 1000,
currentHealth: 1000,
attributes: { firepower: 100 },
},
).outcomes.map((outcome) => outcome.targetId),
).toEqual(['enemy-a', 'enemy-b']);
});
it('requires authoritative area evidence only when an area outcome activates', () => {
+13 -14
View File
@@ -97,6 +97,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<
description:
'The Agent deploys or activates a specialization ability. An assigned-ability deployment may supply its active duration.',
fields: [
{
path: 'values.abilityId',
label: 'Deployed ability ID',
type: 'string',
},
{
path: 'values.assignedAbilityDurationSeconds',
label: 'Assigned ability duration',
@@ -123,8 +128,9 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<
key: 'skill_hit',
label: 'Skill hit',
description:
'A deployed skill connects with a target. Critical and distance details belong in event values.',
'A deployed skill connects with a target. Critical and distance details belong in event values. An optional area-target sample lists valid entities by distance from the hit target.',
fields: [
{ path: 'values.abilityId', label: 'Source ability ID', type: 'string' },
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
{
path: 'values.assignedAbilityActive',
@@ -1892,15 +1898,8 @@ function validateReference(
}
if (reference.source === 'event' && !reference.path.trim())
errors.push(`${path} requires an event path.`);
if (reference.source === 'combat') {
if (!reference.path.trim()) errors.push(`${path} requires a combat path.`);
if (
reference.path === 'currentHealthPercent' &&
reference.timing === 'pre_event'
) {
errors.push(`${path} current Health percentage must use current timing.`);
}
}
if (reference.source === 'combat' && !reference.path.trim())
errors.push(`${path} requires a combat path.`);
if (reference.source === 'attribute' && !reference.attributeId.trim())
errors.push(`${path} requires an Attribute ID.`);
if (reference.source === 'equipment' && !reference.path.trim())
@@ -2067,7 +2066,7 @@ function validateMechanicEvents(events: MechanicEvent[]) {
if (sample === undefined) continue;
const samplePath = `Event ${index + 1} (${definition.label}) area-target sample`;
if (
event.kind !== 'weapon_hit' ||
!['weapon_hit', 'skill_hit'].includes(event.kind) ||
!objectValue(sample) ||
sample.center !== 'event_target' ||
!Array.isArray(sample.validTargets) ||
@@ -2075,7 +2074,7 @@ function validateMechanicEvents(events: MechanicEvent[]) {
!event.targetId.trim()
) {
errors.push(
`${samplePath} requires a weapon-hit event target and event-target-centered valid targets.`,
`${samplePath} requires a weapon-hit or skill-hit event target and event-target-centered valid targets.`,
);
continue;
}
@@ -2769,10 +2768,10 @@ export function validateMechanicProgram(
if (targetingError) errors.push(`${outcomePath} ${targetingError}`);
if (
outcome.targeting?.kind === 'event_target_area' &&
clause.when !== 'weapon_hit'
!['weapon_hit', 'skill_hit'].includes(clause.when)
) {
errors.push(
`${outcomePath} event-target area damage requires a weapon-hit clause.`,
`${outcomePath} event-target area damage requires a weapon-hit or skill-hit clause.`,
);
}
if (
+24 -15
View File
@@ -24,6 +24,8 @@
} = $props();
let query = $state('');
let open = $state(false);
const selected = $derived(items.find((item) => item.id === value));
const visible = $derived(items.filter((item) =>
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
));
@@ -31,26 +33,33 @@
function choose(id: string) {
value = id;
onchange?.(id);
if (searchable) {
open = false;
query = '';
}
}
</script>
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
<legend>{label}</legend>
{#if searchable}
{#if searchable}<button type="button" class="builder-radio-toggle" aria-expanded={open} onclick={() => open = !open}><ItemIcon src={selected?.iconUrl} alt=""/><span><b>{selected?.name ?? 'SELECT OPTION'}</b><small>{selected?.category ?? 'NOT CONFIGURED'}</small></span><em>{open ? 'CLOSE' : 'CHANGE'}</em></button>{/if}
{#if searchable && open}
<input class="builder-radio-search" type="search" bind:value={query} placeholder="SEARCH AVAILABLE OPTIONS" aria-label={`Search ${label.toLocaleLowerCase()}`}/>
{/if}
<div>
{#each visible as item}
<label title={item.name}>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
<span>
<ItemIcon src={item.iconUrl} alt=""/>
<b>{item.name}</b>
{#if item.category}<small>{item.category}</small>{/if}
</span>
</label>
{:else}
<p>NO MATCHING OPTIONS</p>
{/each}
</div>
{#if !searchable || open}
<div>
{#each visible as item}
<label title={item.name}>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
<span>
<ItemIcon src={item.iconUrl} alt=""/>
<b>{item.name}</b>
{#if item.category}<small>{item.category}</small>{/if}
</span>
</label>
{:else}
<p>NO MATCHING OPTIONS</p>
{/each}
</div>
{/if}
</fieldset>
+12 -13
View File
@@ -37,9 +37,9 @@
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
const issueLabel = (issue: string) => {
if (/not configured|required/i.test(issue)) return 'ACTION REQUIRED';
if (/incomplete/i.test(issue)) return 'DATA INCOMPLETE';
return 'CHECK CONFIGURATION';
if (/not configured|required/i.test(issue)) return 'REQUIRED';
if (/incomplete/i.test(issue)) return 'INCOMPLETE';
return 'CHECK';
};
</script>
@@ -60,7 +60,7 @@
onclick={() => onselect(doctrine.id)}
>
<ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" />
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em class:visible={selected(doctrine)}>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div>
@@ -68,7 +68,7 @@
{#each abilities as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
</button>
{/each}
</div>
@@ -80,7 +80,7 @@
{#each weapons as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
@@ -93,7 +93,7 @@
{#each gear as cell}
<button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<span><small>{cell.label}</small><strong>{cell.name}</strong><em class:visible={selected(cell)}>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
{/each}
@@ -104,7 +104,7 @@
<small class="section-label">AGENT SYSTEM · ONE OS PROTOCOL</small>
<button type="button" class:active={selected(protocol)} class:needs-attention={Boolean(protocol.issue)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}>
<ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" />
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em class:visible={selected(protocol)}>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
<i aria-hidden="true"></i>
</button>
</div>
@@ -112,21 +112,20 @@
<style>
.loadout-matrix{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
.loadout-matrix>header{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
.loadout-matrix>header{height:auto;min-height:40px;position:static;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
.loadout-matrix>header b{color:var(--shd-warning);font-size:9px;text-align:right}.loadout-matrix>header b.ready{color:var(--shd-success)}
.matrix-section{padding:5px;border-bottom:1px solid var(--shd-border)}.matrix-section:last-child{border-bottom:0}
.section-label{display:block;padding:2px 3px 5px;color:var(--shd-text-dim);font-size:9px;font-weight:900;letter-spacing:.09em}
.doctrine-cell,.equipment-cells button,.ability-cells button,.protocol-section>button{min-width:0;display:grid;grid-template-columns:34px minmax(0,1fr) 3px;align-items:center;gap:7px;padding:7px;border:1px solid var(--shd-border-soft);background:var(--shd-builder-canvas);color:var(--shd-text);text-align:left}
.doctrine-cell{width:100%;min-height:76px;grid-template-columns:42px minmax(0,1fr) 3px}.doctrine-cell :global(.matrix-icon){width:40px;height:40px}
:global(.matrix-icon){width:30px;height:30px;object-fit:contain}
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button em{display:none}button em.visible{display:block}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)}
button.needs-attention{border-bottom-color:color-mix(in srgb,var(--shd-danger) 68%,var(--shd-border-soft))}button.needs-attention>i{background:var(--shd-danger)}button mark{width:fit-content;max-width:100%;overflow:hidden;margin-top:2px;padding:2px 4px;border:1px solid color-mix(in srgb,var(--shd-danger) 58%,transparent);background:#1d1212;color:var(--shd-danger);font-size:8px;font-weight:900;letter-spacing:.07em;line-height:1.2;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}
.supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em}
.ability-cells{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:3px}.ability-cells button{min-height:68px;grid-template-columns:25px minmax(0,1fr);padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
.ability-cells{display:grid;grid-template-columns:1fr;gap:3px}.ability-cells button{min-height:48px;grid-template-columns:25px minmax(0,1fr);padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
.equipment-cells{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px}
button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
@media(max-width:1240px){.ability-cells{grid-template-columns:1fr}.ability-cells button{min-height:48px}}
@media(max-width:800px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:800px) and (min-width:651px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}.ability-cells button{min-height:68px}}
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}}
</style>
@@ -40,7 +40,6 @@
<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}
@@ -78,24 +77,32 @@
<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>
<details open={layout === 'page'}>
<summary><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source}{equipped.tierLabel ? ` · ${equipped.tierLabel}` : ''}</small></summary>
<p>{equipped.description}</p>
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
</span>
</details>
</div>
{:else}
<p>Selected equipment Talents appear here.</p>
{/each}
</div>
</div>
<p class="known-stats-note">Totals reflect this saved loadout and the current canonical item definitions. They include fixed values, rolls, inherited profiles, enabled augments, set bonuses, Protocol effects, and static Specialization rank effects. Conditional Talent outcomes are reserved for the advanced combat model.</p>
<p class="known-stats-note">Includes deterministic equipment values only. Conditional Talent effects remain outside current totals.</p>
</section>
<style>
.loadout-known-stats{background:var(--shd-surface)}
.summary-talent{grid-template-columns:1fr;cursor:default}
.summary-talent details,.summary-talent summary{min-width:0}
.summary-talent summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;cursor:pointer;list-style:none}
.summary-talent summary::-webkit-details-marker{display:none}
.summary-talent summary:after{content:'+';grid-column:2;grid-row:1/3;color:var(--shd-orange);font-size:14px}
.summary-talent details[open] summary:after{content:'-'}
.summary-talent summary b,.summary-talent summary small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.summary-talent summary small{grid-column:1;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}
.summary-talent details>p{margin:8px 0 0;color:var(--shd-text-muted);font:var(--shd-type-telemetry)/1.45 var(--shd-font-editorial)}
.summary-talent summary:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:3px}
.known-stats-note{margin:0;padding:11px;border-top:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.45}
.page{border:1px solid var(--shd-border);border-top:2px solid var(--shd-success)}
.page .summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}
@@ -0,0 +1,61 @@
<script lang="ts">
import ItemIcon from '$lib/components/ItemIcon.svelte';
import type { TalentTier } from '$lib/builder/loadout';
let {
label = 'TALENT TIER',
name,
talent,
value = $bindable(1),
onchange
}: {
label?: string;
name: string;
talent: { name: string; iconUrl?: string; tiers?: Array<{ tier: string }> };
value?: TalentTier;
onchange?: (tier: TalentTier) => void;
} = $props();
const available = (tier: TalentTier) => talent.tiers?.some((entry) => entry.tier === (tier === 2 ? 'II' : 'I')) ?? false;
const choose = (tier: TalentTier) => {
value = tier;
onchange?.(tier);
};
</script>
<fieldset class="talent-tier-choice">
<legend>{label}</legend>
<div>
{#each [1, 2] as tier}
<label class:tier-two={tier === 2}>
<input type="radio" {name} value={tier} checked={value === tier} disabled={!available(tier as TalentTier)} onchange={() => choose(tier as TalentTier)}/>
<span>
<ItemIcon src={talent.iconUrl} alt="" className="talent-tier-icon"/>
<small>TIER {tier === 2 ? 'II' : 'I'}</small>
<b>T{tier}</b>
<em>{available(tier as TalentTier) ? value === tier ? 'SELECTED' : 'SELECT' : 'UNAVAILABLE'}</em>
</span>
</label>
{/each}
</div>
</fieldset>
<style>
.talent-tier-choice{min-width:0;margin:8px 0 0;padding:0;border:0}
.talent-tier-choice legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.07em}
.talent-tier-choice>div{display:grid;grid-template-columns:1fr 1fr;gap:5px}
.talent-tier-choice label{min-width:0;cursor:pointer}
.talent-tier-choice input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}
.talent-tier-choice span{min-height:64px;display:grid;grid-template-columns:42px minmax(0,1fr) auto;grid-template-rows:auto auto;align-items:center;gap:1px 8px;padding:7px;border:1px solid var(--shd-border);background:var(--shd-builder-canvas);color:var(--shd-text);transition:var(--shd-transition)}
.talent-tier-choice :global(.talent-tier-icon){grid-row:1/3;width:40px;height:40px;object-fit:contain}
.talent-tier-choice .tier-two :global(.talent-tier-icon){filter:sepia(.3) saturate(1.45) hue-rotate(335deg) brightness(1.12)}
.talent-tier-choice small{align-self:end;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.06em}
.talent-tier-choice b{grid-column:3;grid-row:1/3;color:var(--shd-text-muted);font-size:18px}
.talent-tier-choice em{align-self:start;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900}
.talent-tier-choice label:hover span{border-color:var(--shd-border-strong);background:var(--shd-surface-active)}
.talent-tier-choice input:checked+span{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 0 -2px var(--shd-orange)}
.talent-tier-choice input:checked+span b,.talent-tier-choice input:checked+span em{color:var(--shd-orange)}
.talent-tier-choice input:focus-visible+span{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
.talent-tier-choice input:disabled+span{opacity:.38;cursor:not-allowed;filter:saturate(.35)}
@media(max-width:430px){.talent-tier-choice span{grid-template-columns:36px minmax(0,1fr);}.talent-tier-choice :global(.talent-tier-icon){width:34px;height:34px}.talent-tier-choice b{display:none}}
</style>
+2 -2
View File
@@ -77,7 +77,7 @@ export async function syncArticleTags(database: Database) {
`specialization-${item.id}`,
'specialization',
String(item.name),
`specialization-${slugify(String(item.name))}`,
`specialization-${slugify(String(item.name))}-${item.id}`,
'specialization',
item.id,
Number(item.active) ? 1 : 0,
@@ -93,7 +93,7 @@ export async function syncArticleTags(database: Database) {
`focus-${item.id}`,
'focus',
name,
`focus-${slugify(name)}`,
`focus-${slugify(name)}-${item.id}`,
'focus',
item.id,
Number(item.active) ? 1 : 0,
+1 -1
View File
@@ -59,7 +59,7 @@ export async function loadSharedBuildProjection(
).all(),
(
await database.prepare(
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type,exotic_talent_id FROM weapons',
)
).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
+74
View File
@@ -125,6 +125,74 @@ async function validateSkillChipSelections(
return null;
}
async function validateEquipmentTalentSelections(
database: Awaited<ReturnType<typeof getDatabase>>,
loadout: LoadoutDraft,
) {
const [gearTalents, weaponTalents, weapons, talentTiers] = await Promise.all([
(
await database.prepare('SELECT gear_id,talent_id FROM gear_talents')
).all(),
(
await database.prepare(
'SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options',
)
).all(),
(await database.prepare('SELECT id,exotic_talent_id FROM weapons')).all(),
(await database.prepare('SELECT talent_id,tier FROM talent_tiers')).all(),
]);
const hasTier = (talentId: string, tier: 1 | 2) =>
talentTiers.some(
(entry) =>
String(entry.talent_id) === talentId &&
String(entry.tier) === (tier === 2 ? 'II' : 'I'),
);
for (const slot of Object.keys(loadout.gear)) {
const selection = loadout.gear[slot];
if (!selection.talentId) continue;
if (
!gearTalents.some(
(entry) =>
String(entry.gear_id) === selection.itemId &&
String(entry.talent_id) === selection.talentId,
)
)
return `${slot} has a Talent that is not available for the equipped item.`;
if (!hasTier(selection.talentId, selection.talentTier))
return `${slot} Talent T${selection.talentTier} is not configured.`;
}
for (let index = 0; index < loadout.weapons.length; index += 1) {
const selection = loadout.weapons[index];
const label = index ? 'Secondary weapon' : 'Primary weapon';
for (const configured of [
{ id: selection.talent1Id, tier: selection.talent1Tier, slot: 1 },
{ id: selection.talent2Id, tier: selection.talent2Tier, slot: 2 },
]) {
if (!configured.id) continue;
if (
!weaponTalents.some(
(entry) =>
String(entry.weapon_id) === selection.itemId &&
Number(entry.talent_slot) === configured.slot &&
String(entry.talent_id) === configured.id,
)
)
return `${label} has a Talent that is not available in slot ${configured.slot}.`;
if (!hasTier(configured.id, configured.tier))
return `${label} Talent ${configured.slot} T${configured.tier} is not configured.`;
}
const weapon = weapons.find(
(entry) => String(entry.id) === selection.itemId,
);
const exoticTalentId = String(weapon?.exotic_talent_id ?? '');
if (exoticTalentId && !hasTier(exoticTalentId, selection.exoticTalentTier))
return `${label} exotic Talent T${selection.exoticTalentTier} is not configured.`;
}
return null;
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
@@ -592,6 +660,12 @@ export const actions: Actions = {
return fail(400, { buildError: 'Choose a valid build visibility.' });
const database = await getDatabase();
const equipmentTalentError = await validateEquipmentTalentSelections(
database,
loadout,
);
if (equipmentTalentError)
return fail(400, { buildError: equipmentTalentError });
const skillChipError = await validateSkillChipSelections(database, loadout);
if (skillChipError) return fail(400, { buildError: skillChipError });
const now = new Date().toISOString();
+99 -79
View File
@@ -12,11 +12,12 @@
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
import TalentTierChoice from '$lib/components/builder/TalentTierChoice.svelte';
import { summarizeLoadout } from '$lib/builder/loadout-summary';
import { toast } from '$lib/toast';
import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
type LoadoutDraft, type Roll, type SkillChipQuality
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, migrateLoadoutDraftV5, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
type LoadoutDraft, type Roll, type SkillChipQuality, type TalentTier
} from '$lib/builder/loadout';
let { data, form } = $props();
@@ -71,6 +72,8 @@
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
}) ?? []);
const talent = (id: string) => data.talents.find((item) => item.id === id);
const supportedTalentTier = (id: string, tier: TalentTier): TalentTier =>
talent(id)?.tiers.some((entry: any) => entry.tier === (tier === 2 ? 'II' : 'I')) ? tier : 1;
const brand = (id: string) => data.brands.find((item) => item.id === id);
const unitValue = (value: unknown, statId: string) => {
const numeric = Number(value);
@@ -207,6 +210,7 @@
selection.level = 50;
selection.brandId = item?.brandIds[0] ?? '';
selection.talentId = item?.talentIds[0] ?? '';
selection.talentTier = supportedTalentTier(selection.talentId, Number(item?.tier) >= 2 ? 2 : 1);
selection.health = Number(item?.base_health_min ?? 0);
selection.armor = Number(item?.base_armor_min ?? 0);
const core = coreAllocationRange(Number(item?.tier ?? 0), selection.level, 'gear');
@@ -255,7 +259,10 @@
const core = coreAllocationRange(selection.tier, selection.level, 'weapon');
for (const key of coreKeys) selection[key] = core.min;
selection.talent1Id = item?.talent1Ids[0] ?? '';
selection.talent1Tier = supportedTalentTier(selection.talent1Id, selection.tier >= 2 ? 2 : 1);
selection.talent2Id = item?.talent2Ids[0] ?? '';
selection.talent2Tier = supportedTalentTier(selection.talent2Id, selection.tier >= 2 ? 2 : 1);
selection.exoticTalentTier = supportedTalentTier(item?.exotic_talent_id ?? '', selection.tier >= 2 ? 2 : 1);
selection.rolls = [blankRoll(), blankRoll()];
};
const configureChip = (index: number, id: string) => {
@@ -375,7 +382,7 @@
}));
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
const item = weaponItem(index);
const talents = [selection.talent1Id, selection.talent2Id].map((id) => talent(id)?.name).filter(Boolean);
const talents = [[selection.talent1Id, selection.talent1Tier], [selection.talent2Id, selection.talent2Tier]].map(([id,tier]) => id ? `${talent(String(id))?.name ?? 'Talent'} T${tier}` : '').filter(Boolean);
const details = [...talents, rollSummary(selection.rolls)].filter(Boolean).join(' · ');
return {
id: `weapon:${index}`,
@@ -392,7 +399,7 @@
const item = gearItem(slot);
const details = [
brand(selection.brandId)?.name,
talent(selection.talentId)?.name,
selection.talentId ? `${talent(selection.talentId)?.name ?? 'Talent'} T${selection.talentTier}` : '',
rollSummary(selection.rolls)
].filter(Boolean).join(' · ');
return {
@@ -429,17 +436,24 @@
localStorage.removeItem('shd-build-loadout-draft-v3');
localStorage.removeItem('shd-build-loadout-draft-v4');
if (data.initialBuild) return;
const stored = localStorage.getItem('shd-build-loadout-draft-v5');
const current = localStorage.getItem('shd-build-loadout-draft-v6');
const legacy = current ? null : localStorage.getItem('shd-build-loadout-draft-v5');
const stored = current ?? legacy;
if (!stored) return;
try {
const parsed = parseLoadoutDraft(JSON.parse(stored));
const raw = JSON.parse(stored);
const parsed = current ? parseLoadoutDraft(raw) : migrateLoadoutDraftV5(raw, data);
if (parsed) {
draft = parsed;
if (legacy) {
localStorage.setItem('shd-build-loadout-draft-v6', JSON.stringify(parsed));
localStorage.removeItem('shd-build-loadout-draft-v5');
}
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true;
} else localStorage.removeItem('shd-build-loadout-draft-v5');
} else localStorage.removeItem(current ? 'shd-build-loadout-draft-v6' : 'shd-build-loadout-draft-v5');
} catch {
localStorage.removeItem('shd-build-loadout-draft-v5');
localStorage.removeItem(current ? 'shd-build-loadout-draft-v6' : 'shd-build-loadout-draft-v5');
}
});
$effect(() => {
@@ -457,7 +471,7 @@
: 'Build published to the SHD archive.';
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v5', JSON.stringify(draft));
localStorage.setItem('shd-build-loadout-draft-v6', JSON.stringify(draft));
saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.');
}
@@ -495,7 +509,7 @@
{#if persistedSlug && savedBuildVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div>
{/if}
<div class="builder-status"><span><i></i> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
<div class="builder-status"><span><i></i> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</span></div>
</div>
{#if message}<p class="success" role="status">{message}</p>{/if}
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
@@ -518,29 +532,28 @@
<div class="loadout-editor-head"><div><span>AGENT CLASS</span><h2>SPECIALIZATION FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + FOCUS"/>
{#if selectedFocus}
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt={selectedFocus.specialization.name}/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description has been entered.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}</b></div>
<div class="builder-ability-chip-panels">
{#each [0,1,2] as abilityIndex}
{@const ability = selectedFocus.abilities[abilityIndex]}
<section>
<header><ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability ${abilityIndex + 1}`}/><div><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'NOT CONFIGURED'}</strong></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></header>
<p>{ability?.description ?? 'ISAC requires this class ability before compatible Skill Chips can be equipped.'}</p>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[chipIndex].quality : 'SELECT CHIP'}</span></button>{/each}</div>
</section>
{/each}
</div>
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
<div class="builder-focus-tree">
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>PREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'ENTRY ROW' : 'LOCKED BY PREVIOUS ROWS'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
<div class="builder-specialization-layout">
<div class="builder-editor-lane">
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt={selectedFocus.specialization.name}/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description has been entered.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}</b></div>
<div class="builder-focus-abilities" aria-label="Assigned abilities">
{#each [0,1,2] as abilityIndex}
{@const ability = selectedFocus.abilities[abilityIndex]}
<button type="button" onclick={() => active = `ability:${abilityIndex}`}><ItemIcon src={ability?.iconUrl} alt=""/><span><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'NOT CONFIGURED'}</strong></span><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></button>
{/each}
</div></section>
{/each}
</div>
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
</div>
<div class="builder-focus-tree builder-editor-lane">
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>COMPLETE EARLIER ROWS TO ADVANCE</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'ENTRY ROW' : 'PREVIOUS ROWS REQUIRED'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
{/each}
</div></section>
{/each}
</div>
</div>
<PlannedCombatModel headingId="builder-combat-model-heading" />
{/if}
{:else if activeParts[0] === 'ability'}
{@const abilityIndex = Number(activeParts[1])}
@@ -565,20 +578,27 @@
{#if item}
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
<div class="builder-equipment-controls">
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talentId} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
</div>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional while additional game samples are collected.' : 'Base Health and Armor are calculated from the stored gear base ranges at the selected item level. Display values are truncated to match the game.'}</p>
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
{@const gearCore = coreAllocationRange(Number(item.tier), selection.level, 'gear')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{item.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO GEAR RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
{@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)}
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>ENABLE COLLECTED UPGRADES · BASE BONUSES APPLY TO THIS ITEM ONLY</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
<div class="builder-editor-columns">
<section class="builder-editor-lane" aria-label="Gear identity and base values">
<div class="builder-equipment-controls">
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} value={selection.talentId} onchange={(id) => { selection.talentId = id; selection.talentTier = supportedTalentTier(id, selection.talentTier); }} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
{#if selection.talentId && talent(selection.talentId)}<TalentTierChoice name={`gear-${slot}-talent-tier`} talent={talent(selection.talentId)} bind:value={selection.talentTier}/>{/if}
</div>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional.' : 'Base values scale with item level and use game-matching truncated display values.'}</p>
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
</section>
<section class="builder-editor-lane" aria-label="Gear attributes and augments">
<div class="builder-section-title"><div><span>CORE ALLOCATION</span><small>SHARED T{item.tier} POOL</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>OPTIONAL ITEM UPGRADES</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
</section>
</div>
{/if}
{:else if activeParts[0] === 'weapon'}
{@const index = Number(activeParts[1])}{@const selection = draft.weapons[index]}{@const item = weaponItem(index)}{@const other = weaponItem(index ? 0 : 1)}
@@ -586,27 +606,32 @@
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON DEFINITION"/>
{#if item}
{#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if}
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((range: any) => ({ value: String(range.tier), label: `T${range.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
{#if range}
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range: the weapon curve below level 40 is provisional and may change as observations are added.' : 'Damage is calculated from the stored weapon base range at the selected item level. Core attributes use the shared level-50 tier allocation pool.'}</p>
{@const damageRange = weaponStatRange(range,'damage',selection.level)}
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{selection.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
{#if range}{@const damageRange = weaponStatRange(range,'damage',selection.level)}{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
<div class="builder-editor-columns">
<section class="builder-editor-lane" aria-label="Weapon identity and base values">
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((entry: any) => ({ value: String(entry.tier), label: `T${entry.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range below level 40.' : 'Damage scales with the selected item level.'}</p>
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
</section>
<section class="builder-editor-lane" aria-label="Weapon attributes and talents">
<div class="builder-section-title"><div><span>CORE ALLOCATION</span><small>SHARED T{selection.tier} POOL</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
<div class="builder-talent-grids">
{#if item.talent1Ids.length}<section class="builder-talent-slot"><BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} value={selection.talent1Id} onchange={(id) => { selection.talent1Id = id; selection.talent1Tier = supportedTalentTier(id, selection.talent1Tier); }} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{#if selection.talent1Id && talent(selection.talent1Id)}<TalentTierChoice name={`weapon-${index}-talent-1-tier`} talent={talent(selection.talent1Id)} bind:value={selection.talent1Tier}/>{/if}</section>{/if}
{#if item.talent2Ids.length}<section class="builder-talent-slot"><BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} value={selection.talent2Id} onchange={(id) => { selection.talent2Id = id; selection.talent2Tier = supportedTalentTier(id, selection.talent2Tier); }} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{#if selection.talent2Id && talent(selection.talent2Id)}<TalentTierChoice name={`weapon-${index}-talent-2-tier`} talent={talent(selection.talent2Id)} bind:value={selection.talent2Tier}/>{/if}</section>{/if}
{#if item.exotic_talent_id && talent(item.exotic_talent_id)}<section class="builder-talent-slot builder-exotic-talent"><div><ItemIcon src={talent(item.exotic_talent_id).iconUrl} alt=""/><span><small>EXOTIC TALENT</small><b>{talent(item.exotic_talent_id).name}</b></span></div><TalentTierChoice name={`weapon-${index}-exotic-talent-tier`} talent={talent(item.exotic_talent_id)} bind:value={selection.exoticTalentTier}/></section>{/if}
</div>
</section>
</div>
{/if}
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO WEAPON RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
<div class="builder-talent-grids">
{#if item.talent1Ids.length}<BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent1Id} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{/if}
{#if item.talent2Ids.length}<BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent2Id} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{/if}
</div>
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if}
{:else if activeParts[0] === 'chip'}
{@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}{@const abilityIndex = Math.floor(index / 3)}{@const ability = selectedFocus?.abilities[abilityIndex]}
@@ -615,24 +640,19 @@
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if}
{#if !chipProfileConfigured}<p class="error">SHARED SKILL CHIP PROFILE · CONFIG REQUIRED. Complete every global quality range in ISAC before this loadout can be complete.</p>{/if}
{#if item}
<div class="builder-field-grid">
<SegmentedChoice legend="QUALITY" name={`chip-${index}-quality`} options={skillChipQualities.map((quality) => ({ value: quality, label: quality.toUpperCase() }))} value={chip.quality} onchange={(values) => changeChipQuality(index, values[0] as SkillChipQuality)} required/>
<div class="builder-editor-columns builder-chip-editor">
<section class="builder-editor-lane" aria-label="Skill Chip quality and primary Attribute">
<div class="builder-field-grid"><SegmentedChoice legend="QUALITY" name={`chip-${index}-quality`} options={skillChipQualities.map((quality) => ({ value: quality, label: quality.toUpperCase() }))} value={chip.quality} onchange={(values) => changeChipQuality(index, values[0] as SkillChipQuality)} required/></div>
<div class="builder-section-title"><div><span>ABILITY-SCOPED PRIMARY</span><small>{ability?.name?.toUpperCase() ?? 'ASSIGNED ABILITY'} ONLY</small></div><b>NOT YET DEFINED</b></div>
<p class="builder-scaling-note">Excluded from current Agent totals.</p>
<div class="builder-chip-rolls"><div><BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY ATTRIBUTE"/>{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE · CONFIG REQUIRED</p>{/if}</div></div>
</section>
<section class="builder-editor-lane" aria-label="Skill Chip secondary Attributes">
<div class="builder-section-title"><div><span>SECONDARY ATTRIBUTES</span><small>{skillChipSecondaryCapacity[chip.quality]} SLOT{skillChipSecondaryCapacity[chip.quality] === 1 ? '' : 'S'}</small></div></div>
<div class="builder-chip-rolls">{#each chip.secondary as roll, secondaryIndex}<div><BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ATTRIBUTE ${secondaryIndex + 1}`}/>{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE · CONFIG REQUIRED</p>{/if}</div>{/each}</div>
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">STANDARD QUALITY · NO SECONDARY ATTRIBUTES</p>{/if}
</section>
</div>
<div class="builder-section-title"><div><span>ABILITY-SCOPED PRIMARY</span><small>{ability?.name?.toUpperCase() ?? 'ASSIGNED ABILITY'} ONLY · EXCLUDED FROM KNOWN AGENT STATS</small></div><b>NOT YET DEFINED</b></div>
<p class="builder-scaling-note">The primary roll belongs to this ability. Its calculation application is NOT YET DEFINED, so it does not contribute to current Agent totals.</p>
<div class="builder-chip-rolls">
<div>
<BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY ATTRIBUTE"/>
{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE · CONFIG REQUIRED</p>{/if}
</div>
{#each chip.secondary as roll, secondaryIndex}
<div>
<BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ATTRIBUTE ${secondaryIndex + 1}`}/>
{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE · CONFIG REQUIRED</p>{/if}
</div>
{/each}
</div>
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">STANDARD QUALITY · NO SECONDARY ATTRIBUTES</p>{/if}
{/if}
{:else}
{@const protocol = protocolItem()}
@@ -183,4 +183,56 @@ describe('Builder Skill Chip save validation', () => {
});
expect(mocks.insertBuild).toHaveBeenCalledOnce();
});
it('rejects an equipment Talent tier without canonical tier data', async () => {
const loadout = createDraft();
loadout.buildName = 'Invalid Talent Tier';
loadout.gear.Holster.itemId = 'gear-1';
loadout.gear.Holster.talentId = 'talent-1';
loadout.gear.Holster.talentTier = 2;
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM gear_talents'))
return [{ gear_id: 'gear-1', talent_id: 'talent-1' }];
if (sql.includes('FROM talent_tiers'))
return [{ talent_id: 'talent-1', tier: 'I' }];
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(loadout);
expect(result).toMatchObject({
status: 400,
data: { buildError: 'Holster Talent T2 is not configured.' },
});
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('saves an equipment Talent with an authored Tier II', async () => {
const loadout = createDraft();
loadout.buildName = 'Tier Two Talent';
loadout.gear.Holster.itemId = 'gear-1';
loadout.gear.Holster.talentId = 'talent-1';
loadout.gear.Holster.talentTier = 2;
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM gear_talents'))
return [{ gear_id: 'gear-1', talent_id: 'talent-1' }];
if (sql.includes('FROM talent_tiers'))
return [
{ talent_id: 'talent-1', tier: 'I' },
{ talent_id: 'talent-1', tier: 'II' },
];
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(loadout);
expect(result).toMatchObject({ buildSaved: true });
expect(mocks.insertBuild).toHaveBeenCalledOnce();
});
});
+3 -2
View File
@@ -10,6 +10,7 @@
const chip = (id: string) => data.chips.find((item) => item.id === id);
const protocol = (id: string) => data.protocols.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id);
const talent = (id: string) => data.talents.find((item) => item.id === id);
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
const rollLine = (rolls: Array<{ statId: string; value: number }>) => rolls
@@ -56,11 +57,11 @@
<div class="shared-build-grid">
<section>
<header><span>WEAPONS</span><b>02 SLOTS</b></header>
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · ${item.damage_type} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.damage)} DAMAGE</b><b>{item.rpm} RPM</b><b>{item.magazine} ROUNDS</b><b>{item.reload_speed}s RELOAD</b></div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
{#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}<article><small>{index ? 'SECONDARY' : 'PRIMARY'}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category} · ${item.damage_type} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.damage)} DAMAGE</b><b>{item.rpm} RPM</b><b>{item.magazine} ROUNDS</b><b>{item.reload_speed}s RELOAD</b>{#if selection.talent1Id}<b>{talent(selection.talent1Id)?.name ?? 'TALENT 1'} · T{selection.talent1Tier}</b>{/if}{#if selection.talent2Id}<b>{talent(selection.talent2Id)?.name ?? 'TALENT 2'} · T{selection.talent2Tier}</b>{/if}{#if item.exotic_talent_id}<b>{talent(item.exotic_talent_id)?.name ?? 'EXOTIC TALENT'} · T{selection.exoticTalentTier}</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
</section>
<section>
<header><span>GEAR</span><b>06 SLOTS</b></header>
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.health)} HEALTH</b>{#if item.base_armor_min !== null}<b>{number.format(selection.armor)} ARMOR</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
{#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}<article><small>{slot.toUpperCase()}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}</span>{#if item}<div class="shared-item-facts"><b>{number.format(selection.health)} HEALTH</b>{#if item.base_armor_min !== null}<b>{number.format(selection.armor)} ARMOR</b>{/if}{#if selection.talentId}<b>{talent(selection.talentId)?.name ?? 'TALENT'} · T{selection.talentTier}</b>{/if}</div><em>{coreLine(selection)}{rollLine(selection.rolls) ? ` · ${rollLine(selection.rolls)}` : ''}</em>{/if}</article>{/each}
</section>
<section>
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>