Build catalog-backed loadout workbench
This commit is contained in:
@@ -814,3 +814,26 @@ Attribute relationship, one numeric effect value, and one non-negative
|
||||
whole-number unlock cost. At narrow widths profile cards and augment editor
|
||||
cards reduce from multi-column grids to a single column while retaining
|
||||
44-pixel interactive targets.
|
||||
|
||||
### Loadout workbench
|
||||
|
||||
The builder is a three-rail tactical workbench: compact equipment positions on
|
||||
the left, the currently selected item editor in the center, and a sticky live
|
||||
calculation summary on the right. At medium widths the summary becomes a
|
||||
full-width lower rail; on mobile all three rails stack, with equipment first,
|
||||
editing second, and results third. Equipment positions remain explicit even
|
||||
when empty so loadout completeness is always visible.
|
||||
|
||||
Use the shared `BuilderPicker` for builder catalogs and Attribute pools. It
|
||||
combines an always-visible current selection with a searchable overlay, icon or
|
||||
tactical fallback mark, classification metadata, selected state, and conflict
|
||||
state. Conflicting catalog entries remain visible but disabled so restrictions
|
||||
such as the single-exotic-weapon rule are explained rather than hidden.
|
||||
|
||||
Numeric instance values display their authoritative minimum and maximum beside
|
||||
the field and clamp to that range on completion. Always-on item rolls, fixed
|
||||
bonuses, inherited profiles, enabled augments, and activated set bonuses feed
|
||||
the live totals immediately. Trigger-dependent talents remain explicitly
|
||||
`CONDITIONAL` until the user enables `TRIGGER ACTIVE`; only then may their
|
||||
effects enter combined totals. This textual distinction must remain clear
|
||||
without relying on color.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,38 @@
|
||||
export type Roll = { statId: string; value: number };
|
||||
export type GearSelection = { itemId: string; brandId: string; talentId: string; health: number; armor: number; rolls: Roll[]; augmentSlots: number[] };
|
||||
export type WeaponSelection = { itemId: string; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string };
|
||||
export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
|
||||
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number };
|
||||
export type LoadoutDraft = {
|
||||
version: 1;
|
||||
buildName: string;
|
||||
gear: Record<string, GearSelection>;
|
||||
weapons: [WeaponSelection, WeaponSelection];
|
||||
chips: [ChipSelection, ChipSelection, ChipSelection];
|
||||
protocol: ProtocolSelection;
|
||||
activeTalents: string[];
|
||||
};
|
||||
|
||||
export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
|
||||
export const blankRoll = (): Roll => ({ statId: '', value: 0 });
|
||||
export const blankGear = (): GearSelection => ({ itemId: '', brandId: '', talentId: '', health: 0, armor: 0, rolls: [blankRoll()], augmentSlots: [] });
|
||||
export const blankWeapon = (): WeaponSelection => ({ itemId: '', tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '' });
|
||||
export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
|
||||
export const createDraft = (): LoadoutDraft => ({
|
||||
version: 1,
|
||||
buildName: 'Untitled loadout',
|
||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
||||
weapons: [blankWeapon(), blankWeapon()],
|
||||
chips: [blankChip(), blankChip(), blankChip()],
|
||||
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
|
||||
activeTalents: []
|
||||
});
|
||||
|
||||
export const clamp = (value: unknown, min: unknown, max: unknown) => {
|
||||
const low = Number(min), high = Number(max), numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0;
|
||||
return Math.min(Number.isFinite(high) ? high : numeric, Math.max(Number.isFinite(low) ? low : numeric, numeric));
|
||||
};
|
||||
|
||||
export const scaled = (min: unknown, max: unknown, progress: number, steps: number) =>
|
||||
Number(min) + (Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress)) / steps;
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
items,
|
||||
value = $bindable(''),
|
||||
label,
|
||||
placeholder = 'Search available items…',
|
||||
disabledIds = [],
|
||||
onchange
|
||||
}: {
|
||||
items: Array<{ id: string; name: string; iconUrl?: string; category?: string; pending?: boolean }>;
|
||||
value?: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
disabledIds?: string[];
|
||||
onchange?: (id: string) => void;
|
||||
} = $props();
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
const selected = $derived(items.find((item) => item.id === value));
|
||||
const results = $derived(items.filter((item) => item.name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())).slice(0, 40));
|
||||
const choose = (id: string) => { value = id; onchange?.(id); open = false; query = ''; };
|
||||
</script>
|
||||
|
||||
<div class="builder-picker">
|
||||
<span>{label}</span>
|
||||
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
|
||||
{#if selected?.iconUrl}<img src={selected.iconUrl} alt=""/>{:else}<i>◇</i>{/if}
|
||||
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="builder-picker-panel">
|
||||
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
|
||||
<div>
|
||||
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR SLOT</b><small>REMOVE CURRENT SELECTION</small></span><em>CLEAR</em></button>{/if}
|
||||
{#each results as item}
|
||||
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
|
||||
{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<i>◇</i>{/if}
|
||||
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
|
||||
</button>
|
||||
{:else}<p>NO MATCHING ITEMS</p>{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,101 @@
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { gameDataIconUrl } from '$lib/server/game-data-icons';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const database = await getDatabase();
|
||||
const [
|
||||
attributes, gear, gearBrands, gearTalents, gearAugments, brands, brandEffects,
|
||||
weapons, weaponRanges, weaponTalentOptions, weaponBonuses,
|
||||
talents, talentTiers, talentEffects, talentTemplates,
|
||||
chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects,
|
||||
protocols, protocolPrimary, protocolProfiles, protocolSecondary, protocolTalents, protocolTalentEffects,
|
||||
icons
|
||||
] = await Promise.all([
|
||||
(await database.prepare('SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_brand_sets')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_talents')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
|
||||
(await database.prepare('SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM brand_set_effects ORDER BY pieces_required')).all(),
|
||||
(await database.prepare('SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_talent_options ORDER BY talent_slot')).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_description_templates')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_secondary_options')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talent_effects ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocols WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
|
||||
(await database.prepare("SELECT * FROM game_data_icons")).all()
|
||||
]);
|
||||
const iconUrl = (type: 'talent' | 'brand_set' | 'os_protocol' | 'skill_chip', id: unknown) => {
|
||||
const icon = icons.find((item) => item.entity_type === type && item.entity_id === id);
|
||||
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : '';
|
||||
};
|
||||
const configuredTalents = talents.map((talent) => ({
|
||||
...talent,
|
||||
iconUrl: iconUrl('talent', talent.id),
|
||||
template: talentTemplates.find((item) => item.talent_id === talent.id)?.template ?? '',
|
||||
tiers: talentTiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({
|
||||
...tier,
|
||||
effects: talentEffects.filter((effect) => effect.talent_tier_id === tier.id)
|
||||
}))
|
||||
}));
|
||||
return {
|
||||
attributes,
|
||||
gear: gear.map((item) => ({
|
||||
...item,
|
||||
brandIds: gearBrands.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
|
||||
talentIds: gearTalents.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id))
|
||||
})),
|
||||
gearAugments,
|
||||
brands: brands.map((brand) => ({
|
||||
...brand,
|
||||
iconUrl: iconUrl('brand_set', brand.id),
|
||||
effects: brandEffects.filter((effect) => effect.brand_set_id === brand.id)
|
||||
})),
|
||||
weapons: weapons.map((weapon) => ({
|
||||
...weapon,
|
||||
ranges: weaponRanges.filter((range) => range.weapon_id === weapon.id),
|
||||
talent1Ids: weaponTalentOptions.filter((link) => link.weapon_id === weapon.id && Number(link.talent_slot) === 1).map((link) => String(link.talent_id)),
|
||||
talent2Ids: weaponTalentOptions.filter((link) => link.weapon_id === weapon.id && Number(link.talent_slot) === 2).map((link) => String(link.talent_id)),
|
||||
baseBonuses: weaponBonuses.filter((bonus) => bonus.weapon_id === weapon.id)
|
||||
})),
|
||||
talents: configuredTalents,
|
||||
chipSets: chipSets.map((set) => {
|
||||
const talent = chipTalents.find((item) => item.skill_chip_set_id === set.id);
|
||||
return {
|
||||
...set,
|
||||
iconUrl: iconUrl('skill_chip', set.id),
|
||||
twoPiece: chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
|
||||
primaryStatIds: chipPrimary.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
|
||||
secondaryStatIds: chipSecondary.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
|
||||
talent: talent ? { ...talent, effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id) } : null
|
||||
};
|
||||
}),
|
||||
protocols: protocols.map((protocol) => {
|
||||
const profile = protocolProfiles.find((item) => item.quality === protocol.quality && item.primary_attribute_type === protocol.primary_attribute_type);
|
||||
const talent = protocolTalents.find((item) => item.protocol_id === protocol.id);
|
||||
return {
|
||||
...protocol,
|
||||
iconUrl: iconUrl('os_protocol', protocol.id),
|
||||
primaryEffect: protocolPrimary.find((item) => item.protocol_id === protocol.id) ?? null,
|
||||
secondaryEffects: profile ? protocolSecondary.filter((effect) => effect.profile_id === profile.id) : [],
|
||||
talent: talent ? { ...talent, effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id) } : null
|
||||
};
|
||||
})
|
||||
};
|
||||
};
|
||||
+253
-22
@@ -1,30 +1,207 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
type Slot = { name: string; item: string; brand: string; core: string };
|
||||
let buildName = $state('Untitled loadout');
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import BuilderPicker from '$lib/components/BuilderPicker.svelte';
|
||||
import {
|
||||
blankRoll, clamp, createDraft, gearSlots, scaled,
|
||||
type LoadoutDraft, type Roll
|
||||
} from '$lib/builder/loadout';
|
||||
|
||||
let { data } = $props();
|
||||
let draft = $state<LoadoutDraft>(createDraft());
|
||||
let active = $state('gear:Holster');
|
||||
let saved = $state(false);
|
||||
let message = $state('');
|
||||
let slots = $state<Slot[]>([
|
||||
{ name: 'Primary weapon', item: 'Police M4', brand: 'Assault rifle', core: '5,184 DMG' },
|
||||
{ name: 'Secondary weapon', item: 'SVD', brand: 'Marksman rifle', core: '8,420 DMG' },
|
||||
{ name: 'Mask', item: 'Striker Mask', brand: "Striker's Battlegear", core: '+42 Firearms' },
|
||||
{ name: 'Vest', item: 'Reinforced Vest', brand: 'Fenris Group AB', core: '+38 Armor' },
|
||||
{ name: 'Backpack', item: 'Field Pack', brand: "Striker's Battlegear", core: '+39 Firearms' },
|
||||
{ name: 'Gloves', item: 'Tactical Gloves', brand: 'Sokolov Concern', core: '+6.5% CHC' }
|
||||
]);
|
||||
const weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const;
|
||||
const gearAttributes = $derived(data.attributes.filter((item) => item.gear_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
|
||||
const weaponAttributes = $derived(data.attributes.filter((item) => item.weapon_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
|
||||
const chipAttributes = $derived(data.attributes.filter((item) => item.skill_chip_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
|
||||
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
|
||||
const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
|
||||
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
|
||||
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
|
||||
const protocolItem = () => data.protocols.find((item) => item.id === draft.protocol.itemId);
|
||||
const talent = (id: string) => data.talents.find((item) => item.id === id);
|
||||
const brand = (id: string) => data.brands.find((item) => item.id === id);
|
||||
const unitValue = (value: unknown, statId: string) => {
|
||||
const numeric = Number(value);
|
||||
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||
const unit = attribute(statId)?.unit ?? '';
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||
};
|
||||
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon' | 'chip') => {
|
||||
roll.statId = id;
|
||||
const stat = attribute(id);
|
||||
const min = source === 'gear' ? stat?.gear_min : source === 'weapon' ? stat?.weapon_min : stat?.skill_chip_min;
|
||||
roll.value = Number(min ?? 0);
|
||||
};
|
||||
const toggleTalent = (key: string) => {
|
||||
draft.activeTalents = draft.activeTalents.includes(key)
|
||||
? draft.activeTalents.filter((item) => item !== key)
|
||||
: [...draft.activeTalents, key];
|
||||
};
|
||||
const talentTier = (talentId: string, tier: number) => {
|
||||
const item = talent(talentId);
|
||||
return item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? item?.tiers[0];
|
||||
};
|
||||
const talentDescription = (talentId: string, tier: number) => {
|
||||
const item = talent(talentId), configured = talentTier(talentId, tier);
|
||||
if (!item || !configured) return '';
|
||||
const template = item.template || configured.description;
|
||||
return template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
|
||||
const effect = configured.effects[Number(raw) - 1];
|
||||
return effect ? unitValue(effect.value, effect.stat_attribute_id) : placeholder;
|
||||
});
|
||||
};
|
||||
const configureGear = (slot: string, id: string) => {
|
||||
const selection = draft.gear[slot], item = data.gear.find((entry) => entry.id === id);
|
||||
selection.itemId = id;
|
||||
selection.brandId = item?.brandIds[0] ?? '';
|
||||
selection.talentId = item?.talentIds[0] ?? '';
|
||||
selection.health = Number(item?.base_health_min ?? 0);
|
||||
selection.armor = Number(item?.base_armor_min ?? 0);
|
||||
selection.rolls = [blankRoll()];
|
||||
selection.augmentSlots = [];
|
||||
};
|
||||
const configureWeapon = (index: number, id: string) => {
|
||||
const selection = draft.weapons[index], item = data.weapons.find((entry) => entry.id === id);
|
||||
selection.itemId = id;
|
||||
selection.tier = Number(item?.native_tier ?? 0);
|
||||
const range = item?.ranges.find((entry: any) => Number(entry.tier) === selection.tier);
|
||||
for (const key of ['damage', 'firepower', 'toughness', 'engineering'] as const) selection[key] = Number(range?.[`${key}_min`] ?? 0);
|
||||
selection.talent1Id = item?.talent1Ids[0] ?? '';
|
||||
selection.talent2Id = item?.talent2Ids[0] ?? '';
|
||||
};
|
||||
const configureChip = (index: number, id: string) => {
|
||||
const chip = draft.chips[index], item = data.chipSets.find((entry) => entry.id === id);
|
||||
chip.setId = id;
|
||||
chip.primary = { statId: item?.primaryStatIds[0] ?? '', value: 0 };
|
||||
chip.secondary = [
|
||||
{ statId: item?.secondaryStatIds[0] ?? '', value: 0 },
|
||||
{ statId: item?.secondaryStatIds[1] ?? '', value: 0 }
|
||||
];
|
||||
if (chip.primary.statId) setRollStat(chip.primary, chip.primary.statId, 'chip');
|
||||
for (const roll of chip.secondary) if (roll.statId) setRollStat(roll, roll.statId, 'chip');
|
||||
};
|
||||
const changeWeaponTier = (index: number, tier: number) => {
|
||||
const selection = draft.weapons[index], item = weaponItem(index);
|
||||
selection.tier = tier;
|
||||
const range = item?.ranges.find((entry: any) => Number(entry.tier) === tier);
|
||||
for (const key of ['damage', 'firepower', 'toughness', 'engineering'] as const) selection[key] = Number(range?.[`${key}_min`] ?? 0);
|
||||
};
|
||||
const activeParts = $derived(active.split(':'));
|
||||
const summary = $derived.by(() => {
|
||||
const values = new Map<string, number>();
|
||||
const sources = new Map<string, Set<string>>();
|
||||
const add = (statId: string, value: unknown, source: string) => {
|
||||
if (!statId || !Number.isFinite(Number(value))) return;
|
||||
const stat = attribute(statId), current = values.get(statId) ?? 0, incoming = Number(value);
|
||||
const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
||||
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
|
||||
: current + incoming;
|
||||
values.set(statId, stat?.cap_value === null || stat?.cap_value === undefined ? combined : Math.min(combined, Number(stat.cap_value)));
|
||||
if (!sources.has(statId)) sources.set(statId, new Set());
|
||||
sources.get(statId)!.add(source);
|
||||
};
|
||||
const byName = (name: string) => data.attributes.find((item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.id ?? '';
|
||||
const brandCounts = new Map<string, number>();
|
||||
const activeBonuses: any[] = [], equippedTalents: any[] = [];
|
||||
for (const slot of gearSlots) {
|
||||
const selection = draft.gear[slot], item = gearItem(slot);
|
||||
if (!item) continue;
|
||||
add(byName('Health'), selection.health, `${slot} base`);
|
||||
add(byName('Armor'), selection.armor, `${slot} base`);
|
||||
for (const roll of selection.rolls) add(roll.statId, roll.value, `${slot} roll`);
|
||||
if (selection.brandId) brandCounts.set(selection.brandId, (brandCounts.get(selection.brandId) ?? 0) + 1);
|
||||
for (const augment of data.gearAugments.filter((entry) => entry.gear_slot === slot && selection.augmentSlots.includes(Number(entry.augment_slot)))) {
|
||||
add(augment.stat_attribute_id, augment.value, `${slot} augment ${augment.augment_slot}`);
|
||||
}
|
||||
if (selection.talentId) equippedTalents.push({ key: `gear:${slot}:${selection.talentId}`, id: selection.talentId, tier: Number(item.tier), source: slot, description: talentDescription(selection.talentId, Number(item.tier)) });
|
||||
}
|
||||
for (const [brandId, count] of brandCounts) {
|
||||
const set = brand(brandId);
|
||||
for (const effect of set?.effects.filter((entry: any) => Number(entry.pieces_required) <= count) ?? []) {
|
||||
add(effect.stat_attribute_id, effect.value, `${set.name} ${effect.pieces_required}-piece`);
|
||||
activeBonuses.push({ name: set.name, pieces: effect.pieces_required, count, statId: effect.stat_attribute_id, value: effect.value });
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const selection = draft.weapons[index], item = weaponItem(index);
|
||||
if (!item) continue;
|
||||
for (const name of ['Damage', 'Firepower', 'Toughness', 'Engineering'] as const) add(byName(name), selection[name.toLowerCase() as 'damage'], `${index ? 'Secondary' : 'Primary'} weapon`);
|
||||
for (const bonus of item.baseBonuses) add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
|
||||
for (const talentId of [selection.talent1Id, selection.talent2Id, item.exotic_talent_id].filter(Boolean)) equippedTalents.push({
|
||||
key: `weapon:${index}:${talentId}`, id: talentId, tier: selection.tier, source: index ? 'Secondary weapon' : 'Primary weapon', description: talentDescription(talentId, selection.tier)
|
||||
});
|
||||
}
|
||||
const chipGroups = new Map<string, { count: number; highEnd: number; item: any }>();
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const chip = draft.chips[index], item = chipItem(index);
|
||||
if (!item) continue;
|
||||
add(chip.primary.statId, chip.primary.value, `Skill Chip ${index + 1} primary`);
|
||||
for (const roll of chip.secondary) add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
||||
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
|
||||
group.count += 1;
|
||||
if (chip.quality === 'High-end') group.highEnd += 1;
|
||||
chipGroups.set(item.id, group);
|
||||
}
|
||||
for (const group of chipGroups.values()) {
|
||||
if (group.count >= 2 && group.item.twoPiece) {
|
||||
const value = scaled(group.item.twoPiece.min_value, group.item.twoPiece.max_value, group.highEnd, 3);
|
||||
add(group.item.twoPiece.stat_attribute_id, value, `${group.item.name} 2-piece`);
|
||||
activeBonuses.push({ name: group.item.name, pieces: 2, count: group.count, statId: group.item.twoPiece.stat_attribute_id, value });
|
||||
}
|
||||
if (group.count === 3 && group.item.talent) equippedTalents.push({
|
||||
key: `chips:${group.item.id}`, id: '', custom: group.item.talent, tier: group.highEnd, source: '3 Skill Chips',
|
||||
description: group.item.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
|
||||
const effect = group.item.talent.effects[Number(raw) - 1];
|
||||
return effect ? unitValue(scaled(effect.min_value, effect.max_value, group.highEnd, 3), effect.stat_attribute_id) : placeholder;
|
||||
})
|
||||
});
|
||||
}
|
||||
const protocol = protocolItem();
|
||||
if (protocol) {
|
||||
if (protocol.primaryEffect) add(protocol.primaryEffect.stat_attribute_id, Number(protocol.primaryEffect.base_value) * (1 + (draft.protocol.level - 1) / 49), `${protocol.name} primary`);
|
||||
for (const effect of protocol.secondaryEffects) add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`);
|
||||
if (protocol.talent) equippedTalents.push({
|
||||
key: `protocol:${protocol.id}`, id: '', custom: protocol.talent, tier: draft.protocol.talentUpgrades, source: 'OS Protocol',
|
||||
description: protocol.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
|
||||
const effect = protocol.talent.effects[Number(raw) - 1];
|
||||
return effect ? unitValue(Number(effect.base_value) * (1 + draft.protocol.talentUpgrades / 5), effect.stat_attribute_id) : placeholder;
|
||||
})
|
||||
});
|
||||
}
|
||||
for (const equipped of equippedTalents) {
|
||||
if (!draft.activeTalents.includes(equipped.key)) continue;
|
||||
const effects = equipped.custom?.effects ?? talentTier(equipped.id, equipped.tier)?.effects ?? [];
|
||||
for (const effect of effects) {
|
||||
const value = equipped.key.startsWith('chips:') ? scaled(effect.min_value, effect.max_value, equipped.tier, 3)
|
||||
: equipped.key.startsWith('protocol:') ? Number(effect.base_value) * (1 + equipped.tier / 5) : effect.value;
|
||||
add(effect.stat_attribute_id, value, `${equipped.source} talent`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
stats: [...values].map(([id, value]) => ({ id, value, sourceCount: sources.get(id)?.size ?? 0 })).sort((a, b) => (attribute(a.id)?.category ?? '').localeCompare(attribute(b.id)?.category ?? '') || (attribute(a.id)?.name ?? '').localeCompare(attribute(b.id)?.name ?? '')),
|
||||
activeBonuses,
|
||||
equippedTalents
|
||||
};
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const draft = localStorage.getItem('shd-build-loadout-draft');
|
||||
if (!draft) return;
|
||||
const stored = localStorage.getItem('shd-build-loadout-draft-v1');
|
||||
if (!stored) return;
|
||||
try {
|
||||
const parsed = JSON.parse(draft);
|
||||
buildName = parsed.buildName ?? buildName;
|
||||
slots = parsed.slots ?? slots;
|
||||
saved = true;
|
||||
} catch { localStorage.removeItem('shd-build-loadout-draft'); }
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed?.version === 1) {
|
||||
draft = parsed;
|
||||
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
|
||||
saved = true;
|
||||
}
|
||||
} catch { localStorage.removeItem('shd-build-loadout-draft-v1'); }
|
||||
});
|
||||
function saveDraft() {
|
||||
localStorage.setItem('shd-build-loadout-draft', JSON.stringify({ buildName, slots, updatedAt: new Date().toISOString() }));
|
||||
localStorage.setItem('shd-build-loadout-draft-v1', JSON.stringify(draft));
|
||||
saved = true; message = 'Draft saved on this device.';
|
||||
setTimeout(() => (message = ''), 2200);
|
||||
}
|
||||
@@ -32,10 +209,64 @@
|
||||
|
||||
<svelte:head><title>Build a loadout — SHD.build</title></svelte:head>
|
||||
<main class="app-page builder-page">
|
||||
<div class="builder-command"><div class="page-title"><div><p class="eyebrow"><span></span> LOADOUT WORKBENCH</p><input bind:value={buildName} aria-label="Build name" /></div><div class="actions"><span class:visible={saved}>SAVED LOCALLY</span><button class="primary" onclick={saveDraft}>SAVE DRAFT</button></div></div><div class="builder-status"><span><i></i> DRAFT ACTIVE</span><span>6 EQUIPMENT SLOTS</span><span>LOCAL STORAGE</span></div></div>
|
||||
<div class="builder-command"><div class="page-title"><div><p class="eyebrow"><span></span> LOADOUT WORKBENCH</p><input bind:value={draft.buildName} aria-label="Build name"/></div><div class="actions"><span class:visible={saved}>SAVED LOCALLY</span><button class="primary" onclick={saveDraft}>SAVE DRAFT</button></div></div><div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>12 EQUIPMENT POSITIONS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div></div>
|
||||
{#if message}<p class="success" role="status">{message}</p>{/if}
|
||||
<div class="builder-layout">
|
||||
<section class="slot-grid">{#each slots as slot}<button class="slot-card" onclick={() => (message = `${slot.name} editing will be added in the next builder increment.`)}><span class="slot-label">{slot.name}</span><span class="slot-icon">◇</span><strong>{slot.item}</strong><small>{slot.brand}</small><b>{slot.core}</b></button>{/each}</section>
|
||||
<aside class="summary"><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2><div class="stat"><span><GameIcon name="firepower" decorative/>Firepower</span><strong>4,218</strong><i style="width:76%"></i></div><div class="stat"><span><GameIcon name="toughness" decorative/>Toughness</span><strong>2,840</strong><i style="width:53%"></i></div><div class="stat"><span><GameIcon name="engineering" decorative/>Engineering</span><strong>1,265</strong><i style="width:30%"></i></div><hr/><div class="mini"><span>Critical chance</span><b>52.5%</b></div><div class="mini"><span>Critical damage</span><b>118%</b></div><p class="notice">Anonymous drafts stay on this device. Sign in to publish and sync builds.</p></aside>
|
||||
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
|
||||
<div class="loadout-workspace">
|
||||
<section class="loadout-slots">
|
||||
<header><span>EQUIPMENT</span><b>{gearSlots.filter((slot) => draft.gear[slot].itemId).length + draft.weapons.filter((item) => item.itemId).length + draft.chips.filter((item) => item.setId).length + (draft.protocol.itemId ? 1 : 0)} / 12 EQUIPPED</b></header>
|
||||
<div class="loadout-slot-group weapons">{#each ['PRIMARY', 'SECONDARY'] as label, index}{@const item = weaponItem(index)}<button class:active={active === `weapon:${index}`} onclick={() => active = `weapon:${index}`}><small>{label} WEAPON</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category}${Number(item.is_exotic) ? ' · EXOTIC' : ''}` : 'SELECT WEAPON'}</span></button>{/each}</div>
|
||||
<div class="loadout-slot-group gear">{#each gearSlots as slot}{@const item = gearItem(slot)}<button class:active={active === `gear:${slot}`} onclick={() => active = `gear:${slot}`}><small>{slot}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? brand(draft.gear[slot].brandId)?.name ?? 'CHOOSE BRAND' : 'SELECT GEAR'}</span></button>{/each}</div>
|
||||
<div class="loadout-slot-group systems">{#each [0,1,2] as index}{@const item = chipItem(index)}<button class:active={active === `chip:${index}`} onclick={() => active = `chip:${index}`}><small>SKILL CHIP 0{index + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[index].quality : 'SELECT CHIP'}</span></button>{/each}<button class:active={active === 'protocol:0'} onclick={() => active = 'protocol:0'}><small>OS PROTOCOL</small><strong>{protocolItem()?.name ?? 'EMPTY SLOT'}</strong><span>{protocolItem() ? `LEVEL ${draft.protocol.level}` : 'SELECT PROTOCOL'}</span></button></div>
|
||||
</section>
|
||||
|
||||
<section class="loadout-editor">
|
||||
{#if activeParts[0] === 'gear'}
|
||||
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
|
||||
<div class="loadout-editor-head"><div><span>GEAR SLOT</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `TIER ${item.tier} · LEVEL ${item.level}` : 'UNASSIGNED'}</b></div>
|
||||
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/>
|
||||
{#if item}
|
||||
<div class="builder-field-grid"><label>BRAND SET<select value={selection.brandId} onchange={(event) => selection.brandId = event.currentTarget.value}>{#each item.brandIds as id}<option value={id}>{brand(id)?.name}</option>{/each}</select></label>{#if item.talentIds.length}<label>EQUIPPED TALENT<select bind:value={selection.talentId}>{#each item.talentIds as id}<option value={id}>{talent(id)?.name}</option>{/each}</select></label>{/if}</div>
|
||||
<div class="builder-range-grid"><label>BASE HEALTH <span>{item.base_health_min}–{item.base_health_max}</span><input type="number" min={item.base_health_min} max={item.base_health_max} bind:value={selection.health} onblur={() => selection.health = clamp(selection.health,item.base_health_min,item.base_health_max)}/></label>{#if item.base_armor_min !== null}<label>BASE ARMOR <span>{item.base_armor_min}–{item.base_armor_max}</span><input type="number" min={item.base_armor_min} max={item.base_armor_max} bind:value={selection.armor} onblur={() => selection.armor = clamp(selection.armor,item.base_armor_min,item.base_armor_max)}/></label>{/if}</div>
|
||||
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>VALUES ARE LIMITED TO GEAR RANGES</small></div><button onclick={() => selection.rolls = [...selection.rolls, blankRoll()]} disabled={selection.rolls.length >= 6}>ADD ATTRIBUTE +</button></div>
|
||||
<div class="builder-rolls">{#each selection.rolls as roll, index}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear')} label={`ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" value={roll.value} min={attribute(roll.statId)?.gear_min ?? 0} max={attribute(roll.statId)?.gear_max ?? 0} onchange={(event) => roll.value = clamp(event.currentTarget.value,attribute(roll.statId)?.gear_min,attribute(roll.statId)?.gear_max)}/></label><button aria-label={`Remove attribute ${index + 1}`} onclick={() => selection.rolls = selection.rolls.filter((_, itemIndex) => itemIndex !== index)}>×</button></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</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>{attribute(augment.stat_attribute_id)?.name} · {unitValue(augment.value,augment.stat_attribute_id)}</small></span><em>{augment.unlock_cost} COST</em></label>{/each}</div>{/if}
|
||||
{/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)}
|
||||
<div class="loadout-editor-head"><div><span>WEAPON SLOT</span><h2>{index ? 'SECONDARY' : 'PRIMARY'} WEAPON</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div>
|
||||
<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-field-grid"><label>TIER<select value={selection.tier} onchange={(event) => changeWeaponTier(index,Number(event.currentTarget.value))}>{#each item.ranges as range}<option value={range.tier}>TIER {range.tier}</option>{/each}</select></label><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}<div class="builder-range-grid weapon">{#each weaponRangeKeys as key}<label>{key.toUpperCase()} <span>{range[`${key}_min`]}–{range[`${key}_max`]}</span><input type="number" min={range[`${key}_min`]} max={range[`${key}_max`]} value={selection[key]} onchange={(event) => selection[key] = clamp(event.currentTarget.value,range[`${key}_min`],range[`${key}_max`])}/></label>{/each}</div>{/if}
|
||||
<div class="builder-field-grid">{#if item.talent1Ids.length}<label>TALENT 1<select bind:value={selection.talent1Id}>{#each item.talent1Ids as id}<option value={id}>{talent(id)?.name}</option>{/each}</select></label>{/if}{#if item.talent2Ids.length}<label>TALENT 2<select bind:value={selection.talent2Id}>{#each item.talent2Ids as id}<option value={id}>{talent(id)?.name}</option>{/each}</select></label>{/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)}
|
||||
<div class="loadout-editor-head"><div><span>SKILL SYSTEM</span><h2>SKILL CHIP 0{index + 1}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
|
||||
<BuilderPicker items={data.chipSets} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="SKILL CHIP SET"/>
|
||||
{#if item}<div class="builder-field-grid"><label>QUALITY<select bind:value={chip.quality}><option>Superior</option><option>High-end</option></select></label></div><div class="builder-chip-rolls"><div><BuilderPicker items={chipAttributes.filter((entry) => item.primaryStatIds.includes(entry.id))} bind:value={chip.primary.statId} onchange={(id) => setRollStat(chip.primary,id,'chip')} label="PRIMARY ATTRIBUTE"/><label>VALUE<input type="number" min={attribute(chip.primary.statId)?.skill_chip_min ?? 0} max={attribute(chip.primary.statId)?.skill_chip_max ?? 0} bind:value={chip.primary.value} onblur={() => chip.primary.value = clamp(chip.primary.value,attribute(chip.primary.statId)?.skill_chip_min,attribute(chip.primary.statId)?.skill_chip_max)}/></label></div>{#each chip.secondary as roll,index}<div><BuilderPicker items={chipAttributes.filter((entry) => item.secondaryStatIds.includes(entry.id))} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'chip')} disabledIds={chip.secondary.filter((_,i) => i !== index).map((entry) => entry.statId)} label={`SECONDARY ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" min={attribute(roll.statId)?.skill_chip_min ?? 0} max={attribute(roll.statId)?.skill_chip_max ?? 0} bind:value={roll.value} onblur={() => roll.value = clamp(roll.value,attribute(roll.statId)?.skill_chip_min,attribute(roll.statId)?.skill_chip_max)}/></label></div>{/each}</div>{/if}
|
||||
{:else}
|
||||
{@const protocol = protocolItem()}
|
||||
<div class="loadout-editor-head"><div><span>AGENT SYSTEM</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
|
||||
<BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="OS PROTOCOL"/>
|
||||
{#if protocol}<div class="builder-range-grid"><label>PROTOCOL LEVEL <span>01–50</span><input type="number" min="1" max="50" bind:value={draft.protocol.level} onblur={() => draft.protocol.level = clamp(draft.protocol.level,1,50)}/></label><label>TALENT UPGRADES <span>0–5</span><input type="number" min="0" max="5" bind:value={draft.protocol.talentUpgrades} onblur={() => draft.protocol.talentUpgrades = clamp(draft.protocol.talentUpgrades,0,5)}/></label></div><div class="builder-inherited">{#if protocol.primaryEffect}<span><small>{attribute(protocol.primaryEffect.stat_attribute_id)?.name} · LEVEL {draft.protocol.level}</small><b>{unitValue(Number(protocol.primaryEffect.base_value)*(1+(draft.protocol.level-1)/49),protocol.primaryEffect.stat_attribute_id)}</b></span>{/if}{#each protocol.secondaryEffects as effect}<span><small>{attribute(effect.stat_attribute_id)?.name}</small><b>{unitValue(effect.value,effect.stat_attribute_id)}</b></span>{/each}</div>{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<aside class="loadout-summary">
|
||||
<div class="summary-head"><div><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2></div><b>{summary.stats.length}</b></div>
|
||||
<div class="summary-core">{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}<div><GameIcon name={name.toLowerCase() as GameIconName} decorative/><span>{name}<b>{stat ? unitValue(stat.value,stat.id) : '0'}</b></span></div>{/each}</div>
|
||||
<div class="summary-scroll">
|
||||
<div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div>
|
||||
<div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div>
|
||||
<div class="summary-section"><header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>{#each summary.equippedTalents as equipped}<label class="summary-talent"><input type="checkbox" checked={draft.activeTalents.includes(equipped.key)} onchange={() => toggleTalent(equipped.key)}/><span><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source} · {draft.activeTalents.includes(equipped.key) ? 'TRIGGER ACTIVE' : 'CONDITIONAL'}</small><em>{equipped.description}</em></span></label>{:else}<p>Selected equipment talents appear here.</p>{/each}</div>
|
||||
</div>
|
||||
<p class="notice">Talent effects enter totals only while marked trigger active. Anonymous drafts remain on this device.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user