diff --git a/database/schema.sql b/database/schema.sql
index 40a7509..d7e7d8e 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -164,6 +164,14 @@ CREATE TABLE IF NOT EXISTS comments (
);
CREATE INDEX IF NOT EXISTS comments_thread_status_created_idx ON comments(thread_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS comments_author_status_created_idx ON comments(author_id, status, created_at DESC);
+CREATE TABLE IF NOT EXISTS comment_thread_follows (
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ root_comment_id TEXT NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL,
+ PRIMARY KEY(user_id, root_comment_id)
+);
+CREATE INDEX IF NOT EXISTS comment_thread_follows_root_idx
+ ON comment_thread_follows(root_comment_id, user_id);
CREATE TABLE IF NOT EXISTS community_moderation_settings (
id TEXT PRIMARY KEY CHECK(id='primary'),
automated_enabled INTEGER NOT NULL DEFAULT 0 CHECK(automated_enabled IN (0,1)),
@@ -859,14 +867,15 @@ CREATE TABLE IF NOT EXISTS weapon_augmentation_effects (
UNIQUE(weapon_id, sort_order),
UNIQUE(weapon_id, stat_attribute_id)
);
-CREATE TABLE IF NOT EXISTS weapon_base_attribute_bonuses (
+CREATE TABLE IF NOT EXISTS weapon_category_attribute_bonuses (
id TEXT PRIMARY KEY,
- weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE,
+ weapon_category TEXT NOT NULL CHECK(weapon_category IN ('Shotgun','Rifle','MMR','AR','LMG','SMG')),
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
value REAL NOT NULL CHECK(value > 0),
- sort_order INTEGER NOT NULL CHECK(sort_order >= 1),
- UNIQUE(weapon_id, stat_attribute_id),
- UNIQUE(weapon_id, sort_order)
+ sort_order INTEGER NOT NULL CHECK(sort_order BETWEEN 1 AND 3),
+ updated_at TEXT NOT NULL,
+ UNIQUE(weapon_category, stat_attribute_id),
+ UNIQUE(weapon_category, sort_order)
);
CREATE TABLE IF NOT EXISTS os_protocols (
id TEXT PRIMARY KEY,
diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md
index 62aa092..36a705d 100644
--- a/docs/DESIGN_SYSTEM.md
+++ b/docs/DESIGN_SYSTEM.md
@@ -389,9 +389,13 @@ direct URL. Do not present authentication as a way around a disabled section.
### Agent and authentication surfaces
-- Account pages should read as an agent dossier: identity and access at the top,
- activity metrics beside it, archives in the primary column, and security
- controls in a narrower console column.
+- The canonical signed-in account surface is the owner’s public Agent profile at
+ `/agents/{slug}`. `/account` is a compatibility route that redirects there;
+ the full notification inbox remains at `/account/notifications`.
+- Owners see the same dossier and conversation that visitors see, plus inline
+ bio editing and a compact Account Control widget in the right rail. Do not
+ create a separate profile-editing dashboard or remove community context while
+ the owner manages their profile.
- Dense account consoles use a tabbed control surface for profile, credentials,
linked identities, and account lifecycle actions. Only one panel is shown at
a time; validation failures reopen the panel containing the failed action.
@@ -446,8 +450,10 @@ direct URL. Do not present authentication as a way around a disabled section.
- ISAC user dossiers reuse the same linked-identity node. Administrative
unlinking requires a scoped identity-management capability, produces an audit
event, and must never remove the user's only available sign-in method.
-- On small screens, the split frame becomes a single column and account controls
- move before archival content.
+- On small screens, the split frame becomes one column. Show the public dossier
+ and archives first, followed by Account Control, notifications, and Recent
+ Discussion. Place Profile Messages at the bottom. Give owners a mobile-only
+ header link to Account Control.
- Public display names and private login usernames are distinct. Display names
support normalized Unicode and relaxed visible characters. They are unique
under the product's normalized identity key and generate readable,
@@ -458,38 +464,89 @@ direct URL. Do not present authentication as a way around a disabled section.
### Public Agent profiles
-Public Agent profiles are community dossiers, not account-control screens. Lead
-with the shared `AgentIdentityLink` in its large state: circular portrait,
-display name, explicit `SHD.BUILD AGENT PROFILE` context, and a directional
-link cue. Place assigned site-role badges beneath the identity as a labeled
-semantic list. Show role names only, in alphabetical order, and omit the list
-when the Agent has no assigned roles. Badges use the shared raised surface,
-explicit role text, and a small orange diamond; they wrap without truncating
-long or multiple role names. Never expose role descriptions, permissions,
-direct grants, or access scopes on public profiles. The profile owner may
-receive an `EDIT MY PROFILE` action in the header. Keep public content totals
-in a compact telemetry strip below the identity header, then place the field
-bio, public builds, published guides, and recent discussion in the primary
-column. Profile messages occupy the narrower communications column and may
-remain sticky where the viewport has room.
+Public Agent profiles are community dossiers. The owner view extends the same
+page with account controls without changing its public hierarchy. Lead with the
+shared `AgentIdentityLink` in its large state: circular portrait, display name,
+and assigned role badges. Place role badges to the right of the display name on
+desktop and below the name on mobile. Show role names only, in alphabetical
+order, and omit the list when the Agent has no assigned roles. Badges use the
+shared raised surface, explicit role text, and a small orange
+diamond; they wrap without truncating long or multiple role names. Never expose
+role descriptions, permissions, direct grants, or access scopes on public
+profiles. The owner header contains the visibility switch, its information
+control, and a mobile-only Account Control link. Present the primary identity
+and its actions directly on the page canvas
+without a card surface, border, or decorative accent rule. Give the identity and
+actions content-sized responsive tracks so long names, multiple roles, and
+mobile actions cannot overlap the public content totals. Keep those totals in a
+compact telemetry strip below the identity header. Use four columns on desktop
+and two columns on mobile. Place the field bio in the primary column and keep
+its mobile Edit Bio action compact within the section header. Put Public Builds
+and Published Guides side by side directly
+beneath the bio; stack them on narrow screens. Their cards stay within their own
+half of the primary column. Profile Messages follow the archive pair within the
+same column and must not inherit height from the right rail.
+
+Recent Discussion occupies the narrower activity rail. Owner-only Account
+Control and notification-preview widgets appear above it. The profile preview
+shows unread notifications only; the full inbox retains notification history.
+Recent-discussion
+entries use compact destination cards because each entry links to content
+elsewhere on the site. Show only the discussion type, destination title, comment
+date, and open action; do not repeat the message body in this rail. Reset nested
+card headers to content height and align the destination identity with its date;
+do not inherit the application header's fixed height or surface treatment.
+Profile messages continue directly
+beneath the biography in the primary column so the conversation begins without
+dead space.
Use `AgentIdentityLink` for author attribution on builds, guides, comments, and
other community records. Its compact and default states combine the circular
-portrait, public display name, optional contextual metadata, and a clear link
-cue. Hover and focus-visible states may turn the name and cue orange. The
-portrait remains circular at every size; selectors may use square tactical
-thumbnails only where the avatar rules explicitly allow them.
+portrait, public display name, assigned public role badges, and optional
+contextual metadata. Role badges remain beside the display name in comment
+headers and other author attributions; an empty role list renders no badge. The
+display name supplies the link affordance through its hover and focus-visible
+states; do not add a trailing arrow. Use the primary orange token for the
+portrait border, with a two-pixel edge that remains legible against the page
+canvas. The default author portrait is 44 pixels; compact identities may use 30
+pixels. The portrait remains circular at every size; selectors may use square
+tactical thumbnails only where the avatar rules explicitly allow them.
Public-profile links use the stored readable slug, URL-encoded as one path
segment. A display-name change updates the canonical slug. Legacy UUID routes
remain valid and redirect to the canonical address; UUIDs remain the internal
identity for mutations, relationships, and audit evidence.
-Public biographies and community comments use the shared `MarkdownEditor` and
-`MarkdownContent` pair. The editor supplies a small formatting toolbar,
-remaining-character count, concise help, a readable editorial typeface, and a
-minimum 44px touch target for toolbar actions on narrow screens. It supports
-headings, lists, quotes, links, emphasis, and inline code. Raw HTML is escaped.
+Public biographies use the shared `MarkdownEditor` and `MarkdownContent` pair.
+The editor supplies formatting controls, a remaining-character count, concise
+help, and a readable editorial typeface. Profile messages and replies use the
+compact `CommentComposer`: one textarea, a Markdown notice, a character count,
+and submit or cancel actions. Rendered Markdown supports headings, lists,
+quotes, links, bold, italics, and inline code. Load the required editorial font
+weights and italic face because the global type system disables synthetic font
+styles. Raw HTML is escaped.
+
+Profile conversations begin with a collapsed `LEAVE A MESSAGE` action. A reply
+opens the same editor beneath the selected message, and only one editor remains
+open. Comments sit on the page canvas without cards, section surfaces, or
+detached author headers. Align each body and action row with the username rather
+than the avatar gutter. Keep Reply visible below the message. Place Follow or
+Unfollow, Report, and the author-only Remove action in a shared ellipsis menu
+beside Reply. The menu uses a 44-pixel trigger, returns focus when
+closed, supports Escape and directional-key navigation, and becomes a fixed
+bottom panel on narrow screens. Keep destructive actions neutral at rest and use
+the danger color on hover or keyboard focus. Render conversations
+in depth-first reading order: newest root conversations first and each branch
+oldest-first. Use one-pixel guide lines and restrained indentation for replies,
+then cap visual indentation on narrow screens while retaining the logical
+parent chain. If an author or moderator removes a message that has replies, show
+a bodyless `MESSAGE REMOVED` placeholder so readers can follow the remaining
+conversation. Following applies to the top-level conversation and sends inbox
+notifications for visible descendant replies, excluding the reply author and
+any recipient already receiving the direct-reply notification. Send reply
+notifications to the direct parent author and link to the specific reply.
+Recent-discussion links target the exact comment anchor instead of the
+containing profile or content page.
Public user-generated content uses the shared `ContentReport` control when the
viewer is authenticated and does not own the content. Keep the control visually
@@ -538,8 +595,10 @@ record is absent without implying an error.
Profile visibility is an explicit public or hidden account setting. A hidden
profile is excluded from public discovery and cannot receive new profile
messages. Hiding it does not remove the Agent's display name or portrait from
-public builds, guides, or comments they authored. State this consequence beside
-the visibility control so the setting does not imply retroactive anonymity.
+public builds, guides, or comments they authored. Put the compact textual
+`PUBLIC` or `HIDDEN` switch in the owner’s identity header and explain the
+consequence in an adjacent information popover. Display-name restrictions use
+the same popover pattern instead of persistent instructional copy.
Community forms use `PendingForm` to block duplicate submissions. Pending
actions retain their label context, validation errors preserve entered text,
@@ -808,8 +867,10 @@ Repository agents and contributors should follow the maintenance checklist in
### Agent avatars
Agent portraits use a circular crop in identity headers and square tactical
-thumbnails in selectors. Preset choices appear in a compact four-column grid;
-the active choice uses the orange action border and an explicit pressed state.
+thumbnails in selectors. Account Control shows the current portrait first and
+keeps preset and upload controls behind an explicit Edit Avatar action. Preset
+choices appear in a compact four-column grid; the active choice uses the orange
+action border and an explicit pressed state.
Custom images are normalized server-side to a metadata-free 256 × 256 WebP.
Selectors must retain visible hover, focus-visible, pressed, pending, and
disabled states, and collapse naturally with their containing account console.
@@ -826,13 +887,12 @@ navigation links use the same height, typography, and interaction motion.
Complex progression definitions use a stacked control-surface editor: identity
and fixed values first, then parallel tier cards, mutually exclusive talent
-pools, fixed base bonuses, and the augmentation sequence. Tier cards retain
+pools, and the augmentation sequence. Tier cards retain
identical row ordering so values can be compared horizontally on wide screens
and stack without changing meaning on narrow screens. System-derived augment
steps appear as read-only numbered cells; editable effects use the shared
attribute/value row pattern. Exotic is a status layered onto a weapon family,
-never a replacement for the family classification. Fixed base bonuses remain
-visually separate from tactical augmentation effects. Exotic definitions add a
+never a replacement for the family classification. Exotic definitions add a
dedicated exotic-only talent after the two standard talent pools; their sixth
augmentation cell communicates that this talent advances to Tier II.
The exotic talent is owned by exactly one exotic weapon and is authored inline
@@ -842,6 +902,14 @@ Tier I/II previews, and the shared source-owned mechanic program editor. The Tal
Registry may display an owned exotic talent for discovery, but routes editing
back to its weapon rather than opening the shared-talent editor.
+Place shared weapon-category bonuses above the weapon registry in one collapsed
+profile console. Its six category buttons show the three inherited Attribute
+values and open one category editor. The editor keeps Attribute identities
+fixed and exposes only their positive numeric values. Saving closes the editor;
+validation preserves its values and error state. Standard and Exotic weapons
+inherit the profile for their underlying Shotgun, Rifle, MMR, AR, LMG, or SMG
+category. Do not repeat these controls inside individual weapon editors.
+
Large relationship sets use the searchable assignment control instead of
checkbox walls. It keeps assigned records visible as removable chips, filters
available records by identity, renders at most thirty results at once, and
@@ -1426,6 +1494,25 @@ large enough that scanning a dropdown is inefficient. The selected record,
pending-contribution state, keyboard focus, and remove interaction must remain
explicit in both modes.
+Records with established visual identity may supply an optional square icon.
+Standard assignment lists show it at 32 pixels in result rows and 24 pixels in
+selected chips. An icon-first multiselect may use the `visual-grid` presentation
+for a finite visual registry. Its compact tiles use a 38-pixel icon without
+persistent tile text. Hover and keyboard focus reveal the record name, while
+the accessible label includes the name and assignment state. The orange inset
+edge marks selected tiles. Weapon Talent 1 and Talent 2 pools and the Backpack
+and Body Armor talent pools use this grid. Use the shared missing-item artwork
+when a record lacks an icon. Render the full eligible set without a search,
+result-count row, item cap, or internal scroll region; the section header owns
+the selected count. Reflow tiles without changing keyboard order.
+
+Use the `toggle-grid` presentation for a small fixed registry whose text labels
+are the primary recognition aid. Each 44-pixel-or-taller option uses checkbox
+semantics and shows only the record name. The orange action edge marks the
+selected state, and the accessible label includes that state. Weapon mod slot
+selectors use this presentation so an operator can toggle each slot by clicking
+its name. Keep all options visible and preserve DOM order as the grid reflows.
+
Every ISAC relationship to an Attribute uses `SearchableAssignment`; native
attribute dropdowns are not permitted because the registry is expected to
contain hundreds of records. Use full mode for primary editor sections and
@@ -1679,10 +1766,16 @@ persistence controls and shared-loadout columns stack vertically.
When compatibility depends on two independent dimensions, keep those
dimensions explicit rather than presenting a flattened list. Weapon
-definitions configure their available Weapon Mod slots per tier. T0 requires
-four distinct slots; T1 and T2 require five. Weapon Mod definitions then
-configure one slot plus one or more compatible weapon categories. The UI and
-validation must require both dimensions to match.
+definitions configure one shared set of five Weapon Mod slots for T1 and T2.
+T0 uses the same set minus one explicitly selected slot. A T2-native weapon
+shows only its applicable shared-slot control, and tiers below the native tier
+remain unavailable. Operators may save an unverified shared set or T0 exclusion
+when they cannot inspect the weapon in-game. Mark an active weapon `CONFIG
+REQUIRED` until every available tier has its full slot count, and keep verified
+assignments editable. Reject unknown slot types and counts above the tier limit.
+Weapon Mod definitions then configure one slot plus one or more compatible
+weapon categories. The UI and validation must require both dimensions to match
+once the weapon definition is complete.
Weapon Mod definitions use the shared segmented-choice control for the bounded
eight-slot vocabulary, with a full-width row on desktop and two columns on
diff --git a/docs/architecture.md b/docs/architecture.md
index 6f87573..199f7aa 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -255,6 +255,26 @@ active Administrator. A later critical transition may alert again; repeated
scheduler runs within the same episode do not. Provider errors preserve the
last known balance and never expose the API key or raw response body.
+# Weapon category bonuses
+
+`weapon_category_attribute_bonuses` stores three ordered fixed Attribute
+contributions for each Shotgun, Rifle, MMR, AR, LMG, and SMG category. Each
+profile grants 5% Weapon Critical Hit Chance and 50% Weapon Critical Hit Damage.
+Shotgun, Rifle, AR, LMG, and SMG grant 40% Headshot Damage; MMR grants 70%.
+
+Weapon records do not own copies of these rows. Both builder catalog loaders
+attach the matching category profile when they hydrate a weapon. Exotic weapons
+retain an underlying category and inherit the same profile as a standard weapon
+of that type. The combat summary preserves the existing equipped-weapon
+semantics: each equipped weapon contributes its inherited profile to the
+Agent's accumulated Attributes.
+
+ISAC exposes one category-profile editor above the weapon registry. Operators
+may change the three positive values, while the Attribute identities and order
+remain fixed. Each successful save appends a
+`game_data/weapon_bonus_profile.updated` audit event with the complete previous
+and resulting profile.
+
# Weapon-owned exotic talents
Standard weapon talent pools remain reusable across weapons and use Talent 1 /
@@ -291,9 +311,12 @@ the existing `game_data` audit history.
### Weapon Mods and tiered weapon slots
`weapon_mod_slots` records each weapon's available slot vocabulary per tier.
-Canonical validation requires four unique slots for T0 and five for T1/T2 for
-every tier at or above the weapon's native tier. Regular Weapon Mods declare a
-single slot type and one or more compatible base weapon categories through
+The editor captures one shared set for T1/T2 and the single shared slot omitted
+at T0, then expands that input into the normalized per-tier rows. Operators may
+save partial rows when the in-game configuration is unavailable. ISAC derives
+`CONFIG REQUIRED` until every available tier contains four unique slots at T0
+or five at T1/T2. Regular Weapon Mods declare a single slot type and one or more
+compatible base weapon categories through
`weapon_mod_compatible_categories`; eligibility requires both a category match
and the slot to exist on the equipped weapon at its selected tier.
diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs
index 49871b6..1722691 100644
--- a/scripts/db-migrate.mjs
+++ b/scripts/db-migrate.mjs
@@ -2,6 +2,7 @@ import { connect } from '@tursodatabase/database';
import { access, mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import process from 'node:process';
+import { randomUUID } from 'node:crypto';
import { initializeDefaultUsers } from './default-users.mjs';
const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
@@ -386,6 +387,53 @@ for (const statement of statements) {
await database.exec(statement);
}
+const weaponBonusAttributes = await (
+ await database.prepare(`SELECT id,name FROM stat_attributes
+ WHERE name IN ('Weapon Critical Hit Chance','Weapon Critical Hit Damage','Headshot Damage')`)
+).all();
+if (weaponBonusAttributes.length && weaponBonusAttributes.length !== 3) {
+ throw new Error('The shared weapon-category bonus migration requires all three fixed weapon Attributes.');
+}
+if (weaponBonusAttributes.length === 3) {
+ const attributeId = (name) => String(weaponBonusAttributes.find((attribute) => attribute.name === name)?.id ?? '');
+ const defaults = [
+ ['Weapon Critical Hit Chance', 5],
+ ['Weapon Critical Hit Damage', 50],
+ ['Headshot Damage', 40]
+ ];
+ await database.exec('BEGIN');
+ try {
+ for (const category of ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']) {
+ for (let index = 0; index < defaults.length; index += 1) {
+ const [name, defaultValue] = defaults[index];
+ const value = category === 'MMR' && name === 'Headshot Damage' ? 70 : defaultValue;
+ await (await database.prepare(`INSERT OR IGNORE INTO weapon_category_attribute_bonuses(
+ id,weapon_category,stat_attribute_id,value,sort_order,updated_at
+ ) VALUES(?,?,?,?,?,?)`)).run(
+ randomUUID(), category, attributeId(name), value, index + 1, new Date().toISOString()
+ );
+ }
+ }
+ const configured = await (await database.prepare(
+ 'SELECT COUNT(*) AS count FROM weapon_category_attribute_bonuses'
+ )).get();
+ if (Number(configured?.count) !== 18) {
+ throw new Error('Every weapon category must have exactly three shared base Attribute bonuses.');
+ }
+ const legacyWeaponBonuses = await (await database.prepare(
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='weapon_base_attribute_bonuses'"
+ )).get();
+ if (legacyWeaponBonuses) {
+ console.log('Replacing per-weapon base Attribute bonuses with shared weapon-category profiles.');
+ await database.exec('DROP TABLE weapon_base_attribute_bonuses');
+ }
+ await database.exec('COMMIT');
+ } catch (error) {
+ await database.exec('ROLLBACK');
+ throw error;
+ }
+}
+
const deletedRoleTable = await (await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='deleted_roles'"
)).get();
diff --git a/scripts/isac-data-tables.mjs b/scripts/isac-data-tables.mjs
index f7fc31a..29f66e9 100644
--- a/scripts/isac-data-tables.mjs
+++ b/scripts/isac-data-tables.mjs
@@ -22,7 +22,7 @@ export const ISAC_DATA_TABLES = [
'weapon_tier_ranges',
'weapon_talent_options',
'weapon_augmentation_effects',
- 'weapon_base_attribute_bonuses',
+ 'weapon_category_attribute_bonuses',
'os_protocols',
'os_protocol_primary_effects',
'os_protocol_secondary_profiles',
diff --git a/src/app.css b/src/app.css
index 9667f2e..3095942 100644
--- a/src/app.css
+++ b/src/app.css
@@ -2409,3 +2409,35 @@
.modeling-content{padding:10px}
@media(max-width:560px){.modeling-disclosure>summary{min-height:56px}.modeling-content{padding:7px}}
@media(prefers-reduced-motion:reduce){.modeling-disclosure>summary{transition:none}}
+
+/* Optional visual identity in shared assignment options. */
+.assignment-item-icon{flex:0 0 auto;object-fit:contain;background:var(--shd-bg)}
+.assignment-chip-icon{width:24px;height:24px}
+.assignment-results>button.with-icon{min-height:44px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:9px}
+.assignment-result-icon{width:32px;height:32px}
+.assignment-results>button.with-icon>span{min-width:0;overflow-wrap:anywhere}
+.assignment-picker.visual-grid .assignment-results{display:grid;grid-template-columns:repeat(auto-fill,minmax(52px,1fr));grid-auto-rows:52px;gap:2px;max-height:none;padding:2px;overflow:visible;background:var(--shd-border-soft)}
+.assignment-picker.visual-grid .assignment-results>button.visual-grid-option{position:relative;min-width:0;min-height:44px;display:grid;place-items:center;padding:5px;border:0;background:var(--shd-surface)}
+.assignment-grid-icon{width:38px;height:38px}
+.assignment-picker.visual-grid .assignment-results>button.visual-grid-option::after{content:attr(data-tooltip);position:absolute;left:50%;bottom:calc(100% + 6px);z-index:30;width:max-content;max-width:210px;padding:5px 7px;border:1px solid var(--shd-border-strong);background:var(--shd-bg);color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:700;line-height:1.25;white-space:normal;opacity:0;pointer-events:none;transform:translateX(-50%)}
+.assignment-picker.visual-grid .assignment-results>button.visual-grid-option:hover::after,.assignment-picker.visual-grid .assignment-results>button.visual-grid-option:focus-visible::after{opacity:1}
+.assignment-picker.visual-grid .assignment-results>button.visual-grid-option:hover{background:var(--shd-surface-active);box-shadow:inset 0 0 0 1px var(--shd-border-strong)}
+.assignment-picker.visual-grid .assignment-results>button.visual-grid-option:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:-2px}
+.assignment-picker.visual-grid .assignment-results>button.visual-grid-option.selected{background:var(--shd-surface-active);box-shadow:inset 0 0 0 2px var(--shd-orange)}
+.assignment-picker.toggle-grid{border:0;background:transparent}
+.assignment-picker.toggle-grid .assignment-results{display:grid;grid-template-columns:repeat(auto-fit,minmax(94px,1fr));gap:5px;max-height:none;overflow:visible}
+.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option{min-width:0;min-height:44px;display:grid;grid-template-columns:minmax(0,1fr);padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface);color:var(--shd-text-muted)}
+.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option>span{min-width:0;font-size:var(--shd-type-supporting);font-weight:700;overflow-wrap:anywhere}
+.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option:hover{border-color:var(--shd-border-strong);background:var(--shd-surface-active);color:var(--shd-text)}
+.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
+.assignment-picker.toggle-grid .assignment-results>button.toggle-grid-option.selected{border-color:var(--shd-orange);background:var(--shd-surface-active);color:var(--shd-orange);box-shadow:inset 2px 0 var(--shd-orange)}
+@media(max-width:560px){.assignment-picker.visual-grid .assignment-results{grid-template-columns:repeat(auto-fill,minmax(48px,1fr))}.assignment-picker.toggle-grid .assignment-results{grid-template-columns:repeat(2,minmax(0,1fr))}}
+
+/* Shared weapon-category bonus profiles. */
+.weapon-bonus-registry{margin-top:10px}
+.weapon-bonus-profile-grid>button header strong{display:flex;align-items:center;gap:6px}
+.weapon-bonus-editor{border-top-color:var(--shd-success)}
+.weapon-bonus-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin-top:9px}
+.weapon-mod-slot-note{display:flex;align-items:center;gap:10px;margin:8px 0;padding:8px 10px;border:1px solid #74552a;background:#18150d;color:#a89568;font-size:var(--shd-type-telemetry);line-height:1.45}.weapon-mod-slot-note b{flex:0 0 auto;color:var(--shd-orange);letter-spacing:.8px}.weapon-mod-slot-config{margin-top:8px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.weapon-mod-slot-config b.config-required,.state-dot.config-required{color:var(--shd-orange)}.weapon-t0-exclusion{margin-top:10px;padding-top:9px;border-top:1px solid var(--shd-border)}.weapon-t0-exclusion>.segmented-choice{margin-top:7px}.weapon-t0-exclusion>.segmented-choice>div{height:auto;min-height:44px}@media(max-width:700px){.weapon-mod-slot-note{align-items:flex-start;flex-direction:column;gap:4px}.weapon-t0-exclusion>.segmented-choice>div{grid-template-columns:repeat(2,minmax(0,1fr))!important}}
+.weapon-bonus-fields .field{margin:0}
+@media(max-width:700px){.weapon-bonus-fields{grid-template-columns:1fr}}
diff --git a/src/lib/components/AccountControl.svelte b/src/lib/components/AccountControl.svelte
new file mode 100644
index 0000000..797bfce
--- /dev/null
+++ b/src/lib/components/AccountControl.svelte
@@ -0,0 +1,188 @@
+
+
+
+ ACCOUNT CONTROL
+
+ accountPanel = 'identity'}>PROFILE
+ accountPanel = 'access'}>ACCESS
+ accountPanel = 'account'}>ACCOUNT
+
+
+ {#if accountPanel === 'identity'}
+
+
AGENT PORTRAIT
AVATAR
+
+
+
CURRENT AVATAR {displayName}
+
avatarEditorOpen = !avatarEditorOpen}>{avatarEditorOpen ? 'CLOSE' : 'EDIT AVATAR'}
+
+ {#if avatarEditorOpen}
+
+
+ avatarPanel = 'presets'}>SHD PRESETS
+ avatarPanel = 'upload'}>CUSTOM UPLOAD
+
+ {#if avatarPanel === 'presets'}
+
+ {#each account.avatarPresets as preset}
+
+
+ {preset.label}
+
+
+ {/each}
+
+ {:else}
+
+
+ JPEG, PNG, WebP, or AVIF · 5 MiB maximum · cropped to a 256 px square.
+
+ UPLOAD CUSTOM AVATAR →
+
+ {/if}
+
+ {/if}
+
+
+
PUBLIC IDENTITY
DISPLAY NAME
+
+
+
+ PUBLIC DISPLAY NAME
+ UPDATE DISPLAY NAME →
+
+
+ {:else if accountPanel === 'access'}
+
+
+ accessPanel = 'passkeys'}>PASSKEYS {account.passkeys.length}
+ accessPanel = 'email'}>EMAIL {account.emailIdentity ? 1 : 0}
+ accessPanel = 'local'}>PASSWORD {account.hasLocalCredentials ? 1 : 0}
+ accessPanel = 'connections'}>LINKS {account.discordConnection ? 1 : 0}
+
+ {#if accessPanel === 'passkeys'}
+
PREFERRED ACCESS
PASSKEYS
+ {#if account.passkeys.length}
+
+ {#each account.passkeys as passkey}
+
{passkey.name} {passkey.backedUp ? 'SYNCED PASSKEY' : 'DEVICE PASSKEY'}
REMOVE
+ {/each}
+
+ {:else}
NO PASSKEYS REGISTERED
{/if}
+
PASSKEY NAME
+
{passkeyPending ? 'REGISTERING…' : 'ADD A PASSKEY →'}
+ {:else if accessPanel === 'email'}
+
PASSWORDLESS RECOVERY
MAGIC LINK EMAIL
+ {#if account.emailIdentity}
+
{account.emailIdentity.email} VERIFIED EMAIL IDENTITY
VERIFIED
+
REMOVE MAGIC-LINK EMAIL
+ {/if}
+
+ {account.emailIdentity ? 'CHANGE VERIFIED EMAIL' : 'EMAIL ADDRESS'}
+ SEND VERIFICATION LINK →
+
+ {:else if accessPanel === 'local'}
+
OPTIONAL FALLBACK
USERNAME & PASSWORD
+ {#if account.hasLocalCredentials}
+
PRIVATE LOGIN USERNAME UPDATE LOGIN USERNAME →
+
+
CURRENT PASSWORD NEW PASSWORD CONFIRM PASSWORD UPDATE PASSWORD →
+
REMOVE USERNAME & PASSWORD CURRENT PASSWORD REMOVE PASSWORD SIGN-IN
+ {:else}
+
USERNAME AND PASSWORD SIGN-IN IS NOT CONFIGURED
+
PRIVATE LOGIN USERNAME PASSWORD CONFIRM PASSWORD ADD PASSWORD FALLBACK →
+ {/if}
+ {:else}
+
CONNECTIONS
SIGN-IN PROVIDERS
+ {#if account.discordConnection}
+
+
{account.discordConnection.displayName ?? account.discordConnection.username ?? 'DISCORD USER'} {account.discordConnection.username ? `@${account.discordConnection.username}` : `ID ${account.discordConnection.id}`}
+
discordUnlinkConfirm = true}>UNLINK
+ {#if discordUnlinkConfirm}
UNLINK DISCORD? discordUnlinkConfirm = false}>CANCEL CONFIRM {/if}
+
+ {:else}
+
discordLinkPending = true}>Link Discord
+ {/if}
+ {/if}
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/src/lib/components/AgentIdentityLink.svelte b/src/lib/components/AgentIdentityLink.svelte
index b58a504..c2e24a8 100644
--- a/src/lib/components/AgentIdentityLink.svelte
+++ b/src/lib/components/AgentIdentityLink.svelte
@@ -1,29 +1,42 @@
-{#if linked}
+{#if linked && roles !== null}
+
+
+
+
+ {#if meta}
{meta} {:else if showId}
AGENT {agent.id.slice(0,8).toUpperCase()} {/if}
+
+
+{:else if linked}
{agent.displayName}
{#if meta}{meta} {:else if showId}AGENT {agent.id.slice(0,8).toUpperCase()} {/if}
- ›
{:else}
@@ -33,14 +46,15 @@
{/if}
diff --git a/src/lib/components/CommentComposer.svelte b/src/lib/components/CommentComposer.svelte
new file mode 100644
index 0000000..4f3078a
--- /dev/null
+++ b/src/lib/components/CommentComposer.svelte
@@ -0,0 +1,51 @@
+
+
+
+
+
diff --git a/src/lib/components/ContentReport.svelte b/src/lib/components/ContentReport.svelte
index 2d4bed4..4e1bf39 100644
--- a/src/lib/components/ContentReport.svelte
+++ b/src/lib/components/ContentReport.svelte
@@ -5,12 +5,16 @@
contentType,
contentId,
label = 'REPORT',
- result
+ result,
+ plain = false,
+ menuItem = false
}: {
contentType: 'comment' | 'profile_bio' | 'guide' | 'avatar';
contentId: string;
label?: string;
result?: Record
| null;
+ plain?: boolean;
+ menuItem?: boolean;
} = $props();
const categories = [
@@ -27,37 +31,45 @@
String(result?.userReportContentId ?? '') === contentId
&& String(result?.userReportContentType ?? '') === contentType
);
+ let reportOpen = $state(false);
+
+ function closeReport() {
+ reportOpen = false;
+ }
-
- {label}
-
-
REQUEST OVERSIGHT REVIEW
-
Send this content to Community Oversight. A report does not remove it automatically.
-
- {#snippet children(pending)}
-
-
- CONCERN
-
- {#each categories as category}{category.label} {/each}
-
-
- DETAILS — OPTIONAL
-
-
- {#if matchesResult && result?.userReportError}{String(result.userReportError)}
{/if}
- {#if matchesResult && result?.userReportSaved}Report sent to Community Oversight.
{/if}
-
- {pending ? 'SENDING…' : 'SEND REPORT →'}
-
- {/snippet}
-
-
+
+ {label}
+ {#if reportOpen}
+
+
REQUEST OVERSIGHT REVIEW
+
Send this content to Community Oversight. A report does not remove it automatically.
+
+ {#snippet children(pending)}
+
+
+ CONCERN
+
+ {#each categories as category}{category.label} {/each}
+
+
+ DETAILS — OPTIONAL
+
+
+ {#if matchesResult && result?.userReportError}{String(result.userReportError)}
{/if}
+ {#if matchesResult && result?.userReportSaved}Report sent to Community Oversight.
{/if}
+
+ CANCEL
+ {pending ? 'SENDING…' : 'SEND REPORT →'}
+
+ {/snippet}
+
+
+ {/if}
diff --git a/src/lib/components/InfoPopover.svelte b/src/lib/components/InfoPopover.svelte
new file mode 100644
index 0000000..5924db8
--- /dev/null
+++ b/src/lib/components/InfoPopover.svelte
@@ -0,0 +1,48 @@
+
+
+
+ i
+
+
+
+
diff --git a/src/lib/components/MarkdownContent.svelte b/src/lib/components/MarkdownContent.svelte
index 93d5ed1..d683c07 100644
--- a/src/lib/components/MarkdownContent.svelte
+++ b/src/lib/components/MarkdownContent.svelte
@@ -27,6 +27,8 @@
.markdown-content :global(li+li){margin-top:.3em}
.markdown-content :global(a){color:var(--shd-orange);font-family:var(--shd-font-sans);font-weight:700}
.markdown-content :global(a:hover),.markdown-content :global(a:focus-visible){color:var(--shd-orange-hover)}
+ .markdown-content :global(strong){font-weight:700}
+ .markdown-content :global(em){font-style:italic}
.markdown-content :global(blockquote){padding:.2em 0 .2em 1em;border-left:2px solid var(--shd-orange);color:var(--shd-text-muted)}
.markdown-content :global(code){padding:2px 4px;border:1px solid var(--shd-border);background:#0d0f10;color:var(--shd-text);font-family:var(--shd-font-mono);font-size:.9em}
.markdown-content :global(:first-child){margin-top:0}
diff --git a/src/lib/components/ModerationDisclosure.svelte b/src/lib/components/ModerationDisclosure.svelte
index 085c309..2bbd7b2 100644
--- a/src/lib/components/ModerationDisclosure.svelte
+++ b/src/lib/components/ModerationDisclosure.svelte
@@ -1,4 +1,8 @@
-
+
+
+
i
This submission may receive automated safety review.
@@ -13,5 +17,6 @@
.moderation-disclosure a{display:inline-block;margin-left:4px;color:var(--shd-orange);font-weight:900;letter-spacing:.4px}
.moderation-disclosure a:hover{text-decoration:underline}
.moderation-disclosure a:focus-visible{outline:2px solid var(--shd-orange);outline-offset:3px}
+ .moderation-disclosure.compact{align-items:center;margin:0;padding:0;border:0;background:transparent}.moderation-disclosure.compact>span{display:none}.moderation-disclosure.compact p{color:var(--shd-text-dim)}.moderation-disclosure.compact a{color:var(--shd-text-muted)}
@media(max-width:650px){.moderation-disclosure{padding:10px}.moderation-disclosure a{display:block;min-height:44px;margin:4px 0 0;padding-top:8px}}
diff --git a/src/lib/components/OverflowMenu.svelte b/src/lib/components/OverflowMenu.svelte
new file mode 100644
index 0000000..0053ff0
--- /dev/null
+++ b/src/lib/components/OverflowMenu.svelte
@@ -0,0 +1,89 @@
+
+
+
+
+
diff --git a/src/lib/components/SearchableAssignment.svelte b/src/lib/components/SearchableAssignment.svelte
index d66f1de..c298843 100644
--- a/src/lib/components/SearchableAssignment.svelte
+++ b/src/lib/components/SearchableAssignment.svelte
@@ -1,11 +1,22 @@
- { if (compact) open = true; }} onfocusout={(event) => {
+
{ if (compact) open = true; }} onfocusout={(event) => {
if (compact && !event.currentTarget.contains(event.relatedTarget as Node | null)) open = false;
}}>
{#if fieldName}{#each selectedIds as id}
{/each}{/if}
-
-
{mode === 'single' ? 'SELECTED' : 'ASSIGNED'} {selectedIds.length}
-
- {#each selectedItems as item}
-
toggle(item.id)} aria-label={`Remove ${accessibleName(item)}`}>{item.name}{#if unitLabel(item)}UNIT · {unitLabel(item)} {/if}{#if item.pending}PENDING {/if} ×
- {:else}
-
{emptySelectionLabel}
- {/each}
+ {#if presentation === 'list'}
+
+
{mode === 'single' ? 'SELECTED' : 'ASSIGNED'} {selectedIds.length}
+
+ {#each selectedItems as item}
+ toggle(item.id)} aria-label={`Remove ${accessibleName(item)}`}>
+ {#if item.showIcon || item.iconUrl} {/if}
+ {item.name}{#if unitLabel(item)}UNIT · {unitLabel(item)} {/if}{#if item.pending}PENDING {/if} ×
+
+ {:else}
+ {emptySelectionLabel}
+ {/each}
+
-
-
{searchLabel}
-
{available.length} MATCHES {#if available.length > visible.length}SHOWING FIRST {visible.length} {/if}
+
{searchLabel}
+
{available.length} MATCHES {#if available.length > visible.length}SHOWING FIRST {visible.length} {/if}
+ {/if}
{#each visible as item}
-
toggle(item.id)}>
- {item.name}{#if unitLabel(item)}UNIT · {unitLabel(item)} {/if}{#if item.pending}PENDING · {item.proposerName ?? 'CONTRIBUTOR'} {/if} {selectedIds.includes(item.id) ? (mode === 'single' ? 'SELECTED' : 'ASSIGNED') : (mode === 'single' ? 'SELECT →' : 'ADD +')}
+ toggle(item.id)}>
+ {#if presentation === 'visual-grid' || item.showIcon || item.iconUrl} {/if}
+ {#if presentation === 'toggle-grid'}
+ {item.name}
+ {:else if presentation === 'list'}
+ {item.name}{#if unitLabel(item)}UNIT · {unitLabel(item)} {/if}{#if item.pending}PENDING · {item.proposerName ?? 'CONTRIBUTOR'} {/if} {selectedIds.includes(item.id) ? (mode === 'single' ? 'SELECTED' : 'ASSIGNED') : (mode === 'single' ? 'SELECT →' : 'ADD +')}
+ {/if}
{:else}
{emptyLabel}
diff --git a/src/lib/server/account-control-data.ts b/src/lib/server/account-control-data.ts
new file mode 100644
index 0000000..3bb9ed5
--- /dev/null
+++ b/src/lib/server/account-control-data.ts
@@ -0,0 +1,67 @@
+import { AVATAR_PRESETS } from '$lib/server/avatars';
+import { emailDeliveryConfigured } from '$lib/server/auth/mail';
+import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
+import type { Database } from '@tursodatabase/database';
+
+export async function loadAccountControlData(
+ database: Database,
+ userId: string,
+ requestedPanel: string | null = null
+) {
+ const [connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread] =
+ await Promise.all([
+ (await database.prepare(`SELECT users.discord_id,users.login_username,users.password_hash,
+ discord_profiles.username AS discord_username,discord_profiles.display_name,
+ discord_profiles.avatar_hash
+ FROM users
+ LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id
+ WHERE users.id=?`)).get(userId),
+ (await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(userId),
+ (await database.prepare(`SELECT id,name,device_type,backed_up,last_used_at,created_at
+ FROM passkeys WHERE user_id=? ORDER BY created_at DESC`)).all(userId),
+ getAuthenticationMethods(database, userId),
+ (await database.prepare(`SELECT n.id,n.category,n.source,n.title,n.body,n.link,n.mandatory,
+ n.created_at,n.read_at
+ FROM notifications n
+ LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
+ WHERE n.user_id=? AND n.read_at IS NULL
+ AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)
+ ORDER BY n.created_at DESC LIMIT 5`)).all(userId),
+ (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
+ LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
+ WHERE n.user_id=? AND n.read_at IS NULL
+ AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(userId)
+ ]);
+ return {
+ initialPanel: ['identity', 'access', 'account'].includes(String(requestedPanel))
+ ? String(requestedPanel)
+ : 'identity',
+ loginUsername: connection?.login_username ? String(connection.login_username) : null,
+ hasLocalCredentials: typeof connection?.password_hash === 'string'
+ && typeof connection?.login_username === 'string',
+ discordConnection: connection?.discord_id ? {
+ id: String(connection.discord_id),
+ username: connection.discord_username ? String(connection.discord_username) : null,
+ displayName: connection.display_name ? String(connection.display_name) : null,
+ avatarHash: connection.avatar_hash ? String(connection.avatar_hash) : null,
+ canUnlink: canRemoveAuthenticationMethod(authenticationMethods, 'discord')
+ } : null,
+ emailIdentity: emailIdentity ? {
+ email: String(emailIdentity.email),
+ verifiedAt: String(emailIdentity.verified_at)
+ } : null,
+ passkeys: passkeys.map((passkey) => ({
+ id: String(passkey.id),
+ name: String(passkey.name),
+ deviceType: String(passkey.device_type),
+ backedUp: Boolean(passkey.backed_up),
+ lastUsedAt: passkey.last_used_at ? String(passkey.last_used_at) : null,
+ createdAt: String(passkey.created_at)
+ })),
+ authenticationMethods,
+ emailDeliveryConfigured: emailDeliveryConfigured(),
+ avatarPresets: AVATAR_PRESETS,
+ notifications,
+ notificationUnread: Number(notificationUnread?.count ?? 0)
+ };
+}
diff --git a/src/lib/server/profile-comment-follows.ts b/src/lib/server/profile-comment-follows.ts
new file mode 100644
index 0000000..17d4e50
--- /dev/null
+++ b/src/lib/server/profile-comment-follows.ts
@@ -0,0 +1,72 @@
+import { agentProfileHref } from '$lib/agents';
+import { getDatabase } from '$lib/server/database';
+import { createNotification } from '$lib/server/notifications';
+import {
+ profileCommentFollowerRecipients,
+ profileCommentRootId
+} from '$lib/server/profile-comment-thread';
+
+type CommentTreeRow = {
+ id: unknown;
+ parent_id: unknown;
+};
+
+export async function profileCommentConversation(
+ commentId: string,
+ profileOwnerId: string
+): Promise<{ rootId: string; rows: Array<{ id: string; parentId: string | null }> } | null> {
+ const database = await getDatabase();
+ const selected = await (await database.prepare(`SELECT comments.id,comments.thread_id
+ FROM comments
+ JOIN comment_threads thread ON thread.id=comments.thread_id
+ WHERE comments.id=? AND comments.status='visible'
+ AND thread.target_type='profile' AND thread.target_id=?`))
+ .get(commentId, profileOwnerId);
+ if (!selected?.thread_id) return null;
+ const records = await (await database.prepare(
+ `SELECT id,parent_id FROM comments WHERE thread_id=? AND status IN ('visible','removed')`
+ )).all(selected.thread_id) as CommentTreeRow[];
+ const rows = records.map((row) => ({
+ id: String(row.id),
+ parentId: row.parent_id ? String(row.parent_id) : null
+ }));
+ const rootId = profileCommentRootId(rows, commentId);
+ return rootId ? { rootId, rows } : null;
+}
+
+export async function notifyProfileCommentFollowers(input: {
+ commentId: string;
+ authorId: string;
+ authorDisplayName: string;
+ profileOwnerId: string;
+ profileOwnerDisplayName: string;
+ profileOwnerPublicSlug: string | null;
+ directRecipientId: string | null;
+}) {
+ const conversation = await profileCommentConversation(input.commentId, input.profileOwnerId);
+ if (!conversation) return;
+ const database = await getDatabase();
+ const followerRows = await (await database.prepare(
+ 'SELECT user_id FROM comment_thread_follows WHERE root_comment_id=?'
+ )).all(conversation.rootId);
+ const recipients = profileCommentFollowerRecipients(
+ input.authorId,
+ input.directRecipientId,
+ followerRows.map((row) => String(row.user_id))
+ );
+ const link = agentProfileHref({
+ id: input.profileOwnerId,
+ publicSlug: input.profileOwnerPublicSlug
+ }, `profile-comment-${input.commentId}`);
+ for (const userId of recipients) {
+ await createNotification({
+ userId,
+ category: 'comments',
+ source: 'agent',
+ title: 'New reply in a followed conversation',
+ body: `${input.authorDisplayName} replied in a conversation you follow on ${input.profileOwnerDisplayName}'s Agent profile.`,
+ link,
+ actions: [{ label: 'VIEW REPLY', href: link }]
+ });
+ }
+}
diff --git a/src/lib/server/profile-comment-thread.test.ts b/src/lib/server/profile-comment-thread.test.ts
new file mode 100644
index 0000000..c1bb080
--- /dev/null
+++ b/src/lib/server/profile-comment-thread.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from 'vitest';
+import {
+ orderProfileCommentThread,
+ profileCommentFollowerRecipients,
+ profileCommentRootId,
+ profileCommentNotificationRecipient
+} from './profile-comment-thread';
+
+const row = (
+ id: string,
+ parentId: string | null,
+ createdAt: string,
+ status: 'visible' | 'removed' = 'visible'
+) => ({ id, parentId, createdAt, status, label: id });
+
+describe('profile comment threading', () => {
+ it('shows newest conversations first and replies in reading order', () => {
+ const ordered = orderProfileCommentThread([
+ row('older-root', null, '2026-01-01T00:00:00.000Z'),
+ row('newer-root', null, '2026-01-03T00:00:00.000Z'),
+ row('later-reply', 'newer-root', '2026-01-03T02:00:00.000Z'),
+ row('first-reply', 'newer-root', '2026-01-03T01:00:00.000Z'),
+ row('nested-reply', 'first-reply', '2026-01-03T01:30:00.000Z')
+ ]);
+
+ expect(ordered.map((comment) => [comment.id, comment.depth])).toEqual([
+ ['newer-root', 0],
+ ['first-reply', 1],
+ ['nested-reply', 2],
+ ['later-reply', 1],
+ ['older-root', 0]
+ ]);
+ expect(ordered.find((comment) => comment.id === 'newer-root')?.hasReplies).toBe(true);
+ expect(ordered.find((comment) => comment.id === 'nested-reply')?.rootId).toBe('newer-root');
+ });
+
+ it('keeps removed ancestors only when they preserve a visible conversation', () => {
+ const ordered = orderProfileCommentThread([
+ row('removed-parent', null, '2026-01-01T00:00:00.000Z', 'removed'),
+ row('visible-reply', 'removed-parent', '2026-01-02T00:00:00.000Z'),
+ row('removed-leaf', null, '2026-01-03T00:00:00.000Z', 'removed')
+ ]);
+
+ expect(ordered.map((comment) => comment.id)).toEqual(['removed-parent', 'visible-reply']);
+ });
+
+ it('recovers visible orphaned and cyclic rows as top-level conversations', () => {
+ const ordered = orderProfileCommentThread([
+ row('orphan', 'missing', '2026-01-03T00:00:00.000Z'),
+ row('cycle-a', 'cycle-b', '2026-01-01T00:00:00.000Z'),
+ row('cycle-b', 'cycle-a', '2026-01-02T00:00:00.000Z')
+ ]);
+
+ expect(new Set(ordered.map((comment) => comment.id))).toEqual(
+ new Set(['orphan', 'cycle-a', 'cycle-b'])
+ );
+ expect(ordered.every((comment) => Number.isFinite(comment.depth))).toBe(true);
+ });
+
+ it('notifies the direct reply recipient without sending self-notifications', () => {
+ expect(profileCommentNotificationRecipient('author', 'parent-author', 'owner'))
+ .toBe('parent-author');
+ expect(profileCommentNotificationRecipient('author', 'author', 'owner'))
+ .toBe('owner');
+ expect(profileCommentNotificationRecipient('owner', null, 'owner'))
+ .toBeNull();
+ });
+
+ it('resolves a nested reply to its top-level conversation', () => {
+ const rows = [
+ row('root', null, '2026-01-01T00:00:00.000Z'),
+ row('reply', 'root', '2026-01-02T00:00:00.000Z'),
+ row('nested', 'reply', '2026-01-03T00:00:00.000Z')
+ ];
+ expect(profileCommentRootId(rows, 'nested')).toBe('root');
+ expect(profileCommentRootId(rows, 'missing')).toBeNull();
+ });
+
+ it('deduplicates followers and excludes the author and direct recipient', () => {
+ expect(profileCommentFollowerRecipients(
+ 'author',
+ 'direct',
+ ['follower', 'direct', 'author', 'follower', 'second']
+ )).toEqual(['follower', 'second']);
+ });
+});
diff --git a/src/lib/server/profile-comment-thread.ts b/src/lib/server/profile-comment-thread.ts
new file mode 100644
index 0000000..6a212e8
--- /dev/null
+++ b/src/lib/server/profile-comment-thread.ts
@@ -0,0 +1,106 @@
+export type ProfileCommentThreadRow = {
+ id: string;
+ parentId: string | null;
+ status: 'visible' | 'removed';
+ createdAt: string;
+};
+
+export type ThreadedProfileComment = T & {
+ depth: number;
+ hasReplies: boolean;
+ rootId: string;
+};
+
+const timestamp = (value: string) => {
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? 0 : parsed;
+};
+
+export function orderProfileCommentThread(
+ rows: T[]
+): Array> {
+ const nodes = new Map(rows.map((row) => [
+ row.id,
+ { row, children: [] as T[] }
+ ]));
+ const roots: T[] = [];
+
+ for (const row of rows) {
+ const parent = row.parentId && row.parentId !== row.id
+ ? nodes.get(row.parentId)
+ : null;
+ if (parent) parent.children.push(row);
+ else roots.push(row);
+ }
+
+ const renderable = new Map();
+ const visibilityStack = new Set();
+ const hasVisibleContent = (row: T): boolean => {
+ const cached = renderable.get(row.id);
+ if (cached !== undefined) return cached;
+ if (visibilityStack.has(row.id)) return row.status === 'visible';
+ visibilityStack.add(row.id);
+ const result = row.status === 'visible'
+ || Boolean(nodes.get(row.id)?.children.some(hasVisibleContent));
+ visibilityStack.delete(row.id);
+ renderable.set(row.id, result);
+ return result;
+ };
+
+ const byNewest = (left: T, right: T) =>
+ timestamp(right.createdAt) - timestamp(left.createdAt) || right.id.localeCompare(left.id);
+ const byOldest = (left: T, right: T) =>
+ timestamp(left.createdAt) - timestamp(right.createdAt) || left.id.localeCompare(right.id);
+ roots.sort(byNewest);
+ for (const node of nodes.values()) node.children.sort(byOldest);
+
+ const ordered: Array> = [];
+ const visited = new Set();
+ const append = (row: T, depth: number, rootId: string) => {
+ if (visited.has(row.id) || !hasVisibleContent(row)) return;
+ visited.add(row.id);
+ const children = (nodes.get(row.id)?.children ?? []).filter(hasVisibleContent);
+ ordered.push({ ...row, depth, hasReplies: children.length > 0, rootId });
+ for (const child of children) append(child, depth + 1, rootId);
+ };
+
+ for (const root of roots) append(root, 0, root.id);
+ for (const row of [...rows].sort(byNewest)) append(row, 0, row.id);
+ return ordered;
+}
+
+export function profileCommentRootId(
+ rows: Array>,
+ commentId: string
+): string | null {
+ const parentById = new Map(rows.map((row) => [row.id, row.parentId]));
+ if (!parentById.has(commentId)) return null;
+ const visited = new Set();
+ let currentId = commentId;
+ while (true) {
+ if (visited.has(currentId)) return commentId;
+ visited.add(currentId);
+ const parentId = parentById.get(currentId);
+ if (!parentId || parentId === currentId || !parentById.has(parentId)) return currentId;
+ currentId = parentId;
+ }
+}
+
+export function profileCommentFollowerRecipients(
+ authorId: string,
+ directRecipientId: string | null,
+ followerIds: string[]
+): string[] {
+ return [...new Set(followerIds)].filter(
+ (userId) => userId !== authorId && userId !== directRecipientId
+ );
+}
+
+export function profileCommentNotificationRecipient(
+ authorId: string,
+ parentAuthorId: string | null,
+ profileOwnerId: string
+): string | null {
+ if (parentAuthorId && parentAuthorId !== authorId) return parentAuthorId;
+ return profileOwnerId !== authorId ? profileOwnerId : null;
+}
diff --git a/src/lib/server/profile-settings.ts b/src/lib/server/profile-settings.ts
new file mode 100644
index 0000000..a501229
--- /dev/null
+++ b/src/lib/server/profile-settings.ts
@@ -0,0 +1,73 @@
+import { normalizeUserMarkdown } from '$lib/markdown';
+import {
+ moderateCommunityText,
+ recordCommunityModerationScan
+} from '$lib/server/community-moderation';
+import { getDatabase } from '$lib/server/database';
+import { randomUUID } from 'node:crypto';
+
+export async function saveProfileBio(user: { id: string; displayName: string }, value: FormDataEntryValue | null) {
+ const bio = normalizeUserMarkdown(value, 4_000);
+ if (bio === null) {
+ return {
+ ok: false as const,
+ error: 'Agent biographies may contain up to 4,000 characters and cannot contain control characters.'
+ };
+ }
+ const now = new Date().toISOString();
+ const database = await getDatabase();
+ const moderation = await moderateCommunityText({ contentType: 'profile_bio', text: bio });
+ const candidateRevisionId = randomUUID();
+ await (await database.prepare(`INSERT OR IGNORE INTO moderated_content_revisions
+ (id,content_type,content_id,submitted_by,body_markdown,content_hash,created_at)
+ VALUES(?,'profile_bio',?,?,?,?,?)`))
+ .run(candidateRevisionId, user.id, user.id, bio, moderation.contentHash, now);
+ const storedRevision = await (await database.prepare(`SELECT id
+ FROM moderated_content_revisions
+ WHERE content_type='profile_bio' AND content_id=? AND content_hash=?`))
+ .get(user.id, moderation.contentHash);
+ const revisionId = String(storedRevision?.id ?? candidateRevisionId);
+ if (moderation.decision === 'approved') {
+ await (await database.prepare(`INSERT INTO user_profiles
+ (user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
+ VALUES(?,?,?,1,?,?)
+ ON CONFLICT(user_id) DO UPDATE SET
+ bio_markdown=excluded.bio_markdown,
+ bio_updated_at=excluded.bio_updated_at,
+ updated_at=excluded.updated_at`))
+ .run(user.id, user.displayName, bio, now, now);
+ }
+ await recordCommunityModerationScan({
+ contentType: 'profile_bio',
+ contentId: user.id,
+ revisionId,
+ authorId: user.id,
+ result: moderation
+ });
+ return {
+ ok: true as const,
+ bio,
+ reviewQueued: moderation.decision !== 'approved'
+ };
+}
+
+export async function updateProfileVisibility(
+ user: { id: string; displayName: string },
+ value: FormDataEntryValue | null
+) {
+ const isPublicValue = String(value ?? '');
+ if (!['0', '1'].includes(isPublicValue)) {
+ return { ok: false as const, error: 'Choose whether your Agent profile is public.' };
+ }
+ const isPublic = isPublicValue === '1';
+ const now = new Date().toISOString();
+ const database = await getDatabase();
+ await (await database.prepare(`INSERT INTO user_profiles
+ (user_id,display_name,is_public,updated_at)
+ VALUES(?,?,?,?)
+ ON CONFLICT(user_id) DO UPDATE SET
+ is_public=excluded.is_public,
+ updated_at=excluded.updated_at`))
+ .run(user.id, user.displayName, isPublic ? 1 : 0, now);
+ return { ok: true as const, isPublic };
+}
diff --git a/src/lib/server/shared-build-combat.ts b/src/lib/server/shared-build-combat.ts
index b36be45..22555ac 100644
--- a/src/lib/server/shared-build-combat.ts
+++ b/src/lib/server/shared-build-combat.ts
@@ -1,4 +1,5 @@
import type { Database } from '@tursodatabase/database';
+import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
export async function loadSharedBuildCombatCatalog(database: Database) {
const [
@@ -49,7 +50,7 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
(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_base_attribute_bonuses ORDER BY sort_order')).all(),
+ (await database.prepare('SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order')).all(),
(await database.prepare('SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
@@ -121,10 +122,7 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
...brand,
effects: brandEffects.filter((effect) => effect.brand_set_id === brand.id)
})),
- weapons: weapons.map((weapon) => ({
- ...weapon,
- baseBonuses: weaponBonuses.filter((bonus) => bonus.weapon_id === weapon.id)
- })),
+ weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
talents: configuredTalents,
chipSets: chipSets.map((set) => {
const talent = chipTalents.find((item) => item.skill_chip_set_id === set.id);
diff --git a/src/lib/weapons/category-bonuses.test.ts b/src/lib/weapons/category-bonuses.test.ts
new file mode 100644
index 0000000..18f6c52
--- /dev/null
+++ b/src/lib/weapons/category-bonuses.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { attachWeaponCategoryBonuses, WEAPON_CATEGORIES } from './category-bonuses';
+
+describe('weapon category bonuses', () => {
+ it('defines every supported weapon category once', () => {
+ expect(WEAPON_CATEGORIES).toEqual(['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']);
+ expect(new Set(WEAPON_CATEGORIES).size).toBe(6);
+ });
+
+ it('gives standard and exotic weapons the same category profile', () => {
+ const weapons = [
+ { id: 'standard', category: 'MMR', is_exotic: 0 },
+ { id: 'exotic', category: 'MMR', is_exotic: 1 },
+ { id: 'other', category: 'AR', is_exotic: 0 }
+ ];
+ const bonuses = [
+ { weapon_category: 'MMR', stat_attribute_id: 'crit', value: 5, sort_order: 1 },
+ { weapon_category: 'MMR', stat_attribute_id: 'headshot', value: 70, sort_order: 3 },
+ { weapon_category: 'AR', stat_attribute_id: 'headshot', value: 40, sort_order: 3 }
+ ];
+
+ const result = attachWeaponCategoryBonuses(weapons, bonuses);
+
+ expect(result[0].baseBonuses).toEqual(result[1].baseBonuses);
+ expect(result[0].baseBonuses.map((bonus) => bonus.value)).toEqual([5, 70]);
+ expect(result[2].baseBonuses.map((bonus) => bonus.value)).toEqual([40]);
+ expect(weapons.some((weapon) => 'baseBonuses' in weapon)).toBe(false);
+ });
+});
diff --git a/src/lib/weapons/category-bonuses.ts b/src/lib/weapons/category-bonuses.ts
new file mode 100644
index 0000000..b17450e
--- /dev/null
+++ b/src/lib/weapons/category-bonuses.ts
@@ -0,0 +1,13 @@
+export const WEAPON_CATEGORIES = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'] as const;
+
+export type WeaponCategory = (typeof WEAPON_CATEGORIES)[number];
+
+export function attachWeaponCategoryBonuses<
+ TWeapon extends { category: unknown },
+ TBonus extends { weapon_category: unknown }
+>(weapons: TWeapon[], bonuses: TBonus[]) {
+ return weapons.map((weapon) => ({
+ ...weapon,
+ baseBonuses: bonuses.filter((bonus) => bonus.weapon_category === weapon.category)
+ }));
+}
diff --git a/src/lib/weapons/mod-slots.test.ts b/src/lib/weapons/mod-slots.test.ts
new file mode 100644
index 0000000..9c04e6e
--- /dev/null
+++ b/src/lib/weapons/mod-slots.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+import {
+ excludedTierZeroWeaponModSlot,
+ expandSharedWeaponModSlots,
+ normalizeWeaponModSlotPayload,
+ sharedWeaponModSlots,
+ validateWeaponModSlots,
+ weaponModSlotsComplete,
+ type WeaponModSlotConfig
+} from './mod-slots';
+
+const configuration = (tier: number, slots: string[]): WeaponModSlotConfig => ({ tier, slots });
+
+describe('weapon mod slot configuration', () => {
+ it('accepts missing and partial slot data for later configuration', () => {
+ const slots = [
+ configuration(0, []),
+ configuration(1, ['Optics', 'Muzzle']),
+ configuration(2, [])
+ ];
+
+ expect(validateWeaponModSlots(0, slots)).toBeNull();
+ expect(weaponModSlotsComplete(0, slots)).toBe(false);
+ });
+
+ it('marks exact slot counts at every available tier as complete', () => {
+ const slots = [
+ configuration(1, ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel']),
+ configuration(2, ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel'])
+ ];
+
+ expect(validateWeaponModSlots(1, slots)).toBeNull();
+ expect(weaponModSlotsComplete(1, slots)).toBe(true);
+ });
+
+ it('rejects unavailable slot types and counts above the tier limit', () => {
+ expect(validateWeaponModSlots(2, [configuration(2, ['Unknown'])])).toContain('unavailable');
+ expect(validateWeaponModSlots(0, [
+ configuration(0, ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel'])
+ ])).toContain('at most 4');
+ });
+
+ it('combines the shared T1/T2 selection with one T0 exclusion', () => {
+ const shared = ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel'];
+ const expanded = expandSharedWeaponModSlots(shared, 'Barrel');
+
+ expect(expanded).toEqual([
+ configuration(0, ['Optics', 'Muzzle', 'Underbarrel', 'Magazine']),
+ configuration(1, shared),
+ configuration(2, shared)
+ ]);
+ expect(sharedWeaponModSlots(expanded)).toEqual(shared);
+ expect(excludedTierZeroWeaponModSlot(shared, expanded)).toBe('Barrel');
+ expect(weaponModSlotsComplete(0, expanded)).toBe(true);
+ });
+
+ it('keeps T0 unconfigured when its excluded slot is unknown', () => {
+ const expanded = expandSharedWeaponModSlots(
+ ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel'],
+ ''
+ );
+
+ expect(expanded[0].slots).toEqual([]);
+ expect(weaponModSlotsComplete(0, expanded)).toBe(false);
+ });
+
+ it('normalizes duplicate tiers and ignores malformed rows', () => {
+ expect(normalizeWeaponModSlotPayload([
+ null,
+ { tier: 1, slots: ['Optics', 'Muzzle'] },
+ { tier: 1, slots: ['Muzzle', 'Barrel'] },
+ { tier: 7, slots: ['Trigger'] }
+ ])).toEqual([
+ configuration(1, ['Optics', 'Muzzle', 'Barrel'])
+ ]);
+ });
+});
diff --git a/src/lib/weapons/mod-slots.ts b/src/lib/weapons/mod-slots.ts
new file mode 100644
index 0000000..0593f0c
--- /dev/null
+++ b/src/lib/weapons/mod-slots.ts
@@ -0,0 +1,100 @@
+export const WEAPON_MOD_SLOT_TYPES = [
+ 'Optics',
+ 'Muzzle',
+ 'Underbarrel',
+ 'Magazine',
+ 'Barrel',
+ 'Upper Receiver',
+ 'Lower Receiver',
+ 'Trigger'
+] as const;
+
+export type WeaponModSlotConfig = {
+ tier: number;
+ slots: string[];
+};
+
+export function normalizeWeaponModSlotPayload(payload: unknown): WeaponModSlotConfig[] {
+ if (!Array.isArray(payload)) return [];
+ const byTier = new Map>();
+ for (const row of payload) {
+ if (!row || typeof row !== 'object') continue;
+ const tier = Number('tier' in row ? row.tier : Number.NaN);
+ if (!Number.isInteger(tier) || tier < 0 || tier > 2) continue;
+ const slots = 'slots' in row && Array.isArray(row.slots) ? row.slots.map((slot: unknown) => String(slot)) : [];
+ const collected = byTier.get(tier) ?? new Set();
+ for (const slot of slots) collected.add(slot);
+ byTier.set(tier, collected);
+ }
+ return [...byTier.entries()]
+ .sort(([left], [right]) => left - right)
+ .map(([tier, slots]) => ({ tier, slots: [...slots] }));
+}
+
+export function requiredWeaponModSlotCount(tier: number): number {
+ return tier === 0 ? 4 : 5;
+}
+
+export function sharedWeaponModSlots(configurations: WeaponModSlotConfig[]): string[] {
+ return [...new Set(
+ configurations
+ .filter((row) => Number(row.tier) === 1 || Number(row.tier) === 2)
+ .flatMap((row) => row.slots)
+ )];
+}
+
+export function excludedTierZeroWeaponModSlot(
+ sharedSlots: string[],
+ configurations: WeaponModSlotConfig[]
+): string {
+ const tierZeroSlots = configurations.find((row) => Number(row.tier) === 0)?.slots ?? [];
+ const excluded = sharedSlots.filter((slot) => !tierZeroSlots.includes(slot));
+ return excluded.length === 1 && tierZeroSlots.every((slot) => sharedSlots.includes(slot)) ? excluded[0] : '';
+}
+
+export function expandSharedWeaponModSlots(sharedSlots: string[], tierZeroExcludedSlot: string): WeaponModSlotConfig[] {
+ const uniqueSharedSlots = [...new Set(sharedSlots)];
+ return [
+ {
+ tier: 0,
+ slots: tierZeroExcludedSlot
+ ? uniqueSharedSlots.filter((slot) => slot !== tierZeroExcludedSlot)
+ : []
+ },
+ { tier: 1, slots: [...uniqueSharedSlots] },
+ { tier: 2, slots: [...uniqueSharedSlots] }
+ ];
+}
+
+export function weaponModSlotsComplete(nativeTier: number, configurations: WeaponModSlotConfig[]): boolean {
+ for (let tier = nativeTier; tier <= 2; tier += 1) {
+ const configured = configurations.find((row) => Number(row.tier) === tier);
+ if (configured?.slots.length !== requiredWeaponModSlotCount(tier)) return false;
+ }
+ if (nativeTier <= 1) {
+ const tierOne = configurations.find((row) => Number(row.tier) === 1)?.slots ?? [];
+ const tierTwo = configurations.find((row) => Number(row.tier) === 2)?.slots ?? [];
+ if (tierOne.some((slot) => !tierTwo.includes(slot)) || tierTwo.some((slot) => !tierOne.includes(slot))) return false;
+ }
+ if (nativeTier === 0) {
+ const tierZero = configurations.find((row) => Number(row.tier) === 0)?.slots ?? [];
+ const tierOne = configurations.find((row) => Number(row.tier) === 1)?.slots ?? [];
+ if (tierZero.some((slot) => !tierOne.includes(slot))) return false;
+ }
+ return true;
+}
+
+export function validateWeaponModSlots(nativeTier: number, configurations: WeaponModSlotConfig[]): string | null {
+ for (let tier = nativeTier; tier <= 2; tier += 1) {
+ const configured = configurations.find((row) => Number(row.tier) === tier);
+ const slots = configured?.slots ?? [];
+ const required = requiredWeaponModSlotCount(tier);
+ if (slots.some((slot) => !WEAPON_MOD_SLOT_TYPES.includes(slot as (typeof WEAPON_MOD_SLOT_TYPES)[number]))) {
+ return `Tier ${tier} includes an unavailable Weapon Mod slot.`;
+ }
+ if (slots.length > required) {
+ return `Tier ${tier} supports at most ${required} unique Weapon Mod slots.`;
+ }
+ }
+ return null;
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 3ed14aa..5e49236 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -5,7 +5,9 @@
import '@fontsource/barlow/800.css';
import '@fontsource/barlow/900.css';
import '@fontsource/source-serif-4/400.css';
+ import '@fontsource/source-serif-4/400-italic.css';
import '@fontsource/source-serif-4/600.css';
+ import '@fontsource/source-serif-4/700.css';
import '../app.css';
import AppShell from '$lib/components/AppShell.svelte';
import ToastViewport from '$lib/components/ToastViewport.svelte';
diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts
index eb31f48..8c244e7 100644
--- a/src/routes/account/+page.server.ts
+++ b/src/routes/account/+page.server.ts
@@ -9,13 +9,12 @@ import { createMagicLinkToken } from '$lib/server/auth/magic-link';
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
import { recordIsacAudit } from '$lib/server/audit';
-import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown';
import { randomUUID } from 'node:crypto';
import {
moderateCommunityImage,
- moderateCommunityText,
recordCommunityModerationScan
} from '$lib/server/community-moderation';
+import { saveProfileBio, updateProfileVisibility } from '$lib/server/profile-settings';
import { agentProfileHref, displayNameKey } from '$lib/agents';
import {
createPublicProfileSlug,
@@ -26,103 +25,23 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
- const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread, profile, pendingBioRevision] = await Promise.all([
- (await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
- (await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
- (await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
- (await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author, ratings.value FROM ratings JOIN builds ON builds.id = ratings.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE ratings.user_id = ? ORDER BY builds.updated_at DESC')).all(user.id),
- (await database.prepare(`SELECT users.discord_id,users.login_username,users.password_hash,discord_profiles.username AS discord_username,
- discord_profiles.display_name,discord_profiles.avatar_hash
- FROM users LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id WHERE users.id=?`)).get(user.id),
- (await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(user.id),
- (await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(user.id),
- getAuthenticationMethods(database, user.id),
- (await database.prepare(`SELECT n.id,n.category,n.source,n.title,n.body,n.link,n.mandatory,n.created_at,n.read_at
- FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
- WHERE n.user_id=? AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)
- ORDER BY n.created_at DESC LIMIT 5`)).all(user.id),
- (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
- LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
- WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id),
- (await database.prepare(`SELECT profile.bio_markdown,profile.is_public,profile.bio_updated_at,profile.public_slug,
- COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason AS moderation_reason
- FROM user_profiles profile
- LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
- WHERE profile.user_id=?`)).get(user.id),
- (await database.prepare(`SELECT revision.body_markdown,revision.created_at,scan.status,scan.reason
- FROM moderated_content_revisions revision
- JOIN content_moderation_scans scan ON scan.revision_id=revision.id
- LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
- WHERE revision.content_type='profile_bio' AND revision.content_id=?
- AND scan.status IN ('review','priority_review','error') AND review.id IS NULL
- ORDER BY revision.created_at DESC LIMIT 1`)).get(user.id)
- ]);
- const oauthErrorCode = url.searchParams.get('oauth_error');
- const oauthErrors: Record = {
- discord_in_use: {
- title: 'DISCORD ACCOUNT ALREADY LINKED',
- message: 'That Discord account belongs to another SHD.build agent. No accounts were changed. Sign out, then use Continue with Discord to access the existing account.'
- },
- link_session_changed: {
- title: 'SESSION CHANGED',
- message: 'Your SHD.build session changed while Discord was connecting. Sign in to the intended account and try linking again.'
- },
- discord_denied: {
- title: 'DISCORD AUTHORIZATION CANCELLED',
- message: 'Discord did not authorize the connection. Your SHD.build account was not changed.'
- }
- };
- const oauthError = oauthErrorCode
- ? oauthErrors[oauthErrorCode] ?? {
- title: 'DISCORD LINK FAILED',
- message: 'Discord linking could not be completed. Your existing sign-in methods and account data were not changed.'
- }
- : null;
- return {
- user: {
- ...user,
- bioMarkdown: String(pendingBioRevision?.body_markdown ?? profile?.bio_markdown ?? ''),
- profileIsPublic: profile?.is_public === undefined ? true : Boolean(profile.is_public)
- },
- account: { builds, guides, favorites, ratings, notifications, notificationUnread: Number(notificationUnread?.count ?? 0) },
- discordConnection: connection?.discord_id ? {
- id: String(connection.discord_id),
- username: connection.discord_username ? String(connection.discord_username) : null,
- displayName: connection.display_name ? String(connection.display_name) : null,
- avatarHash: connection.avatar_hash ? String(connection.avatar_hash) : null,
- canUnlink: canRemoveAuthenticationMethod(authenticationMethods, 'discord')
- } : null,
- hasPassword: typeof connection?.password_hash === 'string',
- hasLocalCredentials: typeof connection?.password_hash === 'string' && typeof connection?.login_username === 'string',
- emailIdentity: emailIdentity ? { email: String(emailIdentity.email), verifiedAt: String(emailIdentity.verified_at) } : null,
- authenticationMethods,
- emailDeliveryConfigured: emailDeliveryConfigured(),
- emailVerified: url.searchParams.has('email_verified'),
- passkeys: passkeys.map((passkey) => ({
- id: String(passkey.id), name: String(passkey.name), deviceType: String(passkey.device_type),
- backedUp: Boolean(passkey.backed_up), lastUsedAt: passkey.last_used_at ? String(passkey.last_used_at) : null,
- createdAt: String(passkey.created_at)
- })),
- chooseDisplayName: url.searchParams.has('choose_display_name'),
- discordLinked: url.searchParams.has('discord_linked'),
- oauthError,
- avatarPresets: AVATAR_PRESETS,
- profile: {
- bioMarkdown: String(profile?.bio_markdown ?? ''),
- bioHtml: renderUserMarkdown(profile?.bio_markdown),
- isPublic: Boolean(profile?.is_public),
- bioHidden: Boolean(profile?.bio_hidden),
- moderationReason: profile?.moderation_reason ? String(profile.moderation_reason) : null,
- bioReviewPending: Boolean(pendingBioRevision),
- bioReviewReason: pendingBioRevision?.reason ? String(pendingBioRevision.reason) : null,
- pendingBioSubmittedAt: pendingBioRevision?.created_at ? String(pendingBioRevision.created_at) : null,
- bioUpdatedAt: profile?.bio_updated_at ? String(profile.bio_updated_at) : null,
- href: agentProfileHref({
- id: user.id,
- publicSlug: typeof profile?.public_slug === 'string' ? profile.public_slug : null
- })
- }
- };
+ const ownerProfile = await (await database.prepare(
+ 'SELECT public_slug FROM user_profiles WHERE user_id=?'
+ )).get(user.id);
+ const profileTarget = new URL(
+ agentProfileHref({
+ id: user.id,
+ publicSlug: ownerProfile?.public_slug ? String(ownerProfile.public_slug) : null
+ }),
+ url.origin
+ );
+ const requestedPanel = url.searchParams.get('panel');
+ if (requestedPanel) profileTarget.searchParams.set('control', requestedPanel);
+ for (const key of ['oauth_error', 'discord_linked', 'email_verified']) {
+ const value = url.searchParams.get(key);
+ if (value) profileTarget.searchParams.set(key, value);
+ }
+ redirect(303, `${profileTarget.pathname}${profileTarget.search}`);
};
export const actions: Actions = {
@@ -130,73 +49,36 @@ export const actions: Actions = {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
- const bio = normalizeUserMarkdown(form.get('bio'), 4_000);
- if (bio === null) {
+ const result = await saveProfileBio(user, form.get('bio'));
+ if (!result.ok) {
return fail(400, {
- bioError: 'Agent biographies may contain up to 4,000 characters and cannot contain control characters.',
+ bioError: result.error,
bioResultToken: randomUUID()
});
}
- const now = new Date().toISOString();
- const database = await getDatabase();
- const moderation = await moderateCommunityText({ contentType: 'profile_bio', text: bio });
- const candidateRevisionId = randomUUID();
- await (await database.prepare(`INSERT OR IGNORE INTO moderated_content_revisions
- (id,content_type,content_id,submitted_by,body_markdown,content_hash,created_at)
- VALUES(?,'profile_bio',?,?,?,?,?)`))
- .run(candidateRevisionId, user.id, user.id, bio, moderation.contentHash, now);
- const storedRevision = await (await database.prepare(`SELECT id
- FROM moderated_content_revisions
- WHERE content_type='profile_bio' AND content_id=? AND content_hash=?`))
- .get(user.id, moderation.contentHash);
- const revisionId = String(storedRevision?.id ?? candidateRevisionId);
- if (moderation.decision === 'approved') {
- await (await database.prepare(`INSERT INTO user_profiles
- (user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
- VALUES(?,?,?,1,?,?)
- ON CONFLICT(user_id) DO UPDATE SET
- bio_markdown=excluded.bio_markdown,
- bio_updated_at=excluded.bio_updated_at,
- updated_at=excluded.updated_at`))
- .run(user.id, user.displayName, bio, now, now);
- }
- await recordCommunityModerationScan({
- contentType: 'profile_bio',
- contentId: user.id,
- revisionId,
- authorId: user.id,
- result: moderation
- });
return {
bioSaved: true,
- bioReviewQueued: moderation.decision !== 'approved',
- bioMarkdown: bio,
+ bioReviewQueued: result.reviewQueued,
+ bioMarkdown: result.bio,
bioResultToken: randomUUID()
};
},
setProfileVisibility: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
- const isPublicValue = String((await request.formData()).get('isPublic') ?? '');
- if (!['0', '1'].includes(isPublicValue)) {
+ const result = await updateProfileVisibility(
+ user,
+ (await request.formData()).get('isPublic')
+ );
+ if (!result.ok) {
return fail(400, {
- profileVisibilityError: 'Choose whether your Agent profile is public.',
+ profileVisibilityError: result.error,
profileVisibilityResultToken: randomUUID()
});
}
- const isPublic = isPublicValue === '1';
- const now = new Date().toISOString();
- const database = await getDatabase();
- await (await database.prepare(`INSERT INTO user_profiles
- (user_id,display_name,is_public,updated_at)
- VALUES(?,?,?,?)
- ON CONFLICT(user_id) DO UPDATE SET
- is_public=excluded.is_public,
- updated_at=excluded.updated_at`))
- .run(user.id, user.displayName, isPublic ? 1 : 0, now);
return {
profileVisibilitySaved: true,
- profileIsPublic: isPublic,
+ profileIsPublic: result.isPublic,
profileVisibilityResultToken: randomUUID()
};
},
diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte
deleted file mode 100644
index 58684ad..0000000
--- a/src/routes/account/+page.svelte
+++ /dev/null
@@ -1,304 +0,0 @@
-
-
-{user.displayName} — SHD.build
-
-
- {#if data.chooseDisplayName}IDENTITY SETUP Choose the SHD.build display name other agents will see. Unicode characters are supported.
{/if}
- {#if data.discordLinked}Discord account linked successfully.
{/if}
- {#if form?.discordUnlinked}Discord account unlinked. Your other configured sign-in methods remain active.
{/if}
- {#if data.oauthError}{data.oauthError.title} {data.oauthError.message}
{/if}
-
-
-
-
- {#if data.account.builds.length}
LOADOUT VISIBILITY UPDATED
{#each data.account.builds as build}
{build.title} /{build.slug}
{build.visibility} {new Date(String(build.updated_at)).toLocaleDateString()} EDIT → {/each}
{:else}
{/if}
-
- SAVED INTELLIGENCE
FAVORITES {data.account.favorites.length} SAVED LOADOUTS FIELD FEEDBACK
RATINGS {data.account.ratings.length} RATED LOADOUTS
-
-
- ACCOUNT CONTROL
-
- accountPanel = 'identity'}>PROFILE
- accountPanel = 'access'}>ACCESS
- accountPanel = 'account'}>ACCOUNT
-
-
- {#if accountPanel === 'identity'}
-
-
AGENT PORTRAIT
AVATAR
-
- avatarPanel = 'presets'}>SHD PRESETS
- avatarPanel = 'upload'}>CUSTOM UPLOAD
-
- {#if avatarPanel === 'presets'}
-
- {#each data.avatarPresets as preset}
-
-
- {preset.label}
-
-
- {/each}
-
- {#if form?.avatarChanged}
Avatar updated.
{/if}
- {:else}
-
- CUSTOM AVATAR
-
- JPEG, PNG, WebP, or AVIF · 5 MiB maximum · cropped to a 256 px square.
-
- {#if form?.avatarError}{form.avatarError}
{/if}
- {#if form?.avatarChanged}{form?.avatarReviewQueued ? 'Avatar received and held for Oversight review. The default portrait remains public.' : 'Avatar updated.'}
{/if}
- UPLOAD CUSTOM AVATAR →
-
- {/if}
-
-
PUBLIC IDENTITY
DISPLAY NAME
-
PUBLIC DISPLAY NAME Unique across SHD.build and used to create your readable public-profile address. Unicode, spaces, and non-ASCII writing systems are supported. {#if form?.displayNameError}{form.displayNameError}
{/if}UPDATE DISPLAY NAME →
-
-
FIELD DOSSIER
PUBLIC BIO
- {#if data.profile.bioHidden}
-
BIO HIDDEN BY OVERSIGHT {data.profile.moderationReason ?? 'Review the ISAC notification in your inbox for details.'} You may edit your bio, but it will remain hidden until Oversight restores it.
- {/if}
- {#if data.profile.bioReviewPending}
-
BIO REVISION AWAITING REVIEW {data.profile.bioReviewReason ?? 'Automated safety checks requested human review.'} Your last approved biography remains public while Oversight reviews this revision.
- {/if}
-
-
-
- {#if form?.bioError}{form.bioError}
{/if}
- {#if form?.bioSaved}{form?.bioReviewQueued ? 'Bio revision queued for Oversight review.' : 'Public bio updated.'}
{/if}
- SAVE PUBLIC BIO →
-
-
-
-
PROFILE VISIBILITY
-
{user.profileIsPublic !== false ? 'PUBLIC' : 'HIDDEN'}
-
- {user.profileIsPublic !== false
- ? 'Other agents can discover this profile and leave profile messages.'
- : 'This profile is excluded from discovery and cannot receive new profile messages.'}
-
-
Hiding your profile does not remove your name or avatar from public builds, guides, or comments you authored.
-
-
-
-
- {user.profileIsPublic === false ? 'MAKE PROFILE PUBLIC →' : 'HIDE PUBLIC PROFILE'}
-
-
-
- {#if form?.profileVisibilityError}
{form.profileVisibilityError}
{/if}
- {#if form?.profileVisibilitySaved}
Profile visibility updated.
{/if}
-
- {:else if accountPanel === 'access'}
-
-
- accessPanel = 'passkeys'}>PASSKEYS {data.passkeys.length}
- accessPanel = 'email'}>EMAIL {data.emailIdentity ? 1 : 0}
- accessPanel = 'local'}>PASSWORD {data.hasLocalCredentials ? 1 : 0}
- accessPanel = 'connections'}>LINKS {data.discordConnection ? 1 : 0}
-
- {#if accessPanel === 'passkeys'}
-
PREFERRED ACCESS
PASSKEYS
-
Use Face ID, Touch ID, Windows Hello, a security key, or your password manager without entering a SHD.build password.
- {#if data.passkeys.length}
-
- {#each data.passkeys as passkey}
-
{passkey.name} {passkey.backedUp ? 'SYNCED PASSKEY' : 'DEVICE PASSKEY'} · ADDED {new Date(passkey.createdAt).toLocaleDateString()}
REMOVE
- {/each}
-
- {:else}
NO PASSKEYS REGISTERED
{/if}
- {#if form?.passkeyError}
{form.passkeyError}
{/if}
- {#if form?.passkeyRemoved}
Passkey removed.
{/if}
- {#if passkeyClientError}
{passkeyClientError}
{/if}
- {#if passkeyMessage}
{passkeyMessage}
{/if}
-
PASSKEY NAME
-
{passkeyPending ? 'REGISTERING…' : 'ADD A PASSKEY →'}
- {#if !passkeySupported}
This browser does not support passkeys.
{/if}
- {:else if accessPanel === 'email'}
-
PASSWORDLESS RECOVERY
MAGIC LINK EMAIL
- {#if data.emailIdentity}
{data.emailIdentity.email} VERIFIED EMAIL IDENTITY
VERIFIED REMOVE MAGIC-LINK EMAIL {/if}
-
{data.emailIdentity ? 'CHANGE VERIFIED EMAIL' : 'EMAIL ADDRESS'} We will email a verification link before this address can be used to sign in. {#if form?.emailError}{form.emailError}
{/if}{#if form?.emailVerificationSent}Verification link sent. It expires in 15 minutes.
{/if}{#if data.emailVerified}Email verified. Magic-link sign-in is now active.
{/if}SEND VERIFICATION LINK →
- {:else if accessPanel === 'local'}
-
OPTIONAL FALLBACK
USERNAME & PASSWORD
- {#if data.hasLocalCredentials}
-
PRIVATE LOGIN USERNAME Used only to sign in and visible to you and authorized ISAC operators. {#if form?.loginUsernameError}{form.loginUsernameError}
{/if}UPDATE LOGIN USERNAME →
-
CURRENT PASSWORD NEW PASSWORD CONFIRM PASSWORD {#if form?.passwordError}{form.passwordError}
{/if}UPDATE PASSWORD →
-
REMOVE USERNAME & PASSWORD This removes both local credentials. Your public display name is unaffected.
CURRENT PASSWORD {#if form?.localCredentialError}{form.localCredentialError}
{/if}REMOVE PASSWORD SIGN-IN
- {:else}
-
USERNAME AND PASSWORD SIGN-IN IS NOT CONFIGURED
-
PRIVATE LOGIN USERNAME PASSWORD CONFIRM PASSWORD {#if form?.localCredentialError}{form.localCredentialError}
{/if}{#if form?.localCredentialsAdded}Username and password sign-in added.
{/if}ADD PASSWORD FALLBACK →
- {/if}
- {:else}
-
CONNECTIONS
SIGN-IN PROVIDERS
-
- {#if data.discordConnection}
-
-
{data.discordConnection.displayName ?? data.discordConnection.username ?? 'DISCORD USER'} {data.discordConnection.username ? `@${data.discordConnection.username} · ` : ''}ID {data.discordConnection.id}
-
discordUnlinkConfirm = true}>LINKED UNLINK
- {#if discordUnlinkConfirm}
-
UNLINK DISCORD? Disconnect this identity from your SHD.build account?
discordUnlinkConfirm = false}>CANCEL CONFIRM UNLINK
- {/if}
- {#if !data.discordConnection.canUnlink}
Discord is your only sign-in method. Add a passkey, verified email, or password before unlinking.
{/if}
- {#if form?.discordUnlinkError}
{form.discordUnlinkError}
{/if}
-
- {:else}
-
- {/if}
-
G GOOGLE ACCOUNT LINKING PLACEHOLDER
PLANNED
-
A APPLE ACCOUNT LINKING PLACEHOLDER
PLANNED
-
S STEAM ACCOUNT LINKING PLACEHOLDER
PLANNED
-
- {/if}
-
- {:else}
-
- {/if}
-
-
-
-
-
diff --git a/src/routes/agents/[id]/+page.server.ts b/src/routes/agents/[id]/+page.server.ts
index 42a796d..1f69b50 100644
--- a/src/routes/agents/[id]/+page.server.ts
+++ b/src/routes/agents/[id]/+page.server.ts
@@ -14,6 +14,16 @@ import { randomUUID } from 'node:crypto';
import { queueCurrentAvatarReview } from '$lib/server/avatars';
import { agentProfileHref } from '$lib/agents';
import { resolveAgentProfile } from '$lib/server/agent-profiles';
+import {
+ orderProfileCommentThread,
+ profileCommentNotificationRecipient
+} from '$lib/server/profile-comment-thread';
+import {
+ notifyProfileCommentFollowers,
+ profileCommentConversation
+} from '$lib/server/profile-comment-follows';
+import { loadAccountControlData } from '$lib/server/account-control-data';
+import { saveProfileBio, updateProfileVisibility } from '$lib/server/profile-settings';
import type { Actions, PageServerLoad } from './$types';
const COMMENT_MAX_CHARACTERS = 2_000;
@@ -24,17 +34,23 @@ const unavailable = (row: Record | null | undefined) => {
return !row || Boolean(row.deleted_at || row.blocked_at || banned);
};
-const commentHref = (targetType: string, slug: unknown, targetId: string, targetPublicSlug: unknown) => {
+const commentHref = (
+ targetType: string,
+ slug: unknown,
+ targetId: string,
+ targetPublicSlug: unknown,
+ commentId: string
+) => {
if (targetType === 'profile') {
return agentProfileHref({
id: targetId,
publicSlug: typeof targetPublicSlug === 'string' ? targetPublicSlug : null
- }, 'profile-comments');
+ }, `profile-comment-${commentId}`);
}
- return `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
+ return `/builds/${encodeURIComponent(String(slug ?? ''))}#comment-${encodeURIComponent(commentId)}`;
};
-export const load: PageServerLoad = async ({ params, cookies }) => {
+export const load: PageServerLoad = async ({ params, cookies, url }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
const resolved = await resolveAgentProfile(database, params.id);
@@ -43,7 +59,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
const agent = await (await database.prepare(`SELECT users.id,
COALESCE(profile.display_name,users.username) AS display_name,
profile.public_slug,profile.bio_markdown,profile.is_public,profile.bio_updated_at,
- COALESCE(moderation.bio_hidden,0) AS bio_hidden,
+ COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason AS moderation_reason,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
status.blocked_at,status.banned_until,deleted.deleted_at
FROM users
@@ -61,6 +77,18 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
if (params.id !== String(agent.public_slug)) {
redirect(308, agentProfileHref({ id: agentId, publicSlug: String(agent.public_slug) }));
}
+ const [pendingBioRevision, accountControl] = isSelf
+ ? await Promise.all([
+ (await database.prepare(`SELECT revision.body_markdown,revision.created_at,scan.reason
+ FROM moderated_content_revisions revision
+ JOIN content_moderation_scans scan ON scan.revision_id=revision.id
+ LEFT JOIN content_moderation_reviews review ON review.scan_id=scan.id
+ WHERE revision.content_type='profile_bio' AND revision.content_id=?
+ AND scan.status IN ('review','priority_review','error') AND review.id IS NULL
+ ORDER BY revision.created_at DESC LIMIT 1`)).get(agentId),
+ loadAccountControlData(database, agentId, url.searchParams.get('control'))
+ ])
+ : [null, null];
const [roleRows, buildRows, guideRows, authoredCommentRows, profileCommentRows, authoredCount, profileCommentCount, contentCounts] =
await Promise.all([
@@ -76,7 +104,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
FROM builds WHERE owner_id=? AND visibility='public'
AND guide_body IS NOT NULL AND length(trim(guide_body))>0
ORDER BY updated_at DESC LIMIT 60`)).all(agentId),
- (await database.prepare(`SELECT comments.id,comments.body_markdown,comments.created_at,
+ (await database.prepare(`SELECT comments.id,comments.created_at,
thread.target_type,thread.target_id,builds.title AS build_title,builds.slug,
COALESCE(target_profile.display_name,target_user.username) AS target_display_name,
target_profile.public_slug AS target_public_slug
@@ -94,8 +122,9 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
)
ORDER BY comments.created_at DESC LIMIT 50`)).all(agentId, new Date().toISOString()),
- (await database.prepare(`SELECT comments.id,comments.author_id,comments.body_markdown,
- comments.created_at,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
+ (await database.prepare(`SELECT comments.id,comments.author_id,comments.parent_id,comments.body_markdown,
+ comments.status,comments.created_at,
+ COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
profile.public_slug,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at
FROM comment_threads thread
@@ -128,6 +157,86 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
FROM builds WHERE owner_id=? AND visibility='public'`)).get(agentId)
]);
+ const loadedCommentIds = new Set(profileCommentRows.map((comment) => String(comment.id)));
+ let missingParentIds = [...new Set(profileCommentRows
+ .map((comment) => comment.parent_id ? String(comment.parent_id) : '')
+ .filter((parentId) => parentId && !loadedCommentIds.has(parentId)))];
+ for (let level = 0; missingParentIds.length && level < 100; level += 1) {
+ const placeholders = missingParentIds.map(() => '?').join(',');
+ const ancestors = await (await database.prepare(`SELECT comments.id,comments.author_id,
+ comments.parent_id,comments.body_markdown,comments.status,comments.created_at,
+ COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
+ profile.public_slug,
+ avatar.source AS avatar_source,avatar.value AS avatar_value,
+ avatar.updated_at AS avatar_updated_at
+ FROM comments
+ JOIN comment_threads thread ON thread.id=comments.thread_id
+ LEFT JOIN users ON users.id=comments.author_id
+ LEFT JOIN user_profiles profile ON profile.user_id=users.id
+ LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
+ WHERE comments.id IN (${placeholders})
+ AND comments.status IN ('visible','removed')
+ AND thread.target_type='profile' AND thread.target_id=?`))
+ .all(...missingParentIds, agentId);
+ if (!ancestors.length) break;
+ profileCommentRows.push(...ancestors);
+ for (const ancestor of ancestors) loadedCommentIds.add(String(ancestor.id));
+ missingParentIds = [...new Set(ancestors
+ .map((ancestor) => ancestor.parent_id ? String(ancestor.parent_id) : '')
+ .filter((parentId) => parentId && !loadedCommentIds.has(parentId)))];
+ }
+
+ const commentAuthorIds = [...new Set(profileCommentRows
+ .map((comment) => comment.author_id ? String(comment.author_id) : '')
+ .filter(Boolean))];
+ const commentRoleRows = commentAuthorIds.length
+ ? await (await database.prepare(`SELECT user_roles.user_id,roles.name
+ FROM user_roles
+ JOIN roles ON roles.id=user_roles.role_id
+ WHERE user_roles.user_id IN (${commentAuthorIds.map(() => '?').join(',')})
+ ORDER BY roles.name COLLATE NOCASE,roles.id`)).all(...commentAuthorIds)
+ : [];
+ const rolesByCommentAuthor = new Map();
+ for (const row of commentRoleRows) {
+ const userId = String(row.user_id);
+ const roles = rolesByCommentAuthor.get(userId) ?? [];
+ roles.push(String(row.name));
+ rolesByCommentAuthor.set(userId, roles);
+ }
+ const threadedProfileComments = orderProfileCommentThread(profileCommentRows.map((comment) => {
+ const status = String(comment.status) === 'removed' ? 'removed' as const : 'visible' as const;
+ return {
+ id: String(comment.id),
+ parentId: comment.parent_id ? String(comment.parent_id) : null,
+ status,
+ bodyHtml: status === 'visible' ? renderUserMarkdown(comment.body_markdown) : '',
+ createdAt: String(comment.created_at),
+ author: {
+ id: String(comment.author_id),
+ displayName: String(comment.display_name),
+ publicSlug: comment.public_slug ? String(comment.public_slug) : null,
+ avatarUrl: avatarUrl(
+ comment.avatar_source,
+ comment.avatar_value,
+ String(comment.author_id),
+ comment.avatar_updated_at
+ ),
+ roles: rolesByCommentAuthor.get(String(comment.author_id)) ?? []
+ },
+ canDelete: status === 'visible' && viewer?.id === String(comment.author_id),
+ canReport: status === 'visible' && Boolean(viewer) && viewer?.id !== String(comment.author_id)
+ };
+ }));
+ const followedRootIds = viewer && threadedProfileComments.length
+ ? new Set((await (await database.prepare(`SELECT root_comment_id
+ FROM comment_thread_follows
+ WHERE user_id=? AND root_comment_id IN (${[...new Set(threadedProfileComments.map((comment) => comment.rootId))]
+ .map(() => '?').join(',')})`)).all(
+ viewer.id,
+ ...[...new Set(threadedProfileComments.map((comment) => comment.rootId))]
+ )).map((row) => String(row.root_comment_id)))
+ : new Set();
+
return {
agent: {
id: String(agent.id),
@@ -140,6 +249,16 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
agent.avatar_updated_at
),
bioHtml: agent.bio_hidden ? '' : renderUserMarkdown(agent.bio_markdown),
+ bioMarkdown: isSelf
+ ? String(pendingBioRevision?.body_markdown ?? agent.bio_markdown ?? '')
+ : '',
+ isPublic: Boolean(agent.is_public),
+ bioHidden: Boolean(agent.bio_hidden),
+ bioModerationReason: isSelf && agent.moderation_reason
+ ? String(agent.moderation_reason)
+ : null,
+ bioReviewPending: Boolean(pendingBioRevision),
+ bioReviewReason: pendingBioRevision?.reason ? String(pendingBioRevision.reason) : null,
roles: roleRows.map((role) => String(role.name))
},
counts: {
@@ -167,58 +286,109 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
const targetId = String(comment.target_id);
return {
id: String(comment.id),
- bodyHtml: renderUserMarkdown(comment.body_markdown),
createdAt: String(comment.created_at),
targetType,
targetTitle: targetType === 'profile'
? `${String(comment.target_display_name ?? 'Agent')} profile`
: String(comment.build_title ?? 'Community content'),
- targetHref: commentHref(targetType, comment.slug, targetId, comment.target_public_slug)
+ targetHref: commentHref(
+ targetType,
+ comment.slug,
+ targetId,
+ comment.target_public_slug,
+ String(comment.id)
+ )
};
}),
- profileComments: profileCommentRows.map((comment) => ({
- id: String(comment.id),
- bodyHtml: renderUserMarkdown(comment.body_markdown),
- createdAt: String(comment.created_at),
- author: {
- id: String(comment.author_id),
- displayName: String(comment.display_name),
- publicSlug: comment.public_slug ? String(comment.public_slug) : null,
- avatarUrl: avatarUrl(
- comment.avatar_source,
- comment.avatar_value,
- String(comment.author_id),
- comment.avatar_updated_at
- )
- },
- canDelete: viewer?.id === String(comment.author_id),
- canReport: Boolean(viewer) && viewer?.id !== String(comment.author_id)
+ profileComments: threadedProfileComments.map((comment) => ({
+ ...comment,
+ isFollowing: followedRootIds.has(comment.rootId)
})),
viewer: {
id: viewer?.id ?? null,
canComment: Boolean(viewer) && Boolean(agent.is_public),
canReportProfile: Boolean(viewer) && !isSelf,
isSelf
- }
+ },
+ accountControl
};
};
export const actions: Actions = {
+ saveBio: async ({ params, cookies, request }) => {
+ const viewer = await getSessionUser(cookies);
+ if (!viewer) return fail(401, { bioError: 'Sign in to edit your profile.', bioResultToken: randomUUID() });
+ const database = await getDatabase();
+ const owner = await resolveAgentProfile(database, params.id);
+ if (!owner || owner.id !== viewer.id) {
+ return fail(403, { bioError: 'You can edit only your own profile.', bioResultToken: randomUUID() });
+ }
+ const result = await saveProfileBio(viewer, (await request.formData()).get('bio'));
+ if (!result.ok) {
+ return fail(400, { bioError: result.error, bioResultToken: randomUUID() });
+ }
+ return {
+ bioSaved: true,
+ bioReviewQueued: result.reviewQueued,
+ bioMarkdown: result.bio,
+ bioResultToken: randomUUID()
+ };
+ },
+
+ setProfileVisibility: async ({ params, cookies, request }) => {
+ const viewer = await getSessionUser(cookies);
+ if (!viewer) return fail(401, {
+ profileVisibilityError: 'Sign in to change profile visibility.',
+ profileVisibilityResultToken: randomUUID()
+ });
+ const database = await getDatabase();
+ const owner = await resolveAgentProfile(database, params.id);
+ if (!owner || owner.id !== viewer.id) {
+ return fail(403, {
+ profileVisibilityError: 'You can change only your own profile.',
+ profileVisibilityResultToken: randomUUID()
+ });
+ }
+ const result = await updateProfileVisibility(
+ viewer,
+ (await request.formData()).get('isPublic')
+ );
+ if (!result.ok) {
+ return fail(400, {
+ profileVisibilityError: result.error,
+ profileVisibilityResultToken: randomUUID()
+ });
+ }
+ return {
+ profileVisibilitySaved: true,
+ profileIsPublic: result.isPublic,
+ profileVisibilityResultToken: randomUUID()
+ };
+ },
+
commentProfile: async ({ params, cookies, request }) => {
const commenter = await getSessionUser(cookies);
- if (!commenter) return fail(401, { profileCommentError: 'Sign in to post a profile comment.' });
+ const formData = await request.formData();
+ const parentId = String(formData.get('parentId') ?? '').trim() || null;
+ const commentFailure = (status: number, profileCommentError: string) => fail(status, {
+ profileCommentError,
+ profileCommentParentId: parentId,
+ profileCommentResultToken: randomUUID()
+ });
+ if (!commenter) return commentFailure(401, 'Sign in to post a profile comment.');
const body = normalizeUserMarkdown(
- (await request.formData()).get('body'),
+ formData.get('body'),
COMMENT_MAX_CHARACTERS
);
if (body === null || Array.from(body).length < 2) {
- return fail(400, {
- profileCommentError: `Comments must contain 2–${COMMENT_MAX_CHARACTERS.toLocaleString()} visible characters.`
- });
+ return commentFailure(
+ 400,
+ `Comments must contain 2–${COMMENT_MAX_CHARACTERS.toLocaleString()} visible characters.`
+ );
}
const database = await getDatabase();
const owner = await resolveAgentProfile(database, params.id);
- if (!owner) return fail(404, { profileCommentError: 'Agent profile not found.' });
+ if (!owner) return commentFailure(404, 'Agent profile not found.');
const agentId = owner.id;
const moderationPolicy = await getCommunityModerationPolicy();
const now = new Date();
@@ -233,10 +403,10 @@ export const actions: Actions = {
const commenterState = rows.find((row) => row.id === commenter.id);
const ownerState = rows.find((row) => row.id === agentId);
if (unavailable(commenterState)) {
- return fail(403, { profileCommentError: 'This account cannot post comments.' });
+ return commentFailure(403, 'This account cannot post comments.');
}
if (unavailable(ownerState) || !Boolean(ownerState?.is_public)) {
- return fail(404, { profileCommentError: 'Agent profile not found.' });
+ return commentFailure(404, 'Agent profile not found.');
}
const recent = await (await database.prepare(`SELECT comments.body_markdown,comments.created_at
@@ -247,10 +417,10 @@ export const actions: Actions = {
if (typeof recent?.created_at === 'string') {
const elapsed = (now.getTime() - new Date(recent.created_at).getTime()) / 1_000;
if (elapsed < RAPID_COMMENT_SECONDS) {
- return fail(429, { profileCommentError: 'Wait a few seconds before posting again.' });
+ return commentFailure(429, 'Wait a few seconds before posting again.');
}
if (elapsed < moderationPolicy.duplicateWindowSeconds && recent.body_markdown === body) {
- return fail(409, { profileCommentError: 'That comment was already posted.' });
+ return commentFailure(409, 'That comment was already posted.');
}
}
@@ -268,41 +438,133 @@ export const actions: Actions = {
const thread = await (await database.prepare(
"SELECT id FROM comment_threads WHERE target_type='profile' AND target_id=?"
)).get(agentId);
- if (!thread?.id) return fail(500, { profileCommentError: 'The profile thread could not be opened.' });
+ if (!thread?.id) return commentFailure(500, 'The profile thread could not be opened.');
+ const parent = parentId
+ ? await (await database.prepare(`SELECT parent.author_id
+ FROM comments parent
+ JOIN comment_threads thread ON thread.id=parent.thread_id
+ WHERE parent.id=? AND parent.status='visible' AND thread.id=?
+ AND thread.target_type='profile' AND thread.target_id=?`))
+ .get(parentId, thread.id, agentId)
+ : null;
+ if (parentId && !parent) {
+ return commentFailure(404, 'That message is no longer available for replies.');
+ }
const commentId = randomUUID();
- await (await database.prepare(`INSERT INTO comments
- (id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at)
- VALUES(?,?,?,NULL,?,?,?,?)`))
- .run(commentId, thread.id, commenter.id, body, commentStatus, createdAt, createdAt);
+ const inserted = parentId
+ ? await (await database.prepare(`INSERT INTO comments
+ (id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at)
+ SELECT ?,parent.thread_id,?,parent.id,?,?,?,?
+ FROM comments parent
+ JOIN comment_threads thread ON thread.id=parent.thread_id
+ WHERE parent.id=? AND parent.status='visible' AND thread.id=?
+ AND thread.target_type='profile' AND thread.target_id=?`))
+ .run(
+ commentId,
+ commenter.id,
+ body,
+ commentStatus,
+ createdAt,
+ createdAt,
+ parentId,
+ thread.id,
+ agentId
+ )
+ : await (await database.prepare(`INSERT INTO comments
+ (id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at)
+ VALUES(?,?,?,NULL,?,?,?,?)`))
+ .run(commentId, thread.id, commenter.id, body, commentStatus, createdAt, createdAt);
+ if (!inserted.changes) {
+ return commentFailure(409, 'That conversation changed before the reply could be posted.');
+ }
await recordCommunityModerationScan({
contentType: 'comment',
contentId: commentId,
authorId: commenter.id,
result: moderation
});
- if (commentStatus === 'visible' && commenter.id !== agentId) {
- const profileLink = agentProfileHref(owner, 'profile-comments');
+ const parentAuthorId = parent?.author_id ? String(parent.author_id) : null;
+ const recipientId = profileCommentNotificationRecipient(
+ commenter.id,
+ parentAuthorId,
+ agentId
+ );
+ if (commentStatus === 'visible' && recipientId) {
+ const profileLink = agentProfileHref(owner, `profile-comment-${commentId}`);
+ const isDirectReplyRecipient = Boolean(parentAuthorId && recipientId === parentAuthorId);
await createNotification({
- userId: agentId,
+ userId: recipientId,
category: 'comments',
source: 'agent',
- title: 'New profile comment',
- body: `${commenter.displayName} posted a comment on your Agent profile.`,
+ title: parentId ? 'New profile reply' : 'New profile comment',
+ body: parentId
+ ? isDirectReplyRecipient
+ ? `${commenter.displayName} replied to your message on ${owner.displayName}'s Agent profile.`
+ : `${commenter.displayName} replied in a conversation on your Agent profile.`
+ : `${commenter.displayName} posted a comment on your Agent profile.`,
link: profileLink,
actions: [{
- label: 'VIEW COMMENT',
+ label: parentId ? 'VIEW REPLY' : 'VIEW COMMENT',
href: profileLink
}]
});
}
+ if (commentStatus === 'visible' && parentId) {
+ await notifyProfileCommentFollowers({
+ commentId,
+ authorId: commenter.id,
+ authorDisplayName: commenter.displayName,
+ profileOwnerId: agentId,
+ profileOwnerDisplayName: owner.displayName,
+ profileOwnerPublicSlug: owner.publicSlug,
+ directRecipientId: recipientId
+ });
+ }
return {
profileCommentSaved: true,
profileCommentReviewQueued: commentStatus === 'pending_review',
profileCommentId: commentId,
+ profileCommentParentId: parentId,
profileCommentResultToken: randomUUID()
};
},
+ toggleProfileCommentFollow: async ({ params, cookies, request }) => {
+ const viewer = await getSessionUser(cookies);
+ const formData = await request.formData();
+ const commentId = String(formData.get('commentId') ?? '').trim();
+ const intent = String(formData.get('intent') ?? '').trim();
+ const failure = (status: number, message: string) => fail(status, {
+ profileCommentFollowError: message,
+ profileCommentFollowId: commentId,
+ profileCommentFollowResultToken: randomUUID()
+ });
+ if (!viewer) return failure(401, 'Sign in to follow a conversation.');
+ if (!commentId || !['follow', 'unfollow'].includes(intent)) {
+ return failure(400, 'Choose a conversation and follow preference.');
+ }
+ const database = await getDatabase();
+ const owner = await resolveAgentProfile(database, params.id);
+ if (!owner) return failure(404, 'Agent profile not found.');
+ const conversation = await profileCommentConversation(commentId, owner.id);
+ if (!conversation) return failure(404, 'That conversation is no longer available.');
+ if (intent === 'follow') {
+ await (await database.prepare(`INSERT OR IGNORE INTO comment_thread_follows
+ (user_id,root_comment_id,created_at) VALUES(?,?,?)`))
+ .run(viewer.id, conversation.rootId, new Date().toISOString());
+ } else {
+ await (await database.prepare(
+ 'DELETE FROM comment_thread_follows WHERE user_id=? AND root_comment_id=?'
+ )).run(viewer.id, conversation.rootId);
+ }
+ return {
+ profileCommentFollowSaved: true,
+ profileCommentFollowing: intent === 'follow',
+ profileCommentFollowId: commentId,
+ profileCommentFollowResultToken: randomUUID()
+ };
+ },
+
deleteProfileComment: async ({ params, cookies, request }) => {
const viewer = await getSessionUser(cookies);
if (!viewer) return fail(401, { profileCommentError: 'Sign in to remove a comment.' });
@@ -320,9 +582,12 @@ export const actions: Actions = {
return fail(403, { profileCommentError: 'You can remove only your own comment.' });
}
const removedAt = new Date().toISOString();
- await (await database.prepare(`UPDATE comments SET status='removed',removed_at=?,removed_by=?,
+ const removed = await (await database.prepare(`UPDATE comments SET status='removed',removed_at=?,removed_by=?,
removal_reason='Removed by author',updated_at=? WHERE id=? AND status='visible'`))
.run(removedAt, viewer.id, removedAt, commentId);
+ if (!removed.changes) {
+ return fail(409, { profileCommentError: 'That comment changed before it could be removed.' });
+ }
return {
profileCommentDeleted: true,
profileCommentId: commentId,
diff --git a/src/routes/agents/[id]/+page.svelte b/src/routes/agents/[id]/+page.svelte
index e367fe1..a6450b5 100644
--- a/src/routes/agents/[id]/+page.svelte
+++ b/src/routes/agents/[id]/+page.svelte
@@ -1,12 +1,16 @@
+{#snippet commentComposer(parentId: string | null, label: string)}
+
+{/snippet}
+
{data.agent.displayName} — Agent Profile — SHD.build
@@ -80,12 +164,19 @@
@@ -97,93 +188,81 @@
+
- FIELD DOSSIER
ABOUT {data.agent.displayName.toUpperCase()}
-
-
-
+
+ FIELD DOSSIER
ABOUT {data.agent.displayName.toUpperCase()}
+
+
+ {#if data.viewer.isSelf && data.agent.bioHidden}
+ BIO HIDDEN BY OVERSIGHT {data.agent.bioModerationReason ?? 'Review your notification inbox for details.'}
+ {/if}
+ {#if data.viewer.isSelf && data.agent.bioReviewPending}
+ BIO REVISION AWAITING REVIEW {data.agent.bioReviewReason ?? 'Your last approved biography remains public during review.'}
+ {/if}
+ {#if form?.profileVisibilityError}{form.profileVisibilityError}
{/if}
+ {#if form?.profileVisibilitySaved}Profile visibility updated.
{/if}
+ {#if data.viewer.isSelf && bioEditorOpen}
+
+
+
+ {#if form?.bioError}{form.bioError}
{/if}
+ bioEditorOpen = false}>CANCEL SAVE PUBLIC BIO →
+
+ {:else}
+
+
+
+ {/if}
-
- LOADOUT ARCHIVE
PUBLIC BUILDS {data.counts.builds} TOTAL
-
-
-
-
- FIELD GUIDANCE
PUBLISHED GUIDES {data.counts.guides} TOTAL
-
-
-
-
- COMMUNITY RECORD
RECENT DISCUSSION {data.counts.authoredComments} COMMENTS
-
-
+
+
+ LOADOUT ARCHIVE
PUBLIC BUILDS {data.counts.builds} TOTAL
+
+
+
+ FIELD GUIDANCE
PUBLISHED GUIDES {data.counts.guides} TOTAL
+
+
+
-
- AGENT COMMS
PROFILE MESSAGES {data.counts.profileComments}
+
+
+
+
+ {#if data.viewer.isSelf && data.accountControl}
+
+
+ {/if}
+
+ COMMUNITY RECORD
RECENT DISCUSSION {data.counts.authoredComments} COMMENTS
+
+
diff --git a/src/routes/builder/+page.server.ts b/src/routes/builder/+page.server.ts
index 4dce68a..583df94 100644
--- a/src/routes/builder/+page.server.ts
+++ b/src/routes/builder/+page.server.ts
@@ -5,6 +5,7 @@ import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { parseLoadoutDraft } from '$lib/builder/loadout';
import type { Actions, PageServerLoad } from './$types';
+import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
const validVisibility = new Set(['private', 'unlisted', 'public']);
const slugify = (value: string) => value.toLowerCase().normalize('NFKD')
@@ -53,7 +54,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(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 weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order')).all(),
(await database.prepare('SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
@@ -144,13 +145,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
iconUrl: iconUrl('brand_set', brand.id),
effects: brandEffects.filter((effect) => effect.brand_set_id === brand.id)
})),
- weapons: weapons.map((weapon) => ({
+ weapons: attachWeaponCategoryBonuses(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)
- })),
+ talent2Ids: weaponTalentOptions.filter((link) => link.weapon_id === weapon.id && Number(link.talent_slot) === 2).map((link) => String(link.talent_id))
+ })), weaponBonuses),
talents: configuredTalents,
chipSets: chipSets.map((set) => {
const talent = chipTalents.find((item) => item.skill_chip_set_id === set.id);
diff --git a/src/routes/database/[category]/+page.server.ts b/src/routes/database/[category]/+page.server.ts
index 894b1ae..61a1387 100644
--- a/src/routes/database/[category]/+page.server.ts
+++ b/src/routes/database/[category]/+page.server.ts
@@ -212,7 +212,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
native.damage_min,native.damage_max,native.reference_level,
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=1) AS talent_1_count,
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count,
- (SELECT COUNT(*) FROM weapon_base_attribute_bonuses bonuses WHERE bonuses.weapon_id=item.id) AS bonus_count
+ (SELECT COUNT(*) FROM weapon_category_attribute_bonuses bonuses WHERE bonuses.weapon_category=item.category) AS bonus_count
FROM weapons item
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier
WHERE ${where}
diff --git a/src/routes/isac/community/+page.server.ts b/src/routes/isac/community/+page.server.ts
index 14c5805..98bccc7 100644
--- a/src/routes/isac/community/+page.server.ts
+++ b/src/routes/isac/community/+page.server.ts
@@ -10,6 +10,8 @@ import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { agentProfileHref } from '$lib/agents';
+import { profileCommentNotificationRecipient } from '$lib/server/profile-comment-thread';
+import { notifyProfileCommentFollowers } from '$lib/server/profile-comment-follows';
const COMMENT_PERMISSION = 'community.comments.moderate';
const PROFILE_PERMISSION = 'community.profiles.moderate';
@@ -95,6 +97,12 @@ const queueTargetHref = (
? `/builds/${encodeURIComponent(String(slug))}`
: `/builds/${encodeURIComponent(String(contentId))}`;
}
+ if (String(targetType) === 'profile') {
+ return agentProfileHref({
+ id: String(targetId),
+ publicSlug: typeof publicSlug === 'string' ? publicSlug : null
+ }, `profile-comment-${String(contentId)}`);
+ }
return targetHref(String(targetType), targetId, slug, publicSlug);
};
@@ -123,7 +131,10 @@ const assertQueuePermission = async (
const scanQueueRecord = async (database: Awaited>, id: string) =>
(await database.prepare(`SELECT scan.*,revision.body_markdown AS revision_body,
comments.body_markdown AS comment_body,comments.status AS comment_status,
- comments.thread_id,thread.target_type,thread.target_id,target_build.slug,target_build.title AS target_title,
+ comments.thread_id,comments.parent_id,parent_comment.author_id AS parent_author_id,
+ thread.target_type,thread.target_id,target_build.slug,
+ COALESCE(target_build.title,target_profile.display_name,target_user.username) AS target_title,
+ target_profile.public_slug AS target_public_slug,
target_build.guide_body AS current_guide_body,target_build.visibility AS current_guide_visibility,
target_build.owner_id AS guide_owner_id,
profile.bio_markdown AS current_bio,profile.bio_updated_at AS current_bio_updated_at,
@@ -133,10 +144,15 @@ const scanQueueRecord = async (database: Awaited>
LEFT JOIN moderated_content_revisions revision ON revision.id=scan.revision_id
LEFT JOIN comments ON scan.content_type='comment' AND comments.id=scan.content_id
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
+ LEFT JOIN comments parent_comment ON parent_comment.id=comments.parent_id
+ AND parent_comment.thread_id=comments.thread_id
LEFT JOIN builds target_build ON
(scan.content_type='guide' AND target_build.id=scan.content_id)
OR (scan.content_type='comment' AND thread.target_type IN ('build','guide')
AND target_build.id=thread.target_id)
+ LEFT JOIN user_profiles target_profile ON scan.content_type='comment'
+ AND thread.target_type='profile' AND target_profile.user_id=thread.target_id
+ LEFT JOIN users target_user ON target_user.id=target_profile.user_id
LEFT JOIN user_profiles profile ON profile.user_id=scan.author_id
LEFT JOIN users ON users.id=scan.author_id
WHERE scan.id=? AND scan.content_type IN ('comment','profile_bio','guide')
@@ -634,7 +650,7 @@ export const actions: Actions = {
if (!body.trim()) return queueActionFailure(409, 'The queued content is no longer available.');
const now = new Date().toISOString();
const reviewId = randomUUID();
- let targetOwnerId: string | null = null;
+ let commentRecipientId: string | null = null;
await database.exec('BEGIN IMMEDIATE');
try {
const claim = await (await database.prepare(`INSERT INTO content_moderation_reviews
@@ -651,9 +667,16 @@ export const actions: Actions = {
SET status='visible',removed_at=NULL,removed_by=NULL,removal_reason=NULL,updated_at=?
WHERE id=? AND status='pending_review'`)).run(now, scan.content_id);
if (!result.changes) throw new Error('The pending comment changed before approval.');
- targetOwnerId = String(scan.target_type) === 'profile'
+ const targetOwnerId = String(scan.target_type) === 'profile'
? String(scan.target_id)
- : scan.guide_owner_id ? String(scan.guide_owner_id) : null;
+ : scan.guide_owner_id ? String(scan.guide_owner_id) : '';
+ commentRecipientId = targetOwnerId
+ ? profileCommentNotificationRecipient(
+ String(scan.author_id),
+ scan.parent_author_id ? String(scan.parent_author_id) : null,
+ targetOwnerId
+ )
+ : null;
} else if (contentType === 'profile_bio') {
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
@@ -685,6 +708,7 @@ export const actions: Actions = {
policyVersion: Number(scan.policy_version),
provider: String(scan.provider),
model: String(scan.model),
+ parentId: scan.parent_id ? String(scan.parent_id) : null,
currentStatus: contentType === 'comment'
? String(scan.comment_status)
: contentType === 'profile_bio' ? 'revision_pending' : 'revision_pending'
@@ -714,7 +738,8 @@ export const actions: Actions = {
scan.content_id,
scan.target_type,
scan.target_id,
- scan.slug
+ scan.slug,
+ scan.target_public_slug
);
await createNotification({
userId: authorId,
@@ -728,15 +753,41 @@ export const actions: Actions = {
actions: [{ label: 'VIEW CONTENT', href: link }],
mandatory: true
});
- if (contentType === 'comment' && targetOwnerId && targetOwnerId !== authorId) {
+ if (contentType === 'comment' && commentRecipientId && commentRecipientId !== authorId) {
+ const isProfileReply = String(scan.target_type) === 'profile' && Boolean(scan.parent_id);
+ const isDirectReplyRecipient = Boolean(
+ scan.parent_author_id
+ && commentRecipientId === String(scan.parent_author_id)
+ );
await createNotification({
- userId: targetOwnerId,
+ userId: commentRecipientId,
category: 'comments',
source: 'agent',
- title: 'New comment',
- body: `${String(scan.display_name ?? 'An Agent')} posted a comment on your ${String(scan.target_type) === 'profile' ? 'Agent profile' : 'build'}.`,
+ title: isProfileReply ? 'New profile reply' : 'New comment',
+ body: isProfileReply
+ ? isDirectReplyRecipient
+ ? `${String(scan.display_name ?? 'An Agent')} replied to your message on ${String(scan.target_title ?? 'an Agent')}'s Agent profile.`
+ : `${String(scan.display_name ?? 'An Agent')} replied in a conversation on your Agent profile.`
+ : `${String(scan.display_name ?? 'An Agent')} posted a comment on your ${String(scan.target_type) === 'profile' ? 'Agent profile' : 'build'}.`,
link,
- actions: [{ label: 'VIEW COMMENT', href: link }]
+ actions: [{ label: isProfileReply ? 'VIEW REPLY' : 'VIEW COMMENT', href: link }]
+ });
+ }
+ if (
+ contentType === 'comment'
+ && String(scan.target_type) === 'profile'
+ && Boolean(scan.parent_id)
+ ) {
+ await notifyProfileCommentFollowers({
+ commentId: String(scan.content_id),
+ authorId,
+ authorDisplayName: String(scan.display_name ?? 'An Agent'),
+ profileOwnerId: String(scan.target_id),
+ profileOwnerDisplayName: String(scan.target_title ?? 'an Agent'),
+ profileOwnerPublicSlug: scan.target_public_slug
+ ? String(scan.target_public_slug)
+ : null,
+ directRecipientId: commentRecipientId
});
}
return { communitySaved: true, communityResultToken: randomUUID() };
@@ -947,6 +998,7 @@ export const actions: Actions = {
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
+ parentId: comment.parent_id ? String(comment.parent_id) : null,
targetType: String(comment.target_type),
targetId: String(comment.target_id),
updatedAt: String(comment.updated_at)
@@ -1005,6 +1057,7 @@ export const actions: Actions = {
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
+ parentId: comment.parent_id ? String(comment.parent_id) : null,
removedAt: String(comment.removed_at),
removedBy: String(comment.removed_by),
removalReason: String(comment.removal_reason),
@@ -1026,6 +1079,7 @@ export const actions: Actions = {
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
+ parentId: comment.parent_id ? String(comment.parent_id) : null,
restorationReason: reason,
updatedAt: now
}
diff --git a/src/routes/isac/gear/+page.server.ts b/src/routes/isac/gear/+page.server.ts
index 7a391e8..49690ea 100644
--- a/src/routes/isac/gear/+page.server.ts
+++ b/src/routes/isac/gear/+page.server.ts
@@ -76,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : [];
const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length);
const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length);
- const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons] = await Promise.all([
+ const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, talentIcons] = await Promise.all([
databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
@@ -85,11 +85,24 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
(await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
- (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all()
+ (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all(),
+ (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all()
]);
const pendingBrands = pendingDefinitions.filter((item) => item.kind === 'brand');
const pendingTalents = pendingDefinitions.filter((item) => item.kind === 'talent' && ['Backpack', 'Body Armor'].includes(String(item.category)));
const pendingStats = pendingDefinitions.filter((item) => item.kind === 'attribute');
+ const talentOptionWithIcon = (talent: Record) => {
+ const talentId = String(talent.id);
+ const icon = talentIcons.find((item) => String(item.entity_id) === talentId);
+ return {
+ ...talent,
+ id: talentId,
+ name: String(talent.name),
+ category: String(talent.category),
+ showIcon: true,
+ iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''
+ };
+ };
return {
gear: [...pendingGearOnPage.map((item) => ({
...item,
@@ -109,7 +122,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const icon = brandIcons.find((item) => item.entity_id === brand.id);
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '' };
})],
- talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))],
+ talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))].map(talentOptionWithIcon),
augmentProfiles: slots.map((slot) => ({
slot,
requiredSlots: augmentSlotCount(slot),
diff --git a/src/routes/isac/gear/+page.svelte b/src/routes/isac/gear/+page.svelte
index c02f541..93fabd8 100644
--- a/src/routes/isac/gear/+page.svelte
+++ b/src/routes/isac/gear/+page.svelte
@@ -86,7 +86,7 @@
{#if editing.slot === 'Backpack' || editing.slot === 'Body Armor'}
{editing.slot.toUpperCase()} TALENTS SELECT AT LEAST ONE · REQUIRED
{editing.talentIds.length} SELECTED
-
+
{#if gearTalents(editing.slot).length === 0}Create an active {editing.slot} talent before adding gear for this slot.
{/if}
{/if}
{#if data.brands.length < 2}Create at least two active brand sets before adding gear.
{/if}
diff --git a/src/routes/isac/weapons/+page.server.ts b/src/routes/isac/weapons/+page.server.ts
index e9cdab8..3e92249 100644
--- a/src/routes/isac/weapons/+page.server.ts
+++ b/src/routes/isac/weapons/+page.server.ts
@@ -15,11 +15,13 @@ import {
saveConditionalRuleInTransaction
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
+import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
+import { normalizeWeaponModSlotPayload, validateWeaponModSlots, WEAPON_MOD_SLOT_TYPES } from '$lib/weapons/mod-slots';
-const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
+const categories: readonly string[] = WEAPON_CATEGORIES;
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const rangeNames = ['damage'] as const;
-const modSlotTypes = ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel', 'Upper Receiver', 'Lower Receiver', 'Trigger'] as const;
+const modSlotTypes = WEAPON_MOD_SLOT_TYPES;
type ExoticEffect = { statId: string | null; valueI: number; valueII: number };
const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`;
@@ -41,6 +43,15 @@ const descriptionOutcomes = (rule: ReturnType outcome.descriptionSlot)
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
+async function bonusProfileSnapshot(category: string) {
+ const database = await getDatabase();
+ return (await (await database.prepare(`SELECT bonuses.id,bonuses.weapon_category,bonuses.stat_attribute_id,
+ attributes.name AS attribute_name,attributes.unit,bonuses.value,bonuses.sort_order,bonuses.updated_at
+ FROM weapon_category_attribute_bonuses bonuses
+ JOIN stat_attributes attributes ON attributes.id=bonuses.stat_attribute_id
+ WHERE bonuses.weapon_category=? ORDER BY bonuses.sort_order`)).all(category));
+}
+
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
@@ -76,13 +87,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])];
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
- const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, exoticTemplates, talentIcons, pending] = await Promise.all([
+ const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, categoryBonuses, talent1Options, talent2Options, stats, exoticTemplates, talentIcons, pending] = await Promise.all([
(await database.prepare(`SELECT * FROM weapons ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(),
(await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(),
(await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_augmentation_effects ORDER BY sort_order')).all(),
- (await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(),
+ (await database.prepare(`SELECT bonuses.*,attributes.name AS attribute_name,attributes.unit
+ FROM weapon_category_attribute_bonuses bonuses
+ JOIN stat_attributes attributes ON attributes.id=bonuses.stat_attribute_id
+ ORDER BY bonuses.weapon_category,bonuses.sort_order`)).all(),
(await database.prepare(`SELECT talents.id,talents.name FROM talents
JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id
JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id
@@ -105,6 +119,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
])
]);
const pendingTalents = pending.filter((item) => item.kind === 'talent');
+ const talentOptionWithIcon = (talent: Record) => {
+ const talentId = String(talent.id);
+ const icon = talentIcons.find((item) => String(item.entity_id) === talentId);
+ return {
+ ...talent,
+ id: talentId,
+ name: String(talent.name),
+ showIcon: true,
+ iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''
+ };
+ };
const pendingWeapons = pending.filter((item) => item.kind === 'weapon').filter((item) =>
requestedWeaponId ? item.id === requestedWeaponId
: filter === 'standard' ? !item.isExotic
@@ -117,7 +142,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
damage_type: weapon.damageType,
is_exotic: weapon.isExotic ? 1 : 0,
reload_speed: weapon.reloadSpeed,
- baseBonuses: [],
tiers: [],
modSlots: (() => { try { return JSON.parse(String(weapon.modSlots ?? '[]')); } catch { return []; } })(),
talent1Ids: Array.isArray(weapon.talent1Ids) ? weapon.talent1Ids : [weapon.talent1Ids].filter(Boolean),
@@ -150,7 +174,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)),
talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)),
augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id),
- baseBonuses: baseBonuses.filter((effect) => effect.weapon_id === weapon.id),
+ baseBonuses: categoryBonuses.filter((effect) => effect.weapon_category === weapon.category),
exoticTalent: talentId ? {
id: talentId,
iconUrl: (() => { const icon = talentIcons.find((item) => item.entity_id === talentId); return icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''; })(),
@@ -164,8 +188,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
}}));
return {
weapons: [...pendingWeapons, ...configuredWeapons],
- talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options],
- talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options],
+ talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options].map(talentOptionWithIcon),
+ talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options].map(talentOptionWithIcon),
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'weapon').length, pending: pending.filter((item) => item.kind === 'weapon').length },
pagination,
@@ -173,6 +197,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
search,
sort,
requestedWeaponId
+ ,bonusProfiles: categories.map((category) => ({
+ category,
+ effects: categoryBonuses.filter((effect) => effect.weapon_category === category)
+ }))
,modSlotOptions: modSlotTypes.map((name) => ({ id: name, name }))
};
};
@@ -189,6 +217,43 @@ function readRange(form: FormData, tier: number, range: (typeof rangeNames)[numb
}
export const actions: Actions = {
+ saveBonusProfile: async ({ request, cookies }) => {
+ const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
+ const form = await request.formData();
+ const category = String(form.get('category') ?? '');
+ if (!categories.includes(category)) return fail(400, { bonusProfileError: 'Choose a valid weapon category.' });
+ const values = [1, 2, 3].map((order) => number(form, `bonusValue${order}`));
+ if (values.some((value) => !Number.isFinite(value) || value <= 0)) {
+ return fail(400, { bonusProfileError: 'Each category bonus requires a positive value.' });
+ }
+ const before = await bonusProfileSnapshot(category);
+ if (before.length !== 3) {
+ return fail(500, { bonusProfileError: `${category} requires exactly three shared bonus Attributes.` });
+ }
+ const database = await getDatabase();
+ const now = new Date().toISOString();
+ await database.exec('BEGIN');
+ try {
+ for (let index = 0; index < before.length; index += 1) {
+ await (await database.prepare(
+ 'UPDATE weapon_category_attribute_bonuses SET value=?,updated_at=? WHERE id=?'
+ )).run(values[index], now, before[index].id);
+ }
+ await database.exec('COMMIT');
+ } catch (error) {
+ await database.exec('ROLLBACK');
+ throw error;
+ }
+ await recordIsacAudit({
+ actorId: operator.id,
+ category: 'game_data',
+ action: 'weapon_bonus_profile.updated',
+ entityId: category,
+ before,
+ after: await bonusProfileSnapshot(category)
+ });
+ return { weaponBonusProfileSaved: true, weaponBonusProfileMode: 'edit', resultToken: randomUUID() };
+ },
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
@@ -212,8 +277,7 @@ export const actions: Actions = {
const talent2Ids = [...new Set(form.getAll('talent2Ids').map(String))];
const modSlots = (() => {
try {
- const parsed = JSON.parse(String(form.get('modSlots') ?? '[]'));
- return Array.isArray(parsed) ? parsed.map((row) => ({ tier: Number(row.tier), slots: [...new Set(Array.isArray(row.slots) ? row.slots.map(String) : [])] })) : [];
+ return normalizeWeaponModSlotPayload(JSON.parse(String(form.get('modSlots') ?? '[]')));
} catch { return []; }
})();
const existingExoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : '';
@@ -263,13 +327,8 @@ export const actions: Actions = {
if (talent1Ids.some((talentId) => talent2Ids.includes(talentId))) {
return fail(400, { weaponError: 'Talent 1 and Talent 2 availability lists must be unique.' });
}
- for (let tier = nativeTier; tier <= 2; tier++) {
- const configured = modSlots.find((row) => row.tier === tier);
- const required = tier === 0 ? 4 : 5;
- if (!configured || configured.slots.length !== required || configured.slots.some((slot) => !modSlotTypes.includes(slot as typeof modSlotTypes[number]))) {
- return fail(400, { weaponError: `Tier ${tier} requires exactly ${required} unique Weapon Mod slots.` });
- }
- }
+ const modSlotError = validateWeaponModSlots(nativeTier, modSlots);
+ if (modSlotError) return fail(400, { weaponError: modSlotError });
if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticConditionalRule)) {
return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, and modeled conditional mechanic.' });
}
@@ -304,21 +363,6 @@ export const actions: Actions = {
if (!augmentEffects.length || new Set(augmentEffects.map((effect) => effect.statId)).size !== augmentEffects.length) {
return fail(400, { weaponError: 'The first tactical augmentation requires one or two unique attribute effects.' });
}
- const baseBonusCount = number(form, 'baseBonusCount');
- if (!Number.isInteger(baseBonusCount) || baseBonusCount < 1 || baseBonusCount > 12) {
- return fail(400, { weaponError: 'Weapons require between one and twelve base attribute bonuses.' });
- }
- const baseBonuses = [];
- for (let order = 1; order <= baseBonusCount; order++) {
- const statId = String(form.get(`baseBonusStat${order}`) ?? '');
- const value = number(form, `baseBonusValue${order}`);
- if (!statId || !Number.isFinite(value) || value <= 0) return fail(400, { weaponError: `Base attribute bonus ${order} requires an attribute and a positive value.` });
- baseBonuses.push({ statId, value, order });
- }
- if (new Set(baseBonuses.map((effect) => effect.statId)).size !== baseBonuses.length) {
- return fail(400, { weaponError: 'Each base attribute bonus must reference a different attribute.' });
- }
-
if (await (await database.prepare('SELECT id FROM weapons WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
return fail(409, { weaponError: 'That weapon name already exists.' });
}
@@ -332,7 +376,6 @@ export const actions: Actions = {
}
const statIds = [...new Set([
...augmentEffects.map((effect) => effect.statId),
- ...baseBonuses.map((effect) => effect.statId),
...exoticEffects.map((effect) => effect.statId).filter((value): value is string => Boolean(value))
])];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
@@ -368,7 +411,6 @@ export const actions: Actions = {
tiers: await (await database.prepare('SELECT * FROM weapon_tier_ranges WHERE weapon_id=? ORDER BY tier')).all(id),
talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id),
augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id),
- baseBonuses: await (await database.prepare('SELECT * FROM weapon_base_attribute_bonuses WHERE weapon_id=? ORDER BY sort_order')).all(id),
modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id),
exoticTalent: existingExoticTalentId ? {
record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId),
@@ -446,12 +488,7 @@ export const actions: Actions = {
await (await database.prepare('INSERT INTO weapon_augmentation_effects(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)'))
.run(randomUUID(), weaponId, effect.statId, effect.value, effect.order);
}
- await (await database.prepare('DELETE FROM weapon_base_attribute_bonuses WHERE weapon_id=?')).run(weaponId);
- for (const effect of baseBonuses) {
- await (await database.prepare('INSERT INTO weapon_base_attribute_bonuses(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)'))
- .run(randomUUID(), weaponId, effect.statId, effect.value, effect.order);
- }
- await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, conditional: await conditionalRuleSnapshot(database, 'talent', String(exoticTalentId)) } : null } });
+ await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, conditional: await conditionalRuleSnapshot(database, 'talent', String(exoticTalentId)) } : null } });
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
diff --git a/src/routes/isac/weapons/+page.svelte b/src/routes/isac/weapons/+page.svelte
index 7e37a71..e161f50 100644
--- a/src/routes/isac/weapons/+page.svelte
+++ b/src/routes/isac/weapons/+page.svelte
@@ -11,8 +11,15 @@
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
+ import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
+ import {
+ excludedTierZeroWeaponModSlot,
+ expandSharedWeaponModSlots,
+ sharedWeaponModSlots,
+ weaponModSlotsComplete
+ } from '$lib/weapons/mod-slots';
let { data, form } = $props();
- const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
+ const categories = [...WEAPON_CATEGORIES];
const categoryOptions = categories.map((value) => ({ value, label: value, icon: value.toLowerCase() as GameIconName, iconOnly: true }));
const tierOptions = [0, 1, 2].map((value) => ({ value: String(value), label: `T${value}` }));
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
@@ -24,8 +31,9 @@
clauses:[{key:'primary',label:'Primary',event:'weapon_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'equipped_weapon',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{I:{value:0},II:{value:0}}}]}]
});
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', descriptionTemplate: '', conditionalRule: blankConditionalRule() });
- const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
+ const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), sharedModSlots: [], t0ExcludedSlot: '', talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state(null);
+ let bonusEditing = $state(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
let openedRequestedWeapon = $state(false);
@@ -35,10 +43,24 @@
return { ...blankTier(tier), ...saved };
});
const modSlots = [0,1,2].map((tier) => ({ tier, slots: [...(weapon.modSlots?.find((row: any) => Number(row.tier) === tier)?.slots ?? [])] }));
- editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, conditionalRule: weapon.exoticTalent.conditionalRule ?? blankConditionalRule() } : blankExoticTalent() };
+ const sharedSlots = sharedWeaponModSlots(modSlots);
+ editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, sharedModSlots: sharedSlots, t0ExcludedSlot: excludedTierZeroWeaponModSlot(sharedSlots, modSlots), talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, conditionalRule: weapon.exoticTalent.conditionalRule ?? blankConditionalRule() } : blankExoticTalent() };
+ bonusEditing = null;
+ };
+ const editBonusProfile = (profile: any) => {
+ bonusEditing = { category: profile.category, effects: profile.effects.map((effect: any) => ({ ...effect })) };
+ editing = null;
};
const tierAt = (tier: number) => editing.tiers.find((item: any) => Number(item.tier) === tier);
- const modSlotsAt = (tier: number) => editing.modSlots.find((item: any) => Number(item.tier) === tier);
+ const modSlotPayload = () => expandSharedWeaponModSlots(editing.sharedModSlots, editing.t0ExcludedSlot);
+ const t0SlotCount = () => editing.t0ExcludedSlot ? Math.max(0, editing.sharedModSlots.length - 1) : 0;
+ const weaponState = (weapon: any) => weapon.pending
+ ? 'PENDING'
+ : Number(weapon.active) !== 1
+ ? 'ARCHIVED'
+ : weaponModSlotsComplete(Number(weapon.native_tier), weapon.modSlots)
+ ? 'ACTIVE'
+ : 'CONFIG REQUIRED';
const statLabel = (id: string) => data.stats.find((stat) => stat.id === id)?.name ?? 'Unknown attribute';
const talentLabel = (id: string) => [...data.talent1Options, ...data.talent2Options].find((talent) => talent.id === id)?.name ?? 'Unknown talent';
const setCategory = (category: string) => {
@@ -74,13 +96,10 @@
const removeAugmentEffect = (index: number) => {
if (editing && index > 0) editing.augmentEffects = editing.augmentEffects.filter((_: any, effectIndex: number) => effectIndex !== index);
};
- const addBaseBonus = () => {
- if (editing && editing.baseBonuses.length < 12) editing.baseBonuses = [...editing.baseBonuses, { stat_attribute_id: '', value: 0, sort_order: editing.baseBonuses.length + 1 }];
- };
- const removeBaseBonus = (index: number) => {
- if (editing && editing.baseBonuses.length > 1) editing.baseBonuses = editing.baseBonuses.filter((_: any, bonusIndex: number) => bonusIndex !== index);
- };
$effect(() => {
+ if (editing?.t0ExcludedSlot && !editing.sharedModSlots.includes(editing.t0ExcludedSlot)) {
+ editing.t0ExcludedSlot = '';
+ }
if (!openedRequestedWeapon && data.requestedWeaponId) {
const requested = data.weapons.find((weapon) => weapon.id === data.requestedWeaponId);
if (requested) edit(requested);
@@ -91,11 +110,42 @@
if (form.weaponMode === 'add') iconResetToken += 1;
editing = form.weaponMode === 'add' ? blank() : null;
}
+ if (form?.weaponBonusProfileSaved && form.resultToken && form.resultToken !== handledResultToken) {
+ handledResultToken = form.resultToken;
+ bonusEditing = null;
+ }
});
Weapons — SHD.build admin
-
+
+
+ SHARED WEAPON CATEGORY BONUSES INHERITED BY STANDARD AND EXOTIC WEAPONS
{data.bonusProfiles.filter((profile) => profile.effects.length === 3).length} / 6 CONFIGURED +
+
+ {#each data.bonusProfiles as profile}
+
editBonusProfile(profile)}>
+
+ {#each profile.effects as effect}0{effect.sort_order} {effect.attribute_name} {effect.value}{effect.unit === 'flat' ? '' : effect.unit} {/each}
+
+ {/each}
+
+
+{#if bonusEditing}
+
+
SHARED CATEGORY BONUSES
{bonusEditing.category} bonusEditing = null}>×
+
+
+ Every {bonusEditing.category} inherits these bonuses, including exotic weapons in this category.
+
+ {#each bonusEditing.effects as effect, index}
+ {effect.attribute_name}
+ {/each}
+
+ {#if form?.bonusProfileError}{form.bonusProfileError}
{/if}
+ bonusEditing = null}>CANCEL SAVE CATEGORY BONUSES →
+
+
+{/if}
{#snippet weaponEditor()}
@@ -103,7 +153,7 @@
{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}
{editing.id ? editing.name : 'WEAPON DEFINITION'} editing = null}>×
-
+
NAME
setCategory(values[0])} required/>
@@ -140,27 +190,29 @@
ACCURACY
STABILITY
- BASE ATTRIBUTE BONUSES FIXED BONUSES PROVIDED BY THIS WEAPON
= 12}>ADD BONUS +
-
-
- {#each editing.baseBonuses as bonus: any, index}
-
- {/each}
-
TIER PROGRESSION DAMAGE RANGES · CORE ATTRIBUTES USE GLOBAL CONFIGURATION
3 TIERS REQUIRED
+ MOD SLOTS OPTIONAL T1 and T2 share one slot set. T0 uses that set minus one slot. ISAC marks the weapon Config Required until you can verify both parts.
+
+ {Number(editing.native_tier) === 2 ? 'T2 WEAPON MOD SLOTS' : 'SHARED T1 / T2 WEAPON MOD SLOTS'} SELECT UP TO FIVE VERIFIED SLOTS
{editing.sharedModSlots.length} / 5
+
+ {#if Number(editing.native_tier) === 0}
+
+
T0 SLOT NOT PRESENT CHOOSE THE ONE SHARED SLOT OMITTED AT T0
{editing.t0ExcludedSlot ? `${t0SlotCount()} / 4` : 'NOT VERIFIED'}
+
({value:slot,label:slot}))]} columns={Math.min(3, editing.sharedModSlots.length + 1)} bind:value={editing.t0ExcludedSlot}/>
+
+ {/if}
+
-
TALENT 1 · TOP POOL GLOBALLY CLASSIFIED FOR FIRST SLOT
{editing.talent1Ids.length}
-
TALENT 2 · BOTTOM POOL GLOBALLY CLASSIFIED FOR SECOND SLOT
{editing.talent2Ids.length}
+
TALENT 1 · TOP POOL GLOBALLY CLASSIFIED FOR FIRST SLOT
{editing.talent1Ids.length}
+
TALENT 2 · BOTTOM POOL GLOBALLY CLASSIFIED FOR SECOND SLOT
{editing.talent2Ids.length}
TACTICAL AUGMENTATION 01 ONE OR TWO UNIQUE ATTRIBUTE UPGRADES
= 2}>ADD EFFECT +
@@ -185,7 +237,7 @@
{#each data.weapons as weapon}
-
{weapon.name} {#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else} {Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}
{#if Number(weapon.is_exotic)} {/if}{weapon.category} · T{weapon.native_tier} {weapon.rpm} RPM · {weapon.magazine} MAG · {weapon.reload_speed} RELOAD {weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS {weapon.talent1Ids.length} / {weapon.talent2Ids.length} `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS {weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'} {#if weapon.pending}
{:else}
edit(weapon)} aria-expanded={editing?.id === weapon.id} aria-controls={`weapon-editor-${weapon.id}`}>EDIT {/if}
+
{weapon.name} {#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else} {Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}
{#if Number(weapon.is_exotic)} {/if}{weapon.category} · T{weapon.native_tier} {weapon.rpm} RPM · {weapon.magazine} MAG · {weapon.reload_speed} RELOAD {weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS {weapon.talent1Ids.length} / {weapon.talent2Ids.length} `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS {weaponState(weapon)} {#if weapon.pending}
{:else}
edit(weapon)} aria-expanded={editing?.id === weapon.id} aria-controls={`weapon-editor-${weapon.id}`}>EDIT {/if}
{#if editing?.id === weapon.id}
{@render weaponEditor()}