feat: expand ISAC content modeling
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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=?',
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user