feat: expand ISAC content modeling
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s

This commit is contained in:
jb
2026-08-03 01:23:20 -04:00
parent d9924a95b9
commit 57068b61cd
90 changed files with 9635 additions and 1270 deletions
+55
View File
@@ -92,6 +92,10 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
WHERE users.id=?`)
).get(agentId);
const isSelf = viewer?.id === agentId;
const canManageNews =
isSelf &&
(viewerPermissions.includes('*') ||
viewerPermissions.includes('news.manage'));
if (unavailable(agent) || (!Boolean(agent?.is_public) && !isSelf))
error(404, 'Agent profile not found');
if (params.id !== String(agent.public_slug)) {
@@ -129,6 +133,9 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
profileCommentCount,
buildCount,
guideCount,
ownedBuildRows,
ownedGuideRows,
ownedNewsRows,
] = await Promise.all([
(
await database.prepare(`SELECT roles.name FROM user_roles
@@ -216,6 +223,28 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
await database.prepare(`SELECT COUNT(*) AS count FROM articles
WHERE kind='guide' AND owner_id=? AND visibility='public' AND deleted_at IS NULL`)
).get(agentId),
isSelf
? (
await database.prepare(`SELECT id,slug,title,visibility,updated_at
FROM builds WHERE owner_id=? ORDER BY updated_at DESC`)
).all(agentId)
: [],
isSelf
? (
await database.prepare(`SELECT articles.id,articles.slug,draft.title,articles.visibility,articles.updated_at
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id
WHERE articles.kind='guide' AND articles.owner_id=? AND articles.deleted_at IS NULL
ORDER BY articles.updated_at DESC`)
).all(agentId)
: [],
canManageNews
? (
await database.prepare(`SELECT articles.id,articles.slug,draft.title,articles.visibility,articles.updated_at
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id
WHERE articles.kind='news' AND articles.owner_id=? AND articles.deleted_at IS NULL
ORDER BY articles.updated_at DESC`)
).all(agentId)
: [],
]);
const loadedCommentIds = new Set(
@@ -401,6 +430,32 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
title: String(guide.title),
updatedAt: String(guide.updated_at),
})),
ownedContent: isSelf
? {
builds: ownedBuildRows.map((build) => ({
id: String(build.id),
slug: String(build.slug),
title: String(build.title),
visibility: String(build.visibility),
updatedAt: String(build.updated_at),
})),
guides: ownedGuideRows.map((guide) => ({
id: String(guide.id),
slug: String(guide.slug),
title: String(guide.title),
visibility: String(guide.visibility),
updatedAt: String(guide.updated_at),
})),
news: ownedNewsRows.map((news) => ({
id: String(news.id),
slug: String(news.slug),
title: String(news.title),
visibility: String(news.visibility),
updatedAt: String(news.updated_at),
})),
canManageNews,
}
: null,
authoredComments: authoredCommentRows.map((comment) => {
const targetType = String(comment.target_type);
const targetId = String(comment.target_id);
+49
View File
@@ -32,6 +32,7 @@
title: string;
updatedAt: string;
};
type OwnedContent = PublicContent & { visibility: string };
type AuthoredComment = {
id: string;
createdAt: string;
@@ -60,6 +61,12 @@
counts: { builds: number; guides: number; authoredComments: number; profileComments: number };
builds: PublicContent[];
guides: PublicContent[];
ownedContent: {
builds: OwnedContent[];
guides: OwnedContent[];
news: OwnedContent[];
canManageNews: boolean;
} | null;
authoredComments: AuthoredComment[];
profileComments: ProfileComment[];
viewer: { id: string | null; canComment: boolean; canReportProfile: boolean; isSelf: boolean };
@@ -86,6 +93,7 @@
? 'DATE UNKNOWN'
: date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }).toUpperCase();
};
const visibilityLabel = (visibility: string) => visibility.toUpperCase().replace('_', ' ');
$effect(() => {
const token = String(form?.profileCommentResultToken ?? form?.commentResultToken ?? '');
@@ -242,6 +250,44 @@
</div>
</section>
</div>
{#if data.viewer.isSelf && data.ownedContent}
<section class="profile-section content-control">
<header>
<div><p class="eyebrow">OWNER WORKSPACE</p><h2>CONTENT CONTROL</h2></div>
<span>PRIVATE TO YOU</span>
</header>
<p class="content-control-intro">Open any authored item to edit it or change its visibility. Private and unlisted items appear only here.</p>
<div class="content-control-grid">
<section>
<header><span>LOADOUTS</span><a href="/builder">NEW BUILD +</a></header>
<div class="content-control-list">
{#each data.ownedContent.builds as build}
<a href={`/builder?build=${encodeURIComponent(build.slug)}`}><span><b>{build.title}</b><small>UPDATED {dateLabel(build.updatedAt)}</small></span><em>{visibilityLabel(build.visibility)}</em><strong>EDIT →</strong></a>
{:else}<p class="content-control-empty">No builds yet. Create one in the Loadout Lab.</p>{/each}
</div>
</section>
<section>
<header><span>GUIDES</span><a href="/guides/new">NEW GUIDE +</a></header>
<div class="content-control-list">
{#each data.ownedContent.guides as guide}
<a href={`/guides/${encodeURIComponent(guide.slug)}/edit`}><span><b>{guide.title}</b><small>UPDATED {dateLabel(guide.updatedAt)}</small></span><em>{visibilityLabel(guide.visibility)}</em><strong>EDIT →</strong></a>
{:else}<p class="content-control-empty">No Guides yet. Start a field guide when you are ready.</p>{/each}
</div>
</section>
{#if data.ownedContent.canManageNews}
<section>
<header><span>NEWS POSTS</span><a href="/news/new">NEW ARTICLE +</a></header>
<div class="content-control-list">
{#each data.ownedContent.news as news}
<a href={`/news/${encodeURIComponent(news.slug)}/edit`}><span><b>{news.title}</b><small>UPDATED {dateLabel(news.updatedAt)}</small></span><em>{visibilityLabel(news.visibility)}</em><strong>EDIT →</strong></a>
{:else}<p class="content-control-empty">No news posts authored by this Agent.</p>{/each}
</div>
</section>
{/if}
</div>
</section>
{/if}
</div>
<section class="profile-comms" id="profile-comments">
@@ -392,6 +438,7 @@
.profile-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.profile-section>header{min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.profile-section>header>div{display:grid;gap:2px}.profile-section>header :is(h1,h2,p){margin:0}.profile-section>header h1,.profile-section>header h2{font-size:var(--shd-type-body);letter-spacing:.4px}.profile-section>header>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
.bio-content{min-height:110px;padding:18px}:global(form.profile-bio-form){display:grid;gap:10px;padding:16px}.profile-bio-actions{display:flex;justify-content:flex-end;gap:7px}.profile-bio-actions button{min-height:40px}.profile-owner-notice{display:grid;gap:4px;margin:12px 16px 0;padding:10px;border:1px solid #745b2b;background:#1c170e;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.profile-owner-notice strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.profile-owner-notice.error{border-color:#70423b;background:#211414}.profile-owner-notice.error strong{color:var(--shd-danger)}.owner-profile-result{margin:10px 16px 0}.public-content-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:7px}
.public-content-card{min-width:0;display:grid;grid-template-rows:auto auto minmax(48px,1fr) auto;gap:6px;padding:12px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011;color:var(--shd-text)}.public-content-card>span,.public-content-card footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.public-content-card>span{color:var(--shd-orange)}.public-content-card h3,.public-content-card p{margin:0}.public-content-card h3{font-size:1.05rem}.public-content-card p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.45}.public-content-card footer{display:flex;justify-content:space-between;gap:8px;padding-top:8px;border-top:1px solid var(--shd-border-soft)}.public-content-card small{color:var(--shd-text-dim)}.public-content-card b{color:var(--shd-orange)}.public-content-card:hover,.public-content-card:focus-visible{border-color:var(--shd-orange);background:var(--shd-surface-active)}
.content-control{margin-top:10px}.content-control-intro{margin:0;padding:10px 12px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.content-control-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;padding:7px}.content-control-grid>section{min-width:0;border:1px solid var(--shd-border);background:#0d1011}.content-control-grid>section>header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.content-control-grid>section>header :is(span,a){font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.content-control-grid>section>header span{color:var(--shd-text-muted)}.content-control-grid>section>header a,.content-control-list strong{color:var(--shd-orange)}.content-control-list{display:grid}.content-control-list>a{min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;padding:10px;border-top:1px solid var(--shd-border);color:var(--shd-text)}.content-control-list>a:first-child{border-top:0}.content-control-list>a>span{min-width:0;display:grid;gap:3px}.content-control-list b{overflow:hidden;font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}.content-control-list small,.content-control-list em,.content-control-list strong{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px}.content-control-list small{color:var(--shd-text-dim)}.content-control-list em{justify-self:start;padding:2px 5px;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-style:normal}.content-control-list strong{grid-column:2;grid-row:1 / span 2;align-self:center}.content-control-list>a:hover,.content-control-list>a:focus-visible{background:var(--shd-surface-active);outline:1px solid var(--shd-orange);outline-offset:-1px}.content-control-empty{min-height:92px;display:grid;place-content:center;margin:0;padding:12px;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);text-align:center}
.public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px}
.authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:10px;padding:11px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;padding:0;border:0;background:transparent}.authored-comment-list article>header>div{min-width:0;display:grid;align-content:center;gap:2px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{align-self:center;color:var(--shd-text-muted);text-align:right;white-space:nowrap}.authored-comment-list article>a{justify-self:end}
.profile-comms{margin-top:16px;padding:0;background:transparent}.profile-comms-heading{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:end;justify-content:space-between;gap:16px;padding:0 0 12px;border:0;border-bottom:1px solid var(--shd-border);background:transparent}.profile-comms-heading>div{display:grid;gap:3px}.profile-comms-heading :is(h2,p){margin:0}.profile-comms-heading h2{font-size:1.25rem}.profile-comms-heading>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.profile-comment-start{width:min(920px,100%);padding-top:16px}.profile-comment-trigger{width:100%;min-height:48px;padding:0 14px;border:1px solid var(--shd-border-strong);background:transparent;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);text-align:left;cursor:pointer}.profile-comment-trigger:hover,.profile-comment-trigger:focus-visible,.profile-comment-trigger[aria-expanded=true]{border-color:var(--shd-orange);color:var(--shd-text);background:var(--shd-surface-active)}:global(form.profile-comment-form){width:min(920px,100%);display:grid;gap:9px;padding:14px 0 0;border:0;background:transparent}.profile-comment-form-actions{display:flex;justify-content:flex-end;gap:7px}.profile-comment-form-actions button{min-height:38px}.profile-comment-list{width:min(980px,100%);display:grid;margin-top:26px;overflow:visible}.profile-comment-list>article{--thread-depth:0;position:relative;display:grid;gap:8px;margin:18px 0 0 calc(var(--thread-depth) * 22px);padding:0;scroll-margin-top:96px;background:transparent}.profile-comment-list>article:target{outline:1px solid var(--shd-orange);outline-offset:8px}.profile-comment-list>article.reply{padding-left:14px;border-left:1px solid var(--shd-border-strong)}.profile-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:center;justify-content:flex-start;gap:12px;padding:0;border:0;background:transparent}.profile-comment-list article>header :global(.agent-identity){flex:0 1 auto}.profile-comment-list article :global(.markdown-content){max-width:75ch;margin-left:47px}.comment-actions{display:flex;align-items:center;gap:14px;min-height:32px;margin-left:47px}.comment-reply{min-height:32px;padding:0;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px;cursor:pointer}.comment-reply:hover,.comment-reply:focus-visible,.comment-reply[aria-expanded=true]{color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.reply-composer{margin:6px 0 0 47px}.reply-composer :global(form.profile-comment-form){padding-top:0}.removed-message{display:grid;gap:3px;padding:4px 0}.removed-message strong{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.removed-message span{color:var(--shd-text-dim);font-size:var(--shd-type-supporting);font-style:italic}.comment-delete-confirm{width:min(520px,calc(100% - 47px));display:grid;gap:8px;margin-left:47px;padding:10px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{width:min(920px,100%);display:grid;gap:7px;padding:16px 0 0}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}.profile-comms .public-empty{min-height:100px;place-content:start;padding:24px 0;text-align:left}.profile-comms .public-empty span{display:none}
@@ -399,6 +446,8 @@
.profile-comment-list article :global(.markdown-content),.comment-actions{margin-left:53px}.reply-composer{margin-left:53px}.comment-delete-confirm{width:min(520px,calc(100% - 53px));margin-left:53px}
.pinned-comment-label{margin-left:auto;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
@media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.agent-main-column{grid-column:1}.agent-activity{position:static;grid-column:1;grid-template-columns:repeat(2,minmax(0,1fr))}.agent-activity .profile-section:last-child{grid-column:1/-1}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}}
@media(max-width:1000px){.content-control-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:650px){.content-control-grid{grid-template-columns:1fr}}
@media(max-width:650px){.agent-command{grid-template-columns:1fr;align-items:stretch}.agent-command-actions{width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto}.agent-command-actions :global(.profile-visibility-form),.agent-command-actions .visibility-toggle{width:100%}.agent-command-actions .visibility-toggle{min-height:44px;justify-content:center}.agent-command-actions :global(.content-report){grid-column:1/-1}.agent-command-actions :global(.content-report summary){width:100%;justify-content:center}.mobile-account-jump{grid-column:1/-1;min-height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.mobile-account-jump:hover,.mobile-account-jump:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange);background:var(--shd-surface-active)}.agent-main-column{display:contents}.agent-primary{grid-column:1;grid-row:1}.agent-activity{grid-column:1;grid-row:2;grid-template-columns:1fr}.profile-comms{grid-column:1;grid-row:3;margin-top:16px}.agent-activity .profile-section:last-child{grid-column:auto}.agent-published-grid{grid-template-columns:1fr}.public-content-grid{grid-template-columns:1fr}.profile-section>header,.profile-comms-heading{align-items:flex-start;flex-direction:column}.agent-bio>header{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start}.agent-bio .profile-header-actions{width:auto}.bio-edit-trigger{min-height:44px;padding:0 5px;border-color:transparent}.bio-edit-trigger:hover,.bio-edit-trigger:focus-visible,.bio-edit-trigger[aria-expanded=true]{border-color:var(--shd-border-strong)}.profile-bio-actions{display:grid;grid-template-columns:1fr 1fr}.profile-bio-actions button{min-height:44px}.profile-comment-trigger,.profile-comment-form-actions button,.comment-reply,.comment-delete-confirm button{min-height:44px}.profile-comment-form-actions{display:grid;grid-template-columns:1fr 1fr}.profile-comment-list>article{margin-left:calc(var(--thread-depth) * 12px)}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.public-content-card,.profile-comment-trigger,.comment-reply{transition:none}}
</style>
@@ -60,7 +60,10 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
}
try {
const authoringRequest = parseAiCalculationAuthoringRequest(payload);
const release = reserveAiMechanicGeneration(operator.id);
const release = reserveAiMechanicGeneration(
operator.id,
operator.role === 'admin',
);
let response;
try {
response = await generateAiCalculationDraft(
@@ -58,7 +58,10 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
}
try {
const authoringRequest = parseAiMechanicAuthoringRequest(payload);
const release = reserveAiMechanicGeneration(operator.id);
const release = reserveAiMechanicGeneration(
operator.id,
operator.role === 'admin',
);
let response;
try {
response = await generateAiMechanicDraft(authoringRequest, operator.id);
+132 -11
View File
@@ -3,10 +3,11 @@ import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { getSessionUser } from '$lib/server/auth/session';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { parseLoadoutDraft } from '$lib/builder/loadout';
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
import { publicOrigin } from '$lib/server/public-origin';
import type { Actions, PageServerLoad } from './$types';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
const validVisibility = new Set(['private', 'unlisted', 'public']);
const slugify = (value: string) =>
@@ -28,6 +29,102 @@ function parseLoadout(raw: FormDataEntryValue | null) {
}
}
async function validateSkillChipSelections(
database: Awaited<ReturnType<typeof getDatabase>>,
loadout: LoadoutDraft,
) {
if (!loadout.chips.some((chip) => chip.setId)) return null;
const [
sets,
focusAbilities,
primaryOptions,
primaryValues,
secondaryOptions,
secondaryValues,
] = await Promise.all([
(
await database.prepare(
'SELECT id,specialization_ability_id FROM skill_chip_sets WHERE active=1',
)
).all(),
(
await database.prepare(
'SELECT ability_id,ability_slot FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot',
)
).all(loadout.specialization.focusId),
(
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_primary_options',
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_primary_quality_values')
).all(),
(
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_secondary_options',
)
).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_secondary_quality_values',
)
).all(),
]);
const primaryIds = new Set(
primaryOptions.map((row) => String(row.stat_attribute_id)),
);
const secondaryIds = new Set(
secondaryOptions.map((row) => String(row.stat_attribute_id)),
);
const rangeFor = (rows: any[], statId: string, quality: string) =>
rows.find(
(row) =>
String(row.stat_attribute_id) === statId && row.quality === quality,
);
const inRange = (value: number, range: any) =>
range &&
value >= Number(range.min_value) &&
value <= Number(range.max_value);
for (let index = 0; index < loadout.chips.length; index += 1) {
const chip = loadout.chips[index];
if (!chip.setId) continue;
const definition = sets.find((row) => String(row.id) === chip.setId);
const expectedAbility = focusAbilities.find(
(row) => Number(row.ability_slot) === Math.floor(index / 3) + 1,
);
if (
!definition ||
!expectedAbility ||
String(definition.specialization_ability_id) !==
String(expectedAbility.ability_id)
)
return `Skill Chip ${index + 1} is not compatible with its assigned ability.`;
const primaryRange = rangeFor(
primaryValues,
chip.primary.statId,
chip.quality,
);
if (
!primaryIds.has(chip.primary.statId) ||
!inRange(chip.primary.value, primaryRange)
)
return `Skill Chip ${index + 1} has an invalid primary Attribute value.`;
if (
new Set(chip.secondary.map((roll) => roll.statId)).size !==
chip.secondary.length
)
return `Skill Chip ${index + 1} must use distinct secondary Attributes.`;
for (const roll of chip.secondary) {
const range = rangeFor(secondaryValues, roll.statId, chip.quality);
if (!secondaryIds.has(roll.statId) || !inRange(roll.value, range))
return `Skill Chip ${index + 1} has an invalid secondary Attribute value.`;
}
}
return null;
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
@@ -70,8 +167,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talentTemplates,
chipSets,
chipTwoPiece,
chipPrimary,
chipSecondary,
chipPrimaryOptions,
chipPrimaryQualityValues,
chipSecondaryOptions,
chipSecondaryQualityValues,
chipTalents,
chipTalentEffects,
protocols,
@@ -162,9 +261,25 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
(
await database.prepare('SELECT * FROM skill_chip_secondary_options')
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_primary_options ORDER BY stat_attribute_id',
)
).all(),
(
await database.prepare(
'SELECT stat_attribute_id,quality,min_value,max_value FROM skill_chip_primary_quality_values ORDER BY stat_attribute_id,quality',
)
).all(),
(
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_secondary_options ORDER BY stat_attribute_id',
)
).all(),
(
await database.prepare(
'SELECT stat_attribute_id,quality,min_value,max_value FROM skill_chip_secondary_quality_values ORDER BY stat_attribute_id,quality',
)
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
@@ -329,6 +444,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
weaponBonuses,
),
talents: configuredTalents,
skillChipProfile: {
primaryOptions: hydrateSkillChipProfileRows(
chipPrimaryOptions,
chipPrimaryQualityValues,
),
secondaryOptions: hydrateSkillChipProfileRows(
chipSecondaryOptions,
chipSecondaryQualityValues,
),
},
chipSets: chipSets.map((set) => {
const talent = chipTalents.find(
(item) => item.skill_chip_set_id === set.id,
@@ -339,12 +464,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
twoPiece:
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
null,
primaryStatIds: chipPrimary
.filter((item) => item.skill_chip_set_id === set.id)
.map((item) => String(item.stat_attribute_id)),
secondaryStatIds: chipSecondary
.filter((item) => item.skill_chip_set_id === set.id)
.map((item) => String(item.stat_attribute_id)),
talent: talent
? {
...talent,
@@ -473,6 +592,8 @@ export const actions: Actions = {
return fail(400, { buildError: 'Choose a valid build visibility.' });
const database = await getDatabase();
const skillChipError = await validateSkillChipSelections(database, loadout);
if (skillChipError) return fail(400, { buildError: skillChipError });
const now = new Date().toISOString();
let buildId = id;
let slug = '';
+110 -25
View File
@@ -15,8 +15,8 @@
import { summarizeLoadout } from '$lib/builder/loadout-summary';
import { toast } from '$lib/toast';
import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
type LoadoutDraft, type Roll
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
type LoadoutDraft, type Roll, type SkillChipQuality
} from '$lib/builder/loadout';
let { data, form } = $props();
@@ -35,8 +35,23 @@
const coreKeys = ['firepower','toughness','engineering'] as const;
const gearAttributes = $derived(data.attributes.filter((item) => item.gear_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const weaponAttributes = $derived(data.attributes.filter((item) => item.weapon_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const chipAttributes = $derived(data.attributes.filter((item) => item.skill_chip_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
const chipPrimaryAttributes = $derived(data.skillChipProfile.primaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const chipSecondaryAttributes = $derived(data.skillChipProfile.secondaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const chipRange = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) =>
data.skillChipProfile[`${kind}Options`].find((option: any) => option.statId === statId)?.ranges?.[quality] ?? null;
type ChipRange = { min: number; max: number };
const validChipRange = (range: any): range is ChipRange => Number.isFinite(Number(range?.min)) && Number.isFinite(Number(range?.max)) && Number(range.max) >= Number(range.min);
const chipRangeMin = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.min ?? 0);
const chipRangeMax = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.max ?? 0);
const chipProfileConfigured = $derived(
data.skillChipProfile.primaryOptions.length === 4
&& chipPrimaryAttributes.length === data.skillChipProfile.primaryOptions.length
&& data.skillChipProfile.primaryOptions.every((option: any) => skillChipQualities.every((quality) => validChipRange(option.ranges?.[quality])))
&& data.skillChipProfile.secondaryOptions.length >= 2
&& chipSecondaryAttributes.length === data.skillChipProfile.secondaryOptions.length
&& data.skillChipProfile.secondaryOptions.every((option: any) => ['Specialized','Superior','High-end'].every((quality) => validChipRange(option.ranges?.[quality])))
);
const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
@@ -80,13 +95,35 @@
coreKeys.reduce((total, key) => total + Number(selection[key] ?? 0), 0);
const attributeRange = (stat: any, source: 'gear' | 'weapon', level: number) =>
scaledRange(stat?.[`${source}_min`], stat?.[`${source}_max`], level, stat?.[`${source}_reference_level`], source);
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon' | 'chip', level = 50) => {
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon', level = 50) => {
roll.statId = id;
const stat = attribute(id);
const min = source === 'chip'
? stat?.skill_chip_min
: attributeRange(stat, source, level).min;
roll.value = Number(min ?? 0);
roll.value = Number(attributeRange(stat, source, level).min ?? 0);
};
const setChipRoll = (roll: Roll, id: string, kind: 'primary' | 'secondary', quality: SkillChipQuality) => {
roll.statId = id;
const range = chipRange(kind, id, quality);
roll.value = validChipRange(range) ? Number(range.min) : 0;
};
const chipRollConfigured = (roll: Roll, kind: 'primary' | 'secondary', quality: SkillChipQuality) => {
const options = data.skillChipProfile[`${kind}Options`];
const range = chipRange(kind, roll.statId, quality);
const value = Number(roll.value);
return options.some((option: any) => option.statId === roll.statId)
&& validChipRange(range)
&& Number.isFinite(value)
&& value >= Number(range.min)
&& value <= Number(range.max);
};
const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number]) => {
const capacity = skillChipSecondaryCapacity[chip.quality];
const secondaryIds = chip.secondary.map((roll) => roll.statId);
return Boolean(data.chipSets.some((item: any) => item.id === chip.setId))
&& chipProfileConfigured
&& chipRollConfigured(chip.primary, 'primary', chip.quality)
&& chip.secondary.length === capacity
&& new Set(secondaryIds).size === secondaryIds.length
&& chip.secondary.every((roll) => chipRollConfigured(roll, 'secondary', chip.quality));
};
const rankPointCost = (rank: number) => Number(data.specializationProgression.rankProfiles.find((profile: any) => Number(profile.rank) === rank)?.point_cost ?? rank);
const selectedSpecializationPoints = $derived(selectedFocus?.nodes.reduce((total: number, node: any) => {
@@ -222,15 +259,41 @@
selection.rolls = [blankRoll(), blankRoll()];
};
const configureChip = (index: number, id: string) => {
const chip = draft.chips[index], item = data.chipSets.find((entry) => entry.id === id);
const chip = draft.chips[index];
chip.setId = id;
chip.primary = { statId: item?.primaryStatIds[0] ?? '', value: 0 };
chip.secondary = [
{ statId: item?.secondaryStatIds[0] ?? '', value: 0 },
{ statId: item?.secondaryStatIds[1] ?? '', value: 0 }
];
if (chip.primary.statId) setRollStat(chip.primary, chip.primary.statId, 'chip');
for (const roll of chip.secondary) if (roll.statId) setRollStat(roll, roll.statId, 'chip');
chip.primary = blankRoll();
chip.secondary = [];
changeChipQuality(index, chip.quality);
};
const changeChipQuality = (index: number, quality: SkillChipQuality) => {
if (!skillChipQualities.includes(quality)) return;
const chip = draft.chips[index];
chip.quality = quality;
const primaryOption = data.skillChipProfile.primaryOptions.find((option: any) => option.statId === chip.primary.statId && validChipRange(option.ranges?.[quality]))
?? data.skillChipProfile.primaryOptions.find((option: any) => validChipRange(option.ranges?.[quality]));
if (primaryOption) {
const range = primaryOption.ranges[quality];
chip.primary = {
statId: primaryOption.statId,
value: clamp(chip.primary.statId === primaryOption.statId ? chip.primary.value : range.min, range.min, range.max)
};
} else chip.primary = blankRoll();
const capacity = skillChipSecondaryCapacity[quality];
const next: Roll[] = [];
for (const roll of chip.secondary) {
if (next.length >= capacity || next.some((entry) => entry.statId === roll.statId)) continue;
const range = chipRange('secondary', roll.statId, quality);
if (!validChipRange(range)) continue;
next.push({ statId: roll.statId, value: clamp(roll.value, range.min, range.max) });
}
for (const option of data.skillChipProfile.secondaryOptions) {
if (next.length >= capacity) break;
if (next.some((entry) => entry.statId === option.statId)) continue;
const range = option.ranges?.[quality];
if (validChipRange(range)) next.push({ statId: option.statId, value: Number(range.min) });
}
while (next.length < capacity) next.push(blankRoll());
chip.secondary = next;
};
const changeWeaponTier = (index: number, tier: number) => {
const selection = draft.weapons[index], item = weaponItem(index);
@@ -297,7 +360,8 @@
});
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
const ability = selectedFocus?.abilities[index];
const configuredChips = draft.chips.slice(index * 3, index * 3 + 3).filter((chip) => chip.setId);
const abilityChips = draft.chips.slice(index * 3, index * 3 + 3);
const configuredChips = abilityChips.filter((chip) => chip.setId);
const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean);
return {
id: `ability:${index}`,
@@ -306,7 +370,7 @@
meta: ability?.attack?.damage_type ?? '',
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
iconUrl: ability?.iconUrl,
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : undefined
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : abilityChips.some((chip) => !chipSelectionConfigured(chip)) ? 'CONFIG REQUIRED' : undefined
};
}));
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
@@ -357,14 +421,15 @@
&& selectedFocus.abilities.length === 3
&& draft.weapons.every((item) => item.itemId)
&& gearSlots.every((slot) => draft.gear[slot].itemId)
&& draft.chips.every((item) => item.setId)
&& draft.chips.every(chipSelectionConfigured)
&& draft.protocol.itemId
));
onMount(() => {
if (data.initialBuild) return;
localStorage.removeItem('shd-build-loadout-draft-v3');
const stored = localStorage.getItem('shd-build-loadout-draft-v4');
localStorage.removeItem('shd-build-loadout-draft-v4');
if (data.initialBuild) return;
const stored = localStorage.getItem('shd-build-loadout-draft-v5');
if (!stored) return;
try {
const parsed = parseLoadoutDraft(JSON.parse(stored));
@@ -372,9 +437,9 @@
draft = parsed;
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true;
} else localStorage.removeItem('shd-build-loadout-draft-v4');
} else localStorage.removeItem('shd-build-loadout-draft-v5');
} catch {
localStorage.removeItem('shd-build-loadout-draft-v4');
localStorage.removeItem('shd-build-loadout-draft-v5');
}
});
$effect(() => {
@@ -392,7 +457,7 @@
: 'Build published to the SHD archive.';
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft));
localStorage.setItem('shd-build-loadout-draft-v5', JSON.stringify(draft));
saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.');
}
@@ -548,7 +613,27 @@
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← RETURN TO {ability?.name?.toUpperCase() ?? 'ABILITY'}</button>
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if}
{#if item}<div class="builder-field-grid"><label>QUALITY<select bind:value={chip.quality}><option>Superior</option><option>High-end</option></select></label></div><div class="builder-chip-rolls"><div><BuilderPicker items={chipAttributes.filter((entry) => item.primaryStatIds.includes(entry.id))} bind:value={chip.primary.statId} onchange={(id) => setRollStat(chip.primary,id,'chip')} label="PRIMARY ATTRIBUTE"/><label>VALUE<input type="number" min={attribute(chip.primary.statId)?.skill_chip_min ?? 0} max={attribute(chip.primary.statId)?.skill_chip_max ?? 0} bind:value={chip.primary.value} onblur={() => chip.primary.value = clamp(chip.primary.value,attribute(chip.primary.statId)?.skill_chip_min,attribute(chip.primary.statId)?.skill_chip_max)}/></label></div>{#each chip.secondary as roll,index}<div><BuilderPicker items={chipAttributes.filter((entry) => item.secondaryStatIds.includes(entry.id))} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'chip')} disabledIds={chip.secondary.filter((_,i) => i !== index).map((entry) => entry.statId)} label={`SECONDARY ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" min={attribute(roll.statId)?.skill_chip_min ?? 0} max={attribute(roll.statId)?.skill_chip_max ?? 0} bind:value={roll.value} onblur={() => roll.value = clamp(roll.value,attribute(roll.statId)?.skill_chip_min,attribute(roll.statId)?.skill_chip_max)}/></label></div>{/each}</div>{/if}
{#if !chipProfileConfigured}<p class="error">SHARED SKILL CHIP PROFILE · CONFIG REQUIRED. Complete every global quality range in ISAC before this loadout can be complete.</p>{/if}
{#if item}
<div class="builder-field-grid">
<SegmentedChoice legend="QUALITY" name={`chip-${index}-quality`} options={skillChipQualities.map((quality) => ({ value: quality, label: quality.toUpperCase() }))} value={chip.quality} onchange={(values) => changeChipQuality(index, values[0] as SkillChipQuality)} required/>
</div>
<div class="builder-section-title"><div><span>ABILITY-SCOPED PRIMARY</span><small>{ability?.name?.toUpperCase() ?? 'ASSIGNED ABILITY'} ONLY · EXCLUDED FROM KNOWN AGENT STATS</small></div><b>NOT YET DEFINED</b></div>
<p class="builder-scaling-note">The primary roll belongs to this ability. Its calculation application is NOT YET DEFINED, so it does not contribute to current Agent totals.</p>
<div class="builder-chip-rolls">
<div>
<BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY ATTRIBUTE"/>
{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE · CONFIG REQUIRED</p>{/if}
</div>
{#each chip.secondary as roll, secondaryIndex}
<div>
<BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ATTRIBUTE ${secondaryIndex + 1}`}/>
{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE · CONFIG REQUIRED</p>{/if}
</div>
{/each}
</div>
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">STANDARD QUALITY · NO SECONDARY ATTRIBUTES</p>{/if}
{/if}
{:else}
{@const protocol = protocolItem()}
<div class="loadout-editor-head"><div><span>AGENT SYSTEM</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
+186
View File
@@ -0,0 +1,186 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createDraft, type LoadoutDraft } from '$lib/builder/loadout';
const mocks = vi.hoisted(() => ({
insertBuild: vi.fn(),
getSessionUser: vi.fn(async () => ({
id: 'agent-1',
displayName: 'Test Agent',
})),
database: {
prepare: vi.fn(),
},
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: mocks.getSessionUser,
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
import { actions } from './+page.server';
const primaryStatId = 'skill-duration';
const secondaryStatIds = ['critical-hit-chance', 'critical-hit-damage'];
const validLoadout = (): LoadoutDraft => {
const loadout = createDraft();
loadout.buildName = 'Validated Skill Chips';
loadout.specialization.focusId = 'focus-1';
loadout.chips[0] = {
setId: 'chip-set-1',
quality: 'Superior',
primary: { statId: primaryStatId, value: 15 },
secondary: [
{ statId: secondaryStatIds[0], value: 2 },
{ statId: secondaryStatIds[1], value: 4 },
],
};
return loadout;
};
const saveRequest = (loadout: LoadoutDraft) => {
const form = new FormData();
form.set('loadout', JSON.stringify(loadout));
form.set('visibility', 'private');
return new Request('http://localhost/builder?/save', {
method: 'POST',
body: form,
});
};
const runSave = (loadout: LoadoutDraft) =>
actions.save?.({
cookies: {} as never,
request: saveRequest(loadout),
} as never);
describe('Builder Skill Chip save validation', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_sets'))
return [
{
id: 'chip-set-1',
specialization_ability_id: 'ability-1',
},
];
if (sql.includes('FROM specialization_focus_abilities'))
return [{ ability_id: 'ability-1', ability_slot: 1 }];
if (sql.includes('FROM skill_chip_primary_options'))
return [{ stat_attribute_id: primaryStatId }];
if (sql.includes('FROM skill_chip_primary_quality_values'))
return [
{
stat_attribute_id: primaryStatId,
quality: 'Superior',
min_value: 10,
max_value: 20,
},
];
if (sql.includes('FROM skill_chip_secondary_options'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
}));
if (sql.includes('FROM skill_chip_secondary_quality_values'))
return secondaryStatIds.map((stat_attribute_id) => ({
stat_attribute_id,
quality: 'Superior',
min_value: 1,
max_value: 5,
}));
return [];
}),
run: mocks.insertBuild,
}));
});
it('rejects a selected Skill Chip assigned to a different focus ability', async () => {
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_sets'))
return [
{
id: 'chip-set-1',
specialization_ability_id: 'ability-2',
},
];
if (sql.includes('FROM specialization_focus_abilities'))
return [{ ability_id: 'ability-1', ability_slot: 1 }];
return [];
}),
run: mocks.insertBuild,
}));
const result = await runSave(validLoadout());
expect(result).toMatchObject({
status: 400,
data: {
buildError: 'Skill Chip 1 is not compatible with its assigned ability.',
},
});
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it.each([
{
label: 'primary',
mutate: (loadout: LoadoutDraft) => {
loadout.chips[0].primary.value = 21;
},
message: 'Skill Chip 1 has an invalid primary Attribute value.',
},
{
label: 'secondary',
mutate: (loadout: LoadoutDraft) => {
loadout.chips[0].secondary[1].value = 6;
},
message: 'Skill Chip 1 has an invalid secondary Attribute value.',
},
])('rejects an out-of-range $label value', async ({ mutate, message }) => {
const loadout = validLoadout();
mutate(loadout);
const result = await runSave(loadout);
expect(result).toMatchObject({
status: 400,
data: { buildError: message },
});
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('rejects duplicate secondary Attributes', async () => {
const loadout = validLoadout();
loadout.chips[0].secondary[1].statId = secondaryStatIds[0];
const result = await runSave(loadout);
expect(result).toMatchObject({
status: 400,
data: {
buildError: 'Skill Chip 1 must use distinct secondary Attributes.',
},
});
expect(mocks.insertBuild).not.toHaveBeenCalled();
});
it('accepts primary and secondary values within the configured profiles', async () => {
const result = await runSave(validLoadout());
expect(result).toMatchObject({
buildSaved: true,
buildToken: expect.any(String),
savedBuild: {
visibility: 'private',
title: 'Validated Skill Chips',
},
});
expect(mocks.insertBuild).toHaveBeenCalledOnce();
});
});
+1 -1
View File
@@ -64,7 +64,7 @@
</section>
<section>
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span>{#if item}<em>{rollLine([selection.primary, ...selection.secondary]) || 'ATTRIBUTES NOT CONFIGURED'}</em>{/if}</article>{/each}
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span>{#if item}<em>ABILITY-SCOPED PRIMARY · {rollLine([selection.primary]) || 'NOT CONFIGURED'} · NOT YET DEFINED{selection.secondary.length ? ` · SECONDARY · ${rollLine(selection.secondary)}` : ''}</em>{/if}</article>{/each}
{#if protocol(data.loadout.protocol.itemId)}{@const protocolItem=protocol(data.loadout.protocol.itemId)}<article><small>OS PROTOCOL</small><strong>{protocolItem.name}</strong><span>LEVEL {data.loadout.protocol.level} · {data.loadout.protocol.talentUpgrades}/5 UPGRADES</span></article>{:else}<article><small>OS PROTOCOL</small><strong>EMPTY SLOT</strong><span>NOT CONFIGURED</span></article>{/if}
</section>
</div>
+94 -34
View File
@@ -40,6 +40,10 @@ import { recordIsacAudit } from '$lib/server/audit';
import { removePublicComment } from '$lib/server/comment-removal';
import { createNotification } from '$lib/server/notifications';
import type { Actions } from './$types';
import {
publicWeaponModSlots,
weaponModSlotDefinitionByName,
} from '$lib/weapons/mod-slots';
const number = (value: unknown) => {
const numeric = Number(value);
@@ -336,8 +340,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
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_category_attribute_bonuses bonuses WHERE bonuses.weapon_category=item.category) AS bonus_count
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count
FROM weapons item
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier
LEFT JOIN managed_images render ON render.source_entity_type='weapon_render' AND render.source_entity_id=item.id
@@ -386,7 +389,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
rowsSql = `SELECT item.*,weapons.name AS exotic_weapon,
render.id AS render_id,render.updated_at AS render_updated_at,
GROUP_CONCAT(DISTINCT categories.weapon_category) AS categories,
(SELECT COUNT(*) FROM weapon_mod_secondary_options secondary WHERE secondary.weapon_mod_id=item.id) AS secondary_count,
(SELECT COUNT(*) FROM weapon_mod_secondary_options) AS secondary_count,
(SELECT COUNT(*) FROM weapon_mod_effects effects WHERE effects.weapon_mod_id=item.id) AS effect_count
FROM weapon_mods item
LEFT JOIN weapons ON weapons.id=item.exotic_weapon_id
@@ -398,7 +401,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
case 'gear-mods':
countSql = `SELECT COUNT(*) AS total FROM gear_mods item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
(SELECT COUNT(*) FROM gear_mod_secondary_options secondary WHERE secondary.gear_mod_id=item.id) AS secondary_count,
(SELECT COUNT(*) FROM gear_mod_secondary_options) AS secondary_count,
(SELECT MIN(primary_level_1_min) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_1_floor,
(SELECT MAX(primary_level_20_max) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_20_ceiling
FROM gear_mods item
@@ -409,7 +412,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
case 'os-protocols':
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit,
primary_effect.base_value,talents.name AS talent_name,icons.updated_at AS icon_updated_at,
primary_effect.base_value,talents.name AS talent_name,talents.description_template AS talent_description,icons.updated_at AS icon_updated_at,
render.id AS render_id,render.updated_at AS render_updated_at
FROM os_protocols item
LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id
@@ -500,8 +503,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
talents.name AS talent_name,abilities.name AS ability_name,specializations.name AS specialization_name,
(SELECT COUNT(*) FROM skill_chip_primary_options options WHERE options.skill_chip_set_id=item.id) AS primary_count,
(SELECT COUNT(*) FROM skill_chip_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count,
(SELECT COUNT(*) FROM skill_chip_primary_options) AS primary_count,
(SELECT COUNT(*) FROM skill_chip_secondary_options) AS secondary_count,
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
FROM skill_chip_sets item
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
@@ -549,6 +552,17 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
const rows = await (
await database.prepare(rowsSql)
).all(...queryArgs, pagination.perPage, pagination.offset);
const protocolTalentEffects =
category.slug === 'os-protocols' && rows.length
? await (
await database.prepare(`SELECT talents.protocol_id,effects.base_value,attributes.unit
FROM os_protocol_talents talents
JOIN os_protocol_talent_effects effects ON effects.talent_id=talents.id
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
WHERE talents.protocol_id IN (${rows.map(() => '?').join(',')})
ORDER BY talents.protocol_id,effects.sort_order`)
).all(...rows.map((row) => row.id))
: [];
const weaponRelations =
category.slug === 'weapons' && rows.length
? await (
@@ -563,6 +577,22 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
ORDER BY options.talent_slot,talents.name COLLATE NOCASE`)
).all(...rows.map((row) => row.id))
: [];
const weaponModSlotRows =
category.slug === 'weapons' && rows.length
? await (
await database.prepare(`SELECT weapon_id,tier,slot_type FROM weapon_mod_slots
WHERE weapon_id IN (${rows.map(() => '?').join(',')})
ORDER BY weapon_id,tier,slot_type`)
).all(...rows.map((row) => row.id))
: [];
const weaponModSlotIconRows =
category.slug === 'weapons'
? await (
await database.prepare(
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='weapon_mod_slot'",
)
).all()
: [];
const gearRelations =
category.slug === 'gear' && rows.length
? await (
@@ -769,6 +799,36 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
const relatedTalents = weaponRelations.filter(
(relation) => relation.weapon_id === row.id,
);
const modSlots = publicWeaponModSlots(
Number(row.native_tier),
[0, 1, 2].map((tier) => ({
tier,
slots: weaponModSlotRows
.filter(
(slot) =>
slot.weapon_id === row.id && Number(slot.tier) === tier,
)
.map((slot) => String(slot.slot_type)),
})),
).map((slot) => {
const definition = weaponModSlotDefinitionByName(slot.slot);
const icon = definition
? weaponModSlotIconRows.find(
(row) => row.entity_id === definition.id,
)
: null;
return {
...slot,
iconUrl:
definition && icon
? gameDataIconUrl(
'weapon_mod_slot',
definition.id,
icon.updated_at,
)
: '',
};
});
const talentItems = (slot: number) =>
relatedTalents
.filter((relation) => Number(relation.talent_slot) === slot)
@@ -801,11 +861,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
? managedImageUrl(String(row.render_id), row.render_updated_at)
: '',
gameIcon: weaponIcon(row.category),
headerBadge: `T${row.native_tier}`,
headerBadges: [
`T${row.native_tier}`,
...(Number(row.is_exotic) ? ['EXOTIC'] : []),
],
headerMetaIcon: weaponIcon(row.damage_type),
badges: Number(row.is_exotic)
? [{ label: 'EXOTIC', tone: 'exotic' }]
: [],
factLayout: 'stack' as const,
facts: [
{
@@ -831,7 +891,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
],
},
],
lines: [{ label: 'BASE BONUSES', value: String(row.bonus_count) }],
weaponModSlots: modSlots,
iconGroups: [
{
label: 'TALENT 1 POOL',
@@ -914,28 +974,28 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
? managedImageUrl(String(row.render_id), row.render_updated_at)
: '',
iconUrl: iconUrl('os_protocol', row.id, row.icon_updated_at),
badges: [
{
label: String(row.quality),
tone: String(row.quality).toLowerCase().replace('-', ''),
},
],
facts: [
{ label: 'LEVELS', value: '01–50' },
{
label: 'PRIMARY EFFECT',
value: row.primary_stat
? `${row.primary_stat} · ${valueWithUnit(row.base_value, row.primary_unit)}`
: 'NOT CONFIGURED',
},
{ label: 'TALENT UPGRADES', value: '0–5' },
],
lines: [
{
label: 'UNIQUE TALENT',
value: list(row.talent_name, 'NOT CONFIGURED'),
},
],
topBorderTone: String(row.quality).toLowerCase().replace('-', ''),
headerBadge: String(row.quality).toUpperCase(),
headerBadgeTone: String(row.quality).toLowerCase().replace('-', ''),
factLayout: 'stack' as const,
facts: [],
protocolPreview: {
primaryStat: String(row.primary_stat ?? 'NOT CONFIGURED'),
primaryUnit: String(row.primary_unit ?? ''),
primaryBaseValue: Number(row.base_value),
talentDescription: row.talent_description
? String(row.talent_description)
: undefined,
talentEffects: protocolTalentEffects
.filter((effect) => effect.protocol_id === row.id)
.map((effect) => ({
baseValue: Number(effect.base_value),
unit: String(effect.unit ?? ''),
})),
},
description: row.talent_description
? String(row.talent_description)
: undefined,
};
case 'talents':
return talentCardItem(row, mechanicsFor(row.id));
+6 -15
View File
@@ -34,8 +34,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'gearMax',
'weaponMin',
'weaponMax',
'chipMin',
'chipMax',
'capValue',
],
},
@@ -47,8 +45,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
gear_max: stat.gearMax || null,
weapon_min: stat.weaponMin || null,
weapon_max: stat.weaponMax || null,
skill_chip_min: stat.chipMin || null,
skill_chip_max: stat.chipMax || null,
cap_value: stat.capValue || null,
}));
const summary = await (
@@ -211,14 +207,9 @@ export const actions: Actions = {
let values: Array<number | null>;
try {
values = [
...[
'gearMin',
'gearMax',
'weaponMin',
'weaponMax',
'chipMin',
'chipMax',
].map((key) => number(key)),
...['gearMin', 'gearMax', 'weaponMin', 'weaponMax'].map((key) =>
number(key),
),
number('capValue', false),
];
} catch {
@@ -226,7 +217,7 @@ export const actions: Actions = {
error: 'Ranges must be numbers. Optional caps cannot be negative.',
});
}
for (let index = 0; index < 6; index += 2)
for (let index = 0; index < 4; index += 2)
if (
(values[index] === null) !== (values[index + 1] === null) ||
(values[index] !== null &&
@@ -253,7 +244,7 @@ export const actions: Actions = {
if (id)
await (
await database.prepare(
`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`,
`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,cap_value=?,updated_at=? WHERE id=?`,
)
).run(
name,
@@ -269,7 +260,7 @@ export const actions: Actions = {
else
await (
await database.prepare(
`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
)
).run(
statId,
+2 -2
View File
@@ -14,7 +14,7 @@
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value }));
const unitOptions = [{value:'',label:'None'},{value:'%',label:'%'},{value:'s',label:'s'},{value:'Shots',label:'Shots'},{value:'m',label:'m'}];
const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',cap_value:''});
const value = (input: unknown) => input ?? '';
const attributeMeta = (stat: any) => stat.pending
? `PROPOSED BY ${stat.proposerName}`
@@ -32,7 +32,7 @@
</script>
{#snippet attributeEditor()}
{#if editing}
<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>
<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>
{/if}
{/snippet}
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>
+1 -3
View File
@@ -241,7 +241,7 @@ export const actions: Actions = {
});
await (
await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`)
weapon_min=?,weapon_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`)
).run(
before.name,
before.description ?? null,
@@ -253,8 +253,6 @@ export const actions: Actions = {
before.gear_max,
before.weapon_min,
before.weapon_max,
before.skill_chip_min,
before.skill_chip_max,
before.cap_value,
before.active,
new Date().toISOString(),
+35 -20
View File
@@ -12,6 +12,12 @@ import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types';
import { createNotification } from '$lib/server/notifications';
import { conditionalRuleSnapshot } from '$lib/server/conditional-effects';
import {
getSkillChipPrimaryProfiles,
getSkillChipSecondaryPool,
} from '$lib/server/skill-chip-profiles';
import { gameDataIconMetadata } from '$lib/server/game-data-icons';
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
const statuses = ['pending', 'approved', 'rejected', 'failed'];
const modules = [
@@ -174,16 +180,6 @@ async function skillChipProposalSnapshot(database: any, id: string) {
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get(id)) ?? null,
primaryOptions: await (
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
)
).all(id),
secondaryOptions: await (
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
)
).all(id),
talent: talent ?? null,
talentEffects: talent
? await (
@@ -317,16 +313,35 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
? ((await (
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
).get(String(proposal.entity_id))) ?? null)
: proposal.module === 'skillchips' && proposal.entity_id
? await skillChipProposalSnapshot(
database,
String(proposal.entity_id),
)
: proposal.module === 'calculations'
? await calculationProposalSnapshot(database, proposal)
: proposal.module === 'specializations'
? await specializationProposalSnapshot(database, proposal)
: null,
: proposal.module === 'skillchips' &&
proposal.action === 'savePrimaryProfile'
? await getSkillChipPrimaryProfiles(database)
: proposal.module === 'skillchips' &&
proposal.action === 'saveSecondaryPool'
? await getSkillChipSecondaryPool(database)
: proposal.module === 'weaponmods' &&
proposal.action === 'saveSlotIcon' &&
proposal.entity_id
? {
slot: weaponModSlotDefinitionById(
String(proposal.entity_id),
),
icon: await gameDataIconMetadata(
database,
'weapon_mod_slot',
String(proposal.entity_id),
),
}
: proposal.module === 'skillchips' && proposal.entity_id
? await skillChipProposalSnapshot(
database,
String(proposal.entity_id),
)
: proposal.module === 'calculations'
? await calculationProposalSnapshot(database, proposal)
: proposal.module === 'specializations'
? await specializationProposalSnapshot(database, proposal)
: null,
})),
);
const summaryRows = await (
+90 -126
View File
@@ -21,8 +21,16 @@ import {
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import {
MOD_QUALITIES,
normalizeModSecondaryPool,
} from '$lib/mods/secondary-pool';
import {
getModSecondaryPool,
replaceModSecondaryPool,
} from '$lib/server/mod-secondary-pools';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const;
const qualities = MOD_QUALITIES;
type QualityValue = {
quality: string;
primaryLevel1Min: number;
@@ -30,11 +38,6 @@ type QualityValue = {
primaryLevel20Min: number;
primaryLevel20Max: number;
};
type SecondaryRange = {
statId: string;
values: Record<string, { min: number; max: number }>;
};
function parseJson<T>(value: FormDataEntryValue | null, fallback: T): T {
try {
return JSON.parse(String(value ?? '')) as T;
@@ -52,16 +55,6 @@ async function snapshot(id: string) {
'SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality',
)
).all(id),
secondaryOptions: await (
await database.prepare(
'SELECT stat_attribute_id FROM gear_mod_secondary_options WHERE gear_mod_id=? ORDER BY stat_attribute_id',
)
).all(id),
secondaryQualityValues: await (
await database.prepare(
'SELECT * FROM gear_mod_secondary_quality_values WHERE gear_mod_id=? ORDER BY stat_attribute_id,quality',
)
).all(id),
render:
(await (
await database.prepare(
@@ -87,10 +80,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
asc: 'name COLLATE NOCASE ASC,id ASC',
desc: 'name COLLATE NOCASE DESC,id DESC',
},
secondaries: {
asc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) ASC,name COLLATE NOCASE ASC',
desc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) DESC,name COLLATE NOCASE ASC',
},
state: {
asc: 'active ASC,name COLLATE NOCASE ASC',
desc: 'active DESC,name COLLATE NOCASE ASC',
@@ -140,7 +129,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
module: 'gearmods',
action: 'save',
kind: 'gearmod',
fields: ['qualityValues', 'secondaryRanges'],
fields: ['qualityValues'],
},
]),
]);
@@ -161,38 +150,29 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const [mods, qualityRows, secondaryOptions, secondaryValues, stats, renders] =
await Promise.all([
databaseLimit
? (
await database.prepare(`SELECT * FROM gear_mods ${where}
const [mods, qualityRows, stats, renders, secondaryPool] = await Promise.all([
databaseLimit
? (
await database.prepare(`SELECT * FROM gear_mods ${where}
ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)
).all(...args, databaseLimit, databaseOffset)
: [],
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
(
await database.prepare('SELECT * FROM gear_mod_secondary_options')
).all(),
(
await database.prepare(
'SELECT * FROM gear_mod_secondary_quality_values',
)
).all(),
(
await database.prepare(
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'",
)
).all(),
]);
).all(...args, databaseLimit, databaseOffset)
: [],
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
(
await database.prepare(
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'",
)
).all(),
getModSecondaryPool(database, 'gear'),
]);
const parsedPending = pendingOnPage.map((item) => ({
...item,
qualityValues: parseJson(String(item.qualityValues), []),
secondaryRanges: parseJson(String(item.secondaryRanges), []),
}));
return {
mods: [
@@ -208,23 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
primaryLevel20Min: Number(row.primary_level_20_min),
primaryLevel20Max: Number(row.primary_level_20_max),
})),
secondaryRanges: secondaryOptions
.filter((option) => option.gear_mod_id === mod.id)
.map((option) => ({
statId: String(option.stat_attribute_id),
values: Object.fromEntries(
secondaryValues
.filter(
(row) =>
row.gear_mod_id === mod.id &&
row.stat_attribute_id === option.stat_attribute_id,
)
.map((row) => [
String(row.quality),
{ min: Number(row.min_value), max: Number(row.max_value) },
]),
),
})),
renderUrl: (() => {
const render = renders.find(
(item) => item.source_entity_id === mod.id,
@@ -235,6 +198,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
})(),
})),
],
secondaryPool,
stats: [...pendingStats, ...stats],
qualities,
summary: {
@@ -251,6 +215,60 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
};
export const actions: Actions = {
saveSecondaryPool: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const form = await request.formData();
if (String(form.get('id') ?? '') !== 'gear-mod-secondary-pool') {
return fail(400, {
gearModSecondaryPoolError:
'The shared Gear Mod pool target is invalid.',
});
}
const normalized = normalizeModSecondaryPool(
parseJson<unknown>(form.get('secondaryRanges'), null),
);
if (normalized.error) {
return fail(400, { gearModSecondaryPoolError: normalized.error });
}
const database = await getDatabase();
const statIds = normalized.pool.map((range) => range.statId);
const validStats = await (
await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)
).get(...statIds);
if (Number(validStats?.count) !== statIds.length) {
return fail(400, {
gearModSecondaryPoolError:
'One or more selected Attributes are unavailable.',
});
}
await database.exec('BEGIN IMMEDIATE');
try {
const before = await getModSecondaryPool(database, 'gear');
await replaceModSecondaryPool(database, 'gear', normalized.pool);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'gear_mod_secondary_pool.updated',
entityId: 'gear-mod-secondary-pool',
before,
after: await getModSecondaryPool(database, 'gear'),
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return {
gearModSecondaryPoolSaved: true,
gearModSecondaryPoolMode: 'edit',
resultToken: randomUUID(),
};
},
save: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
@@ -275,14 +293,16 @@ export const actions: Actions = {
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
if (form.has('secondaryRanges')) {
return fail(400, {
gearModError:
'Secondary Attributes must be submitted through the shared pool editor.',
});
}
const qualityValues = parseJson<QualityValue[]>(
form.get('qualityValues'),
[],
);
const secondaryRanges = parseJson<SecondaryRange[]>(
form.get('secondaryRanges'),
[],
);
if (name.length < 2 || name.length > 100)
return fail(400, {
gearModError: 'Gear Mod names must be 2–100 characters.',
@@ -311,30 +331,6 @@ export const actions: Actions = {
'Configure valid primary Level 1 and Level 20 ranges for all four qualities.',
});
}
if (
secondaryRanges.length < 2 ||
secondaryRanges.length > 50 ||
new Set(secondaryRanges.map((range) => range.statId)).size !==
secondaryRanges.length ||
secondaryRanges.some(
(range) =>
!range.statId ||
qualities.some((quality) => {
const values = range.values?.[quality];
return (
!values ||
!Number.isFinite(Number(values.min)) ||
!Number.isFinite(Number(values.max)) ||
Number(values.max) < Number(values.min)
);
}),
)
) {
return fail(400, {
gearModError:
'Configure at least two unique possible secondary attributes with valid ranges for every quality.',
});
}
const database = await getDatabase();
if (
await (
@@ -347,15 +343,6 @@ export const actions: Actions = {
gearModError: 'That Gear Mod name is already registered.',
});
}
const statIds = secondaryRanges.map((range) => range.statId);
const validStats = await (
await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)
).get(...statIds);
if (Number(validStats?.count) !== statIds.length)
return fail(400, {
gearModError: 'One or more selected attributes are unavailable.',
});
const modId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await snapshot(id) : null;
@@ -394,29 +381,6 @@ export const actions: Actions = {
row.primaryLevel20Min,
row.primaryLevel20Max,
);
await (
await database.prepare(
'DELETE FROM gear_mod_secondary_options WHERE gear_mod_id=?',
)
).run(modId);
for (const range of secondaryRanges) {
await (
await database.prepare(
'INSERT INTO gear_mod_secondary_options(gear_mod_id,stat_attribute_id) VALUES(?,?)',
)
).run(modId, range.statId);
for (const quality of qualities)
await (
await database.prepare(`INSERT INTO gear_mod_secondary_quality_values
(gear_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`)
).run(
modId,
range.statId,
quality,
range.values[quality].min,
range.values[quality].max,
);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
+8 -46
View File
@@ -5,26 +5,21 @@
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
import ModSecondaryPoolEditor from '$lib/components/ModSecondaryPoolEditor.svelte';
let { data, form } = $props();
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const qualityRow = (quality: string) => ({
quality, primaryLevel1Min: 0, primaryLevel1Max: 0, primaryLevel20Min: 0, primaryLevel20Max: 0
});
const blankSecondary = () => ({
statId: '',
values: Object.fromEntries(data.qualities.map((quality: string) => [quality, { min: 0, max: 0 }]))
});
const blank = () => ({
id: '',
name: '',
active: 1,
renderUrl: '',
qualityValues: data.qualities.map(qualityRow),
secondaryRanges: [blankSecondary(), blankSecondary()]
qualityValues: data.qualities.map(qualityRow)
});
let editing = $state<any | null>(null);
let levelPreview = $state(1);
@@ -32,7 +27,6 @@
let handledResultToken = $state('');
let renderResetToken = $state(0);
let openedRequestedItem = $state(false);
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview));
const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19;
const display = (value: unknown) => Number.isFinite(Number(value))
@@ -44,22 +38,11 @@
qualityValues: data.qualities.map((quality: string) => ({
...qualityRow(quality),
...(item.qualityValues.find((row: any) => row.quality === quality) ?? {})
})),
secondaryRanges: item.secondaryRanges.length
? item.secondaryRanges.map((range: any) => ({ statId: range.statId, values: structuredClone(range.values) }))
: [blankSecondary(), blankSecondary()]
}))
};
levelPreview = 1;
qualityPreview = 'High-end';
};
const addSecondary = () => {
if (editing.secondaryRanges.length < 50) editing.secondaryRanges = [...editing.secondaryRanges, blankSecondary()];
};
const removeSecondary = (index: number) => {
if (editing.secondaryRanges.length > 2) {
editing.secondaryRanges = editing.secondaryRanges.filter((_: any, itemIndex: number) => itemIndex !== index);
}
};
$effect(() => {
if (!openedRequestedItem && data.requestedItemId) {
const requested = data.mods.find((item: any) => item.id === data.requestedItemId);
@@ -87,6 +70,8 @@
<button class="primary" onclick={() => editing = blank()}>ADD GEAR MOD <span>+</span></button>
</div>
<ModSecondaryPoolEditor kind="Gear Mod" pool={data.secondaryPool} stats={data.stats} error={form?.gearModSecondaryPoolError} saved={form?.gearModSecondaryPoolSaved} resultToken={form?.resultToken}/>
<KpiFilters active={data.filter} items={[
{value:'',label:'TOTAL MODS',count:data.summary.total,description:'Show all Gear Mod definitions'},
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to the builder'},
@@ -103,7 +88,6 @@
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
<input type="hidden" name="secondaryRanges" value={JSON.stringify(editing.secondaryRanges)}/>
<div class="gear-mod-identity">
<label class="field">GEAR MOD NAME<input name="name" bind:value={editing.name} maxlength="100" required/></label>
<div class="weapon-mod-primary-types gear-mod-primary-types">
@@ -143,34 +127,12 @@
{#if previewRow}
<div class="weapon-mod-preview-values">
<span><small>SELECTED PRIMARY · LEVEL {levelPreview}</small><b>{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}</b></span>
<span><small>SECONDARY ATTRIBUTE POOL</small><b>{editing.secondaryRanges.length} OPTIONS</b></span>
<span><small>SHARED SECONDARY POOL</small><b>{data.secondaryPool.length} OPTIONS</b></span>
</div>
{/if}
</div>
</section>
<section class="weapon-mod-secondary-section">
<div class="selection-head">
<div><span>SECONDARY ATTRIBUTE POOL</span><small>THE BUILDER SELECTS TWO DISTINCT ATTRIBUTES · EVERY OPTION REQUIRES A RANGE AT EACH QUALITY</small></div>
<button type="button" class="row-action" onclick={addSecondary} disabled={editing.secondaryRanges.length >= 50}>ADD ATTRIBUTE +</button>
</div>
<div class="weapon-mod-secondary-options">
{#each editing.secondaryRanges as range,index}
<div>
<strong>{String(index + 1).padStart(2,'0')}</strong>
<SearchableAssignment items={data.stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={editing.secondaryRanges.filter((_: any,itemIndex: number) => itemIndex !== index).map((item: any) => item.statId)} bind:value={range.statId}/>
{#each data.qualities as quality}
<label>
<span class={qualityClass(quality)}>{quality.toUpperCase()}</span>
<div><input type="number" step="any" bind:value={range.values[quality].min} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].max} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} maximum`} required/></div>
</label>
{/each}
<button type="button" aria-label={`Remove secondary attribute option ${index + 1}`} onclick={() => removeSecondary(index)} disabled={editing.secondaryRanges.length <= 2}>×</button>
</div>
{/each}
</div>
</section>
{#if form?.gearModError}<p class="error">{form.gearModError}</p>{/if}
<div class="form-actions split-actions">
<div>{#if editing.id}<button class="moderation-action danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE GEAR MOD' : 'UNARCHIVE GEAR MOD'}</button>{/if}</div>
@@ -186,12 +148,12 @@
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row gear-mod-row table-heading"><SortableColumnHeader label="GEAR MOD" column="name" sort={data.sort}/><span>PRIMARY</span><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><span>QUALITY</span><span>LEVELS</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
<div class="dashboard-row gear-mod-row table-heading"><SortableColumnHeader label="GEAR MOD" column="name" sort={data.sort}/><span>PRIMARY</span><span>SECONDARIES</span><span>QUALITY</span><span>LEVELS</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.mods as item}
<div class="dashboard-row gear-mod-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}>
<div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}</small></div></div>
<span><span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>5 OPTIONS</span>
<span>{item.secondaryRanges.length} POSSIBLE<small> · SELECT 2</small></span>
<span>{data.secondaryPool.length} SHARED<small> · 0 / 1 / 2 / 2</small></span>
<span>STANDARD → HIGH-END</span>
<span>01 → 20</span>
<b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
audits: [] as Array<Record<string, unknown>>,
database: {
exec: vi.fn(async () => undefined),
prepare: vi.fn(async () => ({
get: vi.fn(async (...statIds: unknown[]) => ({ count: statIds.length })),
})),
},
getModSecondaryPool: vi.fn(),
replaceModSecondaryPool: vi.fn(),
requirePermission: vi.fn(async (user) => user),
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: mocks.requirePermission,
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/server/audit', () => ({
auditRecord: vi.fn(),
recordIsacAudit: vi.fn(async (event) => {
mocks.audits.push(event);
}),
}));
vi.mock('$lib/server/data-moderation', () => ({
getPendingEntities: vi.fn(async () => []),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: vi.fn(),
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(),
}));
vi.mock('$lib/server/mod-secondary-pools', () => ({
getModSecondaryPool: mocks.getModSecondaryPool,
replaceModSecondaryPool: mocks.replaceModSecondaryPool,
}));
import { actions } from './+page.server';
const endpoints = (
level1Min: number,
level1Max: number,
level20Min: number,
level20Max: number,
) => ({ level1Min, level1Max, level20Min, level20Max });
const pool = (suffix: string, offset = 0) => [
{
statId: `critical-hit-chance-${suffix}`,
values: {
Specialized: endpoints(1 + offset, 2 + offset, 3 + offset, 4 + offset),
Superior: endpoints(2 + offset, 3 + offset, 4 + offset, 5 + offset),
'High-end': endpoints(3 + offset, 4 + offset, 5 + offset, 6 + offset),
},
},
{
statId: `critical-hit-damage-${suffix}`,
values: {
Specialized: endpoints(4 + offset, 5 + offset, 6 + offset, 7 + offset),
Superior: endpoints(5 + offset, 6 + offset, 7 + offset, 8 + offset),
'High-end': endpoints(6 + offset, 7 + offset, 8 + offset, 9 + offset),
},
},
];
describe('Gear Mod shared secondary pool action', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.audits.length = 0;
});
it('audits the actor, stable pool target, and complete before/after evidence', async () => {
const before = pool('before');
const after = pool('after', 1);
mocks.getModSecondaryPool
.mockResolvedValueOnce(before)
.mockResolvedValueOnce(after);
const form = new FormData();
form.set('id', 'gear-mod-secondary-pool');
form.set('secondaryRanges', JSON.stringify(after));
const result = await actions.saveSecondaryPool?.({
cookies: {} as never,
request: new Request(
'http://localhost/isac/gearmods?/saveSecondaryPool',
{ method: 'POST', body: form },
),
} as never);
expect(result).toMatchObject({
gearModSecondaryPoolSaved: true,
gearModSecondaryPoolMode: 'edit',
resultToken: expect.any(String),
});
expect(mocks.requirePermission).toHaveBeenCalledWith(
{ id: 'operator-1' },
'data.manage',
);
expect(mocks.replaceModSecondaryPool).toHaveBeenCalledWith(
mocks.database,
'gear',
after,
);
expect(mocks.audits).toEqual([
{
actorId: 'operator-1',
category: 'game_data',
action: 'gear_mod_secondary_pool.updated',
entityId: 'gear-mod-secondary-pool',
before,
after,
database: mocks.database,
},
]);
expect(mocks.database.exec).toHaveBeenNthCalledWith(1, 'BEGIN IMMEDIATE');
expect(mocks.database.exec).toHaveBeenNthCalledWith(2, 'COMMIT');
});
});
@@ -9,7 +9,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
const attributes = await (
await database.prepare(`
SELECT id,name,description,unit,type,category,calculation_type,cap_value,
gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max
gear_min,gear_max,weapon_min,weapon_max
FROM stat_attributes
WHERE active=1
ORDER BY category COLLATE NOCASE,type COLLATE NOCASE,name COLLATE NOCASE
@@ -19,8 +19,6 @@
gear_max?: number | null;
weapon_min?: number | null;
weapon_max?: number | null;
skill_chip_min?: number | null;
skill_chip_max?: number | null;
};
let { data } = $props();
@@ -131,7 +129,6 @@
const sourceRanges = (attribute: AttributeVariable) => [
{ label: 'GEAR', value: valueRange(attribute.gear_min, attribute.gear_max) },
{ label: 'WEAPON', value: valueRange(attribute.weapon_min, attribute.weapon_max) },
{ label: 'SKILL CHIP', value: valueRange(attribute.skill_chip_min, attribute.skill_chip_max) }
].filter((source) => source.value);
const superiorFirepower = [
+237 -167
View File
@@ -35,27 +35,15 @@ import {
saveConditionalRuleInTransaction,
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import {
getOsProtocolProfileAttributeIds,
type OsProtocolAttributeType,
} from '$lib/server/os-protocol-profiles';
import type { Database } from '@tursodatabase/database';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
const secondaryRoles = ['Core', 'Damage', 'Health'];
const expectedSecondaryAttributes: Record<string, Record<string, string>> = {
Firepower: {
Core: 'Firepower',
Damage: 'All Weapon Damage',
Health: 'Health',
},
Toughness: {
Core: 'Toughness',
Damage: 'All Weapon Damage',
Health: 'Health',
},
Engineering: {
Core: 'Engineering',
Damage: 'Skill Damage',
Health: 'Health',
},
};
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
render
@@ -88,15 +76,15 @@ const descriptionOutcomes = (
Number(left.descriptionSlot) - Number(right.descriptionSlot),
);
async function protocolSnapshot(id: string) {
const database = await getDatabase();
async function protocolSnapshot(id: string, suppliedDatabase?: Database) {
const database = suppliedDatabase ?? (await getDatabase());
const talent = await (
await database.prepare(
'SELECT * FROM os_protocol_talents WHERE protocol_id=?',
)
).get(id);
return {
record: await auditRecord('os_protocols', id),
record: await auditRecord('os_protocols', id, database),
icon: await gameDataIconMetadata(database, 'os_protocol', id),
render: renderAuditMetadata(
await (
@@ -129,8 +117,8 @@ async function protocolSnapshot(id: string) {
};
}
async function profileSnapshot(id: string) {
const database = await getDatabase();
async function profileSnapshot(id: string, suppliedDatabase?: Database) {
const database = suppliedDatabase ?? (await getDatabase());
return {
record:
(await (
@@ -292,6 +280,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'attributeType',
'primaryStatId',
'primaryBaseValue',
'hasTalent',
'talentName',
'descriptionTemplate',
'conditionalRule',
@@ -341,17 +330,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
profile.quality === protocol.quality &&
profile.primary_attribute_type === protocol.attributeType,
) ?? null,
talent: {
name: protocol.talentName,
description_template: protocol.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
} catch {
return null;
}
})(),
},
talent:
protocol.hasTalent === '1'
? {
name: protocol.talentName,
description_template: protocol.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
} catch {
return null;
}
})(),
}
: null,
}));
const configuredProtocols = await Promise.all(
protocols.map(async (protocol) => {
@@ -450,27 +442,26 @@ export const actions: Actions = {
profileError: 'Choose a valid quality and attribute type.',
});
const database = await getDatabase();
const expected = expectedSecondaryAttributes[attributeType];
const statRows = await (
await database.prepare(
`SELECT id,name FROM stat_attributes WHERE active=1 AND name IN (?,?,?) COLLATE NOCASE`,
)
).all(expected.Core, expected.Damage, expected.Health);
const statByName = new Map(
statRows.map((stat) => [
String(stat.name).toLowerCase(),
String(stat.id),
]),
const attributeIds = await getOsProtocolProfileAttributeIds(
database,
attributeType as OsProtocolAttributeType,
);
if (statRows.length !== 3)
if (Object.values(attributeIds).some((id) => !id))
return fail(400, {
profileError: `Create active stats named ${Object.values(expected).join(', ')} before configuring this profile.`,
profileError:
'Create the required active core, damage, and Health Attributes before configuring this profile.',
});
const effects = secondaryRoles.map((role) => ({
role,
statId: statByName.get(expected[role].toLowerCase()) ?? '',
value: Number(form.get(`value${role}`)),
}));
const effects = secondaryRoles.map((role) => {
const rawValue = form.get(`value${role}`);
return {
role,
statId: attributeIds[role as keyof typeof attributeIds] ?? '',
value:
typeof rawValue === 'string' && rawValue.trim()
? Number(rawValue)
: Number.NaN,
};
});
if (
effects.some(
(effect) =>
@@ -481,47 +472,58 @@ export const actions: Actions = {
profileError:
'Every shared secondary attribute requires a non-negative value.',
});
const existing = await (
await database.prepare(
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
)
).get(quality, attributeType);
const profileId = String(existing?.id ?? randomUUID()),
now = new Date().toISOString();
const before = existing ? await profileSnapshot(profileId) : null;
if (existing)
await (
await database.prepare(
'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?',
const saveProfile = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const existing = await (
await tx.prepare(
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
)
).run(now, profileId);
else
).get(quality, attributeType);
const profileId = String(existing?.id ?? randomUUID());
const now = new Date().toISOString();
const before = existing ? await profileSnapshot(profileId, tx) : null;
if (existing)
await (
await tx.prepare(
'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?',
)
).run(now, profileId);
else
await (
await tx.prepare(
'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)',
)
).run(profileId, quality, attributeType, now, now);
await (
await database.prepare(
'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)',
await tx.prepare(
'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?',
)
).run(profileId, quality, attributeType, now, now);
await (
await database.prepare(
'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?',
)
).run(profileId);
for (const effect of effects)
await (
await database.prepare(
'INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)',
)
).run(randomUUID(), profileId, effect.role, effect.statId, effect.value);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: existing
? 'os_protocol_profile.updated'
: 'os_protocol_profile.created',
entityId: profileId,
before,
after: await profileSnapshot(profileId),
).run(profileId);
for (const effect of effects)
await (
await tx.prepare(
'INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)',
)
).run(
randomUUID(),
profileId,
effect.role,
effect.statId,
effect.value,
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: existing
? 'os_protocol_profile.updated'
: 'os_protocol_profile.created',
entityId: profileId,
before,
after: await profileSnapshot(profileId, tx),
database: tx,
});
});
await saveProfile.immediate();
return { profileSaved: true, resultToken: randomUUID() };
},
save: async ({ request, cookies }) => {
@@ -559,7 +561,12 @@ export const actions: Actions = {
const quality = String(form.get('quality') ?? '');
const attributeType = String(form.get('attributeType') ?? '');
const primaryStatId = String(form.get('primaryStatId') ?? '');
const primaryBaseValue = Number(form.get('primaryBaseValue'));
const primaryBaseValueRaw = form.get('primaryBaseValue');
const primaryBaseValue =
typeof primaryBaseValueRaw === 'string' && primaryBaseValueRaw.trim()
? Number(primaryBaseValueRaw)
: Number.NaN;
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '')
.normalize('NFKC')
.trim();
@@ -567,22 +574,27 @@ export const actions: Actions = {
.normalize('NFKC')
.trim();
const database = await getDatabase();
let conditionalRule;
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
null;
try {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare('SELECT id FROM stat_attributes WHERE active=1')
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[{ key: 'base', variantKey: 'base' }],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
if (hasTalent) {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1',
)
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[{ key: 'base', variantKey: 'base' }],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
}
} catch (error) {
return fail(400, {
protocolError:
@@ -608,29 +620,39 @@ export const actions: Actions = {
protocolError:
'Choose the protocol’s unique primary stat and a non-negative level-1 base value.',
});
const outcomes = mechanicOutcomes(conditionalRule);
const numberedOutcomes = descriptionOutcomes(conditionalRule);
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
const numberedOutcomes = conditionalRule
? descriptionOutcomes(conditionalRule)
: [];
const durationSeconds =
conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule.pools.length
const maxStacks = conditionalRule?.pools.length
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
const requiresCompleteMechanic =
hasTalent && conditionalRule?.simulationReadiness !== 'captured_only';
if (
talentName.length < 2 ||
talentName.length > 80 ||
!descriptionTemplate ||
!conditionalRule.clauses.length ||
!outcomes.length
hasTalent &&
(talentName.length < 2 || talentName.length > 80 || !descriptionTemplate)
)
return fail(400, {
protocolError:
'The unique talent requires a name, description template, mechanic branch, and at least one outcome.',
'The assigned Talent requires a name and description template.',
});
if (
requiresCompleteMechanic &&
(!conditionalRule?.clauses.length || !outcomes.length)
)
return fail(400, {
protocolError:
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
});
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map(
(match) => Number(match[1]),
);
if (
requiresCompleteMechanic &&
placeholders.some(
(index) =>
!numberedOutcomes.some(
@@ -642,23 +664,49 @@ export const actions: Actions = {
protocolError:
'Description placeholders must reference configured numbered outcomes.',
});
if (descriptionTemplate.includes('{duration}') && durationSeconds === null)
if (
requiresCompleteMechanic &&
descriptionTemplate.includes('{duration}') &&
durationSeconds === null
)
return fail(400, {
protocolError:
'The description uses {duration}, but no expiring state pool is defined.',
});
if (descriptionTemplate.includes('{max_stacks}') && !maxStacks)
if (
requiresCompleteMechanic &&
descriptionTemplate.includes('{max_stacks}') &&
!maxStacks
)
return fail(400, {
protocolError:
'The description uses {max_stacks}, but no state pool maximum is defined.',
});
if (
!(await (
const profileAttributeIds = await getOsProtocolProfileAttributeIds(
database,
attributeType as OsProtocolAttributeType,
);
const completeProfile =
Object.values(profileAttributeIds).every(Boolean) &&
(await (
await database.prepare(
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
`SELECT profile.id FROM os_protocol_secondary_profiles profile
JOIN os_protocol_secondary_effects effect ON effect.profile_id=profile.id
WHERE profile.quality=? AND profile.primary_attribute_type=?
GROUP BY profile.id
HAVING COUNT(effect.id)=3
AND SUM(CASE WHEN effect.effect_role='Core' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Damage' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Health' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1`,
)
).get(quality, attributeType))
)
).get(
quality,
attributeType,
profileAttributeIds.Core,
profileAttributeIds.Damage,
profileAttributeIds.Health,
));
if (!completeProfile)
return fail(400, {
protocolError:
'Configure the shared secondary profile for this quality and attribute type first.',
@@ -679,11 +727,21 @@ export const actions: Actions = {
).get(id)
: null;
if (
await (
existingTalent &&
!hasTalent &&
form.get('confirmTalentRemoval') !== 'REMOVE'
)
return fail(400, {
protocolError:
'Confirm removal of the existing Talent and mechanic program.',
});
if (
hasTalent &&
(await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(talentName, existingTalent?.id ?? 'new')
).get(talentName, existingTalent?.id ?? 'new'))
)
return fail(409, {
protocolError: 'That protocol talent name is already in use.',
@@ -701,71 +759,83 @@ export const actions: Actions = {
return fail(400, {
protocolError: 'One or more selected attributes are unavailable.',
});
const protocolId = id || randomUUID(),
const protocolId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
talentId = String(existingTalent?.id ?? randomUUID()),
now = new Date().toISOString();
const before = id ? await protocolSnapshot(id) : null;
if (id && !before?.record)
return fail(404, { protocolError: 'OS Protocol not found.' });
await database.exec('BEGIN');
try {
const saveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const before = id ? await protocolSnapshot(id, tx) : null;
if (id && !before?.record)
return fail(404, { protocolError: 'OS Protocol not found.' });
if (id) {
await (
await database.prepare(
await tx.prepare(
'UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?',
)
).run(name, quality, attributeType, now, id);
} else
await (
await database.prepare(
await tx.prepare(
'INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)',
)
).run(protocolId, name, quality, attributeType, now, now);
await (
await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
await tx.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)
).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
await (
await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
await (
await database.prepare(
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
)
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
if (hasTalent && conditionalRule) {
await (
await database.prepare(
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)',
await tx.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
await (
await tx.prepare(
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
)
).run(
randomUUID(),
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
await (
await tx.prepare(
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)',
)
).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.base.value,
compatibilitySortOrder,
);
}
await saveConditionalRuleInTransaction(
tx,
'os_protocol_talent',
talentId,
outcome.attributeId,
outcome.variants.base.value,
compatibilitySortOrder,
conditionalRule,
);
} else if (existingTalent) {
await (
await tx.prepare(
"DELETE FROM conditional_effect_rules WHERE source_type='os_protocol_talent' AND source_id=?",
)
).run(String(existingTalent.id));
await (
await tx.prepare('DELETE FROM os_protocol_talents WHERE id=?')
).run(String(existingTalent.id));
}
await saveConditionalRuleInTransaction(
database,
'os_protocol_talent',
talentId,
conditionalRule,
);
await applyIconMutation(
database,
tx,
'os_protocol',
protocolId,
iconMutation,
operator.id,
);
await applyGameDataRender(
database,
tx,
'os_protocol',
protocolId,
name,
@@ -778,13 +848,13 @@ export const actions: Actions = {
action: id ? 'os_protocol.updated' : 'os_protocol.created',
entityId: protocolId,
before,
after: await protocolSnapshot(protocolId),
after: await protocolSnapshot(protocolId, tx),
database: tx,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return null;
});
const transactionResult = await saveProtocol.immediate();
if (transactionResult) return transactionResult;
return {
protocolSaved: true,
protocolMode: id ? 'edit' : 'add',
+36 -18
View File
@@ -29,15 +29,16 @@
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: [{key:'primary',label:'Primary',event:'tick',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'self',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{base:{value:0}}}]}]
clauses: []
});
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
let editing = $state<any | null>(null);
let profileEditing = $state<any | null>(null);
let talentUpgrade = $state(0);
let protocolLevel = $state(1);
let handledResultToken = $state('');
let handledErrorForm = $state<unknown>(null);
let iconResetToken = $state(0);
let renderResetToken = $state(0);
let openedRequestedItem = $state(false);
@@ -86,6 +87,8 @@
editing = {
...protocol,
primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' },
hasTalent: Boolean(protocol.talent),
talentOpen: false,
talent: protocol.talent ? {
...protocol.talent,
conditionalRule: protocol.talent.conditionalRule ?? blankRule()
@@ -115,6 +118,10 @@
talentUpgrade = 0;
protocolLevel = 1;
}
if (form?.protocolError && form !== handledErrorForm) {
handledErrorForm = form;
if (editing?.hasTalent) editing.talentOpen = true;
}
});
</script>
@@ -162,6 +169,7 @@
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="hasTalent" value={editing.hasTalent ? '1' : '0'}/>
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)} />
<div class="protocol-identity-grid">
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="OS PROTOCOL RENDER" resetToken={renderResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
@@ -178,21 +186,31 @@
<div class="protocol-primary-editor"><div class="field"><span>PRIMARY STAT</span><SearchableAssignment items={data.stats} fieldName="primaryStatId" mode="single" compact subjectLabel="PRIMARY ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={editing.primaryEffect.stat_attribute_id}/></div><label class="field">LEVEL 1 BASE VALUE<input name="primaryBaseValue" type="number" step="any" min="0" bind:value={editing.primaryEffect.base_value} required/></label><div><span>LEVEL {protocolLevel} PREVIEW</span><b>{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}</b><small>LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}</small></div></div>
<section class="protocol-talent">
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES FOR MARKED VALUES</small></div><b>{editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
<MechanicProgramEditor
attributes={data.stats}
valueColumns={talentValueColumns}
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
aiContext={{
sourceKind: 'os_protocol_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.description_template ?? ''
}}
allowProtocolUpgradeScaling
bind:rule={editing.talent.conditionalRule}
/>
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OPTIONAL OWNED TALENT · FIVE LINEAR UPGRADES FOR MARKED VALUES</small></div><SegmentedChoice legend="TALENT ASSIGNMENT" name="talent-assignment" options={[{value:'0',label:'NOT ASSIGNED'},{value:'1',label:'ASSIGNED'}]} value={editing.hasTalent ? '1' : '0'} onchange={(values) => { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/></div>
{#if editing.hasTalent}
<details class="modeling-disclosure protocol-talent-disclosure" bind:open={editing.talentOpen}>
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>{previewOutcomes().length} PROTOCOL OUTCOME{previewOutcomes().length === 1 ? '' : 'S'} · {editing.talent.conditionalRule.simulationReadiness === 'captured_only' ? 'CAPTURE ONLY' : 'MODELED'}</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
<div class="modeling-content">
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
<MechanicProgramEditor
attributes={data.stats}
valueColumns={talentValueColumns}
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
aiContext={{
sourceKind: 'os_protocol_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.description_template ?? ''
}}
allowProtocolUpgradeScaling
bind:rule={editing.talent.conditionalRule}
/>
</div>
</details>
{:else}
<p class="console-note">This OS Protocol has no Talent. Its primary and inherited secondary Attributes remain available.</p>
{#if editing.talent.id}<label class="protocol-talent-removal"><input type="checkbox" name="confirmTalentRemoval" value="REMOVE" required/> CONFIRM REMOVAL OF THE EXISTING TALENT AND MECHANIC PROGRAM</label>{/if}
{/if}
</section>
{#if form?.protocolError}<p class="error">{form.protocolError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0}>SAVE PROTOCOL →</button></div></div>
@@ -206,7 +224,7 @@
<div class="dashboard-table protocol-dashboard-table">
<div class="dashboard-row protocol-row table-heading"><SortableColumnHeader label="OS PROTOCOL" column="name" sort={data.sort}/><SortableColumnHeader label="QUALITY" column="quality" sort={data.sort}/><SortableColumnHeader label="TYPE" column="type" sort={data.sort}/><SortableColumnHeader label="UNIQUE PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="UNIQUE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.protocols as protocol}
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> {protocol.talent?.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CONFIG REQUIRED · '}5 UPGRADES</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{#if protocol.talent}<span class="mini-diamonds">◆◆◆◆◆</span> {protocol.talent.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CAPTURE ONLY · '}5 UPGRADES{:else}OPTIONAL{/if}</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
{#if editing?.id === protocol.id}<RegistryInlineEditor id={`protocol-editor-${protocol.id}`} label={`Edit ${protocol.name}`}>{@render protocolEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
</div>
@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => {
const transaction = { prepare: vi.fn() };
const database = {
prepare: vi.fn(),
transactionAsync: vi.fn(),
};
return {
transaction,
database,
profileExists: false,
effects: [] as Array<Record<string, unknown>>,
getAttributeIds: vi.fn(),
recordIsacAudit: vi.fn(),
};
});
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: vi.fn(async (user) => user),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/server/audit', () => ({
auditRecord: vi.fn(),
recordIsacAudit: mocks.recordIsacAudit,
}));
vi.mock('$lib/server/data-moderation', () => ({
getPendingEntities: vi.fn(async () => []),
}));
vi.mock('$lib/server/os-protocol-profiles', () => ({
getOsProtocolProfileAttributeIds: mocks.getAttributeIds,
}));
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: vi.fn(),
gameDataIconMetadata: vi.fn(),
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
readIconMutation: vi.fn(),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: vi.fn(),
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: vi.fn(),
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: vi.fn(),
saveConditionalRuleInTransaction: vi.fn(),
}));
import { actions } from './+page.server';
const request = (includeValues = true) => {
const form = new FormData();
form.set('quality', 'Standard');
form.set('attributeType', 'Firepower');
if (includeValues) {
form.set('valueCore', '10');
form.set('valueDamage', '20');
form.set('valueHealth', '30');
}
return new Request('http://localhost/isac/protocols?/saveProfile', {
method: 'POST',
body: form,
});
};
describe('OS Protocol shared profile action', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.profileExists = false;
mocks.effects = [];
mocks.getAttributeIds.mockResolvedValue({
Core: 'firepower-flat',
Damage: 'weapon-damage-flat',
Health: 'health-flat',
});
mocks.transaction.prepare.mockImplementation(async (sql: string) => ({
get: vi.fn(async () => {
if (sql.includes('WHERE quality=?'))
return mocks.profileExists ? { id: 'profile-1' } : null;
if (sql.includes('WHERE id=?'))
return mocks.profileExists
? {
id: 'profile-1',
quality: 'Standard',
primary_attribute_type: 'Firepower',
}
: null;
return null;
}),
all: vi.fn(async () =>
sql.includes('os_protocol_secondary_effects') ? mocks.effects : [],
),
run: vi.fn(async (...args: unknown[]) => {
if (sql.includes('INSERT INTO os_protocol_secondary_profiles'))
mocks.profileExists = true;
if (sql.includes('DELETE FROM os_protocol_secondary_effects'))
mocks.effects = [];
if (sql.includes('INSERT INTO os_protocol_secondary_effects'))
mocks.effects.push({
profile_id: 'profile-1',
effect_role: args[2],
stat_attribute_id: args[3],
value: args[4],
});
}),
}));
mocks.database.transactionAsync.mockImplementation(
(callback: (transaction: typeof mocks.transaction) => Promise<void>) => {
const run = vi.fn(() => callback(mocks.transaction));
return Object.assign(run, { immediate: run });
},
);
});
it('creates and audits all three profile roles in one owned transaction', async () => {
const result = await actions.saveProfile?.({
cookies: {} as never,
request: request(),
} as never);
expect(result).toMatchObject({
profileSaved: true,
resultToken: expect.any(String),
});
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
expect(mocks.effects).toHaveLength(3);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'operator-1',
category: 'game_data',
action: 'os_protocol_profile.created',
before: null,
after: expect.objectContaining({
effects: expect.arrayContaining([
expect.objectContaining({ effect_role: 'Core', value: 10 }),
expect.objectContaining({ effect_role: 'Damage', value: 20 }),
expect.objectContaining({ effect_role: 'Health', value: 30 }),
]),
}),
database: mocks.transaction,
}),
);
});
it('does not start persistence when a required Attribute is missing', async () => {
mocks.getAttributeIds.mockResolvedValue({
Core: 'firepower-flat',
Damage: null,
Health: 'health-flat',
});
const result = await actions.saveProfile?.({
cookies: {} as never,
request: request(),
} as never);
expect(result).toMatchObject({ status: 400 });
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('rejects missing numeric values instead of coercing them to zero', async () => {
const result = await actions.saveProfile?.({
cookies: {} as never,
request: request(false),
} as never);
expect(result).toMatchObject({ status: 400 });
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,231 @@
import { connect, type Database } from '@tursodatabase/database';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
getDatabase: vi.fn(),
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: vi.fn(async (user) => user),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: mocks.getDatabase,
}));
vi.mock('$lib/server/data-moderation', () => ({
getPendingEntities: vi.fn(async () => []),
}));
import { actions } from './+page.server';
let database: Database;
const profileRequest = () => {
const form = new FormData();
form.set('quality', 'Standard');
form.set('attributeType', 'Firepower');
form.set('valueCore', '10');
form.set('valueDamage', '20');
form.set('valueHealth', '30');
return new Request('http://localhost/isac/protocols?/saveProfile', {
method: 'POST',
body: form,
});
};
const protocolRequest = () => {
const form = new FormData();
form.set('id', '');
form.set('name', 'Integration Protocol');
form.set('quality', 'Standard');
form.set('attributeType', 'Firepower');
form.set('primaryStatId', 'weapon-damage-flat');
form.set('primaryBaseValue', '5');
form.set('hasTalent', '0');
return new Request('http://localhost/isac/protocols?/save', {
method: 'POST',
body: form,
});
};
describe('OS Protocol save actions with embedded Turso', () => {
beforeEach(async () => {
database = await connect(':memory:');
mocks.getDatabase.mockResolvedValue(database);
await database.exec(`
CREATE TABLE stat_attributes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
unit TEXT NOT NULL,
active INTEGER NOT NULL
);
CREATE TABLE os_protocols (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
quality TEXT NOT NULL,
primary_attribute_type TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX os_protocols_name_nocase_idx ON os_protocols(name COLLATE NOCASE);
CREATE TABLE os_protocol_primary_effects (
id TEXT PRIMARY KEY,
protocol_id TEXT NOT NULL UNIQUE,
stat_attribute_id TEXT NOT NULL,
base_value REAL NOT NULL
);
CREATE TABLE os_protocol_secondary_profiles (
id TEXT PRIMARY KEY,
quality TEXT NOT NULL,
primary_attribute_type TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(quality, primary_attribute_type)
);
CREATE TABLE os_protocol_secondary_effects (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
effect_role TEXT NOT NULL,
stat_attribute_id TEXT NOT NULL,
value REAL NOT NULL,
UNIQUE(profile_id, effect_role),
UNIQUE(profile_id, stat_attribute_id)
);
CREATE TABLE os_protocol_talents (
id TEXT PRIMARY KEY,
protocol_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description_template TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE os_protocol_talent_effects (
id TEXT PRIMARY KEY,
talent_id TEXT NOT NULL,
stat_attribute_id TEXT NOT NULL,
base_value REAL NOT NULL,
sort_order INTEGER NOT NULL
);
CREATE TABLE game_data_icons (
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
storage_key TEXT,
byte_size INTEGER,
updated_at TEXT
);
CREATE TABLE managed_images (
id TEXT PRIMARY KEY,
category TEXT,
tags_json TEXT,
source_entity_type TEXT,
source_entity_id TEXT
);
CREATE TABLE audit_log (
id TEXT PRIMARY KEY,
actor_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
created_at TEXT NOT NULL
);
`);
for (const [id, name, unit] of [
['firepower-flat', 'Firepower', ''],
['firepower-percent', 'Firepower', '%'],
['weapon-damage-flat', 'Weapon Damage', ''],
['weapon-damage-percent', 'Weapon Damage', '%'],
['health-flat', 'Health', ''],
]) {
await (
await database.prepare(
'INSERT INTO stat_attributes(id,name,unit,active) VALUES(?,?,?,1)',
)
).run(id, name, unit);
}
});
afterEach(async () => {
await database.close();
});
it('saves a complete shared profile and then a no-Talent Protocol', async () => {
const profileResult = await actions.saveProfile?.({
cookies: {} as never,
request: profileRequest(),
} as never);
expect(profileResult).toMatchObject({ profileSaved: true });
const profile = await (
await database.prepare(
"SELECT id FROM os_protocol_secondary_profiles WHERE quality='Standard' AND primary_attribute_type='Firepower'",
)
).get();
expect(profile).toBeTruthy();
expect(
await (
await database.prepare(
'SELECT effect_role,stat_attribute_id,value FROM os_protocol_secondary_effects WHERE profile_id=? ORDER BY effect_role',
)
).all(profile?.id),
).toEqual([
{
effect_role: 'Core',
stat_attribute_id: 'firepower-flat',
value: 10,
},
{
effect_role: 'Damage',
stat_attribute_id: 'weapon-damage-flat',
value: 20,
},
{
effect_role: 'Health',
stat_attribute_id: 'health-flat',
value: 30,
},
]);
const protocolResult = await actions.save?.({
cookies: {} as never,
request: protocolRequest(),
} as never);
expect(protocolResult).toMatchObject({
protocolSaved: true,
protocolMode: 'add',
});
expect(
await (
await database.prepare(
'SELECT name,quality,primary_attribute_type FROM os_protocols',
)
).get(),
).toEqual({
name: 'Integration Protocol',
quality: 'Standard',
primary_attribute_type: 'Firepower',
});
expect(
await (
await database.prepare(
'SELECT entity_type,action FROM audit_log ORDER BY created_at',
)
).all(),
).toEqual([
{
entity_type: 'game_data',
action: 'os_protocol_profile.created',
},
{ entity_type: 'game_data', action: 'os_protocol.created' },
]);
});
});
@@ -0,0 +1,344 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const capturedRule = {
version: 1 as const,
simulationReadiness: 'captured_only' as const,
scenarioInputs: [],
pools: [],
clauses: [],
};
const mocks = vi.hoisted(() => {
const events: string[] = [];
const database = {
exec: vi.fn(async (sql: string) => {
events.push(sql);
}),
prepare: vi.fn(),
transactionAsync: vi.fn(),
};
database.transactionAsync.mockImplementation(
(callback: (transaction: typeof database) => Promise<unknown>) => {
const run = vi.fn(async () => {
events.push('BEGIN');
try {
const result = await callback(database);
events.push('COMMIT');
return result;
} catch (error) {
events.push('ROLLBACK');
throw error;
}
});
return Object.assign(run, { immediate: run });
},
);
return {
events,
talentExists: false,
profileConfigured: true,
auditRecord: vi.fn(),
conditionalRuleSnapshot: vi.fn(),
parseConditionalRulePayload: vi.fn(),
recordIsacAudit: vi.fn(async () => {
events.push('audit');
}),
saveConditionalRuleInTransaction: vi.fn(async () => {
events.push('save conditional rule');
}),
database,
};
});
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: vi.fn(async (user) => user),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/server/audit', () => ({
auditRecord: mocks.auditRecord,
recordIsacAudit: mocks.recordIsacAudit,
}));
vi.mock('$lib/server/data-moderation', () => ({
getPendingEntities: vi.fn(async () => []),
}));
vi.mock('$lib/server/os-protocol-profiles', () => ({
getOsProtocolProfileAttributeIds: vi.fn(async () => ({
Core: 'firepower',
Damage: 'weapon-damage',
Health: 'health',
})),
}));
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: vi.fn(),
gameDataIconMetadata: vi.fn(async () => null),
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
readIconMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: vi.fn(),
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: mocks.conditionalRuleSnapshot,
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: mocks.parseConditionalRulePayload,
saveConditionalRuleInTransaction: mocks.saveConditionalRuleInTransaction,
}));
import { actions } from './+page.server';
const protocolId = 'protocol-1';
const talentId = 'talent-1';
const protocolRecord = {
id: protocolId,
name: 'Adaptive Kernel',
quality: 'Standard',
primary_attribute_type: 'Firepower',
active: 1,
};
const primaryEffect = {
protocol_id: protocolId,
stat_attribute_id: 'weapon-damage',
base_value: 5,
};
const talentRecord = {
id: talentId,
protocol_id: protocolId,
name: 'Escalation Loop',
description_template: 'Captured Talent',
};
const saveRequest = ({
id = '',
hasTalent = false,
confirmRemoval = false,
includePrimaryValue = true,
}: {
id?: string;
hasTalent?: boolean;
confirmRemoval?: boolean;
includePrimaryValue?: boolean;
} = {}) => {
const form = new FormData();
form.set('id', id);
form.set('name', 'Adaptive Kernel');
form.set('quality', 'Standard');
form.set('attributeType', 'Firepower');
form.set('primaryStatId', 'weapon-damage');
if (includePrimaryValue) form.set('primaryBaseValue', '5');
form.set('hasTalent', hasTalent ? '1' : '0');
if (hasTalent) {
form.set('talentName', 'Escalation Loop');
form.set('descriptionTemplate', 'Captured Talent');
form.set('conditionalRule', JSON.stringify(capturedRule));
}
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
return new Request('http://localhost/isac/protocols?/save', {
method: 'POST',
body: form,
});
};
const runSave = (options?: Parameters<typeof saveRequest>[0]) =>
actions.save?.({
cookies: {} as never,
request: saveRequest(options),
} as never);
describe('OS Protocol optional Talent action', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.events.length = 0;
mocks.talentExists = false;
mocks.profileConfigured = true;
mocks.parseConditionalRulePayload.mockReturnValue(capturedRule);
mocks.conditionalRuleSnapshot.mockResolvedValue({
id: 'rule-1',
source_id: talentId,
});
mocks.auditRecord.mockResolvedValue(protocolRecord);
mocks.database.prepare.mockImplementation(async (sql: string) => ({
get: vi.fn(async (...args: unknown[]) => {
if (sql.includes('FROM os_protocol_secondary_profiles'))
return mocks.profileConfigured ? { id: 'profile-1' } : null;
if (sql.includes('FROM os_protocols WHERE name=')) return null;
if (sql.includes('FROM os_protocol_talents WHERE protocol_id='))
return mocks.talentExists ? talentRecord : null;
if (sql.includes('FROM os_protocol_talents WHERE name=')) return null;
if (sql.includes('COUNT(*) AS count FROM stat_attributes'))
return { count: args.length };
if (sql.includes('FROM managed_images')) return null;
if (sql.includes('FROM os_protocol_primary_effects'))
return primaryEffect;
return null;
}),
all: vi.fn(async () => []),
run: vi.fn(async () => {
if (sql.includes('INSERT INTO os_protocol_talents'))
mocks.talentExists = true;
if (sql.includes('DELETE FROM conditional_effect_rules'))
mocks.events.push('delete conditional rule');
if (sql.includes('DELETE FROM os_protocol_talents')) {
mocks.events.push('delete talent');
mocks.talentExists = false;
}
}),
}));
});
it('creates a protocol without a Talent', async () => {
const result = await runSave();
expect(result).toMatchObject({
protocolSaved: true,
protocolMode: 'add',
resultToken: expect.any(String),
});
expect(mocks.parseConditionalRulePayload).not.toHaveBeenCalled();
expect(mocks.saveConditionalRuleInTransaction).not.toHaveBeenCalled();
expect(
mocks.database.prepare.mock.calls.some(([sql]) =>
String(sql).includes('INSERT INTO os_protocol_talents'),
),
).toBe(false);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'operator-1',
category: 'game_data',
action: 'os_protocol.created',
before: null,
after: expect.objectContaining({ talent: null, conditional: null }),
}),
);
});
it('rejects a protocol whose shared profile is incomplete', async () => {
mocks.profileConfigured = false;
const result = await runSave();
expect(result).toMatchObject({
status: 400,
data: {
protocolError:
'Configure the shared secondary profile for this quality and attribute type first.',
},
});
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('rejects a missing primary value instead of coercing it to zero', async () => {
const result = await runSave({ includePrimaryValue: false });
expect(result).toMatchObject({ status: 400 });
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('accepts an assigned CAPTURE ONLY Talent with no mechanic branches', async () => {
const result = await runSave({ hasTalent: true });
expect(result).toMatchObject({
protocolSaved: true,
protocolMode: 'add',
});
expect(mocks.parseConditionalRulePayload).toHaveBeenCalledOnce();
expect(mocks.saveConditionalRuleInTransaction).toHaveBeenCalledWith(
mocks.database,
'os_protocol_talent',
expect.any(String),
capturedRule,
);
});
it.each(['ready', 'requires_assumptions'] as const)(
'rejects an empty %s mechanic program',
async (simulationReadiness) => {
mocks.parseConditionalRulePayload.mockReturnValue({
...capturedRule,
simulationReadiness,
});
const result = await runSave({ hasTalent: true });
expect(result).toMatchObject({
status: 400,
data: {
protocolError:
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
},
});
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
},
);
it('requires confirmation before removing an existing Talent', async () => {
mocks.talentExists = true;
const result = await runSave({ id: protocolId });
expect(result).toMatchObject({
status: 400,
data: {
protocolError:
'Confirm removal of the existing Talent and mechanic program.',
},
});
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('removes an existing Talent and its mechanic in the audited transaction', async () => {
mocks.talentExists = true;
const result = await runSave({
id: protocolId,
confirmRemoval: true,
});
expect(result).toMatchObject({
protocolSaved: true,
protocolMode: 'edit',
});
expect(mocks.events).toEqual([
'BEGIN',
'delete conditional rule',
'delete talent',
'audit',
'COMMIT',
]);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'operator-1',
category: 'game_data',
action: 'os_protocol.updated',
entityId: protocolId,
before: expect.objectContaining({
talent: talentRecord,
conditional: expect.objectContaining({ source_id: talentId }),
}),
after: expect.objectContaining({ talent: null, conditional: null }),
}),
);
});
});
+361 -227
View File
@@ -25,7 +25,10 @@ import {
getRegistrySearch,
getRegistrySort,
} from '$lib/server/pagination';
import { refreshAbilityFocuses } from '$lib/server/specialization-readiness';
import {
refreshAbilityFocuses,
refreshSpecializationFocusReady,
} from '$lib/server/specialization-readiness';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
@@ -36,6 +39,18 @@ import {
saveConditionalRuleInTransaction,
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import {
normalizeSkillChipProfiles,
SKILL_CHIP_QUALITIES,
SKILL_CHIP_SECONDARY_QUALITIES,
} from '$lib/skill-chips/profiles';
import {
getSkillChipPrimaryAttributeIds,
getSkillChipPrimaryProfiles,
getSkillChipSecondaryPool,
replaceSkillChipPrimaryProfiles,
replaceSkillChipSecondaryPool,
} from '$lib/server/skill-chip-profiles';
const mechanicOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
@@ -68,6 +83,16 @@ const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
}
: null;
async function refreshAllFocusReadiness(
database: Awaited<ReturnType<typeof getDatabase>>,
) {
const focuses = await (
await database.prepare('SELECT id FROM specialization_focuses')
).all();
for (const focus of focuses)
await refreshSpecializationFocusReady(database, String(focus.id));
}
async function snapshot(id: string) {
const database = await getDatabase();
const talent = await (
@@ -91,16 +116,6 @@ async function snapshot(id: string) {
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get(id)) ?? null,
primaryOptions: await (
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
)
).all(id),
secondaryOptions: await (
await database.prepare(
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
)
).all(id),
talent: talent ?? null,
talentEffects: talent
? await (
@@ -150,8 +165,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
desc: "COALESCE((SELECT talent.name FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC",
},
rollPools: {
asc: '((SELECT COUNT(*) FROM skill_chip_primary_options primary_option WHERE primary_option.skill_chip_set_id=sets.id)+(SELECT COUNT(*) FROM skill_chip_secondary_options secondary_option WHERE secondary_option.skill_chip_set_id=sets.id)) ASC,sets.name COLLATE NOCASE ASC',
desc: '((SELECT COUNT(*) FROM skill_chip_primary_options primary_option WHERE primary_option.skill_chip_set_id=sets.id)+(SELECT COUNT(*) FROM skill_chip_secondary_options secondary_option WHERE secondary_option.skill_chip_set_id=sets.id)) DESC,sets.name COLLATE NOCASE ASC',
asc: 'sets.name COLLATE NOCASE ASC,sets.id ASC',
desc: 'sets.name COLLATE NOCASE DESC,sets.id DESC',
},
state: {
asc: 'sets.active ASC,sets.name COLLATE NOCASE ASC',
@@ -173,7 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: filter === 'configured'
? [
'EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)',
'EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)',
]
: []),
...(!requestedItemId && search
@@ -198,7 +212,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
SUM(CASE WHEN specialization_ability_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned,
SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
FROM skill_chip_sets`)
).get(),
(
@@ -211,7 +225,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
module: 'attributes',
action: 'save',
kind: 'stat',
fields: ['unit', 'chipMin', 'chipMax'],
fields: ['unit'],
},
]),
getPendingEntities(user.id, [
@@ -224,8 +238,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'twoPieceStatId',
'twoPieceMin',
'twoPieceMax',
'primaryStatIds',
'secondaryStatIds',
'hasTalent',
'talentName',
'descriptionTemplate',
'conditionalRule',
@@ -252,8 +265,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const [
sets,
twoPiece,
primaryOptions,
secondaryOptions,
talents,
talentEffects,
stats,
@@ -262,6 +273,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
abilityChipCounts,
icons,
renders,
primaryProfiles,
secondaryPool,
primaryAttributeIds,
] = await Promise.all([
(
await database.prepare(
@@ -271,10 +285,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
(
await database.prepare('SELECT * FROM skill_chip_secondary_options')
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
await database.prepare(
@@ -283,7 +293,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
).all(),
(
await database.prepare(
'SELECT id,name,unit,skill_chip_min,skill_chip_max FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
@@ -310,13 +320,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='skill_chip_render'",
)
).all(),
getSkillChipPrimaryProfiles(database),
getSkillChipSecondaryPool(database),
getSkillChipPrimaryAttributeIds(database),
]);
const availableStats = [
...pendingStats.map((item) => ({
...item,
unit: String(item.unit ?? ''),
skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin),
skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax),
})),
...stats,
];
@@ -347,9 +358,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const currentPendingSets = pendingSets.filter(
(set) =>
Boolean(set.abilityId) &&
Array.isArray(set.primaryStatIds) &&
Array.isArray(set.secondaryStatIds) &&
Boolean(set.conditionalRule),
(String(set.hasTalent) !== '1' || Boolean(set.conditionalRule)),
);
const pendingSetRows = requestedItemId
? currentPendingSets.filter((set) => set.id === requestedItemId)
@@ -374,19 +383,22 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
min_value: Number(set.twoPieceMin),
max_value: Number(set.twoPieceMax),
},
primaryStatIds: set.primaryStatIds,
secondaryStatIds: set.secondaryStatIds,
talent: {
name: set.talentName,
description_template: set.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(String(set.conditionalRule ?? 'null'));
} catch {
return null;
}
})(),
},
talent:
String(set.hasTalent) === '1'
? {
name: set.talentName,
description_template: set.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(
String(set.conditionalRule ?? 'null'),
);
} catch {
return null;
}
})(),
}
: null,
}));
const configuredSets = await Promise.all(
sets.map(async (set) => {
@@ -407,12 +419,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
})(),
twoPiece:
twoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
primaryStatIds: primaryOptions
.filter((item) => item.skill_chip_set_id === set.id)
.map((item) => String(item.stat_attribute_id)),
secondaryStatIds: secondaryOptions
.filter((item) => item.skill_chip_set_id === set.id)
.map((item) => String(item.stat_attribute_id)),
talent: talent
? {
...talent,
@@ -433,9 +439,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
return {
sets: [...pendingSetRows, ...configuredSets],
stats: availableStats,
rollStats: availableStats.filter(
(stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null,
primaryProfiles: primaryAttributeIds.map(
(statId) =>
primaryProfiles.find((profile) => profile.statId === statId) ?? {
statId,
values: {},
},
),
primaryProfileBase: primaryProfiles,
secondaryPool,
abilities: loadedAbilities,
summary: {
active: Number(summary?.active ?? 0),
@@ -490,12 +502,7 @@ export const actions: Actions = {
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
const twoPieceMin = Number(form.get('twoPieceMin'));
const twoPieceMax = Number(form.get('twoPieceMax'));
const primaryStatIds = [
...new Set(form.getAll('primaryStatIds').map(String).filter(Boolean)),
];
const secondaryStatIds = [
...new Set(form.getAll('secondaryStatIds').map(String).filter(Boolean)),
];
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '')
.normalize('NFKC')
.trim();
@@ -503,25 +510,30 @@ export const actions: Actions = {
.normalize('NFKC')
.trim();
const database = await getDatabase();
let conditionalRule;
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
null;
try {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare('SELECT id FROM stat_attributes WHERE active=1')
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[
{ key: 'minimum', variantKey: 'minimum' },
{ key: 'maximum', variantKey: 'maximum' },
],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
if (hasTalent) {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1',
)
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[
{ key: 'minimum', variantKey: 'minimum' },
{ key: 'maximum', variantKey: 'maximum' },
],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
}
} catch (error) {
return fail(400, {
skillChipError:
@@ -547,38 +559,30 @@ export const actions: Actions = {
return fail(400, {
skillChipError: 'Configure a valid 2-piece stat range.',
});
if (!primaryStatIds.length)
return fail(400, {
skillChipError: 'Configure at least one possible primary stat.',
});
if (secondaryStatIds.length < 2)
return fail(400, {
skillChipError: 'Configure at least two possible secondary stats.',
});
if (primaryStatIds.some((statId) => secondaryStatIds.includes(statId)))
return fail(400, {
skillChipError: 'Primary and secondary stat pools must be distinct.',
});
const outcomes = mechanicOutcomes(conditionalRule);
const numberedOutcomes = descriptionOutcomes(conditionalRule);
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
const numberedOutcomes = conditionalRule
? descriptionOutcomes(conditionalRule)
: [];
const durationSeconds =
conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule.pools.length
const maxStacks = conditionalRule?.pools.length
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
if (
talentName.length < 2 ||
talentName.length > 80 ||
!descriptionTemplate ||
!conditionalRule.clauses.length ||
!outcomes.length
hasTalent &&
(talentName.length < 2 ||
talentName.length > 80 ||
!descriptionTemplate ||
!conditionalRule?.clauses.length ||
!outcomes.length)
)
return fail(400, {
skillChipError:
'The 3-piece talent requires a name, description, mechanic branch, and outcome.',
});
if (
hasTalent &&
outcomes.some(
(outcome) =>
outcome.variants.maximum.value < outcome.variants.minimum.value,
@@ -592,6 +596,7 @@ export const actions: Actions = {
(match) => Number(match[1]),
);
if (
hasTalent &&
placeholders.some(
(index) =>
!numberedOutcomes.some(
@@ -603,12 +608,16 @@ export const actions: Actions = {
skillChipError:
'Description placeholders must reference configured numbered outcomes.',
});
if (descriptionTemplate.includes('{duration}') && durationSeconds === null)
if (
hasTalent &&
descriptionTemplate.includes('{duration}') &&
durationSeconds === null
)
return fail(400, {
skillChipError:
'The description uses {duration}, but no expiring state pool is defined.',
});
if (descriptionTemplate.includes('{max_stacks}') && !maxStacks)
if (hasTalent && descriptionTemplate.includes('{max_stacks}') && !maxStacks)
return fail(400, {
skillChipError:
'The description uses {max_stacks}, but no state pool maximum is defined.',
@@ -656,11 +665,21 @@ export const actions: Actions = {
).get(id)
: null;
if (
await (
existingTalent &&
!hasTalent &&
form.get('confirmTalentRemoval') !== 'REMOVE'
)
return fail(400, {
skillChipError:
'Confirm removal of the existing 3-piece Talent and mechanic program.',
});
if (
hasTalent &&
(await (
await database.prepare(
'SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(talentName, existingTalent?.id ?? 'new')
).get(talentName, existingTalent?.id ?? 'new'))
)
return fail(409, {
skillChipError: 'That Skill Chip talent name is already in use.',
@@ -668,14 +687,7 @@ export const actions: Actions = {
const outcomeStatIds = outcomes
.map((effect) => effect.attributeId)
.filter((value): value is string => Boolean(value));
const allStatIds = [
...new Set([
twoPieceStatId,
...primaryStatIds,
...secondaryStatIds,
...outcomeStatIds,
]),
];
const allStatIds = [...new Set([twoPieceStatId, ...outcomeStatIds])];
const validStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${allStatIds.map(() => '?').join(',')})`,
@@ -685,17 +697,6 @@ export const actions: Actions = {
return fail(400, {
skillChipError: 'One or more selected stats are unavailable.',
});
const rollStatIds = [...primaryStatIds, ...secondaryStatIds];
const validRollStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND id IN (${rollStatIds.map(() => '?').join(',')})`,
)
).get(...rollStatIds);
if (Number(validRollStats?.count) !== rollStatIds.length)
return fail(400, {
skillChipError:
'Every primary and secondary option requires a configured Skill Chip range.',
});
const setId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const talentId = String(existingTalent?.id ?? randomUUID());
@@ -724,61 +725,50 @@ export const actions: Actions = {
await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)
).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax);
await (
await database.prepare(
'DELETE FROM skill_chip_primary_options WHERE skill_chip_set_id=?',
)
).run(setId);
for (const statId of primaryStatIds)
if (hasTalent && conditionalRule) {
await (
await database.prepare(
'INSERT INTO skill_chip_primary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)',
)
).run(setId, statId);
await (
await database.prepare(
'DELETE FROM skill_chip_secondary_options WHERE skill_chip_set_id=?',
)
).run(setId);
for (const statId of secondaryStatIds)
await (
await database.prepare(
'INSERT INTO skill_chip_secondary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)',
)
).run(setId, statId);
await (
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
).run(talentId, setId, talentName, descriptionTemplate, now, now);
await (
await database.prepare(
'DELETE FROM skill_chip_talent_effects WHERE talent_id=?',
)
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
).run(talentId, setId, talentName, descriptionTemplate, now, now);
await (
await database.prepare(
'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
'DELETE FROM skill_chip_talent_effects WHERE talent_id=?',
)
).run(
randomUUID(),
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
await (
await database.prepare(
'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
)
).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.minimum.value,
outcome.variants.maximum.value,
compatibilitySortOrder,
);
}
await saveConditionalRuleInTransaction(
database,
'skill_chip_talent',
talentId,
outcome.attributeId,
outcome.variants.minimum.value,
outcome.variants.maximum.value,
compatibilitySortOrder,
conditionalRule,
);
} else if (existingTalent) {
await (
await database.prepare(
"DELETE FROM conditional_effect_rules WHERE source_type='skill_chip_talent' AND source_id=?",
)
).run(String(existingTalent.id));
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE id=?')
).run(String(existingTalent.id));
}
await saveConditionalRuleInTransaction(
database,
'skill_chip_talent',
talentId,
conditionalRule,
);
await applyIconMutation(
database,
'skill_chip',
@@ -804,6 +794,7 @@ export const actions: Actions = {
entityId: setId,
before,
after: await snapshot(setId),
database,
});
await database.exec('COMMIT');
} catch (error) {
@@ -816,33 +807,163 @@ export const actions: Actions = {
resultToken: randomUUID(),
};
},
savePrimaryProfile: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const database = await getDatabase();
const form = await request.formData();
const expectedStatIds = await getSkillChipPrimaryAttributeIds(database);
const normalized = normalizeSkillChipProfiles(
(() => {
try {
return JSON.parse(String(form.get('profiles') ?? ''));
} catch {
return null;
}
})(),
SKILL_CHIP_QUALITIES,
{ expectedStatIds, minimumOptions: 4 },
);
if (normalized.error)
return fail(400, { skillChipPrimaryProfileError: normalized.error });
await database.exec('BEGIN IMMEDIATE');
try {
const before = await getSkillChipPrimaryProfiles(database);
if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) {
await database.exec('ROLLBACK');
if (JSON.stringify(before) === JSON.stringify(normalized.profiles))
return {
skillChipPrimaryProfileSaved: true,
resultToken: randomUUID(),
};
return fail(409, {
skillChipPrimaryProfileError:
'The shared primary profile changed after this editor opened. Review the latest values and try again.',
});
}
await replaceSkillChipPrimaryProfiles(database, normalized.profiles);
await refreshAllFocusReadiness(database);
const after = await getSkillChipPrimaryProfiles(database);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip_primary_profile.updated',
entityId: 'skill-chip-primary-profile',
before,
after,
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipPrimaryProfileSaved: true, resultToken: randomUUID() };
},
saveSecondaryPool: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const database = await getDatabase();
const form = await request.formData();
const normalized = normalizeSkillChipProfiles(
(() => {
try {
return JSON.parse(String(form.get('profiles') ?? ''));
} catch {
return null;
}
})(),
SKILL_CHIP_SECONDARY_QUALITIES,
{ minimumOptions: 2 },
);
if (normalized.error)
return fail(400, { skillChipSecondaryPoolError: normalized.error });
const statIds = normalized.profiles.map((profile) => profile.statId);
const validStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`,
)
).get(...statIds);
if (Number(validStats?.count) !== statIds.length)
return fail(400, {
skillChipSecondaryPoolError:
'One or more selected Attributes are unavailable.',
});
await database.exec('BEGIN IMMEDIATE');
try {
const before = await getSkillChipSecondaryPool(database);
if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) {
await database.exec('ROLLBACK');
if (JSON.stringify(before) === JSON.stringify(normalized.profiles))
return {
skillChipSecondaryPoolSaved: true,
resultToken: randomUUID(),
};
return fail(409, {
skillChipSecondaryPoolError:
'The shared secondary pool changed after this editor opened. Review the latest values and try again.',
});
}
await replaceSkillChipSecondaryPool(database, normalized.profiles);
await refreshAllFocusReadiness(database);
const after = await getSkillChipSecondaryPool(database);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip_secondary_pool.updated',
entityId: 'skill-chip-secondary-pool',
before,
after,
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipSecondaryPoolSaved: true, resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record)
return fail(404, { skillChipError: 'Skill Chip set not found.' });
const database = await getDatabase();
await (
await database.prepare(
'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await refreshAbilityFocuses(
database,
String(before.record.specialization_ability_id),
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip.archived',
entityId: id,
before,
after: await snapshot(id),
});
await database.exec('BEGIN IMMEDIATE');
try {
const before = await snapshot(id);
if (!before.record) {
await database.exec('ROLLBACK');
return fail(404, { skillChipError: 'Skill Chip set not found.' });
}
await (
await database.prepare(
'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await refreshAbilityFocuses(
database,
String(before.record.specialization_ability_id),
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip.archived',
entityId: id,
before,
after: await snapshot(id),
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipArchived: true };
},
unarchive: async ({ request, cookies }) => {
@@ -851,50 +972,63 @@ export const actions: Actions = {
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record)
return fail(404, { skillChipError: 'Skill Chip set not found.' });
const abilityId = String(before.record.specialization_ability_id);
const database = await getDatabase();
if (
!(await (
await database.prepare(
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
)
).get(abilityId))
) {
return fail(400, {
skillChipError:
'The assigned specialization ability must be active before restoring this Skill Chip.',
});
}
const assignedCount = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
await database.exec('BEGIN IMMEDIATE');
try {
const before = await snapshot(id);
if (!before.record) {
await database.exec('ROLLBACK');
return fail(404, { skillChipError: 'Skill Chip set not found.' });
}
const abilityId = String(before.record.specialization_ability_id);
if (
!(await (
await database.prepare(
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
)
).get(abilityId))
) {
await database.exec('ROLLBACK');
return fail(400, {
skillChipError:
'The assigned specialization ability must be active before restoring this Skill Chip.',
});
}
const assignedCount = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
WHERE specialization_ability_id=? AND active=1 AND id<>?`)
).get(abilityId, id)
)?.count ?? 0,
);
if (assignedCount >= 3)
return fail(409, {
skillChipError:
'That ability already has all three active Skill Chips assigned.',
).get(abilityId, id)
)?.count ?? 0,
);
if (assignedCount >= 3) {
await database.exec('ROLLBACK');
return fail(409, {
skillChipError:
'That ability already has all three active Skill Chips assigned.',
});
}
await (
await database.prepare(
'UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await refreshAbilityFocuses(database, abilityId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip.unarchived',
entityId: id,
before,
after: await snapshot(id),
database,
});
await (
await database.prepare(
'UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await refreshAbilityFocuses(database, abilityId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'skill_chip.unarchived',
entityId: id,
before,
after: await snapshot(id),
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipUnarchived: true };
},
};
+35 -18
View File
@@ -10,6 +10,7 @@
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import SkillChipSharedProfiles from '$lib/components/SkillChipSharedProfiles.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
let { data, form } = $props();
@@ -22,7 +23,7 @@
clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}]
});
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
let editing = $state<any | null>(null);
let highEndCount = $state('0');
let handledResultToken = $state('');
@@ -65,9 +66,9 @@
...item,
abilityId: String(item.specialization_ability_id),
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
primaryStatIds: [...item.primaryStatIds],
secondaryStatIds: [...item.secondaryStatIds],
talent: item.talent ? { ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() } : blankTalent()
hasTalent: Boolean(item.talent),
talentOpen: false,
talent: item.talent ? structuredClone({ ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() }) : blankTalent()
};
highEndCount = '0';
};
@@ -86,17 +87,30 @@
editing = form.skillChipMode === 'add' ? blank() : null;
highEndCount = '0';
}
if (form?.skillChipError && editing?.hasTalent) editing.talentOpen = true;
});
</script>
<svelte:head><title>Skill Chips — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">SKILL MODIFICATION SYNERGY REGISTRY</p><h2>SKILL CHIPS</h2><p>Configure Superior and High-end chip rolls, 2-piece bonuses, and quality-scaled 3-piece talents.</p></div><button class="primary" onclick={() => editing = blank()}>ADD SKILL CHIP <span>+</span></button></div>
<div class="module-toolbar"><div><p class="eyebrow">SKILL MODIFICATION SYNERGY REGISTRY</p><h2>SKILL CHIPS</h2><p>Configure shared quality rolls, ability-linked chip sets, 2-piece bonuses, and optional 3-piece talents.</p></div><button class="primary" onclick={() => editing = blank()}>ADD SKILL CHIP <span>+</span></button></div>
<SkillChipSharedProfiles
primaryProfiles={data.primaryProfiles}
primaryProfileBase={data.primaryProfileBase}
secondaryPool={data.secondaryPool}
stats={data.stats}
primaryError={form?.skillChipPrimaryProfileError}
secondaryError={form?.skillChipSecondaryPoolError}
primarySaved={form?.skillChipPrimaryProfileSaved}
secondarySaved={form?.skillChipSecondaryPoolSaved}
resultToken={form?.resultToken}
/>
<KpiFilters active={data.filter} items={[
{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},
{value:'assigned',label:'ABILITY LINKED',count:data.summary.assigned,description:'Assigned to a class ability'},
{value:'configured',label:'CONFIGURED',count:data.summary.configured,description:'Complete 2-piece and 3-piece effects'},
{value:'configured',label:'CONFIGURED',count:data.summary.configured,description:'Complete 2-piece bonus data'},
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
]}/>
@@ -106,8 +120,9 @@
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}</p><h3>{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="hasTalent" value={editing.hasTalent ? '1' : '0'}/>
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b>STANDARD</b><b>SPECIALIZED</b><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Shared profiles define primary and secondary ranges.</small></div></div>
<section class="skill-chip-ability-assignment">
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
@@ -118,15 +133,14 @@
<div class="skill-chip-bonus-grid"><div><SearchableAssignment items={data.stats} fieldName="twoPieceStatId" mode="single" subjectLabel="BONUS ATTRIBUTE" searchPlaceholder="Search attributes…" emptySelectionLabel="NO BONUS ATTRIBUTE SELECTED" bind:value={editing.twoPiece.stat_attribute_id}/></div><div class="skill-chip-range-fields"><label class="field">MINIMUM VALUE<input name="twoPieceMin" type="number" step="any" bind:value={editing.twoPiece.min_value} required/></label><label class="field">MAXIMUM VALUE<input name="twoPieceMax" type="number" step="any" bind:value={editing.twoPiece.max_value} required/></label></div></div>
</section>
<div class="skill-chip-pools">
<div><div class="selection-head"><div><span>PRIMARY STAT POOL</span><small>ONE RANDOM STAT · USES ATTRIBUTE SKILL CHIP RANGE</small></div><b>{editing.primaryStatIds.length}</b></div><SearchableAssignment items={data.rollStats} fieldName="primaryStatIds" subjectLabel="PRIMARY STATS" searchPlaceholder="Search Skill Chip stats…" excludedIds={editing.secondaryStatIds} bind:selected={editing.primaryStatIds}/></div>
<div><div class="selection-head"><div><span>SECONDARY STAT POOL</span><small>TWO DISTINCT RANDOM STATS · USE ATTRIBUTE RANGES</small></div><b>{editing.secondaryStatIds.length}</b></div><SearchableAssignment items={data.rollStats} fieldName="secondaryStatIds" subjectLabel="SECONDARY STATS" searchPlaceholder="Search Skill Chip stats…" excludedIds={editing.primaryStatIds} bind:selected={editing.secondaryStatIds}/></div>
</div>
<section class="skill-chip-talent">
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>QUALITY-SCALED OUTCOMES · TYPED TRIGGER AND DESCRIPTION</small></div><b>{previewOutcomes().length} OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}</b></div>
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>OPTIONAL SET BONUS · QUALITY-SCALED WHEN ASSIGNED</small></div><SegmentedChoice legend="TALENT ASSIGNMENT" name="talent-assignment" options={[{value:'0',label:'NOT ASSIGNED'},{value:'1',label:'ASSIGNED'}]} value={editing.hasTalent ? '1' : '0'} onchange={(values) => { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/></div>
{#if editing.hasTalent}
<details class="modeling-disclosure skill-chip-talent-disclosure" bind:open={editing.talentOpen}>
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>{previewOutcomes().length} QUALITY-SCALED OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
<div class="modeling-content">
<div class="skill-chip-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} Skill Damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for scaled effect values and units.</small></label></div>
<div class="skill-chip-preview"><SegmentedChoice legend="QUALITY MIX PREVIEW" name="qualityPreview" options={qualitySteps} bind:value={highEndCount}/><div><span>{3 - Number(highEndCount)} SUPERIOR · {highEndCount} HIGH-END</span><p>{talentPreview() || 'Talent description preview'}</p></div></div>
<div class="skill-chip-preview"><SegmentedChoice legend="QUALITY MIX PREVIEW" name="qualityPreview" options={qualitySteps} bind:value={highEndCount}/><div><span>{3 - Number(highEndCount)} NON-HIGH-END · {highEndCount} HIGH-END</span><p>{talentPreview() || 'Talent description preview'}</p></div></div>
<MechanicProgramEditor
attributes={data.stats}
allowAssignedAbility
@@ -139,9 +153,12 @@
}}
bind:rule={editing.talent.conditionalRule}
/>
</div>
</details>
{:else}<p class="console-note">This set has no 3-piece Talent. Its 2-piece bonus remains active when the requirement is met.</p>{#if editing.talent.id}<label class="skill-chip-talent-removal"><input type="checkbox" name="confirmTalentRemoval" value="REMOVE" required/> CONFIRM REMOVAL OF THE EXISTING TALENT AND MECHANIC PROGRAM</label>{/if}{/if}
</section>
{#if form?.skillChipError}<p class="error">{form.skillChipError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.abilityId || !editing.primaryStatIds.length || editing.secondaryStatIds.length < 2 || !data.rollStats.length}>SAVE SKILL CHIP →</button></div></div>
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.abilityId}>SAVE SKILL CHIP →</button></div></div>
</PendingForm>
</div>
{/if}
@@ -150,9 +167,9 @@
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="ROLL POOLS" column="rollPools" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><span>ROLL PROFILE</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.sets as item}
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED'}</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first ability-linked Skill Chip definition.</span></div>{/each}
</div>
@@ -0,0 +1,256 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => {
const database = {
exec: vi.fn(async () => undefined),
prepare: vi.fn(),
};
return {
database,
getDatabase: vi.fn(async () => database),
getPrimaryAttributeIds: vi.fn(),
getPrimaryProfiles: vi.fn(),
getSecondaryPool: vi.fn(),
recordIsacAudit: vi.fn(),
refreshFocus: vi.fn(),
replacePrimaryProfiles: vi.fn(),
replaceSecondaryPool: vi.fn(),
requirePermission: vi.fn(async (user) => user),
};
});
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: mocks.requirePermission,
}));
vi.mock('$lib/server/database', () => ({
getDatabase: mocks.getDatabase,
}));
vi.mock('$lib/server/audit', () => ({
auditRecord: vi.fn(),
recordIsacAudit: mocks.recordIsacAudit,
}));
vi.mock('$lib/server/data-moderation', () => ({
getPendingEntities: vi.fn(async () => []),
}));
vi.mock('$lib/server/skill-chip-profiles', () => ({
getSkillChipPrimaryAttributeIds: mocks.getPrimaryAttributeIds,
getSkillChipPrimaryProfiles: mocks.getPrimaryProfiles,
getSkillChipSecondaryPool: mocks.getSecondaryPool,
replaceSkillChipPrimaryProfiles: mocks.replacePrimaryProfiles,
replaceSkillChipSecondaryPool: mocks.replaceSecondaryPool,
}));
vi.mock('$lib/server/specialization-readiness', () => ({
refreshAbilityFocuses: vi.fn(),
refreshSpecializationFocusReady: mocks.refreshFocus,
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: vi.fn(),
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(),
}));
import { actions } from './+page.server';
const primaryIds = [
'skill-duration',
'skill-radius',
'skill-cooldown-recovery',
'skill-intensity',
];
const primaryQualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const secondaryQualities = ['Specialized', 'Superior', 'High-end'];
const profiles = (ids: string[], qualities: string[], offset = 0) =>
ids.map((statId, profileIndex) => ({
statId,
values: Object.fromEntries(
qualities.map((quality, qualityIndex) => [
quality,
{
min: offset + profileIndex * 10 + qualityIndex + 1,
max: offset + profileIndex * 10 + qualityIndex + 2,
},
]),
),
}));
const primaryAfter = profiles(primaryIds, primaryQualities, 10);
const secondaryIds = ['critical-hit-chance', 'critical-hit-damage'];
const secondaryAfter = profiles(secondaryIds, secondaryQualities, 20);
const actionRequest = (action: string, payload: unknown, base: unknown) => {
const form = new FormData();
form.set('profiles', JSON.stringify(payload));
form.set('baseProfiles', JSON.stringify(base));
return new Request(`http://localhost/isac/skillchips?/${action}`, {
method: 'POST',
body: form,
});
};
const cases = [
{
actionName: 'savePrimaryProfile' as const,
payload: primaryAfter,
getProfiles: mocks.getPrimaryProfiles,
replaceProfiles: mocks.replacePrimaryProfiles,
before: profiles(primaryIds, primaryQualities),
after: profiles(primaryIds, primaryQualities, 30),
auditAction: 'skill_chip_primary_profile.updated',
entityId: 'skill-chip-primary-profile',
savedKey: 'skillChipPrimaryProfileSaved',
},
{
actionName: 'saveSecondaryPool' as const,
payload: secondaryAfter,
getProfiles: mocks.getSecondaryPool,
replaceProfiles: mocks.replaceSecondaryPool,
before: profiles(secondaryIds, secondaryQualities),
after: profiles(secondaryIds, secondaryQualities, 40),
auditAction: 'skill_chip_secondary_pool.updated',
entityId: 'skill-chip-secondary-pool',
savedKey: 'skillChipSecondaryPoolSaved',
},
];
describe('Skill Chip shared profile actions', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getPrimaryAttributeIds.mockResolvedValue(primaryIds);
mocks.database.prepare.mockImplementation(async (sql: string) => ({
all: vi.fn(async () =>
sql.includes('specialization_focuses') ? [{ id: 'focus-1' }] : [],
),
get: vi.fn(async (...statIds: unknown[]) => ({ count: statIds.length })),
run: vi.fn(),
}));
});
for (const testCase of cases) {
it(`${testCase.actionName} audits complete evidence and commits its transaction`, async () => {
testCase.getProfiles
.mockResolvedValueOnce(testCase.before)
.mockResolvedValueOnce(testCase.after);
const result = await actions[testCase.actionName]?.({
cookies: {} as never,
request: actionRequest(
testCase.actionName,
testCase.payload,
testCase.before,
),
} as never);
expect(result).toMatchObject({
[testCase.savedKey]: true,
resultToken: expect.any(String),
});
expect(mocks.requirePermission).toHaveBeenCalledWith(
{ id: 'operator-1' },
'data.manage',
);
expect(testCase.getProfiles).toHaveBeenNthCalledWith(1, mocks.database);
expect(testCase.replaceProfiles).toHaveBeenCalledWith(
mocks.database,
testCase.payload,
);
expect(testCase.getProfiles).toHaveBeenNthCalledWith(2, mocks.database);
expect(mocks.refreshFocus).toHaveBeenCalledWith(
mocks.database,
'focus-1',
);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
actorId: 'operator-1',
category: 'game_data',
action: testCase.auditAction,
entityId: testCase.entityId,
before: testCase.before,
after: testCase.after,
database: mocks.database,
});
expect(mocks.database.exec.mock.calls).toEqual([
['BEGIN IMMEDIATE'],
['COMMIT'],
]);
});
it(`${testCase.actionName} rolls back and does not commit when audit fails`, async () => {
const auditError = new Error('audit insert failed');
testCase.getProfiles
.mockResolvedValueOnce(testCase.before)
.mockResolvedValueOnce(testCase.after);
mocks.recordIsacAudit.mockRejectedValueOnce(auditError);
await expect(
actions[testCase.actionName]?.({
cookies: {} as never,
request: actionRequest(
testCase.actionName,
testCase.payload,
testCase.before,
),
} as never),
).rejects.toThrow(auditError);
expect(mocks.database.exec.mock.calls).toEqual([
['BEGIN IMMEDIATE'],
['ROLLBACK'],
]);
expect(mocks.database.exec).not.toHaveBeenCalledWith('COMMIT');
});
it(`${testCase.actionName} rejects stale baseProfiles without replacing or auditing`, async () => {
testCase.getProfiles.mockResolvedValueOnce(testCase.before);
const result = await actions[testCase.actionName]?.({
cookies: {} as never,
request: actionRequest(
testCase.actionName,
testCase.payload,
profiles(['stale-profile'], primaryQualities),
),
} as never);
expect(result).toMatchObject({ status: 409 });
expect(testCase.replaceProfiles).not.toHaveBeenCalled();
expect(mocks.refreshFocus).not.toHaveBeenCalled();
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
expect(mocks.database.exec.mock.calls).toEqual([
['BEGIN IMMEDIATE'],
['ROLLBACK'],
]);
});
}
it('does not open a database when data.manage permission is denied', async () => {
const permissionError = new Error('Forbidden');
mocks.requirePermission.mockRejectedValueOnce(permissionError);
await expect(
actions.savePrimaryProfile?.({
cookies: {} as never,
request: actionRequest('savePrimaryProfile', primaryAfter, []),
} as never),
).rejects.toThrow(permissionError);
expect(mocks.requirePermission).toHaveBeenCalledWith(
{ id: 'operator-1' },
'data.manage',
);
expect(mocks.getDatabase).not.toHaveBeenCalled();
expect(mocks.database.exec).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,225 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => {
const events: string[] = [];
return {
events,
talentExists: true,
auditRecord: vi.fn(),
conditionalRuleSnapshot: vi.fn(),
gameDataIconMetadata: vi.fn(async () => null),
recordIsacAudit: vi.fn(async () => {
events.push('audit');
}),
database: {
exec: vi.fn(async (sql: string) => {
events.push(sql);
}),
prepare: vi.fn(),
},
};
});
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: vi.fn(async (user) => user),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/server/audit', () => ({
auditRecord: mocks.auditRecord,
recordIsacAudit: mocks.recordIsacAudit,
}));
vi.mock('$lib/server/data-moderation', () => ({
getPendingEntities: vi.fn(async () => []),
}));
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: vi.fn(),
gameDataIconMetadata: mocks.gameDataIconMetadata,
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
readIconMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: vi.fn(),
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(async () => ({})),
}));
vi.mock('$lib/server/specialization-readiness', () => ({
refreshAbilityFocuses: vi.fn(),
refreshSpecializationFocusReady: vi.fn(),
}));
vi.mock('$lib/server/conditional-effects', () => ({
conditionalRuleSnapshot: mocks.conditionalRuleSnapshot,
loadConditionalRuleDraft: vi.fn(),
parseConditionalRulePayload: vi.fn(),
saveConditionalRuleInTransaction: vi.fn(),
}));
import { actions } from './+page.server';
const beforeRecord = {
id: 'set-1',
name: 'Pulse Matrix',
specialization_ability_id: 'ability-1',
active: 1,
};
const afterRecord = { ...beforeRecord, updated_at: '2026-08-02T12:00:00.000Z' };
const existingTalent = {
id: 'talent-1',
skill_chip_set_id: 'set-1',
name: 'Signal Cascade',
description_template: 'Existing Talent',
};
const twoPiece = {
skill_chip_set_id: 'set-1',
stat_attribute_id: 'skill-duration',
min_value: 5,
max_value: 10,
};
const talentEffects = [
{
id: 'effect-1',
talent_id: 'talent-1',
stat_attribute_id: 'skill-intensity',
min_value: 1,
max_value: 2,
sort_order: 1,
},
];
const conditional = { id: 'rule-1', source_id: 'talent-1' };
const saveRequest = (confirmRemoval = false) => {
const form = new FormData();
form.set('id', 'set-1');
form.set('name', 'Pulse Matrix');
form.set('abilityId', 'ability-1');
form.set('twoPieceStatId', 'skill-duration');
form.set('twoPieceMin', '5');
form.set('twoPieceMax', '10');
form.set('hasTalent', '0');
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
return new Request('http://localhost/isac/skillchips?/save', {
method: 'POST',
body: form,
});
};
const runSave = (confirmRemoval = false) =>
actions.save?.({
cookies: {} as never,
request: saveRequest(confirmRemoval),
} as never);
describe('Skill Chip Talent removal', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.events.length = 0;
mocks.talentExists = true;
mocks.auditRecord
.mockResolvedValueOnce(beforeRecord)
.mockResolvedValueOnce(afterRecord);
mocks.conditionalRuleSnapshot.mockResolvedValue(conditional);
mocks.database.prepare.mockImplementation(async (sql: string) => ({
get: vi.fn(async (...args: unknown[]) => {
if (sql.includes('FROM specialization_abilities'))
return { id: 'ability-1' };
if (sql.includes('COUNT(*) AS count FROM skill_chip_sets'))
return { count: 2 };
if (sql.includes('FROM skill_chip_sets WHERE name=')) return null;
if (sql.includes('FROM skill_chip_talents WHERE skill_chip_set_id='))
return mocks.talentExists ? existingTalent : null;
if (sql.includes('COUNT(*) AS count FROM stat_attributes'))
return { count: args.length };
if (sql.includes('FROM managed_images')) return null;
if (sql.includes('FROM skill_chip_two_piece_bonuses')) return twoPiece;
return null;
}),
all: vi.fn(async () => {
if (sql.includes('FROM skill_chip_talent_effects'))
return talentEffects;
return [];
}),
run: vi.fn(async () => {
if (sql.includes('DELETE FROM conditional_effect_rules'))
mocks.events.push('delete conditional rule');
if (sql.includes('DELETE FROM skill_chip_talents')) {
mocks.events.push('delete talent');
mocks.talentExists = false;
}
}),
}));
});
it('requires explicit confirmation before removing an existing Talent', async () => {
const result = await runSave();
expect(result).toMatchObject({
status: 400,
data: {
skillChipError:
'Confirm removal of the existing 3-piece Talent and mechanic program.',
},
});
expect(mocks.database.exec).not.toHaveBeenCalled();
expect(mocks.events).not.toContain('delete conditional rule');
expect(mocks.events).not.toContain('delete talent');
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
});
it('removes the Talent and mechanic in one audited transaction after confirmation', async () => {
const result = await runSave(true);
expect(result).toMatchObject({
skillChipSaved: true,
skillChipMode: 'edit',
resultToken: expect.any(String),
});
expect(mocks.events).toEqual([
'BEGIN',
'delete conditional rule',
'delete talent',
'audit',
'COMMIT',
]);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
actorId: 'operator-1',
category: 'game_data',
action: 'skill_chip.updated',
entityId: 'set-1',
before: {
record: beforeRecord,
icon: null,
render: null,
twoPiece,
talent: existingTalent,
talentEffects,
conditional,
},
after: {
record: afterRecord,
icon: null,
render: null,
twoPiece,
talent: null,
talentEffects: [],
conditional: null,
},
database: mocks.database,
});
});
});
+5 -3
View File
@@ -211,7 +211,7 @@
nodeEditing = null;
};
const editSpecialization = (item: any) => {
specializationEditing = { id: item.id, name: item.name, description: item.description ?? '', active: Number(item.active), iconUrl: item.iconUrl ?? '' };
specializationEditing = { id: item.id, name: item.name, description: item.description ?? '', active: Number(item.active), iconUrl: item.iconUrl ?? '', renderUrl: item.renderUrl ?? '' };
focusEditing = abilityEditing = nodeEditing = null;
};
const editFocus = (item: any) => {
@@ -249,7 +249,8 @@
} : blankAbility(item.specialization_id).attack,
effects: (item.effects ?? []).map(normalizedEffect),
active: Number(item.active),
iconUrl: item.iconUrl ?? ''
iconUrl: item.iconUrl ?? '',
renderUrl: item.renderUrl ?? ''
};
specializationEditing = focusEditing = nodeEditing = null;
combatEffectEditing = null;
@@ -284,7 +285,8 @@
costs: { ...(rank.costs ?? {}) }
})),
active: Number(item.active),
iconUrl: item.iconUrl ?? ''
iconUrl: item.iconUrl ?? '',
renderUrl: item.renderUrl ?? ''
};
specializationEditing = focusEditing = abilityEditing = null;
};
+183 -114
View File
@@ -21,18 +21,29 @@ import {
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import {
MOD_QUALITIES,
normalizeModSecondaryPool,
} from '$lib/mods/secondary-pool';
import {
getModSecondaryPool,
replaceModSecondaryPool,
} from '$lib/server/mod-secondary-pools';
import {
applyIconMutation,
gameDataIconMetadata,
gameDataIconUrl,
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
WEAPON_MOD_SLOT_DEFINITIONS,
WEAPON_MOD_SLOT_TYPES,
weaponModSlotDefinitionById,
} from '$lib/weapons/mod-slots';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const;
const slots = [
'Optics',
'Muzzle',
'Underbarrel',
'Magazine',
'Barrel',
'Upper Receiver',
'Lower Receiver',
'Trigger',
] as const;
const qualities = MOD_QUALITIES;
const slots = WEAPON_MOD_SLOT_TYPES;
const weaponCategories = [
'shotgun',
'rifle',
@@ -49,10 +60,6 @@ type QualityValue = {
primaryLevel20Max: number;
};
type Effect = { statId: string; values: Record<string, number> };
type SecondaryRange = {
statId: string;
values: Record<string, { min: number; max: number }>;
};
type ExoticAttribute = {
statId: string;
level1Value: number;
@@ -86,16 +93,6 @@ async function snapshot(id: string) {
'SELECT * FROM weapon_mod_quality_values WHERE weapon_mod_id=? ORDER BY quality',
)
).all(id),
secondaryOptions: await (
await database.prepare(
'SELECT stat_attribute_id FROM weapon_mod_secondary_options WHERE weapon_mod_id=? ORDER BY stat_attribute_id',
)
).all(id),
secondaryQualityValues: await (
await database.prepare(
'SELECT * FROM weapon_mod_secondary_quality_values WHERE weapon_mod_id=? ORDER BY stat_attribute_id,quality',
)
).all(id),
exoticAttributes: await (
await database.prepare(
'SELECT * FROM weapon_mod_exotic_attributes WHERE weapon_mod_id=? ORDER BY sort_order',
@@ -146,10 +143,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
asc: 'CASE WHEN mods.exotic_weapon_id IS NULL THEN 0 ELSE 1 END ASC,mods.name COLLATE NOCASE ASC',
desc: 'CASE WHEN mods.exotic_weapon_id IS NULL THEN 0 ELSE 1 END DESC,mods.name COLLATE NOCASE ASC',
},
secondaries: {
asc: '(SELECT COUNT(*) FROM weapon_mod_secondary_options option WHERE option.weapon_mod_id=mods.id) ASC,mods.name COLLATE NOCASE ASC',
desc: '(SELECT COUNT(*) FROM weapon_mod_secondary_options option WHERE option.weapon_mod_id=mods.id) DESC,mods.name COLLATE NOCASE ASC',
},
compatibility: {
asc: "COALESCE(weapons.name,(SELECT MIN(link.weapon_category) FROM weapon_mod_compatible_categories link WHERE link.weapon_mod_id=mods.id),'') COLLATE NOCASE ASC,mods.name COLLATE NOCASE ASC",
desc: "COALESCE(weapons.name,(SELECT MIN(link.weapon_category) FROM weapon_mod_compatible_categories link WHERE link.weapon_mod_id=mods.id),'') COLLATE NOCASE DESC,mods.name COLLATE NOCASE ASC",
@@ -229,7 +222,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'exoticWeaponId',
'compatibleCategories',
'qualityValues',
'secondaryRanges',
'effects',
'exoticAttributes',
],
@@ -260,14 +252,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
mods,
compatibility,
qualityRows,
secondaryOptions,
secondaryValues,
exoticAttributes,
effects,
effectValues,
stats,
weapons,
renders,
slotIcons,
secondaryPool,
] = await Promise.all([
(
await database.prepare(`SELECT mods.*,weapons.name AS exotic_weapon_name FROM weapon_mods mods
@@ -278,14 +270,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
await database.prepare('SELECT * FROM weapon_mod_compatible_categories')
).all(),
(await database.prepare('SELECT * FROM weapon_mod_quality_values')).all(),
(
await database.prepare('SELECT * FROM weapon_mod_secondary_options')
).all(),
(
await database.prepare(
'SELECT * FROM weapon_mod_secondary_quality_values',
)
).all(),
(
await database.prepare(
'SELECT * FROM weapon_mod_exotic_attributes ORDER BY sort_order',
@@ -314,6 +298,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='weapon_mod_render'",
)
).all(),
(
await database.prepare(
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='weapon_mod_slot'",
)
).all(),
getModSecondaryPool(database, 'weapon'),
]);
const availableWeapons = [
...pendingWeapons.map((item) => ({
@@ -331,7 +321,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
?.name ?? '',
compatibleCategories: item.compatibleCategories,
qualityValues: parseJson(String(item.qualityValues), []),
secondaryRanges: parseJson(String(item.secondaryRanges), []),
exoticAttributes: parseJson(String(item.exoticAttributes), []),
effects: parseJson(String(item.effects), []),
}));
@@ -352,23 +341,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
primaryLevel20Min: row.primary_level_20_min,
primaryLevel20Max: row.primary_level_20_max,
})),
secondaryRanges: secondaryOptions
.filter((option) => option.weapon_mod_id === mod.id)
.map((option) => ({
statId: String(option.stat_attribute_id),
values: Object.fromEntries(
secondaryValues
.filter(
(row) =>
row.weapon_mod_id === mod.id &&
row.stat_attribute_id === option.stat_attribute_id,
)
.map((row) => [
String(row.quality),
{ min: Number(row.min_value), max: Number(row.max_value) },
]),
),
})),
exoticAttributes: exoticAttributes
.filter((row) => row.weapon_mod_id === mod.id)
.map((row) => ({
@@ -396,9 +368,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
})(),
})),
],
secondaryPool,
stats: [...pendingStats, ...stats],
weapons: availableWeapons,
slots: slots.map((name) => ({ id: name, name })),
slots: WEAPON_MOD_SLOT_DEFINITIONS.map((definition) => {
const icon = slotIcons.find((row) => row.entity_id === definition.id);
return {
...definition,
iconUrl: icon
? gameDataIconUrl('weapon_mod_slot', definition.id, icon.updated_at)
: '',
updatedAt: icon ? String(icon.updated_at) : '',
};
}),
qualities,
weaponCategories: weaponCategories.map((name) => ({
id: name,
@@ -418,6 +400,141 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
};
export const actions: Actions = {
saveSlotIcon: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const form = await request.formData();
const id = String(form.get('id') ?? '');
const slotType = String(form.get('slotType') ?? '');
const definition = weaponModSlotDefinitionById(id);
if (!definition || definition.name !== slotType) {
return fail(400, {
weaponModSlotIconError: 'Choose a valid Weapon Mod slot icon target.',
});
}
let mutation: Awaited<ReturnType<typeof readIconMutation>>;
try {
mutation = await readIconMutation(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
return fail(400, { weaponModSlotIconError: error.message });
throw error;
}
if (!mutation.output && !mutation.remove) {
return fail(400, {
weaponModSlotIconError:
'Choose an icon to upload or remove the current icon.',
});
}
const database = await getDatabase();
await database.exec('BEGIN IMMEDIATE');
try {
const beforeIcon = await gameDataIconMetadata(
database,
'weapon_mod_slot',
definition.id,
);
const baseUpdatedAt = String(form.get('baseUpdatedAt') ?? '');
if (String(beforeIcon?.updated_at ?? '') !== baseUpdatedAt) {
await database.exec('ROLLBACK');
return fail(409, {
weaponModSlotIconError:
'This slot icon changed after the editor opened. Review the latest icon and try again.',
});
}
if (mutation.remove && !beforeIcon) {
await database.exec('ROLLBACK');
return fail(400, {
weaponModSlotIconError: 'This slot does not have an icon to remove.',
});
}
await applyIconMutation(
database,
'weapon_mod_slot',
definition.id,
mutation,
operator.id,
);
const afterIcon = await gameDataIconMetadata(
database,
'weapon_mod_slot',
definition.id,
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'weapon_mod_slot_icon.updated',
entityId: definition.id,
before: { slot: definition, icon: beforeIcon },
after: { slot: definition, icon: afterIcon },
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return {
weaponModSlotIconSaved: true,
resultToken: randomUUID(),
};
},
saveSecondaryPool: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const form = await request.formData();
if (String(form.get('id') ?? '') !== 'weapon-mod-secondary-pool') {
return fail(400, {
weaponModSecondaryPoolError:
'The shared Weapon Mod pool target is invalid.',
});
}
const normalized = normalizeModSecondaryPool(
parseJson<unknown>(form.get('secondaryRanges'), null),
);
if (normalized.error) {
return fail(400, { weaponModSecondaryPoolError: normalized.error });
}
const database = await getDatabase();
const statIds = normalized.pool.map((range) => range.statId);
const validStats = await (
await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)
).get(...statIds);
if (Number(validStats?.count) !== statIds.length) {
return fail(400, {
weaponModSecondaryPoolError:
'One or more selected Attributes are unavailable.',
});
}
await database.exec('BEGIN IMMEDIATE');
try {
const before = await getModSecondaryPool(database, 'weapon');
await replaceModSecondaryPool(database, 'weapon', normalized.pool);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'weapon_mod_secondary_pool.updated',
entityId: 'weapon-mod-secondary-pool',
before,
after: await getModSecondaryPool(database, 'weapon'),
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return {
weaponModSecondaryPoolSaved: true,
weaponModSecondaryPoolMode: 'edit',
resultToken: randomUUID(),
};
},
save: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
@@ -442,6 +559,12 @@ export const actions: Actions = {
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
if (form.has('secondaryRanges')) {
return fail(400, {
weaponModError:
'Secondary Attributes must be submitted through the shared pool editor.',
});
}
const slotType = String(form.get('slotType') ?? '');
const isExotic = form.get('isExotic') === '1';
const exoticWeaponId = String(form.get('exoticWeaponId') ?? '');
@@ -454,10 +577,6 @@ export const actions: Actions = {
form.get('qualityValues'),
[],
);
const secondaryRanges = parseJson<SecondaryRange[]>(
form.get('secondaryRanges'),
[],
);
const effects = parseJson<Effect[]>(form.get('effects'), []);
const exoticAttributes = parseJson<ExoticAttribute[]>(
form.get('exoticAttributes'),
@@ -478,30 +597,6 @@ export const actions: Actions = {
weaponModError:
'Exotic weapon assignment is only available for Exotic Weapon Mods.',
});
if (
!isExotic &&
(secondaryRanges.length < 2 ||
secondaryRanges.length > 50 ||
new Set(secondaryRanges.map((range) => range.statId)).size !==
secondaryRanges.length ||
secondaryRanges.some(
(range) =>
!range.statId ||
qualities.some((quality) => {
const values = range.values?.[quality];
return (
!values ||
!Number.isFinite(Number(values.min)) ||
!Number.isFinite(Number(values.max)) ||
Number(values.max) < Number(values.min)
);
}),
))
)
return fail(400, {
weaponModError:
'Configure at least two unique possible secondary attributes with valid ranges for every quality.',
});
if (isExotic && compatibleCategories.length)
return fail(400, {
weaponModError:
@@ -599,10 +694,7 @@ export const actions: Actions = {
...new Set(
isExotic
? exoticAttributes.map((attribute) => attribute.statId)
: [
...secondaryRanges.map((range) => range.statId),
...effects.map((effect) => effect.statId),
],
: effects.map((effect) => effect.statId),
),
];
const validStats = await (
@@ -671,29 +763,6 @@ export const actions: Actions = {
row.primaryLevel20Min,
row.primaryLevel20Max,
);
await (
await database.prepare(
'DELETE FROM weapon_mod_secondary_options WHERE weapon_mod_id=?',
)
).run(modId);
for (const range of isExotic ? [] : secondaryRanges) {
await (
await database.prepare(
'INSERT INTO weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) VALUES(?,?)',
)
).run(modId, range.statId);
for (const quality of qualities)
await (
await database.prepare(`INSERT INTO weapon_mod_secondary_quality_values
(weapon_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`)
).run(
modId,
range.statId,
quality,
range.values[quality].min,
range.values[quality].max,
);
}
await (
await database.prepare(
'DELETE FROM weapon_mod_effects WHERE weapon_mod_id=?',
+10 -20
View File
@@ -9,19 +9,20 @@
import GameIcon from '$lib/components/GameIcon.svelte';
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
import type { GameIconName } from '$lib/game-icons';
import ModSecondaryPoolEditor from '$lib/components/ModSecondaryPoolEditor.svelte';
import WeaponModSlotIconPanel from '$lib/components/WeaponModSlotIconPanel.svelte';
let { data, form } = $props();
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const qualityRow = (quality: string) => ({
quality, primaryLevel1Min: 0, primaryLevel1Max: 0, primaryLevel20Min: 0, primaryLevel20Max: 0
});
const blankSecondary = () => ({ statId: '', values: Object.fromEntries(data.qualities.map((quality: string) => [quality,{ min:0,max:0 }])) });
const blankEffect = () => ({ statId: '', values: Object.fromEntries(data.qualities.map((quality: string) => [quality, 0])) });
const blankExoticAttribute = () => ({ statId:'',level1Value:0,level20Value:0 });
const blank = () => ({
id: '', name: '', active: 1, slot_type: '', exotic_weapon_id: '', renderUrl: '',
compatibleCategories: [], qualityValues: data.qualities.map(qualityRow),
secondaryRanges: [blankSecondary(),blankSecondary()], effects: [blankEffect()], exoticAttributes:[blankExoticAttribute()]
effects: [blankEffect()], exoticAttributes:[blankExoticAttribute()]
});
let editing = $state<any | null>(null);
let levelPreview = $state(1);
@@ -31,9 +32,8 @@
let renderResetToken = $state(0);
let openedRequestedItem = $state(false);
const categoryOptions = $derived(data.weaponCategories.map((category: any) => ({ value:category.id,label:category.name,icon:category.id as GameIconName,iconOnly:true })));
const slotOptions = $derived(data.slots.map((slot: any) => ({ value:slot.id,label:slot.name.toUpperCase() })));
const slotOptions = $derived(data.slots.map((slot: any) => ({ value:slot.name,label:slot.name.toUpperCase() })));
const exoticWeapons = $derived(data.weapons.filter((weapon: any) => Number(weapon.is_exotic)));
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
const weapon = (id: string) => data.weapons.find((item: any) => item.id === id);
const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview));
const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19;
@@ -43,7 +43,6 @@
...item,
compatibleCategories: [...item.compatibleCategories],
qualityValues: data.qualities.map((quality: string) => ({ ...qualityRow(quality), ...(item.qualityValues.find((row: any) => row.quality === quality) ?? {}) })),
secondaryRanges: item.secondaryRanges.length ? item.secondaryRanges.map((range: any) => ({ statId:range.statId,values:structuredClone(range.values) })) : [blankSecondary(),blankSecondary()],
exoticAttributes: item.exoticAttributes.length ? item.exoticAttributes.map((attribute: any)=>({...attribute})) : [blankExoticAttribute()],
effects: item.effects.length ? item.effects.map((effect: any) => ({ statId: effect.statId, values: { ...effect.values } })) : [blankEffect()]
};
@@ -58,8 +57,6 @@
};
const addEffect = () => { if (editing.effects.length < 2) editing.effects = [...editing.effects, blankEffect()]; };
const removeEffect = (index: number) => { if (editing.effects.length > 1) editing.effects = editing.effects.filter((_: any, itemIndex: number) => itemIndex !== index); };
const addSecondary = () => { if (editing.secondaryRanges.length < 50) editing.secondaryRanges = [...editing.secondaryRanges,blankSecondary()]; };
const removeSecondary = (index: number) => { if (editing.secondaryRanges.length > 2) editing.secondaryRanges = editing.secondaryRanges.filter((_: any,itemIndex: number) => itemIndex !== index); };
const addExoticAttribute = () => { if(editing.exoticAttributes.length<12) editing.exoticAttributes=[...editing.exoticAttributes,blankExoticAttribute()]; };
const removeExoticAttribute = (index:number) => { if(editing.exoticAttributes.length>1) editing.exoticAttributes=editing.exoticAttributes.filter((_:any,itemIndex:number)=>itemIndex!==index); };
$effect(() => {
@@ -81,6 +78,9 @@
<svelte:head><title>Weapon Mods — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ARMAMENT MODIFICATION REGISTRY</p><h2>WEAPON MODS</h2><p>Configure tier-aware slot compatibility, quality-scaled rolls, and fixed Mod Effects.</p></div><button class="primary" onclick={() => { editing = blank(); isExotic = false; }}>ADD WEAPON MOD <span>+</span></button></div>
<WeaponModSlotIconPanel slots={data.slots} error={form?.weaponModSlotIconError} saved={form?.weaponModSlotIconSaved} resultToken={form?.resultToken}/>
<ModSecondaryPoolEditor kind="Weapon Mod" pool={data.secondaryPool} stats={data.stats} error={form?.weaponModSecondaryPoolError} saved={form?.weaponModSecondaryPoolSaved} resultToken={form?.resultToken}/>
<KpiFilters active={data.filter} items={[
{value:'',label:'TOTAL MODS',count:data.summary.total,description:'Show all definitions'},
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to weapons'},
@@ -96,7 +96,6 @@
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="isExotic" value={isExotic ? '1' : '0'}/>
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
<input type="hidden" name="secondaryRanges" value={JSON.stringify(editing.secondaryRanges)}/>
<input type="hidden" name="effects" value={JSON.stringify(editing.effects)}/>
<input type="hidden" name="exoticAttributes" value={JSON.stringify(editing.exoticAttributes)}/>
<div class="weapon-mod-identity">
@@ -130,16 +129,7 @@
</div>
<div class="weapon-mod-preview">
<div><label>LEVEL PREVIEW <b>{levelPreview}</b><input type="range" min="1" max="20" step="1" bind:value={levelPreview}/></label><SegmentedChoice legend="QUALITY PREVIEW" name="qualityPreview" options={data.qualities.map((quality: string) => ({ value:quality,label:quality }))} bind:value={qualityPreview}/></div>
{#if previewRow}<div class="weapon-mod-preview-values"><span><div class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></div><small>SELECTED PRIMARY · LEVEL {levelPreview}</small><b>{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}</b></span><span><small>SECONDARY ATTRIBUTE POOL</small><b>{editing.secondaryRanges.length} OPTIONS</b></span></div>{/if}
</div>
</section>
<section class="weapon-mod-secondary-section">
<div class="selection-head"><div><span>SECONDARY ATTRIBUTE POOL</span><small>THE BUILDER SELECTS TWO DISTINCT ATTRIBUTES · EVERY OPTION REQUIRES A RANGE AT EACH QUALITY</small></div><button type="button" class="row-action" onclick={addSecondary} disabled={editing.secondaryRanges.length>=50}>ADD ATTRIBUTE +</button></div>
<div class="weapon-mod-secondary-options">
{#each editing.secondaryRanges as range,index}
<div><strong>{String(index+1).padStart(2,'0')}</strong><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={editing.secondaryRanges.filter((_: any,itemIndex: number)=>itemIndex!==index).map((item: any)=>item.statId)} bind:value={range.statId}/>{#each data.qualities as quality}<label><span class={qualityClass(quality)}>{quality.toUpperCase()}</span><div><input type="number" step="any" bind:value={range.values[quality].min} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].max} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} maximum`} required/></div></label>{/each}<button type="button" aria-label={`Remove secondary attribute option ${index+1}`} onclick={()=>removeSecondary(index)} disabled={editing.secondaryRanges.length<=2}>×</button></div>
{/each}
{#if previewRow}<div class="weapon-mod-preview-values"><span><div class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></div><small>SELECTED PRIMARY · LEVEL {levelPreview}</small><b>{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}</b></span><span><small>SHARED SECONDARY POOL</small><b>{data.secondaryPool.length} OPTIONS</b></span></div>{/if}
</div>
</section>
@@ -166,9 +156,9 @@
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table weapon-mod-dashboard-table">
<div class="dashboard-row weapon-mod-row table-heading"><SortableColumnHeader label="WEAPON MOD" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><SortableColumnHeader label="COMPATIBILITY" column="compatibility" sort={data.sort}/><SortableColumnHeader label="MOD EFFECT" column="effects" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
<div class="dashboard-row weapon-mod-row table-heading"><SortableColumnHeader label="WEAPON MOD" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="PRIMARY" column="primary" sort={data.sort}/><span>SECONDARIES</span><SortableColumnHeader label="COMPATIBILITY" column="compatibility" sort={data.sort}/><SortableColumnHeader label="MOD EFFECT" column="effects" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.mods as item}
<div class="dashboard-row weapon-mod-row" class:archived={Number(item.active)!==1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</span><b class:pending={item.pending} class="state-dot">{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`weapon-mod-editor-${item.id}`}>EDIT</button>{/if}</div>
<div class="dashboard-row weapon-mod-row" class:archived={Number(item.active)!==1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${data.secondaryPool.length} SHARED`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : '0 / 1 / 2 / 2'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</span><b class:pending={item.pending} class="state-dot">{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`weapon-mod-editor-${item.id}`}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}
<RegistryInlineEditor id={`weapon-mod-editor-${item.id}`} label={`Edit ${item.name}`}>
{@render weaponModEditor()}
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
audits: [] as Array<Record<string, unknown>>,
database: {
exec: vi.fn(async () => undefined),
prepare: vi.fn(async () => ({
get: vi.fn(async (...statIds: unknown[]) => ({ count: statIds.length })),
})),
},
getModSecondaryPool: vi.fn(),
recordIsacAudit: vi.fn(),
replaceModSecondaryPool: vi.fn(),
requirePermission: vi.fn(async (user) => user),
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: mocks.requirePermission,
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/server/audit', () => ({
auditRecord: vi.fn(),
recordIsacAudit: mocks.recordIsacAudit,
}));
vi.mock('$lib/server/data-moderation', () => ({
getPendingEntities: vi.fn(async () => []),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: vi.fn(),
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(),
}));
vi.mock('$lib/server/mod-secondary-pools', () => ({
getModSecondaryPool: mocks.getModSecondaryPool,
replaceModSecondaryPool: mocks.replaceModSecondaryPool,
}));
import { actions } from './+page.server';
const endpoints = (
level1Min: number,
level1Max: number,
level20Min: number,
level20Max: number,
) => ({ level1Min, level1Max, level20Min, level20Max });
const pool = (suffix: string, offset = 0) => [
{
statId: `critical-hit-chance-${suffix}`,
values: {
Specialized: endpoints(1 + offset, 2 + offset, 3 + offset, 4 + offset),
Superior: endpoints(2 + offset, 3 + offset, 4 + offset, 5 + offset),
'High-end': endpoints(3 + offset, 4 + offset, 5 + offset, 6 + offset),
},
},
{
statId: `critical-hit-damage-${suffix}`,
values: {
Specialized: endpoints(4 + offset, 5 + offset, 6 + offset, 7 + offset),
Superior: endpoints(5 + offset, 6 + offset, 7 + offset, 8 + offset),
'High-end': endpoints(6 + offset, 7 + offset, 8 + offset, 9 + offset),
},
},
];
const request = (secondaryRanges: ReturnType<typeof pool>) => {
const form = new FormData();
form.set('id', 'weapon-mod-secondary-pool');
form.set('secondaryRanges', JSON.stringify(secondaryRanges));
return new Request('http://localhost/isac/weaponmods?/saveSecondaryPool', {
method: 'POST',
body: form,
});
};
describe('Weapon Mod shared secondary pool action', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.audits.length = 0;
mocks.recordIsacAudit.mockImplementation(async (event) => {
mocks.audits.push(event);
});
});
it('audits the complete contract and commits the transaction', async () => {
const before = pool('before');
const after = pool('after', 1);
mocks.getModSecondaryPool
.mockResolvedValueOnce(before)
.mockResolvedValueOnce(after);
const result = await actions.saveSecondaryPool?.({
cookies: {} as never,
request: request(after),
} as never);
expect(result).toMatchObject({
weaponModSecondaryPoolSaved: true,
weaponModSecondaryPoolMode: 'edit',
resultToken: expect.any(String),
});
expect(mocks.requirePermission).toHaveBeenCalledWith(
{ id: 'operator-1' },
'data.manage',
);
expect(mocks.replaceModSecondaryPool).toHaveBeenCalledWith(
mocks.database,
'weapon',
after,
);
expect(mocks.audits).toEqual([
{
actorId: 'operator-1',
category: 'game_data',
action: 'weapon_mod_secondary_pool.updated',
entityId: 'weapon-mod-secondary-pool',
before,
after,
database: mocks.database,
},
]);
expect(mocks.database.exec.mock.calls).toEqual([
['BEGIN IMMEDIATE'],
['COMMIT'],
]);
});
it('rolls back without committing when the audit write fails', async () => {
const before = pool('before');
const after = pool('after', 1);
const auditError = new Error('audit insert failed');
mocks.getModSecondaryPool
.mockResolvedValueOnce(before)
.mockResolvedValueOnce(after);
mocks.recordIsacAudit.mockRejectedValueOnce(auditError);
await expect(
actions.saveSecondaryPool?.({
cookies: {} as never,
request: request(after),
} as never),
).rejects.toThrow(auditError);
expect(mocks.recordIsacAudit).toHaveBeenCalledOnce();
expect(mocks.database.exec).toHaveBeenCalledWith('ROLLBACK');
expect(mocks.database.exec).not.toHaveBeenCalledWith('COMMIT');
expect(mocks.database.exec.mock.calls).toEqual([
['BEGIN IMMEDIATE'],
['ROLLBACK'],
]);
});
});
@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WEAPON_MOD_SLOT_DEFINITIONS } from '$lib/weapons/mod-slots';
const mocks = vi.hoisted(() => ({
applyIconMutation: vi.fn(),
database: {
exec: vi.fn(async () => undefined),
prepare: vi.fn(async () => ({ get: vi.fn(), all: vi.fn() })),
},
gameDataIconMetadata: vi.fn(),
readIconMutation: vi.fn(),
recordIsacAudit: vi.fn(),
requirePermission: vi.fn(async (user) => user),
}));
vi.mock('$lib/server/auth/session', () => ({
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
}));
vi.mock('$lib/server/auth/authorization', () => ({
requireDataView: vi.fn(async (user) => user),
requirePermission: mocks.requirePermission,
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => mocks.database),
}));
vi.mock('$lib/server/audit', () => ({
auditRecord: vi.fn(),
recordIsacAudit: mocks.recordIsacAudit,
}));
vi.mock('$lib/server/data-moderation', () => ({
getPendingEntities: vi.fn(async () => []),
}));
vi.mock('$lib/server/managed-images', () => ({
applyGameDataRender: vi.fn(),
ManagedImageValidationError: class extends Error {},
managedImageUrl: vi.fn(() => ''),
processManagedImage: vi.fn(),
readGameDataRenderMutation: vi.fn(),
}));
vi.mock('$lib/server/game-data-icons', () => ({
applyIconMutation: mocks.applyIconMutation,
gameDataIconMetadata: mocks.gameDataIconMetadata,
gameDataIconUrl: vi.fn(() => ''),
GameDataIconValidationError: class extends Error {},
readIconMutation: mocks.readIconMutation,
}));
vi.mock('$lib/server/mod-secondary-pools', () => ({
getModSecondaryPool: vi.fn(),
replaceModSecondaryPool: vi.fn(),
}));
import { actions } from './+page.server';
const definition = WEAPON_MOD_SLOT_DEFINITIONS[0];
function actionRequest(overrides: Record<string, string> = {}) {
const form = new FormData();
form.set('id', overrides.id ?? definition.id);
form.set('name', `${definition.name} Slot`);
form.set('slotType', overrides.slotType ?? definition.name);
form.set('baseUpdatedAt', overrides.baseUpdatedAt ?? 'before-version');
return new Request('http://localhost/isac/weaponmods?/saveSlotIcon', {
method: 'POST',
body: form,
});
}
const invoke = (request = actionRequest()) =>
actions.saveSlotIcon?.({ cookies: {} as never, request } as never);
describe('Weapon Mod shared slot icon action', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.gameDataIconMetadata.mockReset();
mocks.readIconMutation.mockReset();
mocks.recordIsacAudit.mockReset();
mocks.readIconMutation.mockResolvedValue({
output: Buffer.from('icon'),
remove: false,
});
mocks.gameDataIconMetadata
.mockResolvedValueOnce({ byte_size: 10, updated_at: 'before-version' })
.mockResolvedValueOnce({ byte_size: 20, updated_at: 'after-version' });
});
it('validates the target, saves, audits, and commits', async () => {
const result = await invoke();
expect(result).toMatchObject({
weaponModSlotIconSaved: true,
resultToken: expect.any(String),
});
expect(mocks.requirePermission).toHaveBeenCalledWith(
{ id: 'operator-1' },
'data.manage',
);
expect(mocks.applyIconMutation).toHaveBeenCalledWith(
mocks.database,
'weapon_mod_slot',
definition.id,
{ output: Buffer.from('icon'), remove: false },
'operator-1',
);
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
actorId: 'operator-1',
category: 'game_data',
action: 'weapon_mod_slot_icon.updated',
entityId: definition.id,
before: {
slot: definition,
icon: { byte_size: 10, updated_at: 'before-version' },
},
after: {
slot: definition,
icon: { byte_size: 20, updated_at: 'after-version' },
},
database: mocks.database,
});
expect(mocks.database.exec.mock.calls).toEqual([
['BEGIN IMMEDIATE'],
['COMMIT'],
]);
});
it('rejects a mismatched slot identity before reading the upload', async () => {
const result = await invoke(actionRequest({ slotType: 'Muzzle' }));
expect(result).toMatchObject({ status: 400 });
expect(mocks.readIconMutation).not.toHaveBeenCalled();
expect(mocks.database.exec).not.toHaveBeenCalled();
});
it('rejects stale editor data without changing the icon', async () => {
const result = await invoke(
actionRequest({ baseUpdatedAt: 'stale-version' }),
);
expect(result).toMatchObject({ status: 409 });
expect(mocks.applyIconMutation).not.toHaveBeenCalled();
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
expect(mocks.database.exec.mock.calls).toEqual([
['BEGIN IMMEDIATE'],
['ROLLBACK'],
]);
});
it('rolls back when audit persistence fails', async () => {
const auditError = new Error('audit failed');
mocks.recordIsacAudit.mockRejectedValueOnce(auditError);
await expect(invoke()).rejects.toThrow(auditError);
expect(mocks.database.exec.mock.calls).toEqual([
['BEGIN IMMEDIATE'],
['ROLLBACK'],
]);
});
});