feat: add community publishing and news syndication
Validate and deploy production / validate (push) Successful in 23s
Validate and deploy staging / validate (push) Successful in 50s
Validate and deploy production / deploy (push) Failing after 40s
Validate and deploy staging / deploy (push) Failing after 7s

This commit is contained in:
jb
2026-08-01 21:42:09 -04:00
parent b47e2588e9
commit dd84dff055
288 changed files with 83254 additions and 38351 deletions
+18172 -2070
View File
File diff suppressed because one or more lines are too long
+10 -10
View File
@@ -1,16 +1,16 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
interface Error {
message: string;
errorId?: string;
}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
namespace App {
interface Error {
message: string;
errorId?: string;
}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+9 -9
View File
@@ -1,12 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+137 -9
View File
@@ -1,12 +1,140 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>%sveltekit.status% · SHD.build system fault</title>
<style>
:root{color-scheme:dark;font-family:Arial,Helvetica,sans-serif;background:#0a0c0d;color:#ecebe5}*{box-sizing:border-box}body{min-height:100vh;margin:0;display:grid;place-items:center;padding:24px;background:linear-gradient(135deg,#101314 0,#0a0c0d 55%)}body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.08;background-image:linear-gradient(#ffffff14 1px,transparent 1px),linear-gradient(90deg,#ffffff14 1px,transparent 1px);background-size:48px 48px}main{position:relative;width:min(760px,100%);border:1px solid #3b4142;border-top:2px solid #df8069;background:#111415}header{height:64px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;border-bottom:1px solid #303536}header div{display:grid;gap:4px}header span{color:#858b8d;font-size:8px;font-weight:900;letter-spacing:1.5px}header strong{color:#df8069;font-size:11px;letter-spacing:1px}header b{font-size:32px;color:#df8069}section{padding:clamp(28px,6vw,58px)}p{color:#858b8d;font:15px/1.6 Georgia,serif}small{display:block;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.8px}h1{max-width:620px;margin:14px 0;font-size:clamp(34px,7vw,62px);line-height:.92;letter-spacing:-2px}a{display:inline-block;margin-top:20px;padding:14px 18px;background:#f6a821;color:#111;font-size:9px;font-weight:900;letter-spacing:1.2px;text-decoration:none}footer{display:flex;align-items:center;gap:8px;padding:12px 20px;border-top:1px solid #303536;color:#697174;font-size:7px;letter-spacing:1px}footer i{width:6px;height:6px;background:#df8069;box-shadow:0 0 7px #df8069}
</style>
</head>
<body><main><header><div><span>ISAC FALLBACK CHANNEL</span><strong>CORE SHELL INTERRUPTED</strong></div><b>%sveltekit.status%</b></header><section><small>SHD NETWORK RESPONSE</small><h1>SYSTEM INTERFACE UNAVAILABLE</h1><p>%sveltekit.error.message%</p><a href="/">RETURN TO SHD.BUILD →</a></section><footer><i></i> FAULT ISOLATED · CORE SERVICES PROTECTED</footer></main></body>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>%sveltekit.status% · SHD.build system fault</title>
<style>
:root {
color-scheme: dark;
font-family: Arial, Helvetica, sans-serif;
background: #0a0c0d;
color: #ecebe5;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
background: linear-gradient(135deg, #101314 0, #0a0c0d 55%);
}
body:before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.08;
background-image:
linear-gradient(#ffffff14 1px, transparent 1px),
linear-gradient(90deg, #ffffff14 1px, transparent 1px);
background-size: 48px 48px;
}
main {
position: relative;
width: min(760px, 100%);
border: 1px solid #3b4142;
border-top: 2px solid #df8069;
background: #111415;
}
header {
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid #303536;
}
header div {
display: grid;
gap: 4px;
}
header span {
color: #858b8d;
font-size: 8px;
font-weight: 900;
letter-spacing: 1.5px;
}
header strong {
color: #df8069;
font-size: 11px;
letter-spacing: 1px;
}
header b {
font-size: 32px;
color: #df8069;
}
section {
padding: clamp(28px, 6vw, 58px);
}
p {
color: #858b8d;
font:
15px/1.6 Georgia,
serif;
}
small {
display: block;
color: #f6a821;
font-size: 8px;
font-weight: 900;
letter-spacing: 1.8px;
}
h1 {
max-width: 620px;
margin: 14px 0;
font-size: clamp(34px, 7vw, 62px);
line-height: 0.92;
letter-spacing: -2px;
}
a {
display: inline-block;
margin-top: 20px;
padding: 14px 18px;
background: #f6a821;
color: #111;
font-size: 9px;
font-weight: 900;
letter-spacing: 1.2px;
text-decoration: none;
}
footer {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid #303536;
color: #697174;
font-size: 7px;
letter-spacing: 1px;
}
footer i {
width: 6px;
height: 6px;
background: #df8069;
box-shadow: 0 0 7px #df8069;
}
</style>
</head>
<body>
<main>
<header>
<div>
<span>ISAC FALLBACK CHANNEL</span
><strong>CORE SHELL INTERRUPTED</strong>
</div>
<b>%sveltekit.status%</b>
</header>
<section>
<small>SHD NETWORK RESPONSE</small>
<h1>SYSTEM INTERFACE UNAVAILABLE</h1>
<p>%sveltekit.error.message%</p>
<a href="/">RETURN TO SHD.BUILD →</a>
</section>
<footer><i></i> FAULT ISOLATED · CORE SERVICES PROTECTED</footer>
</main>
</body>
</html>
+143 -81
View File
@@ -2,89 +2,151 @@ import { json, type Handle, type HandleServerError } from '@sveltejs/kit';
import { stringify } from 'devalue';
import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session';
import { getSiteAccessBlock, getSiteAccessSettings } from '$lib/server/site-access';
import { DATA_MODULES, proposalActionResult, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation';
import {
getSiteAccessBlock,
getSiteAccessSettings,
} from '$lib/server/site-access';
import {
DATA_MODULES,
proposalActionResult,
queueDataProposal,
shouldModerateDataMutation,
type DataModule,
} from '$lib/server/data-moderation';
export const handle: Handle = async ({ event, resolve }) => {
if (event.url.pathname === '/admin' || event.url.pathname.startsWith('/admin/')) {
const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`;
return new Response(null, { status: 308, headers: { location } });
}
const siteSettings = await getSiteAccessSettings();
const sessionUser = await getSessionUser(event.cookies);
const accessBlock = getSiteAccessBlock(siteSettings, event.url.pathname, Boolean(sessionUser));
if (accessBlock) {
const target = new URL('/coming-soon', event.url);
if (accessBlock.kind === 'section') target.searchParams.set('unavailable', accessBlock.section);
if (event.request.method === 'GET' || event.request.method === 'HEAD') {
return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } });
}
return json(
{
error: accessBlock.kind === 'section'
? 'This section is not available.'
: 'The public site is not available yet.'
},
{ status: 404 }
);
}
if (event.request.method === 'POST') {
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
const module = match?.[1] as DataModule | undefined;
const action = event.url.search.startsWith('?/') ? event.url.search.slice(2) : '';
if (module && DATA_MODULES.includes(module) && action) {
// Publishing calculation rules is never a contributor proposal. The
// action has its own dedicated permission and confirmation contract.
if (module === 'calculations' && action === 'publishRevision') return resolve(event);
const user = sessionUser;
const access = await shouldModerateDataMutation(user);
if (!access.canManage && access.canPropose && user) {
if (!access.contributorEnabled) {
if (event.request.headers.get('accept')?.includes('application/json')) {
return json({ type: 'failure', status: 403, data: { contributorModeRequired: true } }, { status: 403 });
}
return new Response('Contributor mode must be enabled before submitting data changes.', { status: 403 });
}
const submittedForm = await event.request.formData();
const proposal = await queueDataProposal(user, module, action, submittedForm);
if (event.request.headers.get('accept')?.includes('application/json')) {
const result = proposalActionResult(module, action, Boolean(proposal.entityId), proposal.id);
if (module === 'attributes' && action === 'save') {
Object.assign(result, {
savedItem: {
id: proposal.entityId ?? proposal.draftEntityId,
name: String(submittedForm.get('name') ?? ''),
type: String(submittedForm.get('type') ?? ''),
category: String(submittedForm.get('category') ?? ''),
calculation_type: String(submittedForm.get('calculationType') ?? ''),
unit: String(submittedForm.get('unit') ?? ''),
active: 1,
pending: true,
proposalId: proposal.id,
proposerName: user.displayName
}
});
}
return json({
type: 'success',
status: 200,
data: stringify(result)
});
}
const target = new URL(event.url.pathname, event.url);
target.searchParams.set('proposal', proposal.id);
return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } });
}
}
}
return resolve(event);
if (
event.url.pathname === '/admin' ||
event.url.pathname.startsWith('/admin/')
) {
const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`;
return new Response(null, { status: 308, headers: { location } });
}
const siteSettings = await getSiteAccessSettings();
const sessionUser = await getSessionUser(event.cookies);
const accessBlock = getSiteAccessBlock(
siteSettings,
event.url.pathname,
Boolean(sessionUser),
);
if (accessBlock) {
const target = new URL('/coming-soon', event.url);
if (accessBlock.kind === 'section')
target.searchParams.set('unavailable', accessBlock.section);
if (event.request.method === 'GET' || event.request.method === 'HEAD') {
return new Response(null, {
status: 303,
headers: { location: `${target.pathname}${target.search}` },
});
}
return json(
{
error:
accessBlock.kind === 'section'
? 'This section is not available.'
: 'The public site is not available yet.',
},
{ status: 404 },
);
}
if (event.request.method === 'POST') {
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
const module = match?.[1] as DataModule | undefined;
const action = event.url.search.startsWith('?/')
? event.url.search.slice(2)
: '';
if (module && DATA_MODULES.includes(module) && action) {
// Publishing calculation rules is never a contributor proposal. The
// action has its own dedicated permission and confirmation contract.
if (module === 'calculations' && action === 'publishRevision')
return resolve(event);
const user = sessionUser;
const access = await shouldModerateDataMutation(user);
if (!access.canManage && access.canPropose && user) {
if (!access.contributorEnabled) {
if (
event.request.headers.get('accept')?.includes('application/json')
) {
return json(
{
type: 'failure',
status: 403,
data: { contributorModeRequired: true },
},
{ status: 403 },
);
}
return new Response(
'Contributor mode must be enabled before submitting data changes.',
{ status: 403 },
);
}
const submittedForm = await event.request.formData();
const proposal = await queueDataProposal(
user,
module,
action,
submittedForm,
);
if (event.request.headers.get('accept')?.includes('application/json')) {
const result = proposalActionResult(
module,
action,
Boolean(proposal.entityId),
proposal.id,
);
if (module === 'attributes' && action === 'save') {
Object.assign(result, {
savedItem: {
id: proposal.entityId ?? proposal.draftEntityId,
name: String(submittedForm.get('name') ?? ''),
type: String(submittedForm.get('type') ?? ''),
category: String(submittedForm.get('category') ?? ''),
calculation_type: String(
submittedForm.get('calculationType') ?? '',
),
unit: String(submittedForm.get('unit') ?? ''),
active: 1,
pending: true,
proposalId: proposal.id,
proposerName: user.displayName,
},
});
}
return json({
type: 'success',
status: 200,
data: stringify(result),
});
}
const target = new URL(event.url.pathname, event.url);
target.searchParams.set('proposal', proposal.id);
return new Response(null, {
status: 303,
headers: { location: `${target.pathname}${target.search}` },
});
}
}
}
return resolve(event);
};
export const handleError: HandleServerError = ({ error, event, status, message }) => {
const errorId = randomUUID().slice(0, 8).toUpperCase();
console.error(`[${errorId}] ${event.request.method} ${event.url.pathname} -> ${status}`, error);
return {
message: status >= 500 ? 'An unexpected fault interrupted this operation. No further action was applied.' : message,
errorId
};
export const handleError: HandleServerError = ({
error,
event,
status,
message,
}) => {
const errorId = randomUUID().slice(0, 8).toUpperCase();
console.error(
`[${errorId}] ${event.request.method} ${event.url.pathname} -> ${status}`,
error,
);
return {
message:
status >= 500
? 'An unexpected fault interrupted this operation. No further action was applied.'
: message,
errorId,
};
};
+49 -34
View File
@@ -1,50 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
agentProfileHref,
displayNameKey,
newAgentDisplayName,
publicProfileSlugBase
agentProfileHref,
displayNameKey,
newAgentDisplayName,
publicProfileSlugBase,
} from './agents.js';
describe('display names', () => {
it('uses one normalized key for uniqueness checks', () => {
expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn'));
expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite'));
});
it('uses one normalized key for uniqueness checks', () => {
expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn'));
expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite'));
});
it('creates readable short-UID placeholders', () => {
expect(newAgentDisplayName(47)).toBe('Agent 47');
});
it('creates readable short-UID placeholders', () => {
expect(newAgentDisplayName(47)).toBe('Agent 47');
});
});
describe('publicProfileSlugBase', () => {
it('creates readable normalized slugs', () => {
expect(publicProfileSlugBase(' Aewyn Prime ', '12345678-0000-4000-8000-000000000000'))
.toBe('aewyn-prime');
expect(publicProfileSlugBase('Élite 装備', '12345678-0000-4000-8000-000000000000'))
.toBe('élite-装備');
});
it('creates readable normalized slugs', () => {
expect(
publicProfileSlugBase(
' Aewyn Prime ',
'12345678-0000-4000-8000-000000000000',
),
).toBe('aewyn-prime');
expect(
publicProfileSlugBase(
'Élite 装備',
'12345678-0000-4000-8000-000000000000',
),
).toBe('élite-装備');
});
it('uses a stable fallback when a name has no letters or numbers', () => {
expect(publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'))
.toBe('agent-12345678');
});
it('uses a stable fallback when a name has no letters or numbers', () => {
expect(
publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'),
).toBe('agent-12345678');
});
it('keeps UUID-shaped display names out of the legacy UUID namespace', () => {
expect(publicProfileSlugBase(
'57a1160e-632f-4695-927f-7df03e20753f',
'12345678-0000-4000-8000-000000000000'
)).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f');
});
it('keeps UUID-shaped display names out of the legacy UUID namespace', () => {
expect(
publicProfileSlugBase(
'57a1160e-632f-4695-927f-7df03e20753f',
'12345678-0000-4000-8000-000000000000',
),
).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f');
});
});
describe('agentProfileHref', () => {
it('prefers the public slug and safely encodes it', () => {
expect(agentProfileHref({ id: 'uuid', publicSlug: 'élite 装備' }, 'profile-comments'))
.toBe('/agents/%C3%A9lite%20%E8%A3%85%E5%82%99#profile-comments');
});
it('prefers the public slug and safely encodes it', () => {
expect(
agentProfileHref(
{ id: 'uuid', publicSlug: 'élite 装備' },
'profile-comments',
),
).toBe('/agents/%C3%A9lite%20%E8%A3%85%E5%82%99#profile-comments');
});
it('falls back to the UUID for unmigrated records', () => {
expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid');
});
it('falls back to the UUID for unmigrated records', () => {
expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid');
});
});
+25 -21
View File
@@ -1,36 +1,40 @@
export type AgentProfileReference = {
id: string;
publicSlug?: string | null;
id: string;
publicSlug?: string | null;
};
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function displayNameKey(displayName: string): string {
return displayName.normalize('NFKC').toLocaleLowerCase('en-US');
return displayName.normalize('NFKC').toLocaleLowerCase('en-US');
}
export function newAgentDisplayName(sequence: number): string {
return `Agent ${Math.max(1, Math.trunc(sequence))}`;
return `Agent ${Math.max(1, Math.trunc(sequence))}`;
}
export function publicProfileSlugBase(displayName: string, userId: string): string {
const normalized = displayName
.normalize('NFKC')
.toLocaleLowerCase('en-US')
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
.replace(/-+$/g, '');
const fallback = `agent-${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback;
return UUID_PATTERN.test(normalized) ? `agent-${normalized}` : normalized;
export function publicProfileSlugBase(
displayName: string,
userId: string,
): string {
const normalized = displayName
.normalize('NFKC')
.toLocaleLowerCase('en-US')
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
.replace(/-+$/g, '');
const fallback = `agent-${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback;
return UUID_PATTERN.test(normalized) ? `agent-${normalized}` : normalized;
}
export function agentProfileHref(
agent: AgentProfileReference,
fragment = ''
agent: AgentProfileReference,
fragment = '',
): string {
const identifier = agent.publicSlug || agent.id;
const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : '';
return `/agents/${encodeURIComponent(identifier)}${suffix}`;
const identifier = agent.publicSlug || agent.id;
const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : '';
return `/agents/${encodeURIComponent(identifier)}${suffix}`;
}
+78 -78
View File
@@ -1,120 +1,120 @@
import type {
AiMechanicQuestion,
AiMechanicResponseMeta
AiMechanicQuestion,
AiMechanicResponseMeta,
} from './mechanic-authoring';
export const AI_CALCULATION_STAGE_OPERATORS = [
'base_damage',
'flat_add',
'additive_percent',
'multiplicative_percent',
'expected_critical',
'expected_headshot',
'rate_of_fire',
'magazine_size',
'reload_speed',
'firing_cycle'
'base_damage',
'flat_add',
'additive_percent',
'multiplicative_percent',
'expected_critical',
'expected_headshot',
'rate_of_fire',
'magazine_size',
'reload_speed',
'firing_cycle',
] as const;
export const AI_SKILL_CALCULATION_STAGES = [
'engineering',
'flat_damage',
'skill_damage',
'final_damage',
'critical_chance',
'critical_damage',
'multishot_chance',
'multishot_damage',
'cooldown_recovery',
'duration'
'engineering',
'flat_damage',
'skill_damage',
'final_damage',
'critical_chance',
'critical_damage',
'multishot_chance',
'multishot_damage',
'cooldown_recovery',
'duration',
] as const;
export type AiCalculationStageDraft = {
stageKey: string;
label: string;
operator: (typeof AI_CALCULATION_STAGE_OPERATORS)[number];
displayOrder: number;
lockedPosition: 'first' | 'last' | null;
stageKey: string;
label: string;
operator: (typeof AI_CALCULATION_STAGE_OPERATORS)[number];
displayOrder: number;
lockedPosition: 'first' | 'last' | null;
};
export type AiCalculationProfileDraft = {
statAttributeId: string;
stageKey: string;
weight: number;
softCap: number | null;
hardCap: number | null;
curveType: 'none' | 'power' | 'smoothstep';
curveParameter: number | null;
weaponCategory: string;
statAttributeId: string;
stageKey: string;
weight: number;
softCap: number | null;
hardCap: number | null;
curveType: 'none' | 'power' | 'smoothstep';
curveParameter: number | null;
weaponCategory: string;
};
export type AiSkillCalculationProfileDraft = {
statAttributeId: string;
stageKey: (typeof AI_SKILL_CALCULATION_STAGES)[number];
operator: 'add' | 'multiply';
weight: number;
statAttributeId: string;
stageKey: (typeof AI_SKILL_CALCULATION_STAGES)[number];
operator: 'add' | 'multiply';
weight: number;
};
export type AiCalculationRevisionDraft = {
name: string;
notes: string;
stages: AiCalculationStageDraft[];
profiles: AiCalculationProfileDraft[];
skillProfiles: AiSkillCalculationProfileDraft[];
name: string;
notes: string;
stages: AiCalculationStageDraft[];
profiles: AiCalculationProfileDraft[];
skillProfiles: AiSkillCalculationProfileDraft[];
};
export type AiCalculationTranscriptEntry = {
questionId: string;
question: string;
answer: string;
questionId: string;
question: string;
answer: string;
};
export type AiCalculationAuthoringRequest = {
instructions: string;
baseRevisionId: string | null;
currentDraft: AiCalculationRevisionDraft;
transcript: AiCalculationTranscriptEntry[];
instructions: string;
baseRevisionId: string | null;
currentDraft: AiCalculationRevisionDraft;
transcript: AiCalculationTranscriptEntry[];
};
export type AiCalculationQuestion = AiMechanicQuestion;
export type AiCalculationCapabilityGap = {
area: 'stage_operator' | 'curve' | 'skill_stage' | 'attribute' | 'runtime';
requiredBehavior: string;
whyUnsupported: string;
developerChange: string;
workaround: string | null;
area: 'stage_operator' | 'curve' | 'skill_stage' | 'attribute' | 'runtime';
requiredBehavior: string;
whyUnsupported: string;
developerChange: string;
workaround: string | null;
};
export type AiCalculationClarificationResponse = {
status: 'clarification_required';
summary: string;
assumptions: string[];
warnings: string[];
questions: AiCalculationQuestion[];
meta: AiMechanicResponseMeta;
status: 'clarification_required';
summary: string;
assumptions: string[];
warnings: string[];
questions: AiCalculationQuestion[];
meta: AiMechanicResponseMeta;
};
export type AiCalculationDraftResponse = {
status: 'draft_ready';
draft: AiCalculationRevisionDraft;
summary: string;
assumptions: string[];
warnings: string[];
pseudoScript: string;
meta: AiMechanicResponseMeta;
status: 'draft_ready';
draft: AiCalculationRevisionDraft;
summary: string;
assumptions: string[];
warnings: string[];
pseudoScript: string;
meta: AiMechanicResponseMeta;
};
export type AiCalculationCapabilityGapResponse = {
status: 'capability_gap';
summary: string;
assumptions: string[];
warnings: string[];
gaps: AiCalculationCapabilityGap[];
meta: AiMechanicResponseMeta;
status: 'capability_gap';
summary: string;
assumptions: string[];
warnings: string[];
gaps: AiCalculationCapabilityGap[];
meta: AiMechanicResponseMeta;
};
export type AiCalculationAuthoringResponse =
| AiCalculationClarificationResponse
| AiCalculationDraftResponse
| AiCalculationCapabilityGapResponse;
| AiCalculationClarificationResponse
| AiCalculationDraftResponse
| AiCalculationCapabilityGapResponse;
+100 -97
View File
@@ -1,140 +1,143 @@
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
export const AI_MECHANIC_SOURCE_KINDS = [
'talent',
'exotic_weapon_talent',
'os_protocol_talent',
'skill_chip_talent',
'specialization_tree_rank'
'talent',
'exotic_weapon_talent',
'os_protocol_talent',
'skill_chip_talent',
'specialization_tree_rank',
] as const;
export type AiMechanicSourceKind = (typeof AI_MECHANIC_SOURCE_KINDS)[number];
export const AI_MECHANIC_SOURCE_PROFILES = {
talent: {
variantKeys: ['I', 'II'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false
},
exotic_weapon_talent: {
variantKeys: ['I', 'II'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false
},
os_protocol_talent: {
variantKeys: ['base'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: true
},
skill_chip_talent: {
variantKeys: ['minimum', 'maximum'],
allowAssignedAbility: true,
allowProtocolUpgradeScaling: false
},
specialization_tree_rank: {
variantKeys: ['base'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false
}
} as const satisfies Record<AiMechanicSourceKind, {
variantKeys: readonly string[];
allowAssignedAbility: boolean;
allowProtocolUpgradeScaling: boolean;
}>;
talent: {
variantKeys: ['I', 'II'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
},
exotic_weapon_talent: {
variantKeys: ['I', 'II'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
},
os_protocol_talent: {
variantKeys: ['base'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: true,
},
skill_chip_talent: {
variantKeys: ['minimum', 'maximum'],
allowAssignedAbility: true,
allowProtocolUpgradeScaling: false,
},
specialization_tree_rank: {
variantKeys: ['base'],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
},
} as const satisfies Record<
AiMechanicSourceKind,
{
variantKeys: readonly string[];
allowAssignedAbility: boolean;
allowProtocolUpgradeScaling: boolean;
}
>;
export type AiMechanicContext = {
sourceKind: AiMechanicSourceKind;
sourceName: string;
sourceDescription: string;
sourceKind: AiMechanicSourceKind;
sourceName: string;
sourceDescription: string;
};
export type AiMechanicValueColumn = {
key: string;
variantKey: string;
label: string;
key: string;
variantKey: string;
label: string;
};
export type AiMechanicTranscriptEntry = {
questionId: string;
question: string;
answer: string | number | boolean;
questionId: string;
question: string;
answer: string | number | boolean;
};
export type AiMechanicAuthoringRequest = {
context: AiMechanicContext;
currentDraft: ConditionalMechanicDraftV1;
valueColumns: AiMechanicValueColumn[];
allowAssignedAbility: boolean;
allowProtocolUpgradeScaling: boolean;
transcript: AiMechanicTranscriptEntry[];
context: AiMechanicContext;
currentDraft: ConditionalMechanicDraftV1;
valueColumns: AiMechanicValueColumn[];
allowAssignedAbility: boolean;
allowProtocolUpgradeScaling: boolean;
transcript: AiMechanicTranscriptEntry[];
};
export type AiMechanicQuestion = {
id: string;
question: string;
reason: string;
answerType: 'text' | 'number' | 'choice' | 'boolean';
options: string[];
id: string;
question: string;
reason: string;
answerType: 'text' | 'number' | 'choice' | 'boolean';
options: string[];
};
export type AiMechanicResponseMeta = {
requestId: string;
model: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
requestId: string;
model: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
export type AiMechanicClarificationResponse = {
status: 'clarification_required';
summary: string;
assumptions: string[];
warnings: string[];
questions: AiMechanicQuestion[];
meta: AiMechanicResponseMeta;
status: 'clarification_required';
summary: string;
assumptions: string[];
warnings: string[];
questions: AiMechanicQuestion[];
meta: AiMechanicResponseMeta;
};
export type AiMechanicDraftResponse = {
status: 'draft_ready';
draft: ConditionalMechanicDraftV1;
summary: string;
assumptions: string[];
warnings: string[];
pseudoScript: string;
meta: AiMechanicResponseMeta;
status: 'draft_ready';
draft: ConditionalMechanicDraftV1;
summary: string;
assumptions: string[];
warnings: string[];
pseudoScript: string;
meta: AiMechanicResponseMeta;
};
export type AiMechanicCapabilityGap = {
area:
| 'event'
| 'predicate'
| 'reducer'
| 'mutation'
| 'outcome'
| 'scope'
| 'value_reference'
| 'source_variant'
| 'runtime';
requiredBehavior: string;
whyUnsupported: string;
developerChange: string;
workaround: string | null;
area:
| 'event'
| 'predicate'
| 'reducer'
| 'mutation'
| 'outcome'
| 'scope'
| 'value_reference'
| 'source_variant'
| 'runtime';
requiredBehavior: string;
whyUnsupported: string;
developerChange: string;
workaround: string | null;
};
export type AiMechanicCapabilityGapResponse = {
status: 'capability_gap';
summary: string;
assumptions: string[];
warnings: string[];
gaps: AiMechanicCapabilityGap[];
meta: AiMechanicResponseMeta;
status: 'capability_gap';
summary: string;
assumptions: string[];
warnings: string[];
gaps: AiMechanicCapabilityGap[];
meta: AiMechanicResponseMeta;
};
export type AiMechanicAuthoringResponse =
| AiMechanicClarificationResponse
| AiMechanicDraftResponse
| AiMechanicCapabilityGapResponse;
| AiMechanicClarificationResponse
| AiMechanicDraftResponse
| AiMechanicCapabilityGapResponse;
export type AiMechanicAuthoringError = {
message: string;
message: string;
};
+64 -56
View File
@@ -1,66 +1,74 @@
import { describe, expect, it } from 'vitest';
import { buildMechanicDeveloperPrompt, type AiMechanicDeveloperHandoff } from './mechanic-developer-handoff';
import {
buildMechanicDeveloperPrompt,
type AiMechanicDeveloperHandoff,
} from './mechanic-developer-handoff';
const handoff: AiMechanicDeveloperHandoff = {
context: {
sourceKind: 'talent',
sourceName: 'Chain Reaction',
sourceDescription: 'Every third critical hit transfers the mark to a nearby enemy.'
},
valueColumns: [
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
{ key: 'tierII', variantKey: 'II', label: 'TIER II' }
],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
transcript: [{
questionId: 'transfer-target',
question: 'How is the next target selected?',
answer: 'Nearest enemy'
}],
response: {
status: 'capability_gap',
summary: 'The runtime cannot select a nearby transfer target.',
assumptions: ['The third hit consumes the current mark.'],
warnings: ['Target selection must be deterministic.'],
gaps: [{
area: 'runtime',
requiredBehavior: 'Select the nearest eligible enemy.',
whyUnsupported: 'The event has no ordered nearby-target collection.',
developerChange: 'Add a deterministic nearby-target selector.',
workaround: null
}],
meta: {
requestId: 'req_gap_123',
model: 'test-model',
inputTokens: 100,
outputTokens: 50,
totalTokens: 150
}
}
context: {
sourceKind: 'talent',
sourceName: 'Chain Reaction',
sourceDescription:
'Every third critical hit transfers the mark to a nearby enemy.',
},
valueColumns: [
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
{ key: 'tierII', variantKey: 'II', label: 'TIER II' },
],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
transcript: [
{
questionId: 'transfer-target',
question: 'How is the next target selected?',
answer: 'Nearest enemy',
},
],
response: {
status: 'capability_gap',
summary: 'The runtime cannot select a nearby transfer target.',
assumptions: ['The third hit consumes the current mark.'],
warnings: ['Target selection must be deterministic.'],
gaps: [
{
area: 'runtime',
requiredBehavior: 'Select the nearest eligible enemy.',
whyUnsupported: 'The event has no ordered nearby-target collection.',
developerChange: 'Add a deterministic nearby-target selector.',
workaround: null,
},
],
meta: {
requestId: 'req_gap_123',
model: 'test-model',
inputTokens: 100,
outputTokens: 50,
totalTokens: 150,
},
},
};
describe('mechanic developer handoff prompt', () => {
it('includes the exact analyzed source, clarification evidence, and required code change', () => {
const prompt = buildMechanicDeveloperPrompt(handoff);
it('includes the exact analyzed source, clarification evidence, and required code change', () => {
const prompt = buildMechanicDeveloperPrompt(handoff);
expect(prompt).toContain(handoff.context.sourceDescription);
expect(prompt).toContain('How is the next target selected?');
expect(prompt).toContain('Answer: Nearest enemy');
expect(prompt).toContain('Add a deterministic nearby-target selector.');
expect(prompt).toContain('Assistant request ID: req_gap_123');
expect(prompt).toContain('Persistence round-trips the new representation');
});
expect(prompt).toContain(handoff.context.sourceDescription);
expect(prompt).toContain('How is the next target selected?');
expect(prompt).toContain('Answer: Nearest enemy');
expect(prompt).toContain('Add a deterministic nearby-target selector.');
expect(prompt).toContain('Assistant request ID: req_gap_123');
expect(prompt).toContain('Persistence round-trips the new representation');
});
it('omits empty optional sections', () => {
const prompt = buildMechanicDeveloperPrompt({
...handoff,
transcript: [],
response: { ...handoff.response, assumptions: [], warnings: [] }
});
it('omits empty optional sections', () => {
const prompt = buildMechanicDeveloperPrompt({
...handoff,
transcript: [],
response: { ...handoff.response, assumptions: [], warnings: [] },
});
expect(prompt).not.toContain('## Clarification history');
expect(prompt).not.toContain('## Assumptions');
expect(prompt).not.toContain('## Warnings');
});
expect(prompt).not.toContain('## Clarification history');
expect(prompt).not.toContain('## Assumptions');
expect(prompt).not.toContain('## Warnings');
});
});
+42 -27
View File
@@ -1,40 +1,55 @@
import type {
AiMechanicCapabilityGapResponse,
AiMechanicContext,
AiMechanicTranscriptEntry,
AiMechanicValueColumn
AiMechanicCapabilityGapResponse,
AiMechanicContext,
AiMechanicTranscriptEntry,
AiMechanicValueColumn,
} from '$lib/ai/mechanic-authoring';
export type AiMechanicDeveloperHandoff = {
context: AiMechanicContext;
valueColumns: AiMechanicValueColumn[];
allowAssignedAbility: boolean;
allowProtocolUpgradeScaling: boolean;
transcript: AiMechanicTranscriptEntry[];
response: AiMechanicCapabilityGapResponse;
context: AiMechanicContext;
valueColumns: AiMechanicValueColumn[];
allowAssignedAbility: boolean;
allowProtocolUpgradeScaling: boolean;
transcript: AiMechanicTranscriptEntry[];
response: AiMechanicCapabilityGapResponse;
};
function section(title: string, items: string[]) {
return items.length ? `\n## ${title}\n\n${items.join('\n')}` : '';
return items.length ? `\n## ${title}\n\n${items.join('\n')}` : '';
}
export function buildMechanicDeveloperPrompt(handoff: AiMechanicDeveloperHandoff) {
const { context, valueColumns, allowAssignedAbility, allowProtocolUpgradeScaling, transcript, response } = handoff;
const variants = valueColumns.map((column) => `${column.label} (${column.variantKey})`).join(', ');
const clarifications = transcript.map((entry) =>
`- ${entry.question}\n Answer: ${String(entry.answer)}`
);
const assumptions = response.assumptions.map((value) => `- ${value}`);
const warnings = response.warnings.map((value) => `- ${value}`);
const gaps = response.gaps.map((gap, index) => [
`### Gap ${index + 1}: ${gap.area}`,
`- Required behavior: ${gap.requiredBehavior}`,
`- Why unsupported: ${gap.whyUnsupported}`,
`- Developer change: ${gap.developerChange}`,
gap.workaround ? `- Current workaround: ${gap.workaround}` : '- Current workaround: None'
].join('\n'));
export function buildMechanicDeveloperPrompt(
handoff: AiMechanicDeveloperHandoff,
) {
const {
context,
valueColumns,
allowAssignedAbility,
allowProtocolUpgradeScaling,
transcript,
response,
} = handoff;
const variants = valueColumns
.map((column) => `${column.label} (${column.variantKey})`)
.join(', ');
const clarifications = transcript.map(
(entry) => `- ${entry.question}\n Answer: ${String(entry.answer)}`,
);
const assumptions = response.assumptions.map((value) => `- ${value}`);
const warnings = response.warnings.map((value) => `- ${value}`);
const gaps = response.gaps.map((gap, index) =>
[
`### Gap ${index + 1}: ${gap.area}`,
`- Required behavior: ${gap.requiredBehavior}`,
`- Why unsupported: ${gap.whyUnsupported}`,
`- Developer change: ${gap.developerChange}`,
gap.workaround
? `- Current workaround: ${gap.workaround}`
: '- Current workaround: None',
].join('\n'),
);
return `Implement support for the following ISAC mechanic-language gap in the SHD.build repository.
return `Implement support for the following ISAC mechanic-language gap in the SHD.build repository.
Start by tracing the existing typed mechanic grammar, persistence adapter, runtime, editor, AI authoring context, and tests. Extend the smallest reusable layer that can represent the behavior exactly. Do not invent combat behavior or weaken deterministic validation. Preserve manual mechanic authoring and existing saved game data. Update shared registries, types, validation, persistence, runtime evaluation, editor controls, AI prompt context, tests, and maintained architecture/design documentation wherever the implementation requires them. Follow the repository's audit, database-preservation, accessibility, and development-server rules.
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import { EMPTY_ARTICLE_DOCUMENT, parseArticleDocument } from './document';
describe('article documents', () => {
it('accepts formatted prose, multiple Builds, and article images', () => {
const parsed = parseArticleDocument({
version: 1,
document: {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 2 },
content: [
{ type: 'text', text: 'Opening', marks: [{ type: 'bold' }] },
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Use this rotation.',
marks: [
{ type: 'link', attrs: { href: 'https://shd.build/guides' } },
],
},
],
},
{
type: 'buildEmbed',
attrs: {
buildId: '11111111-1111-4111-8111-111111111111',
embedKey: 'build-11111111-1111-4111-8111-111111111111',
},
},
{
type: 'buildEmbed',
attrs: {
buildId: '22222222-2222-4222-8222-222222222222',
embedKey: 'build-22222222-2222-4222-8222-222222222222',
},
},
{
type: 'articleImage',
attrs: {
imageId: '33333333-3333-4333-8333-333333333333',
alt: 'A loadout screen',
caption: 'Final configuration',
},
},
],
},
});
expect(parsed?.buildEmbeds).toHaveLength(2);
expect(parsed?.imageIds).toEqual(['33333333-3333-4333-8333-333333333333']);
expect(parsed?.plainText).toContain('Opening Use this rotation.');
});
it('rejects unsafe links and unknown nodes', () => {
expect(
parseArticleDocument({
version: 1,
document: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'unsafe',
marks: [
{ type: 'link', attrs: { href: 'javascript:alert(1)' } },
],
},
],
},
],
},
}),
).toBeNull();
expect(
parseArticleDocument({
version: 1,
document: {
type: 'doc',
content: [{ type: 'iframe', attrs: { src: 'https://example.com' } }],
},
}),
).toBeNull();
});
it('rejects duplicate embed keys and unknown envelope fields', () => {
const embed = {
type: 'buildEmbed',
attrs: {
buildId: '11111111-1111-4111-8111-111111111111',
embedKey: 'build-11111111-1111-4111-8111-111111111111',
},
};
expect(
parseArticleDocument({
version: 1,
document: { type: 'doc', content: [embed, embed] },
}),
).toBeNull();
expect(
parseArticleDocument({ ...EMPTY_ARTICLE_DOCUMENT, legacy: true }),
).toBeNull();
});
});
+280
View File
@@ -0,0 +1,280 @@
export const ARTICLE_DOCUMENT_VERSION = 1 as const;
export const MAX_ARTICLE_DOCUMENT_BYTES = 240_000;
export const MAX_ARTICLE_TEXT_CHARACTERS = 60_000;
export const MAX_ARTICLE_NODES = 1_200;
export const MAX_ARTICLE_DEPTH = 8;
const ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/;
export type ArticleMark = {
type: 'bold' | 'italic' | 'code' | 'link';
attrs?: { href: string };
};
export type ArticleNode = {
type: string;
attrs?: Record<string, unknown>;
content?: ArticleNode[];
marks?: ArticleMark[];
text?: string;
};
export type ArticleDocument = {
version: typeof ARTICLE_DOCUMENT_VERSION;
document: ArticleNode;
};
export type ArticleBuildEmbed = {
embedKey: string;
buildId: string;
ordinal: number;
};
export const EMPTY_ARTICLE_DOCUMENT: ArticleDocument = {
version: ARTICLE_DOCUMENT_VERSION,
document: { type: 'doc', content: [{ type: 'paragraph' }] },
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const exactKeys = (value: Record<string, unknown>, allowed: string[]) =>
Object.keys(value).every((key) => allowed.includes(key));
const safeLink = (value: unknown) => {
if (typeof value !== 'string' || value.length > 2_000) return null;
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) ? url.toString() : null;
} catch {
return null;
}
};
function parseMarks(value: unknown): ArticleMark[] | null {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > 4) return null;
const seen = new Set<string>();
const marks: ArticleMark[] = [];
for (const candidate of value) {
if (
!isRecord(candidate) ||
typeof candidate.type !== 'string' ||
seen.has(candidate.type)
)
return null;
seen.add(candidate.type);
if (['bold', 'italic', 'code'].includes(candidate.type)) {
if (!exactKeys(candidate, ['type'])) return null;
marks.push({ type: candidate.type as 'bold' | 'italic' | 'code' });
continue;
}
if (
candidate.type !== 'link' ||
!exactKeys(candidate, ['type', 'attrs']) ||
!isRecord(candidate.attrs)
)
return null;
if (!exactKeys(candidate.attrs, ['href'])) return null;
const href = safeLink(candidate.attrs.href);
if (!href) return null;
marks.push({ type: 'link', attrs: { href } });
}
return marks;
}
export function parseArticleDocument(value: unknown): {
document: ArticleDocument;
plainText: string;
buildEmbeds: ArticleBuildEmbed[];
imageIds: string[];
links: string[];
} | null {
if (typeof value === 'string') {
if (new TextEncoder().encode(value).byteLength > MAX_ARTICLE_DOCUMENT_BYTES)
return null;
try {
value = JSON.parse(value);
} catch {
return null;
}
}
if (
!isRecord(value) ||
!exactKeys(value, ['version', 'document']) ||
value.version !== ARTICLE_DOCUMENT_VERSION
)
return null;
let nodeCount = 0;
let textLength = 0;
let embedOrdinal = 0;
const textParts: string[] = [];
const buildEmbeds: ArticleBuildEmbed[] = [];
const imageIds: string[] = [];
const embedKeys = new Set<string>();
const parseNode = (candidate: unknown, depth: number): ArticleNode | null => {
if (
!isRecord(candidate) ||
depth > MAX_ARTICLE_DEPTH ||
++nodeCount > MAX_ARTICLE_NODES
)
return null;
if (typeof candidate.type !== 'string') return null;
const type = candidate.type;
if (type === 'text') {
if (
!exactKeys(candidate, ['type', 'text', 'marks']) ||
typeof candidate.text !== 'string'
)
return null;
const text = candidate.text.normalize('NFKC');
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
textLength += [...text].length;
if (textLength > MAX_ARTICLE_TEXT_CHARACTERS) return null;
const marks = parseMarks(candidate.marks);
if (!marks) return null;
textParts.push(text);
return marks.length ? { type, text, marks } : { type, text };
}
if (type === 'buildEmbed') {
if (
!exactKeys(candidate, ['type', 'attrs']) ||
!isRecord(candidate.attrs) ||
!exactKeys(candidate.attrs, ['buildId', 'embedKey'])
)
return null;
const buildId = String(candidate.attrs.buildId ?? '');
const embedKey = String(candidate.attrs.embedKey ?? '');
if (
!ID_PATTERN.test(buildId) ||
!EMBED_KEY_PATTERN.test(embedKey) ||
embedKeys.has(embedKey)
)
return null;
embedKeys.add(embedKey);
buildEmbeds.push({ buildId, embedKey, ordinal: embedOrdinal++ });
textParts.push('[Embedded Build]');
return { type, attrs: { buildId, embedKey } };
}
if (type === 'articleImage') {
if (
!exactKeys(candidate, ['type', 'attrs']) ||
!isRecord(candidate.attrs) ||
!exactKeys(candidate.attrs, ['imageId', 'alt', 'caption'])
)
return null;
const imageId = String(candidate.attrs.imageId ?? '');
const alt = String(candidate.attrs.alt ?? '')
.normalize('NFKC')
.trim();
const caption = String(candidate.attrs.caption ?? '')
.normalize('NFKC')
.trim();
if (
!ID_PATTERN.test(imageId) ||
!alt ||
[...alt].length > 300 ||
[...caption].length > 500
)
return null;
imageIds.push(imageId);
textParts.push(alt, caption);
return { type, attrs: { imageId, alt, caption } };
}
const leafTypes = ['horizontalRule', 'hardBreak'];
if (leafTypes.includes(type)) {
return exactKeys(candidate, ['type']) ? { type } : null;
}
const containerTypes = [
'doc',
'paragraph',
'heading',
'bulletList',
'orderedList',
'listItem',
'blockquote',
];
if (
!containerTypes.includes(type) ||
!exactKeys(candidate, ['type', 'attrs', 'content'])
)
return null;
let attrs: Record<string, unknown> | undefined;
if (type === 'heading') {
if (
!isRecord(candidate.attrs) ||
!exactKeys(candidate.attrs, ['level']) ||
![2, 3].includes(Number(candidate.attrs.level))
)
return null;
attrs = { level: Number(candidate.attrs.level) };
} else if (candidate.attrs !== undefined) {
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
return null;
}
if (candidate.content !== undefined && !Array.isArray(candidate.content))
return null;
const content: ArticleNode[] = [];
for (const child of candidate.content ?? []) {
const parsed = parseNode(child, depth + 1);
if (!parsed) return null;
content.push(parsed);
}
if (['paragraph', 'heading', 'listItem', 'blockquote'].includes(type))
textParts.push('\n');
return {
type,
...(attrs ? { attrs } : {}),
...(content.length ? { content } : {}),
};
};
const document = parseNode(value.document, 0);
if (!document || document.type !== 'doc') return null;
const parsed: ArticleDocument = {
version: ARTICLE_DOCUMENT_VERSION,
document,
};
if (
new TextEncoder().encode(JSON.stringify(parsed)).byteLength >
MAX_ARTICLE_DOCUMENT_BYTES
)
return null;
const links: string[] = [];
const collectLinks = (node: ArticleNode) => {
for (const mark of node.marks ?? [])
if (mark.type === 'link' && mark.attrs?.href) links.push(mark.attrs.href);
for (const child of node.content ?? []) collectLinks(child);
};
collectLinks(document);
return {
document: parsed,
plainText: textParts.join(' ').replace(/\s+/g, ' ').trim(),
buildEmbeds,
imageIds: [...new Set(imageIds)],
links: [...new Set(links)],
};
}
export function articleModerationText(input: {
title: string;
deck: string;
plainText: string;
coverAlt?: string;
coverCaption?: string;
links?: string[];
}) {
return [
input.title,
input.deck,
input.coverAlt,
input.coverCaption,
input.plainText,
...(input.links ?? []),
]
.filter(Boolean)
.join('\n\n');
}
+71
View File
@@ -0,0 +1,71 @@
import { Node, mergeAttributes } from '@tiptap/core';
export const BuildEmbedNode = Node.create({
name: 'buildEmbed',
group: 'block',
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
buildId: { default: null },
embedKey: { default: null },
};
},
parseHTML() {
return [{ tag: 'article[data-build-embed]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'article',
mergeAttributes(HTMLAttributes, {
'data-build-embed': '',
class: 'article-editor-embed',
contenteditable: 'false',
}),
['span', {}, 'EMBEDDED BUILD'],
['strong', {}, String(HTMLAttributes.buildId ?? 'BUILD')],
];
},
});
export const ArticleImageNode = Node.create({
name: 'articleImage',
group: 'block',
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
imageId: { default: null },
alt: { default: '' },
caption: { default: '' },
};
},
parseHTML() {
return [{ tag: 'figure[data-article-image]' }];
},
renderHTML({ HTMLAttributes }) {
const imageId = String(HTMLAttributes.imageId ?? '');
return [
'figure',
mergeAttributes(HTMLAttributes, {
'data-article-image': '',
class: 'article-editor-image',
contenteditable: 'false',
}),
[
'img',
{
src: `/images/library/${encodeURIComponent(imageId)}`,
alt: String(HTMLAttributes.alt ?? ''),
},
],
[
'figcaption',
{},
String(HTMLAttributes.caption || HTMLAttributes.alt || 'ARTICLE IMAGE'),
],
];
},
});
+81 -21
View File
@@ -1,29 +1,89 @@
export const AVATAR_PRESETS = [
{ id: 'field-agent', label: 'Field Agent', url: '/avatars/default/field-agent.webp' },
{ id: 'field-medic', label: 'Field Medic', url: '/avatars/default/field-medic.webp' },
{ id: 'systems-engineer', label: 'Systems Engineer', url: '/avatars/default/systems-engineer.webp' },
{ id: 'recon-scout', label: 'Recon Scout', url: '/avatars/default/recon-scout.webp' },
{ id: 'drone-pilot', label: 'Drone Pilot', url: '/avatars/default/drone-pilot.webp' },
{ id: 'field-commander', label: 'Field Commander', url: '/avatars/default/field-commander.webp' },
{ id: 'rescue-specialist', label: 'Rescue Specialist', url: '/avatars/default/rescue-specialist.webp' },
{ id: 'equipment-engineer', label: 'Equipment Engineer', url: '/avatars/default/equipment-engineer.webp' },
{ id: 'wilderness-tracker', label: 'Wilderness Tracker', url: '/avatars/default/wilderness-tracker.webp' },
{ id: 'intelligence-analyst', label: 'Intelligence Analyst', url: '/avatars/default/intelligence-analyst.webp' },
{ id: 'combat-medic', label: 'Combat Medic', url: '/avatars/default/combat-medic.webp' },
{ id: 'comms-specialist', label: 'Comms Specialist', url: '/avatars/default/comms-specialist.webp' }
{
id: 'field-agent',
label: 'Field Agent',
url: '/avatars/default/field-agent.webp',
},
{
id: 'field-medic',
label: 'Field Medic',
url: '/avatars/default/field-medic.webp',
},
{
id: 'systems-engineer',
label: 'Systems Engineer',
url: '/avatars/default/systems-engineer.webp',
},
{
id: 'recon-scout',
label: 'Recon Scout',
url: '/avatars/default/recon-scout.webp',
},
{
id: 'drone-pilot',
label: 'Drone Pilot',
url: '/avatars/default/drone-pilot.webp',
},
{
id: 'field-commander',
label: 'Field Commander',
url: '/avatars/default/field-commander.webp',
},
{
id: 'rescue-specialist',
label: 'Rescue Specialist',
url: '/avatars/default/rescue-specialist.webp',
},
{
id: 'equipment-engineer',
label: 'Equipment Engineer',
url: '/avatars/default/equipment-engineer.webp',
},
{
id: 'wilderness-tracker',
label: 'Wilderness Tracker',
url: '/avatars/default/wilderness-tracker.webp',
},
{
id: 'intelligence-analyst',
label: 'Intelligence Analyst',
url: '/avatars/default/intelligence-analyst.webp',
},
{
id: 'combat-medic',
label: 'Combat Medic',
url: '/avatars/default/combat-medic.webp',
},
{
id: 'comms-specialist',
label: 'Comms Specialist',
url: '/avatars/default/comms-specialist.webp',
},
] as const;
export const DEFAULT_AVATAR_PRESET = AVATAR_PRESETS[0].id;
export function isAvatarPreset(value: string): value is (typeof AVATAR_PRESETS)[number]['id'] {
return AVATAR_PRESETS.some((preset) => preset.id === value);
export function isAvatarPreset(
value: string,
): value is (typeof AVATAR_PRESETS)[number]['id'] {
return AVATAR_PRESETS.some((preset) => preset.id === value);
}
export function avatarUrl(source: unknown, value: unknown, userId: string, updatedAt?: unknown): string {
if (source === 'upload') {
const version = updatedAt ? `?v=${encodeURIComponent(String(updatedAt))}` : '';
return `/avatars/user/${encodeURIComponent(userId)}${version}`;
}
const preset = typeof value === 'string' && isAvatarPreset(value) ? value : DEFAULT_AVATAR_PRESET;
return `/avatars/default/${preset}.webp`;
export function avatarUrl(
source: unknown,
value: unknown,
userId: string,
updatedAt?: unknown,
): string {
if (source === 'upload') {
const version = updatedAt
? `?v=${encodeURIComponent(String(updatedAt))}`
: '';
return `/avatars/user/${encodeURIComponent(userId)}${version}`;
}
const preset =
typeof value === 'string' && isAvatarPreset(value)
? value
: DEFAULT_AVATAR_PRESET;
return `/avatars/default/${preset}.webp`;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+59 -57
View File
@@ -4,76 +4,78 @@ import { createDraft, parseLoadoutDraft } from './loadout.js';
const copyDraft = () => structuredClone(createDraft());
describe('parseLoadoutDraft', () => {
it('accepts a current draft and returns an independent clone', () => {
const draft = createDraft();
const parsed = parseLoadoutDraft(draft);
it('accepts a current draft and returns an independent clone', () => {
const draft = createDraft();
const parsed = parseLoadoutDraft(draft);
expect(parsed).toEqual(draft);
expect(parsed?.calculationMode).toBe('maximum');
expect(parsed).not.toBe(draft);
parsed!.gear.Holster.level = 1;
expect(draft.gear.Holster.level).toBe(50);
});
expect(parsed).toEqual(draft);
expect(parsed?.calculationMode).toBe('maximum');
expect(parsed).not.toBe(draft);
parsed!.gear.Holster.level = 1;
expect(draft.gear.Holster.level).toBe(50);
});
it('rejects the retired version 1 three-chip shape', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 1;
retired.chips = (retired.chips as unknown[]).slice(0, 3);
it('rejects the retired version 1 three-chip shape', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 1;
retired.chips = (retired.chips as unknown[]).slice(0, 3);
expect(parseLoadoutDraft(retired)).toBeNull();
});
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
const draft = copyDraft();
draft.chips.pop();
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
const draft = copyDraft();
draft.chips.pop();
expect(parseLoadoutDraft(draft)).toBeNull();
});
expect(parseLoadoutDraft(draft)).toBeNull();
});
it('rejects a draft missing the unified combat profile', () => {
const draft = copyDraft() as unknown as Record<string, unknown>;
delete draft.combatProfile;
it('rejects a draft missing the unified combat profile', () => {
const draft = copyDraft() as unknown as Record<string, unknown>;
delete draft.combatProfile;
expect(parseLoadoutDraft(draft)).toBeNull();
});
expect(parseLoadoutDraft(draft)).toBeNull();
});
it('accepts a custom scenario and preserves its assumptions', () => {
const draft = copyDraft();
draft.calculationMode = 'custom';
draft.combatProfile.hitRate = 63;
draft.combatProfile.targetDistanceMeters = 28;
draft.conditionalEffects.relentless = {
mode: 'encounter',
inputs: {
alliesWithinRange: 3,
targetHasDebuff: true
}
};
it('accepts a custom scenario and preserves its assumptions', () => {
const draft = copyDraft();
draft.calculationMode = 'custom';
draft.combatProfile.hitRate = 63;
draft.combatProfile.targetDistanceMeters = 28;
draft.conditionalEffects.relentless = {
mode: 'encounter',
inputs: {
alliesWithinRange: 3,
targetHasDebuff: true,
},
};
expect(parseLoadoutDraft(draft)).toEqual(draft);
});
expect(parseLoadoutDraft(draft)).toEqual(draft);
});
it('rejects drafts without the required calculation model', () => {
const missing = copyDraft() as unknown as Record<string, unknown>;
delete missing.calculationMode;
expect(parseLoadoutDraft(missing)).toBeNull();
it('rejects drafts without the required calculation model', () => {
const missing = copyDraft() as unknown as Record<string, unknown>;
delete missing.calculationMode;
expect(parseLoadoutDraft(missing)).toBeNull();
const invalid = copyDraft() as unknown as Record<string, unknown>;
invalid.calculationMode = 'legacy';
expect(parseLoadoutDraft(invalid)).toBeNull();
});
const invalid = copyDraft() as unknown as Record<string, unknown>;
invalid.calculationMode = 'legacy';
expect(parseLoadoutDraft(invalid)).toBeNull();
});
it('rejects a specialization that carries retired unassigned chips', () => {
const draft = copyDraft() as unknown as { specialization: Record<string, unknown> };
draft.specialization.unassignedChips = [];
it('rejects a specialization that carries retired unassigned chips', () => {
const draft = copyDraft() as unknown as {
specialization: Record<string, unknown>;
};
draft.specialization.unassignedChips = [];
expect(parseLoadoutDraft(draft)).toBeNull();
});
expect(parseLoadoutDraft(draft)).toBeNull();
});
it('rejects malformed nested equipment selections', () => {
const draft = copyDraft();
draft.weapons[0].rolls[1] = { statId: 'weapon-damage', value: Number.NaN };
it('rejects malformed nested equipment selections', () => {
const draft = copyDraft();
draft.weapons[0].rolls[1] = { statId: 'weapon-damage', value: Number.NaN };
expect(parseLoadoutDraft(draft)).toBeNull();
});
expect(parseLoadoutDraft(draft)).toBeNull();
});
});
+462 -227
View File
@@ -1,279 +1,514 @@
export type Roll = { statId: string; value: number };
export type GearSelection = { itemId: string; level: number; brandId: string; talentId: string; health: number; armor: number; firepower: number; toughness: number; engineering: number; rolls: [Roll, Roll]; augmentSlots: number[] };
export type WeaponSelection = { itemId: string; level: number; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string; rolls: [Roll, Roll] };
export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number };
export type GearSelection = {
itemId: string;
level: number;
brandId: string;
talentId: string;
health: number;
armor: number;
firepower: number;
toughness: number;
engineering: number;
rolls: [Roll, Roll];
augmentSlots: number[];
};
export type WeaponSelection = {
itemId: string;
level: number;
tier: number;
damage: number;
firepower: number;
toughness: number;
engineering: number;
talent1Id: string;
talent2Id: string;
rolls: [Roll, Roll];
};
export type ChipSelection = {
setId: string;
quality: 'Superior' | 'High-end';
primary: Roll;
secondary: [Roll, Roll];
};
export type ProtocolSelection = {
itemId: string;
level: number;
talentUpgrades: number;
};
export type CombatProfile = {
encounterSeconds: number;
hitRate: number;
headshotRate: number;
shotsPerBurst: number;
burstPauseSeconds: number;
sameTargetSeconds: number;
distinctTargetsPerMinute: number;
killsPerMinute: number;
headshotKillRate: number;
damageTakenPerMinute: number;
swapsPerMinute: number;
targetDistanceMeters: number;
healthPercent: number;
extraHealthActive: boolean;
encounterSeconds: number;
hitRate: number;
headshotRate: number;
shotsPerBurst: number;
burstPauseSeconds: number;
sameTargetSeconds: number;
distinctTargetsPerMinute: number;
killsPerMinute: number;
headshotKillRate: number;
damageTakenPerMinute: number;
swapsPerMinute: number;
targetDistanceMeters: number;
healthPercent: number;
extraHealthActive: boolean;
};
export type CalculationMode = 'maximum' | 'custom';
export type EffectMode = 'off' | 'peak' | 'encounter';
export type ConditionalEffectSelection = {
mode: EffectMode;
overrides?: Partial<CombatProfile>;
inputs?: Record<string, number | boolean>;
mode: EffectMode;
overrides?: Partial<CombatProfile>;
inputs?: Record<string, number | boolean>;
};
export type SkillActivationSelection = {
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
usesPerMinute?: number;
targets: number;
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
usesPerMinute?: number;
targets: number;
};
export type SpecializationSelection = {
focusId: string;
nodeRanks: Record<string, number>;
focusId: string;
nodeRanks: Record<string, number>;
};
export type LoadoutDraft = {
version: 4;
buildName: string;
calculationMode: CalculationMode;
gear: Record<string, GearSelection>;
weapons: [WeaponSelection, WeaponSelection];
chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection];
protocol: ProtocolSelection;
conditionalEffects: Record<string, ConditionalEffectSelection>;
combatProfile: CombatProfile;
skillActivations: Record<string, SkillActivationSelection>;
specialization: SpecializationSelection;
version: 4;
buildName: string;
calculationMode: CalculationMode;
gear: Record<string, GearSelection>;
weapons: [WeaponSelection, WeaponSelection];
chips: [
ChipSelection,
ChipSelection,
ChipSelection,
ChipSelection,
ChipSelection,
ChipSelection,
ChipSelection,
ChipSelection,
ChipSelection,
];
protocol: ProtocolSelection;
conditionalEffects: Record<string, ConditionalEffectSelection>;
combatProfile: CombatProfile;
skillActivations: Record<string, SkillActivationSelection>;
specialization: SpecializationSelection;
};
export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
export const gearSlots = [
'Holster',
'Gloves',
'Backpack',
'Knee Pads',
'Body Armor',
'Mask',
] as const;
export const blankRoll = (): Roll => ({ statId: '', value: 0 });
export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, firepower: 0, toughness: 0, engineering: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] });
export const blankWeapon = (): WeaponSelection => ({ itemId: '', level: 50, tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '', rolls: [blankRoll(), blankRoll()] });
export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
export const blankGear = (): GearSelection => ({
itemId: '',
level: 50,
brandId: '',
talentId: '',
health: 0,
armor: 0,
firepower: 0,
toughness: 0,
engineering: 0,
rolls: [blankRoll(), blankRoll()],
augmentSlots: [],
});
export const blankWeapon = (): WeaponSelection => ({
itemId: '',
level: 50,
tier: 0,
damage: 0,
firepower: 0,
toughness: 0,
engineering: 0,
talent1Id: '',
talent2Id: '',
rolls: [blankRoll(), blankRoll()],
});
export const blankChip = (): ChipSelection => ({
setId: '',
quality: 'Superior',
primary: blankRoll(),
secondary: [blankRoll(), blankRoll()],
});
export const defaultCombatProfile = (): CombatProfile => ({
encounterSeconds: 60,
hitRate: 75,
headshotRate: 20,
shotsPerBurst: 30,
burstPauseSeconds: 0,
sameTargetSeconds: 5,
distinctTargetsPerMinute: 12,
killsPerMinute: 4,
headshotKillRate: 20,
damageTakenPerMinute: 6,
swapsPerMinute: 2,
targetDistanceMeters: 15,
healthPercent: 100,
extraHealthActive: false
encounterSeconds: 60,
hitRate: 75,
headshotRate: 20,
shotsPerBurst: 30,
burstPauseSeconds: 0,
sameTargetSeconds: 5,
distinctTargetsPerMinute: 12,
killsPerMinute: 4,
headshotKillRate: 20,
damageTakenPerMinute: 6,
swapsPerMinute: 2,
targetDistanceMeters: 15,
healthPercent: 100,
extraHealthActive: false,
});
export const createDraft = (): LoadoutDraft => ({
version: 4,
buildName: 'Untitled loadout',
calculationMode: 'maximum',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
weapons: [blankWeapon(), blankWeapon()],
chips: [blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip()],
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
conditionalEffects: {},
combatProfile: defaultCombatProfile(),
skillActivations: {},
specialization: {
focusId: '',
nodeRanks: {}
}
version: 4,
buildName: 'Untitled loadout',
calculationMode: 'maximum',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
weapons: [blankWeapon(), blankWeapon()],
chips: [
blankChip(),
blankChip(),
blankChip(),
blankChip(),
blankChip(),
blankChip(),
blankChip(),
blankChip(),
blankChip(),
],
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
conditionalEffects: {},
combatProfile: defaultCombatProfile(),
skillActivations: {},
specialization: {
focusId: '',
nodeRanks: {},
},
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
Object.keys(value).length === keys.length && keys.every((key) => key in value);
const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
Object.keys(value).length === keys.length &&
keys.every((key) => key in value);
const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const isNumberRecord = (value: unknown) =>
isRecord(value) && Object.values(value).every((entry) => typeof entry === 'number' && Number.isFinite(entry));
isRecord(value) &&
Object.values(value).every(
(entry) => typeof entry === 'number' && Number.isFinite(entry),
);
const isRoll = (value: unknown): value is Roll =>
isRecord(value)
&& hasExactKeys(value, ['statId', 'value'])
&& typeof value.statId === 'string'
&& isFiniteNumber(value.value);
isRecord(value) &&
hasExactKeys(value, ['statId', 'value']) &&
typeof value.statId === 'string' &&
isFiniteNumber(value.value);
const isRollPair = (value: unknown): value is [Roll, Roll] =>
Array.isArray(value) && value.length === 2 && value.every(isRoll);
Array.isArray(value) && value.length === 2 && value.every(isRoll);
const isGearSelection = (value: unknown): value is GearSelection =>
isRecord(value)
&& hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots'])
&& ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string')
&& ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
&& isRollPair(value.rolls)
&& Array.isArray(value.augmentSlots)
&& value.augmentSlots.every(isFiniteNumber);
isRecord(value) &&
hasExactKeys(value, [
'itemId',
'level',
'brandId',
'talentId',
'health',
'armor',
'firepower',
'toughness',
'engineering',
'rolls',
'augmentSlots',
]) &&
['itemId', 'brandId', 'talentId'].every(
(key) => typeof value[key] === 'string',
) &&
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls) &&
Array.isArray(value.augmentSlots) &&
value.augmentSlots.every(isFiniteNumber);
const isWeaponSelection = (value: unknown): value is WeaponSelection =>
isRecord(value)
&& hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls'])
&& ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string')
&& ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
&& isRollPair(value.rolls);
isRecord(value) &&
hasExactKeys(value, [
'itemId',
'level',
'tier',
'damage',
'firepower',
'toughness',
'engineering',
'talent1Id',
'talent2Id',
'rolls',
]) &&
['itemId', 'talent1Id', 'talent2Id'].every(
(key) => typeof value[key] === 'string',
) &&
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
isRollPair(value.rolls);
const isChipSelection = (value: unknown): value is ChipSelection =>
isRecord(value)
&& hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary'])
&& typeof value.setId === 'string'
&& (value.quality === 'Superior' || value.quality === 'High-end')
&& isRoll(value.primary)
&& isRollPair(value.secondary);
isRecord(value) &&
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
typeof value.setId === 'string' &&
(value.quality === 'Superior' || value.quality === 'High-end') &&
isRoll(value.primary) &&
isRollPair(value.secondary);
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
isRecord(value)
&& hasExactKeys(value, ['itemId', 'level', 'talentUpgrades'])
&& typeof value.itemId === 'string'
&& isFiniteNumber(value.level)
&& isFiniteNumber(value.talentUpgrades);
isRecord(value) &&
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
typeof value.itemId === 'string' &&
isFiniteNumber(value.level) &&
isFiniteNumber(value.talentUpgrades);
const isCombatProfile = (value: unknown): value is CombatProfile =>
isRecord(value)
&& hasExactKeys(value, [
'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds',
'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute',
'swapsPerMinute', 'targetDistanceMeters', 'healthPercent', 'extraHealthActive'
])
&& [
'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds',
'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute',
'swapsPerMinute', 'targetDistanceMeters', 'healthPercent'
].every((key) => isFiniteNumber(value[key]))
&& typeof value.extraHealthActive === 'boolean';
const isEffectStates = (value: unknown): value is Record<string, ConditionalEffectSelection> =>
isRecord(value) && Object.values(value).every((entry) => {
if (!isRecord(entry)
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
const allowed = new Set(['mode', 'overrides', 'inputs']);
if (Object.keys(entry).some((key) => !allowed.has(key))
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
if (entry.inputs !== undefined && (
!isRecord(entry.inputs)
|| Object.values(entry.inputs).some((input) =>
typeof input !== 'boolean' && !isFiniteNumber(input)
)
)) return false;
return entry.overrides === undefined || (
isRecord(entry.overrides)
&& Object.entries(entry.overrides).every(([key, override]) =>
key === 'extraHealthActive'
? typeof override === 'boolean'
: key in defaultCombatProfile() && isFiniteNumber(override)
)
);
});
const isSkillActivations = (value: unknown): value is Record<string, SkillActivationSelection> =>
isRecord(value) && Object.values(value).every((entry) =>
isRecord(entry)
&& ['off', 'on_cooldown', 'custom_per_minute'].includes(String(entry.mode))
&& isFiniteNumber(entry.targets)
&& (entry.usesPerMinute === undefined || isFiniteNumber(entry.usesPerMinute))
&& Object.keys(entry).every((key) => ['mode', 'usesPerMinute', 'targets'].includes(key))
);
isRecord(value) &&
hasExactKeys(value, [
'encounterSeconds',
'hitRate',
'headshotRate',
'shotsPerBurst',
'burstPauseSeconds',
'sameTargetSeconds',
'distinctTargetsPerMinute',
'killsPerMinute',
'headshotKillRate',
'damageTakenPerMinute',
'swapsPerMinute',
'targetDistanceMeters',
'healthPercent',
'extraHealthActive',
]) &&
[
'encounterSeconds',
'hitRate',
'headshotRate',
'shotsPerBurst',
'burstPauseSeconds',
'sameTargetSeconds',
'distinctTargetsPerMinute',
'killsPerMinute',
'headshotKillRate',
'damageTakenPerMinute',
'swapsPerMinute',
'targetDistanceMeters',
'healthPercent',
].every((key) => isFiniteNumber(value[key])) &&
typeof value.extraHealthActive === 'boolean';
const isEffectStates = (
value: unknown,
): value is Record<string, ConditionalEffectSelection> =>
isRecord(value) &&
Object.values(value).every((entry) => {
if (
!isRecord(entry) ||
!['off', 'peak', 'encounter'].includes(String(entry.mode))
)
return false;
const allowed = new Set(['mode', 'overrides', 'inputs']);
if (
Object.keys(entry).some((key) => !allowed.has(key)) ||
!['off', 'peak', 'encounter'].includes(String(entry.mode))
)
return false;
if (
entry.inputs !== undefined &&
(!isRecord(entry.inputs) ||
Object.values(entry.inputs).some(
(input) => typeof input !== 'boolean' && !isFiniteNumber(input),
))
)
return false;
return (
entry.overrides === undefined ||
(isRecord(entry.overrides) &&
Object.entries(entry.overrides).every(([key, override]) =>
key === 'extraHealthActive'
? typeof override === 'boolean'
: key in defaultCombatProfile() && isFiniteNumber(override),
))
);
});
const isSkillActivations = (
value: unknown,
): value is Record<string, SkillActivationSelection> =>
isRecord(value) &&
Object.values(value).every(
(entry) =>
isRecord(entry) &&
['off', 'on_cooldown', 'custom_per_minute'].includes(
String(entry.mode),
) &&
isFiniteNumber(entry.targets) &&
(entry.usesPerMinute === undefined ||
isFiniteNumber(entry.usesPerMinute)) &&
Object.keys(entry).every((key) =>
['mode', 'usesPerMinute', 'targets'].includes(key),
),
);
export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
if (!isRecord(value)
|| !hasExactKeys(value, [
'version', 'buildName', 'calculationMode', 'gear', 'weapons', 'chips', 'protocol',
'conditionalEffects', 'combatProfile', 'skillActivations', 'specialization'
])
|| value.version !== 4
|| typeof value.buildName !== 'string'
|| !['maximum', 'custom'].includes(String(value.calculationMode))) return null;
const gear = value.gear;
if (!isRecord(gear) || Object.keys(gear).length !== gearSlots.length
|| !gearSlots.every((slot) => isGearSelection(gear[slot]))) return null;
if (!Array.isArray(value.weapons) || value.weapons.length !== 2 || !value.weapons.every(isWeaponSelection)) return null;
if (!Array.isArray(value.chips) || value.chips.length !== 9 || !value.chips.every(isChipSelection)) return null;
if (!isProtocolSelection(value.protocol)
|| !isEffectStates(value.conditionalEffects)
|| !isCombatProfile(value.combatProfile)
|| !isSkillActivations(value.skillActivations)) return null;
if (!isRecord(value.specialization)
|| !hasExactKeys(value.specialization, ['focusId', 'nodeRanks'])
|| typeof value.specialization.focusId !== 'string'
|| !isNumberRecord(value.specialization.nodeRanks)) return null;
return structuredClone(value) as LoadoutDraft;
if (
!isRecord(value) ||
!hasExactKeys(value, [
'version',
'buildName',
'calculationMode',
'gear',
'weapons',
'chips',
'protocol',
'conditionalEffects',
'combatProfile',
'skillActivations',
'specialization',
]) ||
value.version !== 4 ||
typeof value.buildName !== 'string' ||
!['maximum', 'custom'].includes(String(value.calculationMode))
)
return null;
const gear = value.gear;
if (
!isRecord(gear) ||
Object.keys(gear).length !== gearSlots.length ||
!gearSlots.every((slot) => isGearSelection(gear[slot]))
)
return null;
if (
!Array.isArray(value.weapons) ||
value.weapons.length !== 2 ||
!value.weapons.every(isWeaponSelection)
)
return null;
if (
!Array.isArray(value.chips) ||
value.chips.length !== 9 ||
!value.chips.every(isChipSelection)
)
return null;
if (
!isProtocolSelection(value.protocol) ||
!isEffectStates(value.conditionalEffects) ||
!isCombatProfile(value.combatProfile) ||
!isSkillActivations(value.skillActivations)
)
return null;
if (
!isRecord(value.specialization) ||
!hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) ||
typeof value.specialization.focusId !== 'string' ||
!isNumberRecord(value.specialization.nodeRanks)
)
return null;
return structuredClone(value) as LoadoutDraft;
}
export const clamp = (value: unknown, min: unknown, max: unknown) => {
const low = Number(min), high = Number(max), numeric = Number(value);
if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0;
return Math.min(Number.isFinite(high) ? high : numeric, Math.max(Number.isFinite(low) ? low : numeric, numeric));
const low = Number(min),
high = Number(max),
numeric = Number(value);
if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0;
return Math.min(
Number.isFinite(high) ? high : numeric,
Math.max(Number.isFinite(low) ? low : numeric, numeric),
);
};
export const scaled = (min: unknown, max: unknown, progress: number, steps: number) =>
Number(min) + (Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress)) / steps;
export const scaled = (
min: unknown,
max: unknown,
progress: number,
steps: number,
) =>
Number(min) +
((Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress))) /
steps;
export type LevelScaledRange = {
rawMin: number;
rawMax: number;
min: number;
max: number;
provisional: boolean;
rawMin: number;
rawMax: number;
min: number;
max: number;
provisional: boolean;
};
const itemLevel = (value: unknown) => Math.round(clamp(value, 1, 50));
export const gearMultiplier = (level: unknown) => 0.91 + 0.05 * itemLevel(level);
export const weaponMultiplier = (level: unknown) => 1.14 + 0.05 * itemLevel(level);
export const gearMultiplier = (level: unknown) =>
0.91 + 0.05 * itemLevel(level);
export const weaponMultiplier = (level: unknown) =>
1.14 + 0.05 * itemLevel(level);
export function gearBaseStatRange(minimum: unknown, maximum: unknown, targetLevel: unknown): LevelScaledRange {
const target = itemLevel(targetLevel);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * gearMultiplier(target);
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * gearMultiplier(target);
return {
rawMin,
rawMax,
min: Math.floor(rawMin),
max: Math.floor(rawMax),
provisional: target < 10
};
export function gearBaseStatRange(
minimum: unknown,
maximum: unknown,
targetLevel: unknown,
): LevelScaledRange {
const target = itemLevel(targetLevel);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin =
(Number.isFinite(configuredMin) ? configuredMin : 0) *
gearMultiplier(target);
const rawMax =
(Number.isFinite(configuredMax) ? configuredMax : 0) *
gearMultiplier(target);
return {
rawMin,
rawMax,
min: Math.floor(rawMin),
max: Math.floor(rawMax),
provisional: target < 10,
};
}
export function weaponBaseRange(minimum: unknown, maximum: unknown, targetLevel: unknown): LevelScaledRange {
const target = itemLevel(targetLevel);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * weaponMultiplier(target);
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * weaponMultiplier(target);
return {
rawMin,
rawMax,
min: Math.floor(rawMin),
max: Math.floor(rawMax),
provisional: target < 40
};
export function weaponBaseRange(
minimum: unknown,
maximum: unknown,
targetLevel: unknown,
): LevelScaledRange {
const target = itemLevel(targetLevel);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin =
(Number.isFinite(configuredMin) ? configuredMin : 0) *
weaponMultiplier(target);
const rawMax =
(Number.isFinite(configuredMax) ? configuredMax : 0) *
weaponMultiplier(target);
return {
rawMin,
rawMax,
min: Math.floor(rawMin),
max: Math.floor(rawMax),
provisional: target < 40,
};
}
export function levelScaledRange(
minimum: unknown,
maximum: unknown,
targetLevel: unknown,
referenceLevel: unknown,
source: 'gear' | 'weapon'
minimum: unknown,
maximum: unknown,
targetLevel: unknown,
referenceLevel: unknown,
source: 'gear' | 'weapon',
): LevelScaledRange {
const target = itemLevel(targetLevel);
const reference = itemLevel(referenceLevel);
const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier;
const ratio = multiplier(target) / multiplier(reference);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio;
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio;
return {
rawMin,
rawMax,
min: Math.floor(rawMin),
max: Math.floor(rawMax),
provisional: source === 'gear' ? target < 10 || reference < 10 : target < 40 || reference < 40
};
const target = itemLevel(targetLevel);
const reference = itemLevel(referenceLevel);
const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier;
const ratio = multiplier(target) / multiplier(reference);
const configuredMin = Number(minimum);
const configuredMax = Number(maximum);
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio;
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio;
return {
rawMin,
rawMax,
min: Math.floor(rawMin),
max: Math.floor(rawMax),
provisional:
source === 'gear'
? target < 10 || reference < 10
: target < 40 || reference < 40,
};
}
export function remapRangeValue(value: unknown, from: LevelScaledRange, to: LevelScaledRange) {
const span = from.max - from.min;
const position = span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0;
return Math.round(to.min + (to.max - to.min) * position);
export function remapRangeValue(
value: unknown,
from: LevelScaledRange,
to: LevelScaledRange,
) {
const span = from.max - from.min;
const position =
span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0;
return Math.round(to.min + (to.max - to.min) * position);
}
+59 -32
View File
@@ -1,39 +1,66 @@
import { describe, expect, it } from 'vitest';
import {
formatItemDamageRangeScript,
formatWeaponCalculationScript
formatItemDamageRangeScript,
formatWeaponCalculationScript,
} from './calculation-script';
describe('formatWeaponCalculationScript', () => {
it('starts from the selected equipped damage and explains ordered typed stages', () => {
const lines = formatWeaponCalculationScript({
revision: { version: 4, name: 'Resurgence model' },
stages: [
{ stage_key: 'base', label: 'Base damage', operator: 'base_damage', display_order: 1 },
{ stage_key: 'flat', label: 'Flat damage', operator: 'flat_add', display_order: 2 },
{ stage_key: 'cycle', label: 'Firing cycle', operator: 'firing_cycle', display_order: 3 }
],
profiles: [{
stat_attribute_id: 'weapon-flat',
stage_key: 'flat',
weight: 1.5,
soft_cap: null,
hard_cap: 100,
curve_type: 'none',
curve_parameter: null,
weapon_category: 'SMG'
}],
attributes: [{ id: 'weapon-flat', name: 'Flat Weapon Damage', unit: null }]
});
expect(lines).toContain('TAKE equipped_weapon.damage AS base_damage');
expect(lines).toContain('ADD flat_weapon_damage × 1.5 HARD_CAP 100 FOR SMG TO damage');
expect(lines.at(-1)).toBe('RETURN expected_damage_per_second');
});
it('starts from the selected equipped damage and explains ordered typed stages', () => {
const lines = formatWeaponCalculationScript({
revision: { version: 4, name: 'Resurgence model' },
stages: [
{
stage_key: 'base',
label: 'Base damage',
operator: 'base_damage',
display_order: 1,
},
{
stage_key: 'flat',
label: 'Flat damage',
operator: 'flat_add',
display_order: 2,
},
{
stage_key: 'cycle',
label: 'Firing cycle',
operator: 'firing_cycle',
display_order: 3,
},
],
profiles: [
{
stat_attribute_id: 'weapon-flat',
stage_key: 'flat',
weight: 1.5,
soft_cap: null,
hard_cap: 100,
curve_type: 'none',
curve_parameter: null,
weapon_category: 'SMG',
},
],
attributes: [
{ id: 'weapon-flat', name: 'Flat Weapon Damage', unit: null },
],
});
expect(lines).toContain('TAKE equipped_weapon.damage AS base_damage');
expect(lines).toContain(
'ADD flat_weapon_damage × 1.5 HARD_CAP 100 FOR SMG TO damage',
);
expect(lines.at(-1)).toBe('RETURN expected_damage_per_second');
});
it('keeps level scaling in item configuration instead of applying it twice', () => {
const lines = formatItemDamageRangeScript();
expect(lines).toContain('SCALE configured_damage_range TO equipped_weapon.level');
expect(lines).toContain('PASS equipped_weapon.damage TO combat_calculation');
expect(lines).not.toContain('SCALE equipped_weapon.damage TO equipped_weapon.level');
});
it('keeps level scaling in item configuration instead of applying it twice', () => {
const lines = formatItemDamageRangeScript();
expect(lines).toContain(
'SCALE configured_damage_range TO equipped_weapon.level',
);
expect(lines).toContain(
'PASS equipped_weapon.damage TO combat_calculation',
);
expect(lines).not.toContain(
'SCALE equipped_weapon.damage TO equipped_weapon.level',
);
});
});
+120 -104
View File
@@ -1,131 +1,147 @@
export type CalculationScriptStage = {
stage_key: string;
label: string;
operator: string;
display_order: number;
stage_key: string;
label: string;
operator: string;
display_order: number;
};
export type CalculationScriptProfile = {
stat_attribute_id: string;
stage_key: string;
weight?: number | null;
soft_cap?: number | null;
hard_cap?: number | null;
curve_type?: string | null;
curve_parameter?: number | null;
weapon_category?: string | null;
stat_attribute_id: string;
stage_key: string;
weight?: number | null;
soft_cap?: number | null;
hard_cap?: number | null;
curve_type?: string | null;
curve_parameter?: number | null;
weapon_category?: string | null;
};
export type CalculationScriptAttribute = {
id: string;
name: string;
unit?: string | null;
id: string;
name: string;
unit?: string | null;
};
export type CalculationScriptRevision = {
version?: number | null;
name?: string | null;
version?: number | null;
name?: string | null;
};
const number = (value: unknown) =>
Number.isInteger(Number(value)) ? String(Number(value)) : String(Number(Number(value).toFixed(3)));
Number.isInteger(Number(value))
? String(Number(value))
: String(Number(Number(value).toFixed(3)));
const identifier = (value: unknown) => String(value ?? '')
.normalize('NFKC')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'unnamed';
const identifier = (value: unknown) =>
String(value ?? '')
.normalize('NFKC')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'unnamed';
function profileExpression(
profile: CalculationScriptProfile,
attributes: Map<string, CalculationScriptAttribute>
profile: CalculationScriptProfile,
attributes: Map<string, CalculationScriptAttribute>,
) {
const attribute = attributes.get(profile.stat_attribute_id);
const unit = attribute?.unit ? ` ${attribute.unit}` : '';
const weight = Number(profile.weight ?? 1);
let expression = `${identifier(attribute?.name ?? profile.stat_attribute_id)}${unit}`;
if (weight !== 1) expression += ` × ${number(weight)}`;
if (profile.soft_cap !== null && profile.soft_cap !== undefined) {
const curve = profile.curve_type && profile.curve_type !== 'none'
? ` USING ${String(profile.curve_type).toUpperCase()}(${number(profile.curve_parameter ?? 1)})`
: '';
expression += ` SOFT_CAP ${number(profile.soft_cap)}${curve}`;
}
if (profile.hard_cap !== null && profile.hard_cap !== undefined) {
expression += ` HARD_CAP ${number(profile.hard_cap)}`;
}
if (profile.weapon_category && profile.weapon_category !== '*') {
expression += ` FOR ${String(profile.weapon_category).toUpperCase()}`;
}
return expression;
const attribute = attributes.get(profile.stat_attribute_id);
const unit = attribute?.unit ? ` ${attribute.unit}` : '';
const weight = Number(profile.weight ?? 1);
let expression = `${identifier(attribute?.name ?? profile.stat_attribute_id)}${unit}`;
if (weight !== 1) expression += ` × ${number(weight)}`;
if (profile.soft_cap !== null && profile.soft_cap !== undefined) {
const curve =
profile.curve_type && profile.curve_type !== 'none'
? ` USING ${String(profile.curve_type).toUpperCase()}(${number(profile.curve_parameter ?? 1)})`
: '';
expression += ` SOFT_CAP ${number(profile.soft_cap)}${curve}`;
}
if (profile.hard_cap !== null && profile.hard_cap !== undefined) {
expression += ` HARD_CAP ${number(profile.hard_cap)}`;
}
if (profile.weapon_category && profile.weapon_category !== '*') {
expression += ` FOR ${String(profile.weapon_category).toUpperCase()}`;
}
return expression;
}
const operatorStatement = (operator: string, label: string, sources: string[]) => {
const source = sources.length ? sources.join(', ') : 'no_bound_attributes';
switch (operator) {
case 'base_damage':
return ['TAKE equipped_weapon.damage AS base_damage'];
case 'flat_add':
return [`ADD ${source} TO damage`];
case 'additive_percent':
return [`APPLY SUM(${source}) AS additive_percent TO damage`];
case 'multiplicative_percent':
return [`APPLY EACH(${source}) AS multiplicative_percent TO damage`];
case 'expected_critical':
return [
`WEIGHT critical_damage BY critical_chance`,
...(sources.length ? [`SOURCE ${source}`] : [])
];
case 'expected_headshot':
return [
`WEIGHT headshot_damage BY headshot_chance`,
...(sources.length ? [`SOURCE ${source}`] : [])
];
case 'rate_of_fire':
return [`ADJUST equipped_weapon.rpm USING ${source}`];
case 'magazine_size':
return [`ADJUST equipped_weapon.magazine USING ${source}`];
case 'reload_speed':
return [`ADJUST equipped_weapon.reload_seconds USING ${source}`];
case 'firing_cycle':
return [
'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds',
'RETURN expected_damage_per_second'
];
default:
return [`APPLY ${identifier(label)} USING ${source}`];
}
const operatorStatement = (
operator: string,
label: string,
sources: string[],
) => {
const source = sources.length ? sources.join(', ') : 'no_bound_attributes';
switch (operator) {
case 'base_damage':
return ['TAKE equipped_weapon.damage AS base_damage'];
case 'flat_add':
return [`ADD ${source} TO damage`];
case 'additive_percent':
return [`APPLY SUM(${source}) AS additive_percent TO damage`];
case 'multiplicative_percent':
return [`APPLY EACH(${source}) AS multiplicative_percent TO damage`];
case 'expected_critical':
return [
`WEIGHT critical_damage BY critical_chance`,
...(sources.length ? [`SOURCE ${source}`] : []),
];
case 'expected_headshot':
return [
`WEIGHT headshot_damage BY headshot_chance`,
...(sources.length ? [`SOURCE ${source}`] : []),
];
case 'rate_of_fire':
return [`ADJUST equipped_weapon.rpm USING ${source}`];
case 'magazine_size':
return [`ADJUST equipped_weapon.magazine USING ${source}`];
case 'reload_speed':
return [`ADJUST equipped_weapon.reload_seconds USING ${source}`];
case 'firing_cycle':
return [
'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds',
'RETURN expected_damage_per_second',
];
default:
return [`APPLY ${identifier(label)} USING ${source}`];
}
};
export function formatWeaponCalculationScript(input: {
revision?: CalculationScriptRevision | null;
stages: CalculationScriptStage[];
profiles: CalculationScriptProfile[];
attributes: CalculationScriptAttribute[];
revision?: CalculationScriptRevision | null;
stages: CalculationScriptStage[];
profiles: CalculationScriptProfile[];
attributes: CalculationScriptAttribute[];
}) {
const attributes = new Map(input.attributes.map((attribute) => [String(attribute.id), attribute]));
const lines = [
`CALCULATE expected_weapon_dps${input.revision?.version ? ` WITH rules_r${input.revision.version}` : ''}`
];
for (const stage of [...input.stages].sort(
(left, right) => Number(left.display_order) - Number(right.display_order)
)) {
const sources = input.profiles
.filter((profile) => profile.stage_key === stage.stage_key)
.map((profile) => profileExpression(profile, attributes));
lines.push('', `STAGE ${String(stage.label).toUpperCase()}`);
lines.push(...operatorStatement(String(stage.operator), String(stage.label), sources));
}
return lines;
const attributes = new Map(
input.attributes.map((attribute) => [String(attribute.id), attribute]),
);
const lines = [
`CALCULATE expected_weapon_dps${input.revision?.version ? ` WITH rules_r${input.revision.version}` : ''}`,
];
for (const stage of [...input.stages].sort(
(left, right) => Number(left.display_order) - Number(right.display_order),
)) {
const sources = input.profiles
.filter((profile) => profile.stage_key === stage.stage_key)
.map((profile) => profileExpression(profile, attributes));
lines.push('', `STAGE ${String(stage.label).toUpperCase()}`);
lines.push(
...operatorStatement(
String(stage.operator),
String(stage.label),
sources,
),
);
}
return lines;
}
export function formatItemDamageRangeScript() {
return [
'CONFIGURE equipped_weapon',
'READ configured_damage_range AT level_1',
'SCALE configured_damage_range TO equipped_weapon.level',
'SELECT equipped_weapon.damage WITHIN scaled_damage_range',
'PASS equipped_weapon.damage TO combat_calculation'
];
return [
'CONFIGURE equipped_weapon',
'READ configured_damage_range AT level_1',
'SCALE configured_damage_range TO equipped_weapon.level',
'SELECT equipped_weapon.damage WITHIN scaled_damage_range',
'PASS equipped_weapon.damage TO combat_calculation',
];
}
+221 -126
View File
@@ -1,141 +1,236 @@
import { describe, expect, it } from 'vitest';
import {
calculateCombat,
expectedWeaponShotDamage,
type CombatCalculationInput
calculateCombat,
expectedWeaponShotDamage,
type CombatCalculationInput,
} from './combat.js';
const base = (overrides: Partial<CombatCalculationInput> = {}): CombatCalculationInput => ({
weapon: { baseDamage: 100, rpm: 600, magazine: 10, reloadSeconds: 2, headshotMultiplier: 2 },
attributes: { weapon: 20, critChance: 25, critDamage: 50, headshotChance: 0, final: 10 },
bindings: [
{ attributeId: 'weapon', stage: 'weapon_damage', operator: 'add' },
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' },
{ attributeId: 'headshotChance', stage: 'headshot_chance', operator: 'add' },
{ attributeId: 'final', stage: 'final_damage', operator: 'add' }
],
assumptions: { mode: 'off', hitRate: 100, headshotRate: 0, firingActivity: 100, encounterSeconds: 20 },
...overrides
const base = (
overrides: Partial<CombatCalculationInput> = {},
): CombatCalculationInput => ({
weapon: {
baseDamage: 100,
rpm: 600,
magazine: 10,
reloadSeconds: 2,
headshotMultiplier: 2,
},
attributes: {
weapon: 20,
critChance: 25,
critDamage: 50,
headshotChance: 0,
final: 10,
},
bindings: [
{ attributeId: 'weapon', stage: 'weapon_damage', operator: 'add' },
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' },
{
attributeId: 'headshotChance',
stage: 'headshot_chance',
operator: 'add',
},
{ attributeId: 'final', stage: 'final_damage', operator: 'add' },
],
assumptions: {
mode: 'off',
hitRate: 100,
headshotRate: 0,
firingActivity: 100,
encounterSeconds: 20,
},
...overrides,
});
describe('calculateCombat', () => {
it('calculates four hit values and reload-aware DPS', () => {
const result = calculateCombat(base());
expect(result.status).toBe('OK');
expect(result.hitValues).toEqual({ normal: 132, critical: 198, headshot: 264, criticalHeadshot: 396 });
expect(result.burstDps).toBe(1485);
expect(result.sustainedDps).toBeCloseTo(495);
});
it('calculates four hit values and reload-aware DPS', () => {
const result = calculateCombat(base());
expect(result.status).toBe('OK');
expect(result.hitValues).toEqual({
normal: 132,
critical: 198,
headshot: 264,
criticalHeadshot: 396,
});
expect(result.burstDps).toBe(1485);
expect(result.sustainedDps).toBeCloseTo(495);
});
it('calculates expected body and headshot damage for timeline events', () => {
const input = base({
attributes: { critChance: 50, critDamage: 100 },
bindings: [
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' }
]
});
expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings)).toBe(150);
expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings, true)).toBe(300);
});
it('calculates expected body and headshot damage for timeline events', () => {
const input = base({
attributes: { critChance: 50, critDamage: 100 },
bindings: [
{
attributeId: 'critChance',
stage: 'critical_chance',
operator: 'add',
},
{
attributeId: 'critDamage',
stage: 'critical_damage',
operator: 'add',
},
],
});
expect(
expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings),
).toBe(150);
expect(
expectedWeaponShotDamage(
input.weapon,
input.attributes,
input.bindings,
true,
),
).toBe(300);
});
it('applies weights, hard caps, and a configured curve safely', () => {
const result = calculateCombat(base({
attributes: { weapon: 100 },
bindings: [{
attributeId: 'weapon',
stage: 'weapon_damage',
operator: 'add',
weight: 2,
softCap: 50,
hardCap: 80,
curveType: 'power',
curveParameter: 1
}]
}));
expect(result.hitValues?.normal).toBe(180);
});
it('applies weights, hard caps, and a configured curve safely', () => {
const result = calculateCombat(
base({
attributes: { weapon: 100 },
bindings: [
{
attributeId: 'weapon',
stage: 'weapon_damage',
operator: 'add',
weight: 2,
softCap: 50,
hardCap: 80,
curveType: 'power',
curveParameter: 1,
},
],
}),
);
expect(result.hitValues?.normal).toBe(180);
});
it('applies firing-cycle attributes to burst and sustained DPS', () => {
const result = calculateCombat(base({
attributes: { rate: 20, magazine: 100, reload: 100 },
bindings: [
{ attributeId: 'rate', stage: 'rate_of_fire', operator: 'add' },
{ attributeId: 'magazine', stage: 'magazine_size', operator: 'add' },
{ attributeId: 'reload', stage: 'reload_speed', operator: 'add' }
]
}));
expect(result.status).toBe('OK');
expect(result.burstDps).toBe(1200);
expect(result.sustainedDps).toBeCloseTo(750);
});
it('applies firing-cycle attributes to burst and sustained DPS', () => {
const result = calculateCombat(
base({
attributes: { rate: 20, magazine: 100, reload: 100 },
bindings: [
{ attributeId: 'rate', stage: 'rate_of_fire', operator: 'add' },
{ attributeId: 'magazine', stage: 'magazine_size', operator: 'add' },
{ attributeId: 'reload', stage: 'reload_speed', operator: 'add' },
],
}),
);
expect(result.status).toBe('OK');
expect(result.burstDps).toBe(1200);
expect(result.sustainedDps).toBeCloseTo(750);
});
it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => {
const stackingEffects = [{
id: 'relentless',
trigger: { hitsRequired: 9, windowSeconds: 3 },
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
attributes: { weapon: 3, critDamage: 1 }
}];
const off = calculateCombat(base({ stackingEffects }));
const peak = calculateCombat(base({
stackingEffects,
assumptions: { mode: 'off', perEffect: { relentless: { mode: 'peak' } } }
}));
expect(off.hitValues?.normal).toBe(132);
expect(peak.hitValues?.normal).toBeCloseTo(165);
expect(peak.stackSummaries[0].stacksAtEnd).toBe(10);
});
it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => {
const stackingEffects = [
{
id: 'relentless',
trigger: { hitsRequired: 9, windowSeconds: 3 },
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
attributes: { weapon: 3, critDamage: 1 },
},
];
const off = calculateCombat(base({ stackingEffects }));
const peak = calculateCombat(
base({
stackingEffects,
assumptions: {
mode: 'off',
perEffect: { relentless: { mode: 'peak' } },
},
}),
);
expect(off.hitValues?.normal).toBe(132);
expect(peak.hitValues?.normal).toBeCloseTo(165);
expect(peak.stackSummaries[0].stacksAtEnd).toBe(10);
});
it('models encounter stack ramp and refresh with the weapon firing cycle', () => {
const result = calculateCombat(base({
stackingEffects: [{
id: 'relentless',
trigger: { hitsRequired: 9, windowSeconds: 3 },
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
attributes: { weapon: 3 }
}],
assumptions: { mode: 'encounter', hitRate: 100, firingActivity: 100, encounterSeconds: 30 }
}));
expect(result.encounterDps).not.toBeNull();
expect(result.encounterDps!).toBeGreaterThan(495);
expect(result.stackSummaries[0].maxStacksReached).toBe(10);
expect(result.stackSummaries[0].maintained).toBe(true);
});
it('models encounter stack ramp and refresh with the weapon firing cycle', () => {
const result = calculateCombat(
base({
stackingEffects: [
{
id: 'relentless',
trigger: { hitsRequired: 9, windowSeconds: 3 },
lifecycle: {
maxStacks: 10,
durationSeconds: 10,
stacksPerTrigger: 1,
},
attributes: { weapon: 3 },
},
],
assumptions: {
mode: 'encounter',
hitRate: 100,
firingActivity: 100,
encounterSeconds: 30,
},
}),
);
expect(result.encounterDps).not.toBeNull();
expect(result.encounterDps!).toBeGreaterThan(495);
expect(result.stackSummaries[0].maxStacksReached).toBe(10);
expect(result.stackSummaries[0].maintained).toBe(true);
});
it('does not trigger when expected hits cannot meet the trigger window', () => {
const result = calculateCombat(base({
stackingEffects: [{
id: 'slow',
trigger: { hitsRequired: 9, windowSeconds: 0.5 },
lifecycle: { maxStacks: 10, durationSeconds: 10 },
attributes: { weapon: 3 }
}],
assumptions: { mode: 'encounter', hitRate: 10, firingActivity: 50, encounterSeconds: 20 }
}));
expect(result.stackSummaries[0]).toMatchObject({ maxStacksReached: 0, averageStacks: 0, maintained: false });
});
it('does not trigger when expected hits cannot meet the trigger window', () => {
const result = calculateCombat(
base({
stackingEffects: [
{
id: 'slow',
trigger: { hitsRequired: 9, windowSeconds: 0.5 },
lifecycle: { maxStacks: 10, durationSeconds: 10 },
attributes: { weapon: 3 },
},
],
assumptions: {
mode: 'encounter',
hitRate: 10,
firingActivity: 50,
encounterSeconds: 20,
},
}),
);
expect(result.stackSummaries[0]).toMatchObject({
maxStacksReached: 0,
averageStacks: 0,
maintained: false,
});
});
it('returns explicit requirements instead of partial results', () => {
const result = calculateCombat(base({
weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 },
assumptions: { mode: 'encounter' },
stackingEffects: [{
id: 'need-profile',
trigger: { hitsRequired: 1, windowSeconds: 1 },
lifecycle: { maxStacks: 1, durationSeconds: 1 },
attributes: {}
}]
}));
expect(result).toMatchObject({ status: 'INSUFFICIENT_DATA', hitValues: null, burstDps: null });
expect(result.missing).toEqual(expect.arrayContaining([
'weapon.rpm',
'weapon.magazine',
'weapon.reloadSeconds',
'assumptions.hitRate',
'assumptions.firingActivity',
'assumptions.encounterSeconds'
]));
});
it('returns explicit requirements instead of partial results', () => {
const result = calculateCombat(
base({
weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 },
assumptions: { mode: 'encounter' },
stackingEffects: [
{
id: 'need-profile',
trigger: { hitsRequired: 1, windowSeconds: 1 },
lifecycle: { maxStacks: 1, durationSeconds: 1 },
attributes: {},
},
],
}),
);
expect(result).toMatchObject({
status: 'INSUFFICIENT_DATA',
hitValues: null,
burstDps: null,
});
expect(result.missing).toEqual(
expect.arrayContaining([
'weapon.rpm',
'weapon.magazine',
'weapon.reloadSeconds',
'assumptions.hitRate',
'assumptions.firingActivity',
'assumptions.encounterSeconds',
]),
);
});
});
+525 -390
View File
@@ -1,465 +1,600 @@
export type CalculationStage =
| 'flat_damage'
| 'weapon_damage'
| 'critical_chance'
| 'critical_damage'
| 'headshot_chance'
| 'headshot_damage'
| 'rate_of_fire'
| 'magazine_size'
| 'reload_speed'
| 'final_damage';
| 'flat_damage'
| 'weapon_damage'
| 'critical_chance'
| 'critical_damage'
| 'headshot_chance'
| 'headshot_damage'
| 'rate_of_fire'
| 'magazine_size'
| 'reload_speed'
| 'final_damage';
export type CalculationOperator = 'add' | 'multiply';
export type CurveType = 'none' | 'power' | 'smoothstep';
export type AttributeBinding = {
attributeId: string;
stage: CalculationStage;
operator: CalculationOperator;
weight?: number;
softCap?: number | null;
hardCap?: number | null;
curveType?: CurveType;
curveParameter?: number | null;
attributeId: string;
stage: CalculationStage;
operator: CalculationOperator;
weight?: number;
softCap?: number | null;
hardCap?: number | null;
curveType?: CurveType;
curveParameter?: number | null;
};
export type StatValues = Record<string, number>;
export type WeaponCombatInput = {
baseDamage?: number;
rpm?: number;
magazine?: number;
reloadSeconds?: number;
headshotMultiplier?: number;
baseDamage?: number;
rpm?: number;
magazine?: number;
reloadSeconds?: number;
headshotMultiplier?: number;
};
export type WeaponFiringCycle = {
rpm: number;
magazine: number;
reloadSeconds: number;
rpm: number;
magazine: number;
reloadSeconds: number;
};
export type StackingMode = 'off' | 'peak' | 'encounter';
export type TriggerRule = { hitsRequired: number; windowSeconds: number };
export type StackLifecycle = { maxStacks: number; durationSeconds: number; stacksPerTrigger?: number };
export type StackLifecycle = {
maxStacks: number;
durationSeconds: number;
stacksPerTrigger?: number;
};
export type StackingEffect = {
id: string;
trigger: TriggerRule;
lifecycle: StackLifecycle;
attributes: StatValues;
id: string;
trigger: TriggerRule;
lifecycle: StackLifecycle;
attributes: StatValues;
};
export type CombatAssumptions = {
mode: StackingMode;
hitRate?: number;
headshotRate?: number;
firingActivity?: number;
encounterSeconds?: number;
perEffect?: Record<string, Partial<Omit<CombatAssumptions, 'perEffect'>>>;
mode: StackingMode;
hitRate?: number;
headshotRate?: number;
firingActivity?: number;
encounterSeconds?: number;
perEffect?: Record<string, Partial<Omit<CombatAssumptions, 'perEffect'>>>;
};
export type MissingRequirement =
| 'weapon.baseDamage'
| 'weapon.rpm'
| 'weapon.magazine'
| 'weapon.reloadSeconds'
| 'assumptions.hitRate'
| 'assumptions.firingActivity'
| 'assumptions.encounterSeconds'
| `binding:${string}`;
| 'weapon.baseDamage'
| 'weapon.rpm'
| 'weapon.magazine'
| 'weapon.reloadSeconds'
| 'assumptions.hitRate'
| 'assumptions.firingActivity'
| 'assumptions.encounterSeconds'
| `binding:${string}`;
export type DamageValues = {
normal: number;
critical: number;
headshot: number;
criticalHeadshot: number;
normal: number;
critical: number;
headshot: number;
criticalHeadshot: number;
};
export type StackSummary = {
effectId: string;
mode: StackingMode;
stacksAtEnd: number;
maxStacksReached: number;
buildUpSeconds: number | null;
averageStacks: number;
maintained: boolean;
effectId: string;
mode: StackingMode;
stacksAtEnd: number;
maxStacksReached: number;
buildUpSeconds: number | null;
averageStacks: number;
maintained: boolean;
};
export type CombatCalculation = {
status: 'OK' | 'INSUFFICIENT_DATA';
missing: MissingRequirement[];
hitValues: DamageValues | null;
burstDps: number | null;
sustainedDps: number | null;
encounterDps: number | null;
stackSummaries: StackSummary[];
status: 'OK' | 'INSUFFICIENT_DATA';
missing: MissingRequirement[];
hitValues: DamageValues | null;
burstDps: number | null;
sustainedDps: number | null;
encounterDps: number | null;
stackSummaries: StackSummary[];
};
export type CombatCalculationInput = {
weapon: WeaponCombatInput;
attributes: StatValues;
bindings: AttributeBinding[];
stageOrder?: CalculationStage[];
stackingEffects?: StackingEffect[];
assumptions: CombatAssumptions;
weapon: WeaponCombatInput;
attributes: StatValues;
bindings: AttributeBinding[];
stageOrder?: CalculationStage[];
stackingEffects?: StackingEffect[];
assumptions: CombatAssumptions;
};
type ResolvedAssumptions = {
mode: StackingMode;
hitRate: number;
headshotRate: number;
firingActivity: number;
encounterSeconds: number;
mode: StackingMode;
hitRate: number;
headshotRate: number;
firingActivity: number;
encounterSeconds: number;
};
type StackState = {
stacks: number;
expiresAt: number;
maxStacksReached: number;
buildUpSeconds: number | null;
stacks: number;
expiresAt: number;
maxStacksReached: number;
buildUpSeconds: number | null;
};
type TimedStackEffect = {
effect: StackingEffect;
assumptions: ResolvedAssumptions;
events: number[];
effect: StackingEffect;
assumptions: ResolvedAssumptions;
events: number[];
};
const EPSILON = 0.000_001;
const finitePositive = (value: unknown) => Number.isFinite(Number(value)) && Number(value) > 0;
const finiteNonNegative = (value: unknown) => Number.isFinite(Number(value)) && Number(value) >= 0;
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
const percent = (value: unknown, fallback = 0) => clamp(Number.isFinite(Number(value)) ? Number(value) : fallback, 0, 100) / 100;
const finitePositive = (value: unknown) =>
Number.isFinite(Number(value)) && Number(value) > 0;
const finiteNonNegative = (value: unknown) =>
Number.isFinite(Number(value)) && Number(value) >= 0;
const clamp = (value: number, low: number, high: number) =>
Math.min(high, Math.max(low, value));
const percent = (value: unknown, fallback = 0) =>
clamp(Number.isFinite(Number(value)) ? Number(value) : fallback, 0, 100) /
100;
function resolveAssumptions(base: CombatAssumptions, effectId: string): ResolvedAssumptions {
const override = base.perEffect?.[effectId] ?? {};
return {
mode: override.mode ?? base.mode,
hitRate: percent(override.hitRate ?? base.hitRate, 100),
headshotRate: percent(override.headshotRate ?? base.headshotRate, 0),
firingActivity: percent(override.firingActivity ?? base.firingActivity, 100),
encounterSeconds: Number(override.encounterSeconds ?? base.encounterSeconds ?? 0)
};
function resolveAssumptions(
base: CombatAssumptions,
effectId: string,
): ResolvedAssumptions {
const override = base.perEffect?.[effectId] ?? {};
return {
mode: override.mode ?? base.mode,
hitRate: percent(override.hitRate ?? base.hitRate, 100),
headshotRate: percent(override.headshotRate ?? base.headshotRate, 0),
firingActivity: percent(
override.firingActivity ?? base.firingActivity,
100,
),
encounterSeconds: Number(
override.encounterSeconds ?? base.encounterSeconds ?? 0,
),
};
}
function transformedValue(raw: number, binding: AttributeBinding) {
const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1;
const sign = raw < 0 ? -1 : 1;
let value = Math.abs(raw * weight);
const softCap = finiteNonNegative(binding.softCap) ? Number(binding.softCap) : null;
const hardCap = finiteNonNegative(binding.hardCap) ? Number(binding.hardCap) : null;
if (softCap !== null && value > softCap && binding.curveType && binding.curveType !== 'none') {
const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1));
const overflow = value - softCap;
if (binding.curveType === 'power') {
value = softCap + overflow * Math.pow(softCap / Math.max(softCap + overflow, EPSILON), parameter);
} else {
const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1);
const t = clamp(overflow / Math.max(ceiling - softCap, EPSILON), 0, 1);
value = softCap + (ceiling - softCap) * (t * t * (3 - 2 * t));
}
}
if (hardCap !== null) value = Math.min(value, hardCap);
return value * sign;
const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1;
const sign = raw < 0 ? -1 : 1;
let value = Math.abs(raw * weight);
const softCap = finiteNonNegative(binding.softCap)
? Number(binding.softCap)
: null;
const hardCap = finiteNonNegative(binding.hardCap)
? Number(binding.hardCap)
: null;
if (
softCap !== null &&
value > softCap &&
binding.curveType &&
binding.curveType !== 'none'
) {
const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1));
const overflow = value - softCap;
if (binding.curveType === 'power') {
value =
softCap +
overflow *
Math.pow(softCap / Math.max(softCap + overflow, EPSILON), parameter);
} else {
const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1);
const t = clamp(overflow / Math.max(ceiling - softCap, EPSILON), 0, 1);
value = softCap + (ceiling - softCap) * (t * t * (3 - 2 * t));
}
}
if (hardCap !== null) value = Math.min(value, hardCap);
return value * sign;
}
function addAttributes(target: StatValues, incoming: StatValues, multiplier = 1) {
for (const [id, value] of Object.entries(incoming)) {
if (Number.isFinite(value)) target[id] = (target[id] ?? 0) + value * multiplier;
}
function addAttributes(
target: StatValues,
incoming: StatValues,
multiplier = 1,
) {
for (const [id, value] of Object.entries(incoming)) {
if (Number.isFinite(value))
target[id] = (target[id] ?? 0) + value * multiplier;
}
}
function requiredData(input: CombatCalculationInput): MissingRequirement[] {
const missing: MissingRequirement[] = [];
if (!finitePositive(input.weapon.baseDamage)) missing.push('weapon.baseDamage');
if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm');
if (!Number.isInteger(input.weapon.magazine) || Number(input.weapon.magazine) < 1) missing.push('weapon.magazine');
if (!finiteNonNegative(input.weapon.reloadSeconds)) missing.push('weapon.reloadSeconds');
const boundIds = new Set(input.bindings.map((binding) => binding.attributeId));
for (const binding of input.bindings) {
if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0)) missing.push(`binding:${binding.attributeId}`);
}
if (input.assumptions.mode === 'encounter') {
if (!finitePositive(input.assumptions.hitRate)) missing.push('assumptions.hitRate');
if (!finitePositive(input.assumptions.firingActivity)) missing.push('assumptions.firingActivity');
if (!finitePositive(input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
}
for (const effect of input.stackingEffects ?? []) {
const assumptions = resolveAssumptions(input.assumptions, effect.id);
if (assumptions.mode === 'encounter') {
const override = input.assumptions.perEffect?.[effect.id];
if (!finitePositive(override?.hitRate ?? input.assumptions.hitRate)) missing.push('assumptions.hitRate');
if (!finitePositive(override?.firingActivity ?? input.assumptions.firingActivity)) missing.push('assumptions.firingActivity');
if (!finitePositive(override?.encounterSeconds ?? input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
}
if (assumptions.mode !== 'off') for (const id of Object.keys(effect.attributes)) {
if (!boundIds.has(id)) missing.push(`binding:${id}`);
}
}
return [...new Set(missing)];
const missing: MissingRequirement[] = [];
if (!finitePositive(input.weapon.baseDamage))
missing.push('weapon.baseDamage');
if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm');
if (
!Number.isInteger(input.weapon.magazine) ||
Number(input.weapon.magazine) < 1
)
missing.push('weapon.magazine');
if (!finiteNonNegative(input.weapon.reloadSeconds))
missing.push('weapon.reloadSeconds');
const boundIds = new Set(
input.bindings.map((binding) => binding.attributeId),
);
for (const binding of input.bindings) {
if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0))
missing.push(`binding:${binding.attributeId}`);
}
if (input.assumptions.mode === 'encounter') {
if (!finitePositive(input.assumptions.hitRate))
missing.push('assumptions.hitRate');
if (!finitePositive(input.assumptions.firingActivity))
missing.push('assumptions.firingActivity');
if (!finitePositive(input.assumptions.encounterSeconds))
missing.push('assumptions.encounterSeconds');
}
for (const effect of input.stackingEffects ?? []) {
const assumptions = resolveAssumptions(input.assumptions, effect.id);
if (assumptions.mode === 'encounter') {
const override = input.assumptions.perEffect?.[effect.id];
if (!finitePositive(override?.hitRate ?? input.assumptions.hitRate))
missing.push('assumptions.hitRate');
if (
!finitePositive(
override?.firingActivity ?? input.assumptions.firingActivity,
)
)
missing.push('assumptions.firingActivity');
if (
!finitePositive(
override?.encounterSeconds ?? input.assumptions.encounterSeconds,
)
)
missing.push('assumptions.encounterSeconds');
}
if (assumptions.mode !== 'off')
for (const id of Object.keys(effect.attributes)) {
if (!boundIds.has(id)) missing.push(`binding:${id}`);
}
}
return [...new Set(missing)];
}
function stageValues(attributes: StatValues, bindings: AttributeBinding[], stage: CalculationStage) {
let additive = 0;
let multiplicative = 1;
for (const binding of bindings.filter((item) => item.stage === stage)) {
const value = transformedValue(Number(attributes[binding.attributeId] ?? 0), binding);
if (binding.operator === 'add') additive += value;
else multiplicative *= value;
}
return { additive, multiplicative };
function stageValues(
attributes: StatValues,
bindings: AttributeBinding[],
stage: CalculationStage,
) {
let additive = 0;
let multiplicative = 1;
for (const binding of bindings.filter((item) => item.stage === stage)) {
const value = transformedValue(
Number(attributes[binding.attributeId] ?? 0),
binding,
);
if (binding.operator === 'add') additive += value;
else multiplicative *= value;
}
return { additive, multiplicative };
}
function damageBase(
weapon: Required<WeaponCombatInput>,
attributes: StatValues,
bindings: AttributeBinding[],
stageOrder?: CalculationStage[]
weapon: Required<WeaponCombatInput>,
attributes: StatValues,
bindings: AttributeBinding[],
stageOrder?: CalculationStage[],
) {
let damage = weapon.baseDamage;
const order = stageOrder?.length
? stageOrder
: ['flat_damage', 'weapon_damage', 'final_damage'] satisfies CalculationStage[];
for (const stage of order) {
if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage)) continue;
const values = stageValues(attributes, bindings, stage);
if (stage === 'flat_damage') damage = (damage + values.additive) * values.multiplicative;
else damage *= (1 + values.additive / 100) * values.multiplicative;
}
return damage;
let damage = weapon.baseDamage;
const order = stageOrder?.length
? stageOrder
: ([
'flat_damage',
'weapon_damage',
'final_damage',
] satisfies CalculationStage[]);
for (const stage of order) {
if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage))
continue;
const values = stageValues(attributes, bindings, stage);
if (stage === 'flat_damage')
damage = (damage + values.additive) * values.multiplicative;
else damage *= (1 + values.additive / 100) * values.multiplicative;
}
return damage;
}
function hitValues(
weapon: Required<WeaponCombatInput>,
attributes: StatValues,
bindings: AttributeBinding[],
stageOrder?: CalculationStage[]
weapon: Required<WeaponCombatInput>,
attributes: StatValues,
bindings: AttributeBinding[],
stageOrder?: CalculationStage[],
): DamageValues {
const normal = damageBase(weapon, attributes, bindings, stageOrder);
const critical = stageValues(attributes, bindings, 'critical_damage');
const headshot = stageValues(attributes, bindings, 'headshot_damage');
const criticalFactor = (1 + critical.additive / 100) * critical.multiplicative;
const headshotFactor = weapon.headshotMultiplier * (1 + headshot.additive / 100) * headshot.multiplicative;
return {
normal,
critical: normal * criticalFactor,
headshot: normal * headshotFactor,
criticalHeadshot: normal * criticalFactor * headshotFactor
};
const normal = damageBase(weapon, attributes, bindings, stageOrder);
const critical = stageValues(attributes, bindings, 'critical_damage');
const headshot = stageValues(attributes, bindings, 'headshot_damage');
const criticalFactor =
(1 + critical.additive / 100) * critical.multiplicative;
const headshotFactor =
weapon.headshotMultiplier *
(1 + headshot.additive / 100) *
headshot.multiplicative;
return {
normal,
critical: normal * criticalFactor,
headshot: normal * headshotFactor,
criticalHeadshot: normal * criticalFactor * headshotFactor,
};
}
function dpsFor(
weapon: Required<WeaponCombatInput>,
attributes: StatValues,
bindings: AttributeBinding[],
assumptions: ResolvedAssumptions,
stageOrder?: CalculationStage[]
weapon: Required<WeaponCombatInput>,
attributes: StatValues,
bindings: AttributeBinding[],
assumptions: ResolvedAssumptions,
stageOrder?: CalculationStage[],
) {
const values = hitValues(weapon, attributes, bindings, stageOrder);
const critical = stageValues(attributes, bindings, 'critical_chance');
const boundCriticalChance = clamp(critical.additive / 100, 0, 1);
const configuredHeadshot = stageValues(attributes, bindings, 'headshot_chance');
const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1);
const headshotChance = assumptions.headshotRate || boundHeadshotChance;
const average = values.normal * (1 - boundCriticalChance) * (1 - headshotChance)
+ values.critical * boundCriticalChance * (1 - headshotChance)
+ values.headshot * (1 - boundCriticalChance) * headshotChance
+ values.criticalHeadshot * boundCriticalChance * headshotChance;
const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle(
weapon,
attributes,
bindings
);
const shotsPerSecond = rpm / 60;
return {
values,
burst: average * shotsPerSecond,
sustained: average * magazine / (magazine / shotsPerSecond + reloadSeconds)
};
const values = hitValues(weapon, attributes, bindings, stageOrder);
const critical = stageValues(attributes, bindings, 'critical_chance');
const boundCriticalChance = clamp(critical.additive / 100, 0, 1);
const configuredHeadshot = stageValues(
attributes,
bindings,
'headshot_chance',
);
const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1);
const headshotChance = assumptions.headshotRate || boundHeadshotChance;
const average =
values.normal * (1 - boundCriticalChance) * (1 - headshotChance) +
values.critical * boundCriticalChance * (1 - headshotChance) +
values.headshot * (1 - boundCriticalChance) * headshotChance +
values.criticalHeadshot * boundCriticalChance * headshotChance;
const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle(
weapon,
attributes,
bindings,
);
const shotsPerSecond = rpm / 60;
return {
values,
burst: average * shotsPerSecond,
sustained:
(average * magazine) / (magazine / shotsPerSecond + reloadSeconds),
};
}
export function expectedWeaponShotDamage(
weapon: WeaponCombatInput,
attributes: StatValues,
bindings: AttributeBinding[],
headshot = false,
stageOrder?: CalculationStage[]
weapon: WeaponCombatInput,
attributes: StatValues,
bindings: AttributeBinding[],
headshot = false,
stageOrder?: CalculationStage[],
) {
const resolvedWeapon: Required<WeaponCombatInput> = {
baseDamage: Number(weapon.baseDamage),
rpm: Number(weapon.rpm),
magazine: Number(weapon.magazine),
reloadSeconds: Number(weapon.reloadSeconds),
headshotMultiplier: Number(weapon.headshotMultiplier ?? 2)
};
const values = hitValues(resolvedWeapon, attributes, bindings, stageOrder);
const critical = stageValues(attributes, bindings, 'critical_chance');
const criticalChance = clamp(critical.additive / 100, 0, 1);
return headshot
? values.headshot * (1 - criticalChance) + values.criticalHeadshot * criticalChance
: values.normal * (1 - criticalChance) + values.critical * criticalChance;
const resolvedWeapon: Required<WeaponCombatInput> = {
baseDamage: Number(weapon.baseDamage),
rpm: Number(weapon.rpm),
magazine: Number(weapon.magazine),
reloadSeconds: Number(weapon.reloadSeconds),
headshotMultiplier: Number(weapon.headshotMultiplier ?? 2),
};
const values = hitValues(resolvedWeapon, attributes, bindings, stageOrder);
const critical = stageValues(attributes, bindings, 'critical_chance');
const criticalChance = clamp(critical.additive / 100, 0, 1);
return headshot
? values.headshot * (1 - criticalChance) +
values.criticalHeadshot * criticalChance
: values.normal * (1 - criticalChance) + values.critical * criticalChance;
}
export function resolveWeaponFiringCycle(
weapon: WeaponCombatInput,
attributes: StatValues,
bindings: AttributeBinding[]
weapon: WeaponCombatInput,
attributes: StatValues,
bindings: AttributeBinding[],
): WeaponFiringCycle {
const rateOfFire = stageValues(attributes, bindings, 'rate_of_fire');
const magazineSize = stageValues(attributes, bindings, 'magazine_size');
const reloadSpeed = stageValues(attributes, bindings, 'reload_speed');
return {
rpm: Number(weapon.rpm)
* (1 + rateOfFire.additive / 100)
* rateOfFire.multiplicative,
magazine: Math.max(
1,
Number(weapon.magazine)
* (1 + magazineSize.additive / 100)
* magazineSize.multiplicative
),
reloadSeconds: Number(weapon.reloadSeconds) / Math.max(
EPSILON,
(1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative
)
};
const rateOfFire = stageValues(attributes, bindings, 'rate_of_fire');
const magazineSize = stageValues(attributes, bindings, 'magazine_size');
const reloadSpeed = stageValues(attributes, bindings, 'reload_speed');
return {
rpm:
Number(weapon.rpm) *
(1 + rateOfFire.additive / 100) *
rateOfFire.multiplicative,
magazine: Math.max(
1,
Number(weapon.magazine) *
(1 + magazineSize.additive / 100) *
magazineSize.multiplicative,
),
reloadSeconds:
Number(weapon.reloadSeconds) /
Math.max(
EPSILON,
(1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative,
),
};
}
function triggerEvents(weapon: Required<WeaponCombatInput>, effect: StackingEffect, assumptions: ResolvedAssumptions) {
if (assumptions.mode !== 'encounter' || effect.trigger.hitsRequired < 1 || effect.trigger.windowSeconds <= 0) return [];
const shotInterval = (60 / weapon.rpm) / Math.max(assumptions.firingActivity, EPSILON);
let magazineRemaining = weapon.magazine;
let expectedHits = 0;
let counterStartedAt = 0;
const events: number[] = [];
for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) {
if (magazineRemaining === 0) {
time += weapon.reloadSeconds;
magazineRemaining = weapon.magazine;
if (time - counterStartedAt > effect.trigger.windowSeconds) {
expectedHits = 0;
counterStartedAt = time;
}
continue;
}
if (expectedHits === 0) counterStartedAt = time;
expectedHits += assumptions.hitRate;
magazineRemaining -= 1;
if (time - counterStartedAt > effect.trigger.windowSeconds) {
expectedHits = assumptions.hitRate;
counterStartedAt = time;
}
if (expectedHits + EPSILON >= effect.trigger.hitsRequired) {
events.push(time);
expectedHits = 0;
counterStartedAt = time;
}
time += shotInterval;
}
return events;
function triggerEvents(
weapon: Required<WeaponCombatInput>,
effect: StackingEffect,
assumptions: ResolvedAssumptions,
) {
if (
assumptions.mode !== 'encounter' ||
effect.trigger.hitsRequired < 1 ||
effect.trigger.windowSeconds <= 0
)
return [];
const shotInterval =
60 / weapon.rpm / Math.max(assumptions.firingActivity, EPSILON);
let magazineRemaining = weapon.magazine;
let expectedHits = 0;
let counterStartedAt = 0;
const events: number[] = [];
for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) {
if (magazineRemaining === 0) {
time += weapon.reloadSeconds;
magazineRemaining = weapon.magazine;
if (time - counterStartedAt > effect.trigger.windowSeconds) {
expectedHits = 0;
counterStartedAt = time;
}
continue;
}
if (expectedHits === 0) counterStartedAt = time;
expectedHits += assumptions.hitRate;
magazineRemaining -= 1;
if (time - counterStartedAt > effect.trigger.windowSeconds) {
expectedHits = assumptions.hitRate;
counterStartedAt = time;
}
if (expectedHits + EPSILON >= effect.trigger.hitsRequired) {
events.push(time);
expectedHits = 0;
counterStartedAt = time;
}
time += shotInterval;
}
return events;
}
function stateAt(effect: TimedStackEffect, time: number): StackState {
if (effect.assumptions.mode === 'off') {
return { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
}
if (effect.assumptions.mode === 'peak') {
return {
stacks: effect.effect.lifecycle.maxStacks,
expiresAt: Infinity,
maxStacksReached: effect.effect.lifecycle.maxStacks,
buildUpSeconds: 0
};
}
const state: StackState = { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
for (const eventTime of effect.events) {
if (eventTime - time > EPSILON) break;
if (eventTime - state.expiresAt > EPSILON) state.stacks = 0;
state.stacks = Math.min(
effect.effect.lifecycle.maxStacks,
state.stacks + Math.max(1, effect.effect.lifecycle.stacksPerTrigger ?? 1)
);
state.expiresAt = eventTime + effect.effect.lifecycle.durationSeconds;
state.maxStacksReached = Math.max(state.maxStacksReached, state.stacks);
if (state.stacks === effect.effect.lifecycle.maxStacks && state.buildUpSeconds === null) {
state.buildUpSeconds = eventTime;
}
}
if (time - state.expiresAt > EPSILON) state.stacks = 0;
return state;
if (effect.assumptions.mode === 'off') {
return {
stacks: 0,
expiresAt: 0,
maxStacksReached: 0,
buildUpSeconds: null,
};
}
if (effect.assumptions.mode === 'peak') {
return {
stacks: effect.effect.lifecycle.maxStacks,
expiresAt: Infinity,
maxStacksReached: effect.effect.lifecycle.maxStacks,
buildUpSeconds: 0,
};
}
const state: StackState = {
stacks: 0,
expiresAt: 0,
maxStacksReached: 0,
buildUpSeconds: null,
};
for (const eventTime of effect.events) {
if (eventTime - time > EPSILON) break;
if (eventTime - state.expiresAt > EPSILON) state.stacks = 0;
state.stacks = Math.min(
effect.effect.lifecycle.maxStacks,
state.stacks + Math.max(1, effect.effect.lifecycle.stacksPerTrigger ?? 1),
);
state.expiresAt = eventTime + effect.effect.lifecycle.durationSeconds;
state.maxStacksReached = Math.max(state.maxStacksReached, state.stacks);
if (
state.stacks === effect.effect.lifecycle.maxStacks &&
state.buildUpSeconds === null
) {
state.buildUpSeconds = eventTime;
}
}
if (time - state.expiresAt > EPSILON) state.stacks = 0;
return state;
}
function stackAttributes(base: StatValues, effects: TimedStackEffect[], time: number) {
const result = { ...base };
for (const effect of effects) addAttributes(result, effect.effect.attributes, stateAt(effect, time).stacks);
return result;
function stackAttributes(
base: StatValues,
effects: TimedStackEffect[],
time: number,
) {
const result = { ...base };
for (const effect of effects)
addAttributes(
result,
effect.effect.attributes,
stateAt(effect, time).stacks,
);
return result;
}
export function calculateCombat(input: CombatCalculationInput): CombatCalculation {
const missing = requiredData(input);
if (missing.length) {
return {
status: 'INSUFFICIENT_DATA',
missing,
hitValues: null,
burstDps: null,
sustainedDps: null,
encounterDps: null,
stackSummaries: []
};
}
const weapon: Required<WeaponCombatInput> = {
baseDamage: Number(input.weapon.baseDamage),
rpm: Number(input.weapon.rpm),
magazine: Number(input.weapon.magazine),
reloadSeconds: Number(input.weapon.reloadSeconds),
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2)
};
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map((effect) => {
const assumptions = resolveAssumptions(input.assumptions, effect.id);
return { effect, assumptions, events: triggerEvents(weapon, effect, assumptions) };
});
const globalAssumptions = resolveAssumptions(input.assumptions, '');
const finalTime = Math.max(
globalAssumptions.encounterSeconds,
...effects.filter((effect) => effect.assumptions.mode === 'encounter').map((effect) => effect.assumptions.encounterSeconds)
);
const current = dpsFor(
weapon,
stackAttributes(input.attributes, effects, finalTime),
input.bindings,
globalAssumptions,
input.stageOrder
);
let encounterDps: number | null = null;
if (effects.some((effect) => effect.assumptions.mode === 'encounter') && finalTime > 0) {
const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4)));
let total = 0;
for (let index = 0; index < steps; index += 1) {
const time = finalTime * (index + 0.5) / steps;
const result = dpsFor(
weapon,
stackAttributes(input.attributes, effects, time),
input.bindings,
globalAssumptions,
input.stageOrder
);
total += result.sustained * globalAssumptions.firingActivity;
}
encounterDps = total / steps;
}
return {
status: 'OK',
missing: [],
hitValues: current.values,
burstDps: current.burst,
sustainedDps: current.sustained,
encounterDps,
stackSummaries: effects.map((item) => {
const end = stateAt(item, finalTime);
const duration = item.assumptions.mode === 'encounter' ? item.assumptions.encounterSeconds : 0;
const samples = item.assumptions.mode === 'encounter' && duration > 0 ? 100 : 1;
let sum = 0;
for (let index = 0; index < samples; index += 1) {
sum += stateAt(item, duration * (index + 0.5) / samples).stacks;
}
return {
effectId: item.effect.id,
mode: item.assumptions.mode,
stacksAtEnd: end.stacks,
maxStacksReached: end.maxStacksReached,
buildUpSeconds: end.buildUpSeconds,
averageStacks: sum / samples,
maintained: end.maxStacksReached === item.effect.lifecycle.maxStacks
&& (item.assumptions.mode === 'peak' || end.stacks > 0)
};
})
};
export function calculateCombat(
input: CombatCalculationInput,
): CombatCalculation {
const missing = requiredData(input);
if (missing.length) {
return {
status: 'INSUFFICIENT_DATA',
missing,
hitValues: null,
burstDps: null,
sustainedDps: null,
encounterDps: null,
stackSummaries: [],
};
}
const weapon: Required<WeaponCombatInput> = {
baseDamage: Number(input.weapon.baseDamage),
rpm: Number(input.weapon.rpm),
magazine: Number(input.weapon.magazine),
reloadSeconds: Number(input.weapon.reloadSeconds),
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2),
};
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map(
(effect) => {
const assumptions = resolveAssumptions(input.assumptions, effect.id);
return {
effect,
assumptions,
events: triggerEvents(weapon, effect, assumptions),
};
},
);
const globalAssumptions = resolveAssumptions(input.assumptions, '');
const finalTime = Math.max(
globalAssumptions.encounterSeconds,
...effects
.filter((effect) => effect.assumptions.mode === 'encounter')
.map((effect) => effect.assumptions.encounterSeconds),
);
const current = dpsFor(
weapon,
stackAttributes(input.attributes, effects, finalTime),
input.bindings,
globalAssumptions,
input.stageOrder,
);
let encounterDps: number | null = null;
if (
effects.some((effect) => effect.assumptions.mode === 'encounter') &&
finalTime > 0
) {
const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4)));
let total = 0;
for (let index = 0; index < steps; index += 1) {
const time = (finalTime * (index + 0.5)) / steps;
const result = dpsFor(
weapon,
stackAttributes(input.attributes, effects, time),
input.bindings,
globalAssumptions,
input.stageOrder,
);
total += result.sustained * globalAssumptions.firingActivity;
}
encounterDps = total / steps;
}
return {
status: 'OK',
missing: [],
hitValues: current.values,
burstDps: current.burst,
sustainedDps: current.sustained,
encounterDps,
stackSummaries: effects.map((item) => {
const end = stateAt(item, finalTime);
const duration =
item.assumptions.mode === 'encounter'
? item.assumptions.encounterSeconds
: 0;
const samples =
item.assumptions.mode === 'encounter' && duration > 0 ? 100 : 1;
let sum = 0;
for (let index = 0; index < samples; index += 1) {
sum += stateAt(item, (duration * (index + 0.5)) / samples).stacks;
}
return {
effectId: item.effect.id,
mode: item.assumptions.mode,
stacksAtEnd: end.stacks,
maxStacksReached: end.maxStacksReached,
buildUpSeconds: end.buildUpSeconds,
averageStacks: sum / samples,
maintained:
end.maxStacksReached === item.effect.lifecycle.maxStacks &&
(item.assumptions.mode === 'peak' || end.stacks > 0),
};
}),
};
}
+533 -375
View File
@@ -1,413 +1,571 @@
import { describe, expect, it } from 'vitest';
import {
TRIGGER_ACTION_REGISTRY,
evaluateConditionalProgram,
summarizeTrigger,
validateConditionalProgram,
validateTriggerConfiguration,
type ConditionalEvent,
type ConditionalProgram,
type TriggerConfiguration
TRIGGER_ACTION_REGISTRY,
evaluateConditionalProgram,
summarizeTrigger,
validateConditionalProgram,
validateTriggerConfiguration,
type ConditionalEvent,
type ConditionalProgram,
type TriggerConfiguration,
} from './conditional.js';
const modifier = {
id: 'weapon-damage',
scope: 'equipped_weapon' as const,
application: 'active_modifier' as const,
attributeId: 'weapon-damage',
value: 3
id: 'weapon-damage',
scope: 'equipped_weapon' as const,
application: 'active_modifier' as const,
attributeId: 'weapon-damage',
value: 3,
};
const program = (
trigger: TriggerConfiguration,
overrides: Partial<ConditionalProgram> = {}
trigger: TriggerConfiguration,
overrides: Partial<ConditionalProgram> = {},
): ConditionalProgram => ({
id: 'test-effect',
trigger,
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 10,
maxStacks: 10,
stacksPerActivation: 1
},
outcomes: [modifier],
...overrides
id: 'test-effect',
trigger,
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 10,
maxStacks: 10,
stacksPerActivation: 1,
},
outcomes: [modifier],
...overrides,
});
const shot = (
at: number,
targetId: string | null,
hit = true,
headshot = false
at: number,
targetId: string | null,
hit = true,
headshot = false,
): ConditionalEvent => ({ kind: 'shot', at, targetId, hit, headshot });
describe('trigger action registry', () => {
it('owns every action and validates discriminated parameters', () => {
expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16);
expect(validateTriggerConfiguration('same_target_hits_within_window', {
hitsRequired: 9,
windowSeconds: 3
})).toMatchObject({ ok: true });
expect(validateTriggerConfiguration('same_target_hits_within_window', {
hitsRequired: 0,
windowSeconds: -1
})).toEqual({
ok: false,
errors: ['Hits required must be a positive integer.', 'Window seconds must be greater than 0.']
});
expect(validateTriggerConfiguration('while_firing', { unexpected: true })).toMatchObject({ ok: false });
expect(validateTriggerConfiguration('target_hit_within_distance', { meters: 0 })).toEqual({
ok: false,
errors: ['Distance must be greater than 0.']
});
});
it('owns every action and validates discriminated parameters', () => {
expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16);
expect(
validateTriggerConfiguration('same_target_hits_within_window', {
hitsRequired: 9,
windowSeconds: 3,
}),
).toMatchObject({ ok: true });
expect(
validateTriggerConfiguration('same_target_hits_within_window', {
hitsRequired: 0,
windowSeconds: -1,
}),
).toEqual({
ok: false,
errors: [
'Hits required must be a positive integer.',
'Window seconds must be greater than 0.',
],
});
expect(
validateTriggerConfiguration('while_firing', { unexpected: true }),
).toMatchObject({ ok: false });
expect(
validateTriggerConfiguration('target_hit_within_distance', { meters: 0 }),
).toEqual({
ok: false,
errors: ['Distance must be greater than 0.'],
});
});
it('generates user-facing summaries from typed values', () => {
expect(summarizeTrigger({
action: 'distinct_enemies_within_window',
params: { enemiesRequired: 4, windowSeconds: 2.5 }
})).toBe('Hit 4 distinct enemies within 2.5 seconds.');
expect(summarizeTrigger({
action: 'continuous_firing_duration',
params: { seconds: 1 }
})).toBe('Fire continuously for 1 second.');
expect(summarizeTrigger({
action: 'target_hit_within_distance',
params: { meters: 20 }
})).toBe('Hit a target within 20 meters.');
});
it('generates user-facing summaries from typed values', () => {
expect(
summarizeTrigger({
action: 'distinct_enemies_within_window',
params: { enemiesRequired: 4, windowSeconds: 2.5 },
}),
).toBe('Hit 4 distinct enemies within 2.5 seconds.');
expect(
summarizeTrigger({
action: 'continuous_firing_duration',
params: { seconds: 1 },
}),
).toBe('Fire continuously for 1 second.');
expect(
summarizeTrigger({
action: 'target_hit_within_distance',
params: { meters: 20 },
}),
).toBe('Hit a target within 20 meters.');
});
it('validates unique health bands and their outcome relationships', () => {
const result = validateConditionalProgram(program({
action: 'health_threshold_bands',
params: {
bands: [
{ thresholdPercent: 50, outcomeKey: 'low' },
{ thresholdPercent: 20, outcomeKey: 'critical' }
]
}
}, {
outcomes: [
{ ...modifier, id: 'low', outcomeKey: 'low' },
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 }
]
}));
expect(result).toMatchObject({ ok: true });
});
it('validates unique health bands and their outcome relationships', () => {
const result = validateConditionalProgram(
program(
{
action: 'health_threshold_bands',
params: {
bands: [
{ thresholdPercent: 50, outcomeKey: 'low' },
{ thresholdPercent: 20, outcomeKey: 'critical' },
],
},
},
{
outcomes: [
{ ...modifier, id: 'low', outcomeKey: 'low' },
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 },
],
},
),
);
expect(result).toMatchObject({ ok: true });
});
it('rejects negative instant outcomes but permits negative Attribute modifiers', () => {
expect(validateConditionalProgram(program({
action: 'headshot',
params: {}
}, {
outcomes: [{
id: 'damage',
scope: 'target',
application: 'direct_damage',
basis: 'flat',
value: -10
}]
}))).toEqual({
ok: false,
errors: ['Instant outcome "damage" cannot use a negative value.']
});
expect(validateConditionalProgram(program({
action: 'headshot',
params: {}
}, {
outcomes: [{ ...modifier, value: -10 }]
}))).toMatchObject({ ok: true });
});
it('rejects negative instant outcomes but permits negative Attribute modifiers', () => {
expect(
validateConditionalProgram(
program(
{
action: 'headshot',
params: {},
},
{
outcomes: [
{
id: 'damage',
scope: 'target',
application: 'direct_damage',
basis: 'flat',
value: -10,
},
],
},
),
),
).toEqual({
ok: false,
errors: ['Instant outcome "damage" cannot use a negative value.'],
});
expect(
validateConditionalProgram(
program(
{
action: 'headshot',
params: {},
},
{
outcomes: [{ ...modifier, value: -10 }],
},
),
),
).toMatchObject({ ok: true });
});
});
describe('evaluateConditionalProgram', () => {
it('counts repeated hits against the same target inside the window', () => {
const result = evaluateConditionalProgram(program({
action: 'same_target_hits_within_window',
params: { hitsRequired: 3, windowSeconds: 1 }
}), [
shot(0, 'alpha'),
shot(0.2, 'bravo'),
shot(0.4, 'alpha'),
shot(0.8, 'alpha')
], { mode: 'encounter', encounterSeconds: 5 });
expect(result).toMatchObject({
status: 'OK',
activationCount: 1,
maxStacksReached: 1
});
expect(result.activations[0]).toMatchObject({ at: 0.8, stacksBefore: 0, stacksAfter: 1 });
});
it('counts repeated hits against the same target inside the window', () => {
const result = evaluateConditionalProgram(
program({
action: 'same_target_hits_within_window',
params: { hitsRequired: 3, windowSeconds: 1 },
}),
[
shot(0, 'alpha'),
shot(0.2, 'bravo'),
shot(0.4, 'alpha'),
shot(0.8, 'alpha'),
],
{ mode: 'encounter', encounterSeconds: 5 },
);
expect(result).toMatchObject({
status: 'OK',
activationCount: 1,
maxStacksReached: 1,
});
expect(result.activations[0]).toMatchObject({
at: 0.8,
stacksBefore: 0,
stacksAfter: 1,
});
});
it('counts each distinct enemy only once per window', () => {
const result = evaluateConditionalProgram(program({
action: 'distinct_enemies_within_window',
params: { enemiesRequired: 3, windowSeconds: 2 }
}), [
shot(0, 'alpha'),
shot(0.2, 'alpha'),
shot(0.4, 'bravo'),
shot(0.6, 'charlie')
], { mode: 'encounter', encounterSeconds: 5 });
expect(result.activationCount).toBe(1);
expect(result.activations[0].at).toBe(0.6);
});
it('counts each distinct enemy only once per window', () => {
const result = evaluateConditionalProgram(
program({
action: 'distinct_enemies_within_window',
params: { enemiesRequired: 3, windowSeconds: 2 },
}),
[
shot(0, 'alpha'),
shot(0.2, 'alpha'),
shot(0.4, 'bravo'),
shot(0.6, 'charlie'),
],
{ mode: 'encounter', encounterSeconds: 5 },
);
expect(result.activationCount).toBe(1);
expect(result.activations[0].at).toBe(0.6);
});
it('resets consecutive hits after a miss or target change', () => {
const result = evaluateConditionalProgram(program({
action: 'consecutive_same_target_hits',
params: { hitsRequired: 3 }
}), [
shot(0, 'alpha'),
shot(0.1, 'alpha'),
shot(0.2, null, false),
shot(0.3, 'alpha'),
shot(0.4, 'bravo'),
shot(0.5, 'bravo'),
shot(0.6, 'bravo')
], { mode: 'encounter', encounterSeconds: 5 });
expect(result.activationCount).toBe(1);
expect(result.activations[0].at).toBe(0.6);
});
it('resets consecutive hits after a miss or target change', () => {
const result = evaluateConditionalProgram(
program({
action: 'consecutive_same_target_hits',
params: { hitsRequired: 3 },
}),
[
shot(0, 'alpha'),
shot(0.1, 'alpha'),
shot(0.2, null, false),
shot(0.3, 'alpha'),
shot(0.4, 'bravo'),
shot(0.5, 'bravo'),
shot(0.6, 'bravo'),
],
{ mode: 'encounter', encounterSeconds: 5 },
);
expect(result.activationCount).toBe(1);
expect(result.activations[0].at).toBe(0.6);
});
it('starts cooldown on activation and applies the activation after its triggering event', () => {
const result = evaluateConditionalProgram(program({
action: 'headshot',
params: {}
}, {
lifecycle: {
cooldownSeconds: 3,
durationSeconds: 5,
maxStacks: 3,
stacksPerActivation: 1
}
}), [
shot(1, 'alpha', true, true),
shot(2, 'alpha', true, true),
shot(4, 'alpha', true, true)
], { mode: 'encounter', encounterSeconds: 10 });
expect(result.activations).toHaveLength(2);
expect(result.activations.map((activation) => activation.at)).toEqual([1, 4]);
expect(result.activations[0].effectiveAfter).toBeGreaterThan(1);
expect(result.activations[1]).toMatchObject({
stacksBefore: 1,
stacksAfter: 2,
expiresAt: 9,
cooldownUntil: 7
});
});
it('starts cooldown on activation and applies the activation after its triggering event', () => {
const result = evaluateConditionalProgram(
program(
{
action: 'headshot',
params: {},
},
{
lifecycle: {
cooldownSeconds: 3,
durationSeconds: 5,
maxStacks: 3,
stacksPerActivation: 1,
},
},
),
[
shot(1, 'alpha', true, true),
shot(2, 'alpha', true, true),
shot(4, 'alpha', true, true),
],
{ mode: 'encounter', encounterSeconds: 10 },
);
expect(result.activations).toHaveLength(2);
expect(result.activations.map((activation) => activation.at)).toEqual([
1, 4,
]);
expect(result.activations[0].effectiveAfter).toBeGreaterThan(1);
expect(result.activations[1]).toMatchObject({
stacksBefore: 1,
stacksAfter: 2,
expiresAt: 9,
cooldownUntil: 7,
});
});
it('treats an explicit non-headshot hit as a body shot', () => {
const result = evaluateConditionalProgram(program({
action: 'body_shot',
params: {}
}), [
shot(1, 'alpha', true, false),
shot(2, 'alpha', true, true),
shot(3, 'alpha', false, false)
], { mode: 'encounter', encounterSeconds: 5 });
expect(result.activations.map((activation) => activation.at)).toEqual([1]);
});
it('treats an explicit non-headshot hit as a body shot', () => {
const result = evaluateConditionalProgram(
program({
action: 'body_shot',
params: {},
}),
[
shot(1, 'alpha', true, false),
shot(2, 'alpha', true, true),
shot(3, 'alpha', false, false),
],
{ mode: 'encounter', encounterSeconds: 5 },
);
expect(result.activations.map((activation) => activation.at)).toEqual([1]);
});
it('refreshes one shared duration and clears every stack on expiry', () => {
const result = evaluateConditionalProgram(program({
action: 'damage_taken',
params: {}
}, {
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 3,
maxStacks: 3,
stacksPerActivation: 1
}
}), [
{ kind: 'damage_taken', at: 0 },
{ kind: 'damage_taken', at: 1 },
{ kind: 'damage_taken', at: 5 }
], { mode: 'encounter', encounterSeconds: 10 });
expect(result.activations.map(({ stacksBefore, stacksAfter }) => ({ stacksBefore, stacksAfter }))).toEqual([
{ stacksBefore: 0, stacksAfter: 1 },
{ stacksBefore: 1, stacksAfter: 2 },
{ stacksBefore: 0, stacksAfter: 1 }
]);
expect(result.intervals).toEqual([
{ start: 0.000001, end: 1.000001, stacks: 1, outcomeKey: null },
{ start: 1.000001, end: 4, stacks: 2, outcomeKey: null },
{ start: 5.000001, end: 8, stacks: 1, outcomeKey: null }
]);
});
it('refreshes one shared duration and clears every stack on expiry', () => {
const result = evaluateConditionalProgram(
program(
{
action: 'damage_taken',
params: {},
},
{
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 3,
maxStacks: 3,
stacksPerActivation: 1,
},
},
),
[
{ kind: 'damage_taken', at: 0 },
{ kind: 'damage_taken', at: 1 },
{ kind: 'damage_taken', at: 5 },
],
{ mode: 'encounter', encounterSeconds: 10 },
);
expect(
result.activations.map(({ stacksBefore, stacksAfter }) => ({
stacksBefore,
stacksAfter,
})),
).toEqual([
{ stacksBefore: 0, stacksAfter: 1 },
{ stacksBefore: 1, stacksAfter: 2 },
{ stacksBefore: 0, stacksAfter: 1 },
]);
expect(result.intervals).toEqual([
{ start: 0.000001, end: 1.000001, stacks: 1, outcomeKey: null },
{ start: 1.000001, end: 4, stacks: 2, outcomeKey: null },
{ start: 5.000001, end: 8, stacks: 1, outcomeKey: null },
]);
});
it('resets firing progress on reload without removing an active buff', () => {
const result = evaluateConditionalProgram(program({
action: 'continuous_firing_duration',
params: { seconds: 2 }
}, {
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 10,
maxStacks: 4,
stacksPerActivation: 1
}
}), [
{ kind: 'firing_started', at: 0 },
shot(0.5, 'alpha'),
{ kind: 'reload_started', at: 3 },
{ kind: 'reload_completed', at: 4 },
{ kind: 'firing_started', at: 4 },
{ kind: 'firing_stopped', at: 7 }
], { mode: 'encounter', encounterSeconds: 12 });
expect(result.activations.map((activation) => activation.at)).toEqual([2, 6]);
expect(result.activations[1]).toMatchObject({ stacksBefore: 1, stacksAfter: 2, expiresAt: 16 });
expect(result.uptimePercent).toBeCloseTo((10 / 12) * 100);
});
it('resets firing progress on reload without removing an active buff', () => {
const result = evaluateConditionalProgram(
program(
{
action: 'continuous_firing_duration',
params: { seconds: 2 },
},
{
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 10,
maxStacks: 4,
stacksPerActivation: 1,
},
},
),
[
{ kind: 'firing_started', at: 0 },
shot(0.5, 'alpha'),
{ kind: 'reload_started', at: 3 },
{ kind: 'reload_completed', at: 4 },
{ kind: 'firing_started', at: 4 },
{ kind: 'firing_stopped', at: 7 },
],
{ mode: 'encounter', encounterSeconds: 12 },
);
expect(result.activations.map((activation) => activation.at)).toEqual([
2, 6,
]);
expect(result.activations[1]).toMatchObject({
stacksBefore: 1,
stacksAfter: 2,
expiresAt: 16,
});
expect(result.uptimePercent).toBeCloseTo((10 / 12) * 100);
});
it('activates continuous firing only once in each uninterrupted segment', () => {
const result = evaluateConditionalProgram(program({
action: 'continuous_firing_duration',
params: { seconds: 2 }
}), [
{ kind: 'firing_started', at: 0 },
{ kind: 'firing_stopped', at: 7 }
], { mode: 'encounter', encounterSeconds: 10 });
expect(result.activations.map((activation) => activation.at)).toEqual([2]);
});
it('activates continuous firing only once in each uninterrupted segment', () => {
const result = evaluateConditionalProgram(
program({
action: 'continuous_firing_duration',
params: { seconds: 2 },
}),
[
{ kind: 'firing_started', at: 0 },
{ kind: 'firing_stopped', at: 7 },
],
{ mode: 'encounter', encounterSeconds: 10 },
);
expect(result.activations.map((activation) => activation.at)).toEqual([2]);
});
it('uses only the strongest matching health band', () => {
const result = evaluateConditionalProgram(program({
action: 'health_threshold_bands',
params: {
bands: [
{ thresholdPercent: 50, outcomeKey: 'low' },
{ thresholdPercent: 20, outcomeKey: 'critical' }
]
}
}, {
lifecycle: { cooldownSeconds: 0, durationSeconds: 0, maxStacks: 1, stacksPerActivation: 1 },
outcomes: [
{ ...modifier, id: 'low', outcomeKey: 'low', value: 5 },
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 }
]
}), [], {
mode: 'encounter',
encounterSeconds: 20,
healthPercent: 15
});
expect(result.selectedOutcomeKey).toBe('critical');
expect(result.intervals).toEqual([{ start: 0, end: 20, stacks: 1, outcomeKey: 'critical' }]);
});
it('uses only the strongest matching health band', () => {
const result = evaluateConditionalProgram(
program(
{
action: 'health_threshold_bands',
params: {
bands: [
{ thresholdPercent: 50, outcomeKey: 'low' },
{ thresholdPercent: 20, outcomeKey: 'critical' },
],
},
},
{
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 0,
maxStacks: 1,
stacksPerActivation: 1,
},
outcomes: [
{ ...modifier, id: 'low', outcomeKey: 'low', value: 5 },
{ ...modifier, id: 'critical', outcomeKey: 'critical', value: 10 },
],
},
),
[],
{
mode: 'encounter',
encounterSeconds: 20,
healthPercent: 15,
},
);
expect(result.selectedOutcomeKey).toBe('critical');
expect(result.intervals).toEqual([
{ start: 0, end: 20, stacks: 1, outcomeKey: 'critical' },
]);
});
it('allows a direct proc-rate override and reports per-stack instant outcomes', () => {
const result = evaluateConditionalProgram(program({
action: 'weapon_kill',
params: {}
}, {
lifecycle: { cooldownSeconds: 0, durationSeconds: 20, maxStacks: 3, stacksPerActivation: 1 },
outcomes: [{
id: 'blast',
scope: 'target',
application: 'direct_damage',
basis: 'weapon_base_percent',
value: 25
}]
}), [], {
mode: 'encounter',
encounterSeconds: 30,
directProcsPerMinute: 6
});
expect(result.activations.map((activation) => activation.at)).toEqual([10, 20, 30]);
expect(result.outcomeEvents.map((event) => event.value)).toEqual([25, 50, 75]);
});
it('allows a direct proc-rate override and reports per-stack instant outcomes', () => {
const result = evaluateConditionalProgram(
program(
{
action: 'weapon_kill',
params: {},
},
{
lifecycle: {
cooldownSeconds: 0,
durationSeconds: 20,
maxStacks: 3,
stacksPerActivation: 1,
},
outcomes: [
{
id: 'blast',
scope: 'target',
application: 'direct_damage',
basis: 'weapon_base_percent',
value: 25,
},
],
},
),
[],
{
mode: 'encounter',
encounterSeconds: 30,
directProcsPerMinute: 6,
},
);
expect(result.activations.map((activation) => activation.at)).toEqual([
10, 20, 30,
]);
expect(result.outcomeEvents.map((event) => event.value)).toEqual([
25, 50, 75,
]);
});
it('uses real trigger events when the whole-program activation override is zero', () => {
const result = evaluateConditionalProgram(program({
action: 'weapon_kill',
params: {}
}), [
{ kind: 'enemy_killed', at: 4 }
], {
mode: 'encounter',
encounterSeconds: 30,
directProcsPerMinute: 0
});
expect(result.activations.map((activation) => activation.at)).toEqual([4]);
});
it('uses real trigger events when the whole-program activation override is zero', () => {
const result = evaluateConditionalProgram(
program({
action: 'weapon_kill',
params: {},
}),
[{ kind: 'enemy_killed', at: 4 }],
{
mode: 'encounter',
encounterSeconds: 30,
directProcsPerMinute: 0,
},
);
expect(result.activations.map((activation) => activation.at)).toEqual([4]);
});
it('models headshot kills from enemy-killed timeline events', () => {
const result = evaluateConditionalProgram(program({
action: 'headshot_kill',
params: {}
}), [
{ kind: 'enemy_killed', at: 2, headshot: false },
{ kind: 'enemy_killed', at: 7, headshot: true }
], { mode: 'encounter', encounterSeconds: 15 });
expect(result.activations.map((activation) => activation.at)).toEqual([7]);
});
it('models headshot kills from enemy-killed timeline events', () => {
const result = evaluateConditionalProgram(
program({
action: 'headshot_kill',
params: {},
}),
[
{ kind: 'enemy_killed', at: 2, headshot: false },
{ kind: 'enemy_killed', at: 7, headshot: true },
],
{ mode: 'encounter', encounterSeconds: 15 },
);
expect(result.activations.map((activation) => activation.at)).toEqual([7]);
});
it('activates on successful target hits while the target is within the configured distance', () => {
const inRange = evaluateConditionalProgram(program({
action: 'target_hit_within_distance',
params: { meters: 20 }
}), [
shot(1, 'alpha', false),
shot(2, null),
shot(3, 'alpha'),
shot(4, 'bravo')
], {
mode: 'encounter',
encounterSeconds: 10,
distanceMeters: 20
});
expect(inRange.activations.map((activation) => activation.at)).toEqual([3, 4]);
it('activates on successful target hits while the target is within the configured distance', () => {
const inRange = evaluateConditionalProgram(
program({
action: 'target_hit_within_distance',
params: { meters: 20 },
}),
[
shot(1, 'alpha', false),
shot(2, null),
shot(3, 'alpha'),
shot(4, 'bravo'),
],
{
mode: 'encounter',
encounterSeconds: 10,
distanceMeters: 20,
},
);
expect(inRange.activations.map((activation) => activation.at)).toEqual([
3, 4,
]);
const outOfRange = evaluateConditionalProgram(program({
action: 'target_hit_within_distance',
params: { meters: 20 }
}), [shot(3, 'alpha')], {
mode: 'encounter',
encounterSeconds: 10,
distanceMeters: 21
});
expect(outOfRange.activationCount).toBe(0);
});
const outOfRange = evaluateConditionalProgram(
program({
action: 'target_hit_within_distance',
params: { meters: 20 },
}),
[shot(3, 'alpha')],
{
mode: 'encounter',
encounterSeconds: 10,
distanceMeters: 21,
},
);
expect(outOfRange.activationCount).toBe(0);
});
it('requires a target-distance assumption for distance-limited hit events', () => {
const result = evaluateConditionalProgram(program({
action: 'target_hit_within_distance',
params: { meters: 20 }
}), [shot(3, 'alpha')], {
mode: 'encounter',
encounterSeconds: 10
});
expect(result.activationCount).toBe(0);
});
it('requires a target-distance assumption for distance-limited hit events', () => {
const result = evaluateConditionalProgram(
program({
action: 'target_hit_within_distance',
params: { meters: 20 },
}),
[shot(3, 'alpha')],
{
mode: 'encounter',
encounterSeconds: 10,
},
);
expect(result.activationCount).toBe(0);
});
it('keeps target proximity as a persistent condition rather than a hit event', () => {
const result = evaluateConditionalProgram(program({
action: 'within_distance',
params: { meters: 20 }
}), [], {
mode: 'encounter',
encounterSeconds: 10,
distanceMeters: 20
});
expect(result.activationCount).toBe(1);
expect(result.intervals).toEqual([{ start: 0, end: 10, stacks: 10, outcomeKey: null }]);
});
it('keeps target proximity as a persistent condition rather than a hit event', () => {
const result = evaluateConditionalProgram(
program({
action: 'within_distance',
params: { meters: 20 },
}),
[],
{
mode: 'encounter',
encounterSeconds: 10,
distanceMeters: 20,
},
);
expect(result.activationCount).toBe(1);
expect(result.intervals).toEqual([
{ start: 0, end: 10, stacks: 10, outcomeKey: null },
]);
});
it('keeps while-firing effects active only inside firing segments', () => {
const result = evaluateConditionalProgram(program({
action: 'while_firing',
params: {}
}), [
{ kind: 'firing_started', at: 1 },
{ kind: 'firing_stopped', at: 3 },
{ kind: 'firing_started', at: 5 },
{ kind: 'reload_started', at: 8 }
], { mode: 'encounter', encounterSeconds: 10 });
expect(result.intervals).toEqual([
{ start: 1, end: 3, stacks: 10, outcomeKey: null },
{ start: 5, end: 8, stacks: 10, outcomeKey: null }
]);
expect(result.uptimePercent).toBe(50);
});
it('keeps while-firing effects active only inside firing segments', () => {
const result = evaluateConditionalProgram(
program({
action: 'while_firing',
params: {},
}),
[
{ kind: 'firing_started', at: 1 },
{ kind: 'firing_stopped', at: 3 },
{ kind: 'firing_started', at: 5 },
{ kind: 'reload_started', at: 8 },
],
{ mode: 'encounter', encounterSeconds: 10 },
);
expect(result.intervals).toEqual([
{ start: 1, end: 3, stacks: 10, outcomeKey: null },
{ start: 5, end: 8, stacks: 10, outcomeKey: null },
]);
expect(result.uptimePercent).toBe(50);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+232 -160
View File
@@ -1,186 +1,258 @@
import { describe, expect, it } from 'vitest';
import {
TRIGGER_ACTION_KEYS,
validateTriggerConfiguration,
type TriggerActionKey,
type TriggerConfiguration
TRIGGER_ACTION_KEYS,
validateTriggerConfiguration,
type TriggerActionKey,
type TriggerConfiguration,
} from './conditional.js';
import {
createMechanicRecipeForTrigger,
MECHANIC_RECIPE_REGISTRY,
type MechanicRecipeSeed
createMechanicRecipeForTrigger,
MECHANIC_RECIPE_REGISTRY,
type MechanicRecipeSeed,
} from './mechanic-recipes.js';
import { validateMechanicProgram } from './mechanic.js';
const paramsByAction: Record<TriggerActionKey, unknown> = {
same_target_hits_within_window: { hitsRequired: 3, windowSeconds: 2 },
distinct_enemies_within_window: { enemiesRequired: 3, windowSeconds: 4 },
consecutive_same_target_hits: { hitsRequired: 4 },
continuous_firing_duration: { seconds: 2.5 },
while_firing: {},
health_threshold_bands: {
bands: [
{ thresholdPercent: 25, outcomeKey: 'critical' },
{ thresholdPercent: 50, outcomeKey: 'damaged' }
]
},
headshot: {},
body_shot: {},
headshot_kill: {},
weapon_kill: {},
target_hit_within_distance: { meters: 15 },
damage_taken: {},
weapon_swap: {},
reload: {},
within_distance: { meters: 10 },
extra_health_active: {}
same_target_hits_within_window: { hitsRequired: 3, windowSeconds: 2 },
distinct_enemies_within_window: { enemiesRequired: 3, windowSeconds: 4 },
consecutive_same_target_hits: { hitsRequired: 4 },
continuous_firing_duration: { seconds: 2.5 },
while_firing: {},
health_threshold_bands: {
bands: [
{ thresholdPercent: 25, outcomeKey: 'critical' },
{ thresholdPercent: 50, outcomeKey: 'damaged' },
],
},
headshot: {},
body_shot: {},
headshot_kill: {},
weapon_kill: {},
target_hit_within_distance: { meters: 15 },
damage_taken: {},
weapon_swap: {},
reload: {},
within_distance: { meters: 10 },
extra_health_active: {},
};
function triggerFor(action: TriggerActionKey): TriggerConfiguration {
const trigger = validateTriggerConfiguration(action, paramsByAction[action]);
if (!trigger.ok) throw new Error(trigger.errors.join(' '));
return trigger.value;
const trigger = validateTriggerConfiguration(action, paramsByAction[action]);
if (!trigger.ok) throw new Error(trigger.errors.join(' '));
return trigger.value;
}
function seedFor(action: TriggerActionKey): MechanicRecipeSeed {
const trigger = triggerFor(action);
const result = createMechanicRecipeForTrigger(trigger);
if (result.status !== 'supported') throw new Error(result.reason);
return result.seed;
const trigger = triggerFor(action);
const result = createMechanicRecipeForTrigger(trigger);
if (result.status !== 'supported') throw new Error(result.reason);
return result.seed;
}
describe('mechanic recipe registry', () => {
it('covers every trigger action with a supported seed or an explicit reason', () => {
expect(Object.keys(MECHANIC_RECIPE_REGISTRY).sort()).toEqual([...TRIGGER_ACTION_KEYS].sort());
for (const action of TRIGGER_ACTION_KEYS) {
const result = createMechanicRecipeForTrigger(triggerFor(action));
expect(['supported', 'unsupported']).toContain(result.status);
if (result.status === 'unsupported') expect(result.reason.trim()).not.toBe('');
else expect(result.seed.clauses.every((clause) => clause.recipeKey === action)).toBe(true);
}
});
it('covers every trigger action with a supported seed or an explicit reason', () => {
expect(Object.keys(MECHANIC_RECIPE_REGISTRY).sort()).toEqual(
[...TRIGGER_ACTION_KEYS].sort(),
);
for (const action of TRIGGER_ACTION_KEYS) {
const result = createMechanicRecipeForTrigger(triggerFor(action));
expect(['supported', 'unsupported']).toContain(result.status);
if (result.status === 'unsupported')
expect(result.reason.trim()).not.toBe('');
else
expect(
result.seed.clauses.every((clause) => clause.recipeKey === action),
).toBe(true);
}
});
it('produces a valid editable program for every supported recipe without hidden placeholders', () => {
for (const action of TRIGGER_ACTION_KEYS) {
const seed = seedFor(action);
const validation = validateMechanicProgram({
id: `recipe-${action}`,
name: `Recipe ${action}`,
...seed
});
expect(validation, action).toMatchObject({ ok: true });
}
});
it('produces a valid editable program for every supported recipe without hidden placeholders', () => {
for (const action of TRIGGER_ACTION_KEYS) {
const seed = seedFor(action);
const validation = validateMechanicProgram({
id: `recipe-${action}`,
name: `Recipe ${action}`,
...seed,
});
expect(validation, action).toMatchObject({ ok: true });
}
});
it('maps hit-family actions to same-target, distinct, and consecutive reducers', () => {
expect(seedFor('same_target_hits_within_window').clauses[0]).toMatchObject({
when: 'weapon_hit',
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
reducer: {
kind: 'sliding_count', threshold: 3, windowSeconds: 2, reset: 'zero',
groupBy: { source: 'event', path: 'targetId' }
}
});
expect(seedFor('distinct_enemies_within_window').clauses[0]).toMatchObject({
when: 'weapon_hit',
reducer: {
kind: 'distinct_count', threshold: 3, windowSeconds: 4,
distinctBy: { source: 'event', path: 'targetId' }
}
});
expect(seedFor('consecutive_same_target_hits').clauses[0]).toMatchObject({
when: 'weapon_hit',
reducer: {
kind: 'consecutive_count', threshold: 4,
groupBy: { source: 'event', path: 'targetId' }
}
});
});
it('maps hit-family actions to same-target, distinct, and consecutive reducers', () => {
expect(seedFor('same_target_hits_within_window').clauses[0]).toMatchObject({
when: 'weapon_hit',
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
reducer: {
kind: 'sliding_count',
threshold: 3,
windowSeconds: 2,
reset: 'zero',
groupBy: { source: 'event', path: 'targetId' },
},
});
expect(seedFor('distinct_enemies_within_window').clauses[0]).toMatchObject({
when: 'weapon_hit',
reducer: {
kind: 'distinct_count',
threshold: 3,
windowSeconds: 4,
distinctBy: { source: 'event', path: 'targetId' },
},
});
expect(seedFor('consecutive_same_target_hits').clauses[0]).toMatchObject({
when: 'weapon_hit',
reducer: {
kind: 'consecutive_count',
threshold: 4,
groupBy: { source: 'event', path: 'targetId' },
},
});
});
it('maps head and body shots to authoritative weapon-hit predicates', () => {
for (const [action, headshot] of [['headshot', true], ['body_shot', false]] as const) {
expect(seedFor(action).clauses[0]).toMatchObject({
when: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{ kind: 'exists', value: { source: 'event', path: 'values.headshot' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: headshot
}
]
}
});
}
});
it('maps head and body shots to authoritative weapon-hit predicates', () => {
for (const [action, headshot] of [
['headshot', true],
['body_shot', false],
] as const) {
expect(seedFor(action).clauses[0]).toMatchObject({
when: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{
kind: 'exists',
value: { source: 'event', path: 'values.headshot' },
},
{
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: headshot,
},
],
},
});
}
});
it('models firing triggers as explicit lifecycle branches', () => {
const continuous = seedFor('continuous_firing_duration');
expect(continuous.pools).toEqual([expect.objectContaining({ key: 'firing-active', max: 1 })]);
expect(continuous.clauses.map((clause) => clause.when)).toEqual([
'firing_started', 'firing_stopped', 'reload_started', 'tick'
]);
expect(continuous.clauses.at(-1)).toMatchObject({
reducer: { kind: 'duration', seconds: 2.5 },
if: { kind: 'compare', left: { source: 'pool', pool: 'firing-active' }, operator: 'gt', right: 0 }
});
expect(seedFor('while_firing').clauses.map((clause) => clause.when)).toEqual([
'firing_started', 'firing_stopped', 'reload_started'
]);
});
it('models firing triggers as explicit lifecycle branches', () => {
const continuous = seedFor('continuous_firing_duration');
expect(continuous.pools).toEqual([
expect.objectContaining({ key: 'firing-active', max: 1 }),
]);
expect(continuous.clauses.map((clause) => clause.when)).toEqual([
'firing_started',
'firing_stopped',
'reload_started',
'tick',
]);
expect(continuous.clauses.at(-1)).toMatchObject({
reducer: { kind: 'duration', seconds: 2.5 },
if: {
kind: 'compare',
left: { source: 'pool', pool: 'firing-active' },
operator: 'gt',
right: 0,
},
});
expect(
seedFor('while_firing').clauses.map((clause) => clause.when),
).toEqual(['firing_started', 'firing_stopped', 'reload_started']);
});
it('models health bands and persistent distance and Extra Health state as clearable branches', () => {
const health = seedFor('health_threshold_bands');
expect(health.pools?.map((pool) => pool.key)).toEqual(['health-band-1', 'health-band-2']);
expect(health.clauses.map((clause) => clause.id)).toEqual([
'select-health-band-1', 'select-health-band-2', 'clear-health-bands'
]);
expect(health.clauses[1].if).toMatchObject({
kind: 'all',
predicates: expect.arrayContaining([
{ kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'lt', right: 50 },
{ kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'gte', right: 25 }
])
});
expect(health.clauses.every((clause) => clause.when === 'tick')).toBe(true);
expect(health.clauses[2].if).toEqual({
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'gte',
right: 50
});
it('models health bands and persistent distance and Extra Health state as clearable branches', () => {
const health = seedFor('health_threshold_bands');
expect(health.pools?.map((pool) => pool.key)).toEqual([
'health-band-1',
'health-band-2',
]);
expect(health.clauses.map((clause) => clause.id)).toEqual([
'select-health-band-1',
'select-health-band-2',
'clear-health-bands',
]);
expect(health.clauses[1].if).toMatchObject({
kind: 'all',
predicates: expect.arrayContaining([
{
kind: 'compare',
left: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current',
},
operator: 'lt',
right: 50,
},
{
kind: 'compare',
left: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current',
},
operator: 'gte',
right: 25,
},
]),
});
expect(health.clauses.every((clause) => clause.when === 'tick')).toBe(true);
expect(health.clauses[2].if).toEqual({
kind: 'compare',
left: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current',
},
operator: 'gte',
right: 50,
});
expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({
when: 'weapon_hit',
if: {
kind: 'all',
predicates: expect.arrayContaining([
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{ kind: 'compare', left: { source: 'event', path: 'values.distanceMeters' }, operator: 'lte', right: 15 }
])
}
});
expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({
when: 'weapon_hit',
if: {
kind: 'all',
predicates: expect.arrayContaining([
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.distanceMeters' },
operator: 'lte',
right: 15,
},
]),
},
});
const withinDistance = seedFor('within_distance');
expect(withinDistance.clauses.map((clause) => clause.id)).toEqual([
'enter-target-within-distance', 'exit-target-within-distance'
]);
expect(withinDistance.clauses[0].if).toEqual({
kind: 'compare', left: { source: 'event', path: 'values.distanceMeters' }, operator: 'lte', right: 10
});
expect(withinDistance.clauses[1].if).toMatchObject({ kind: 'not' });
const withinDistance = seedFor('within_distance');
expect(withinDistance.clauses.map((clause) => clause.id)).toEqual([
'enter-target-within-distance',
'exit-target-within-distance',
]);
expect(withinDistance.clauses[0].if).toEqual({
kind: 'compare',
left: { source: 'event', path: 'values.distanceMeters' },
operator: 'lte',
right: 10,
});
expect(withinDistance.clauses[1].if).toMatchObject({ kind: 'not' });
const extraHealth = seedFor('extra_health_active');
expect(extraHealth.clauses.map((clause) => clause.id)).toEqual([
'enter-extra-health-active', 'exit-extra-health-active'
]);
expect(extraHealth.clauses.every((clause) => clause.when === 'extra_health_changed')).toBe(true);
expect(extraHealth.clauses[0].if).toEqual({
kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true
});
});
const extraHealth = seedFor('extra_health_active');
expect(extraHealth.clauses.map((clause) => clause.id)).toEqual([
'enter-extra-health-active',
'exit-extra-health-active',
]);
expect(
extraHealth.clauses.every(
(clause) => clause.when === 'extra_health_changed',
),
).toBe(true);
expect(extraHealth.clauses[0].if).toEqual({
kind: 'compare',
left: { source: 'event', path: 'values.hasExtraHealth' },
operator: 'eq',
right: true,
});
});
});
+451 -312
View File
@@ -1,336 +1,471 @@
import type {
ConsecutiveHitsParams,
ContinuousFiringParams,
DistanceParams,
DistinctEnemiesParams,
HealthThresholdParams,
SameTargetHitsParams,
TriggerActionKey,
TriggerConfiguration,
TriggerParamsByAction
ConsecutiveHitsParams,
ContinuousFiringParams,
DistanceParams,
DistinctEnemiesParams,
HealthThresholdParams,
SameTargetHitsParams,
TriggerActionKey,
TriggerConfiguration,
TriggerParamsByAction,
} from './conditional.js';
import type {
MechanicClause,
MechanicEventKind,
MechanicProgram,
PoolDefinition,
Predicate,
ValueReference
MechanicClause,
MechanicEventKind,
MechanicProgram,
PoolDefinition,
Predicate,
ValueReference,
} from './mechanic.js';
/** A partial program that an editor can merge into a new mechanic draft. */
export type MechanicRecipeSeed = Pick<MechanicProgram, 'scenarioInputs' | 'pools' | 'clauses'>;
export type MechanicRecipeSeed = Pick<
MechanicProgram,
'scenarioInputs' | 'pools' | 'clauses'
>;
export type SupportedMechanicRecipe = {
status: 'supported';
seed: MechanicRecipeSeed;
status: 'supported';
seed: MechanicRecipeSeed;
};
export type UnsupportedMechanicRecipe = {
status: 'unsupported';
reason: string;
status: 'unsupported';
reason: string;
};
export type MechanicRecipeResult = SupportedMechanicRecipe | UnsupportedMechanicRecipe;
export type MechanicRecipeResult =
SupportedMechanicRecipe | UnsupportedMechanicRecipe;
export type MechanicRecipeDefinition<K extends TriggerActionKey = TriggerActionKey> = {
key: K;
createSeed: (params: TriggerParamsByAction[K]) => MechanicRecipeResult;
export type MechanicRecipeDefinition<
K extends TriggerActionKey = TriggerActionKey,
> = {
key: K;
createSeed: (params: TriggerParamsByAction[K]) => MechanicRecipeResult;
};
export type MechanicRecipeRegistry = {
[K in TriggerActionKey]: MechanicRecipeDefinition<K>;
[K in TriggerActionKey]: MechanicRecipeDefinition<K>;
};
const eventValue = (field: string): ValueReference => ({ source: 'event', path: `values.${field}` });
const eventValue = (field: string): ValueReference => ({
source: 'event',
path: `values.${field}`,
});
const targetExists: Predicate = {
kind: 'exists',
value: { source: 'event', path: 'targetId' }
kind: 'exists',
value: { source: 'event', path: 'targetId' },
};
const noMutation = [{ kind: 'none' as const }];
const supported = (seed: MechanicRecipeSeed): SupportedMechanicRecipe => ({ status: 'supported', seed });
const supported = (seed: MechanicRecipeSeed): SupportedMechanicRecipe => ({
status: 'supported',
seed,
});
function directClause(
id: string,
when: MechanicClause['when'],
ifPredicate?: Predicate
id: string,
when: MechanicClause['when'],
ifPredicate?: Predicate,
): MechanicClause {
return { id, when, if: ifPredicate, reducer: { kind: 'none' }, mutations: noMutation };
return {
id,
when,
if: ifPredicate,
reducer: { kind: 'none' },
mutations: noMutation,
};
}
function firingLifecycleSeed(activePool: string, includeDuration?: number): MechanicRecipeSeed {
const pools: PoolDefinition[] = [{
key: activePool,
label: 'Firing active',
scope: 'agent',
initial: 0,
min: 0,
max: 1
}];
const clauses: MechanicClause[] = [
{
id: 'start-firing',
label: 'Start firing lifecycle',
when: 'firing_started',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: activePool, value: 1 }]
},
{
id: 'stop-firing',
label: 'Stop firing lifecycle',
when: 'firing_stopped',
reducer: { kind: 'none' },
mutations: [{ kind: 'clear', pool: activePool }]
},
{
id: 'reload-stops-firing',
label: 'Reload stops firing lifecycle',
when: 'reload_started',
reducer: { kind: 'none' },
mutations: [{ kind: 'clear', pool: activePool }]
}
];
if (includeDuration !== undefined) {
clauses.push({
id: 'continuous-firing-duration',
label: 'Reach continuous firing duration',
when: 'tick',
if: {
kind: 'compare',
left: { source: 'pool', pool: activePool },
operator: 'gt',
right: 0
},
reducer: { kind: 'duration', seconds: includeDuration },
mutations: noMutation
});
}
return { pools, clauses };
function firingLifecycleSeed(
activePool: string,
includeDuration?: number,
): MechanicRecipeSeed {
const pools: PoolDefinition[] = [
{
key: activePool,
label: 'Firing active',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
},
];
const clauses: MechanicClause[] = [
{
id: 'start-firing',
label: 'Start firing lifecycle',
when: 'firing_started',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: activePool, value: 1 }],
},
{
id: 'stop-firing',
label: 'Stop firing lifecycle',
when: 'firing_stopped',
reducer: { kind: 'none' },
mutations: [{ kind: 'clear', pool: activePool }],
},
{
id: 'reload-stops-firing',
label: 'Reload stops firing lifecycle',
when: 'reload_started',
reducer: { kind: 'none' },
mutations: [{ kind: 'clear', pool: activePool }],
},
];
if (includeDuration !== undefined) {
clauses.push({
id: 'continuous-firing-duration',
label: 'Reach continuous firing duration',
when: 'tick',
if: {
kind: 'compare',
left: { source: 'pool', pool: activePool },
operator: 'gt',
right: 0,
},
reducer: { kind: 'duration', seconds: includeDuration },
mutations: noMutation,
});
}
return { pools, clauses };
}
function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
const sortedBands = [...params.bands].sort((left, right) => left.thresholdPercent - right.thresholdPercent);
const currentHealthPercent: ValueReference = {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current'
};
const pools: PoolDefinition[] = sortedBands.map((band, index) => ({
key: `health-band-${index + 1}`,
label: `Health band: ${band.outcomeKey}`,
scope: 'agent',
initial: 0,
min: 0,
max: 1
}));
const clauses: MechanicClause[] = sortedBands.map((band, index) => {
const lowerBound = sortedBands[index - 1]?.thresholdPercent;
const predicates: Predicate[] = [{
kind: 'compare',
left: currentHealthPercent,
operator: 'lt',
right: band.thresholdPercent
}];
if (lowerBound !== undefined) {
predicates.push({
kind: 'compare',
left: currentHealthPercent,
operator: 'gte',
right: lowerBound
});
}
return {
id: `select-health-band-${index + 1}`,
label: `Select ${band.outcomeKey} below ${band.thresholdPercent}% Health`,
when: 'tick',
if: { kind: 'all', predicates },
reducer: { kind: 'none' },
mutations: pools.map((pool, poolIndex) =>
poolIndex === index
? { kind: 'set' as const, pool: pool.key, value: 1 }
: { kind: 'clear' as const, pool: pool.key }
)
};
});
clauses.push({
id: 'clear-health-bands',
label: 'Clear health bands above the highest threshold',
when: 'tick',
if: {
kind: 'compare',
left: currentHealthPercent,
operator: 'gte',
right: sortedBands.at(-1)?.thresholdPercent ?? 0
},
reducer: { kind: 'none' },
mutations: pools.map((pool) => ({ kind: 'clear' as const, pool: pool.key }))
});
return { pools, clauses };
const sortedBands = [...params.bands].sort(
(left, right) => left.thresholdPercent - right.thresholdPercent,
);
const currentHealthPercent: ValueReference = {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current',
};
const pools: PoolDefinition[] = sortedBands.map((band, index) => ({
key: `health-band-${index + 1}`,
label: `Health band: ${band.outcomeKey}`,
scope: 'agent',
initial: 0,
min: 0,
max: 1,
}));
const clauses: MechanicClause[] = sortedBands.map((band, index) => {
const lowerBound = sortedBands[index - 1]?.thresholdPercent;
const predicates: Predicate[] = [
{
kind: 'compare',
left: currentHealthPercent,
operator: 'lt',
right: band.thresholdPercent,
},
];
if (lowerBound !== undefined) {
predicates.push({
kind: 'compare',
left: currentHealthPercent,
operator: 'gte',
right: lowerBound,
});
}
return {
id: `select-health-band-${index + 1}`,
label: `Select ${band.outcomeKey} below ${band.thresholdPercent}% Health`,
when: 'tick',
if: { kind: 'all', predicates },
reducer: { kind: 'none' },
mutations: pools.map((pool, poolIndex) =>
poolIndex === index
? { kind: 'set' as const, pool: pool.key, value: 1 }
: { kind: 'clear' as const, pool: pool.key },
),
};
});
clauses.push({
id: 'clear-health-bands',
label: 'Clear health bands above the highest threshold',
when: 'tick',
if: {
kind: 'compare',
left: currentHealthPercent,
operator: 'gte',
right: sortedBands.at(-1)?.thresholdPercent ?? 0,
},
reducer: { kind: 'none' },
mutations: pools.map((pool) => ({
kind: 'clear' as const,
pool: pool.key,
})),
});
return { pools, clauses };
}
function eventLifecycleSeed(
pool: PoolDefinition,
event: MechanicEventKind,
activePredicate: Predicate,
label: string
pool: PoolDefinition,
event: MechanicEventKind,
activePredicate: Predicate,
label: string,
): MechanicRecipeSeed {
return {
pools: [pool],
clauses: [
{
id: `enter-${pool.key}`,
label: `Enter ${label}`,
when: event,
if: activePredicate,
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: pool.key, value: 1 }]
},
{
id: `exit-${pool.key}`,
label: `Exit ${label}`,
when: event,
if: { kind: 'not', predicate: activePredicate },
reducer: { kind: 'none' },
mutations: [{ kind: 'clear', pool: pool.key }]
}
]
};
return {
pools: [pool],
clauses: [
{
id: `enter-${pool.key}`,
label: `Enter ${label}`,
when: event,
if: activePredicate,
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: pool.key, value: 1 }],
},
{
id: `exit-${pool.key}`,
label: `Exit ${label}`,
when: event,
if: { kind: 'not', predicate: activePredicate },
reducer: { kind: 'none' },
mutations: [{ kind: 'clear', pool: pool.key }],
},
],
};
}
export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
same_target_hits_within_window: {
key: 'same_target_hits_within_window',
createSeed: (params) => {
const value = params as SameTargetHitsParams;
return supported({ clauses: [{
id: 'same-target-hit-window',
when: 'weapon_hit',
if: targetExists,
reducer: {
kind: 'sliding_count', threshold: value.hitsRequired, windowSeconds: value.windowSeconds,
reset: 'zero', groupBy: { source: 'event', path: 'targetId' }
},
mutations: noMutation
}] });
}
},
distinct_enemies_within_window: {
key: 'distinct_enemies_within_window',
createSeed: (params) => {
const value = params as DistinctEnemiesParams;
return supported({ clauses: [{
id: 'distinct-enemy-hit-window', when: 'weapon_hit', if: targetExists,
reducer: {
kind: 'distinct_count', threshold: value.enemiesRequired, windowSeconds: value.windowSeconds,
distinctBy: { source: 'event', path: 'targetId' }
},
mutations: noMutation
}] });
}
},
consecutive_same_target_hits: {
key: 'consecutive_same_target_hits',
createSeed: (params) => {
const value = params as ConsecutiveHitsParams;
return supported({ clauses: [{
id: 'consecutive-same-target-hits', when: 'weapon_hit', if: targetExists,
reducer: {
kind: 'consecutive_count', threshold: value.hitsRequired,
groupBy: { source: 'event', path: 'targetId' }
},
mutations: noMutation
}] });
}
},
continuous_firing_duration: {
key: 'continuous_firing_duration',
createSeed: (params) => supported(firingLifecycleSeed('firing-active', (params as ContinuousFiringParams).seconds))
},
while_firing: {
key: 'while_firing',
createSeed: (_params) => supported(firingLifecycleSeed('firing-active'))
},
health_threshold_bands: {
key: 'health_threshold_bands',
createSeed: (params) => supported(healthBandSeed(params as HealthThresholdParams))
},
headshot: {
key: 'headshot',
createSeed: (_params) => supported({ clauses: [directClause('headshot', 'weapon_hit', {
kind: 'all',
predicates: [
targetExists,
{ kind: 'exists', value: eventValue('headshot') },
{ kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true }
]
})] })
},
body_shot: {
key: 'body_shot',
createSeed: (_params) => supported({ clauses: [directClause('body-shot', 'weapon_hit', {
kind: 'all',
predicates: [
targetExists,
{ kind: 'exists', value: eventValue('headshot') },
{ kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: false }
]
})] })
},
headshot_kill: {
key: 'headshot_kill',
createSeed: (_params) => supported({ clauses: [directClause('headshot-kill', 'enemy_killed', {
kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true
})] })
},
weapon_kill: {
key: 'weapon_kill',
createSeed: (_params) => supported({ clauses: [directClause('weapon-kill', 'enemy_killed')] })
},
target_hit_within_distance: {
key: 'target_hit_within_distance',
createSeed: (params) => {
const value = params as DistanceParams;
return supported({ clauses: [directClause('target-hit-within-distance', 'weapon_hit', {
kind: 'all', predicates: [targetExists, {
kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters
}]
})] });
}
},
damage_taken: {
key: 'damage_taken',
createSeed: (_params) => supported({ clauses: [directClause('damage-taken', 'health_damage_taken')] })
},
weapon_swap: {
key: 'weapon_swap',
createSeed: (_params) => supported({ clauses: [directClause('weapon-swap', 'weapon_swapped')] })
},
reload: {
key: 'reload',
createSeed: (_params) => supported({ clauses: [directClause('reload-started', 'reload_started')] })
},
within_distance: {
key: 'within_distance',
createSeed: (params) => {
const value = params as DistanceParams;
return supported(eventLifecycleSeed(
{ key: 'target-within-distance', label: 'Target within distance', scope: 'agent', initial: 0, min: 0, max: 1 },
'proximity_snapshot',
{ kind: 'compare', left: eventValue('distanceMeters'), operator: 'lte', right: value.meters },
`target within ${value.meters}m`
));
}
},
extra_health_active: {
key: 'extra_health_active',
createSeed: (_params) => supported(eventLifecycleSeed(
{ key: 'extra-health-active', label: 'Extra Health active', scope: 'agent', initial: 0, min: 0, max: 1 },
'extra_health_changed',
{ kind: 'compare', left: eventValue('hasExtraHealth'), operator: 'eq', right: true },
'Extra Health active'
))
}
same_target_hits_within_window: {
key: 'same_target_hits_within_window',
createSeed: (params) => {
const value = params as SameTargetHitsParams;
return supported({
clauses: [
{
id: 'same-target-hit-window',
when: 'weapon_hit',
if: targetExists,
reducer: {
kind: 'sliding_count',
threshold: value.hitsRequired,
windowSeconds: value.windowSeconds,
reset: 'zero',
groupBy: { source: 'event', path: 'targetId' },
},
mutations: noMutation,
},
],
});
},
},
distinct_enemies_within_window: {
key: 'distinct_enemies_within_window',
createSeed: (params) => {
const value = params as DistinctEnemiesParams;
return supported({
clauses: [
{
id: 'distinct-enemy-hit-window',
when: 'weapon_hit',
if: targetExists,
reducer: {
kind: 'distinct_count',
threshold: value.enemiesRequired,
windowSeconds: value.windowSeconds,
distinctBy: { source: 'event', path: 'targetId' },
},
mutations: noMutation,
},
],
});
},
},
consecutive_same_target_hits: {
key: 'consecutive_same_target_hits',
createSeed: (params) => {
const value = params as ConsecutiveHitsParams;
return supported({
clauses: [
{
id: 'consecutive-same-target-hits',
when: 'weapon_hit',
if: targetExists,
reducer: {
kind: 'consecutive_count',
threshold: value.hitsRequired,
groupBy: { source: 'event', path: 'targetId' },
},
mutations: noMutation,
},
],
});
},
},
continuous_firing_duration: {
key: 'continuous_firing_duration',
createSeed: (params) =>
supported(
firingLifecycleSeed(
'firing-active',
(params as ContinuousFiringParams).seconds,
),
),
},
while_firing: {
key: 'while_firing',
createSeed: (_params) => supported(firingLifecycleSeed('firing-active')),
},
health_threshold_bands: {
key: 'health_threshold_bands',
createSeed: (params) =>
supported(healthBandSeed(params as HealthThresholdParams)),
},
headshot: {
key: 'headshot',
createSeed: (_params) =>
supported({
clauses: [
directClause('headshot', 'weapon_hit', {
kind: 'all',
predicates: [
targetExists,
{ kind: 'exists', value: eventValue('headshot') },
{
kind: 'compare',
left: eventValue('headshot'),
operator: 'eq',
right: true,
},
],
}),
],
}),
},
body_shot: {
key: 'body_shot',
createSeed: (_params) =>
supported({
clauses: [
directClause('body-shot', 'weapon_hit', {
kind: 'all',
predicates: [
targetExists,
{ kind: 'exists', value: eventValue('headshot') },
{
kind: 'compare',
left: eventValue('headshot'),
operator: 'eq',
right: false,
},
],
}),
],
}),
},
headshot_kill: {
key: 'headshot_kill',
createSeed: (_params) =>
supported({
clauses: [
directClause('headshot-kill', 'enemy_killed', {
kind: 'compare',
left: eventValue('headshot'),
operator: 'eq',
right: true,
}),
],
}),
},
weapon_kill: {
key: 'weapon_kill',
createSeed: (_params) =>
supported({ clauses: [directClause('weapon-kill', 'enemy_killed')] }),
},
target_hit_within_distance: {
key: 'target_hit_within_distance',
createSeed: (params) => {
const value = params as DistanceParams;
return supported({
clauses: [
directClause('target-hit-within-distance', 'weapon_hit', {
kind: 'all',
predicates: [
targetExists,
{
kind: 'compare',
left: eventValue('distanceMeters'),
operator: 'lte',
right: value.meters,
},
],
}),
],
});
},
},
damage_taken: {
key: 'damage_taken',
createSeed: (_params) =>
supported({
clauses: [directClause('damage-taken', 'health_damage_taken')],
}),
},
weapon_swap: {
key: 'weapon_swap',
createSeed: (_params) =>
supported({ clauses: [directClause('weapon-swap', 'weapon_swapped')] }),
},
reload: {
key: 'reload',
createSeed: (_params) =>
supported({
clauses: [directClause('reload-started', 'reload_started')],
}),
},
within_distance: {
key: 'within_distance',
createSeed: (params) => {
const value = params as DistanceParams;
return supported(
eventLifecycleSeed(
{
key: 'target-within-distance',
label: 'Target within distance',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
},
'proximity_snapshot',
{
kind: 'compare',
left: eventValue('distanceMeters'),
operator: 'lte',
right: value.meters,
},
`target within ${value.meters}m`,
),
);
},
},
extra_health_active: {
key: 'extra_health_active',
createSeed: (_params) =>
supported(
eventLifecycleSeed(
{
key: 'extra-health-active',
label: 'Extra Health active',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
},
'extra_health_changed',
{
kind: 'compare',
left: eventValue('hasExtraHealth'),
operator: 'eq',
right: true,
},
'Extra Health active',
),
),
},
};
/**
@@ -338,24 +473,28 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
* validated by the trigger family registry.
*/
export function createMechanicRecipe<K extends TriggerActionKey>(
action: K,
params: TriggerParamsByAction[K]
action: K,
params: TriggerParamsByAction[K],
): MechanicRecipeResult {
const definition = MECHANIC_RECIPE_REGISTRY[action] as MechanicRecipeDefinition<K>;
const result = definition.createSeed(params);
if (result.status === 'unsupported') return result;
return {
status: 'supported',
seed: {
...result.seed,
clauses: result.seed.clauses.map((clause) => ({
...clause,
recipeKey: action
}))
}
};
const definition = MECHANIC_RECIPE_REGISTRY[
action
] as MechanicRecipeDefinition<K>;
const result = definition.createSeed(params);
if (result.status === 'unsupported') return result;
return {
status: 'supported',
seed: {
...result.seed,
clauses: result.seed.clauses.map((clause) => ({
...clause,
recipeKey: action,
})),
},
};
}
export function createMechanicRecipeForTrigger(trigger: TriggerConfiguration): MechanicRecipeResult {
return createMechanicRecipe(trigger.action, trigger.params);
export function createMechanicRecipeForTrigger(
trigger: TriggerConfiguration,
): MechanicRecipeResult {
return createMechanicRecipe(trigger.action, trigger.params);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+319 -240
View File
@@ -1,268 +1,347 @@
import { describe, expect, it } from 'vitest';
import {
calculateSkillDps,
type SkillAbilityInput,
type SkillCalculationInput
calculateSkillDps,
type SkillAbilityInput,
type SkillCalculationInput,
} from './skill.js';
const directAbility = (
overrides: Partial<SkillAbilityInput> = {}
overrides: Partial<SkillAbilityInput> = {},
): SkillAbilityInput => ({
id: 'seeker',
baseDamage: 100,
damageType: 'blast',
delivery: { type: 'direct', hits: 2 },
castSeconds: 0,
cooldownSeconds: 5,
targetCap: 3,
chargePolicy: { type: 'none' },
activation: { mode: 'on_cooldown' },
...overrides
id: 'seeker',
baseDamage: 100,
damageType: 'blast',
delivery: { type: 'direct', hits: 2 },
castSeconds: 0,
cooldownSeconds: 5,
targetCap: 3,
chargePolicy: { type: 'none' },
activation: { mode: 'on_cooldown' },
...overrides,
});
const base = (overrides: Partial<SkillCalculationInput> = {}): SkillCalculationInput => ({
rules: {
revisionId: 'skill-rules-1',
bindings: [
{ attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' },
{ attributeId: 'criticalChance', stage: 'critical_chance', operator: 'add' },
{ attributeId: 'criticalDamage', stage: 'critical_damage', operator: 'add' },
{ attributeId: 'multishotChance', stage: 'multishot_chance', operator: 'add' },
{ attributeId: 'multishotDamage', stage: 'multishot_damage', operator: 'add' }
]
},
abilities: [directAbility()],
attributes: {
skillDamage: 20,
criticalChance: 25,
criticalDamage: 50,
multishotChance: 20,
multishotDamage: 100
},
assumptions: { encounterSeconds: 10, targetCount: 5 },
...overrides
const base = (
overrides: Partial<SkillCalculationInput> = {},
): SkillCalculationInput => ({
rules: {
revisionId: 'skill-rules-1',
bindings: [
{ attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' },
{
attributeId: 'criticalChance',
stage: 'critical_chance',
operator: 'add',
},
{
attributeId: 'criticalDamage',
stage: 'critical_damage',
operator: 'add',
},
{
attributeId: 'multishotChance',
stage: 'multishot_chance',
operator: 'add',
},
{
attributeId: 'multishotDamage',
stage: 'multishot_damage',
operator: 'add',
},
],
},
abilities: [directAbility()],
attributes: {
skillDamage: 20,
criticalChance: 25,
criticalDamage: 50,
multishotChance: 20,
multishotDamage: 100,
},
assumptions: { encounterSeconds: 10, targetCount: 5 },
...overrides,
});
describe('calculateSkillDps', () => {
it('calculates critical and multishot expected damage without a headshot model', () => {
const result = calculateSkillDps(base());
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
it('calculates critical and multishot expected damage without a headshot model', () => {
const result = calculateSkillDps(base());
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
const ability = result.abilities[0];
expect(result.revisionId).toBe('skill-rules-1');
expect(ability.activationCount).toBe(2);
expect(ability.damageEventCount).toBe(4);
expect(ability.effectiveTargets).toBe(3);
expect(ability.damage.averageExpectedDamagePerHit).toBe(162);
expect(ability.damage.singleTargetDamagePerActivation).toBe(324);
expect(ability.damage.singleTargetEncounterDamage).toBe(648);
expect(ability.damage.aggregateEncounterDamage).toBe(1944);
expect(ability.damage.singleTargetDps).toBe(64.8);
expect(ability.damage.aggregateDps).toBe(194.4);
});
const ability = result.abilities[0];
expect(result.revisionId).toBe('skill-rules-1');
expect(ability.activationCount).toBe(2);
expect(ability.damageEventCount).toBe(4);
expect(ability.effectiveTargets).toBe(3);
expect(ability.damage.averageExpectedDamagePerHit).toBe(162);
expect(ability.damage.singleTargetDamagePerActivation).toBe(324);
expect(ability.damage.singleTargetEncounterDamage).toBe(648);
expect(ability.damage.aggregateEncounterDamage).toBe(1944);
expect(ability.damage.singleTargetDps).toBe(64.8);
expect(ability.damage.aggregateDps).toBe(194.4);
});
it('reads current modifiers on every periodic tick instead of snapshotting at cast time', () => {
const result = calculateSkillDps(base({
abilities: [directAbility({
id: 'turret',
delivery: { type: 'periodic', ticks: 4, intervalSeconds: 1 },
cooldownSeconds: 100,
targetCap: 1
})],
attributes: {},
rules: {
revisionId: 'dynamic-ticks',
bindings: [{ attributeId: 'skillDamage', stage: 'skill_damage', operator: 'add' }]
},
modifierWindows: [{
startSeconds: 2,
endSeconds: 4,
attributes: { skillDamage: 100 },
abilityIds: ['turret']
}],
assumptions: { encounterSeconds: 5, targetCount: 1 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
it('reads current modifiers on every periodic tick instead of snapshotting at cast time', () => {
const result = calculateSkillDps(
base({
abilities: [
directAbility({
id: 'turret',
delivery: { type: 'periodic', ticks: 4, intervalSeconds: 1 },
cooldownSeconds: 100,
targetCap: 1,
}),
],
attributes: {},
rules: {
revisionId: 'dynamic-ticks',
bindings: [
{
attributeId: 'skillDamage',
stage: 'skill_damage',
operator: 'add',
},
],
},
modifierWindows: [
{
startSeconds: 2,
endSeconds: 4,
attributes: { skillDamage: 100 },
abilityIds: ['turret'],
},
],
assumptions: { encounterSeconds: 5, targetCount: 1 },
}),
);
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600);
expect(result.abilities[0].damage.averageExpectedDamagePerHit).toBe(150);
});
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600);
expect(result.abilities[0].damage.averageExpectedDamagePerHit).toBe(150);
});
it('supports multiple delivered hits on each periodic tick', () => {
const result = calculateSkillDps(base({
abilities: [directAbility({
id: 'multi-hit-dot',
delivery: { type: 'periodic', ticks: 2, hitsPerTick: 3, intervalSeconds: 1 },
cooldownSeconds: 100,
targetCap: 1
})],
attributes: {},
rules: { revisionId: 'periodic-hits', bindings: [] },
assumptions: { encounterSeconds: 5, targetCount: 1 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
it('supports multiple delivered hits on each periodic tick', () => {
const result = calculateSkillDps(
base({
abilities: [
directAbility({
id: 'multi-hit-dot',
delivery: {
type: 'periodic',
ticks: 2,
hitsPerTick: 3,
intervalSeconds: 1,
},
cooldownSeconds: 100,
targetCap: 1,
}),
],
attributes: {},
rules: { revisionId: 'periodic-hits', bindings: [] },
assumptions: { encounterSeconds: 5, targetCount: 1 },
}),
);
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities[0].damageEventCount).toBe(6);
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600);
});
expect(result.abilities[0].damageEventCount).toBe(6);
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(600);
});
it('applies published cooldown recovery and duration stages to cadence', () => {
const result = calculateSkillDps(base({
abilities: [directAbility({
id: 'cadence',
delivery: {
type: 'periodic',
ticks: 2,
hitsPerTick: 1,
intervalSeconds: 1,
firstTickDelaySeconds: 1
},
cooldownSeconds: 10,
targetCap: 1
})],
attributes: { recovery: 100, duration: 100 },
rules: {
revisionId: 'skill-cadence',
bindings: [
{ attributeId: 'recovery', stage: 'cooldown_recovery', operator: 'add' },
{ attributeId: 'duration', stage: 'duration', operator: 'add' }
]
},
assumptions: { encounterSeconds: 11, targetCount: 1 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
it('applies published cooldown recovery and duration stages to cadence', () => {
const result = calculateSkillDps(
base({
abilities: [
directAbility({
id: 'cadence',
delivery: {
type: 'periodic',
ticks: 2,
hitsPerTick: 1,
intervalSeconds: 1,
firstTickDelaySeconds: 1,
},
cooldownSeconds: 10,
targetCap: 1,
}),
],
attributes: { recovery: 100, duration: 100 },
rules: {
revisionId: 'skill-cadence',
bindings: [
{
attributeId: 'recovery',
stage: 'cooldown_recovery',
operator: 'add',
},
{ attributeId: 'duration', stage: 'duration', operator: 'add' },
],
},
assumptions: { encounterSeconds: 11, targetCount: 1 },
}),
);
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities[0].activationCount).toBe(3);
expect(result.abilities[0].damageEventCount).toBe(8);
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(800);
});
expect(result.abilities[0].activationCount).toBe(3);
expect(result.abilities[0].damageEventCount).toBe(8);
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(800);
});
it('supports custom uses per minute and clips damage events at encounter end', () => {
const result = calculateSkillDps(base({
abilities: [directAbility({
delivery: { type: 'periodic', ticks: 3, intervalSeconds: 2 },
cooldownSeconds: 0,
targetCap: 1,
activation: { mode: 'custom_per_minute', usesPerMinute: 4 }
})],
attributes: {},
rules: { revisionId: 'custom-rate', bindings: [] },
assumptions: { encounterSeconds: 31, targetCount: 1 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
it('supports custom uses per minute and clips damage events at encounter end', () => {
const result = calculateSkillDps(
base({
abilities: [
directAbility({
delivery: { type: 'periodic', ticks: 3, intervalSeconds: 2 },
cooldownSeconds: 0,
targetCap: 1,
activation: { mode: 'custom_per_minute', usesPerMinute: 4 },
}),
],
attributes: {},
rules: { revisionId: 'custom-rate', bindings: [] },
assumptions: { encounterSeconds: 31, targetCount: 1 },
}),
);
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities[0].activationCount).toBe(3);
expect(result.abilities[0].damageEventCount).toBe(7);
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(700);
});
expect(result.abilities[0].activationCount).toBe(3);
expect(result.abilities[0].damageEventCount).toBe(7);
expect(result.abilities[0].damage.singleTargetEncounterDamage).toBe(700);
});
it('models independently recharging charges and full-set recharge differently', () => {
const shared = {
delivery: { type: 'direct', hits: 1 } as const,
castSeconds: 2,
cooldownSeconds: 10,
targetCap: 1,
activation: { mode: 'on_cooldown' } as const
};
const result = calculateSkillDps(base({
abilities: [
directAbility({
...shared,
id: 'independent',
chargePolicy: { type: 'independent', charges: 3 }
}),
directAbility({
...shared,
id: 'full-set',
chargePolicy: { type: 'full_set', charges: 3 }
})
],
attributes: {},
rules: { revisionId: 'charges', bindings: [] },
assumptions: { encounterSeconds: 13, targetCount: 1 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
it('models independently recharging charges and full-set recharge differently', () => {
const shared = {
delivery: { type: 'direct', hits: 1 } as const,
castSeconds: 2,
cooldownSeconds: 10,
targetCap: 1,
activation: { mode: 'on_cooldown' } as const,
};
const result = calculateSkillDps(
base({
abilities: [
directAbility({
...shared,
id: 'independent',
chargePolicy: { type: 'independent', charges: 3 },
}),
directAbility({
...shared,
id: 'full-set',
chargePolicy: { type: 'full_set', charges: 3 },
}),
],
attributes: {},
rules: { revisionId: 'charges', bindings: [] },
assumptions: { encounterSeconds: 13, targetCount: 1 },
}),
);
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities.find((item) => item.abilityId === 'independent')?.activationCount).toBe(5);
expect(result.abilities.find((item) => item.abilityId === 'full-set')?.activationCount).toBe(3);
});
expect(
result.abilities.find((item) => item.abilityId === 'independent')
?.activationCount,
).toBe(5);
expect(
result.abilities.find((item) => item.abilityId === 'full-set')
?.activationCount,
).toBe(3);
});
it('combines every enabled ability and excludes disabled abilities', () => {
const result = calculateSkillDps(base({
abilities: [
directAbility({ id: 'one', delivery: { type: 'direct', hits: 1 } }),
directAbility({ id: 'two', delivery: { type: 'direct', hits: 1 }, baseDamage: 200 }),
directAbility({
id: 'off',
baseDamage: undefined,
activation: { mode: 'off' }
})
],
attributes: {},
rules: { revisionId: 'combined', bindings: [] },
assumptions: { encounterSeconds: 10, targetCount: 1 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
it('combines every enabled ability and excludes disabled abilities', () => {
const result = calculateSkillDps(
base({
abilities: [
directAbility({ id: 'one', delivery: { type: 'direct', hits: 1 } }),
directAbility({
id: 'two',
delivery: { type: 'direct', hits: 1 },
baseDamage: 200,
}),
directAbility({
id: 'off',
baseDamage: undefined,
activation: { mode: 'off' },
}),
],
attributes: {},
rules: { revisionId: 'combined', bindings: [] },
assumptions: { encounterSeconds: 10, targetCount: 1 },
}),
);
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities.find((item) => item.abilityId === 'off')).toMatchObject({
enabled: false,
activationCount: 0
});
expect(result.combined.singleTargetEncounterDamage).toBe(600);
expect(result.combined.singleTargetDps).toBe(60);
});
expect(
result.abilities.find((item) => item.abilityId === 'off'),
).toMatchObject({
enabled: false,
activationCount: 0,
});
expect(result.combined.singleTargetEncounterDamage).toBe(600);
expect(result.combined.singleTargetDps).toBe(60);
});
it('uses each ability target assumption before applying its target cap', () => {
const result = calculateSkillDps(base({
abilities: [
directAbility({ id: 'single', targetCount: 1 }),
directAbility({ id: 'crowd', targetCount: 2 })
],
attributes: {},
rules: { revisionId: 'per-ability-targets', bindings: [] },
assumptions: { encounterSeconds: 10, targetCount: 5 }
}));
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
it('uses each ability target assumption before applying its target cap', () => {
const result = calculateSkillDps(
base({
abilities: [
directAbility({ id: 'single', targetCount: 1 }),
directAbility({ id: 'crowd', targetCount: 2 }),
],
attributes: {},
rules: { revisionId: 'per-ability-targets', bindings: [] },
assumptions: { encounterSeconds: 10, targetCount: 5 },
}),
);
expect(result.status).toBe('OK');
if (result.status !== 'OK') return;
expect(result.abilities.find((item) => item.abilityId === 'single')?.effectiveTargets).toBe(1);
expect(result.abilities.find((item) => item.abilityId === 'crowd')?.effectiveTargets).toBe(2);
});
expect(
result.abilities.find((item) => item.abilityId === 'single')
?.effectiveTargets,
).toBe(1);
expect(
result.abilities.find((item) => item.abilityId === 'crowd')
?.effectiveTargets,
).toBe(2);
});
it('returns explicit missing requirements instead of partial damage', () => {
const result = calculateSkillDps({
abilities: [directAbility({
baseDamage: undefined,
targetCap: undefined,
chargePolicy: undefined,
activation: { mode: 'custom_per_minute', usesPerMinute: 0 }
})],
attributes: { unknownSkillAttribute: 10 },
assumptions: {}
});
expect(result).toMatchObject({
status: 'INSUFFICIENT_DATA',
revisionId: null,
abilities: [],
combined: null
});
expect(result.missing).toEqual(expect.arrayContaining([
'rules.revisionId',
'rules.bindings',
'assumptions.encounterSeconds',
'assumptions.targetCount',
'ability:seeker:baseDamage',
'ability:seeker:targetCap',
'ability:seeker:chargePolicy',
'ability:seeker:activation.usesPerMinute',
'binding:unknownSkillAttribute'
]));
});
it('returns explicit missing requirements instead of partial damage', () => {
const result = calculateSkillDps({
abilities: [
directAbility({
baseDamage: undefined,
targetCap: undefined,
chargePolicy: undefined,
activation: { mode: 'custom_per_minute', usesPerMinute: 0 },
}),
],
attributes: { unknownSkillAttribute: 10 },
assumptions: {},
});
expect(result).toMatchObject({
status: 'INSUFFICIENT_DATA',
revisionId: null,
abilities: [],
combined: null,
});
expect(result.missing).toEqual(
expect.arrayContaining([
'rules.revisionId',
'rules.bindings',
'assumptions.encounterSeconds',
'assumptions.targetCount',
'ability:seeker:baseDamage',
'ability:seeker:targetCap',
'ability:seeker:chargePolicy',
'ability:seeker:activation.usesPerMinute',
'binding:unknownSkillAttribute',
]),
);
});
});
File diff suppressed because it is too large Load Diff
+125 -88
View File
@@ -1,102 +1,139 @@
import { describe, expect, it } from 'vitest';
import type { ConditionalMechanicDraftV1 } from './mechanic-editor';
import { missingTalentDescriptionSlots, numberedTalentOutcomes } from './talent-description';
import {
missingTalentDescriptionSlots,
numberedTalentOutcomes,
} from './talent-description';
const draft = (outcomes: ConditionalMechanicDraftV1['clauses'][number]['outcomes']): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [{
key: 'primary',
label: 'Primary',
event: 'enemy_killed',
if: { kind: 'true' },
reducer: { kind: 'none' },
mutations: [],
outcomes
}]
const draft = (
outcomes: ConditionalMechanicDraftV1['clauses'][number]['outcomes'],
): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'primary',
label: 'Primary',
event: 'enemy_killed',
if: { kind: 'true' },
reducer: { kind: 'none' },
mutations: [],
outcomes,
},
],
});
describe('talent description outcomes', () => {
it('accepts a numbered restoration outcome as a description value', () => {
const outcomes = numberedTalentOutcomes(draft([{
key: 'restore-health',
label: 'Restore Health',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
resource: 'health',
delivery: 'continuous',
durationSeconds: 3,
basis: 'max_health_percent',
descriptionSlot: 1,
variants: {
I: { value: 240 },
II: { value: 336 }
}
}]));
it('accepts a numbered restoration outcome as a description value', () => {
const outcomes = numberedTalentOutcomes(
draft([
{
key: 'restore-health',
label: 'Restore Health',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
resource: 'health',
delivery: 'continuous',
durationSeconds: 3,
basis: 'max_health_percent',
descriptionSlot: 1,
variants: {
I: { value: 240 },
II: { value: 336 },
},
},
]),
);
expect(outcomes).toHaveLength(1);
expect(outcomes[0]).toMatchObject({ kind: 'restoration', descriptionSlot: 1 });
expect(missingTalentDescriptionSlots('Restore {1} over time.', outcomes)).toEqual([]);
});
expect(outcomes).toHaveLength(1);
expect(outcomes[0]).toMatchObject({
kind: 'restoration',
descriptionSlot: 1,
});
expect(
missingTalentDescriptionSlots('Restore {1} over time.', outcomes),
).toEqual([]);
});
it('accepts a numbered marker outcome for a structured timing value', () => {
const outcomes = numberedTalentOutcomes(draft([{
key: 'delayed-reload',
label: 'Delayed reload',
phase: 'after_event',
kind: 'marker',
scope: 'equipped_weapon',
basis: 'flat',
descriptionSlot: 1,
variants: {
I: { value: 4 },
II: { value: 3 }
}
}]));
it('accepts a numbered marker outcome for a structured timing value', () => {
const outcomes = numberedTalentOutcomes(
draft([
{
key: 'delayed-reload',
label: 'Delayed reload',
phase: 'after_event',
kind: 'marker',
scope: 'equipped_weapon',
basis: 'flat',
descriptionSlot: 1,
variants: {
I: { value: 4 },
II: { value: 3 },
},
},
]),
);
expect(outcomes).toHaveLength(1);
expect(outcomes[0]).toMatchObject({ kind: 'marker', descriptionSlot: 1 });
expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]);
});
expect(outcomes).toHaveLength(1);
expect(outcomes[0]).toMatchObject({ kind: 'marker', descriptionSlot: 1 });
expect(
missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes),
).toEqual([]);
});
it('accepts a numbered weapon-reload delay', () => {
const outcomes = numberedTalentOutcomes(draft([{
key: 'inactive-reload',
label: 'Inactive weapon reload',
phase: 'after_event',
kind: 'weapon_reload',
scope: 'inactive_weapon',
basis: 'flat',
descriptionSlot: 1,
variants: {
I: { value: 4 },
II: { value: 3 }
}
}]));
it('accepts a numbered weapon-reload delay', () => {
const outcomes = numberedTalentOutcomes(
draft([
{
key: 'inactive-reload',
label: 'Inactive weapon reload',
phase: 'after_event',
kind: 'weapon_reload',
scope: 'inactive_weapon',
basis: 'flat',
descriptionSlot: 1,
variants: {
I: { value: 4 },
II: { value: 3 },
},
},
]),
);
expect(outcomes).toHaveLength(1);
expect(outcomes[0]).toMatchObject({ kind: 'weapon_reload', descriptionSlot: 1 });
expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]);
});
expect(outcomes).toHaveLength(1);
expect(outcomes[0]).toMatchObject({
kind: 'weapon_reload',
descriptionSlot: 1,
});
expect(
missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes),
).toEqual([]);
});
it('reports exact missing slots instead of relying on outcome count', () => {
const outcomes = numberedTalentOutcomes(draft([{
key: 'damage',
label: 'Damage',
phase: 'on_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
descriptionSlot: 2,
variants: {
I: { value: 10 },
II: { value: 14 }
}
}]));
it('reports exact missing slots instead of relying on outcome count', () => {
const outcomes = numberedTalentOutcomes(
draft([
{
key: 'damage',
label: 'Damage',
phase: 'on_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
descriptionSlot: 2,
variants: {
I: { value: 10 },
II: { value: 14 },
},
},
]),
);
expect(missingTalentDescriptionSlots('{1} then {2} and {3}.', outcomes)).toEqual([1, 3]);
});
expect(
missingTalentDescriptionSlots('{1} then {2} and {3}.', outcomes),
).toEqual([1, 3]);
});
});
+38 -26
View File
@@ -1,40 +1,52 @@
import type {
ConditionalMechanicDraftV1,
ConditionalMechanicOutcomeDraft
ConditionalMechanicDraftV1,
ConditionalMechanicOutcomeDraft,
} from '$lib/calculations/mechanic-editor';
const VALUE_BEARING_OUTCOME_KINDS = new Set([
'attribute_modifier',
'direct_damage',
'restoration',
'resource_cost',
'weapon_reload',
'marker'
'attribute_modifier',
'direct_damage',
'restoration',
'resource_cost',
'weapon_reload',
'marker',
]);
export const numberedTalentOutcomes = (
draft: ConditionalMechanicDraftV1
draft: ConditionalMechanicDraftV1,
): ConditionalMechanicOutcomeDraft[] => {
const outcomes = draft.clauses
.flatMap((clause) => clause.outcomes)
.filter((outcome) =>
VALUE_BEARING_OUTCOME_KINDS.has(outcome.kind)
&& Number.isInteger(outcome.descriptionSlot)
&& Number(outcome.descriptionSlot) > 0
)
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
const outcomes = draft.clauses
.flatMap((clause) => clause.outcomes)
.filter(
(outcome) =>
VALUE_BEARING_OUTCOME_KINDS.has(outcome.kind) &&
Number.isInteger(outcome.descriptionSlot) &&
Number(outcome.descriptionSlot) > 0,
)
.sort(
(left, right) =>
Number(left.descriptionSlot) - Number(right.descriptionSlot),
);
return [...new Map(outcomes.map((outcome) => [outcome.descriptionSlot, outcome])).values()];
return [
...new Map(
outcomes.map((outcome) => [outcome.descriptionSlot, outcome]),
).values(),
];
};
export const missingTalentDescriptionSlots = (
template: string,
outcomes: ConditionalMechanicOutcomeDraft[]
template: string,
outcomes: ConditionalMechanicOutcomeDraft[],
) => {
const availableSlots = new Set(outcomes.map((outcome) => Number(outcome.descriptionSlot)));
return [...new Set(
[...template.matchAll(/\{(\d+)\}/g)]
.map((match) => Number(match[1]))
.filter((slot) => !availableSlots.has(slot))
)];
const availableSlots = new Set(
outcomes.map((outcome) => Number(outcome.descriptionSlot)),
);
return [
...new Set(
[...template.matchAll(/\{(\d+)\}/g)]
.map((match) => Number(match[1]))
.filter((slot) => !availableSlots.has(slot)),
),
];
};
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
let { article, kind = 'guide', prominent = false } = $props();
</script>
<article class="article-card" class:prominent>
<a class="article-card-cover" href={`/${kind === 'guide' ? 'guides' : 'news'}/${encodeURIComponent(article.slug)}`}>
{#if article.coverUrl}<img src={article.coverUrl} alt=""/>{:else}<div><span>SHD // {kind === 'guide' ? 'FIELD MANUAL' : 'TRANSMISSION'}</span><b>{article.tags?.[0]?.name ?? article.channel ?? 'COMMUNITY INTELLIGENCE'}</b></div>{/if}
</a>
<div class="article-card-body">
<header><span>{article.channel ?? article.tags?.[0]?.name ?? kind.toUpperCase()}</span>{#if article.recommended}<b>RECOMMENDED</b>{/if}{#if article.featured}<b>FEATURED</b>{/if}</header>
<h2><a href={`/${kind === 'guide' ? 'guides' : 'news'}/${encodeURIComponent(article.slug)}`}>{article.title}</a></h2>
<p>{article.deck}</p>
<footer><span>BY {(article.source?.name ?? article.author).toUpperCase()} · {new Date(article.publishedAt).toLocaleDateString()}</span>{#if kind === 'guide'}<b>{article.votes ? `★ ${article.rating.toFixed(1)} · ${article.votes}` : 'UNRATED'}</b>{/if}</footer>
</div>
</article>
<style>
.article-card{min-width:0;display:grid;grid-template-rows:220px 1fr;border:1px solid var(--shd-border);background:var(--shd-surface)}.article-card-cover{min-height:0;display:block;overflow:hidden;border-bottom:1px solid var(--shd-border);background:#090b0c}.article-card-cover img{width:100%;height:100%;display:block;object-fit:cover;filter:saturate(.78) contrast(1.06)}.article-card-cover>div{height:100%;display:grid;align-content:space-between;padding:16px;background:linear-gradient(145deg,#15191a 0 58%,#0d1011 58%);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.article-card-cover>div b{color:var(--shd-orange);font-size:1rem}.article-card-body{display:grid;grid-template-rows:auto auto 1fr auto;gap:9px;padding:16px}.article-card-body header{position:static;z-index:auto;width:auto;height:auto;min-height:0;padding:0;border:0;background:transparent}.article-card-body header,.article-card-body footer{display:flex;align-items:center;justify-content:space-between;gap:8px}.article-card-body header span,.article-card-body header b,.article-card-body footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px}.article-card-body header span,.article-card-body footer b{color:var(--shd-orange)}.article-card-body header b,.article-card-body footer span{color:var(--shd-text-muted)}.article-card-body h2,.article-card-body p{margin:0}.article-card-body h2{font-size:clamp(1.35rem,2.5vw,2rem);line-height:1.02}.article-card-body h2 a{color:var(--shd-text)}.article-card-body p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);line-height:1.5}.article-card-body footer{padding-top:10px;border-top:1px solid var(--shd-border)}.article-card:hover,.article-card:focus-within{border-color:var(--shd-border-strong);background:var(--shd-surface-active)}.article-card:hover h2 a,.article-card h2 a:focus-visible{color:var(--shd-orange)}.article-card.prominent{grid-template-columns:minmax(280px,.8fr) minmax(0,1.2fr);grid-template-rows:minmax(310px,auto)}.article-card.prominent .article-card-cover{border-right:1px solid var(--shd-border);border-bottom:0}.article-card.prominent .article-card-body{padding:clamp(20px,4vw,48px)}.article-card.prominent h2{font-size:clamp(2.2rem,5vw,4.5rem)}.article-card.prominent p{font-size:1.1rem}@media(max-width:720px){.article-card.prominent{grid-template-columns:1fr;grid-template-rows:220px auto}.article-card.prominent .article-card-cover{border-right:0;border-bottom:1px solid var(--shd-border)}}
</style>
+143
View File
@@ -0,0 +1,143 @@
<script lang="ts">
import ArticleEditor from '$lib/components/ArticleEditor.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
import { toast } from '$lib/toast';
import { untrack } from 'svelte';
let { data, form = null } = $props();
type ComposerTag = { id: string; name: string; group: string };
let title = $state(untrack(() => data.draft.title));
let deck = $state(untrack(() => data.draft.deck));
let document = $state(untrack(() => data.draft.document));
let selectedTags = $state<string[]>(untrack(() => [...data.draft.tagIds]));
let channel = $state(untrack(() => data.draft.channel || data.newsChannels?.[0] || ''));
let visibility = $state(untrack(() => data.article.visibility));
let coverImageId = $state(untrack(() => data.draft.coverImageId || ''));
let coverUrl = $state(untrack(() => data.draft.coverUrl || ''));
let coverAlt = $state(untrack(() => data.draft.coverAlt || ''));
let coverCaption = $state(untrack(() => data.draft.coverCaption || ''));
let version = $state(untrack(() => data.draft.version));
let saveState = $state<'saved' | 'saving' | 'error' | 'conflict'>('saved');
let coverUploading = $state(false);
let initialized = $state(false);
let lastContent = '';
let saveSequence = 0;
const groupedTags = $derived.by(() => {
const groups: Record<string, ComposerTag[]> = {};
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
return Object.entries(groups);
});
function payload() {
return { articleId: data.article.id, title, deck, document, tagIds: selectedTags, channel,
coverImageId, coverAlt, coverCaption, version };
}
$effect(() => {
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
if (!initialized) {
initialized = true;
lastContent = content;
return;
}
if (content === lastContent) return;
lastContent = content;
const sequence = ++saveSequence;
saveState = 'saving';
const timer = window.setTimeout(async () => {
try {
const response = await fetch('/api/articles/draft', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload())
});
const result = await response.json();
if (sequence !== saveSequence) return;
if (response.status === 409) {
saveState = 'conflict';
return;
}
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
version = result.version;
saveState = 'saved';
} catch (error) {
if (sequence !== saveSequence) return;
saveState = 'error';
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
}
}, 900);
return () => window.clearTimeout(timer);
});
function toggleTag(id: string) {
selectedTags = selectedTags.includes(id) ? selectedTags.filter((tag) => tag !== id) : [...selectedTags, id].slice(0, 12);
}
async function uploadCover(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
coverUploading = true;
const body = new FormData();
body.set('articleId', data.article.id);
body.set('image', file);
try {
const response = await fetch('/api/articles/media', { method: 'POST', body });
const result = await response.json();
if (!response.ok) throw new Error(result.message || 'Cover upload failed.');
coverImageId = result.id;
coverUrl = `/images/library/${result.id}`;
toast.success('Cover uploaded', 'Add alternative text before publishing.');
} catch (error) {
toast.error('Cover not uploaded', error instanceof Error ? error.message : 'Try another image.');
} finally {
coverUploading = false;
}
}
</script>
<main class="app-page article-compose-page">
<header class="article-compose-command">
<div><p>{data.article.kind === 'guide' ? 'GUIDE COMPOSER' : 'NEWSROOM'}</p><h1>{data.article.kind === 'guide' ? 'AUTHOR FIELD INTELLIGENCE' : 'PREPARE A TRANSMISSION'}</h1></div>
<div class="article-save-state" class:error={saveState === 'error' || saveState === 'conflict'}><i></i><span>{saveState === 'saving' ? 'SAVING DRAFT' : saveState === 'saved' ? 'DRAFT SAVED' : saveState === 'conflict' ? 'DRAFT CONFLICT · RELOAD' : 'SAVE FAILED'}</span></div>
</header>
{#if form?.articleError}<p class="error" role="alert">{form.articleError}</p>{/if}
{#if form?.articleMessage}<p class="success" role="status">{form.articleMessage}</p>{/if}
<section class="article-compose-lede" aria-label="Article headline">
<label class="article-title-field"><span>TITLE</span><textarea bind:value={title} maxlength="140" rows="1" placeholder="Give readers a clear reason to open this article"></textarea></label>
<label class="article-deck-field"><span>SUMMARY</span><textarea bind:value={deck} maxlength="320" rows="2" placeholder="Describe what the reader will learn."></textarea></label>
</section>
<section class="article-publication-dock" aria-label="Publication controls">
<div class="article-publication-ident"><span>PUBLICATION</span><strong>{data.article.kind.toUpperCase()}</strong></div>
<SegmentedChoice legend="VISIBILITY" name="composer-visibility" options={[{value:'private',label:'PRIVATE'},{value:'unlisted',label:'UNLISTED'},{value:'public',label:'PUBLIC'}]} value={visibility} onchange={(values) => visibility = values[0]}/>
{#if data.article.kind === 'news'}<label class="article-channel-field">NEWS CHANNEL<select bind:value={channel}>{#each data.newsChannels as item}<option>{item}</option>{/each}</select></label>{/if}
<div class="article-publication-note"><p>{visibility === 'private' ? 'Only you can open this draft.' : visibility === 'unlisted' ? 'Anyone with the direct URL can read it.' : 'Listed in discovery and visible to everyone.'}</p>{#if data.article.pendingRevisionId}<strong>MODERATION REVIEW PENDING</strong>{/if}</div>
<PendingForm action="?/publish" class="article-publish-form">
<input type="hidden" name="articleId" value={data.article.id}/><input type="hidden" name="title" value={title}/><input type="hidden" name="deck" value={deck}/><input type="hidden" name="document" value={document}/><input type="hidden" name="tagIds" value={JSON.stringify(selectedTags)}/><input type="hidden" name="channel" value={channel}/><input type="hidden" name="coverImageId" value={coverImageId}/><input type="hidden" name="coverAlt" value={coverAlt}/><input type="hidden" name="coverCaption" value={coverCaption}/><input type="hidden" name="version" value={version}/><input type="hidden" name="visibility" value={visibility}/>
<button class="primary">{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
</PendingForm>
</section>
<div class="article-compose-grid">
<section class="article-compose-main">
<ArticleEditor bind:value={document} articleId={data.article.id} builds={data.builds}/>
</section>
<aside class="article-compose-rail">
<section><header><span>COVER IMAGE</span></header>{#if coverUrl}<img class="article-cover-preview" src={coverUrl} alt="Selected article cover"/>{/if}<label class="article-file-action">{coverUploading ? 'UPLOADING…' : 'CHOOSE COVER'}<input type="file" accept="image/jpeg,image/png,image/webp,image/avif,image/svg+xml" disabled={coverUploading} onchange={uploadCover}/></label><label>ALTERNATIVE TEXT<input bind:value={coverAlt} maxlength="300"/></label><label>CAPTION · OPTIONAL<input bind:value={coverCaption} maxlength="500"/></label></section>
{#if data.article.kind === 'guide'}<section><header><span>DISCOVERY TAGS</span><b>{selectedTags.length}/12</b></header>{#each groupedTags as [group,tags]}<fieldset class="article-tag-group"><legend>{String(group).replace('_',' ').toUpperCase()}</legend><div>{#each tags as tag}<button type="button" class:selected={selectedTags.includes(tag.id)} aria-pressed={selectedTags.includes(tag.id)} onclick={() => toggleTag(tag.id)}>{tag.name}</button>{/each}</div></fieldset>{/each}</section>{/if}
{#if data.article.kind === 'guide'}<ModerationDisclosure/>{/if}
<PendingForm action="?/delete" class="article-delete-form"><input type="hidden" name="articleId" value={data.article.id}/><button class="danger" onclick={(event) => { if (!window.confirm('Delete this article? Its public page will be removed.')) event.preventDefault(); }}>DELETE ARTICLE</button></PendingForm>
</aside>
</div>
</main>
<style>
.article-compose-page{padding-top:18px}.article-compose-command{position:static;width:auto;height:auto;display:flex;align-items:end;justify-content:space-between;gap:20px;padding:12px 0 16px;border-bottom:1px solid var(--shd-border);background:transparent}.article-compose-command p,.article-compose-command h1{margin:0}.article-compose-command p{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.article-compose-command h1{font-size:clamp(1.6rem,4vw,3rem)}.article-save-state{display:flex;align-items:center;gap:7px;color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.article-save-state i{width:7px;height:7px;background:currentColor}.article-save-state.error{color:var(--shd-danger)}
.article-compose-lede{display:grid;gap:10px;padding:22px 0}.article-title-field,.article-deck-field{display:grid;gap:5px}.article-title-field span,.article-deck-field span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.article-title-field textarea,.article-deck-field textarea{field-sizing:content;overflow:hidden;resize:none}.article-title-field textarea{min-height:1.9em;padding:14px 0;border:0;border-bottom:1px solid var(--shd-border-strong);background:transparent;color:var(--shd-text);font-family:var(--shd-font);font-size:clamp(2.6rem,6vw,5.5rem);font-weight:700;line-height:.92;letter-spacing:-.035em}.article-deck-field textarea{min-height:3.2em;max-width:76ch;padding:8px 0;border:0;background:transparent;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:clamp(1.08rem,2vw,1.3rem);line-height:1.5}
.article-publication-dock{display:grid;grid-template-columns:auto minmax(280px,340px) minmax(190px,240px) minmax(180px,1fr) auto;align-items:end;gap:12px;padding:12px;border:1px solid var(--shd-border-strong);background:var(--shd-surface)}.article-publication-ident{align-self:center;display:grid;gap:3px;padding-right:12px;border-right:1px solid var(--shd-border)}.article-publication-ident span,.article-publication-ident strong,.article-channel-field,.article-publication-note{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.article-publication-ident span,.article-channel-field,.article-publication-note p{color:var(--shd-text-muted)}.article-publication-ident strong{color:var(--shd-text)}.article-channel-field{display:grid;gap:5px}.article-publication-note{align-self:center;display:grid;gap:3px}.article-publication-note p{margin:0;line-height:1.4}.article-publication-note strong{color:var(--shd-orange)}.article-publication-dock :global(.article-publish-form button){min-width:176px;min-height:44px}
.article-compose-grid{display:grid;grid-template-columns:minmax(0,1fr) 310px;align-items:start;gap:12px;margin-top:12px}.article-compose-main{min-width:0}.article-compose-rail{position:sticky;top:86px;display:grid;gap:8px}.article-compose-rail>section,.article-compose-rail :global(.moderation-disclosure){display:grid;gap:9px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.article-compose-rail section>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;justify-content:space-between;gap:8px;padding:0 0 7px;border-bottom:1px solid var(--shd-border);background:transparent}.article-compose-rail section>header span,.article-compose-rail section>header b,.article-compose-rail label{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.article-compose-rail section>header span{color:var(--shd-text-muted)}.article-compose-rail label{display:grid;gap:5px;color:var(--shd-text-muted)}.article-cover-preview{width:100%;max-height:180px;object-fit:cover}.article-file-action{min-height:40px;display:flex!important;align-items:center;justify-content:center;border:1px solid var(--shd-border-strong);color:var(--shd-text)!important;cursor:pointer}.article-file-action input{position:absolute;opacity:0;pointer-events:none}.article-tag-group{display:grid;gap:5px;margin:0;padding:0;border:0}.article-tag-group legend{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900}.article-tag-group>div{display:flex;flex-wrap:wrap;gap:4px}.article-tag-group button{min-height:32px;padding:0 7px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.article-tag-group button.selected{border-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.article-compose-rail :global(.article-delete-form button){width:100%;min-height:44px;border:1px solid #6f3b34;background:#1b1110;color:var(--shd-danger);font-weight:900}.article-compose-rail :global(.article-delete-form button:hover),.article-compose-rail :global(.article-delete-form button:focus-visible){border-color:var(--shd-danger);background:#2a1714;color:#f4a18e}
@media(max-width:1180px){.article-publication-dock{grid-template-columns:auto minmax(280px,1fr) minmax(200px,1fr) auto}.article-publication-note{grid-column:2/4}.article-compose-grid{grid-template-columns:minmax(0,1fr) 290px}}
@media(max-width:900px){.article-publication-dock{grid-template-columns:1fr 1fr}.article-publication-ident{padding:0 0 8px;border-right:0;border-bottom:1px solid var(--shd-border)}.article-publication-note{grid-column:1/-1}.article-publication-dock :global(.article-publish-form),.article-publication-dock :global(.article-publish-form button){width:100%}.article-compose-grid{grid-template-columns:1fr}.article-compose-rail{position:static;grid-template-columns:repeat(2,minmax(0,1fr))}.article-compose-rail :global(.article-delete-form),.article-compose-rail :global(.moderation-disclosure){grid-column:1/-1}}
@media(max-width:650px){.article-compose-command{align-items:start;flex-direction:column}.article-compose-lede{padding:18px 0}.article-title-field textarea{font-size:clamp(2.25rem,12vw,3.5rem)}.article-publication-dock,.article-compose-rail{grid-template-columns:1fr}.article-publication-ident,.article-publication-note,.article-publication-dock :global(.article-publish-form),.article-compose-rail :global(.article-delete-form),.article-compose-rail :global(.moderation-disclosure){grid-column:1}.article-publication-dock :global(.article-publish-form button){width:100%}}
</style>
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
import type { ArticleMark, ArticleNode } from '$lib/articles/document';
import BuildEmbed from '$lib/components/BuildEmbed.svelte';
let {
document,
builds = {},
images = {}
}: {
document: ArticleNode;
builds?: Record<string, {
buildId: string;
title: string;
slug: string;
author: string;
ownerShared: boolean;
available: boolean;
weapons: Array<{ name: string; category: string }>;
gear: Array<{ name: string; slot: string }>;
}>;
images?: Record<string, { url: string; alt: string; caption: string }>;
} = $props();
function link(mark: ArticleMark) {
return mark.type === 'link' ? mark.attrs?.href ?? '#' : '#';
}
</script>
{#snippet renderText(text: string, marks: ArticleMark[], index = 0)}
{#if index >= marks.length}{text}{:else}
{@const mark = marks[index]}
{#if mark.type === 'bold'}<strong>{@render renderText(text, marks, index + 1)}</strong>
{:else if mark.type === 'italic'}<em>{@render renderText(text, marks, index + 1)}</em>
{:else if mark.type === 'code'}<code>{@render renderText(text, marks, index + 1)}</code>
{:else}<a href={link(mark)} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
{/if}
{/snippet}
{#snippet children(node: ArticleNode)}
{#each node.content ?? [] as child}{@render renderNode(child)}{/each}
{/snippet}
{#snippet renderNode(node: ArticleNode)}
{#if node.type === 'text'}{@render renderText(node.text ?? '', node.marks ?? [])}
{:else if node.type === 'paragraph'}<p>{@render children(node)}</p>
{:else if node.type === 'heading' && Number(node.attrs?.level) === 2}<h2>{@render children(node)}</h2>
{:else if node.type === 'heading'}<h3>{@render children(node)}</h3>
{:else if node.type === 'bulletList'}<ul>{@render children(node)}</ul>
{:else if node.type === 'orderedList'}<ol>{@render children(node)}</ol>
{:else if node.type === 'listItem'}<li>{@render children(node)}</li>
{:else if node.type === 'blockquote'}<blockquote>{@render children(node)}</blockquote>
{:else if node.type === 'horizontalRule'}<hr/>
{:else if node.type === 'hardBreak'}<br/>
{:else if node.type === 'buildEmbed' && builds[String(node.attrs?.embedKey ?? '')]}
<BuildEmbed embed={builds[String(node.attrs?.embedKey ?? '')]}/>
{:else if node.type === 'articleImage' && images[String(node.attrs?.imageId ?? '')]}
{@const image = images[String(node.attrs?.imageId ?? '')]}
<figure><img src={image.url} alt={image.alt}/>{#if image.caption}<figcaption>{image.caption}</figcaption>{/if}</figure>
{/if}
{/snippet}
<div class="article-content">{@render children(document)}</div>
<style>
.article-content{max-width:76ch;color:var(--shd-text);font-family:var(--shd-font-editorial);font-size:clamp(1.02rem,1.4vw,1.15rem);line-height:1.72}.article-content :global(p){margin:0 0 1.25em}.article-content :global(h2),.article-content :global(h3){font-family:var(--shd-font);line-height:1.05}.article-content :global(h2){margin:2.1em 0 .6em;font-size:clamp(1.8rem,4vw,2.5rem)}.article-content :global(h3){margin:1.8em 0 .55em;font-size:1.4rem}.article-content :global(ul),.article-content :global(ol){display:grid;gap:7px;margin:0 0 1.35em;padding-left:1.4em}.article-content :global(blockquote){margin:2em 0;padding:18px 22px;border:1px solid var(--shd-border-strong);background:#0d1011;font-size:1.16em;font-style:italic}.article-content :global(hr){margin:38px 0;border:0;border-top:1px solid var(--shd-border-strong)}.article-content :global(a){color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.article-content :global(code){padding:1px 4px;background:#080a0b;color:var(--shd-info);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.88em}.article-content :global(figure){margin:34px 0}.article-content :global(figure img){max-width:100%;height:auto;display:block}.article-content :global(figcaption){padding:9px 0;color:var(--shd-text-muted);font-family:var(--shd-font);font-size:var(--shd-type-supporting);line-height:1.45}
</style>
@@ -0,0 +1,30 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import CommentComposer from '$lib/components/CommentComposer.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import ContentReport from '$lib/components/ContentReport.svelte';
let { comments, viewer, form = null, signInHref } = $props();
let replyingTo = $state('');
</script>
<section class="article-discussion" id="discussion" aria-labelledby="article-discussion-title">
<header><div><span>COMMUNITY DISCUSSION</span><h2 id="article-discussion-title">FIELD NOTES & QUESTIONS</h2></div><b>{comments.filter((item:any)=>item.status==='visible').length} COMMENTS</b></header>
{#if viewer}
<PendingForm action="?/comment"><CommentComposer name="body" label="ADD TO THE DISCUSSION" required/>{#if form?.commentError}<p class="error">{form.commentError}</p>{/if}{#if form?.commentMessage}<p class="success">{form.commentMessage}</p>{/if}<button class="primary">POST COMMENT</button></PendingForm>
{:else}<div class="discussion-sign-in"><p>Sign in to add a comment or reply.</p><a class="secondary" href={signInHref}>SIGN IN TO COMMENT</a></div>{/if}
<div class="article-comment-list">
{#each comments as comment}
<article id={`comment-${comment.id}`} class:reply={comment.depth>0} style={`--depth:${comment.depth}`}>
{#if comment.status === 'visible'}
<header><AgentIdentityLink size="compact" linked={Boolean(comment.author.publicSlug)} agent={comment.author} meta={new Date(comment.createdAt).toLocaleString()}/></header>
<MarkdownContent html={comment.bodyHtml} compact/>
<div class="article-comment-actions">{#if viewer}<button type="button" onclick={()=>replyingTo=replyingTo===comment.id?'':comment.id}>REPLY</button>{/if}{#if comment.canReport}<ContentReport contentType="comment" contentId={comment.id} result={form} plain/>{/if}{#if comment.canDelete}<PendingForm action="?/removeComment"><input type="hidden" name="commentId" value={comment.id}/><button class="danger">REMOVE</button></PendingForm>{/if}</div>
{#if replyingTo===comment.id}<PendingForm action="?/comment"><input type="hidden" name="parentId" value={comment.id}/><CommentComposer name="body" label="WRITE A REPLY" required rows={3}/><button class="primary">POST REPLY</button></PendingForm>{/if}
{:else}<div class="removed-comment"><strong>COMMENT REMOVED</strong><span>Replies remain visible to preserve the conversation.</span></div>{/if}
</article>
{:else}<div class="discussion-empty"><strong>NO COMMENTS YET</strong><span>Start the discussion with a useful question or field observation.</span></div>{/each}
</div>
</section>
<style>
.article-discussion{display:grid;gap:18px;margin-top:54px;padding-top:24px;border-top:1px solid var(--shd-border)}.article-discussion header{position:static;z-index:auto;width:auto;height:auto;min-height:0;padding:0;border:0;background:transparent}.article-discussion>header{display:flex;align-items:end;justify-content:space-between;gap:16px}.article-discussion>header span,.article-discussion>header b{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.article-discussion>header h2{margin:4px 0 0;font-size:clamp(1.6rem,4vw,2.5rem)}.discussion-sign-in{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px;border:1px solid var(--shd-border)}.discussion-sign-in p{margin:0;color:var(--shd-text-muted)}.article-comment-list{max-width:980px;display:grid}.article-comment-list>article{display:grid;gap:8px;margin:20px 0 0 calc(min(var(--depth),4) * 24px);padding-left:calc(var(--depth) * 0px);scroll-margin-top:96px}.article-comment-list>article.reply{padding-left:14px;border-left:1px solid var(--shd-border-strong)}.article-comment-list article :global(.markdown-content),.article-comment-actions,.article-comment-list article :global(.comment-composer){margin-left:47px}.article-comment-actions{display:flex;align-items:center;gap:12px}.article-comment-actions>button,.article-comment-actions :global(button){min-height:32px;padding:0;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.article-comment-actions>button:hover,.article-comment-actions>button:focus-visible{color:var(--shd-orange)}.removed-comment{display:grid;gap:3px;padding:6px 0;color:var(--shd-text-dim)}.removed-comment strong{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.discussion-empty{display:grid;gap:4px;padding:24px 0;color:var(--shd-text-muted)}.discussion-empty strong{color:var(--shd-text)}@media(max-width:650px){.article-discussion>header,.discussion-sign-in{align-items:start;flex-direction:column}.article-comment-list>article{margin-left:calc(min(var(--depth),3) * 10px)}.article-comment-list article :global(.markdown-content),.article-comment-actions,.article-comment-list article :global(.comment-composer){margin-left:0}}
</style>
+188
View File
@@ -0,0 +1,188 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Editor, JSONContent } from '@tiptap/core';
import { EMPTY_ARTICLE_DOCUMENT, ARTICLE_DOCUMENT_VERSION } from '$lib/articles/document';
import { ArticleImageNode, BuildEmbedNode } from '$lib/articles/editor-extensions';
let {
value = $bindable(JSON.stringify(EMPTY_ARTICLE_DOCUMENT)),
articleId,
builds = [],
disabled = false
}: {
value?: string;
articleId: string;
builds?: Array<{ id: string; title: string; visibility: string; owner: boolean }>;
disabled?: boolean;
} = $props();
let holder: HTMLDivElement;
let editor = $state<Editor>();
let buildSearch = $state('');
let showBuilds = $state(false);
let uploadOpen = $state(false);
let uploadError = $state('');
let uploading = $state(false);
let altText = $state('');
let caption = $state('');
let uploadFile = $state<File>();
const filteredBuilds = $derived(builds.filter((build) =>
build.title.toLocaleLowerCase().includes(buildSearch.trim().toLocaleLowerCase())
).slice(0, 30));
function envelope(): JSONContent {
try {
const parsed = JSON.parse(value);
if (parsed?.version === ARTICLE_DOCUMENT_VERSION && parsed.document?.type === 'doc') return parsed.document;
} catch {
// The server remains authoritative; an invalid local draft starts empty.
}
return EMPTY_ARTICLE_DOCUMENT.document;
}
function updateValue(instance: Editor) {
value = JSON.stringify({ version: ARTICLE_DOCUMENT_VERSION, document: instance.getJSON() });
}
function toggleLink() {
if (!editor) return;
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run();
return;
}
const href = window.prompt('Paste an HTTP or HTTPS link');
if (href) editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
}
function insertBuild(buildId: string) {
if (!editor) return;
const embedKey = `build-${crypto.randomUUID()}`;
editor.chain().focus().insertContent({ type: 'buildEmbed', attrs: { buildId, embedKey } }).insertContent({ type: 'paragraph' }).run();
showBuilds = false;
buildSearch = '';
}
async function uploadImage() {
if (!uploadFile || !altText.trim() || !editor) {
uploadError = 'Choose an image and provide descriptive alternative text.';
return;
}
uploading = true;
uploadError = '';
const form = new FormData();
form.set('articleId', articleId);
form.set('image', uploadFile);
try {
const response = await fetch('/api/articles/media', { method: 'POST', body: form });
const result = await response.json();
if (!response.ok) throw new Error(result.message || 'Image upload failed.');
editor.chain().focus().insertContent({
type: 'articleImage',
attrs: { imageId: result.id, alt: altText.trim(), caption: caption.trim() }
}).insertContent({ type: 'paragraph' }).run();
uploadFile = undefined;
altText = '';
caption = '';
uploadOpen = false;
} catch (error) {
uploadError = error instanceof Error ? error.message : 'Image upload failed.';
} finally {
uploading = false;
}
}
onMount(() => {
let disposed = false;
void Promise.all([import('@tiptap/core'), import('@tiptap/starter-kit'), import('@tiptap/extensions')]).then(([core, starter, extensions]) => {
if (disposed) return;
editor = new core.Editor({
element: holder,
content: envelope(),
editable: !disabled,
enableContentCheck: true,
injectCSS: false,
extensions: [
starter.StarterKit.configure({
heading: { levels: [2, 3] },
codeBlock: false,
strike: false,
underline: false,
link: { openOnClick: false, autolink: true, protocols: ['http', 'https'] }
}),
BuildEmbedNode,
ArticleImageNode,
extensions.Placeholder.configure({
placeholder: 'Start writing. Use the toolbar to add structure, images, or Builds.'
})
],
editorProps: { attributes: { 'aria-label': 'Article body', 'aria-multiline': 'true' } },
onUpdate: ({ editor: instance }) => updateValue(instance)
});
});
return () => {
disposed = true;
editor?.destroy();
};
});
</script>
<section class="article-editor-shell" aria-label="Article editor">
<div class="article-editor-toolbar" aria-label="Formatting controls">
<button type="button" aria-label="Paragraph" class:active={editor?.isActive('paragraph')} onclick={() => editor?.chain().focus().setParagraph().run()}>TEXT</button>
<button type="button" aria-label="Heading level 2" class:active={editor?.isActive('heading', { level: 2 })} onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
<button type="button" aria-label="Heading level 3" class:active={editor?.isActive('heading', { level: 3 })} onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}>H3</button>
<button type="button" aria-label="Bold" class:active={editor?.isActive('bold')} onclick={() => editor?.chain().focus().toggleBold().run()}><strong>B</strong></button>
<button type="button" aria-label="Italic" class:active={editor?.isActive('italic')} onclick={() => editor?.chain().focus().toggleItalic().run()}><em>I</em></button>
<button type="button" aria-label="Inline code" class:active={editor?.isActive('code')} onclick={() => editor?.chain().focus().toggleCode().run()}>CODE</button>
<button type="button" aria-label="Bulleted list" class:active={editor?.isActive('bulletList')} onclick={() => editor?.chain().focus().toggleBulletList().run()}>LIST</button>
<button type="button" aria-label="Numbered list" class:active={editor?.isActive('orderedList')} onclick={() => editor?.chain().focus().toggleOrderedList().run()}>1.</button>
<button type="button" aria-label="Quote" class:active={editor?.isActive('blockquote')} onclick={() => editor?.chain().focus().toggleBlockquote().run()}>QUOTE</button>
<button type="button" aria-label="Link" class:active={editor?.isActive('link')} onclick={toggleLink}>LINK</button>
<button type="button" aria-label="Divider" onclick={() => editor?.chain().focus().setHorizontalRule().run()}>RULE</button>
<button type="button" aria-expanded={showBuilds} onclick={() => { showBuilds = !showBuilds; uploadOpen = false; }}>+ BUILD</button>
<button type="button" aria-expanded={uploadOpen} onclick={() => { uploadOpen = !uploadOpen; showBuilds = false; }}>+ IMAGE</button>
<span></span>
<button type="button" aria-label="Undo" disabled={!editor?.can().undo()} onclick={() => editor?.chain().focus().undo().run()}>UNDO</button>
<button type="button" aria-label="Redo" disabled={!editor?.can().redo()} onclick={() => editor?.chain().focus().redo().run()}>REDO</button>
</div>
{#if showBuilds}
<div class="article-insert-panel">
<label>FIND A BUILD<input bind:value={buildSearch} placeholder="Search your Builds and the public archive"/></label>
<div class="article-build-results">
{#each filteredBuilds as build}
<button type="button" onclick={() => insertBuild(build.id)}><span>{build.owner ? 'YOUR BUILD' : 'PUBLIC BUILD'} · {build.visibility.toUpperCase()}</span><strong>{build.title}</strong><b>INSERT →</b></button>
{:else}<p>No eligible Builds match this search.</p>{/each}
</div>
</div>
{/if}
{#if uploadOpen}
<div class="article-insert-panel article-image-insert">
<label>IMAGE<input type="file" accept="image/jpeg,image/png,image/webp,image/avif,image/svg+xml" onchange={(event) => uploadFile = (event.currentTarget as HTMLInputElement).files?.[0]}/></label>
<label>ALTERNATIVE TEXT<input bind:value={altText} maxlength="300" placeholder="Describe the image for readers who cannot see it"/></label>
<label>CAPTION · OPTIONAL<input bind:value={caption} maxlength="500"/></label>
{#if uploadError}<p class="error" role="alert">{uploadError}</p>{/if}
<button type="button" class="primary" disabled={uploading} onclick={uploadImage}>{uploading ? 'UPLOADING…' : 'INSERT IMAGE'}</button>
</div>
{/if}
<div class="article-editor-canvas" bind:this={holder}></div>
<input type="hidden" name="document" {value}/>
</section>
<style>
.article-editor-shell{border:1px solid var(--shd-border-strong);background:var(--shd-surface)}
.article-editor-toolbar{position:sticky;top:76px;z-index:4;display:flex;flex-wrap:wrap;border-bottom:1px solid var(--shd-border);background:#111415}
.article-editor-toolbar button{min-width:42px;min-height:40px;padding:0 10px;border:0;border-right:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.5px;cursor:pointer}
.article-editor-toolbar button:hover,.article-editor-toolbar button:focus-visible,.article-editor-toolbar button.active{background:var(--shd-surface-active);color:var(--shd-orange)}
.article-editor-toolbar button:active{transform:translateY(1px)}.article-editor-toolbar button:disabled{opacity:.4;cursor:not-allowed}.article-editor-toolbar>span{flex:1}
.article-editor-canvas{min-height:580px;padding:clamp(24px,5vw,68px)}
.article-editor-canvas :global(.tiptap){max-width:76ch;min-height:500px;margin:0 auto;outline:0;color:var(--shd-text);font-family:var(--shd-font-editorial);font-size:1.08rem;line-height:1.72}
.article-editor-canvas :global(.tiptap p){margin:0 0 1.2em}.article-editor-canvas :global(.tiptap h2){margin:2.2em 0 .6em;font-family:var(--shd-font);font-size:2rem;line-height:1.05}.article-editor-canvas :global(.tiptap h3){margin:1.8em 0 .55em;font-family:var(--shd-font);font-size:1.35rem}
.article-editor-canvas :global(.tiptap p.is-editor-empty:first-child::before){height:0;float:left;color:var(--shd-text-dim);font-family:var(--shd-font-editorial);font-style:italic;content:attr(data-placeholder);pointer-events:none}
.article-editor-canvas :global(.tiptap blockquote){margin:2em 0;padding:16px 20px;border:1px solid var(--shd-border-strong);background:#0d1011;font-size:1.15rem;font-style:italic}.article-editor-canvas :global(.tiptap hr){margin:32px 0;border:0;border-top:1px solid var(--shd-border-strong)}
.article-editor-canvas :global(.article-editor-embed){display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;margin:24px 0;padding:18px;border:1px solid var(--shd-orange);background:#17140e;font-family:var(--shd-font);cursor:grab}.article-editor-canvas :global(.article-editor-embed span){color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.article-editor-canvas :global(.article-editor-embed strong){overflow:hidden;text-overflow:ellipsis}
.article-editor-canvas :global(.article-editor-image){margin:28px 0}.article-editor-canvas :global(.article-editor-image img){max-width:100%;display:block}.article-editor-canvas :global(.article-editor-image figcaption){padding:8px 0;color:var(--shd-text-muted);font-family:var(--shd-font);font-size:var(--shd-type-supporting)}
.article-insert-panel{display:grid;gap:10px;padding:12px;border-bottom:1px solid var(--shd-border);background:#0d1011}.article-insert-panel label{display:grid;gap:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.article-insert-panel input{width:100%}.article-build-results{max-height:280px;display:grid;overflow:auto;border:1px solid var(--shd-border)}.article-build-results button{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 12px;padding:10px;border:0;border-top:1px solid var(--shd-border);background:var(--shd-surface);color:var(--shd-text);text-align:left}.article-build-results button:first-child{border-top:0}.article-build-results button:hover,.article-build-results button:focus-visible{background:var(--shd-surface-active)}.article-build-results span{grid-column:1/-1;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.article-build-results b{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.article-image-insert{grid-template-columns:repeat(3,minmax(0,1fr)) auto;align-items:end}.article-image-insert .error{grid-column:1/-1}
@media(max-width:760px){.article-editor-toolbar{top:64px;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:thin}.article-editor-toolbar>span{display:none}.article-editor-toolbar button{flex:none;min-width:46px;min-height:44px}.article-editor-canvas{min-height:440px;padding:24px 16px}.article-editor-canvas :global(.tiptap){min-height:390px}.article-image-insert{grid-template-columns:1fr}.article-insert-panel{padding:10px}}
@media(prefers-reduced-motion:reduce){.article-editor-toolbar button{transition:none}}
</style>
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import ArticleContent from '$lib/components/ArticleContent.svelte';
import ArticleDiscussion from '$lib/components/ArticleDiscussion.svelte';
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import { untrack } from 'svelte';
let { data, form = null } = $props();
let reportOpen = $state(untrack(() => Boolean(form?.articleReportError)));
const path = $derived(data.article.kind === 'guide' ? 'guides' : 'news');
const publicationMeta = $derived(data.article.source
? `PUBLISHED ${new Date(data.article.source.publishedAt).toLocaleDateString()} · VIA ${data.article.source.name.toUpperCase()}`
: new Date(data.article.updatedAt).getTime() > new Date(data.article.publishedAt).getTime()
? `UPDATED ${new Date(data.article.updatedAt).toLocaleDateString()}`
: `PUBLISHED ${new Date(data.article.publishedAt).toLocaleDateString()}`);
</script>
<svelte:head>
<title>{data.article.title} — SHD.build</title>
<meta name="description" content={data.article.deck}/>
<meta property="og:title" content={data.article.title}/><meta property="og:description" content={data.article.deck}/>
{#if data.article.coverUrl}<meta property="og:image" content={data.article.coverUrl}/>{/if}
{#if data.article.source}<link rel="canonical" href={data.article.source.url}/>{/if}
{#if data.turnstileSiteKey && form?.articleChallengeRequired}<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>{/if}
</svelte:head>
<main class="page article-reader">
<header class="article-reader-head">
<div class="article-reader-channel"><span>{data.article.channel ?? (data.article.kind === 'guide' ? 'COMMUNITY GUIDE' : 'NEWS')}</span>{#if data.article.featured}<b>FEATURED</b>{/if}{#if data.article.recommended}<b>RECOMMENDED</b>{/if}</div>
<h1>{data.article.title}</h1><p>{data.article.deck}</p>
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>OPEN NEWSROOM</a>{/if}</div></div>
{#if data.article.source}<aside class="article-source-notice"><div><span>OFFICIAL SOURCE</span><strong>Originally published by {data.article.source.name}</strong></div><a href={data.article.source.url} target="_blank" rel="noopener noreferrer">READ ON UBISOFT</a></aside>{/if}
{#if data.tags.length}<nav class="article-reader-tags" aria-label="Article tags">{#each data.tags as tag}<a href={`/${path}?tag=${encodeURIComponent(tag.slug)}`}>{tag.name}</a>{/each}</nav>{/if}
</header>
{#if data.article.coverUrl}<figure class="article-hero-image"><img src={data.article.coverUrl} alt={data.article.coverAlt}/>{#if data.article.coverCaption}<figcaption>{data.article.coverCaption}</figcaption>{/if}</figure>{/if}
<div class="article-reader-layout" class:article-reader-layout-wide={data.article.kind === 'news'}>
<article><ArticleContent document={data.document} builds={data.builds} images={data.images}/></article>
{#if data.article.kind === 'guide'}<aside>
<section class="guide-rating"><span>RATE THIS GUIDE</span><strong>{data.article.votes ? `${data.article.rating.toFixed(1)} / 5` : 'UNRATED'}</strong><small>{data.article.votes} RATING{data.article.votes===1?'':'S'}</small><PendingForm action="?/rate" class="rating-form"><div class="rating-stars">{#each [1,2,3,4,5] as value}<button name="value" {value} aria-label={`Rate ${value} out of 5`}>★<span>{value}</span></button>{/each}</div>{#if form?.ratingError}<p class="error">{form.ratingError}</p>{/if}{#if form?.articleChallengeRequired && data.turnstileSiteKey}<div class="cf-turnstile" data-sitekey={data.turnstileSiteKey}></div>{/if}</PendingForm></section>
<section class="guide-report"><button type="button" aria-expanded={reportOpen} onclick={()=>reportOpen=!reportOpen}>REPORT GUIDE</button>{#if reportOpen}<PendingForm action="?/reportArticle"><label>CONCERN<select name="category" required><option value="">CHOOSE REASON</option><option value="spam">SPAM</option><option value="harassment">HARASSMENT</option><option value="hate">HATE</option><option value="sexual">SEXUAL CONTENT</option><option value="violence">VIOLENCE</option><option value="self_harm">SELF-HARM</option><option value="privacy">PRIVACY</option><option value="other">OTHER</option></select></label><label>DETAILS · OPTIONAL<textarea name="details" maxlength="500" rows="4"></textarea></label>{#if form?.articleReportError}<p class="error">{form.articleReportError}</p>{/if}{#if form?.articleChallengeRequired && data.turnstileSiteKey}<div class="cf-turnstile" data-sitekey={data.turnstileSiteKey}></div>{/if}<button class="danger">SEND REPORT</button></PendingForm>{/if}</section>
{#if data.canCurate}<section class="guide-curation"><span>COMMUNITY OVERSIGHT</span>{#each [['pinned',data.article.pinned],['featured',data.article.featured],['recommended',data.article.recommended]] as item}<PendingForm action="?/curate"><input type="hidden" name="placement" value={item[0]}/><input type="hidden" name="enabled" value={item[1]?'0':'1'}/><button>{item[1]?'REMOVE ':''}{String(item[0]).toUpperCase()}</button></PendingForm>{/each}</section>{/if}
</aside>{/if}
</div>
<ArticleDiscussion comments={data.comments} viewer={data.viewer} {form} signInHref={`/sign-in?returnTo=${encodeURIComponent(`/${path}/${data.article.slug}`)}`}/>
</main>
<style>
.article-reader{padding-top:34px}.article-reader-head{position:static;z-index:auto;width:auto;height:auto;min-height:0;max-width:1100px;display:grid;gap:14px;margin:0 auto;padding:26px 0 34px;border:0;background:transparent;text-align:center}.article-reader-channel{display:flex;justify-content:center;gap:8px}.article-reader-channel span,.article-reader-channel b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}.article-reader-channel span{color:var(--shd-orange)}.article-reader-channel b{color:var(--shd-text-muted)}.article-reader-head h1,.article-reader-head>p{margin:0}.article-reader-head h1{font-size:clamp(3rem,7vw,6rem);line-height:.86;letter-spacing:-.035em;text-wrap:balance}.article-reader-head>p{max-width:68ch;justify-self:center;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:clamp(1.1rem,2vw,1.35rem);line-height:1.5}.article-reader-byline{display:flex;align-items:center;justify-content:flex-start;gap:20px;text-align:left}.article-reader-byline>div{display:flex;gap:6px}.article-source-identity{display:grid!important;gap:2px!important}.article-source-identity strong{font-size:.95rem}.article-source-identity span,.article-source-notice span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.article-source-notice{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:12px 14px;border:1px solid var(--shd-border-strong);background:var(--shd-surface);text-align:left}.article-source-notice>div{display:grid;gap:3px}.article-source-notice a{padding:9px 12px;border:1px solid var(--shd-orange);color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;white-space:nowrap}.article-source-notice a:hover,.article-source-notice a:focus-visible{background:var(--shd-orange);color:#090b0c}.article-reader-tags{display:flex;justify-content:center;flex-wrap:wrap;gap:5px}.article-reader-tags a{padding:5px 8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.article-reader-tags a:hover,.article-reader-tags a:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}
.article-hero-image{max-width:1380px;margin:0 auto 30px}.article-hero-image img{width:100%;max-height:660px;display:block;object-fit:cover}.article-hero-image figcaption{padding:8px 0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.article-reader-layout{display:grid;grid-template-columns:minmax(0,76ch) 250px;justify-content:center;align-items:start;gap:clamp(30px,6vw,90px)}.article-reader-layout-wide{grid-template-columns:minmax(0,1100px)}.article-reader-layout>aside{position:sticky;top:90px;display:grid;gap:8px}.article-reader-layout>aside>section{display:grid;gap:7px;padding:11px;border:1px solid var(--shd-border);background:var(--shd-surface)}.article-reader-layout>aside section>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.guide-rating>strong{font-size:1.5rem}.rating-stars{display:grid;grid-template-columns:repeat(5,1fr)}.rating-stars button{min-width:0;height:40px;padding:0;border:1px solid var(--shd-border);border-left:0;background:transparent;color:var(--shd-text-dim);font-size:18px}.rating-stars button:first-child{border-left:1px solid var(--shd-border)}.rating-stars button:hover,.rating-stars button:focus-visible{color:var(--shd-orange)}.rating-stars span{display:block;font-size:8px}.guide-report>button{min-height:38px;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;text-align:left}.guide-report :global(form){display:grid;gap:8px}.guide-report label{display:grid;gap:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.guide-curation :global(form button){width:100%;min-height:36px}
@media(max-width:900px){.article-reader-layout{grid-template-columns:1fr}.article-reader-layout>article{display:grid;justify-content:center}.article-reader-layout>aside{position:static;grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:650px){.article-reader-head{text-align:left}.article-reader-channel,.article-reader-tags{justify-content:flex-start}.article-reader-byline{align-items:flex-start;flex-direction:column}.article-source-notice{align-items:stretch;flex-direction:column}.article-source-notice a{text-align:center}.article-reader-head h1{font-size:3.4rem}.article-reader-layout>aside{grid-template-columns:1fr}}
</style>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
let {
embed
}: {
embed: {
buildId: string;
title: string;
slug: string;
author: string;
ownerShared: boolean;
available: boolean;
weapons: Array<{ name: string; category: string }>;
gear: Array<{ name: string; slot: string }>;
};
} = $props();
</script>
<aside class="build-embed" aria-label={`Embedded Build: ${embed.title}`}>
<header>
<div><span>SHD BUILD EMBED</span><strong>{embed.title}</strong><small>BY {embed.author.toUpperCase()}</small></div>
<b>{embed.ownerShared ? 'AUTHOR-SHARED' : 'PUBLIC ARCHIVE'}</b>
</header>
{#if embed.available}
<div class="build-embed-matrix">
<section><span>WEAPONS</span>{#each embed.weapons as weapon}<div><strong>{weapon.name}</strong><small>{weapon.category}</small></div>{/each}</section>
<section><span>GEAR</span>{#each embed.gear.slice(0,6) as item}<div><strong>{item.name}</strong><small>{item.slot}</small></div>{/each}</section>
</div>
{#if !embed.ownerShared}<a href={`/builds/${encodeURIComponent(embed.slug)}`}>OPEN CURRENT BUILD →</a>{/if}
{:else}
<div class="build-embed-unavailable"><strong>BUILD UNAVAILABLE</strong><span>The source Build is no longer public.</span></div>
{/if}
</aside>
<style>
.build-embed{margin:32px 0;border:1px solid var(--shd-border-strong);background:#0d1011;font-family:var(--shd-font)}
.build-embed>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:start;justify-content:space-between;gap:16px;padding:14px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.build-embed>header>div{display:grid;gap:3px}.build-embed span,.build-embed small,.build-embed b,.build-embed>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.build-embed>header span,.build-embed>a{color:var(--shd-orange)}.build-embed>header strong{font-size:1.18rem}.build-embed>header small,.build-embed>header>b{color:var(--shd-text-muted)}
.build-embed-matrix{display:grid;grid-template-columns:.45fr 1fr}.build-embed-matrix section{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border-left:1px solid var(--shd-border)}.build-embed-matrix section:first-child{border-left:0}.build-embed-matrix section>span{grid-column:1/-1;padding:7px 10px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted)}.build-embed-matrix section>div{min-width:0;display:grid;gap:2px;padding:9px 10px;border-left:1px solid var(--shd-border);border-top:1px solid var(--shd-border)}.build-embed-matrix section>div:nth-child(2),.build-embed-matrix section>div:nth-child(3){border-top:0}.build-embed-matrix section>div:nth-child(even){border-left:0}.build-embed-matrix strong,.build-embed-matrix small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.build-embed-matrix small{color:var(--shd-text-muted)}
.build-embed>a{min-height:42px;display:flex;align-items:center;justify-content:flex-end;padding:0 12px;border-top:1px solid var(--shd-border)}.build-embed>a:hover,.build-embed>a:focus-visible{background:var(--shd-surface-active);color:var(--shd-orange-hover)}.build-embed-unavailable{display:grid;gap:4px;padding:22px}.build-embed-unavailable strong{color:var(--shd-danger)}.build-embed-unavailable span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}
@media(max-width:700px){.build-embed>header{display:grid}.build-embed-matrix{grid-template-columns:1fr}.build-embed-matrix section{border-top:1px solid var(--shd-border);border-left:0}.build-embed-matrix section:first-child{border-top:0}}
</style>
+4 -1
View File
@@ -7,7 +7,8 @@
label = 'REPORT',
result,
plain = false,
menuItem = false
menuItem = false,
itemId = ''
}: {
contentType: 'comment' | 'profile_bio' | 'guide' | 'avatar';
contentId: string;
@@ -15,6 +16,7 @@
result?: Record<string, unknown> | null;
plain?: boolean;
menuItem?: boolean;
itemId?: string;
} = $props();
const categories = [
@@ -48,6 +50,7 @@
{#snippet children(pending)}
<input type="hidden" name="contentType" value={contentType}/>
<input type="hidden" name="contentId" value={contentId}/>
{#if itemId}<input type="hidden" name="itemId" value={itemId}/>{/if}
<label class="field">CONCERN
<select name="category" required>
{#each categories as category}<option value={category.value}>{category.label}</option>{/each}
@@ -0,0 +1,47 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { tick } from 'svelte';
import type { DatabaseItem } from '$lib/database/items';
import DatabaseItemCard from './DatabaseItemCard.svelte';
import GameDataDiscussion from './GameDataDiscussion.svelte';
let { item, comments, viewer, form, signInHref, closeHref }: {
item: DatabaseItem;
comments: Array<any>;
viewer: { id: string | null; canComment: boolean; canPin: boolean };
form?: Record<string, any> | null;
signInHref: string;
closeHref: string;
} = $props();
let dialog = $state<HTMLDivElement | null>(null);
let closeButton = $state<HTMLButtonElement | null>(null);
$effect(() => { void tick().then(() => closeButton?.focus()); });
const close = () => goto(closeHref, { noScroll: true });
const trapFocus = (event: KeyboardEvent) => {
if (event.key !== 'Tab' || !dialog) return;
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),textarea,input:not([type="hidden"]),[tabindex]:not([tabindex="-1"])')].filter((element) => !element.hasAttribute('hidden'));
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
</script>
<svelte:window onkeydown={(event) => { if (event.key === 'Escape') void close(); }}/>
<div class="database-discussion-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void close(); }}>
<div class="database-discussion-dialog" bind:this={dialog} role="dialog" aria-modal="true" aria-labelledby="database-discussion-title" tabindex="-1" onkeydown={trapFocus}>
<header class="database-discussion-command"><div><small>DATABASE RECORD / COMMUNITY DISCUSSION</small><strong id="database-discussion-title">{item.name}</strong></div><button type="button" bind:this={closeButton} aria-label="Close discussion" onclick={() => void close()}>×</button></header>
<div class="database-discussion-scroll">
<section class="database-discussion-context" aria-label={`${item.name} record context`}><DatabaseItemCard {item} hideDiscussionUtility anchor={false}/></section>
<GameDataDiscussion itemName={item.name} itemId={item.id} {comments} count={item.discussionCount ?? 0} {viewer} {form} {signInHref} embedded/>
</div>
</div>
</div>
<style>
.database-discussion-backdrop{position:fixed;z-index:1200;inset:0;display:grid;place-items:center;padding:20px;background:rgba(3,5,5,.86);backdrop-filter:blur(3px)}.database-discussion-dialog{width:min(920px,100%);max-height:calc(100vh - 40px);display:grid;grid-template-rows:auto minmax(0,1fr);overflow:hidden;border:1px solid var(--shd-border-strong);border-top:2px solid var(--shd-orange);background:var(--shd-bg);box-shadow:0 18px 60px #000}.database-discussion-command{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 12px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-active)}.database-discussion-command>div{display:grid;gap:3px}.database-discussion-command small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.database-discussion-command strong{font-size:var(--shd-type-body);letter-spacing:.2px}.database-discussion-command button{width:34px;height:34px;padding:0;border:1px solid var(--shd-border-strong);background:var(--shd-surface);color:var(--shd-text-muted);font-size:var(--shd-type-title);line-height:1;cursor:pointer}.database-discussion-command button:hover,.database-discussion-command button:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange);background:var(--shd-surface-active)}.database-discussion-command button:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.database-discussion-scroll{min-height:0;overflow:auto;padding:14px 14px 190px}.database-discussion-context{width:min(720px,100%);margin:0 auto}.database-discussion-context :global(.database-item-card){box-shadow:none}.database-discussion-scroll :global(.public-discussion){width:min(720px,100%);margin:24px auto 0}
@media(max-width:650px){.database-discussion-backdrop{padding:0}.database-discussion-dialog{width:100%;height:100%;max-height:none;border-right:0;border-bottom:0;border-left:0}.database-discussion-command{min-height:64px}.database-discussion-command button{width:44px;height:44px}.database-discussion-scroll{padding:10px 10px 190px}.database-discussion-context{width:100%}}
</style>
+33 -5
View File
@@ -5,15 +5,20 @@
import DatabaseFacts from './DatabaseFacts.svelte';
import type { GameIconName } from '$lib/game-icons';
import type { DatabaseItem } from '$lib/database/items';
import MarkdownContent from './MarkdownContent.svelte';
let {
item
item,
hideDiscussionUtility = false,
anchor = true
}: {
item: DatabaseItem & { gameIcon?: GameIconName }
item: DatabaseItem & { gameIcon?: GameIconName };
hideDiscussionUtility?: boolean;
anchor?: boolean;
} = $props();
</script>
<article class="database-item-card" class:talent-card={item.variant === 'talent'}>
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'}>
<header>
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
<div class="database-item-icon">
@@ -55,7 +60,30 @@
{#if item.afterLines?.length}
<div class="database-item-lines after-description">{#each item.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
{/if}
{#if item.actions?.length}
<footer class="database-item-actions">{#each item.actions as action}<a href={action.href}>{action.label} →</a>{/each}</footer>
{#if item.pinnedCommunityNoteHtml}<aside class="database-community-note"><strong>COMMUNITY NOTE</strong><MarkdownContent html={item.pinnedCommunityNoteHtml} compact/></aside>{/if}
{#if item.actions?.length || item.correctionHref || (!hideDiscussionUtility && item.discussionHref)}
<footer class="database-item-footer">
{#if item.actions?.length}<div class="database-item-actions">{#each item.actions as action}<a href={action.href}>{action.label} →</a>{/each}</div>{/if}
<div class="database-item-utilities" aria-label="Community actions">
{#if item.correctionHref}
<a class="database-utility-button correction" href={item.correctionHref} aria-label={`Report an inaccuracy in ${item.name}`} title="Report an inaccuracy">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 21V4M6 5h11l-2.75 3L17 11H6"/></svg>
</a>
{/if}
{#if !hideDiscussionUtility && item.discussionHref}
<a class="database-utility-button discussion" href={item.discussionHref} aria-label={`Open discussion for ${item.name}${item.discussionCount ? `, ${item.discussionCount} messages` : ', no messages yet'}`} title="Open discussion" aria-haspopup="dialog">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5.75h14v9.5H9.25L5 18.75v-13Z"/><path d="M8.25 9h7.5M8.25 12h5"/></svg>
{#if item.discussionCount}<span>{item.discussionCount}</span>{/if}
</a>
{/if}
</div>
</footer>
{/if}
</article>
<style>
.database-community-note{display:grid;gap:5px;margin:12px 0 0;padding:10px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-armor-border);background:linear-gradient(90deg,var(--shd-armor-surface),var(--shd-surface-raised))}.database-community-note>strong{color:var(--shd-armor-label);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.database-community-note :global(.markdown-content){color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.database-community-note :global(.markdown-content a){color:var(--shd-armor-text)}
.database-item-footer{display:flex;align-items:flex-end;justify-content:space-between;gap:8px;margin:10px 12px 12px;padding-top:9px;border-top:1px solid var(--shd-border)}.database-item-footer .database-item-actions{min-width:0;display:flex;flex-wrap:wrap;gap:6px;margin:0;padding:0;border:0}.database-item-utilities{display:flex;align-items:center;gap:4px;margin-left:auto}.database-utility-button{height:32px;min-width:32px;display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:0 7px;border:1px solid var(--shd-border);background:var(--shd-surface);color:var(--shd-text-dim)}.database-utility-button svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:square;stroke-linejoin:miter}.database-utility-button span{min-width:10px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:800;line-height:1;text-align:center}.database-utility-button:hover,.database-utility-button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);color:var(--shd-text)}.database-utility-button:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.database-utility-button:active{transform:translateY(1px);filter:brightness(.84)}
@media(max-width:650px){.database-item-footer{align-items:stretch}.database-item-utilities{align-self:end}.database-utility-button{width:44px;height:44px;padding:0}.database-utility-button.discussion:has(span){width:auto;min-width:44px;padding:0 10px}}
@media(prefers-reduced-motion:reduce){.database-utility-button{transition:none}}
</style>
@@ -0,0 +1,110 @@
<script lang="ts">
import AgentIdentityLink from './AgentIdentityLink.svelte';
import CommentComposer from './CommentComposer.svelte';
import MarkdownContent from './MarkdownContent.svelte';
import ModerationDisclosure from './ModerationDisclosure.svelte';
import PendingForm from './PendingForm.svelte';
import OverflowMenu from './OverflowMenu.svelte';
import ContentReport from './ContentReport.svelte';
import { tick } from 'svelte';
let { itemName, itemId, comments, count, viewer, form, signInHref, embedded = false }: {
itemName: string;
itemId: string;
comments: Array<any>;
count: number;
viewer: { id: string | null; canComment: boolean; canPin: boolean };
form?: Record<string, any> | null;
signInHref: string;
embedded?: boolean;
} = $props();
let draft = $state('');
let activeComposer = $state<string | null>(null);
let handledToken = $state('');
let activeMenuId = $state('');
let deletingCommentId = $state('');
const parentForResult = $derived(String(form?.discussionParentId ?? '') || 'root');
$effect(() => {
const token = String(form?.discussionResultToken ?? '');
if (!token || token === handledToken) return;
handledToken = token;
if (form?.discussionSaved) { draft = ''; activeComposer = null; }
});
async function openComposer(id: string) {
activeComposer = activeComposer === id ? null : id;
draft = '';
if (!activeComposer) return;
await tick();
document.getElementById(`discussion-editor-${activeComposer}`)?.focus();
}
</script>
{#snippet composer(parentId: string | null, label: string)}
<PendingForm action="?/commentDiscussion" class="discussion-form">
<input type="hidden" name="itemId" value={itemId}/>
{#if parentId}<input type="hidden" name="parentId" value={parentId}/>{/if}
<CommentComposer id={`discussion-editor-${parentId ?? 'root'}`} name="body" {label} maxLength={2000} rows={4} required bind:value={draft}/>
<ModerationDisclosure compact/>
{#if parentForResult === (parentId ?? 'root') && form?.discussionError}<p class="error">{form.discussionError}</p>{/if}
<div class="discussion-form-actions"><button type="button" class="secondary" onclick={() => openComposer(parentId ?? 'root')}>CANCEL</button><button class="primary" disabled={!draft.trim()}>{parentId ? 'POST REPLY →' : 'POST NOTE →'}</button></div>
</PendingForm>
{/snippet}
<section class="public-discussion" class:embedded id="discussion">
<header><div><p class="eyebrow">COMMUNITY INTELLIGENCE</p><h2>FIELD DISCUSSION</h2></div><span>{count} {count === 1 ? 'NOTE' : 'NOTES'}</span></header>
{#if viewer.canComment}
<button type="button" class="discussion-trigger" aria-expanded={activeComposer === 'root'} onclick={() => openComposer('root')}>{activeComposer === 'root' ? 'CLOSE EDITOR' : `DISCUSS ${itemName.toUpperCase()}`}</button>
{#if activeComposer === 'root'}{@render composer(null, 'NEW COMMUNITY NOTE')}{/if}
{:else if !viewer.id}
<div class="discussion-access"><strong>SIGN-IN REQUIRED</strong><p>Sign in to discuss this data record or propose a correction.</p><a class="secondary" href={signInHref}>SIGN IN →</a></div>
{/if}
<div class="discussion-list">
{#each comments as comment}
<article id={`discussion-comment-${comment.id}`} class:reply={comment.depth > 0} class:removed={comment.status === 'removed'} class:pinned={comment.isPinned} style={`--thread-depth:${Math.min(comment.depth, 4)}`}>
{#if comment.status === 'visible'}
<header><AgentIdentityLink agent={comment.author} roles={comment.author.roles} meta={new Date(comment.createdAt).toLocaleDateString().toUpperCase()}/>{#if comment.isPinned}<b>COMMUNITY NOTE</b>{/if}</header>
<MarkdownContent html={comment.bodyHtml} compact/>
<div class="discussion-actions">
{#if viewer.canComment}<button type="button" onclick={() => openComposer(comment.id)}>REPLY</button>{/if}
{#if viewer.id}
<OverflowMenu label={`Actions for ${comment.author.displayName}'s message`} bind:open={() => activeMenuId === comment.id, (open) => activeMenuId = open ? comment.id : ''}>
{#snippet children(close)}
<PendingForm action="?/toggleDiscussionFollow" successMessage={comment.isFollowing ? 'Conversation unfollowed.' : 'Conversation followed.'}>
<input type="hidden" name="itemId" value={itemId}/>
<input type="hidden" name="commentId" value={comment.id}/>
<input type="hidden" name="intent" value={comment.isFollowing ? 'unfollow' : 'follow'}/>
<button>{comment.isFollowing ? 'UNFOLLOW THREAD' : 'FOLLOW THREAD'}</button>
</PendingForm>
{#if comment.canReport}<ContentReport contentType="comment" contentId={comment.id} {itemId} result={form} plain menuItem/>{/if}
{#if viewer.canPin && comment.depth === 0}
<PendingForm action="?/setPinnedCommunityNote">
<input type="hidden" name="itemId" value={itemId}/>
<input type="hidden" name="commentId" value={comment.id}/>
<input type="hidden" name="intent" value={comment.isPinned ? 'unpin' : 'pin'}/>
<button>{comment.isPinned ? 'UNPIN COMMUNITY NOTE' : 'PIN AS COMMUNITY NOTE'}</button>
</PendingForm>
{/if}
{#if comment.canDelete}<button type="button" class="danger" onclick={() => { deletingCommentId = comment.id; close(); }}>REMOVE MESSAGE</button>{/if}
{/snippet}
</OverflowMenu>
{/if}
</div>
{:else}<div class="discussion-removed"><strong>{comment.removalReason === 'Removed by Community Oversight' ? 'MESSAGE REMOVED BY COMMUNITY OVERSIGHT' : 'MESSAGE REMOVED BY POSTER'}</strong><span>Replies remain visible to preserve the conversation.</span></div>{/if}
{#if activeComposer === comment.id}<div class="discussion-reply">{@render composer(comment.id, `REPLY TO ${comment.author.displayName.toUpperCase()}`)}</div>{/if}
{#if deletingCommentId === comment.id}
<div class="discussion-delete-confirm" role="alert">
<p>Remove this message? It remains as a labeled placeholder only when replies depend on it.</p>
<div><button type="button" class="secondary" onclick={() => deletingCommentId = ''}>CANCEL</button><PendingForm action="?/deleteDiscussionComment"><input type="hidden" name="itemId" value={itemId}/><input type="hidden" name="commentId" value={comment.id}/><button class="primary">CONFIRM REMOVE</button></PendingForm></div>
</div>
{/if}
</article>
{:else}<p class="discussion-empty">No active field discussion. Use a note to document an observation or help verify this record.</p>{/each}
</div>
</section>
<style>
.public-discussion.embedded{padding:0 0 2px;border:0;background:transparent}.public-discussion.embedded>header{padding:0;border:0;background:transparent}
.public-discussion{margin-top:28px;padding-top:18px;border-top:1px solid var(--shd-border)}.public-discussion>header{display:flex;justify-content:space-between;gap:12px;align-items:end}.public-discussion h2,.public-discussion p{margin:0}.public-discussion h2{font-size:var(--shd-type-title)}.public-discussion>header span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.discussion-trigger{width:100%;min-height:46px;margin-top:14px;padding:0 12px;border:1px solid var(--shd-border-strong);background:transparent;color:var(--shd-text-muted);text-align:left;cursor:pointer}.discussion-trigger:hover,.discussion-trigger:focus-visible{border-color:var(--shd-orange);color:var(--shd-text);background:var(--shd-surface-active)}:global(form.discussion-form){display:grid;gap:9px;margin-top:12px}.discussion-form-actions{display:flex;justify-content:end;gap:7px}.discussion-list{width:100%;display:grid;margin-top:26px;overflow:visible}.discussion-list article{position:relative;display:grid;gap:8px;margin:18px 0 0 calc(var(--thread-depth) * 22px);padding:0;border:0;background:transparent}.discussion-list article.reply{padding-left:14px;border-left:1px solid var(--shd-border-strong)}.discussion-list article.pinned{box-shadow:none}.discussion-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}.discussion-list article>header>b{margin-left:auto;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.discussion-list :global(.markdown-content){max-width:75ch;margin-left:53px}.discussion-actions{min-height:32px;display:flex;align-items:center;gap:14px;margin-left:53px}.discussion-reply,.discussion-delete-confirm{margin:6px 0 0 53px}.discussion-reply :global(form.discussion-form){padding-top:0}.discussion-actions button{border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px;cursor:pointer}.discussion-actions button:hover,.discussion-actions button:focus-visible{color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.discussion-removed{display:grid;gap:3px;margin-left:53px;padding:2px 0;color:var(--shd-text-dim)}.discussion-removed strong{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.discussion-removed span,.discussion-empty,.discussion-access{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.discussion-delete-confirm{width:min(520px,calc(100% - 53px));display:grid;gap:8px}.discussion-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}
.discussion-access,.discussion-empty{display:grid;gap:5px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface-raised);color:var(--shd-text-muted)}.discussion-access strong{color:var(--shd-text);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.discussion-access{margin-top:14px}.discussion-access a{justify-self:start}.discussion-empty{margin-top:16px;font-family:var(--shd-font-editorial)}@media(max-width:650px){.public-discussion>header{align-items:flex-start;flex-direction:column}.discussion-list article{margin-left:calc(var(--thread-depth) * 10px)}.discussion-list article.reply{padding-left:10px}.discussion-list :global(.markdown-content),.discussion-actions,.discussion-reply,.discussion-removed,.discussion-delete-confirm{margin-left:0}.discussion-delete-confirm{width:100%}.discussion-form-actions{display:grid;grid-template-columns:1fr 1fr}.discussion-form-actions button{width:100%;min-height:44px}.discussion-actions>button{min-height:44px}.discussion-access .secondary{min-height:44px}.discussion-list article>header>b{align-self:start}}
</style>
+2 -2
View File
@@ -73,7 +73,7 @@
aria-expanded={open}
onclick={toggle}
>
<span aria-hidden="true">•••</span>
<span aria-hidden="true">...</span>
</button>
{#if open}
<div class="overflow-menu-panel" role="toolbar" aria-label={label} tabindex="-1" onkeydown={navigate}>
@@ -83,7 +83,7 @@
</div>
<style>
.overflow-menu{position:relative}.overflow-menu-trigger{width:44px;height:44px;display:grid;place-items:center;padding:0;border:1px solid transparent;background:transparent;color:var(--shd-text-muted);font-size:15px;font-weight:900;letter-spacing:2px;cursor:pointer}.overflow-menu-trigger:hover,.overflow-menu-trigger:focus-visible,.overflow-menu-trigger[aria-expanded=true]{border-color:var(--shd-border-strong);background:var(--shd-surface-active);color:var(--shd-orange)}.overflow-menu-panel{position:absolute;z-index:7;top:calc(100% + 4px);left:0;width:max-content;min-width:210px;display:grid;padding:4px;border:1px solid var(--shd-border-strong);background:var(--shd-surface);box-shadow:8px 12px 28px #0009}.overflow-menu-panel :global(form>fieldset>button),.overflow-menu-panel :global(details>summary),.overflow-menu-panel :global(>button){width:100%;min-height:40px;display:flex;align-items:center;justify-content:flex-start;padding:0 10px;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px;text-align:left;cursor:pointer}.overflow-menu-panel :global(form>fieldset>button:hover),.overflow-menu-panel :global(form>fieldset>button:focus-visible),.overflow-menu-panel :global(details>summary:hover),.overflow-menu-panel :global(details>summary:focus-visible),.overflow-menu-panel :global(>button:hover),.overflow-menu-panel :global(>button:focus-visible){background:var(--shd-surface-active);color:var(--shd-orange)}.overflow-menu-panel :global(>button.danger:hover),.overflow-menu-panel :global(>button.danger:focus-visible){color:var(--shd-danger)}
.overflow-menu{position:relative}.overflow-menu-trigger{width:auto;min-width:32px;height:32px;display:grid;place-items:center;padding:0 3px;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);font-weight:900;letter-spacing:1px;cursor:pointer}.overflow-menu-trigger:hover,.overflow-menu-trigger:focus-visible,.overflow-menu-trigger[aria-expanded=true]{background:transparent;color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.overflow-menu-panel{position:absolute;z-index:7;top:calc(100% + 4px);left:0;width:max-content;min-width:210px;display:grid;padding:4px;border:1px solid var(--shd-border-strong);background:var(--shd-surface);box-shadow:8px 12px 28px #0009}.overflow-menu-panel :global(form>fieldset>button),.overflow-menu-panel :global(details>summary),.overflow-menu-panel :global(>button){width:100%;min-height:40px;display:flex;align-items:center;justify-content:flex-start;padding:0 10px;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px;text-align:left;cursor:pointer}.overflow-menu-panel :global(form>fieldset>button:hover),.overflow-menu-panel :global(form>fieldset>button:focus-visible),.overflow-menu-panel :global(details>summary:hover),.overflow-menu-panel :global(details>summary:focus-visible),.overflow-menu-panel :global(>button:hover),.overflow-menu-panel :global(>button:focus-visible){background:var(--shd-surface-active);color:var(--shd-orange)}.overflow-menu-panel :global(>button.danger:hover),.overflow-menu-panel :global(>button.danger:focus-visible){color:var(--shd-danger)}
@media(max-width:650px){.overflow-menu-panel{position:fixed;z-index:12;top:auto;right:12px;bottom:12px;left:12px;width:auto;padding:6px}.overflow-menu-panel :global(form>fieldset>button),.overflow-menu-panel :global(details>summary),.overflow-menu-panel :global(>button){min-height:48px}}
@media(prefers-reduced-motion:reduce){.overflow-menu-trigger{transition:none}}
</style>
+57 -11
View File
@@ -1,15 +1,61 @@
export const databaseCategories = [
{ slug: 'gear', label: 'Gear', description: 'Equipment slots, base health and armor, tiers, Brand Sets, and talents.' },
{ slug: 'weapons', label: 'Weapons', description: 'Weapon families, damage types, tier ranges, handling, talents, and bonuses.' },
{ slug: 'weapon-mods', label: 'Weapon Mods', description: 'Slot and weapon compatibility, quality rolls, secondary pools, and Mod Effects.' },
{ slug: 'gear-mods', label: 'Gear Mods', description: 'Quality-scaled primary rolls and selectable secondary attribute pools.' },
{ slug: 'os-protocols', label: 'OS Protocols', description: 'Quality, core profile, primary effect, and unique upgradeable talent.' },
{ slug: 'talents', label: 'Talents', description: 'Equipment and weapon talents with tiered descriptions and combat mechanics.' },
{ slug: 'brands', label: 'Brands', description: 'Brand Set identities and their two-, three-, and four-piece bonuses.' },
{ slug: 'skill-chips', label: 'Skill Chips', description: 'Set bonuses, primary and secondary pools, and quality-scaled unique talents.' },
{ slug: 'specializations', label: 'Specializations', description: 'Specialization identities, focus paths, signature abilities, and tree node progression.' }
{
slug: 'gear',
label: 'Gear',
description:
'Equipment slots, base health and armor, tiers, Brand Sets, and talents.',
},
{
slug: 'weapons',
label: 'Weapons',
description:
'Weapon families, damage types, tier ranges, handling, talents, and bonuses.',
},
{
slug: 'weapon-mods',
label: 'Weapon Mods',
description:
'Slot and weapon compatibility, quality rolls, secondary pools, and Mod Effects.',
},
{
slug: 'gear-mods',
label: 'Gear Mods',
description:
'Quality-scaled primary rolls and selectable secondary attribute pools.',
},
{
slug: 'os-protocols',
label: 'OS Protocols',
description:
'Quality, core profile, primary effect, and unique upgradeable talent.',
},
{
slug: 'talents',
label: 'Talents',
description:
'Equipment and weapon talents with tiered descriptions and combat mechanics.',
},
{
slug: 'brands',
label: 'Brands',
description:
'Brand Set identities and their two-, three-, and four-piece bonuses.',
},
{
slug: 'skill-chips',
label: 'Skill Chips',
description:
'Set bonuses, primary and secondary pools, and quality-scaled unique talents.',
},
{
slug: 'specializations',
label: 'Specializations',
description:
'Specialization identities, focus paths, signature abilities, and tree node progression.',
},
] as const;
export type DatabaseCategorySlug = typeof databaseCategories[number]['slug'];
export type DatabaseCategorySlug = (typeof databaseCategories)[number]['slug'];
export const databaseCategory = (slug: string) => databaseCategories.find((category) => category.slug === slug);
export const databaseCategory = (slug: string) =>
databaseCategories.find((category) => category.slug === slug);
+39 -33
View File
@@ -1,48 +1,54 @@
import type { GameIconName } from '$lib/game-icons';
import type { GameDataTargetType } from '$lib/game-data-targets';
export type DatabaseBadge = { label: string; tone?: string };
export type DatabaseMeter = { label: string; value: number };
export type DatabaseFact = {
label: string;
value: string;
emphasis?: 'primary';
gameIcon?: GameIconName;
note?: string;
meters?: DatabaseMeter[];
label: string;
value: string;
emphasis?: 'primary';
gameIcon?: GameIconName;
note?: string;
meters?: DatabaseMeter[];
};
export type DatabaseItem = {
id: string;
name: string;
kicker: string;
variant?: 'talent';
iconUrl?: string;
renderUrl?: string;
gameIcon?: GameIconName;
headerBadge?: string;
headerBadges?: string[];
headerMetaIcon?: GameIconName;
badges?: DatabaseBadge[];
facts: DatabaseFact[];
factLayout?: 'grid' | 'stack';
actions?: Array<{ label: string; href: string }>;
lines?: DatabaseFact[];
afterLines?: DatabaseFact[];
iconGroups?: DatabaseIconGroup[];
description?: string;
secondaryDescription?: string;
descriptionStyle?: 'prose' | 'data';
id: string;
targetType?: GameDataTargetType;
correctionHref?: string;
discussionHref?: string;
discussionCount?: number;
pinnedCommunityNoteHtml?: string;
name: string;
kicker: string;
variant?: 'talent';
iconUrl?: string;
renderUrl?: string;
gameIcon?: GameIconName;
headerBadge?: string;
headerBadges?: string[];
headerMetaIcon?: GameIconName;
badges?: DatabaseBadge[];
facts: DatabaseFact[];
factLayout?: 'grid' | 'stack';
actions?: Array<{ label: string; href: string }>;
lines?: DatabaseFact[];
afterLines?: DatabaseFact[];
iconGroups?: DatabaseIconGroup[];
description?: string;
secondaryDescription?: string;
descriptionStyle?: 'prose' | 'data';
};
export type DatabaseRelatedItem = {
id: string;
name: string;
iconUrl?: string;
preview?: DatabaseItem;
id: string;
name: string;
iconUrl?: string;
preview?: DatabaseItem;
};
export type DatabaseIconGroup = {
label: string;
items: DatabaseRelatedItem[];
emptyLabel?: string;
label: string;
items: DatabaseRelatedItem[];
emptyLabel?: string;
};
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
gameDataCorrectionHref,
gameDataDiscussionId,
gameDataTarget,
} from './game-data-targets';
describe('game-data public targets', () => {
it('maps every public category to its ISAC registry', () => {
expect(gameDataTarget('weapons')).toEqual({
type: 'weapon',
module: 'weapons',
});
expect(gameDataTarget('weapon-mods')).toEqual({
type: 'weapon_mod',
module: 'weaponmods',
});
expect(gameDataTarget('skill-chips')).toEqual({
type: 'skill_chip',
module: 'skillchips',
});
});
it('uses a stable exact-item correction link and discussion identity', () => {
expect(gameDataCorrectionHref('talents', 'talent id')).toBe(
'/isac/talents?item=talent%20id',
);
expect(gameDataDiscussionId('talent', 'talent-id')).toBe(
'talent:talent-id',
);
});
});
+41
View File
@@ -0,0 +1,41 @@
import type { DatabaseCategorySlug } from '$lib/database/categories';
export const gameDataTargetTypes = [
'gear',
'weapon',
'weapon_mod',
'gear_mod',
'os_protocol',
'talent',
'brand_set',
'skill_chip',
'specialization',
] as const;
export type GameDataTargetType = (typeof gameDataTargetTypes)[number];
const targets: Record<
DatabaseCategorySlug,
{ type: GameDataTargetType; module: string }
> = {
gear: { type: 'gear', module: 'gear' },
weapons: { type: 'weapon', module: 'weapons' },
'weapon-mods': { type: 'weapon_mod', module: 'weaponmods' },
'gear-mods': { type: 'gear_mod', module: 'gearmods' },
'os-protocols': { type: 'os_protocol', module: 'protocols' },
talents: { type: 'talent', module: 'talents' },
brands: { type: 'brand_set', module: 'brands' },
'skill-chips': { type: 'skill_chip', module: 'skillchips' },
specializations: { type: 'specialization', module: 'specializations' },
};
export const gameDataTarget = (category: DatabaseCategorySlug) =>
targets[category];
export const gameDataDiscussionId = (type: GameDataTargetType, id: string) =>
`${type}:${id}`;
export const gameDataCorrectionHref = (
category: DatabaseCategorySlug,
id: string,
) => `/isac/${targets[category].module}?item=${encodeURIComponent(id)}`;
+35 -26
View File
@@ -1,30 +1,39 @@
export const gameIcons = {
blast: { src: '/blast.png', label: 'Blast damage' },
pierce: { src: '/pierce.png', label: 'Pierce damage' },
shredding: { src: '/shredding.png', label: 'Shredding damage' },
firepower: { src: '/firepower.png', label: 'Firepower' },
toughness: { src: '/toughness.png', label: 'Toughness' },
engineering: { src: '/engineering.png', label: 'Engineering' },
bulwark: { src: '/bulwark.png', label: 'Bulwark specialization' },
combat_medic: { src: '/combat_medic.png', label: 'Combat Medic specialization' },
demolitionist: { src: '/demolitionist.png', label: 'Demolitionist specialization' },
tech_operator: { src: '/tech_operator.png', label: 'Tech Operator specialization' },
vanguard: { src: '/vanguard.png', label: 'Vanguard specialization' },
legendary: { src: '/legendary.png', label: 'Legendary difficulty' },
shd_ticket: { src: '/shd_ticket.png', label: 'SHD ticket' },
holster: { src: '/holster.png', label: 'Holster gear slot' },
gloves: { src: '/gloves.png', label: 'Gloves gear slot' },
backpack: { src: '/backpack.png', label: 'Backpack gear slot' },
knee_pads: { src: '/knee_pads.png', label: 'Knee Pads gear slot' },
body_armor: { src: '/body_armor.png', label: 'Body Armor gear slot' },
mask: { src: '/mask.png', label: 'Mask gear slot' },
exotic: { src: '/exotic.png', label: 'Exotic weapon' },
shotgun: { src: '/shotgun.png', label: 'Shotgun weapon category' },
rifle: { src: '/rifle.png', label: 'Rifle weapon category' },
mmr: { src: '/mmr.png', label: 'Marksman rifle weapon category' },
ar: { src: '/ar.png', label: 'Assault rifle weapon category' },
lmg: { src: '/lmg.png', label: 'Light machine gun weapon category' },
smg: { src: '/smg.png', label: 'Submachine gun weapon category' }
blast: { src: '/blast.png', label: 'Blast damage' },
pierce: { src: '/pierce.png', label: 'Pierce damage' },
shredding: { src: '/shredding.png', label: 'Shredding damage' },
firepower: { src: '/firepower.png', label: 'Firepower' },
toughness: { src: '/toughness.png', label: 'Toughness' },
engineering: { src: '/engineering.png', label: 'Engineering' },
bulwark: { src: '/bulwark.png', label: 'Bulwark specialization' },
combat_medic: {
src: '/combat_medic.png',
label: 'Combat Medic specialization',
},
demolitionist: {
src: '/demolitionist.png',
label: 'Demolitionist specialization',
},
tech_operator: {
src: '/tech_operator.png',
label: 'Tech Operator specialization',
},
vanguard: { src: '/vanguard.png', label: 'Vanguard specialization' },
legendary: { src: '/legendary.png', label: 'Legendary difficulty' },
shd_ticket: { src: '/shd_ticket.png', label: 'SHD ticket' },
holster: { src: '/holster.png', label: 'Holster gear slot' },
gloves: { src: '/gloves.png', label: 'Gloves gear slot' },
backpack: { src: '/backpack.png', label: 'Backpack gear slot' },
knee_pads: { src: '/knee_pads.png', label: 'Knee Pads gear slot' },
body_armor: { src: '/body_armor.png', label: 'Body Armor gear slot' },
mask: { src: '/mask.png', label: 'Mask gear slot' },
exotic: { src: '/exotic.png', label: 'Exotic weapon' },
shotgun: { src: '/shotgun.png', label: 'Shotgun weapon category' },
rifle: { src: '/rifle.png', label: 'Rifle weapon category' },
mmr: { src: '/mmr.png', label: 'Marksman rifle weapon category' },
ar: { src: '/ar.png', label: 'Assault rifle weapon category' },
lmg: { src: '/lmg.png', label: 'Light machine gun weapon category' },
smg: { src: '/smg.png', label: 'Submachine gun weapon category' },
} as const;
export type GameIconName = keyof typeof gameIcons;
+122 -96
View File
@@ -1,42 +1,54 @@
const escapeHtml = (value: string) => value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const escapeHtml = (value: string) =>
value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
function safeLink(raw: string) {
try {
const url = new URL(raw);
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
} catch {
return null;
}
try {
const url = new URL(raw);
return url.protocol === 'https:' || url.protocol === 'http:'
? url.toString()
: null;
} catch {
return null;
}
}
function inlineMarkdown(value: string) {
const code: string[] = [];
let output = escapeHtml(value).replace(/`([^`\r\n]+)`/g, (_match, body: string) => {
const token = `\u0000CODE${code.length}\u0000`;
code.push(`<code>${body}</code>`);
return token;
});
output = output.replace(/\[([^\]\r\n]{1,240})\]\(([^)\s]{1,2048})\)/g, (_match, label: string, href: string) => {
const decodedHref = href
.replaceAll('&amp;', '&')
.replaceAll('&#39;', "'")
.replaceAll('&quot;', '"');
const safe = safeLink(decodedHref);
return safe
? `<a href="${escapeHtml(safe)}" rel="nofollow ugc noopener noreferrer" target="_blank">${label}</a>`
: label;
});
output = output
.replace(/\*\*([^*\r\n]+)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_\r\n]+)__/g, '<strong>$1</strong>')
.replace(/(^|[^\w])\*([^*\r\n]+)\*/g, '$1<em>$2</em>')
.replace(/(^|[^\w])_([^_\r\n]+)_/g, '$1<em>$2</em>');
return output.replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) => code[Number(index)] ?? '');
const code: string[] = [];
let output = escapeHtml(value).replace(
/`([^`\r\n]+)`/g,
(_match, body: string) => {
const token = `\u0000CODE${code.length}\u0000`;
code.push(`<code>${body}</code>`);
return token;
},
);
output = output.replace(
/\[([^\]\r\n]{1,240})\]\(([^)\s]{1,2048})\)/g,
(_match, label: string, href: string) => {
const decodedHref = href
.replaceAll('&amp;', '&')
.replaceAll('&#39;', "'")
.replaceAll('&quot;', '"');
const safe = safeLink(decodedHref);
return safe
? `<a href="${escapeHtml(safe)}" rel="nofollow ugc noopener noreferrer" target="_blank">${label}</a>`
: label;
},
);
output = output
.replace(/\*\*([^*\r\n]+)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_\r\n]+)__/g, '<strong>$1</strong>')
.replace(/(^|[^\w])\*([^*\r\n]+)\*/g, '$1<em>$2</em>')
.replace(/(^|[^\w])_([^_\r\n]+)_/g, '$1<em>$2</em>');
return output.replace(
/\u0000CODE(\d+)\u0000/g,
(_match, index: string) => code[Number(index)] ?? '',
);
}
/**
@@ -45,70 +57,84 @@ function inlineMarkdown(value: string) {
* nofollow, and opener isolation attributes.
*/
export function renderUserMarkdown(markdown: unknown): string {
const input = String(markdown ?? '').normalize('NFKC').replace(/\r\n?/g, '\n').trim();
if (!input) return '';
const lines = input.split('\n');
const output: string[] = [];
let paragraph: string[] = [];
let list: 'ul' | 'ol' | null = null;
const input = String(markdown ?? '')
.normalize('NFKC')
.replace(/\r\n?/g, '\n')
.trim();
if (!input) return '';
const lines = input.split('\n');
const output: string[] = [];
let paragraph: string[] = [];
let list: 'ul' | 'ol' | null = null;
const closeParagraph = () => {
if (!paragraph.length) return;
output.push(`<p>${inlineMarkdown(paragraph.join(' '))}</p>`);
paragraph = [];
};
const closeList = () => {
if (!list) return;
output.push(`</${list}>`);
list = null;
};
const closeParagraph = () => {
if (!paragraph.length) return;
output.push(`<p>${inlineMarkdown(paragraph.join(' '))}</p>`);
paragraph = [];
};
const closeList = () => {
if (!list) return;
output.push(`</${list}>`);
list = null;
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
closeParagraph();
closeList();
continue;
}
const heading = /^(#{1,3})\s+(.+)$/.exec(trimmed);
if (heading) {
closeParagraph();
closeList();
const level = heading[1].length + 2;
output.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
continue;
}
const unordered = /^[-*]\s+(.+)$/.exec(trimmed);
const ordered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
if (unordered || ordered) {
closeParagraph();
const nextList = unordered ? 'ul' : 'ol';
if (list !== nextList) {
closeList();
list = nextList;
output.push(`<${list}>`);
}
output.push(`<li>${inlineMarkdown((unordered ?? ordered)?.[1] ?? '')}</li>`);
continue;
}
const quote = /^>\s?(.+)$/.exec(trimmed);
if (quote) {
closeParagraph();
closeList();
output.push(`<blockquote><p>${inlineMarkdown(quote[1])}</p></blockquote>`);
continue;
}
closeList();
paragraph.push(trimmed);
}
closeParagraph();
closeList();
return output.join('');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
closeParagraph();
closeList();
continue;
}
const heading = /^(#{1,3})\s+(.+)$/.exec(trimmed);
if (heading) {
closeParagraph();
closeList();
const level = heading[1].length + 2;
output.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
continue;
}
const unordered = /^[-*]\s+(.+)$/.exec(trimmed);
const ordered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
if (unordered || ordered) {
closeParagraph();
const nextList = unordered ? 'ul' : 'ol';
if (list !== nextList) {
closeList();
list = nextList;
output.push(`<${list}>`);
}
output.push(
`<li>${inlineMarkdown((unordered ?? ordered)?.[1] ?? '')}</li>`,
);
continue;
}
const quote = /^>\s?(.+)$/.exec(trimmed);
if (quote) {
closeParagraph();
closeList();
output.push(
`<blockquote><p>${inlineMarkdown(quote[1])}</p></blockquote>`,
);
continue;
}
closeList();
paragraph.push(trimmed);
}
closeParagraph();
closeList();
return output.join('');
}
export function normalizeUserMarkdown(markdown: unknown, maxCharacters: number) {
const normalized = String(markdown ?? '').normalize('NFKC').replace(/\r\n?/g, '\n').trim();
if (Array.from(normalized).length > maxCharacters) return null;
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(normalized)) return null;
return normalized;
export function normalizeUserMarkdown(
markdown: unknown,
maxCharacters: number,
) {
const normalized = String(markdown ?? '')
.normalize('NFKC')
.replace(/\r\n?/g, '\n')
.trim();
if (Array.from(normalized).length > maxCharacters) return null;
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(normalized))
return null;
return normalized;
}
+88 -49
View File
@@ -1,67 +1,106 @@
import { AVATAR_PRESETS } from '$lib/server/avatars';
import { emailDeliveryConfigured } from '$lib/server/auth/mail';
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
import {
canRemoveAuthenticationMethod,
getAuthenticationMethods,
} from '$lib/server/auth/methods';
import type { Database } from '@tursodatabase/database';
export async function loadAccountControlData(
database: Database,
userId: string,
requestedPanel: string | null = null
database: Database,
userId: string,
requestedPanel: string | null = null,
) {
const [connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread] =
await Promise.all([
(await database.prepare(`SELECT users.discord_id,users.login_username,users.password_hash,
const [
connection,
emailIdentity,
passkeys,
authenticationMethods,
notifications,
notificationUnread,
] = await Promise.all([
(
await database.prepare(`SELECT users.discord_id,users.login_username,users.password_hash,
discord_profiles.username AS discord_username,discord_profiles.display_name,
discord_profiles.avatar_hash
FROM users
LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id
WHERE users.id=?`)).get(userId),
(await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(userId),
(await database.prepare(`SELECT id,name,device_type,backed_up,last_used_at,created_at
FROM passkeys WHERE user_id=? ORDER BY created_at DESC`)).all(userId),
getAuthenticationMethods(database, userId),
(await database.prepare(`SELECT n.id,n.category,n.source,n.title,n.body,n.link,n.mandatory,
WHERE users.id=?`)
).get(userId),
(
await database.prepare(
'SELECT email,verified_at FROM user_emails WHERE user_id=?',
)
).get(userId),
(
await database.prepare(`SELECT id,name,device_type,backed_up,last_used_at,created_at
FROM passkeys WHERE user_id=? ORDER BY created_at DESC`)
).all(userId),
getAuthenticationMethods(database, userId),
(
await database.prepare(`SELECT n.id,n.category,n.source,n.title,n.body,n.link,n.mandatory,
n.created_at,n.read_at
FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL
AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)
ORDER BY n.created_at DESC LIMIT 5`)).all(userId),
(await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
ORDER BY n.created_at DESC LIMIT 5`)
).all(userId),
(
await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL
AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(userId)
]);
return {
initialPanel: ['identity', 'access', 'account'].includes(String(requestedPanel))
? String(requestedPanel)
: 'identity',
loginUsername: connection?.login_username ? String(connection.login_username) : null,
hasLocalCredentials: typeof connection?.password_hash === 'string'
&& typeof connection?.login_username === 'string',
discordConnection: connection?.discord_id ? {
id: String(connection.discord_id),
username: connection.discord_username ? String(connection.discord_username) : null,
displayName: connection.display_name ? String(connection.display_name) : null,
avatarHash: connection.avatar_hash ? String(connection.avatar_hash) : null,
canUnlink: canRemoveAuthenticationMethod(authenticationMethods, 'discord')
} : null,
emailIdentity: emailIdentity ? {
email: String(emailIdentity.email),
verifiedAt: String(emailIdentity.verified_at)
} : null,
passkeys: passkeys.map((passkey) => ({
id: String(passkey.id),
name: String(passkey.name),
deviceType: String(passkey.device_type),
backedUp: Boolean(passkey.backed_up),
lastUsedAt: passkey.last_used_at ? String(passkey.last_used_at) : null,
createdAt: String(passkey.created_at)
})),
authenticationMethods,
emailDeliveryConfigured: emailDeliveryConfigured(),
avatarPresets: AVATAR_PRESETS,
notifications,
notificationUnread: Number(notificationUnread?.count ?? 0)
};
AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)
).get(userId),
]);
return {
initialPanel: ['identity', 'access', 'account'].includes(
String(requestedPanel),
)
? String(requestedPanel)
: 'identity',
loginUsername: connection?.login_username
? String(connection.login_username)
: null,
hasLocalCredentials:
typeof connection?.password_hash === 'string' &&
typeof connection?.login_username === 'string',
discordConnection: connection?.discord_id
? {
id: String(connection.discord_id),
username: connection.discord_username
? String(connection.discord_username)
: null,
displayName: connection.display_name
? String(connection.display_name)
: null,
avatarHash: connection.avatar_hash
? String(connection.avatar_hash)
: null,
canUnlink: canRemoveAuthenticationMethod(
authenticationMethods,
'discord',
),
}
: null,
emailIdentity: emailIdentity
? {
email: String(emailIdentity.email),
verifiedAt: String(emailIdentity.verified_at),
}
: null,
passkeys: passkeys.map((passkey) => ({
id: String(passkey.id),
name: String(passkey.name),
deviceType: String(passkey.device_type),
backedUp: Boolean(passkey.backed_up),
lastUsedAt: passkey.last_used_at ? String(passkey.last_used_at) : null,
createdAt: String(passkey.created_at),
})),
authenticationMethods,
emailDeliveryConfigured: emailDeliveryConfigured(),
avatarPresets: AVATAR_PRESETS,
notifications,
notificationUnread: Number(notificationUnread?.count ?? 0),
};
}
+25 -19
View File
@@ -1,40 +1,46 @@
import { connect } from '@tursodatabase/database';
import { describe, expect, it } from 'vitest';
import {
createAvailableDisplayName,
displayNameIsAvailable
createAvailableDisplayName,
displayNameIsAvailable,
} from './agent-profiles.js';
import { displayNameKey } from '../agents.js';
describe('public profile identity allocation', () => {
it('allocates the next available Agent number', async () => {
const database = await connect(':memory:');
await database.exec(`CREATE TABLE user_profiles (
it('allocates the next available Agent number', async () => {
const database = await connect(':memory:');
await database.exec(`CREATE TABLE user_profiles (
user_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
display_name_key TEXT UNIQUE,
public_slug TEXT
)`);
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key) VALUES(?,?,?)`))
.run('existing', 'Agent 47', displayNameKey('Agent 47'));
await (
await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key) VALUES(?,?,?)`)
).run('existing', 'Agent 47', displayNameKey('Agent 47'));
expect(await createAvailableDisplayName(database, 'Agent', 'new-user', true)).toBe('Agent 48');
});
expect(
await createAvailableDisplayName(database, 'Agent', 'new-user', true),
).toBe('Agent 48');
});
it('compares display names through the normalized Unicode key', async () => {
const database = await connect(':memory:');
await database.exec(`CREATE TABLE user_profiles (
it('compares display names through the normalized Unicode key', async () => {
const database = await connect(':memory:');
await database.exec(`CREATE TABLE user_profiles (
user_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
display_name_key TEXT UNIQUE,
public_slug TEXT
)`);
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key) VALUES(?,?,?)`))
.run('existing', 'Élite', displayNameKey('Élite'));
await (
await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key) VALUES(?,?,?)`)
).run('existing', 'Élite', displayNameKey('Élite'));
expect(await displayNameIsAvailable(database, 'E\u0301lite')).toBe(false);
expect(await displayNameIsAvailable(database, 'Élite', 'existing')).toBe(true);
});
expect(await displayNameIsAvailable(database, 'E\u0301lite')).toBe(false);
expect(await displayNameIsAvailable(database, 'Élite', 'existing')).toBe(
true,
);
});
});
+94 -62
View File
@@ -1,90 +1,122 @@
import type { Database } from '@tursodatabase/database';
import {
displayNameKey,
newAgentDisplayName,
publicProfileSlugBase
displayNameKey,
newAgentDisplayName,
publicProfileSlugBase,
} from '$lib/agents';
export async function displayNameIsAvailable(
database: Database,
displayName: string,
exceptUserId = ''
database: Database,
displayName: string,
exceptUserId = '',
): Promise<boolean> {
const row = await (await database.prepare(
'SELECT user_id FROM user_profiles WHERE display_name_key=? AND user_id<>?'
)).get(displayNameKey(displayName), exceptUserId);
return !row;
const row = await (
await database.prepare(
'SELECT user_id FROM user_profiles WHERE display_name_key=? AND user_id<>?',
)
).get(displayNameKey(displayName), exceptUserId);
return !row;
}
export async function createAvailableDisplayName(
database: Database,
preferredName: string,
userId: string,
placeholder = false
database: Database,
preferredName: string,
userId: string,
placeholder = false,
): Promise<string> {
if (!placeholder && await displayNameIsAvailable(database, preferredName)) return preferredName;
if (placeholder) {
const names = await (await database.prepare(
"SELECT display_name FROM user_profiles WHERE display_name LIKE 'Agent %'"
)).all();
let sequence = names.reduce((highest, row) => {
const match = /^Agent ([1-9]\d*)$/.exec(String(row.display_name));
return match ? Math.max(highest, Number(match[1])) : highest;
}, 0) + 1;
while (!(await displayNameIsAvailable(database, newAgentDisplayName(sequence)))) sequence += 1;
return newAgentDisplayName(sequence);
}
for (let suffixLength = 5; suffixLength <= 12; suffixLength += 1) {
const candidate = `${preferredName} ${userId.replaceAll('-', '').slice(0, suffixLength).toLowerCase()}`;
if (await displayNameIsAvailable(database, candidate)) return candidate;
}
throw new Error('Unable to allocate a unique display name.');
if (!placeholder && (await displayNameIsAvailable(database, preferredName)))
return preferredName;
if (placeholder) {
const names = await (
await database.prepare(
"SELECT display_name FROM user_profiles WHERE display_name LIKE 'Agent %'",
)
).all();
let sequence =
names.reduce((highest, row) => {
const match = /^Agent ([1-9]\d*)$/.exec(String(row.display_name));
return match ? Math.max(highest, Number(match[1])) : highest;
}, 0) + 1;
while (
!(await displayNameIsAvailable(database, newAgentDisplayName(sequence)))
)
sequence += 1;
return newAgentDisplayName(sequence);
}
for (let suffixLength = 5; suffixLength <= 12; suffixLength += 1) {
const candidate = `${preferredName} ${userId.replaceAll('-', '').slice(0, suffixLength).toLowerCase()}`;
if (await displayNameIsAvailable(database, candidate)) return candidate;
}
throw new Error('Unable to allocate a unique display name.');
}
export async function createPublicProfileSlug(
database: Database,
displayName: string,
userId: string
database: Database,
displayName: string,
userId: string,
): Promise<string> {
const base = publicProfileSlugBase(displayName, userId);
const existing = await (await database.prepare(
'SELECT user_id FROM user_profiles WHERE public_slug=?'
)).get(base);
if (!existing || String(existing.user_id) === userId) return base;
return `${base.slice(0, 55).replace(/-+$/g, '')}~${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
const base = publicProfileSlugBase(displayName, userId);
const existing = await (
await database.prepare(
'SELECT user_id FROM user_profiles WHERE public_slug=?',
)
).get(base);
if (!existing || String(existing.user_id) === userId) return base;
return `${base.slice(0, 55).replace(/-+$/g, '')}~${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
}
export async function insertUserProfile(
database: Database,
userId: string,
preferredName: string,
updatedAt: string,
options: { placeholder?: boolean; resolveCollision?: boolean } = {}
database: Database,
userId: string,
preferredName: string,
updatedAt: string,
options: { placeholder?: boolean; resolveCollision?: boolean } = {},
): Promise<{ displayName: string; publicSlug: string }> {
const displayName = options.placeholder || options.resolveCollision
? await createAvailableDisplayName(database, preferredName, userId, Boolean(options.placeholder))
: preferredName;
const publicSlug = await createPublicProfileSlug(database, displayName, userId);
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key,public_slug,updated_at) VALUES(?,?,?,?,?)`))
.run(userId, displayName, displayNameKey(displayName), publicSlug, updatedAt);
return { displayName, publicSlug };
const displayName =
options.placeholder || options.resolveCollision
? await createAvailableDisplayName(
database,
preferredName,
userId,
Boolean(options.placeholder),
)
: preferredName;
const publicSlug = await createPublicProfileSlug(
database,
displayName,
userId,
);
await (
await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key,public_slug,updated_at) VALUES(?,?,?,?,?)`)
).run(
userId,
displayName,
displayNameKey(displayName),
publicSlug,
updatedAt,
);
return { displayName, publicSlug };
}
export async function resolveAgentProfile(
database: Database,
identifier: string
database: Database,
identifier: string,
): Promise<{ id: string; publicSlug: string; displayName: string } | null> {
const row = await (await database.prepare(`SELECT users.id,
const row = await (
await database.prepare(`SELECT users.id,
COALESCE(profile.public_slug,users.id) AS public_slug,
COALESCE(profile.display_name,users.username) AS display_name
FROM users LEFT JOIN user_profiles profile ON profile.user_id=users.id
WHERE users.id=? OR profile.public_slug=?
ORDER BY CASE WHEN profile.public_slug=? THEN 0 ELSE 1 END
LIMIT 1`)).get(identifier, identifier, identifier);
return row ? {
id: String(row.id),
publicSlug: String(row.public_slug),
displayName: String(row.display_name)
} : null;
LIMIT 1`)
).get(identifier, identifier, identifier);
return row
? {
id: String(row.id),
publicSlug: String(row.public_slug),
displayName: String(row.display_name),
}
: null;
}
@@ -1,7 +1,7 @@
import {
AI_CALCULATION_STAGE_OPERATORS,
AI_SKILL_CALCULATION_STAGES,
type AiCalculationAuthoringRequest
AI_CALCULATION_STAGE_OPERATORS,
AI_SKILL_CALCULATION_STAGES,
type AiCalculationAuthoringRequest,
} from '$lib/ai/calculation-authoring';
import type { AiMechanicAttribute } from './mechanic-authoring-context';
@@ -32,28 +32,29 @@ Response rules:
The response is validated against the same constraints as manual ISAC revision saving and is never saved automatically.`;
export function buildAiCalculationAuthoringInput(input: {
request: AiCalculationAuthoringRequest;
attributes: AiMechanicAttribute[];
request: AiCalculationAuthoringRequest;
attributes: AiMechanicAttribute[];
}) {
return JSON.stringify({
task: 'Revise the current typed weapon and Skill combat calculation draft.',
requestedChanges: input.request.instructions,
baseRevisionId: input.request.baseRevisionId,
clarificationTranscript: input.request.transcript,
currentDraft: input.request.currentDraft,
catalog: {
weaponStageOperators: AI_CALCULATION_STAGE_OPERATORS,
curves: ['none', 'power', 'smoothstep'],
skillStages: AI_SKILL_CALCULATION_STAGES,
skillProfileOperators: ['add', 'multiply'],
attributes: input.attributes
},
constraints: {
stageCount: { min: 2, max: 30 },
profileWeight: { min: -100, max: 100 },
softAndHardCaps: 'null or non-negative; hard cap cannot be below soft cap',
maxWeaponProfiles: 2_000,
maxSkillProfiles: 2_000
}
});
return JSON.stringify({
task: 'Revise the current typed weapon and Skill combat calculation draft.',
requestedChanges: input.request.instructions,
baseRevisionId: input.request.baseRevisionId,
clarificationTranscript: input.request.transcript,
currentDraft: input.request.currentDraft,
catalog: {
weaponStageOperators: AI_CALCULATION_STAGE_OPERATORS,
curves: ['none', 'power', 'smoothstep'],
skillStages: AI_SKILL_CALCULATION_STAGES,
skillProfileOperators: ['add', 'multiply'],
attributes: input.attributes,
},
constraints: {
stageCount: { min: 2, max: 30 },
profileWeight: { min: -100, max: 100 },
softAndHardCaps:
'null or non-negative; hard cap cannot be below soft cap',
maxWeaponProfiles: 2_000,
maxSkillProfiles: 2_000,
},
});
}
@@ -1,85 +1,105 @@
import { describe, expect, it } from 'vitest';
import {
parseAiCalculationAuthoringRequest,
validateAiCalculationRevisionDraft
parseAiCalculationAuthoringRequest,
validateAiCalculationRevisionDraft,
} from './calculation-authoring-schema';
const attributes = new Set(['weapon-damage', 'skill-damage']);
const draft = {
name: 'Combat model 2',
notes: 'Drafted from field observations.',
stages: [
{
stageKey: 'base',
label: 'Base damage',
operator: 'base_damage',
displayOrder: 1,
lockedPosition: 'first'
},
{
stageKey: 'additive',
label: 'Additive damage',
operator: 'additive_percent',
displayOrder: 2,
lockedPosition: null
},
{
stageKey: 'cycle',
label: 'Firing cycle',
operator: 'firing_cycle',
displayOrder: 3,
lockedPosition: 'last'
}
],
profiles: [{
statAttributeId: 'weapon-damage',
stageKey: 'additive',
weight: 1,
softCap: null,
hardCap: null,
curveType: 'none',
curveParameter: null,
weaponCategory: '*'
}],
skillProfiles: [{
statAttributeId: 'skill-damage',
stageKey: 'skill_damage',
operator: 'add',
weight: 1
}]
name: 'Combat model 2',
notes: 'Drafted from field observations.',
stages: [
{
stageKey: 'base',
label: 'Base damage',
operator: 'base_damage',
displayOrder: 1,
lockedPosition: 'first',
},
{
stageKey: 'additive',
label: 'Additive damage',
operator: 'additive_percent',
displayOrder: 2,
lockedPosition: null,
},
{
stageKey: 'cycle',
label: 'Firing cycle',
operator: 'firing_cycle',
displayOrder: 3,
lockedPosition: 'last',
},
],
profiles: [
{
statAttributeId: 'weapon-damage',
stageKey: 'additive',
weight: 1,
softCap: null,
hardCap: null,
curveType: 'none',
curveParameter: null,
weaponCategory: '*',
},
],
skillProfiles: [
{
statAttributeId: 'skill-damage',
stageKey: 'skill_damage',
operator: 'add',
weight: 1,
},
],
};
describe('AI calculation authoring validation', () => {
it('accepts a calculation revision accepted by the manual save constraints', () => {
expect(validateAiCalculationRevisionDraft(draft, attributes)).toEqual(draft);
});
it('accepts a calculation revision accepted by the manual save constraints', () => {
expect(validateAiCalculationRevisionDraft(draft, attributes)).toEqual(
draft,
);
});
it('requires base damage first and firing cycle last', () => {
expect(() => validateAiCalculationRevisionDraft({
...draft,
stages: draft.stages.map((stage) => ({
...stage,
lockedPosition: null
}))
}, attributes)).toThrow('Base damage must remain first');
});
it('requires base damage first and firing cycle last', () => {
expect(() =>
validateAiCalculationRevisionDraft(
{
...draft,
stages: draft.stages.map((stage) => ({
...stage,
lockedPosition: null,
})),
},
attributes,
),
).toThrow('Base damage must remain first');
});
it('rejects unavailable Attributes', () => {
expect(() => validateAiCalculationRevisionDraft({
...draft,
profiles: [{ ...draft.profiles[0], statAttributeId: 'invented-attribute' }]
}, attributes)).toThrow('Weapon Attribute profile 1 is invalid.');
});
it('rejects unavailable Attributes', () => {
expect(() =>
validateAiCalculationRevisionDraft(
{
...draft,
profiles: [
{ ...draft.profiles[0], statAttributeId: 'invented-attribute' },
],
},
attributes,
),
).toThrow('Weapon Attribute profile 1 is invalid.');
});
it('bounds the stateless clarification transcript', () => {
expect(() => parseAiCalculationAuthoringRequest({
instructions: 'Add Weapon Damage to the additive stage.',
currentDraft: draft,
transcript: Array.from({ length: 9 }, (_, index) => ({
questionId: String(index),
question: 'Question',
answer: 'Answer'
}))
})).toThrow('Clarification history may contain at most 8 answers.');
});
it('bounds the stateless clarification transcript', () => {
expect(() =>
parseAiCalculationAuthoringRequest({
instructions: 'Add Weapon Damage to the additive stage.',
currentDraft: draft,
transcript: Array.from({ length: 9 }, (_, index) => ({
questionId: String(index),
question: 'Question',
answer: 'Answer',
})),
}),
).toThrow('Clarification history may contain at most 8 answers.');
});
});
+462 -313
View File
@@ -1,10 +1,10 @@
import {
AI_CALCULATION_STAGE_OPERATORS,
AI_SKILL_CALCULATION_STAGES,
type AiCalculationAuthoringRequest,
type AiCalculationCapabilityGap,
type AiCalculationQuestion,
type AiCalculationRevisionDraft
AI_CALCULATION_STAGE_OPERATORS,
AI_SKILL_CALCULATION_STAGES,
type AiCalculationAuthoringRequest,
type AiCalculationCapabilityGap,
type AiCalculationQuestion,
type AiCalculationRevisionDraft,
} from '$lib/ai/calculation-authoring';
const MAX_INSTRUCTIONS = 6_000;
@@ -18,338 +18,487 @@ const curves = new Set(['none', 'power', 'smoothstep']);
type ObjectValue = Record<string, unknown>;
const object = (value: unknown): value is ObjectValue =>
typeof value === 'object' && value !== null && !Array.isArray(value);
typeof value === 'object' && value !== null && !Array.isArray(value);
const finite = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
typeof value === 'number' && Number.isFinite(value);
const text = (value: unknown, label: string, max: number) => {
if (typeof value !== 'string' || !value.trim()) {
throw new AiCalculationRequestError(`${label} is required.`);
}
if (value.trim().length > max) throw new AiCalculationRequestError(`${label} is too long.`);
return value.trim();
if (typeof value !== 'string' || !value.trim()) {
throw new AiCalculationRequestError(`${label} is required.`);
}
if (value.trim().length > max)
throw new AiCalculationRequestError(`${label} is too long.`);
return value.trim();
};
const optionalNumber = (value: unknown) => value === null ? null : Number(value);
const optionalNumber = (value: unknown) =>
value === null ? null : Number(value);
export class AiCalculationRequestError extends Error {
status: number;
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = 'AiCalculationRequestError';
this.status = status;
}
constructor(message: string, status = 400) {
super(message);
this.name = 'AiCalculationRequestError';
this.status = status;
}
}
export type RawAiCalculationModelResponse = {
status: 'clarification_required' | 'draft_ready' | 'capability_gap';
summary: string;
assumptions: string[];
warnings: string[];
questions: AiCalculationQuestion[];
draftJson: string | null;
gaps: AiCalculationCapabilityGap[];
status: 'clarification_required' | 'draft_ready' | 'capability_gap';
summary: string;
assumptions: string[];
warnings: string[];
questions: AiCalculationQuestion[];
draftJson: string | null;
gaps: AiCalculationCapabilityGap[];
};
export function parseAiCalculationAuthoringRequest(value: unknown): AiCalculationAuthoringRequest {
if (!object(value)) throw new AiCalculationRequestError('Calculation authoring input is required.');
if (!object(value.currentDraft)) {
throw new AiCalculationRequestError('The current calculation revision is required.');
}
if (JSON.stringify(value.currentDraft).length > MAX_DRAFT_JSON) {
throw new AiCalculationRequestError('The current calculation revision is too large.');
}
if (!Array.isArray(value.transcript) || value.transcript.length > MAX_TRANSCRIPT) {
throw new AiCalculationRequestError(
`Clarification history may contain at most ${MAX_TRANSCRIPT} answers.`
);
}
return {
instructions: text(value.instructions, 'Calculation change request', MAX_INSTRUCTIONS),
baseRevisionId: value.baseRevisionId === null || value.baseRevisionId === undefined
? null
: text(value.baseRevisionId, 'Base revision identifier', 160),
currentDraft: value.currentDraft as AiCalculationRevisionDraft,
transcript: value.transcript.map((entry, index) => {
if (!object(entry)) {
throw new AiCalculationRequestError(`Clarification answer ${index + 1} is invalid.`);
}
return {
questionId: text(entry.questionId, `Clarification answer ${index + 1} identifier`, 120),
question: text(entry.question, `Clarification answer ${index + 1} question`, MAX_TRANSCRIPT_FIELD),
answer: text(entry.answer, `Clarification answer ${index + 1}`, MAX_TRANSCRIPT_FIELD)
};
})
};
export function parseAiCalculationAuthoringRequest(
value: unknown,
): AiCalculationAuthoringRequest {
if (!object(value))
throw new AiCalculationRequestError(
'Calculation authoring input is required.',
);
if (!object(value.currentDraft)) {
throw new AiCalculationRequestError(
'The current calculation revision is required.',
);
}
if (JSON.stringify(value.currentDraft).length > MAX_DRAFT_JSON) {
throw new AiCalculationRequestError(
'The current calculation revision is too large.',
);
}
if (
!Array.isArray(value.transcript) ||
value.transcript.length > MAX_TRANSCRIPT
) {
throw new AiCalculationRequestError(
`Clarification history may contain at most ${MAX_TRANSCRIPT} answers.`,
);
}
return {
instructions: text(
value.instructions,
'Calculation change request',
MAX_INSTRUCTIONS,
),
baseRevisionId:
value.baseRevisionId === null || value.baseRevisionId === undefined
? null
: text(value.baseRevisionId, 'Base revision identifier', 160),
currentDraft: value.currentDraft as AiCalculationRevisionDraft,
transcript: value.transcript.map((entry, index) => {
if (!object(entry)) {
throw new AiCalculationRequestError(
`Clarification answer ${index + 1} is invalid.`,
);
}
return {
questionId: text(
entry.questionId,
`Clarification answer ${index + 1} identifier`,
120,
),
question: text(
entry.question,
`Clarification answer ${index + 1} question`,
MAX_TRANSCRIPT_FIELD,
),
answer: text(
entry.answer,
`Clarification answer ${index + 1}`,
MAX_TRANSCRIPT_FIELD,
),
};
}),
};
}
export function validateAiCalculationRevisionDraft(
value: unknown,
allowedAttributeIds: ReadonlySet<string>
value: unknown,
allowedAttributeIds: ReadonlySet<string>,
): AiCalculationRevisionDraft {
if (!object(value)) throw new Error('The calculation revision must be an object.');
const name = text(value.name, 'Revision name', 120);
if (name.length < 3) throw new Error('Revision names must be 3–120 characters.');
const notes = typeof value.notes === 'string' ? value.notes.trim() : '';
if (notes.length > 4_000) throw new Error('Revision notes may contain at most 4,000 characters.');
if (!Array.isArray(value.stages) || value.stages.length < 2 || value.stages.length > 30) {
throw new Error('Calculation revisions require between 2 and 30 stages.');
}
const stages = value.stages.map((stage, index) => {
if (!object(stage)) throw new Error(`Calculation stage ${index + 1} is invalid.`);
const stageKey = text(stage.stageKey, `Calculation stage ${index + 1} key`, 120);
const label = text(stage.label, `Calculation stage ${index + 1} label`, 160);
if (label.length < 2 || !stageOperators.has(String(stage.operator))) {
throw new Error(`Calculation stage ${index + 1} has an unsupported label or operator.`);
}
if (!Number.isInteger(stage.displayOrder) || Number(stage.displayOrder) < 1) {
throw new Error(`Calculation stage ${index + 1} requires a positive integer order.`);
}
if (
stage.lockedPosition !== null
&& stage.lockedPosition !== 'first'
&& stage.lockedPosition !== 'last'
) {
throw new Error(`Calculation stage ${index + 1} has an invalid locked position.`);
}
return {
stageKey,
label,
operator: stage.operator as AiCalculationRevisionDraft['stages'][number]['operator'],
displayOrder: Number(stage.displayOrder),
lockedPosition: stage.lockedPosition as 'first' | 'last' | null
};
});
if (new Set(stages.map((stage) => stage.stageKey)).size !== stages.length) {
throw new Error('Every calculation stage requires a unique key.');
}
if (new Set(stages.map((stage) => stage.displayOrder)).size !== stages.length) {
throw new Error('Every calculation stage requires a unique order.');
}
const ordered = [...stages].sort((left, right) => left.displayOrder - right.displayOrder);
if (
ordered[0].operator !== 'base_damage'
|| ordered[0].lockedPosition !== 'first'
|| ordered.at(-1)?.operator !== 'firing_cycle'
|| ordered.at(-1)?.lockedPosition !== 'last'
) {
throw new Error('Base damage must remain first and the firing cycle must remain last.');
}
const stageKeys = new Set(stages.map((stage) => stage.stageKey));
if (!Array.isArray(value.profiles) || value.profiles.length > 2_000) {
throw new Error('The calculation revision has too many weapon Attribute profiles.');
}
const profiles = value.profiles.map((profile, index) => {
if (!object(profile)) throw new Error(`Weapon Attribute profile ${index + 1} is invalid.`);
const statAttributeId = text(
profile.statAttributeId,
`Weapon Attribute profile ${index + 1} Attribute`,
160
);
const stageKey = text(profile.stageKey, `Weapon Attribute profile ${index + 1} stage`, 120);
const softCap = optionalNumber(profile.softCap);
const hardCap = optionalNumber(profile.hardCap);
const curveParameter = optionalNumber(profile.curveParameter);
if (
!allowedAttributeIds.has(statAttributeId)
|| !stageKeys.has(stageKey)
|| !finite(profile.weight)
|| profile.weight < -100
|| profile.weight > 100
|| (softCap !== null && (!Number.isFinite(softCap) || softCap < 0))
|| (hardCap !== null && (!Number.isFinite(hardCap) || hardCap < 0))
|| (softCap !== null && hardCap !== null && hardCap < softCap)
|| !curves.has(String(profile.curveType))
|| (curveParameter !== null && !Number.isFinite(curveParameter))
) {
throw new Error(`Weapon Attribute profile ${index + 1} is invalid.`);
}
return {
statAttributeId,
stageKey,
weight: Number(profile.weight),
softCap,
hardCap,
curveType: profile.curveType as 'none' | 'power' | 'smoothstep',
curveParameter,
weaponCategory: typeof profile.weaponCategory === 'string' && profile.weaponCategory.trim()
? profile.weaponCategory.trim().slice(0, 120)
: '*'
};
});
if (!Array.isArray(value.skillProfiles) || value.skillProfiles.length > 2_000) {
throw new Error('The calculation revision has too many Skill Attribute profiles.');
}
const skillProfiles = value.skillProfiles.map((profile, index) => {
if (!object(profile)) throw new Error(`Skill Attribute profile ${index + 1} is invalid.`);
const statAttributeId = text(
profile.statAttributeId,
`Skill Attribute profile ${index + 1} Attribute`,
160
);
if (
!allowedAttributeIds.has(statAttributeId)
|| !skillStages.has(String(profile.stageKey))
|| !skillOperators.has(String(profile.operator))
|| !finite(profile.weight)
|| profile.weight < -100
|| profile.weight > 100
) {
throw new Error(`Skill Attribute profile ${index + 1} is invalid.`);
}
return {
statAttributeId,
stageKey: profile.stageKey as AiCalculationRevisionDraft['skillProfiles'][number]['stageKey'],
operator: profile.operator as 'add' | 'multiply',
weight: Number(profile.weight)
};
});
if (
new Set(skillProfiles.map((profile) => `${profile.statAttributeId}\u0000${profile.stageKey}`))
.size !== skillProfiles.length
) {
throw new Error('Skill Attribute profiles cannot duplicate an Attribute and stage pair.');
}
return { name, notes, stages, profiles, skillProfiles };
if (!object(value))
throw new Error('The calculation revision must be an object.');
const name = text(value.name, 'Revision name', 120);
if (name.length < 3)
throw new Error('Revision names must be 3–120 characters.');
const notes = typeof value.notes === 'string' ? value.notes.trim() : '';
if (notes.length > 4_000)
throw new Error('Revision notes may contain at most 4,000 characters.');
if (
!Array.isArray(value.stages) ||
value.stages.length < 2 ||
value.stages.length > 30
) {
throw new Error('Calculation revisions require between 2 and 30 stages.');
}
const stages = value.stages.map((stage, index) => {
if (!object(stage))
throw new Error(`Calculation stage ${index + 1} is invalid.`);
const stageKey = text(
stage.stageKey,
`Calculation stage ${index + 1} key`,
120,
);
const label = text(
stage.label,
`Calculation stage ${index + 1} label`,
160,
);
if (label.length < 2 || !stageOperators.has(String(stage.operator))) {
throw new Error(
`Calculation stage ${index + 1} has an unsupported label or operator.`,
);
}
if (
!Number.isInteger(stage.displayOrder) ||
Number(stage.displayOrder) < 1
) {
throw new Error(
`Calculation stage ${index + 1} requires a positive integer order.`,
);
}
if (
stage.lockedPosition !== null &&
stage.lockedPosition !== 'first' &&
stage.lockedPosition !== 'last'
) {
throw new Error(
`Calculation stage ${index + 1} has an invalid locked position.`,
);
}
return {
stageKey,
label,
operator:
stage.operator as AiCalculationRevisionDraft['stages'][number]['operator'],
displayOrder: Number(stage.displayOrder),
lockedPosition: stage.lockedPosition as 'first' | 'last' | null,
};
});
if (new Set(stages.map((stage) => stage.stageKey)).size !== stages.length) {
throw new Error('Every calculation stage requires a unique key.');
}
if (
new Set(stages.map((stage) => stage.displayOrder)).size !== stages.length
) {
throw new Error('Every calculation stage requires a unique order.');
}
const ordered = [...stages].sort(
(left, right) => left.displayOrder - right.displayOrder,
);
if (
ordered[0].operator !== 'base_damage' ||
ordered[0].lockedPosition !== 'first' ||
ordered.at(-1)?.operator !== 'firing_cycle' ||
ordered.at(-1)?.lockedPosition !== 'last'
) {
throw new Error(
'Base damage must remain first and the firing cycle must remain last.',
);
}
const stageKeys = new Set(stages.map((stage) => stage.stageKey));
if (!Array.isArray(value.profiles) || value.profiles.length > 2_000) {
throw new Error(
'The calculation revision has too many weapon Attribute profiles.',
);
}
const profiles = value.profiles.map((profile, index) => {
if (!object(profile))
throw new Error(`Weapon Attribute profile ${index + 1} is invalid.`);
const statAttributeId = text(
profile.statAttributeId,
`Weapon Attribute profile ${index + 1} Attribute`,
160,
);
const stageKey = text(
profile.stageKey,
`Weapon Attribute profile ${index + 1} stage`,
120,
);
const softCap = optionalNumber(profile.softCap);
const hardCap = optionalNumber(profile.hardCap);
const curveParameter = optionalNumber(profile.curveParameter);
if (
!allowedAttributeIds.has(statAttributeId) ||
!stageKeys.has(stageKey) ||
!finite(profile.weight) ||
profile.weight < -100 ||
profile.weight > 100 ||
(softCap !== null && (!Number.isFinite(softCap) || softCap < 0)) ||
(hardCap !== null && (!Number.isFinite(hardCap) || hardCap < 0)) ||
(softCap !== null && hardCap !== null && hardCap < softCap) ||
!curves.has(String(profile.curveType)) ||
(curveParameter !== null && !Number.isFinite(curveParameter))
) {
throw new Error(`Weapon Attribute profile ${index + 1} is invalid.`);
}
return {
statAttributeId,
stageKey,
weight: Number(profile.weight),
softCap,
hardCap,
curveType: profile.curveType as 'none' | 'power' | 'smoothstep',
curveParameter,
weaponCategory:
typeof profile.weaponCategory === 'string' &&
profile.weaponCategory.trim()
? profile.weaponCategory.trim().slice(0, 120)
: '*',
};
});
if (
!Array.isArray(value.skillProfiles) ||
value.skillProfiles.length > 2_000
) {
throw new Error(
'The calculation revision has too many Skill Attribute profiles.',
);
}
const skillProfiles = value.skillProfiles.map((profile, index) => {
if (!object(profile))
throw new Error(`Skill Attribute profile ${index + 1} is invalid.`);
const statAttributeId = text(
profile.statAttributeId,
`Skill Attribute profile ${index + 1} Attribute`,
160,
);
if (
!allowedAttributeIds.has(statAttributeId) ||
!skillStages.has(String(profile.stageKey)) ||
!skillOperators.has(String(profile.operator)) ||
!finite(profile.weight) ||
profile.weight < -100 ||
profile.weight > 100
) {
throw new Error(`Skill Attribute profile ${index + 1} is invalid.`);
}
return {
statAttributeId,
stageKey:
profile.stageKey as AiCalculationRevisionDraft['skillProfiles'][number]['stageKey'],
operator: profile.operator as 'add' | 'multiply',
weight: Number(profile.weight),
};
});
if (
new Set(
skillProfiles.map(
(profile) => `${profile.statAttributeId}\u0000${profile.stageKey}`,
),
).size !== skillProfiles.length
) {
throw new Error(
'Skill Attribute profiles cannot duplicate an Attribute and stage pair.',
);
}
return { name, notes, stages, profiles, skillProfiles };
}
const nullableString = { anyOf: [{ type: 'string' }, { type: 'null' }] };
export const AI_CALCULATION_RESPONSE_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['status', 'summary', 'assumptions', 'warnings', 'questions', 'draftJson', 'gaps'],
properties: {
status: {
type: 'string',
enum: ['clarification_required', 'draft_ready', 'capability_gap']
},
summary: { type: 'string', maxLength: 1_200 },
assumptions: {
type: 'array',
maxItems: 12,
items: { type: 'string', maxLength: 800 }
},
warnings: {
type: 'array',
maxItems: 12,
items: { type: 'string', maxLength: 800 }
},
questions: {
type: 'array',
maxItems: 5,
items: {
type: 'object',
additionalProperties: false,
required: ['id', 'question', 'reason', 'answerType', 'options'],
properties: {
id: { type: 'string', maxLength: 120 },
question: { type: 'string', maxLength: 1_200 },
reason: { type: 'string', maxLength: 1_200 },
answerType: { type: 'string', enum: ['text', 'number', 'choice', 'boolean'] },
options: {
type: 'array',
maxItems: 8,
items: { type: 'string', maxLength: 240 }
}
}
}
},
draftJson: nullableString,
gaps: {
type: 'array',
maxItems: 8,
items: {
type: 'object',
additionalProperties: false,
required: [
'area',
'requiredBehavior',
'whyUnsupported',
'developerChange',
'workaround'
],
properties: {
area: {
type: 'string',
enum: ['stage_operator', 'curve', 'skill_stage', 'attribute', 'runtime']
},
requiredBehavior: { type: 'string', maxLength: 1_600 },
whyUnsupported: { type: 'string', maxLength: 1_600 },
developerChange: { type: 'string', maxLength: 2_400 },
workaround: nullableString
}
}
}
}
type: 'object',
additionalProperties: false,
required: [
'status',
'summary',
'assumptions',
'warnings',
'questions',
'draftJson',
'gaps',
],
properties: {
status: {
type: 'string',
enum: ['clarification_required', 'draft_ready', 'capability_gap'],
},
summary: { type: 'string', maxLength: 1_200 },
assumptions: {
type: 'array',
maxItems: 12,
items: { type: 'string', maxLength: 800 },
},
warnings: {
type: 'array',
maxItems: 12,
items: { type: 'string', maxLength: 800 },
},
questions: {
type: 'array',
maxItems: 5,
items: {
type: 'object',
additionalProperties: false,
required: ['id', 'question', 'reason', 'answerType', 'options'],
properties: {
id: { type: 'string', maxLength: 120 },
question: { type: 'string', maxLength: 1_200 },
reason: { type: 'string', maxLength: 1_200 },
answerType: {
type: 'string',
enum: ['text', 'number', 'choice', 'boolean'],
},
options: {
type: 'array',
maxItems: 8,
items: { type: 'string', maxLength: 240 },
},
},
},
},
draftJson: nullableString,
gaps: {
type: 'array',
maxItems: 8,
items: {
type: 'object',
additionalProperties: false,
required: [
'area',
'requiredBehavior',
'whyUnsupported',
'developerChange',
'workaround',
],
properties: {
area: {
type: 'string',
enum: [
'stage_operator',
'curve',
'skill_stage',
'attribute',
'runtime',
],
},
requiredBehavior: { type: 'string', maxLength: 1_600 },
whyUnsupported: { type: 'string', maxLength: 1_600 },
developerChange: { type: 'string', maxLength: 2_400 },
workaround: nullableString,
},
},
},
},
} as const;
const modelString = (value: unknown, label: string) => {
if (typeof value !== 'string' || !value.trim()) throw new Error(`The model returned an invalid ${label}.`);
return value.trim();
if (typeof value !== 'string' || !value.trim())
throw new Error(`The model returned an invalid ${label}.`);
return value.trim();
};
const modelStrings = (value: unknown, label: string, max: number) => {
if (!Array.isArray(value) || value.length > max || value.some((item) => typeof item !== 'string')) {
throw new Error(`The model returned invalid ${label}.`);
}
return value.map(String);
if (
!Array.isArray(value) ||
value.length > max ||
value.some((item) => typeof item !== 'string')
) {
throw new Error(`The model returned invalid ${label}.`);
}
return value.map(String);
};
export function parseRawAiCalculationModelResponse(value: unknown): RawAiCalculationModelResponse {
if (!object(value)) throw new Error('The model returned an invalid response.');
if (!['clarification_required', 'draft_ready', 'capability_gap'].includes(String(value.status))) {
throw new Error('The model returned an unsupported response status.');
}
const questions = Array.isArray(value.questions) ? value.questions.map((question): AiCalculationQuestion => {
if (
!object(question)
|| !['text', 'number', 'choice', 'boolean'].includes(String(question.answerType))
|| !Array.isArray(question.options)
|| question.options.some((option) => typeof option !== 'string')
) {
throw new Error('The model returned an invalid clarification question.');
}
return {
id: modelString(question.id, 'question identifier'),
question: modelString(question.question, 'question'),
reason: modelString(question.reason, 'question reason'),
answerType: question.answerType as AiCalculationQuestion['answerType'],
options: question.options.map(String)
};
}) : [];
const areas = new Set<AiCalculationCapabilityGap['area']>([
'stage_operator', 'curve', 'skill_stage', 'attribute', 'runtime'
]);
const gaps = Array.isArray(value.gaps) ? value.gaps.map((gap): AiCalculationCapabilityGap => {
if (!object(gap) || !areas.has(gap.area as AiCalculationCapabilityGap['area'])) {
throw new Error('The model returned an invalid capability gap.');
}
return {
area: gap.area as AiCalculationCapabilityGap['area'],
requiredBehavior: modelString(gap.requiredBehavior, 'required behavior'),
whyUnsupported: modelString(gap.whyUnsupported, 'unsupported behavior explanation'),
developerChange: modelString(gap.developerChange, 'developer change'),
workaround: gap.workaround === null ? null : modelString(gap.workaround, 'workaround')
};
}) : [];
if (value.draftJson !== null && typeof value.draftJson !== 'string') {
throw new Error('The model returned an invalid calculation draft.');
}
if (value.status === 'clarification_required' && (!questions.length || value.draftJson !== null || gaps.length)) {
throw new Error('The clarification response has conflicting content.');
}
if (value.status === 'draft_ready' && (!value.draftJson || questions.length || gaps.length)) {
throw new Error('The draft response has conflicting content.');
}
if (value.status === 'capability_gap' && (!gaps.length || questions.length || value.draftJson !== null)) {
throw new Error('The capability-gap response has conflicting content.');
}
return {
status: value.status as RawAiCalculationModelResponse['status'],
summary: modelString(value.summary, 'summary'),
assumptions: modelStrings(value.assumptions, 'assumptions', 12),
warnings: modelStrings(value.warnings, 'warnings', 12),
questions,
draftJson: value.draftJson as string | null,
gaps
};
export function parseRawAiCalculationModelResponse(
value: unknown,
): RawAiCalculationModelResponse {
if (!object(value))
throw new Error('The model returned an invalid response.');
if (
!['clarification_required', 'draft_ready', 'capability_gap'].includes(
String(value.status),
)
) {
throw new Error('The model returned an unsupported response status.');
}
const questions = Array.isArray(value.questions)
? value.questions.map((question): AiCalculationQuestion => {
if (
!object(question) ||
!['text', 'number', 'choice', 'boolean'].includes(
String(question.answerType),
) ||
!Array.isArray(question.options) ||
question.options.some((option) => typeof option !== 'string')
) {
throw new Error(
'The model returned an invalid clarification question.',
);
}
return {
id: modelString(question.id, 'question identifier'),
question: modelString(question.question, 'question'),
reason: modelString(question.reason, 'question reason'),
answerType:
question.answerType as AiCalculationQuestion['answerType'],
options: question.options.map(String),
};
})
: [];
const areas = new Set<AiCalculationCapabilityGap['area']>([
'stage_operator',
'curve',
'skill_stage',
'attribute',
'runtime',
]);
const gaps = Array.isArray(value.gaps)
? value.gaps.map((gap): AiCalculationCapabilityGap => {
if (
!object(gap) ||
!areas.has(gap.area as AiCalculationCapabilityGap['area'])
) {
throw new Error('The model returned an invalid capability gap.');
}
return {
area: gap.area as AiCalculationCapabilityGap['area'],
requiredBehavior: modelString(
gap.requiredBehavior,
'required behavior',
),
whyUnsupported: modelString(
gap.whyUnsupported,
'unsupported behavior explanation',
),
developerChange: modelString(gap.developerChange, 'developer change'),
workaround:
gap.workaround === null
? null
: modelString(gap.workaround, 'workaround'),
};
})
: [];
if (value.draftJson !== null && typeof value.draftJson !== 'string') {
throw new Error('The model returned an invalid calculation draft.');
}
if (
value.status === 'clarification_required' &&
(!questions.length || value.draftJson !== null || gaps.length)
) {
throw new Error('The clarification response has conflicting content.');
}
if (
value.status === 'draft_ready' &&
(!value.draftJson || questions.length || gaps.length)
) {
throw new Error('The draft response has conflicting content.');
}
if (
value.status === 'capability_gap' &&
(!gaps.length || questions.length || value.draftJson !== null)
) {
throw new Error('The capability-gap response has conflicting content.');
}
return {
status: value.status as RawAiCalculationModelResponse['status'],
summary: modelString(value.summary, 'summary'),
assumptions: modelStrings(value.assumptions, 'assumptions', 12),
warnings: modelStrings(value.warnings, 'warnings', 12),
questions,
draftJson: value.draftJson as string | null,
gaps,
};
}
+159 -135
View File
@@ -1,6 +1,6 @@
import type {
AiCalculationAuthoringRequest,
AiCalculationAuthoringResponse
AiCalculationAuthoringRequest,
AiCalculationAuthoringResponse,
} from '$lib/ai/calculation-authoring';
import { formatWeaponCalculationScript } from '$lib/calculations/calculation-script';
import { env } from '$env/dynamic/private';
@@ -9,157 +9,181 @@ import OpenAI from 'openai';
import type { Response } from 'openai/resources/responses/responses';
import { loadAiMechanicAuthoringContext } from './mechanic-authoring-context';
import {
AI_CALCULATION_AUTHORING_INSTRUCTIONS,
buildAiCalculationAuthoringInput
AI_CALCULATION_AUTHORING_INSTRUCTIONS,
buildAiCalculationAuthoringInput,
} from './calculation-authoring-prompt';
import {
AI_CALCULATION_RESPONSE_SCHEMA,
parseRawAiCalculationModelResponse,
validateAiCalculationRevisionDraft
AI_CALCULATION_RESPONSE_SCHEMA,
parseRawAiCalculationModelResponse,
validateAiCalculationRevisionDraft,
} from './calculation-authoring-schema';
import {
AiMechanicConfigurationError,
AiMechanicGenerationError
AiMechanicConfigurationError,
AiMechanicGenerationError,
} from './mechanic-authoring-service';
const DEFAULT_MODEL = 'gpt-5.6-terra';
const reasoningEfforts = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max']);
const reasoningEfforts = new Set([
'none',
'low',
'medium',
'high',
'xhigh',
'max',
]);
let client: OpenAI | null = null;
function openAiClient() {
const apiKey = env.OPENAI_MECHANIC_API_KEY?.trim();
if (!apiKey) throw new AiMechanicConfigurationError('AI calculation authoring is not configured.');
if (!client) client = new OpenAI({ apiKey, timeout: 60_000, maxRetries: 1 });
return client;
const apiKey = env.OPENAI_MECHANIC_API_KEY?.trim();
if (!apiKey)
throw new AiMechanicConfigurationError(
'AI calculation authoring is not configured.',
);
if (!client) client = new OpenAI({ apiKey, timeout: 60_000, maxRetries: 1 });
return client;
}
function reasoningEffort() {
const value = env.OPENAI_MECHANIC_REASONING_EFFORT?.trim().toLocaleLowerCase() || 'medium';
return (reasoningEfforts.has(value) ? value : 'medium') as
'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
const value =
env.OPENAI_MECHANIC_REASONING_EFFORT?.trim().toLocaleLowerCase() ||
'medium';
return (reasoningEfforts.has(value) ? value : 'medium') as
'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
}
function meta(response: Response, model: string) {
return {
requestId: String((response as Response & { _request_id?: string })._request_id ?? response.id),
model,
inputTokens: Number(response.usage?.input_tokens ?? 0),
outputTokens: Number(response.usage?.output_tokens ?? 0),
totalTokens: Number(response.usage?.total_tokens ?? 0)
};
return {
requestId: String(
(response as Response & { _request_id?: string })._request_id ??
response.id,
),
model,
inputTokens: Number(response.usage?.input_tokens ?? 0),
outputTokens: Number(response.usage?.output_tokens ?? 0),
totalTokens: Number(response.usage?.total_tokens ?? 0),
};
}
export async function generateAiCalculationDraft(
request: AiCalculationAuthoringRequest,
userId: string
request: AiCalculationAuthoringRequest,
userId: string,
): Promise<AiCalculationAuthoringResponse> {
const model = env.OPENAI_MECHANIC_MODEL?.trim() || DEFAULT_MODEL;
const catalog = await loadAiMechanicAuthoringContext();
const response = await openAiClient().responses.create({
model,
instructions: AI_CALCULATION_AUTHORING_INSTRUCTIONS,
input: buildAiCalculationAuthoringInput({ request, attributes: catalog.attributes }),
reasoning: { effort: reasoningEffort() },
safety_identifier: createHash('sha256')
.update(`shd-calculation-authoring:${userId}`)
.digest('hex'),
store: false,
tools: [],
max_output_tokens: 12_000,
text: {
format: {
type: 'json_schema',
name: 'shd_calculation_authoring_response',
strict: true,
schema: AI_CALCULATION_RESPONSE_SCHEMA
}
}
});
if (!response.output_text) {
throw new AiMechanicGenerationError('The AI assistant returned no usable response.');
}
let decoded: unknown;
try {
decoded = JSON.parse(response.output_text);
} catch {
throw new AiMechanicGenerationError('The AI assistant returned malformed structured data.');
}
let result;
try {
result = parseRawAiCalculationModelResponse(decoded);
} catch (caught) {
throw new AiMechanicGenerationError(
caught instanceof Error
? `The AI assistant returned invalid structured data: ${caught.message}`
: 'The AI assistant returned invalid structured data.'
);
}
const responseMeta = meta(response, model);
if (result.status === 'clarification_required') {
return {
status: result.status,
summary: result.summary,
assumptions: result.assumptions,
warnings: result.warnings,
questions: result.questions,
meta: responseMeta
};
}
if (result.status === 'capability_gap') {
return {
status: result.status,
summary: result.summary,
assumptions: result.assumptions,
warnings: result.warnings,
gaps: result.gaps,
meta: responseMeta
};
}
let rawDraft: unknown;
try {
rawDraft = JSON.parse(result.draftJson as string);
} catch {
throw new AiMechanicGenerationError('The AI assistant returned malformed calculation draft JSON.');
}
let draft;
try {
draft = validateAiCalculationRevisionDraft(
rawDraft,
new Set(catalog.attributes.map((attribute) => attribute.id))
);
} catch (caught) {
throw new AiMechanicGenerationError(
`The generated calculation draft did not pass deterministic validation: ${
caught instanceof Error ? caught.message : 'Unknown validation error.'
}`
);
}
return {
status: 'draft_ready',
draft,
summary: result.summary,
assumptions: result.assumptions,
warnings: result.warnings,
pseudoScript: formatWeaponCalculationScript({
revision: { name: draft.name },
stages: draft.stages.map((stage) => ({
stage_key: stage.stageKey,
label: stage.label,
operator: stage.operator,
display_order: stage.displayOrder
})),
profiles: draft.profiles.map((profile) => ({
stat_attribute_id: profile.statAttributeId,
stage_key: profile.stageKey,
weight: profile.weight,
soft_cap: profile.softCap,
hard_cap: profile.hardCap,
curve_type: profile.curveType,
curve_parameter: profile.curveParameter,
weapon_category: profile.weaponCategory
})),
attributes: catalog.attributes
}).join('\n'),
meta: responseMeta
};
const model = env.OPENAI_MECHANIC_MODEL?.trim() || DEFAULT_MODEL;
const catalog = await loadAiMechanicAuthoringContext();
const response = await openAiClient().responses.create({
model,
instructions: AI_CALCULATION_AUTHORING_INSTRUCTIONS,
input: buildAiCalculationAuthoringInput({
request,
attributes: catalog.attributes,
}),
reasoning: { effort: reasoningEffort() },
safety_identifier: createHash('sha256')
.update(`shd-calculation-authoring:${userId}`)
.digest('hex'),
store: false,
tools: [],
max_output_tokens: 12_000,
text: {
format: {
type: 'json_schema',
name: 'shd_calculation_authoring_response',
strict: true,
schema: AI_CALCULATION_RESPONSE_SCHEMA,
},
},
});
if (!response.output_text) {
throw new AiMechanicGenerationError(
'The AI assistant returned no usable response.',
);
}
let decoded: unknown;
try {
decoded = JSON.parse(response.output_text);
} catch {
throw new AiMechanicGenerationError(
'The AI assistant returned malformed structured data.',
);
}
let result;
try {
result = parseRawAiCalculationModelResponse(decoded);
} catch (caught) {
throw new AiMechanicGenerationError(
caught instanceof Error
? `The AI assistant returned invalid structured data: ${caught.message}`
: 'The AI assistant returned invalid structured data.',
);
}
const responseMeta = meta(response, model);
if (result.status === 'clarification_required') {
return {
status: result.status,
summary: result.summary,
assumptions: result.assumptions,
warnings: result.warnings,
questions: result.questions,
meta: responseMeta,
};
}
if (result.status === 'capability_gap') {
return {
status: result.status,
summary: result.summary,
assumptions: result.assumptions,
warnings: result.warnings,
gaps: result.gaps,
meta: responseMeta,
};
}
let rawDraft: unknown;
try {
rawDraft = JSON.parse(result.draftJson as string);
} catch {
throw new AiMechanicGenerationError(
'The AI assistant returned malformed calculation draft JSON.',
);
}
let draft;
try {
draft = validateAiCalculationRevisionDraft(
rawDraft,
new Set(catalog.attributes.map((attribute) => attribute.id)),
);
} catch (caught) {
throw new AiMechanicGenerationError(
`The generated calculation draft did not pass deterministic validation: ${
caught instanceof Error ? caught.message : 'Unknown validation error.'
}`,
);
}
return {
status: 'draft_ready',
draft,
summary: result.summary,
assumptions: result.assumptions,
warnings: result.warnings,
pseudoScript: formatWeaponCalculationScript({
revision: { name: draft.name },
stages: draft.stages.map((stage) => ({
stage_key: stage.stageKey,
label: stage.label,
operator: stage.operator,
display_order: stage.displayOrder,
})),
profiles: draft.profiles.map((profile) => ({
stat_attribute_id: profile.statAttributeId,
stage_key: profile.stageKey,
weight: profile.weight,
soft_cap: profile.softCap,
hard_cap: profile.hardCap,
curve_type: profile.curveType,
curve_parameter: profile.curveParameter,
weapon_category: profile.weaponCategory,
})),
attributes: catalog.attributes,
}).join('\n'),
meta: responseMeta,
};
}
+45 -37
View File
@@ -1,47 +1,55 @@
import { TRIGGER_ACTION_KEYS, TRIGGER_ACTION_REGISTRY } from '$lib/calculations/conditional';
import {
MECHANIC_EVENT_KINDS,
MECHANIC_EVENT_REGISTRY
TRIGGER_ACTION_KEYS,
TRIGGER_ACTION_REGISTRY,
} from '$lib/calculations/conditional';
import {
MECHANIC_EVENT_KINDS,
MECHANIC_EVENT_REGISTRY,
} from '$lib/calculations/mechanic';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import { getDatabase } from '$lib/server/database';
export type AiMechanicAttribute = {
id: string;
name: string;
description: string | null;
unit: string | null;
type: string | null;
category: string | null;
calculationType: string | null;
id: string;
name: string;
description: string | null;
unit: string | null;
type: string | null;
category: string | null;
calculationType: string | null;
};
export async function loadAiMechanicAuthoringContext() {
const database = await getDatabase();
const rows = await (await database.prepare(`SELECT id,name,description,unit,type,category,calculation_type
FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE,unit COLLATE NOCASE`)).all();
const descriptionLimit = Math.max(
40,
Math.min(600, Math.floor(60_000 / Math.max(1, rows.length)))
);
const attributes = rows.map((row): AiMechanicAttribute => ({
id: String(row.id),
name: String(row.name),
description: typeof row.description === 'string' && row.description.trim()
? row.description.trim().slice(0, descriptionLimit)
: null,
unit: typeof row.unit === 'string' && row.unit.trim() ? row.unit.trim() : null,
type: typeof row.type === 'string' ? row.type : null,
category: typeof row.category === 'string' ? row.category : null,
calculationType: typeof row.calculation_type === 'string' ? row.calculation_type : null
}));
return {
attributes,
events: MECHANIC_EVENT_KINDS.map((key) => MECHANIC_EVENT_REGISTRY[key]),
actions: TRIGGER_ACTION_KEYS.map((key) => ({
key,
label: TRIGGER_ACTION_REGISTRY[key].label
})),
recipeKeys: Object.keys(MECHANIC_RECIPE_REGISTRY)
};
const database = await getDatabase();
const rows = await (
await database.prepare(`SELECT id,name,description,unit,type,category,calculation_type
FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE,unit COLLATE NOCASE`)
).all();
const descriptionLimit = Math.max(
40,
Math.min(600, Math.floor(60_000 / Math.max(1, rows.length))),
);
const attributes = rows.map((row): AiMechanicAttribute => ({
id: String(row.id),
name: String(row.name),
description:
typeof row.description === 'string' && row.description.trim()
? row.description.trim().slice(0, descriptionLimit)
: null,
unit:
typeof row.unit === 'string' && row.unit.trim() ? row.unit.trim() : null,
type: typeof row.type === 'string' ? row.type : null,
category: typeof row.category === 'string' ? row.category : null,
calculationType:
typeof row.calculation_type === 'string' ? row.calculation_type : null,
}));
return {
attributes,
events: MECHANIC_EVENT_KINDS.map((key) => MECHANIC_EVENT_REGISTRY[key]),
actions: TRIGGER_ACTION_KEYS.map((key) => ({
key,
label: TRIGGER_ACTION_REGISTRY[key].label,
})),
recipeKeys: Object.keys(MECHANIC_RECIPE_REGISTRY),
};
}
@@ -3,303 +3,352 @@ import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
import { parseConditionalMechanicDraft } from '$lib/calculations/mechanic-editor';
import { MECHANIC_EVENT_REGISTRY } from '$lib/calculations/mechanic';
import {
AI_MECHANIC_AUTHORING_INSTRUCTIONS,
AI_MECHANIC_PROMPT_VERSION,
buildAiMechanicAuthoringInput
AI_MECHANIC_AUTHORING_INSTRUCTIONS,
AI_MECHANIC_PROMPT_VERSION,
buildAiMechanicAuthoringInput,
} from './mechanic-authoring-prompt';
const request: AiMechanicAuthoringRequest = {
context: {
sourceKind: 'talent',
sourceName: 'Predatory Aura',
sourceDescription: 'Restore Health based on the current Firepower Multiplier.'
},
currentDraft: {
version: 1,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: []
},
valueColumns: [
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
{ key: 'tierII', variantKey: 'II', label: 'TIER II' }
],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
transcript: []
context: {
sourceKind: 'talent',
sourceName: 'Predatory Aura',
sourceDescription:
'Restore Health based on the current Firepower Multiplier.',
},
currentDraft: {
version: 1,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: [],
},
valueColumns: [
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
{ key: 'tierII', variantKey: 'II', label: 'TIER II' },
],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
transcript: [],
};
describe('AI mechanic authoring prompt', () => {
it('advertises exact catalog Attribute references and displayed-percent semantics', () => {
const input = JSON.parse(buildAiMechanicAuthoringInput({
request,
attributes: [{
id: 'firepower-multiplier',
name: 'Firepower Multiplier',
description: null,
unit: '%',
type: null,
category: null,
calculationType: null
}],
events: [],
actions: [],
recipeKeys: []
}));
it('advertises exact catalog Attribute references and displayed-percent semantics', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
attributes: [
{
id: 'firepower-multiplier',
name: 'Firepower Multiplier',
description: null,
unit: '%',
type: null,
category: null,
calculationType: null,
},
],
events: [],
actions: [],
recipeKeys: [],
}),
);
expect(input.canonicalCatalog.grammar.valueReferences).toContain('attribute');
expect(input.canonicalCatalog.grammar.referenceShapes).toContainEqual({
ref: {
source: 'attribute',
attributeId: 'replace-with-an-exact-catalog-attribute-id'
}
});
expect(input.examplesAndRules).toEqual(expect.arrayContaining([
expect.stringContaining('exact supplied catalog ID'),
expect.stringContaining('Divide that reference by 100')
]));
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain('Never invent an Attribute ID');
});
expect(input.canonicalCatalog.grammar.valueReferences).toContain(
'attribute',
);
expect(input.canonicalCatalog.grammar.referenceShapes).toContainEqual({
ref: {
source: 'attribute',
attributeId: 'replace-with-an-exact-catalog-attribute-id',
},
});
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('exact supplied catalog ID'),
expect.stringContaining('Divide that reference by 100'),
]),
);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Never invent an Attribute ID',
);
});
it('advertises continuous restoration as a supported outcome capability', () => {
const input = JSON.parse(buildAiMechanicAuthoringInput({
request,
attributes: [{
id: '1bce573c-80f7-4487-b1d3-c4dc598b5244',
name: 'Firepower Multiplier',
description: null,
unit: '%',
type: null,
category: null,
calculationType: null
}],
events: [{ key: 'enemy_killed', label: 'Enemy killed' }],
actions: [],
recipeKeys: []
}));
it('advertises continuous restoration as a supported outcome capability', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
attributes: [
{
id: '1bce573c-80f7-4487-b1d3-c4dc598b5244',
name: 'Firepower Multiplier',
description: null,
unit: '%',
type: null,
category: null,
calculationType: null,
},
],
events: [{ key: 'enemy_killed', label: 'Enemy killed' }],
actions: [],
recipeKeys: [],
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds'])
);
expect(input.canonicalCatalog.grammar.outcome.delivery).toEqual([
'instant',
'continuous'
]);
expect(input.canonicalCatalog.grammar.continuousRestorationOutcomeShape)
.toMatchObject({
kind: 'restoration',
resource: 'health',
delivery: 'continuous',
durationSeconds: 3,
variants: {
'replace-with-each-required-variant-key': {
value: 240,
expression: {
left: 240,
right: {
left: {
ref: {
source: 'attribute'
}
},
right: 100
}
}
}
}
});
expect(input.examplesAndRules).toEqual(expect.arrayContaining([
expect.stringContaining('Continuous Health restoration is supported'),
expect.stringContaining('not a per-second value'),
expect.stringContaining('Do not replace continuous delivery with a periodic reducer')
]));
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 7'
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds']),
);
expect(input.canonicalCatalog.grammar.outcome.delivery).toEqual([
'instant',
'continuous',
]);
expect(
input.canonicalCatalog.grammar.continuousRestorationOutcomeShape,
).toMatchObject({
kind: 'restoration',
resource: 'health',
delivery: 'continuous',
durationSeconds: 3,
variants: {
'replace-with-each-required-variant-key': {
value: 240,
expression: {
left: 240,
right: {
left: {
ref: {
source: 'attribute',
},
},
right: 100,
},
},
},
},
});
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('Continuous Health restoration is supported'),
expect.stringContaining('not a per-second value'),
expect.stringContaining(
'Do not replace continuous delivery with a periodic reducer',
),
]),
);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 7',
);
const advertised = structuredClone(
input.canonicalCatalog.grammar.continuousRestorationOutcomeShape
);
const tierI = JSON.parse(JSON.stringify(
advertised.variants['replace-with-each-required-variant-key']
).replaceAll(
'replace-with-an-exact-catalog-attribute-id',
'1bce573c-80f7-4487-b1d3-c4dc598b5244'
));
const tierII = structuredClone(tierI);
tierII.value = 336;
tierII.expression.left = 336;
advertised.variants = { I: tierI, II: tierII };
const parsed = parseConditionalMechanicDraft({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [{
key: 'predator-aura',
label: 'Predator Aura',
event: 'enemy_killed',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.distanceMeters' },
operator: 'lte',
right: 20
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [advertised]
}]
}, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['1bce573c-80f7-4487-b1d3-c4dc598b5244'])
});
expect(parsed.ok).toBe(true);
});
const advertised = structuredClone(
input.canonicalCatalog.grammar.continuousRestorationOutcomeShape,
);
const tierI = JSON.parse(
JSON.stringify(
advertised.variants['replace-with-each-required-variant-key'],
).replaceAll(
'replace-with-an-exact-catalog-attribute-id',
'1bce573c-80f7-4487-b1d3-c4dc598b5244',
),
);
const tierII = structuredClone(tierI);
tierII.value = 336;
tierII.expression.left = 336;
advertised.variants = { I: tierI, II: tierII };
const parsed = parseConditionalMechanicDraft(
{
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'predator-aura',
label: 'Predator Aura',
event: 'enemy_killed',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.distanceMeters' },
operator: 'lte',
right: 20,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [advertised],
},
],
},
{
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['1bce573c-80f7-4487-b1d3-c4dc598b5244']),
},
);
expect(parsed.ok).toBe(true);
});
it('advertises the canonical Extra Health change event contract in version 7', () => {
const input = JSON.parse(buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [MECHANIC_EVENT_REGISTRY.extra_health_changed],
actions: [],
recipeKeys: []
}));
it('advertises the canonical Extra Health change event contract in version 7', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [MECHANIC_EVENT_REGISTRY.extra_health_changed],
actions: [],
recipeKeys: [],
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain('Prompt contract version: 7');
expect(input.canonicalCatalog.events).toContainEqual({
key: 'extra_health_changed',
label: 'Extra Health changed',
description: expect.any(String),
strictFields: true,
fields: [
{
path: 'values.hasExtraHealth',
label: 'Has Extra Health',
type: 'boolean',
required: true
},
{
path: 'values.currentExtraHealth',
label: 'Current Extra Health',
type: 'number',
minimum: 0
}
]
});
});
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 7',
);
expect(input.canonicalCatalog.events).toContainEqual({
key: 'extra_health_changed',
label: 'Extra Health changed',
description: expect.any(String),
strictFields: true,
fields: [
{
path: 'values.hasExtraHealth',
label: 'Has Extra Health',
type: 'boolean',
required: true,
},
{
path: 'values.currentExtraHealth',
label: 'Current Extra Health',
type: 'number',
minimum: 0,
},
],
});
});
it('advertises authoritative current Health percentage with strict band semantics', () => {
const input = JSON.parse(buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [{ key: 'tick', label: 'Simulation tick' }],
actions: [],
recipeKeys: ['health_threshold_bands']
}));
it('advertises authoritative current Health percentage with strict band semantics', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [{ key: 'tick', label: 'Simulation tick' }],
actions: [],
recipeKeys: ['health_threshold_bands'],
}),
);
expect(input.canonicalCatalog.grammar.combatReferences.currentHealthPercent)
.toMatchObject({
label: 'Current Health percentage',
description: expect.stringContaining('0 through 100')
});
expect(input.canonicalCatalog.grammar.referenceShapes).toContainEqual({
ref: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current'
}
});
expect(input.examplesAndRules).toEqual(expect.arrayContaining([
expect.stringContaining('operator:"lt"'),
expect.stringContaining('<25, >=25 and <50, and >=50 and <75')
]));
});
expect(
input.canonicalCatalog.grammar.combatReferences.currentHealthPercent,
).toMatchObject({
label: 'Current Health percentage',
description: expect.stringContaining('0 through 100'),
});
expect(input.canonicalCatalog.grammar.referenceShapes).toContainEqual({
ref: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current',
},
});
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('operator:"lt"'),
expect.stringContaining('<25, >=25 and <50, and >=50 and <75'),
]),
);
});
it('advertises Precise Trauma through target expiry without inventing target tick iteration', () => {
const skillDamageId = 'skill-damage-catalog-id';
const input = JSON.parse(buildAiMechanicAuthoringInput({
request: {
...request,
context: {
...request.context,
sourceName: 'Precise Trauma',
sourceDescription:
'Hits apply a debuff for 1 second, up to 10 stacks. Body shots add 1 stack and headshots add 2 stacks.'
}
},
attributes: [{
id: skillDamageId,
name: 'Skill Damage',
description: null,
unit: null,
type: null,
category: null,
calculationType: null
}],
events: [{ key: 'weapon_hit', label: 'Weapon hit' }],
actions: [
{ key: 'body_shot', label: 'Body shot' },
{ key: 'headshot', label: 'Headshot' }
],
recipeKeys: ['body_shot', 'headshot']
}));
const advertised = structuredClone(input.canonicalCatalog.grammar.preciseTraumaShape);
const outcome = advertised.clauses[0].outcomes[0];
const baseVariant = JSON.parse(JSON.stringify(
outcome.variants['replace-with-each-required-variant-key']
).replaceAll(
'replace-with-the-exact-skill-damage-catalog-id',
skillDamageId
));
outcome.variants = {
I: structuredClone(baseVariant),
II: structuredClone(baseVariant)
};
it('advertises Precise Trauma through target expiry without inventing target tick iteration', () => {
const skillDamageId = 'skill-damage-catalog-id';
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request: {
...request,
context: {
...request.context,
sourceName: 'Precise Trauma',
sourceDescription:
'Hits apply a debuff for 1 second, up to 10 stacks. Body shots add 1 stack and headshots add 2 stacks.',
},
},
attributes: [
{
id: skillDamageId,
name: 'Skill Damage',
description: null,
unit: null,
type: null,
category: null,
calculationType: null,
},
],
events: [{ key: 'weapon_hit', label: 'Weapon hit' }],
actions: [
{ key: 'body_shot', label: 'Body shot' },
{ key: 'headshot', label: 'Headshot' },
],
recipeKeys: ['body_shot', 'headshot'],
}),
);
const advertised = structuredClone(
input.canonicalCatalog.grammar.preciseTraumaShape,
);
const outcome = advertised.clauses[0].outcomes[0];
const baseVariant = JSON.parse(
JSON.stringify(
outcome.variants['replace-with-each-required-variant-key'],
).replaceAll(
'replace-with-the-exact-skill-damage-catalog-id',
skillDamageId,
),
);
outcome.variants = {
I: structuredClone(baseVariant),
II: structuredClone(baseVariant),
};
expect(advertised.pools[0]).toMatchObject({
scope: 'target',
max: 10,
expiresAfterSeconds: 1
});
expect(advertised.clauses[0].mutations[0]).toEqual({
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 1
});
expect(advertised.clauses[1].mutations[0]).toMatchObject({ value: 2 });
expect(outcome).toMatchObject({
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
statePool: 'trauma',
perStack: true,
variants: {
I: {
expression: {
ref: {
source: 'attribute',
attributeId: skillDamageId
}
}
}
}
});
expect(input.examplesAndRules).toEqual(expect.arrayContaining([
expect.stringContaining('uses the entity previous state'),
expect.stringContaining('without inventing generic repeated target ticks')
]));
expect(JSON.stringify(input.canonicalCatalog.grammar.preciseTraumaShape))
.not.toContain('entitiesFromPool');
expect(advertised.pools[0]).toMatchObject({
scope: 'target',
max: 10,
expiresAfterSeconds: 1,
});
expect(advertised.clauses[0].mutations[0]).toEqual({
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 1,
});
expect(advertised.clauses[1].mutations[0]).toMatchObject({ value: 2 });
expect(outcome).toMatchObject({
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
statePool: 'trauma',
perStack: true,
variants: {
I: {
expression: {
ref: {
source: 'attribute',
attributeId: skillDamageId,
},
},
},
},
});
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('uses the entity previous state'),
expect.stringContaining(
'without inventing generic repeated target ticks',
),
]),
);
expect(
JSON.stringify(input.canonicalCatalog.grammar.preciseTraumaShape),
).not.toContain('entitiesFromPool');
const parsed = parseConditionalMechanicDraft(advertised, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set([skillDamageId]),
allowedRecipeKeys: new Set(['body_shot', 'headshot'])
});
expect(parsed.ok).toBe(true);
});
const parsed = parseConditionalMechanicDraft(advertised, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set([skillDamageId]),
allowedRecipeKeys: new Set(['body_shot', 'headshot']),
});
expect(parsed.ok).toBe(true);
});
});
+426 -323
View File
@@ -6,285 +6,388 @@ import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 7;
const mechanicsGrammar = {
version: 1,
simulationReadiness: ['ready', 'requires_assumptions', 'captured_only'],
scenarioInput: {
required: ['key', 'label', 'kind'],
kind: ['toggle', 'slider', 'number'],
readiness: ['ready', 'requires_assumption'],
optional: ['description', 'defaultValue', 'bestCaseValue', 'min', 'max']
},
pool: {
required: ['key', 'label', 'stateKind', 'scope', 'initial'],
stateKind: ['flag', 'count', 'stack', 'duration', 'scalar', 'target_mark', 'resource'],
scope: ['agent', 'target'],
optional: ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds', 'expiresAfterSecondsVariants'],
expirationSemantics:
'expiresAfterSeconds is one timer per pool entity. Use expiresAfterSecondsVariants instead when each source variant has a different duration; define every requested variant exactly once. Every write resets that entity timer, and expiry resets only that entity to the pool initial value.',
targetScopeSemantics:
'A target-scoped pool stores one independent value and expiry timer per resolved entity. Use mutation.entity event_target when a weapon-hit mechanic binds state to the enemy that was hit.'
},
clause: {
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
optional: ['label', 'recipeKey', 'cooldownSeconds', 'weight']
},
predicateKinds: ['true', 'exists', 'compare', 'all', 'any', 'not'],
compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'],
reducerKinds: [
'none',
'count',
'modulo',
'distinct_count',
'consecutive_count',
'sliding_count',
'duration',
'periodic',
'derived_scalar'
],
mutationKinds: ['none', 'add', 'set', 'consume', 'derive', 'clear', 'transfer'],
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
valueReferences: ['event', 'scenario', 'combat', 'equipment', 'attribute', 'reducer', 'pool'],
combatReferences: MECHANIC_COMBAT_REFERENCE_REGISTRY,
numericExpressionOperators: ['add', 'subtract', 'multiply', 'divide', 'min', 'max', 'clamp', 'floor'],
referenceShapes: [
{ ref: { source: 'event', path: 'values.distanceMeters' } },
{ ref: { source: 'scenario', key: 'input-key' } },
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
{ ref: { source: 'combat', path: 'currentHealthPercent', timing: 'current' } },
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
{ ref: { source: 'attribute', attributeId: 'replace-with-an-exact-catalog-attribute-id' } },
{ ref: { source: 'reducer' } },
{ ref: { source: 'pool', pool: 'pool-key', entity: { kind: 'agent' } } }
],
expressionShapes: [
{ op: 'multiply', left: 3, right: { ref: { source: 'pool', pool: 'stacks' } } },
{ op: 'min', left: { ref: { source: 'reducer' } }, right: 5 },
{ op: 'clamp', value: { ref: { source: 'event', path: 'values.enemyCount' } }, min: 0, max: 5 },
{ op: 'floor', value: { op: 'divide', left: { ref: { source: 'event', path: 'values.distanceMeters' } }, right: 5 } }
],
predicateShapes: [
{ kind: 'true' },
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.distanceMeters' },
operator: 'lte',
right: 10
},
{ kind: 'all', predicates: [{ kind: 'true' }] },
{ kind: 'any', predicates: [{ kind: 'true' }] },
{ kind: 'not', predicate: { kind: 'true' } }
],
reducerShapes: [
{ kind: 'none' },
{ kind: 'count', threshold: 9, reset: 'zero', groupBy: { source: 'event', path: 'targetId' } },
{ kind: 'modulo', every: 5 },
{
kind: 'distinct_count',
threshold: 3,
windowSeconds: 5,
distinctBy: { source: 'event', path: 'targetId' }
},
{ kind: 'consecutive_count', threshold: 9, groupBy: { source: 'event', path: 'targetId' } },
{
kind: 'sliding_count',
threshold: 9,
windowSeconds: 3,
reset: 'zero',
groupBy: { source: 'event', path: 'targetId' }
},
{ kind: 'duration', seconds: 3, repeat: false },
{ kind: 'periodic', intervalSeconds: 1, immediate: false },
{ kind: 'derived_scalar', value: { ref: { source: 'event', path: 'values.enemyCount' } } }
],
mutationShapes: [
{ kind: 'none' },
{ kind: 'add', pool: 'stacks', value: 1 },
{
kind: 'add',
pool: 'target-stacks',
entity: { kind: 'event_target' },
value: 1
},
{ kind: 'set', pool: 'active', value: 1 },
{ kind: 'consume', pool: 'stacks', value: 1 },
{ kind: 'derive', pool: 'nearby-enemies', value: { ref: { source: 'reducer' } } },
{ kind: 'clear', pool: 'active' },
{
kind: 'transfer',
pool: 'mark',
from: { kind: 'event_target' },
to: { kind: 'event_field', path: 'values.transferTargetId' },
value: 'all',
clearSource: true
}
],
outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES,
continuousRestorationOutcomeShape: {
key: 'restore-health',
label: 'Restore Health',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
basis: 'flat',
resource: 'health',
delivery: 'continuous',
durationSeconds: 3,
variants: {
'replace-with-each-required-variant-key': {
value: 240,
expression: {
op: 'multiply',
left: 240,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: 'replace-with-an-exact-catalog-attribute-id'
}
},
right: 100
}
}
}
}
},
preciseTraumaShape: {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [{
key: 'trauma',
label: 'Trauma',
stateKind: 'stack',
scope: 'target',
initial: 0,
min: 0,
max: 10,
bestCaseValue: 10,
expiresAfterSeconds: 1
}],
clauses: [
{
key: 'body-shot-trauma',
label: 'Body shot adds one Trauma stack',
recipeKey: 'body_shot',
event: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{ kind: 'exists', value: { source: 'event', path: 'values.headshot' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: false
}
]
},
reducer: { kind: 'none' },
mutations: [{
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 1
}],
outcomes: [{
key: 'trauma-skill-damage',
label: 'Trauma Skill Damage',
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
statePool: 'trauma',
perStack: true,
variants: {
'replace-with-each-required-variant-key': {
value: 0,
expression: {
ref: {
source: 'attribute',
attributeId: 'replace-with-the-exact-skill-damage-catalog-id'
}
}
}
}
}]
},
{
key: 'headshot-trauma',
label: 'Headshot adds two Trauma stacks',
recipeKey: 'headshot',
event: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{ kind: 'exists', value: { source: 'event', path: 'values.headshot' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: true
}
]
},
reducer: { kind: 'none' },
mutations: [{
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 2
}],
outcomes: []
}
]
},
variantValue: {
required: ['value'],
optional: ['expression']
},
completeDraftShape: {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [{
key: 'stacks',
label: 'Stacks',
stateKind: 'stack',
scope: 'agent',
initial: 0,
min: 0,
max: 10,
bestCaseValue: 10,
expiresAfterSeconds: 10
}],
clauses: [{
key: 'gain-stack',
label: 'Gain one stack',
event: 'weapon_hit',
if: { kind: 'true' },
reducer: { kind: 'count', threshold: 9, reset: 'zero' },
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }],
outcomes: [{
key: 'weapon-damage',
label: 'Weapon Damage',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'self',
basis: 'flat',
attributeId: 'replace-with-an-exact-catalog-attribute-id',
statePool: 'stacks',
perStack: true,
descriptionSlot: 1,
variants: {
'replace-with-each-required-variant-key': { value: 3 }
}
}]
}]
}
version: 1,
simulationReadiness: ['ready', 'requires_assumptions', 'captured_only'],
scenarioInput: {
required: ['key', 'label', 'kind'],
kind: ['toggle', 'slider', 'number'],
readiness: ['ready', 'requires_assumption'],
optional: ['description', 'defaultValue', 'bestCaseValue', 'min', 'max'],
},
pool: {
required: ['key', 'label', 'stateKind', 'scope', 'initial'],
stateKind: [
'flag',
'count',
'stack',
'duration',
'scalar',
'target_mark',
'resource',
],
scope: ['agent', 'target'],
optional: [
'min',
'max',
'bestCaseValue',
'expiresAfterSeconds',
'expiresAfterSecondsVariants',
],
expirationSemantics:
'expiresAfterSeconds is one timer per pool entity. Use expiresAfterSecondsVariants instead when each source variant has a different duration; define every requested variant exactly once. Every write resets that entity timer, and expiry resets only that entity to the pool initial value.',
targetScopeSemantics:
'A target-scoped pool stores one independent value and expiry timer per resolved entity. Use mutation.entity event_target when a weapon-hit mechanic binds state to the enemy that was hit.',
},
clause: {
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
optional: ['label', 'recipeKey', 'cooldownSeconds', 'weight'],
},
predicateKinds: ['true', 'exists', 'compare', 'all', 'any', 'not'],
compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'],
reducerKinds: [
'none',
'count',
'modulo',
'distinct_count',
'consecutive_count',
'sliding_count',
'duration',
'periodic',
'derived_scalar',
],
mutationKinds: [
'none',
'add',
'set',
'consume',
'derive',
'clear',
'transfer',
],
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
valueReferences: [
'event',
'scenario',
'combat',
'equipment',
'attribute',
'reducer',
'pool',
],
combatReferences: MECHANIC_COMBAT_REFERENCE_REGISTRY,
numericExpressionOperators: [
'add',
'subtract',
'multiply',
'divide',
'min',
'max',
'clamp',
'floor',
],
referenceShapes: [
{ ref: { source: 'event', path: 'values.distanceMeters' } },
{ ref: { source: 'scenario', key: 'input-key' } },
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
{
ref: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current',
},
},
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
{
ref: {
source: 'attribute',
attributeId: 'replace-with-an-exact-catalog-attribute-id',
},
},
{ ref: { source: 'reducer' } },
{ ref: { source: 'pool', pool: 'pool-key', entity: { kind: 'agent' } } },
],
expressionShapes: [
{
op: 'multiply',
left: 3,
right: { ref: { source: 'pool', pool: 'stacks' } },
},
{ op: 'min', left: { ref: { source: 'reducer' } }, right: 5 },
{
op: 'clamp',
value: { ref: { source: 'event', path: 'values.enemyCount' } },
min: 0,
max: 5,
},
{
op: 'floor',
value: {
op: 'divide',
left: { ref: { source: 'event', path: 'values.distanceMeters' } },
right: 5,
},
},
],
predicateShapes: [
{ kind: 'true' },
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.distanceMeters' },
operator: 'lte',
right: 10,
},
{ kind: 'all', predicates: [{ kind: 'true' }] },
{ kind: 'any', predicates: [{ kind: 'true' }] },
{ kind: 'not', predicate: { kind: 'true' } },
],
reducerShapes: [
{ kind: 'none' },
{
kind: 'count',
threshold: 9,
reset: 'zero',
groupBy: { source: 'event', path: 'targetId' },
},
{ kind: 'modulo', every: 5 },
{
kind: 'distinct_count',
threshold: 3,
windowSeconds: 5,
distinctBy: { source: 'event', path: 'targetId' },
},
{
kind: 'consecutive_count',
threshold: 9,
groupBy: { source: 'event', path: 'targetId' },
},
{
kind: 'sliding_count',
threshold: 9,
windowSeconds: 3,
reset: 'zero',
groupBy: { source: 'event', path: 'targetId' },
},
{ kind: 'duration', seconds: 3, repeat: false },
{ kind: 'periodic', intervalSeconds: 1, immediate: false },
{
kind: 'derived_scalar',
value: { ref: { source: 'event', path: 'values.enemyCount' } },
},
],
mutationShapes: [
{ kind: 'none' },
{ kind: 'add', pool: 'stacks', value: 1 },
{
kind: 'add',
pool: 'target-stacks',
entity: { kind: 'event_target' },
value: 1,
},
{ kind: 'set', pool: 'active', value: 1 },
{ kind: 'consume', pool: 'stacks', value: 1 },
{
kind: 'derive',
pool: 'nearby-enemies',
value: { ref: { source: 'reducer' } },
},
{ kind: 'clear', pool: 'active' },
{
kind: 'transfer',
pool: 'mark',
from: { kind: 'event_target' },
to: { kind: 'event_field', path: 'values.transferTargetId' },
value: 'all',
clearSource: true,
},
],
outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES,
continuousRestorationOutcomeShape: {
key: 'restore-health',
label: 'Restore Health',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
basis: 'flat',
resource: 'health',
delivery: 'continuous',
durationSeconds: 3,
variants: {
'replace-with-each-required-variant-key': {
value: 240,
expression: {
op: 'multiply',
left: 240,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: 'replace-with-an-exact-catalog-attribute-id',
},
},
right: 100,
},
},
},
},
},
preciseTraumaShape: {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [
{
key: 'trauma',
label: 'Trauma',
stateKind: 'stack',
scope: 'target',
initial: 0,
min: 0,
max: 10,
bestCaseValue: 10,
expiresAfterSeconds: 1,
},
],
clauses: [
{
key: 'body-shot-trauma',
label: 'Body shot adds one Trauma stack',
recipeKey: 'body_shot',
event: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{
kind: 'exists',
value: { source: 'event', path: 'values.headshot' },
},
{
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: false,
},
],
},
reducer: { kind: 'none' },
mutations: [
{
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 1,
},
],
outcomes: [
{
key: 'trauma-skill-damage',
label: 'Trauma Skill Damage',
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
statePool: 'trauma',
perStack: true,
variants: {
'replace-with-each-required-variant-key': {
value: 0,
expression: {
ref: {
source: 'attribute',
attributeId:
'replace-with-the-exact-skill-damage-catalog-id',
},
},
},
},
},
],
},
{
key: 'headshot-trauma',
label: 'Headshot adds two Trauma stacks',
recipeKey: 'headshot',
event: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{
kind: 'exists',
value: { source: 'event', path: 'values.headshot' },
},
{
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: true,
},
],
},
reducer: { kind: 'none' },
mutations: [
{
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 2,
},
],
outcomes: [],
},
],
},
variantValue: {
required: ['value'],
optional: ['expression'],
},
completeDraftShape: {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [
{
key: 'stacks',
label: 'Stacks',
stateKind: 'stack',
scope: 'agent',
initial: 0,
min: 0,
max: 10,
bestCaseValue: 10,
expiresAfterSeconds: 10,
},
],
clauses: [
{
key: 'gain-stack',
label: 'Gain one stack',
event: 'weapon_hit',
if: { kind: 'true' },
reducer: { kind: 'count', threshold: 9, reset: 'zero' },
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }],
outcomes: [
{
key: 'weapon-damage',
label: 'Weapon Damage',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'self',
basis: 'flat',
attributeId: 'replace-with-an-exact-catalog-attribute-id',
statePool: 'stacks',
perStack: true,
descriptionSlot: 1,
variants: {
'replace-with-each-required-variant-key': { value: 3 },
},
},
],
},
],
},
};
export const AI_MECHANIC_AUTHORING_INSTRUCTIONS = `You are the SHD mechanic authoring assistant for The Division Resurgence.
@@ -314,49 +417,49 @@ Response rules:
The response is machine-validated after generation. Invalid or unsupported drafts are rejected and are never saved.`;
export function buildAiMechanicAuthoringInput(input: {
request: AiMechanicAuthoringRequest;
attributes: AiMechanicAttribute[];
events: unknown[];
actions: unknown[];
recipeKeys: string[];
request: AiMechanicAuthoringRequest;
attributes: AiMechanicAttribute[];
events: unknown[];
actions: unknown[];
recipeKeys: string[];
}) {
const { request } = input;
return JSON.stringify({
task: 'Author or revise a mechanic draft from the exact in-game wording.',
source: request.context,
sourcePolicy: {
variantKeys: request.valueColumns.map((column) => column.variantKey),
valueColumns: request.valueColumns,
allowAssignedAbility: request.allowAssignedAbility,
allowProtocolUpgradeScaling: request.allowProtocolUpgradeScaling
},
clarificationTranscript: request.transcript,
currentDraft: request.currentDraft,
canonicalCatalog: {
grammar: mechanicsGrammar,
events: input.events,
actions: input.actions,
recipeKeys: input.recipeKeys,
attributes: input.attributes
},
examplesAndRules: [
'Percent values use the numeric percentage shown in game, such as 3 for +3%, rather than 0.03.',
'Live Attribute references must use {source:"attribute",attributeId:"<exact supplied catalog ID>"}. Never use an Attribute name or invent an ID.',
'An Attribute whose catalog unit is % resolves to its displayed percentage number, such as 20 for 20%. Divide that reference by 100 when wording applies it as a percentage multiplier, such as 240 × 20%.',
'combat.currentHealthPercent with timing:"current" resolves authoritative current Health divided by resolved maximum Health on a 0–100 scale. Use operator:"lt" for exact “below” wording.',
'Health threshold bands must be mutually exclusive. For thresholds 25, 50, and 75, use <25, >=25 and <50, and >=50 and <75; exact threshold values enter the next weaker band.',
'Continuous Health restoration is supported. Use kind:"restoration", resource:"health", delivery:"continuous", and a positive durationSeconds. The variant expression resolves the total restoration across that duration, not a per-second value. Do not replace continuous delivery with a periodic reducer.',
'Each continuous-restoration activation creates an independent constant-rate stream. Overlapping streams add, and restoration above the resource maximum is lost when it occurs.',
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
'If cadence is not stated, ask rather than assuming a one-second interval.',
'If wording contains separate actions such as restoration on a periodic snapshot and a health cost on every shot, represent them as separate clauses.',
'A pool with expiresAfterSeconds refreshes the shared expiry for every current stack whenever any mutation writes that pool. Use it for mechanics where gaining a stack refreshes the duration of the full stack pool.',
'Manual editing remains authoritative. Preserve a valid current draft only when it agrees with the supplied wording and answers.'
]
});
const { request } = input;
return JSON.stringify({
task: 'Author or revise a mechanic draft from the exact in-game wording.',
source: request.context,
sourcePolicy: {
variantKeys: request.valueColumns.map((column) => column.variantKey),
valueColumns: request.valueColumns,
allowAssignedAbility: request.allowAssignedAbility,
allowProtocolUpgradeScaling: request.allowProtocolUpgradeScaling,
},
clarificationTranscript: request.transcript,
currentDraft: request.currentDraft,
canonicalCatalog: {
grammar: mechanicsGrammar,
events: input.events,
actions: input.actions,
recipeKeys: input.recipeKeys,
attributes: input.attributes,
},
examplesAndRules: [
'Percent values use the numeric percentage shown in game, such as 3 for +3%, rather than 0.03.',
'Live Attribute references must use {source:"attribute",attributeId:"<exact supplied catalog ID>"}. Never use an Attribute name or invent an ID.',
'An Attribute whose catalog unit is % resolves to its displayed percentage number, such as 20 for 20%. Divide that reference by 100 when wording applies it as a percentage multiplier, such as 240 × 20%.',
'combat.currentHealthPercent with timing:"current" resolves authoritative current Health divided by resolved maximum Health on a 0–100 scale. Use operator:"lt" for exact “below” wording.',
'Health threshold bands must be mutually exclusive. For thresholds 25, 50, and 75, use <25, >=25 and <50, and >=50 and <75; exact threshold values enter the next weaker band.',
'Continuous Health restoration is supported. Use kind:"restoration", resource:"health", delivery:"continuous", and a positive durationSeconds. The variant expression resolves the total restoration across that duration, not a per-second value. Do not replace continuous delivery with a periodic reducer.',
'Each continuous-restoration activation creates an independent constant-rate stream. Overlapping streams add, and restoration above the resource maximum is lost when it occurs.',
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
'If cadence is not stated, ask rather than assuming a one-second interval.',
'If wording contains separate actions such as restoration on a periodic snapshot and a health cost on every shot, represent them as separate clauses.',
'A pool with expiresAfterSeconds refreshes the shared expiry for every current stack whenever any mutation writes that pool. Use it for mechanics where gaining a stack refreshes the duration of the full stack pool.',
'Manual editing remains authoritative. Preserve a valid current draft only when it agrees with the supplied wording and answers.',
],
});
}
@@ -1,27 +1,29 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
AiMechanicRateLimitError,
reserveAiMechanicGeneration,
resetAiMechanicRateLimitForTests
AiMechanicRateLimitError,
reserveAiMechanicGeneration,
resetAiMechanicRateLimitForTests,
} from './mechanic-authoring-rate-limit';
describe('AI mechanic authoring rate limit', () => {
beforeEach(() => resetAiMechanicRateLimitForTests());
beforeEach(() => resetAiMechanicRateLimitForTests());
it('allows only one in-flight request per account', () => {
const release = reserveAiMechanicGeneration('agent-1', 1_000);
expect(() => reserveAiMechanicGeneration('agent-1', 1_001))
.toThrow(AiMechanicRateLimitError);
release();
expect(() => reserveAiMechanicGeneration('agent-1', 1_002)).not.toThrow();
});
it('allows only one in-flight request per account', () => {
const release = reserveAiMechanicGeneration('agent-1', 1_000);
expect(() => reserveAiMechanicGeneration('agent-1', 1_001)).toThrow(
AiMechanicRateLimitError,
);
release();
expect(() => reserveAiMechanicGeneration('agent-1', 1_002)).not.toThrow();
});
it('enforces the hourly account limit', () => {
for (let request = 0; request < 10; request += 1) {
const release = reserveAiMechanicGeneration('agent-1', 10_000 + request);
release();
}
expect(() => reserveAiMechanicGeneration('agent-1', 10_100))
.toThrow('This account has reached the hourly mechanic authoring limit.');
});
it('enforces the hourly account limit', () => {
for (let request = 0; request < 10; request += 1) {
const release = reserveAiMechanicGeneration('agent-1', 10_000 + request);
release();
}
expect(() => reserveAiMechanicGeneration('agent-1', 10_100)).toThrow(
'This account has reached the hourly mechanic authoring limit.',
);
});
});
@@ -7,20 +7,20 @@ let globalRequests: number[] = [];
const inFlightUsers = new Set<string>();
export class AiMechanicRateLimitError extends Error {
retryAfterSeconds: number;
retryAfterSeconds: number;
constructor(message: string, retryAfterSeconds: number) {
super(message);
this.name = 'AiMechanicRateLimitError';
this.retryAfterSeconds = Math.max(1, Math.ceil(retryAfterSeconds));
}
constructor(message: string, retryAfterSeconds: number) {
super(message);
this.name = 'AiMechanicRateLimitError';
this.retryAfterSeconds = Math.max(1, Math.ceil(retryAfterSeconds));
}
}
const activeRequests = (requests: number[], now: number) =>
requests.filter((requestedAt) => now - requestedAt < WINDOW_MS);
requests.filter((requestedAt) => now - requestedAt < WINDOW_MS);
const retryAfter = (requests: number[], now: number) =>
requests.length ? (requests[0] + WINDOW_MS - now) / 1_000 : 1;
requests.length ? (requests[0] + WINDOW_MS - now) / 1_000 : 1;
/**
* Reserves one bounded model request. This application-level guard limits accidental
@@ -28,35 +28,35 @@ const retryAfter = (requests: number[], now: number) =>
* cross-instance spending boundary.
*/
export function reserveAiMechanicGeneration(userId: string, now = Date.now()) {
if (inFlightUsers.has(userId)) {
throw new AiMechanicRateLimitError(
'A mechanic authoring request is already running for this account.',
5
);
}
const userRequests = activeRequests(requestsByUser.get(userId) ?? [], now);
globalRequests = activeRequests(globalRequests, now);
if (userRequests.length >= USER_LIMIT) {
throw new AiMechanicRateLimitError(
'This account has reached the hourly mechanic authoring limit.',
retryAfter(userRequests, now)
);
}
if (globalRequests.length >= GLOBAL_LIMIT) {
throw new AiMechanicRateLimitError(
'The mechanic authoring service has reached its hourly request limit.',
retryAfter(globalRequests, now)
);
}
userRequests.push(now);
globalRequests.push(now);
requestsByUser.set(userId, userRequests);
inFlightUsers.add(userId);
return () => inFlightUsers.delete(userId);
if (inFlightUsers.has(userId)) {
throw new AiMechanicRateLimitError(
'A mechanic authoring request is already running for this account.',
5,
);
}
const userRequests = activeRequests(requestsByUser.get(userId) ?? [], now);
globalRequests = activeRequests(globalRequests, now);
if (userRequests.length >= USER_LIMIT) {
throw new AiMechanicRateLimitError(
'This account has reached the hourly mechanic authoring limit.',
retryAfter(userRequests, now),
);
}
if (globalRequests.length >= GLOBAL_LIMIT) {
throw new AiMechanicRateLimitError(
'The mechanic authoring service has reached its hourly request limit.',
retryAfter(globalRequests, now),
);
}
userRequests.push(now);
globalRequests.push(now);
requestsByUser.set(userId, userRequests);
inFlightUsers.add(userId);
return () => inFlightUsers.delete(userId);
}
export function resetAiMechanicRateLimitForTests() {
requestsByUser.clear();
globalRequests = [];
inFlightUsers.clear();
requestsByUser.clear();
globalRequests = [];
inFlightUsers.clear();
}
@@ -1,88 +1,100 @@
import { describe, expect, it } from 'vitest';
import {
AiMechanicRequestError,
parseAiMechanicAuthoringRequest,
parseRawAiMechanicModelResponse
AiMechanicRequestError,
parseAiMechanicAuthoringRequest,
parseRawAiMechanicModelResponse,
} from './mechanic-authoring-schema';
const request = {
context: {
sourceKind: 'talent',
sourceName: 'Superior Firepower',
sourceDescription: 'Hitting an enemy grants a stacking Weapon Damage effect.'
},
currentDraft: {
version: 1,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: []
},
valueColumns: [
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
{ key: 'tierII', variantKey: 'II', label: 'TIER II' }
],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
transcript: []
context: {
sourceKind: 'talent',
sourceName: 'Superior Firepower',
sourceDescription:
'Hitting an enemy grants a stacking Weapon Damage effect.',
},
currentDraft: {
version: 1,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: [],
},
valueColumns: [
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
{ key: 'tierII', variantKey: 'II', label: 'TIER II' },
],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
transcript: [],
};
describe('AI mechanic authoring request validation', () => {
it('accepts the server-defined variants and capabilities for a talent', () => {
expect(parseAiMechanicAuthoringRequest(request)).toEqual(request);
});
it('accepts the server-defined variants and capabilities for a talent', () => {
expect(parseAiMechanicAuthoringRequest(request)).toEqual(request);
});
it('rejects client-selected variants that do not match the source type', () => {
expect(() => parseAiMechanicAuthoringRequest({
...request,
valueColumns: [{ key: 'base', variantKey: 'base', label: 'BASE' }]
})).toThrow(AiMechanicRequestError);
});
it('rejects client-selected variants that do not match the source type', () => {
expect(() =>
parseAiMechanicAuthoringRequest({
...request,
valueColumns: [{ key: 'base', variantKey: 'base', label: 'BASE' }],
}),
).toThrow(AiMechanicRequestError);
});
it('rejects a client attempt to enable an unavailable editor capability', () => {
expect(() => parseAiMechanicAuthoringRequest({
...request,
allowAssignedAbility: true
})).toThrow('Assigned-ability scope does not match this mechanic source.');
});
it('rejects a client attempt to enable an unavailable editor capability', () => {
expect(() =>
parseAiMechanicAuthoringRequest({
...request,
allowAssignedAbility: true,
}),
).toThrow('Assigned-ability scope does not match this mechanic source.');
});
});
describe('AI mechanic authoring model response validation', () => {
it('accepts a focused clarification response', () => {
const parsed = parseRawAiMechanicModelResponse({
status: 'clarification_required',
summary: 'The stacking reset behavior changes the simulation.',
assumptions: [],
warnings: [],
questions: [{
id: 'stack-reset',
question: 'Does gaining a stack refresh the duration for every stack?',
reason: 'This determines whether the full pool expires together.',
answerType: 'boolean',
options: []
}],
draftJson: null,
gaps: []
});
expect(parsed.status).toBe('clarification_required');
expect(parsed.questions).toHaveLength(1);
});
it('accepts a focused clarification response', () => {
const parsed = parseRawAiMechanicModelResponse({
status: 'clarification_required',
summary: 'The stacking reset behavior changes the simulation.',
assumptions: [],
warnings: [],
questions: [
{
id: 'stack-reset',
question:
'Does gaining a stack refresh the duration for every stack?',
reason: 'This determines whether the full pool expires together.',
answerType: 'boolean',
options: [],
},
],
draftJson: null,
gaps: [],
});
expect(parsed.status).toBe('clarification_required');
expect(parsed.questions).toHaveLength(1);
});
it('rejects a response whose discriminated content conflicts with its status', () => {
expect(() => parseRawAiMechanicModelResponse({
status: 'draft_ready',
summary: 'Drafted.',
assumptions: [],
warnings: [],
questions: [{
id: 'unexpected',
question: 'Unexpected question',
reason: 'Conflicts with draft status',
answerType: 'text',
options: []
}],
draftJson: JSON.stringify(request.currentDraft),
gaps: []
})).toThrow('The draft response has conflicting content.');
});
it('rejects a response whose discriminated content conflicts with its status', () => {
expect(() =>
parseRawAiMechanicModelResponse({
status: 'draft_ready',
summary: 'Drafted.',
assumptions: [],
warnings: [],
questions: [
{
id: 'unexpected',
question: 'Unexpected question',
reason: 'Conflicts with draft status',
answerType: 'text',
options: [],
},
],
draftJson: JSON.stringify(request.currentDraft),
gaps: [],
}),
).toThrow('The draft response has conflicting content.');
});
});
+386 -304
View File
@@ -1,10 +1,10 @@
import {
AI_MECHANIC_SOURCE_KINDS,
AI_MECHANIC_SOURCE_PROFILES,
type AiMechanicAuthoringRequest,
type AiMechanicCapabilityGap,
type AiMechanicQuestion,
type AiMechanicSourceKind
AI_MECHANIC_SOURCE_KINDS,
AI_MECHANIC_SOURCE_PROFILES,
type AiMechanicAuthoringRequest,
type AiMechanicCapabilityGap,
type AiMechanicQuestion,
type AiMechanicSourceKind,
} from '$lib/ai/mechanic-authoring';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
@@ -16,327 +16,409 @@ const MAX_TRANSCRIPT_FIELD_LENGTH = 1_200;
type ObjectValue = Record<string, unknown>;
export type RawAiMechanicModelResponse = {
status: 'clarification_required' | 'draft_ready' | 'capability_gap';
summary: string;
assumptions: string[];
warnings: string[];
questions: AiMechanicQuestion[];
draftJson: string | null;
gaps: AiMechanicCapabilityGap[];
status: 'clarification_required' | 'draft_ready' | 'capability_gap';
summary: string;
assumptions: string[];
warnings: string[];
questions: AiMechanicQuestion[];
draftJson: string | null;
gaps: AiMechanicCapabilityGap[];
};
const isObject = (value: unknown): value is ObjectValue =>
typeof value === 'object' && value !== null && !Array.isArray(value);
typeof value === 'object' && value !== null && !Array.isArray(value);
const boundedString = (value: unknown, label: string, max: number, allowEmpty = false) => {
if (typeof value !== 'string') throw new AiMechanicRequestError(`${label} must be text.`);
const result = value.trim();
if (!allowEmpty && !result) throw new AiMechanicRequestError(`${label} is required.`);
if (result.length > max) throw new AiMechanicRequestError(`${label} is too long.`);
return result;
const boundedString = (
value: unknown,
label: string,
max: number,
allowEmpty = false,
) => {
if (typeof value !== 'string')
throw new AiMechanicRequestError(`${label} must be text.`);
const result = value.trim();
if (!allowEmpty && !result)
throw new AiMechanicRequestError(`${label} is required.`);
if (result.length > max)
throw new AiMechanicRequestError(`${label} is too long.`);
return result;
};
const exactArray = (actual: readonly string[], expected: readonly string[]) =>
actual.length === expected.length && actual.every((value, index) => value === expected[index]);
actual.length === expected.length &&
actual.every((value, index) => value === expected[index]);
export class AiMechanicRequestError extends Error {
status: number;
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = 'AiMechanicRequestError';
this.status = status;
}
constructor(message: string, status = 400) {
super(message);
this.name = 'AiMechanicRequestError';
this.status = status;
}
}
export function parseAiMechanicAuthoringRequest(value: unknown): AiMechanicAuthoringRequest {
if (!isObject(value) || !isObject(value.context)) {
throw new AiMechanicRequestError('Mechanic context is required.');
}
const sourceKind = value.context.sourceKind;
if (
typeof sourceKind !== 'string'
|| !(AI_MECHANIC_SOURCE_KINDS as readonly string[]).includes(sourceKind)
) {
throw new AiMechanicRequestError('The mechanic source type is not supported.');
}
const profile = AI_MECHANIC_SOURCE_PROFILES[sourceKind as AiMechanicSourceKind];
if (!Array.isArray(value.valueColumns) || !value.valueColumns.length) {
throw new AiMechanicRequestError('Mechanic value variants are required.');
}
const valueColumns = value.valueColumns.map((column, index) => {
if (!isObject(column)) {
throw new AiMechanicRequestError(`Value variant ${index + 1} is invalid.`);
}
return {
key: boundedString(column.key, `Value variant ${index + 1} key`, 80),
variantKey: boundedString(column.variantKey, `Value variant ${index + 1} identifier`, 80),
label: boundedString(column.label, `Value variant ${index + 1} label`, 120)
};
});
if (!exactArray(valueColumns.map((column) => column.variantKey), profile.variantKeys)) {
throw new AiMechanicRequestError(
`This mechanic source requires exactly these value variants: ${profile.variantKeys.join(', ')}.`
);
}
if (value.allowAssignedAbility !== profile.allowAssignedAbility) {
throw new AiMechanicRequestError('Assigned-ability scope does not match this mechanic source.');
}
if (value.allowProtocolUpgradeScaling !== profile.allowProtocolUpgradeScaling) {
throw new AiMechanicRequestError('Protocol-upgrade scaling does not match this mechanic source.');
}
if (!isObject(value.currentDraft)) {
throw new AiMechanicRequestError('The current mechanic draft is required.');
}
if (JSON.stringify(value.currentDraft).length > MAX_CURRENT_DRAFT_LENGTH) {
throw new AiMechanicRequestError('The current mechanic draft is too large.');
}
if (!Array.isArray(value.transcript) || value.transcript.length > MAX_TRANSCRIPT_ENTRIES) {
throw new AiMechanicRequestError(
`Clarification history may contain at most ${MAX_TRANSCRIPT_ENTRIES} answers.`
);
}
const transcript = value.transcript.map((entry, index) => {
if (!isObject(entry)) {
throw new AiMechanicRequestError(`Clarification answer ${index + 1} is invalid.`);
}
const answer = entry.answer;
if (
typeof answer !== 'string'
&& typeof answer !== 'number'
&& typeof answer !== 'boolean'
) {
throw new AiMechanicRequestError(`Clarification answer ${index + 1} is invalid.`);
}
if (typeof answer === 'string' && answer.trim().length > MAX_TRANSCRIPT_FIELD_LENGTH) {
throw new AiMechanicRequestError(`Clarification answer ${index + 1} is too long.`);
}
if (typeof answer === 'number' && !Number.isFinite(answer)) {
throw new AiMechanicRequestError(`Clarification answer ${index + 1} must be finite.`);
}
return {
questionId: boundedString(
entry.questionId,
`Clarification answer ${index + 1} identifier`,
120
),
question: boundedString(
entry.question,
`Clarification answer ${index + 1} question`,
MAX_TRANSCRIPT_FIELD_LENGTH
),
answer: typeof answer === 'string' ? answer.trim() : answer
};
});
return {
context: {
sourceKind: sourceKind as AiMechanicSourceKind,
sourceName: boundedString(value.context.sourceName, 'Mechanic name', 160, true)
|| 'Untitled mechanic',
sourceDescription: boundedString(
value.context.sourceDescription,
'In-game mechanic description',
MAX_DESCRIPTION_LENGTH
)
},
currentDraft: value.currentDraft as ConditionalMechanicDraftV1,
valueColumns,
allowAssignedAbility: profile.allowAssignedAbility,
allowProtocolUpgradeScaling: profile.allowProtocolUpgradeScaling,
transcript
};
export function parseAiMechanicAuthoringRequest(
value: unknown,
): AiMechanicAuthoringRequest {
if (!isObject(value) || !isObject(value.context)) {
throw new AiMechanicRequestError('Mechanic context is required.');
}
const sourceKind = value.context.sourceKind;
if (
typeof sourceKind !== 'string' ||
!(AI_MECHANIC_SOURCE_KINDS as readonly string[]).includes(sourceKind)
) {
throw new AiMechanicRequestError(
'The mechanic source type is not supported.',
);
}
const profile =
AI_MECHANIC_SOURCE_PROFILES[sourceKind as AiMechanicSourceKind];
if (!Array.isArray(value.valueColumns) || !value.valueColumns.length) {
throw new AiMechanicRequestError('Mechanic value variants are required.');
}
const valueColumns = value.valueColumns.map((column, index) => {
if (!isObject(column)) {
throw new AiMechanicRequestError(
`Value variant ${index + 1} is invalid.`,
);
}
return {
key: boundedString(column.key, `Value variant ${index + 1} key`, 80),
variantKey: boundedString(
column.variantKey,
`Value variant ${index + 1} identifier`,
80,
),
label: boundedString(
column.label,
`Value variant ${index + 1} label`,
120,
),
};
});
if (
!exactArray(
valueColumns.map((column) => column.variantKey),
profile.variantKeys,
)
) {
throw new AiMechanicRequestError(
`This mechanic source requires exactly these value variants: ${profile.variantKeys.join(', ')}.`,
);
}
if (value.allowAssignedAbility !== profile.allowAssignedAbility) {
throw new AiMechanicRequestError(
'Assigned-ability scope does not match this mechanic source.',
);
}
if (
value.allowProtocolUpgradeScaling !== profile.allowProtocolUpgradeScaling
) {
throw new AiMechanicRequestError(
'Protocol-upgrade scaling does not match this mechanic source.',
);
}
if (!isObject(value.currentDraft)) {
throw new AiMechanicRequestError('The current mechanic draft is required.');
}
if (JSON.stringify(value.currentDraft).length > MAX_CURRENT_DRAFT_LENGTH) {
throw new AiMechanicRequestError(
'The current mechanic draft is too large.',
);
}
if (
!Array.isArray(value.transcript) ||
value.transcript.length > MAX_TRANSCRIPT_ENTRIES
) {
throw new AiMechanicRequestError(
`Clarification history may contain at most ${MAX_TRANSCRIPT_ENTRIES} answers.`,
);
}
const transcript = value.transcript.map((entry, index) => {
if (!isObject(entry)) {
throw new AiMechanicRequestError(
`Clarification answer ${index + 1} is invalid.`,
);
}
const answer = entry.answer;
if (
typeof answer !== 'string' &&
typeof answer !== 'number' &&
typeof answer !== 'boolean'
) {
throw new AiMechanicRequestError(
`Clarification answer ${index + 1} is invalid.`,
);
}
if (
typeof answer === 'string' &&
answer.trim().length > MAX_TRANSCRIPT_FIELD_LENGTH
) {
throw new AiMechanicRequestError(
`Clarification answer ${index + 1} is too long.`,
);
}
if (typeof answer === 'number' && !Number.isFinite(answer)) {
throw new AiMechanicRequestError(
`Clarification answer ${index + 1} must be finite.`,
);
}
return {
questionId: boundedString(
entry.questionId,
`Clarification answer ${index + 1} identifier`,
120,
),
question: boundedString(
entry.question,
`Clarification answer ${index + 1} question`,
MAX_TRANSCRIPT_FIELD_LENGTH,
),
answer: typeof answer === 'string' ? answer.trim() : answer,
};
});
return {
context: {
sourceKind: sourceKind as AiMechanicSourceKind,
sourceName:
boundedString(value.context.sourceName, 'Mechanic name', 160, true) ||
'Untitled mechanic',
sourceDescription: boundedString(
value.context.sourceDescription,
'In-game mechanic description',
MAX_DESCRIPTION_LENGTH,
),
},
currentDraft: value.currentDraft as ConditionalMechanicDraftV1,
valueColumns,
allowAssignedAbility: profile.allowAssignedAbility,
allowProtocolUpgradeScaling: profile.allowProtocolUpgradeScaling,
transcript,
};
}
const nullableString = { anyOf: [{ type: 'string' }, { type: 'null' }] };
export const AI_MECHANIC_RESPONSE_SCHEMA = {
type: 'object',
additionalProperties: false,
required: [
'status',
'summary',
'assumptions',
'warnings',
'questions',
'draftJson',
'gaps'
],
properties: {
status: {
type: 'string',
enum: ['clarification_required', 'draft_ready', 'capability_gap']
},
summary: { type: 'string', maxLength: 1_200 },
assumptions: {
type: 'array',
maxItems: 12,
items: { type: 'string', maxLength: 800 }
},
warnings: {
type: 'array',
maxItems: 12,
items: { type: 'string', maxLength: 800 }
},
questions: {
type: 'array',
maxItems: 5,
items: {
type: 'object',
additionalProperties: false,
required: ['id', 'question', 'reason', 'answerType', 'options'],
properties: {
id: { type: 'string', maxLength: 120 },
question: { type: 'string', maxLength: 1_200 },
reason: { type: 'string', maxLength: 1_200 },
answerType: {
type: 'string',
enum: ['text', 'number', 'choice', 'boolean']
},
options: {
type: 'array',
maxItems: 8,
items: { type: 'string', maxLength: 240 }
}
}
}
},
draftJson: nullableString,
gaps: {
type: 'array',
maxItems: 8,
items: {
type: 'object',
additionalProperties: false,
required: [
'area',
'requiredBehavior',
'whyUnsupported',
'developerChange',
'workaround'
],
properties: {
area: {
type: 'string',
enum: [
'event',
'predicate',
'reducer',
'mutation',
'outcome',
'scope',
'value_reference',
'source_variant',
'runtime'
]
},
requiredBehavior: { type: 'string', maxLength: 1_600 },
whyUnsupported: { type: 'string', maxLength: 1_600 },
developerChange: { type: 'string', maxLength: 2_400 },
workaround: nullableString
}
}
}
}
type: 'object',
additionalProperties: false,
required: [
'status',
'summary',
'assumptions',
'warnings',
'questions',
'draftJson',
'gaps',
],
properties: {
status: {
type: 'string',
enum: ['clarification_required', 'draft_ready', 'capability_gap'],
},
summary: { type: 'string', maxLength: 1_200 },
assumptions: {
type: 'array',
maxItems: 12,
items: { type: 'string', maxLength: 800 },
},
warnings: {
type: 'array',
maxItems: 12,
items: { type: 'string', maxLength: 800 },
},
questions: {
type: 'array',
maxItems: 5,
items: {
type: 'object',
additionalProperties: false,
required: ['id', 'question', 'reason', 'answerType', 'options'],
properties: {
id: { type: 'string', maxLength: 120 },
question: { type: 'string', maxLength: 1_200 },
reason: { type: 'string', maxLength: 1_200 },
answerType: {
type: 'string',
enum: ['text', 'number', 'choice', 'boolean'],
},
options: {
type: 'array',
maxItems: 8,
items: { type: 'string', maxLength: 240 },
},
},
},
},
draftJson: nullableString,
gaps: {
type: 'array',
maxItems: 8,
items: {
type: 'object',
additionalProperties: false,
required: [
'area',
'requiredBehavior',
'whyUnsupported',
'developerChange',
'workaround',
],
properties: {
area: {
type: 'string',
enum: [
'event',
'predicate',
'reducer',
'mutation',
'outcome',
'scope',
'value_reference',
'source_variant',
'runtime',
],
},
requiredBehavior: { type: 'string', maxLength: 1_600 },
whyUnsupported: { type: 'string', maxLength: 1_600 },
developerChange: { type: 'string', maxLength: 2_400 },
workaround: nullableString,
},
},
},
},
} as const;
const stringArray = (value: unknown, label: string, max: number) => {
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string') || value.length > max) {
throw new Error(`The model returned invalid ${label}.`);
}
return value.map(String);
if (
!Array.isArray(value) ||
value.some((item) => typeof item !== 'string') ||
value.length > max
) {
throw new Error(`The model returned invalid ${label}.`);
}
return value.map(String);
};
export function parseRawAiMechanicModelResponse(value: unknown): RawAiMechanicModelResponse {
if (!isObject(value)) throw new Error('The model returned an invalid response.');
if (
value.status !== 'clarification_required'
&& value.status !== 'draft_ready'
&& value.status !== 'capability_gap'
) {
throw new Error('The model returned an unsupported response status.');
}
const summary = boundedModelString(value.summary, 'summary');
const assumptions = stringArray(value.assumptions, 'assumptions', 12);
const warnings = stringArray(value.warnings, 'warnings', 12);
if (!Array.isArray(value.questions) || value.questions.length > 5) {
throw new Error('The model returned invalid clarification questions.');
}
const questions = value.questions.map((question): AiMechanicQuestion => {
if (!isObject(question)) throw new Error('The model returned an invalid clarification question.');
if (!['text', 'number', 'choice', 'boolean'].includes(String(question.answerType))) {
throw new Error('The model returned an unsupported clarification answer type.');
}
if (!Array.isArray(question.options) || question.options.some((option) => typeof option !== 'string')) {
throw new Error('The model returned invalid clarification options.');
}
return {
id: boundedModelString(question.id, 'question identifier'),
question: boundedModelString(question.question, 'question'),
reason: boundedModelString(question.reason, 'question reason'),
answerType: question.answerType as AiMechanicQuestion['answerType'],
options: question.options.map(String)
};
});
if (value.draftJson !== null && typeof value.draftJson !== 'string') {
throw new Error('The model returned an invalid mechanic draft.');
}
if (!Array.isArray(value.gaps) || value.gaps.length > 8) {
throw new Error('The model returned invalid capability gaps.');
}
const validAreas = new Set<AiMechanicCapabilityGap['area']>([
'event',
'predicate',
'reducer',
'mutation',
'outcome',
'scope',
'value_reference',
'source_variant',
'runtime'
]);
const gaps = value.gaps.map((gap): AiMechanicCapabilityGap => {
if (!isObject(gap) || !validAreas.has(gap.area as AiMechanicCapabilityGap['area'])) {
throw new Error('The model returned an invalid capability gap.');
}
return {
area: gap.area as AiMechanicCapabilityGap['area'],
requiredBehavior: boundedModelString(gap.requiredBehavior, 'required behavior'),
whyUnsupported: boundedModelString(gap.whyUnsupported, 'unsupported behavior explanation'),
developerChange: boundedModelString(gap.developerChange, 'developer change'),
workaround: gap.workaround === null
? null
: boundedModelString(gap.workaround, 'workaround')
};
});
if (value.status === 'clarification_required') {
if (!questions.length || value.draftJson !== null || gaps.length) {
throw new Error('The clarification response has conflicting content.');
}
}
if (value.status === 'draft_ready') {
if (!value.draftJson || questions.length || gaps.length) {
throw new Error('The draft response has conflicting content.');
}
}
if (value.status === 'capability_gap') {
if (!gaps.length || questions.length || value.draftJson !== null) {
throw new Error('The capability-gap response has conflicting content.');
}
}
return {
status: value.status,
summary,
assumptions,
warnings,
questions,
draftJson: value.draftJson,
gaps
};
export function parseRawAiMechanicModelResponse(
value: unknown,
): RawAiMechanicModelResponse {
if (!isObject(value))
throw new Error('The model returned an invalid response.');
if (
value.status !== 'clarification_required' &&
value.status !== 'draft_ready' &&
value.status !== 'capability_gap'
) {
throw new Error('The model returned an unsupported response status.');
}
const summary = boundedModelString(value.summary, 'summary');
const assumptions = stringArray(value.assumptions, 'assumptions', 12);
const warnings = stringArray(value.warnings, 'warnings', 12);
if (!Array.isArray(value.questions) || value.questions.length > 5) {
throw new Error('The model returned invalid clarification questions.');
}
const questions = value.questions.map((question): AiMechanicQuestion => {
if (!isObject(question))
throw new Error('The model returned an invalid clarification question.');
if (
!['text', 'number', 'choice', 'boolean'].includes(
String(question.answerType),
)
) {
throw new Error(
'The model returned an unsupported clarification answer type.',
);
}
if (
!Array.isArray(question.options) ||
question.options.some((option) => typeof option !== 'string')
) {
throw new Error('The model returned invalid clarification options.');
}
return {
id: boundedModelString(question.id, 'question identifier'),
question: boundedModelString(question.question, 'question'),
reason: boundedModelString(question.reason, 'question reason'),
answerType: question.answerType as AiMechanicQuestion['answerType'],
options: question.options.map(String),
};
});
if (value.draftJson !== null && typeof value.draftJson !== 'string') {
throw new Error('The model returned an invalid mechanic draft.');
}
if (!Array.isArray(value.gaps) || value.gaps.length > 8) {
throw new Error('The model returned invalid capability gaps.');
}
const validAreas = new Set<AiMechanicCapabilityGap['area']>([
'event',
'predicate',
'reducer',
'mutation',
'outcome',
'scope',
'value_reference',
'source_variant',
'runtime',
]);
const gaps = value.gaps.map((gap): AiMechanicCapabilityGap => {
if (
!isObject(gap) ||
!validAreas.has(gap.area as AiMechanicCapabilityGap['area'])
) {
throw new Error('The model returned an invalid capability gap.');
}
return {
area: gap.area as AiMechanicCapabilityGap['area'],
requiredBehavior: boundedModelString(
gap.requiredBehavior,
'required behavior',
),
whyUnsupported: boundedModelString(
gap.whyUnsupported,
'unsupported behavior explanation',
),
developerChange: boundedModelString(
gap.developerChange,
'developer change',
),
workaround:
gap.workaround === null
? null
: boundedModelString(gap.workaround, 'workaround'),
};
});
if (value.status === 'clarification_required') {
if (!questions.length || value.draftJson !== null || gaps.length) {
throw new Error('The clarification response has conflicting content.');
}
}
if (value.status === 'draft_ready') {
if (!value.draftJson || questions.length || gaps.length) {
throw new Error('The draft response has conflicting content.');
}
}
if (value.status === 'capability_gap') {
if (!gaps.length || questions.length || value.draftJson !== null) {
throw new Error('The capability-gap response has conflicting content.');
}
}
return {
status: value.status,
summary,
assumptions,
warnings,
questions,
draftJson: value.draftJson,
gaps,
};
}
function boundedModelString(value: unknown, label: string) {
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`The model returned an invalid ${label}.`);
}
return value.trim();
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`The model returned an invalid ${label}.`);
}
return value.trim();
}
+203 -172
View File
@@ -1,14 +1,10 @@
import {
type AiMechanicAuthoringRequest,
type AiMechanicAuthoringResponse,
type AiMechanicResponseMeta
type AiMechanicAuthoringRequest,
type AiMechanicAuthoringResponse,
type AiMechanicResponseMeta,
} from '$lib/ai/mechanic-authoring';
import {
formatMechanicProgram
} from '$lib/calculations/mechanic';
import {
parseConditionalMechanicDraft
} from '$lib/calculations/mechanic-editor';
import { formatMechanicProgram } from '$lib/calculations/mechanic';
import { parseConditionalMechanicDraft } from '$lib/calculations/mechanic-editor';
import { TRIGGER_ACTION_KEYS } from '$lib/calculations/conditional';
import { getDatabase } from '$lib/server/database';
import { env } from '$env/dynamic/private';
@@ -17,205 +13,240 @@ import OpenAI from 'openai';
import type { Response } from 'openai/resources/responses/responses';
import { loadAiMechanicAuthoringContext } from './mechanic-authoring-context';
import {
AI_MECHANIC_AUTHORING_INSTRUCTIONS,
buildAiMechanicAuthoringInput
AI_MECHANIC_AUTHORING_INSTRUCTIONS,
buildAiMechanicAuthoringInput,
} from './mechanic-authoring-prompt';
import {
AI_MECHANIC_RESPONSE_SCHEMA,
parseRawAiMechanicModelResponse
AI_MECHANIC_RESPONSE_SCHEMA,
parseRawAiMechanicModelResponse,
} from './mechanic-authoring-schema';
const DEFAULT_MODEL = 'gpt-5.6-terra';
const MAX_OUTPUT_TOKENS = 12_000;
const ALLOWED_REASONING_EFFORT = new Set([
'none',
'low',
'medium',
'high',
'xhigh',
'max'
'none',
'low',
'medium',
'high',
'xhigh',
'max',
]);
let client: OpenAI | null = null;
function openAiClient() {
const apiKey = env.OPENAI_MECHANIC_API_KEY?.trim();
if (!apiKey) throw new AiMechanicConfigurationError('AI mechanic authoring is not configured.');
if (!client) client = new OpenAI({ apiKey, timeout: 60_000, maxRetries: 1 });
return client;
const apiKey = env.OPENAI_MECHANIC_API_KEY?.trim();
if (!apiKey)
throw new AiMechanicConfigurationError(
'AI mechanic authoring is not configured.',
);
if (!client) client = new OpenAI({ apiKey, timeout: 60_000, maxRetries: 1 });
return client;
}
function configuredReasoningEffort() {
const configured = env.OPENAI_MECHANIC_REASONING_EFFORT?.trim().toLocaleLowerCase() || 'medium';
return ALLOWED_REASONING_EFFORT.has(configured)
? configured as 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
: 'medium';
const configured =
env.OPENAI_MECHANIC_REASONING_EFFORT?.trim().toLocaleLowerCase() ||
'medium';
return ALLOWED_REASONING_EFFORT.has(configured)
? (configured as 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max')
: 'medium';
}
export class AiMechanicConfigurationError extends Error {
constructor(message: string) {
super(message);
this.name = 'AiMechanicConfigurationError';
}
constructor(message: string) {
super(message);
this.name = 'AiMechanicConfigurationError';
}
}
export class AiMechanicGenerationError extends Error {
constructor(message: string) {
super(message);
this.name = 'AiMechanicGenerationError';
}
constructor(message: string) {
super(message);
this.name = 'AiMechanicGenerationError';
}
}
function safetyIdentifier(userId: string) {
return createHash('sha256').update(`shd-mechanic-authoring:${userId}`).digest('hex');
return createHash('sha256')
.update(`shd-mechanic-authoring:${userId}`)
.digest('hex');
}
function containsProtocolUpgradeReference(value: unknown): boolean {
if (Array.isArray(value)) return value.some(containsProtocolUpgradeReference);
if (!value || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
if (
record.source === 'equipment'
&& record.path === 'protocolTalentUpgrades'
) {
return true;
}
return Object.values(record).some(containsProtocolUpgradeReference);
if (Array.isArray(value)) return value.some(containsProtocolUpgradeReference);
if (!value || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
if (
record.source === 'equipment' &&
record.path === 'protocolTalentUpgrades'
) {
return true;
}
return Object.values(record).some(containsProtocolUpgradeReference);
}
function responseMeta(
response: Response,
model: string
response: Response,
model: string,
): AiMechanicResponseMeta {
return {
requestId: String((response as Response & { _request_id?: string })._request_id ?? response.id),
model,
inputTokens: Number(response.usage?.input_tokens ?? 0),
outputTokens: Number(response.usage?.output_tokens ?? 0),
totalTokens: Number(response.usage?.total_tokens ?? 0)
};
return {
requestId: String(
(response as Response & { _request_id?: string })._request_id ??
response.id,
),
model,
inputTokens: Number(response.usage?.input_tokens ?? 0),
outputTokens: Number(response.usage?.output_tokens ?? 0),
totalTokens: Number(response.usage?.total_tokens ?? 0),
};
}
export async function generateAiMechanicDraft(
request: AiMechanicAuthoringRequest,
userId: string
request: AiMechanicAuthoringRequest,
userId: string,
): Promise<AiMechanicAuthoringResponse> {
const model = env.OPENAI_MECHANIC_MODEL?.trim() || DEFAULT_MODEL;
const catalog = await loadAiMechanicAuthoringContext();
const response = await openAiClient().responses.create({
model,
instructions: AI_MECHANIC_AUTHORING_INSTRUCTIONS,
input: buildAiMechanicAuthoringInput({ request, ...catalog }),
reasoning: { effort: configuredReasoningEffort() },
safety_identifier: safetyIdentifier(userId),
store: false,
tools: [],
max_output_tokens: MAX_OUTPUT_TOKENS,
text: {
format: {
type: 'json_schema',
name: 'shd_mechanic_authoring_response',
description: 'A validated draft, focused clarification questions, or capability gaps.',
strict: true,
schema: AI_MECHANIC_RESPONSE_SCHEMA
}
}
});
if (!response.output_text) {
throw new AiMechanicGenerationError('The AI assistant returned no usable response.');
}
let decoded: unknown;
try {
decoded = JSON.parse(response.output_text);
} catch {
throw new AiMechanicGenerationError('The AI assistant returned malformed structured data.');
}
let result;
try {
result = parseRawAiMechanicModelResponse(decoded);
} catch (error) {
throw new AiMechanicGenerationError(
error instanceof Error
? `The AI assistant returned invalid structured data: ${error.message}`
: 'The AI assistant returned invalid structured data.'
);
}
const meta = responseMeta(response, model);
if (result.status === 'clarification_required') {
return {
status: result.status,
summary: result.summary,
assumptions: result.assumptions,
warnings: result.warnings,
questions: result.questions,
meta
};
}
if (result.status === 'capability_gap') {
return {
status: result.status,
summary: result.summary,
assumptions: result.assumptions,
warnings: result.warnings,
gaps: result.gaps,
meta
};
}
let draftValue: unknown;
try {
draftValue = JSON.parse(result.draftJson as string);
} catch {
throw new AiMechanicGenerationError('The AI assistant returned malformed mechanic draft JSON.');
}
if (
!request.allowAssignedAbility
&& draftValue
&& typeof draftValue === 'object'
&& Array.isArray((draftValue as { clauses?: unknown }).clauses)
&& (draftValue as { clauses: Array<{ outcomes?: Array<{ scope?: unknown }> }> }).clauses
.some((clause) => clause.outcomes?.some((outcome) => outcome.scope === 'assigned_ability'))
) {
throw new AiMechanicGenerationError(
'The generated draft used assigned-ability scope for a source that does not support it.'
);
}
if (
!request.allowProtocolUpgradeScaling
&& containsProtocolUpgradeReference(draftValue)
) {
throw new AiMechanicGenerationError(
'The generated draft used OS Protocol upgrade scaling for a source that does not support it.'
);
}
const parsed = parseConditionalMechanicDraft(draftValue, {
variantKeys: request.valueColumns.map((column) => column.variantKey),
allowedAttributeIds: new Set(catalog.attributes.map((attribute) => attribute.id)),
allowedRecipeKeys: new Set(TRIGGER_ACTION_KEYS),
programId: `ai-draft:${request.context.sourceKind}`,
programName: request.context.sourceName
});
if (!parsed.ok) {
throw new AiMechanicGenerationError(
`The generated draft did not pass deterministic validation: ${parsed.errors.join(' ')}`
);
}
const firstVariant = request.valueColumns[0]?.variantKey;
const program = firstVariant ? parsed.value.programs[firstVariant] : undefined;
if (!program) throw new AiMechanicGenerationError('The generated draft has no supported value variant.');
return {
status: 'draft_ready',
draft: parsed.value.draft,
summary: result.summary,
assumptions: result.assumptions,
warnings: result.warnings,
pseudoScript: formatMechanicProgram(program),
meta
};
const model = env.OPENAI_MECHANIC_MODEL?.trim() || DEFAULT_MODEL;
const catalog = await loadAiMechanicAuthoringContext();
const response = await openAiClient().responses.create({
model,
instructions: AI_MECHANIC_AUTHORING_INSTRUCTIONS,
input: buildAiMechanicAuthoringInput({ request, ...catalog }),
reasoning: { effort: configuredReasoningEffort() },
safety_identifier: safetyIdentifier(userId),
store: false,
tools: [],
max_output_tokens: MAX_OUTPUT_TOKENS,
text: {
format: {
type: 'json_schema',
name: 'shd_mechanic_authoring_response',
description:
'A validated draft, focused clarification questions, or capability gaps.',
strict: true,
schema: AI_MECHANIC_RESPONSE_SCHEMA,
},
},
});
if (!response.output_text) {
throw new AiMechanicGenerationError(
'The AI assistant returned no usable response.',
);
}
let decoded: unknown;
try {
decoded = JSON.parse(response.output_text);
} catch {
throw new AiMechanicGenerationError(
'The AI assistant returned malformed structured data.',
);
}
let result;
try {
result = parseRawAiMechanicModelResponse(decoded);
} catch (error) {
throw new AiMechanicGenerationError(
error instanceof Error
? `The AI assistant returned invalid structured data: ${error.message}`
: 'The AI assistant returned invalid structured data.',
);
}
const meta = responseMeta(response, model);
if (result.status === 'clarification_required') {
return {
status: result.status,
summary: result.summary,
assumptions: result.assumptions,
warnings: result.warnings,
questions: result.questions,
meta,
};
}
if (result.status === 'capability_gap') {
return {
status: result.status,
summary: result.summary,
assumptions: result.assumptions,
warnings: result.warnings,
gaps: result.gaps,
meta,
};
}
let draftValue: unknown;
try {
draftValue = JSON.parse(result.draftJson as string);
} catch {
throw new AiMechanicGenerationError(
'The AI assistant returned malformed mechanic draft JSON.',
);
}
if (
!request.allowAssignedAbility &&
draftValue &&
typeof draftValue === 'object' &&
Array.isArray((draftValue as { clauses?: unknown }).clauses) &&
(
draftValue as {
clauses: Array<{ outcomes?: Array<{ scope?: unknown }> }>;
}
).clauses.some((clause) =>
clause.outcomes?.some((outcome) => outcome.scope === 'assigned_ability'),
)
) {
throw new AiMechanicGenerationError(
'The generated draft used assigned-ability scope for a source that does not support it.',
);
}
if (
!request.allowProtocolUpgradeScaling &&
containsProtocolUpgradeReference(draftValue)
) {
throw new AiMechanicGenerationError(
'The generated draft used OS Protocol upgrade scaling for a source that does not support it.',
);
}
const parsed = parseConditionalMechanicDraft(draftValue, {
variantKeys: request.valueColumns.map((column) => column.variantKey),
allowedAttributeIds: new Set(
catalog.attributes.map((attribute) => attribute.id),
),
allowedRecipeKeys: new Set(TRIGGER_ACTION_KEYS),
programId: `ai-draft:${request.context.sourceKind}`,
programName: request.context.sourceName,
});
if (!parsed.ok) {
throw new AiMechanicGenerationError(
`The generated draft did not pass deterministic validation: ${parsed.errors.join(' ')}`,
);
}
const firstVariant = request.valueColumns[0]?.variantKey;
const program = firstVariant
? parsed.value.programs[firstVariant]
: undefined;
if (!program)
throw new AiMechanicGenerationError(
'The generated draft has no supported value variant.',
);
return {
status: 'draft_ready',
draft: parsed.value.draft,
summary: result.summary,
assumptions: result.assumptions,
warnings: result.warnings,
pseudoScript: formatMechanicProgram(program),
meta,
};
}
export async function countActiveAiMechanicAttributes() {
const database = await getDatabase();
return Number((await (await database.prepare(
'SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1'
)).get())?.count ?? 0);
const database = await getDatabase();
return Number(
(
await (
await database.prepare(
'SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1',
)
).get()
)?.count ?? 0,
);
}
+141 -123
View File
@@ -1,151 +1,169 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
generated: true,
existing: false,
recipients: [{ id: 'admin-1' }],
notifications: [] as Array<Record<string, unknown>>,
audits: [] as Array<Record<string, unknown>>
generated: true,
existing: false,
recipients: [{ id: 'admin-1' }],
notifications: [] as Array<Record<string, unknown>>,
audits: [] as Array<Record<string, unknown>>,
}));
vi.mock('$lib/server/database', () => ({
getDatabase: async () => ({
prepare: (sql: string) => ({
get: async () => sql.includes("action='mechanic_ai.generated'")
? (mocks.generated ? { id: 'generation-audit' } : undefined)
: sql.includes("action='mechanic_gap.submitted'")
? (mocks.existing ? { id: 'existing-report' } : undefined)
: undefined,
all: async () => sql.includes('SELECT DISTINCT users.id') ? mocks.recipients : []
})
})
getDatabase: async () => ({
prepare: (sql: string) => ({
get: async () =>
sql.includes("action='mechanic_ai.generated'")
? mocks.generated
? { id: 'generation-audit' }
: undefined
: sql.includes("action='mechanic_gap.submitted'")
? mocks.existing
? { id: 'existing-report' }
: undefined
: undefined,
all: async () =>
sql.includes('SELECT DISTINCT users.id') ? mocks.recipients : [],
}),
}),
}));
vi.mock('$lib/server/notifications', () => ({
createNotification: async (input: Record<string, unknown>) => {
mocks.notifications.push(input);
return `notification-${mocks.notifications.length}`;
}
createNotification: async (input: Record<string, unknown>) => {
mocks.notifications.push(input);
return `notification-${mocks.notifications.length}`;
},
}));
vi.mock('$lib/server/audit', () => ({
recordIsacAudit: async (input: Record<string, unknown>) => {
mocks.audits.push(input);
}
recordIsacAudit: async (input: Record<string, unknown>) => {
mocks.audits.push(input);
},
}));
import {
MechanicGapReportError,
parseMechanicGapReport,
submitMechanicGapReport
MechanicGapReportError,
parseMechanicGapReport,
submitMechanicGapReport,
} from './mechanic-gap-report';
const report = {
context: {
sourceKind: 'talent',
sourceName: 'Chain Reaction',
sourceDescription: 'Every third hit transfers the mark.'
},
valueColumns: [
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
{ key: 'tierII', variantKey: 'II', label: 'TIER II' }
],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
transcript: [],
response: {
status: 'capability_gap',
summary: 'The runtime cannot select a transfer target.',
assumptions: [],
warnings: [],
gaps: [{
area: 'runtime',
requiredBehavior: 'Select the nearest eligible enemy.',
whyUnsupported: 'No target selector is available.',
developerChange: 'Add a deterministic nearby-target selector.',
workaround: null
}],
meta: {
requestId: 'req_gap_123',
model: 'test-model',
inputTokens: 100,
outputTokens: 50,
totalTokens: 150
}
}
context: {
sourceKind: 'talent',
sourceName: 'Chain Reaction',
sourceDescription: 'Every third hit transfers the mark.',
},
valueColumns: [
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
{ key: 'tierII', variantKey: 'II', label: 'TIER II' },
],
allowAssignedAbility: false,
allowProtocolUpgradeScaling: false,
transcript: [],
response: {
status: 'capability_gap',
summary: 'The runtime cannot select a transfer target.',
assumptions: [],
warnings: [],
gaps: [
{
area: 'runtime',
requiredBehavior: 'Select the nearest eligible enemy.',
whyUnsupported: 'No target selector is available.',
developerChange: 'Add a deterministic nearby-target selector.',
workaround: null,
},
],
meta: {
requestId: 'req_gap_123',
model: 'test-model',
inputTokens: 100,
outputTokens: 50,
totalTokens: 150,
},
},
};
describe('mechanic gap report validation', () => {
beforeEach(() => {
mocks.generated = true;
mocks.existing = false;
mocks.recipients = [{ id: 'admin-1' }];
mocks.notifications.length = 0;
mocks.audits.length = 0;
});
beforeEach(() => {
mocks.generated = true;
mocks.existing = false;
mocks.recipients = [{ id: 'admin-1' }];
mocks.notifications.length = 0;
mocks.audits.length = 0;
});
it('accepts a bounded capability-gap handoff that matches the source profile', () => {
expect(parseMechanicGapReport(report)).toMatchObject({
context: report.context,
response: {
status: 'capability_gap',
gaps: [{ area: 'runtime' }]
}
});
});
it('accepts a bounded capability-gap handoff that matches the source profile', () => {
expect(parseMechanicGapReport(report)).toMatchObject({
context: report.context,
response: {
status: 'capability_gap',
gaps: [{ area: 'runtime' }],
},
});
});
it('rejects a non-gap assistant response', () => {
expect(() => parseMechanicGapReport({
...report,
response: { ...report.response, status: 'draft_ready' }
})).toThrow(MechanicGapReportError);
});
it('rejects a non-gap assistant response', () => {
expect(() =>
parseMechanicGapReport({
...report,
response: { ...report.response, status: 'draft_ready' },
}),
).toThrow(MechanicGapReportError);
});
it('rejects client-selected capabilities that do not match the source profile', () => {
expect(() => parseMechanicGapReport({
...report,
allowAssignedAbility: true
})).toThrow('Assigned-ability scope does not match the mechanic source.');
});
it('rejects client-selected capabilities that do not match the source profile', () => {
expect(() =>
parseMechanicGapReport({
...report,
allowAssignedAbility: true,
}),
).toThrow('Assigned-ability scope does not match the mechanic source.');
});
it('rejects unbounded source wording', () => {
expect(() => parseMechanicGapReport({
...report,
context: { ...report.context, sourceDescription: 'x'.repeat(12_001) }
})).toThrow('Source wording is too long.');
});
it('rejects unbounded source wording', () => {
expect(() =>
parseMechanicGapReport({
...report,
context: { ...report.context, sourceDescription: 'x'.repeat(12_001) },
}),
).toThrow('Source wording is too long.');
});
it('notifies active administrators and records complete audit evidence after submission', async () => {
const result = await submitMechanicGapReport('author-1', parseMechanicGapReport(report));
it('notifies active administrators and records complete audit evidence after submission', async () => {
const result = await submitMechanicGapReport(
'author-1',
parseMechanicGapReport(report),
);
expect(result).toEqual({ alreadySubmitted: false, recipientCount: 1 });
expect(mocks.notifications).toEqual([
expect.objectContaining({
userId: 'admin-1',
category: 'isac',
mandatory: true
})
]);
expect(mocks.audits).toEqual([
expect.objectContaining({
actorId: 'author-1',
category: 'ai_assistance',
action: 'mechanic_gap.submitted',
entityId: 'mechanic_gap:req_gap_123',
after: expect.objectContaining({
requestId: 'req_gap_123',
sourceKind: 'talent',
recipientCount: 1,
gaps: report.response.gaps
})
})
]);
});
expect(result).toEqual({ alreadySubmitted: false, recipientCount: 1 });
expect(mocks.notifications).toEqual([
expect.objectContaining({
userId: 'admin-1',
category: 'isac',
mandatory: true,
}),
]);
expect(mocks.audits).toEqual([
expect.objectContaining({
actorId: 'author-1',
category: 'ai_assistance',
action: 'mechanic_gap.submitted',
entityId: 'mechanic_gap:req_gap_123',
after: expect.objectContaining({
requestId: 'req_gap_123',
sourceKind: 'talent',
recipientCount: 1,
gaps: report.response.gaps,
}),
}),
]);
});
it('suppresses repeat notifications for the same author and assistant request', async () => {
mocks.existing = true;
it('suppresses repeat notifications for the same author and assistant request', async () => {
mocks.existing = true;
expect(await submitMechanicGapReport('author-1', parseMechanicGapReport(report)))
.toEqual({ alreadySubmitted: true, recipientCount: 0 });
expect(mocks.notifications).toHaveLength(0);
expect(mocks.audits).toHaveLength(0);
});
expect(
await submitMechanicGapReport('author-1', parseMechanicGapReport(report)),
).toEqual({ alreadySubmitted: true, recipientCount: 0 });
expect(mocks.notifications).toHaveLength(0);
expect(mocks.audits).toHaveLength(0);
});
});
+273 -185
View File
@@ -1,7 +1,7 @@
import {
AI_MECHANIC_SOURCE_PROFILES,
AI_MECHANIC_SOURCE_KINDS,
type AiMechanicCapabilityGap
AI_MECHANIC_SOURCE_PROFILES,
AI_MECHANIC_SOURCE_KINDS,
type AiMechanicCapabilityGap,
} from '$lib/ai/mechanic-authoring';
import type { AiMechanicDeveloperHandoff as DeveloperHandoff } from '$lib/ai/mechanic-developer-handoff';
import { recordIsacAudit } from '$lib/server/audit';
@@ -9,156 +9,229 @@ import { getDatabase } from '$lib/server/database';
import { createNotification } from '$lib/server/notifications';
const GAP_AREAS = new Set<AiMechanicCapabilityGap['area']>([
'event',
'predicate',
'reducer',
'mutation',
'outcome',
'scope',
'value_reference',
'source_variant',
'runtime'
'event',
'predicate',
'reducer',
'mutation',
'outcome',
'scope',
'value_reference',
'source_variant',
'runtime',
]);
export class MechanicGapReportError extends Error {
constructor(message: string, readonly status = 400) {
super(message);
this.name = 'MechanicGapReportError';
}
constructor(
message: string,
readonly status = 400,
) {
super(message);
this.name = 'MechanicGapReportError';
}
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new MechanicGapReportError(`${label} must be an object.`);
}
return value as Record<string, unknown>;
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new MechanicGapReportError(`${label} must be an object.`);
}
return value as Record<string, unknown>;
}
function text(value: unknown, label: string, maxLength: number, allowEmpty = false) {
if (typeof value !== 'string') throw new MechanicGapReportError(`${label} must be text.`);
const result = value.trim();
if (!allowEmpty && !result) throw new MechanicGapReportError(`${label} is required.`);
if (result.length > maxLength) throw new MechanicGapReportError(`${label} is too long.`);
return result;
function text(
value: unknown,
label: string,
maxLength: number,
allowEmpty = false,
) {
if (typeof value !== 'string')
throw new MechanicGapReportError(`${label} must be text.`);
const result = value.trim();
if (!allowEmpty && !result)
throw new MechanicGapReportError(`${label} is required.`);
if (result.length > maxLength)
throw new MechanicGapReportError(`${label} is too long.`);
return result;
}
function stringList(value: unknown, label: string, maxItems: number) {
if (!Array.isArray(value) || value.length > maxItems) {
throw new MechanicGapReportError(`${label} must contain at most ${maxItems} items.`);
}
return value.map((item, index) => text(item, `${label} item ${index + 1}`, 2_000));
if (!Array.isArray(value) || value.length > maxItems) {
throw new MechanicGapReportError(
`${label} must contain at most ${maxItems} items.`,
);
}
return value.map((item, index) =>
text(item, `${label} item ${index + 1}`, 2_000),
);
}
export function parseMechanicGapReport(value: unknown): DeveloperHandoff {
const input = record(value, 'Mechanic gap report');
const contextInput = record(input.context, 'Context');
const sourceKind = text(contextInput.sourceKind, 'Source type', 80);
if (!AI_MECHANIC_SOURCE_KINDS.includes(sourceKind as (typeof AI_MECHANIC_SOURCE_KINDS)[number])) {
throw new MechanicGapReportError('Source type is not supported.');
}
const sourceProfile = AI_MECHANIC_SOURCE_PROFILES[
sourceKind as (typeof AI_MECHANIC_SOURCE_KINDS)[number]
];
const columnsInput = input.valueColumns;
if (!Array.isArray(columnsInput) || !columnsInput.length || columnsInput.length > 8) {
throw new MechanicGapReportError('Value columns must contain between one and eight items.');
}
const transcriptInput = input.transcript;
if (!Array.isArray(transcriptInput) || transcriptInput.length > 20) {
throw new MechanicGapReportError('Clarification history must contain at most 20 items.');
}
const responseInput = record(input.response, 'Capability gap response');
if (responseInput.status !== 'capability_gap') {
throw new MechanicGapReportError('Only capability-gap responses can be submitted.');
}
const gapsInput = responseInput.gaps;
if (!Array.isArray(gapsInput) || !gapsInput.length || gapsInput.length > 12) {
throw new MechanicGapReportError('Capability gaps must contain between one and 12 items.');
}
const metaInput = record(responseInput.meta, 'Response metadata');
const input = record(value, 'Mechanic gap report');
const contextInput = record(input.context, 'Context');
const sourceKind = text(contextInput.sourceKind, 'Source type', 80);
if (
!AI_MECHANIC_SOURCE_KINDS.includes(
sourceKind as (typeof AI_MECHANIC_SOURCE_KINDS)[number],
)
) {
throw new MechanicGapReportError('Source type is not supported.');
}
const sourceProfile =
AI_MECHANIC_SOURCE_PROFILES[
sourceKind as (typeof AI_MECHANIC_SOURCE_KINDS)[number]
];
const columnsInput = input.valueColumns;
if (
!Array.isArray(columnsInput) ||
!columnsInput.length ||
columnsInput.length > 8
) {
throw new MechanicGapReportError(
'Value columns must contain between one and eight items.',
);
}
const transcriptInput = input.transcript;
if (!Array.isArray(transcriptInput) || transcriptInput.length > 20) {
throw new MechanicGapReportError(
'Clarification history must contain at most 20 items.',
);
}
const responseInput = record(input.response, 'Capability gap response');
if (responseInput.status !== 'capability_gap') {
throw new MechanicGapReportError(
'Only capability-gap responses can be submitted.',
);
}
const gapsInput = responseInput.gaps;
if (!Array.isArray(gapsInput) || !gapsInput.length || gapsInput.length > 12) {
throw new MechanicGapReportError(
'Capability gaps must contain between one and 12 items.',
);
}
const metaInput = record(responseInput.meta, 'Response metadata');
const valueColumns = columnsInput.map((column, index) => {
const item = record(column, `Value column ${index + 1}`);
return {
key: text(item.key, `Value column ${index + 1} key`, 100),
variantKey: text(item.variantKey, `Value column ${index + 1} variant`, 100),
label: text(item.label, `Value column ${index + 1} label`, 100)
};
});
if (
valueColumns.length !== sourceProfile.variantKeys.length
|| valueColumns.some((column, index) => column.variantKey !== sourceProfile.variantKeys[index])
) {
throw new MechanicGapReportError('Value columns do not match the mechanic source.');
}
if (input.allowAssignedAbility !== sourceProfile.allowAssignedAbility) {
throw new MechanicGapReportError('Assigned-ability scope does not match the mechanic source.');
}
if (input.allowProtocolUpgradeScaling !== sourceProfile.allowProtocolUpgradeScaling) {
throw new MechanicGapReportError('Protocol upgrade scaling does not match the mechanic source.');
}
const valueColumns = columnsInput.map((column, index) => {
const item = record(column, `Value column ${index + 1}`);
return {
key: text(item.key, `Value column ${index + 1} key`, 100),
variantKey: text(
item.variantKey,
`Value column ${index + 1} variant`,
100,
),
label: text(item.label, `Value column ${index + 1} label`, 100),
};
});
if (
valueColumns.length !== sourceProfile.variantKeys.length ||
valueColumns.some(
(column, index) => column.variantKey !== sourceProfile.variantKeys[index],
)
) {
throw new MechanicGapReportError(
'Value columns do not match the mechanic source.',
);
}
if (input.allowAssignedAbility !== sourceProfile.allowAssignedAbility) {
throw new MechanicGapReportError(
'Assigned-ability scope does not match the mechanic source.',
);
}
if (
input.allowProtocolUpgradeScaling !==
sourceProfile.allowProtocolUpgradeScaling
) {
throw new MechanicGapReportError(
'Protocol upgrade scaling does not match the mechanic source.',
);
}
return {
context: {
sourceKind: sourceKind as DeveloperHandoff['context']['sourceKind'],
sourceName: text(contextInput.sourceName, 'Source name', 200, true),
sourceDescription: text(contextInput.sourceDescription, 'Source wording', 12_000)
},
valueColumns,
allowAssignedAbility: sourceProfile.allowAssignedAbility,
allowProtocolUpgradeScaling: sourceProfile.allowProtocolUpgradeScaling,
transcript: transcriptInput.map((entry, index) => {
const item = record(entry, `Clarification ${index + 1}`);
const answer = item.answer;
if (!['string', 'number', 'boolean'].includes(typeof answer)) {
throw new MechanicGapReportError(`Clarification ${index + 1} answer is invalid.`);
}
return {
questionId: text(item.questionId, `Clarification ${index + 1} ID`, 120),
question: text(item.question, `Clarification ${index + 1} question`, 1_000),
answer: typeof answer === 'string'
? text(answer, `Clarification ${index + 1} answer`, 2_000)
: answer as number | boolean
};
}),
response: {
status: 'capability_gap',
summary: text(responseInput.summary, 'Gap summary', 4_000),
assumptions: stringList(responseInput.assumptions, 'Assumptions', 20),
warnings: stringList(responseInput.warnings, 'Warnings', 20),
gaps: gapsInput.map((gap, index) => {
const item = record(gap, `Gap ${index + 1}`);
const area = text(item.area, `Gap ${index + 1} area`, 80);
if (!GAP_AREAS.has(area as AiMechanicCapabilityGap['area'])) {
throw new MechanicGapReportError(`Gap ${index + 1} area is not supported.`);
}
return {
area: area as AiMechanicCapabilityGap['area'],
requiredBehavior: text(item.requiredBehavior, `Gap ${index + 1} required behavior`, 4_000),
whyUnsupported: text(item.whyUnsupported, `Gap ${index + 1} unsupported reason`, 4_000),
developerChange: text(item.developerChange, `Gap ${index + 1} developer change`, 4_000),
workaround: item.workaround === null
? null
: text(item.workaround, `Gap ${index + 1} workaround`, 4_000)
};
}),
meta: {
requestId: text(metaInput.requestId, 'Assistant request ID', 255),
model: text(metaInput.model, 'Assistant model', 120),
inputTokens: Math.max(0, Number(metaInput.inputTokens) || 0),
outputTokens: Math.max(0, Number(metaInput.outputTokens) || 0),
totalTokens: Math.max(0, Number(metaInput.totalTokens) || 0)
}
}
};
return {
context: {
sourceKind: sourceKind as DeveloperHandoff['context']['sourceKind'],
sourceName: text(contextInput.sourceName, 'Source name', 200, true),
sourceDescription: text(
contextInput.sourceDescription,
'Source wording',
12_000,
),
},
valueColumns,
allowAssignedAbility: sourceProfile.allowAssignedAbility,
allowProtocolUpgradeScaling: sourceProfile.allowProtocolUpgradeScaling,
transcript: transcriptInput.map((entry, index) => {
const item = record(entry, `Clarification ${index + 1}`);
const answer = item.answer;
if (!['string', 'number', 'boolean'].includes(typeof answer)) {
throw new MechanicGapReportError(
`Clarification ${index + 1} answer is invalid.`,
);
}
return {
questionId: text(item.questionId, `Clarification ${index + 1} ID`, 120),
question: text(
item.question,
`Clarification ${index + 1} question`,
1_000,
),
answer:
typeof answer === 'string'
? text(answer, `Clarification ${index + 1} answer`, 2_000)
: (answer as number | boolean),
};
}),
response: {
status: 'capability_gap',
summary: text(responseInput.summary, 'Gap summary', 4_000),
assumptions: stringList(responseInput.assumptions, 'Assumptions', 20),
warnings: stringList(responseInput.warnings, 'Warnings', 20),
gaps: gapsInput.map((gap, index) => {
const item = record(gap, `Gap ${index + 1}`);
const area = text(item.area, `Gap ${index + 1} area`, 80);
if (!GAP_AREAS.has(area as AiMechanicCapabilityGap['area'])) {
throw new MechanicGapReportError(
`Gap ${index + 1} area is not supported.`,
);
}
return {
area: area as AiMechanicCapabilityGap['area'],
requiredBehavior: text(
item.requiredBehavior,
`Gap ${index + 1} required behavior`,
4_000,
),
whyUnsupported: text(
item.whyUnsupported,
`Gap ${index + 1} unsupported reason`,
4_000,
),
developerChange: text(
item.developerChange,
`Gap ${index + 1} developer change`,
4_000,
),
workaround:
item.workaround === null
? null
: text(item.workaround, `Gap ${index + 1} workaround`, 4_000),
};
}),
meta: {
requestId: text(metaInput.requestId, 'Assistant request ID', 255),
model: text(metaInput.model, 'Assistant model', 120),
inputTokens: Math.max(0, Number(metaInput.inputTokens) || 0),
outputTokens: Math.max(0, Number(metaInput.outputTokens) || 0),
totalTokens: Math.max(0, Number(metaInput.totalTokens) || 0),
},
},
};
}
function notificationBody(report: DeveloperHandoff) {
const source = report.context.sourceName || 'Untitled mechanic';
const changes = report.response.gaps.map((gap, index) =>
`${index + 1}. ${gap.area}: ${gap.developerChange}`
).join('\n');
return `${source} (${report.context.sourceKind}) needs mechanic-language support.
const source = report.context.sourceName || 'Untitled mechanic';
const changes = report.response.gaps
.map((gap, index) => `${index + 1}. ${gap.area}: ${gap.developerChange}`)
.join('\n');
return `${source} (${report.context.sourceKind}) needs mechanic-language support.
${report.response.summary}
@@ -166,24 +239,35 @@ Developer changes:
${changes}`;
}
export async function submitMechanicGapReport(actorId: string, report: DeveloperHandoff) {
const database = await getDatabase();
const requestId = report.response.meta.requestId;
const generationEntityId = `${report.context.sourceKind}:${requestId}`;
const reportEntityId = `mechanic_gap:${requestId}`;
const generated = await (await database.prepare(`SELECT id FROM audit_log
export async function submitMechanicGapReport(
actorId: string,
report: DeveloperHandoff,
) {
const database = await getDatabase();
const requestId = report.response.meta.requestId;
const generationEntityId = `${report.context.sourceKind}:${requestId}`;
const reportEntityId = `mechanic_gap:${requestId}`;
const generated = await (
await database.prepare(`SELECT id FROM audit_log
WHERE actor_id=? AND entity_type='ai_assistance' AND action='mechanic_ai.generated'
AND entity_id=? AND json_extract(after_json,'$.responseStatus')='capability_gap'
LIMIT 1`)).get(actorId, generationEntityId);
if (!generated) {
throw new MechanicGapReportError('This mechanic gap is not linked to your assistant session.', 409);
}
const existing = await (await database.prepare(`SELECT id FROM audit_log
LIMIT 1`)
).get(actorId, generationEntityId);
if (!generated) {
throw new MechanicGapReportError(
'This mechanic gap is not linked to your assistant session.',
409,
);
}
const existing = await (
await database.prepare(`SELECT id FROM audit_log
WHERE actor_id=? AND entity_type='ai_assistance' AND action='mechanic_gap.submitted'
AND entity_id=? LIMIT 1`)).get(actorId, reportEntityId);
if (existing) return { alreadySubmitted: true, recipientCount: 0 };
AND entity_id=? LIMIT 1`)
).get(actorId, reportEntityId);
if (existing) return { alreadySubmitted: true, recipientCount: 0 };
const recipients = await (await database.prepare(`
const recipients = await (
await database.prepare(`
SELECT DISTINCT users.id
FROM users
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
@@ -197,42 +281,46 @@ export async function submitMechanicGapReport(actorId: string, report: Developer
AND (status.banned_until IS NULL OR status.banned_until<=?)
AND (users.role='admin' OR role_permission.name='system.admin'
OR direct_permission.name='system.admin')
`)).all(new Date().toISOString());
if (!recipients.length) {
throw new MechanicGapReportError('No active administrators are available to receive this report.', 503);
}
`)
).all(new Date().toISOString());
if (!recipients.length) {
throw new MechanicGapReportError(
'No active administrators are available to receive this report.',
503,
);
}
const auditLink = `/isac/audit?category=ai_assistance&action=mechanic_gap.submitted&q=${encodeURIComponent(requestId)}`;
const notificationIds: string[] = [];
for (const recipient of recipients) {
const notificationId = await createNotification({
userId: String(recipient.id),
category: 'isac',
source: 'ISAC AUTHORING ASSIST',
title: `Mechanic language gap: ${report.context.sourceName || report.context.sourceKind}`,
body: notificationBody(report),
link: auditLink,
actions: [{ label: 'OPEN AUDIT EVIDENCE', href: auditLink }],
mandatory: true
});
if (notificationId) notificationIds.push(notificationId);
}
await recordIsacAudit({
actorId,
category: 'ai_assistance',
action: 'mechanic_gap.submitted',
entityId: reportEntityId,
after: {
requestId,
sourceKind: report.context.sourceKind,
sourceName: report.context.sourceName,
sourceDescription: report.context.sourceDescription,
summary: report.response.summary,
assumptions: report.response.assumptions,
warnings: report.response.warnings,
gaps: report.response.gaps,
recipientCount: notificationIds.length
}
});
return { alreadySubmitted: false, recipientCount: notificationIds.length };
const auditLink = `/isac/audit?category=ai_assistance&action=mechanic_gap.submitted&q=${encodeURIComponent(requestId)}`;
const notificationIds: string[] = [];
for (const recipient of recipients) {
const notificationId = await createNotification({
userId: String(recipient.id),
category: 'isac',
source: 'ISAC AUTHORING ASSIST',
title: `Mechanic language gap: ${report.context.sourceName || report.context.sourceKind}`,
body: notificationBody(report),
link: auditLink,
actions: [{ label: 'OPEN AUDIT EVIDENCE', href: auditLink }],
mandatory: true,
});
if (notificationId) notificationIds.push(notificationId);
}
await recordIsacAudit({
actorId,
category: 'ai_assistance',
action: 'mechanic_gap.submitted',
entityId: reportEntityId,
after: {
requestId,
sourceKind: report.context.sourceKind,
sourceName: report.context.sourceName,
sourceDescription: report.context.sourceDescription,
summary: report.response.summary,
assumptions: report.response.assumptions,
warnings: report.response.warnings,
gaps: report.response.gaps,
recipientCount: notificationIds.length,
},
});
return { alreadySubmitted: false, recipientCount: notificationIds.length };
}
+96
View File
@@ -0,0 +1,96 @@
import type { Cookies } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createHash, randomBytes } from 'node:crypto';
import type { Database } from '@tursodatabase/database';
const COOKIE_NAME = 'shd_visitor';
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{40,60}$/;
const digest = (token: string) =>
createHash('sha256').update(`shd.build:visitor:v1:${token}`).digest('hex');
export async function anonymousVisitor(database: Database, cookies: Cookies) {
let token = cookies.get(COOKIE_NAME) ?? '';
if (!TOKEN_PATTERN.test(token)) {
token = randomBytes(32).toString('base64url');
cookies.set(COOKIE_NAME, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365,
});
}
const tokenHash = digest(token);
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO anonymous_visitors(token_hash,created_at,last_seen_at) VALUES(?,?,?)
ON CONFLICT(token_hash) DO UPDATE SET last_seen_at=excluded.last_seen_at`)
).run(tokenHash, now, now);
return tokenHash;
}
export async function anonymousChallengeRequired(
database: Database,
tokenHash: string,
remoteAddress: string,
actionType: 'guide_rating' | 'guide_report',
) {
const since = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const networkHash = createHash('sha256')
.update(`shd.build:network:v1:${remoteAddress}`)
.digest('hex');
const [ratings, reports, networkActivity] = await Promise.all([
(
await database.prepare(
'SELECT COUNT(*) AS count FROM guide_ratings WHERE anonymous_token_hash=? AND updated_at>=?',
)
).get(tokenHash, since),
(
await database.prepare(
'SELECT COUNT(*) AS count FROM user_content_reports WHERE anonymous_token_hash=? AND created_at>=?',
)
).get(tokenHash, since),
(
await database.prepare(
'SELECT action_count,window_started_at FROM anonymous_activity_limits WHERE identity_hash=? AND action_type=?',
)
).get(networkHash, actionType),
]);
const networkCount =
networkActivity && String(networkActivity.window_started_at) >= since
? Number(networkActivity.action_count)
: 0;
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO anonymous_activity_limits(identity_hash,action_type,window_started_at,action_count)
VALUES(?,?,?,1) ON CONFLICT(identity_hash,action_type) DO UPDATE SET
window_started_at=CASE WHEN anonymous_activity_limits.window_started_at<? THEN excluded.window_started_at ELSE anonymous_activity_limits.window_started_at END,
action_count=CASE WHEN anonymous_activity_limits.window_started_at<? THEN 1 ELSE anonymous_activity_limits.action_count+1 END`)
).run(networkHash, actionType, now, since, since);
return (
Number(ratings?.count ?? 0) + Number(reports?.count ?? 0) >= 3 ||
networkCount >= 3
);
}
export async function verifyTurnstile(token: string, remoteIp?: string) {
const secret = env.TURNSTILE_SECRET_KEY?.trim();
if (!secret) return false;
const body = new URLSearchParams({ secret, response: token });
if (remoteIp) body.set('remoteip', remoteIp);
try {
const response = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
body,
signal: AbortSignal.timeout(10_000),
},
);
const result = (await response.json()) as { success?: boolean };
return response.ok && result.success === true;
} catch {
return false;
}
}
+204
View File
@@ -0,0 +1,204 @@
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown';
import {
moderateCommunityText,
recordCommunityModerationScan,
} from '$lib/server/community-moderation';
import { avatarUrl } from '$lib/avatar';
import { createNotification } from '$lib/server/notifications';
import { removePublicComment } from '$lib/server/comment-removal';
export async function loadArticleDiscussion(
database: Database,
articleId: string,
targetType: 'guide' | 'news',
viewerId?: string | null,
) {
const rows = await (
await database.prepare(`SELECT comments.*,COALESCE(profile.display_name,users.username,'Deleted Agent') AS author,
profile.public_slug,avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at
FROM comment_threads thread JOIN comments ON comments.thread_id=thread.id
JOIN users ON users.id=comments.author_id LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
WHERE thread.target_type=? AND thread.target_id=? AND comments.status IN ('visible','removed')
ORDER BY comments.created_at ASC LIMIT 300`)
).all(targetType, articleId);
const byParent = new Map<string | null, any[]>();
for (const row of rows) {
const parent = row.parent_id ? String(row.parent_id) : null;
const list = byParent.get(parent) ?? [];
list.push(row);
byParent.set(parent, list);
}
const ordered: any[] = [];
const visit = (parent: string | null, depth: number) => {
for (const row of byParent.get(parent) ?? []) {
ordered.push({
id: String(row.id),
parentId: row.parent_id ? String(row.parent_id) : null,
depth,
status: String(row.status),
bodyHtml:
row.status === 'visible' ? renderUserMarkdown(row.body_markdown) : '',
createdAt: String(row.created_at),
removalReason: row.removal_reason ? String(row.removal_reason) : null,
author: {
id: String(row.author_id),
displayName: String(row.author),
publicSlug: row.public_slug ? String(row.public_slug) : null,
avatarUrl: avatarUrl(
row.avatar_source,
row.avatar_value,
String(row.author_id),
row.avatar_updated_at,
),
},
canDelete:
row.status === 'visible' && viewerId === String(row.author_id),
canReport:
row.status === 'visible' &&
Boolean(viewerId) &&
viewerId !== String(row.author_id),
});
visit(String(row.id), Math.min(depth + 1, 8));
}
};
visit(null, 0);
return ordered;
}
export async function postArticleComment(input: {
database: Database;
article: {
id: string;
kind: 'guide' | 'news';
ownerId: string;
title: string;
slug: string;
};
author: { id: string };
body: unknown;
parentId?: string | null;
}) {
const body = normalizeUserMarkdown(input.body, 2_000);
if (!body || [...body].length < 2)
return {
ok: false as const,
status: 400,
message: 'Comments must contain between 2 and 2,000 characters.',
};
const rapid = await (
await input.database
.prepare(`SELECT 1 FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.author_id=? AND thread.target_type=? AND thread.target_id=? AND comments.created_at>=? LIMIT 1`)
).get(
input.author.id,
input.article.kind,
input.article.id,
new Date(Date.now() - 10_000).toISOString(),
);
if (rapid)
return {
ok: false as const,
status: 429,
message: 'Wait a few seconds before posting again.',
};
const moderation = await moderateCommunityText({
contentType: 'comment',
text: body,
});
const now = new Date().toISOString();
const threadId = `${input.article.kind}:${input.article.id}`;
await (
await input.database.prepare(
`INSERT OR IGNORE INTO comment_threads(id,target_type,target_id,created_at) VALUES(?,?,?,?)`,
)
).run(threadId, input.article.kind, input.article.id, now);
let parentAuthorId: string | null = null;
if (input.parentId) {
const parent = await (
await input.database.prepare(
"SELECT author_id FROM comments WHERE id=? AND thread_id=? AND status='visible'",
)
).get(input.parentId, threadId);
if (!parent)
return {
ok: false as const,
status: 400,
message: 'The message you are replying to is unavailable.',
};
parentAuthorId = String(parent.author_id);
}
const id = randomUUID();
const status =
moderation.decision === 'approved' ? 'visible' : 'pending_review';
await (
await input.database
.prepare(`INSERT INTO comments(id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`)
).run(
id,
threadId,
input.author.id,
input.parentId ?? null,
body,
status,
now,
now,
);
await recordCommunityModerationScan({
contentType: 'comment',
contentId: id,
authorId: input.author.id,
result: moderation,
});
if (status === 'visible') {
const recipient =
parentAuthorId && parentAuthorId !== input.author.id
? parentAuthorId
: input.article.ownerId !== input.author.id
? input.article.ownerId
: null;
if (recipient)
await createNotification({
userId: recipient,
category: 'comments',
source: 'agent',
title: input.parentId
? `New ${input.article.kind} reply`
: `New ${input.article.kind} comment`,
body: `An Agent commented on ${input.article.title}.`,
link: `/${input.article.kind === 'guide' ? 'guides' : 'news'}/${encodeURIComponent(input.article.slug)}#comment-${id}`,
actions: [
{
label: 'VIEW COMMENT',
href: `/${input.article.kind === 'guide' ? 'guides' : 'news'}/${encodeURIComponent(input.article.slug)}#comment-${id}`,
},
],
});
}
return { ok: true as const, id, pending: status !== 'visible' };
}
export async function removeArticleComment(
database: Database,
commentId: string,
articleId: string,
targetType: 'guide' | 'news',
userId: string,
) {
const comment = await (
await database.prepare(`SELECT comments.author_id FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.id=? AND comments.status='visible' AND thread.target_type=? AND thread.target_id=?`)
).get(commentId, targetType, articleId);
if (!comment || comment.author_id !== userId) return false;
return (
await removePublicComment({
database,
commentId,
removedBy: userId,
removalReason: 'Removed by poster',
})
).changed;
}
+48
View File
@@ -0,0 +1,48 @@
import type { AuthUser } from '$lib/server/auth/session';
import {
saveArticleDraft,
submitArticle,
type ArticleVisibility,
} from '$lib/server/articles';
export async function saveAndSubmitArticleForm(input: {
form: FormData;
user: AuthUser;
canManageNews: boolean;
}) {
let tagIds: unknown[] = [];
try {
const parsed = JSON.parse(String(input.form.get('tagIds') ?? '[]'));
if (Array.isArray(parsed)) tagIds = parsed;
} catch {
return {
ok: false as const,
status: 400,
message: 'The selected article tags are invalid.',
};
}
const articleId = String(input.form.get('articleId') ?? '');
const saved = await saveArticleDraft({
articleId,
actorId: input.user.id,
canManageNews: input.canManageNews,
title: input.form.get('title'),
deck: input.form.get('deck'),
document: input.form.get('document'),
tagIds,
channel: input.form.get('channel'),
coverImageId: input.form.get('coverImageId'),
coverAlt: input.form.get('coverAlt'),
coverCaption: input.form.get('coverCaption'),
version: Number(input.form.get('version')),
});
if (!saved.ok) return saved;
return submitArticle({
articleId,
actorId: input.user.id,
canManageNews: input.canManageNews,
visibility: String(
input.form.get('visibility') ?? 'private',
) as ArticleVisibility,
});
}
+365
View File
@@ -0,0 +1,365 @@
import { env } from '$env/dynamic/private';
import { fail, error } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session';
import { getPermissions, hasPermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { loadPublishedArticle, type ArticleKind } from '$lib/server/articles';
import {
loadArticleDiscussion,
postArticleComment,
removeArticleComment,
} from '$lib/server/article-discussions';
import {
anonymousChallengeRequired,
anonymousVisitor,
verifyTurnstile,
} from '$lib/server/anonymous-visitors';
import { createUserContentReport } from '$lib/server/community-moderation';
import { recordIsacAudit } from '$lib/server/audit';
import type { Actions } from '@sveltejs/kit';
const REPORT_CATEGORIES = new Set([
'spam',
'harassment',
'hate',
'sexual',
'violence',
'self_harm',
'privacy',
'other',
]);
async function target(kind: ArticleKind, slug: string) {
const row = await (
await (
await getDatabase()
)
.prepare(`SELECT articles.id,articles.owner_id,articles.slug,articles.visibility,
articles.published_revision_id,revision.title FROM articles
JOIN article_revisions revision ON revision.id=articles.published_revision_id
WHERE articles.kind=? AND articles.slug=? AND articles.deleted_at IS NULL AND articles.visibility IN ('public','unlisted')`)
).get(kind, slug);
if (!row) error(404, 'Article not found');
return {
id: String(row.id),
ownerId: String(row.owner_id),
slug: String(row.slug),
visibility: String(row.visibility),
revisionId: String(row.published_revision_id),
title: String(row.title),
kind,
};
}
export async function loadArticlePage(
kind: ArticleKind,
slug: string,
cookies: Parameters<typeof getSessionUser>[0],
) {
const viewer = await getSessionUser(cookies);
const article = await loadPublishedArticle(kind, slug, viewer?.id);
const database = await getDatabase();
const comments = await loadArticleDiscussion(
database,
article.article.id,
kind,
viewer?.id,
);
const permissions = viewer ? await getPermissions(viewer) : [];
return {
...article,
comments,
viewer: viewer ? { id: viewer.id } : null,
canManageNews:
kind === 'news' &&
(permissions.includes('*') || permissions.includes('news.manage')),
canCurate:
kind === 'guide' &&
(permissions.includes('*') || permissions.includes('guides.curate')),
turnstileSiteKey: env.TURNSTILE_SITE_KEY?.trim() || '',
};
}
export function articlePageActions(kind: ArticleKind): Actions {
return {
comment: async ({ params, cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) return fail(401, { commentError: 'Sign in to comment.' });
const article = await target(kind, params.slug!);
const form = await request.formData();
const result = await postArticleComment({
database: await getDatabase(),
article,
author: user,
body: form.get('body'),
parentId: String(form.get('parentId') ?? '') || null,
});
return result.ok
? {
commentMessage: result.pending
? 'Comment queued for Community Oversight.'
: 'Comment posted.',
}
: fail(result.status, { commentError: result.message });
},
removeComment: async ({ params, cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user)
return fail(401, { commentError: 'Sign in to remove a comment.' });
const article = await target(kind, params.slug!);
const commentId = String(
(await request.formData()).get('commentId') ?? '',
);
return (await removeArticleComment(
await getDatabase(),
commentId,
article.id,
kind,
user.id,
))
? { commentMessage: 'Comment removed.' }
: fail(403, { commentError: 'You cannot remove that comment.' });
},
reportContent: async ({ params, cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user)
return fail(401, { reportError: 'Sign in to report a comment.' });
const article = await target(kind, params.slug!);
const form = await request.formData();
if (String(form.get('contentType')) !== 'comment')
return fail(400, {
reportError: 'Only comments use this report action.',
});
const contentId = String(form.get('contentId') ?? '');
const category = String(form.get('category') ?? '');
const details = String(form.get('details') ?? '')
.normalize('NFKC')
.trim()
.slice(0, 500);
if (!REPORT_CATEGORIES.has(category))
return fail(400, { reportError: 'Choose a report reason.' });
const comment = await (
await (
await getDatabase()
)
.prepare(`SELECT comments.author_id FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.id=? AND comments.status='visible' AND thread.target_type=? AND thread.target_id=?`)
).get(contentId, kind, article.id);
if (!comment || comment.author_id === user.id)
return fail(400, { reportError: 'That comment cannot be reported.' });
const report = await createUserContentReport({
contentType: 'comment',
contentId,
reporterId: user.id,
category: category as any,
details,
});
return report
? { reportCreated: true, reportContentId: contentId }
: fail(409, {
reportError: 'You already have a pending report for this comment.',
});
},
rate: async ({ params, cookies, request, getClientAddress }) => {
if (kind !== 'guide')
return fail(404, {
ratingError: 'Ratings are available only for Guides.',
});
const article = await target(kind, params.slug!);
const form = await request.formData();
const value = Number(form.get('value'));
if (!Number.isInteger(value) || value < 1 || value > 5)
return fail(400, { ratingError: 'Choose a rating from 1 to 5.' });
const database = await getDatabase();
const user = await getSessionUser(cookies);
let anonymousHash: string | null = null;
if (!user) {
anonymousHash = await anonymousVisitor(database, cookies);
if (
await anonymousChallengeRequired(
database,
anonymousHash,
getClientAddress(),
'guide_rating',
)
) {
const verified = await verifyTurnstile(
String(form.get('cf-turnstile-response') ?? ''),
getClientAddress(),
);
if (!verified)
return fail(403, {
ratingError: 'Complete the verification challenge.',
articleChallengeRequired: true,
});
}
} else if (user.id === article.ownerId)
return fail(400, {
ratingError: 'Authors cannot rate their own Guide.',
});
const now = new Date().toISOString();
if (user)
await (
await database.prepare(`INSERT INTO guide_ratings(id,article_id,revision_id,rater_user_id,value,created_at,updated_at)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(article_id,rater_user_id) WHERE rater_user_id IS NOT NULL DO UPDATE SET
revision_id=excluded.revision_id,value=excluded.value,updated_at=excluded.updated_at`)
).run(
randomUUID(),
article.id,
article.revisionId,
user.id,
value,
now,
now,
);
else
await (
await database.prepare(`INSERT INTO guide_ratings(id,article_id,revision_id,anonymous_token_hash,value,created_at,updated_at)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(article_id,anonymous_token_hash) WHERE anonymous_token_hash IS NOT NULL DO UPDATE SET
revision_id=excluded.revision_id,value=excluded.value,updated_at=excluded.updated_at`)
).run(
randomUUID(),
article.id,
article.revisionId,
anonymousHash,
value,
now,
now,
);
return { ratingSaved: true, ratingValue: value };
},
reportArticle: async ({ params, cookies, request, getClientAddress }) => {
if (kind !== 'guide')
return fail(404, {
articleReportError: 'Article reports are available for Guides.',
});
const article = await target(kind, params.slug!);
const form = await request.formData();
const category = String(form.get('category') ?? '');
const details = String(form.get('details') ?? '')
.normalize('NFKC')
.trim()
.slice(0, 500);
if (!REPORT_CATEGORIES.has(category))
return fail(400, { articleReportError: 'Choose a report reason.' });
const database = await getDatabase();
const user = await getSessionUser(cookies);
if (user?.id === article.ownerId)
return fail(400, {
articleReportError: 'Use the editor to withdraw your own Guide.',
});
let anonymousHash: string | null = null;
if (!user) {
anonymousHash = await anonymousVisitor(database, cookies);
if (
await anonymousChallengeRequired(
database,
anonymousHash,
getClientAddress(),
'guide_report',
)
) {
const verified = await verifyTurnstile(
String(form.get('cf-turnstile-response') ?? ''),
getClientAddress(),
);
if (!verified)
return fail(403, {
articleReportError: 'Complete the verification challenge.',
articleChallengeRequired: true,
});
}
}
const now = new Date().toISOString();
const result = await (
await database.prepare(`INSERT OR IGNORE INTO user_content_reports(
id,content_type,content_id,reporter_id,anonymous_token_hash,article_revision_id,category,details,status,created_at
) VALUES(?,'guide',?,?,?,?,?,?,'pending',?)`)
).run(
randomUUID(),
article.id,
user?.id ?? null,
anonymousHash,
article.revisionId,
category,
details || null,
now,
);
return result.changes
? { articleReportCreated: true }
: fail(409, {
articleReportError:
'A report from this browser is already pending.',
});
},
curate: async ({ params, cookies, request }) => {
if (kind !== 'guide')
return fail(404, { curationError: 'Only Guides can be curated.' });
const user = await getSessionUser(cookies);
if (!user || !(await hasPermission(user, 'guides.curate')))
return fail(403, {
curationError: 'Guide curation permission required.',
});
const article = await target(kind, params.slug!);
if (article.visibility !== 'public')
return fail(400, {
curationError: 'Only public Guides can be curated.',
});
const form = await request.formData();
const placement = String(form.get('placement') ?? '');
const enabled = form.get('enabled') === '1';
if (!['pinned', 'featured', 'recommended'].includes(placement))
return fail(400, {
curationError: 'Choose a valid curation placement.',
});
const database = await getDatabase();
const before =
(await (
await database.prepare(
'SELECT * FROM guide_curations WHERE article_id=?',
)
).get(article.id)) ?? null;
const current = {
pinned_order: before?.pinned_order ?? null,
featured: Number(before?.featured ?? 0),
recommended: Number(before?.recommended ?? 0),
};
if (placement === 'pinned') {
const next = enabled
? await (
await database.prepare(
'SELECT COALESCE(MAX(pinned_order),-1)+1 AS position FROM guide_curations',
)
).get()
: null;
current.pinned_order = enabled ? Number(next?.position ?? 0) : null;
}
if (placement === 'featured') current.featured = enabled ? 1 : 0;
if (placement === 'recommended') current.recommended = enabled ? 1 : 0;
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO guide_curations(article_id,pinned_order,featured,recommended,curated_revision_id,curated_by,curated_at)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(article_id) DO UPDATE SET pinned_order=excluded.pinned_order,featured=excluded.featured,
recommended=excluded.recommended,curated_revision_id=excluded.curated_revision_id,curated_by=excluded.curated_by,curated_at=excluded.curated_at`)
).run(
article.id,
current.pinned_order,
current.featured,
current.recommended,
article.revisionId,
user.id,
now,
);
await recordIsacAudit({
actorId: user.id,
category: 'community_moderation',
action: `guide.${placement}_${enabled ? 'enabled' : 'disabled'}`,
entityId: article.id,
before,
after: { ...current, revisionId: article.revisionId },
});
return { curationSaved: true };
},
};
}
File diff suppressed because it is too large Load Diff
+54 -40
View File
@@ -1,51 +1,65 @@
import type { Database } from '@tursodatabase/database';
import { getDatabase } from '$lib/server/database';
import { randomUUID } from 'node:crypto';
type AuditValue = Record<string, unknown> | unknown[] | null;
export async function recordIsacAudit(input: {
actorId: string;
category: string;
action: string;
entityId: string;
before?: AuditValue;
after?: AuditValue;
actorId: string;
category: string;
action: string;
entityId: string;
before?: AuditValue;
after?: AuditValue;
database?: Database;
createdAt?: string;
}) {
const database = await getDatabase();
await (await database.prepare(`INSERT INTO audit_log
const database = input.database ?? (await getDatabase());
await (
await database.prepare(`INSERT INTO audit_log
(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at)
VALUES(?,?,?,?,?,?,?,?)`)).run(
randomUUID(),
input.actorId,
input.category,
input.entityId,
input.action,
input.before === undefined ? null : JSON.stringify(input.before),
input.after === undefined ? null : JSON.stringify(input.after),
new Date().toISOString()
);
VALUES(?,?,?,?,?,?,?,?)`)
).run(
randomUUID(),
input.actorId,
input.category,
input.entityId,
input.action,
input.before === undefined ? null : JSON.stringify(input.before),
input.after === undefined ? null : JSON.stringify(input.after),
input.createdAt ?? new Date().toISOString(),
);
}
export async function auditRecord(table: string, id: string): Promise<Record<string, unknown> | null> {
if (![
'stat_attributes',
'talents',
'brand_sets',
'gear',
'weapons',
'weapon_mods',
'gear_mods',
'os_protocols',
'skill_chip_sets',
'specializations',
'specialization_focuses',
'specialization_abilities',
'specialization_combat_effects',
'specialization_tree_nodes',
'roles'
].includes(table)) {
throw new Error('Unsupported audit table');
}
const database = await getDatabase();
return await (await database.prepare(`SELECT * FROM ${table} WHERE id=?`)).get(id) ?? null;
export async function auditRecord(
table: string,
id: string,
): Promise<Record<string, unknown> | null> {
if (
![
'stat_attributes',
'talents',
'brand_sets',
'gear',
'weapons',
'weapon_mods',
'gear_mods',
'os_protocols',
'skill_chip_sets',
'specializations',
'specialization_focuses',
'specialization_abilities',
'specialization_combat_effects',
'specialization_tree_nodes',
'roles',
].includes(table)
) {
throw new Error('Unsupported audit table');
}
const database = await getDatabase();
return (
(await (
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
).get(id)) ?? null
);
}
+102 -47
View File
@@ -4,31 +4,46 @@ import { deleteUploadedAvatar } from '$lib/server/avatars';
import { displayNameKey } from '$lib/agents';
export async function softDeleteAccount(
database: Database,
userId: string,
deletedBy: string,
reason: string | null,
purgeSecurityData = false
database: Database,
userId: string,
deletedBy: string,
reason: string | null,
purgeSecurityData = false,
): Promise<boolean> {
const user = await (await database.prepare(`
const user = await (
await database.prepare(`
SELECT users.username,users.login_username,user_profiles.display_name,deleted_accounts.deleted_at
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE users.id=?
`)).get(userId);
if (!user || user.deleted_at) return false;
const now = new Date().toISOString();
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
`)
).get(userId);
if (!user || user.deleted_at) return false;
const now = new Date().toISOString();
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
await (await database.prepare(
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)'
)).run(userId, purgeSecurityData ? deletedUsername : String(user.login_username ?? user.username), now, deletedBy, reason);
await (await database.prepare(
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`
)).run(deletedUsername, userId);
await (await database.prepare(
`INSERT INTO user_profiles(user_id,display_name,display_name_key,bio_markdown,is_public,bio_updated_at,updated_at)
await (
await database.prepare(
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)',
)
).run(
userId,
purgeSecurityData
? deletedUsername
: String(user.login_username ?? user.username),
now,
deletedBy,
reason,
);
await (
await database.prepare(
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`,
)
).run(deletedUsername, userId);
await (
await database.prepare(
`INSERT INTO user_profiles(user_id,display_name,display_name_key,bio_markdown,is_public,bio_updated_at,updated_at)
VALUES(?,?,?,?,0,?,?)
ON CONFLICT(user_id) DO UPDATE SET
display_name=excluded.display_name,
@@ -36,32 +51,72 @@ export async function softDeleteAccount(
bio_markdown='',
is_public=0,
bio_updated_at=excluded.bio_updated_at,
updated_at=excluded.updated_at`
)).run(userId, deletedDisplayName, displayNameKey(deletedDisplayName), '', now, now);
await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM passkeys WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM user_emails WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM oauth_states WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(userId);
await deleteUploadedAvatar(database, userId);
if (purgeSecurityData) {
await (await database.prepare('DELETE FROM login_events WHERE user_id=?')).run(userId);
}
await (await database.prepare(
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'
)).run(
randomUUID(),
deletedBy,
'user_access',
userId,
'account.soft_deleted',
purgeSecurityData ? JSON.stringify({ purgedAtOwnerRequest: true }) : JSON.stringify({ username: user.username, displayName: user.display_name }),
JSON.stringify({ username: deletedUsername, displayName: 'Deleted Agent', reason, securityDataPurged: purgeSecurityData }),
now
);
return true;
updated_at=excluded.updated_at`,
)
).run(
userId,
deletedDisplayName,
displayNameKey(deletedDisplayName),
'',
now,
now,
);
await (
await database.prepare('DELETE FROM sessions WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM passkeys WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM user_emails WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM oauth_states WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
).run(userId);
await (
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
).run(userId);
await deleteUploadedAvatar(database, userId);
if (purgeSecurityData) {
await (
await database.prepare('DELETE FROM login_events WHERE user_id=?')
).run(userId);
}
await (
await database.prepare(
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
)
).run(
randomUUID(),
deletedBy,
'user_access',
userId,
'account.soft_deleted',
purgeSecurityData
? JSON.stringify({ purgedAtOwnerRequest: true })
: JSON.stringify({
username: user.username,
displayName: user.display_name,
}),
JSON.stringify({
username: deletedUsername,
displayName: 'Deleted Agent',
reason,
securityDataPurged: purgeSecurityData,
}),
now,
);
return true;
}
+49 -24
View File
@@ -2,13 +2,18 @@ import { getDatabase } from '$lib/server/database';
import type { AuthUser } from './session';
import { error } from '@sveltejs/kit';
const REGISTERED_AGENT_PERMISSIONS = ['isac.access', 'data.view', 'data.propose'] as const;
const REGISTERED_AGENT_PERMISSIONS = [
'isac.access',
'data.view',
'data.propose',
] as const;
export async function getPermissions(user: AuthUser | null): Promise<string[]> {
if (!user) return [];
if (user.role === 'admin') return ['*'];
const database = await getDatabase();
const rows = await (await database.prepare(`
if (!user) return [];
if (user.role === 'admin') return ['*'];
const database = await getDatabase();
const rows = await (
await database.prepare(`
SELECT permissions.name FROM permissions
JOIN role_permissions ON role_permissions.permission_id=permissions.id
JOIN user_roles ON user_roles.role_id=role_permissions.role_id
@@ -17,32 +22,52 @@ export async function getPermissions(user: AuthUser | null): Promise<string[]> {
SELECT permissions.name FROM permissions
JOIN user_permissions ON user_permissions.permission_id=permissions.id
WHERE user_permissions.user_id=?
`)).all(user.id, user.id);
const permissions = [...new Set([...REGISTERED_AGENT_PERMISSIONS, ...rows.map((row) => String(row.name))])];
return permissions.includes('system.admin') ? ['*'] : permissions;
`)
).all(user.id, user.id);
const permissions = [
...new Set([
...REGISTERED_AGENT_PERMISSIONS,
...rows.map((row) => String(row.name)),
]),
];
return permissions.includes('system.admin') ? ['*'] : permissions;
}
export async function hasPermission(user: AuthUser | null, permission: string): Promise<boolean> {
const permissions = await getPermissions(user);
return permissions.includes('*') || permissions.includes(permission);
export async function hasPermission(
user: AuthUser | null,
permission: string,
): Promise<boolean> {
const permissions = await getPermissions(user);
return permissions.includes('*') || permissions.includes(permission);
}
export async function requirePermission(user: AuthUser | null, permission: string): Promise<AuthUser> {
if (!user) error(401, 'Sign in required');
if (!(await hasPermission(user, permission))) error(403, 'ISAC permission required');
return user;
export async function requirePermission(
user: AuthUser | null,
permission: string,
): Promise<AuthUser> {
if (!user) error(401, 'Sign in required');
if (!(await hasPermission(user, permission)))
error(403, 'ISAC permission required');
return user;
}
export async function requireDataView(user: AuthUser | null): Promise<AuthUser> {
if (!user) error(401, 'Sign in required');
const permissions = await getPermissions(user);
if (!permissions.includes('*') && !['data.view', 'data.manage', 'data.propose', 'data.approve'].some((permission) => permissions.includes(permission))) {
error(403, 'ISAC data access required');
}
return user;
export async function requireDataView(
user: AuthUser | null,
): Promise<AuthUser> {
if (!user) error(401, 'Sign in required');
const permissions = await getPermissions(user);
if (
!permissions.includes('*') &&
!['data.view', 'data.manage', 'data.propose', 'data.approve'].some(
(permission) => permissions.includes(permission),
)
) {
error(403, 'ISAC data access required');
}
return user;
}
export function requireAdmin(user: AuthUser | null): asserts user is AuthUser {
if (!user) error(401, 'Sign in required');
if (user.role !== 'admin') error(403, 'Administrator permission required');
if (!user) error(401, 'Sign in required');
if (user.role !== 'admin') error(403, 'Administrator permission required');
}
+6 -3
View File
@@ -4,8 +4,11 @@ import type { Database } from '@tursodatabase/database';
* Atomically promotes the authenticated user when no administrator exists.
* The conditional UPDATE ensures concurrent first sign-ins cannot both win.
*/
export async function ensureBootstrapAdmin(database: Database, userId: string): Promise<void> {
const promote = await database.prepare(`
export async function ensureBootstrapAdmin(
database: Database,
userId: string,
): Promise<void> {
const promote = await database.prepare(`
UPDATE users
SET role = 'admin'
WHERE id = ?
@@ -13,5 +16,5 @@ export async function ensureBootstrapAdmin(database: Database, userId: string):
SELECT 1 FROM users WHERE role = 'admin'
)
`);
await promote.run(userId);
await promote.run(userId);
}
+39 -16
View File
@@ -4,26 +4,49 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
const MAGIC_LINK_MINUTES = 15;
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
return createHash('sha256').update(token).digest('hex');
}
export async function createMagicLinkToken(database: Database, userId: string, email: string, purpose: 'login' | 'verify_email'): Promise<string> {
const token = randomBytes(32).toString('base64url');
const now = new Date();
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=? AND purpose=?')).run(userId, purpose);
await (await database.prepare(`INSERT INTO magic_link_tokens(id,user_id,email,purpose,token_hash,expires_at,created_at)
VALUES(?,?,?,?,?,?,?)`)).run(
randomUUID(), userId, email, purpose, hashToken(token),
new Date(now.getTime() + MAGIC_LINK_MINUTES * 60_000).toISOString(), now.toISOString()
);
return token;
export async function createMagicLinkToken(
database: Database,
userId: string,
email: string,
purpose: 'login' | 'verify_email',
): Promise<string> {
const token = randomBytes(32).toString('base64url');
const now = new Date();
await (
await database.prepare(
'DELETE FROM magic_link_tokens WHERE user_id=? AND purpose=?',
)
).run(userId, purpose);
await (
await database.prepare(`INSERT INTO magic_link_tokens(id,user_id,email,purpose,token_hash,expires_at,created_at)
VALUES(?,?,?,?,?,?,?)`)
).run(
randomUUID(),
userId,
email,
purpose,
hashToken(token),
new Date(now.getTime() + MAGIC_LINK_MINUTES * 60_000).toISOString(),
now.toISOString(),
);
return token;
}
export async function consumeMagicLinkToken(database: Database, token: string) {
const now = new Date().toISOString();
const row = await (await database.prepare(`UPDATE magic_link_tokens SET consumed_at=?
const now = new Date().toISOString();
const row = await (
await database.prepare(`UPDATE magic_link_tokens SET consumed_at=?
WHERE token_hash=? AND consumed_at IS NULL AND expires_at>?
RETURNING id,user_id,email,purpose`)).get(now, hashToken(token), now);
if (!row) return null;
return { id: String(row.id), userId: String(row.user_id), email: String(row.email), purpose: String(row.purpose) as 'login' | 'verify_email' };
RETURNING id,user_id,email,purpose`)
).get(now, hashToken(token), now);
if (!row) return null;
return {
id: String(row.id),
userId: String(row.user_id),
email: String(row.email),
purpose: String(row.purpose) as 'login' | 'verify_email',
};
}
+61 -38
View File
@@ -4,55 +4,78 @@ import nodemailer from 'nodemailer';
let transporter: ReturnType<typeof nodemailer.createTransport> | undefined;
function mailer() {
if (!env.SMTP_HOST || !env.SMTP_FROM) {
throw new Error('Email delivery is not configured.');
}
if (!transporter) {
transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: Number(env.SMTP_PORT || 587),
secure: env.SMTP_SECURE === 'true',
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD } : undefined,
requireTLS: env.SMTP_SECURE !== 'true',
disableFileAccess: true,
disableUrlAccess: true
});
}
return transporter;
if (!env.SMTP_HOST || !env.SMTP_FROM) {
throw new Error('Email delivery is not configured.');
}
if (!transporter) {
transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: Number(env.SMTP_PORT || 587),
secure: env.SMTP_SECURE === 'true',
auth: env.SMTP_USER
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD }
: undefined,
requireTLS: env.SMTP_SECURE !== 'true',
disableFileAccess: true,
disableUrlAccess: true,
});
}
return transporter;
}
export function applicationOrigin(): string {
return (env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
return (env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
}
export function emailDeliveryConfigured(): boolean {
return Boolean(env.SMTP_HOST && env.SMTP_FROM);
return Boolean(env.SMTP_HOST && env.SMTP_FROM);
}
export async function sendAuthenticationLink(email: string, token: string, purpose: 'login' | 'verify_email'): Promise<void> {
const url = `${applicationOrigin()}/auth/magic-link?token=${encodeURIComponent(token)}`;
const subject = purpose === 'login' ? 'Sign in to SHD.build' : 'Verify your SHD.build email';
const action = purpose === 'login' ? 'SIGN IN TO SHD.BUILD' : 'VERIFY EMAIL ADDRESS';
await mailer().sendMail({
from: env.SMTP_FROM,
to: email,
subject,
text: `${subject}\n\nOpen this one-time link within 15 minutes:\n${url}\n\nIf you did not request this, you can ignore this message.`,
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD IDENTITY NETWORK</p><h1 style="font-size:24px">${subject}</h1><p style="color:#a5aaab">This one-time link expires in 15 minutes.</p><p><a href="${url}" style="display:inline-block;background:#f6a821;color:#111;padding:14px 20px;text-decoration:none;font-weight:800">${action}</a></p><p style="color:#737a7c;font-size:12px">If you did not request this, you can ignore this message.</p></div>`
});
export async function sendAuthenticationLink(
email: string,
token: string,
purpose: 'login' | 'verify_email',
): Promise<void> {
const url = `${applicationOrigin()}/auth/magic-link?token=${encodeURIComponent(token)}`;
const subject =
purpose === 'login'
? 'Sign in to SHD.build'
: 'Verify your SHD.build email';
const action =
purpose === 'login' ? 'SIGN IN TO SHD.BUILD' : 'VERIFY EMAIL ADDRESS';
await mailer().sendMail({
from: env.SMTP_FROM,
to: email,
subject,
text: `${subject}\n\nOpen this one-time link within 15 minutes:\n${url}\n\nIf you did not request this, you can ignore this message.`,
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD IDENTITY NETWORK</p><h1 style="font-size:24px">${subject}</h1><p style="color:#a5aaab">This one-time link expires in 15 minutes.</p><p><a href="${url}" style="display:inline-block;background:#f6a821;color:#111;padding:14px 20px;text-decoration:none;font-weight:800">${action}</a></p><p style="color:#737a7c;font-size:12px">If you did not request this, you can ignore this message.</p></div>`,
});
}
export async function sendNotificationEmail(email: string, title: string, body: string, link: string | null): Promise<void> {
const url = link ? `${applicationOrigin()}${link.startsWith('/') ? link : `/${link}`}` : `${applicationOrigin()}/account/notifications`;
await mailer().sendMail({
from: env.SMTP_FROM,
to: email,
subject: `${title} — SHD.build`,
text: `${title}\n\n${body}\n\nOpen SHD.build: ${url}`,
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD NOTIFICATION NETWORK</p><h1 style="font-size:24px">${escapeHtml(title)}</h1><p style="color:#a5aaab;line-height:1.6">${escapeHtml(body)}</p><p><a href="${url}" style="display:inline-block;background:#f6a821;color:#111;padding:14px 20px;text-decoration:none;font-weight:800">OPEN SHD INBOX</a></p></div>`
});
export async function sendNotificationEmail(
email: string,
title: string,
body: string,
link: string | null,
): Promise<void> {
const url = link
? `${applicationOrigin()}${link.startsWith('/') ? link : `/${link}`}`
: `${applicationOrigin()}/account/notifications`;
await mailer().sendMail({
from: env.SMTP_FROM,
to: email,
subject: `${title} — SHD.build`,
text: `${title}\n\n${body}\n\nOpen SHD.build: ${url}`,
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD NOTIFICATION NETWORK</p><h1 style="font-size:24px">${escapeHtml(title)}</h1><p style="color:#a5aaab;line-height:1.6">${escapeHtml(body)}</p><p><a href="${url}" style="display:inline-block;background:#f6a821;color:#111;padding:14px 20px;text-decoration:none;font-weight:800">OPEN SHD INBOX</a></p></div>`,
});
}
function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!);
return value.replace(
/[&<>"']/g,
(character) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[
character
]!,
);
}
+32 -16
View File
@@ -1,26 +1,42 @@
import type { Database } from '@tursodatabase/database';
export type AuthenticationMethods = {
local: boolean;
discord: boolean;
email: boolean;
passkeys: number;
total: number;
local: boolean;
discord: boolean;
email: boolean;
passkeys: number;
total: number;
};
export async function getAuthenticationMethods(database: Database, userId: string): Promise<AuthenticationMethods> {
const row = await (await database.prepare(`SELECT users.login_username,users.password_hash,users.discord_id,
export async function getAuthenticationMethods(
database: Database,
userId: string,
): Promise<AuthenticationMethods> {
const row = await (
await database.prepare(`SELECT users.login_username,users.password_hash,users.discord_id,
(SELECT COUNT(*) FROM user_emails WHERE user_id=users.id) AS email_count,
(SELECT COUNT(*) FROM passkeys WHERE user_id=users.id) AS passkey_count
FROM users WHERE id=?`)).get(userId);
const local = typeof row?.login_username === 'string' && typeof row?.password_hash === 'string';
const discord = typeof row?.discord_id === 'string';
const email = Number(row?.email_count) > 0;
const passkeys = Number(row?.passkey_count ?? 0);
return { local, discord, email, passkeys, total: Number(local) + Number(discord) + Number(email) + passkeys };
FROM users WHERE id=?`)
).get(userId);
const local =
typeof row?.login_username === 'string' &&
typeof row?.password_hash === 'string';
const discord = typeof row?.discord_id === 'string';
const email = Number(row?.email_count) > 0;
const passkeys = Number(row?.passkey_count ?? 0);
return {
local,
discord,
email,
passkeys,
total: Number(local) + Number(discord) + Number(email) + passkeys,
};
}
export function canRemoveAuthenticationMethod(methods: AuthenticationMethods, method: 'local' | 'discord' | 'email' | 'passkey'): boolean {
const removed = method === 'passkey' ? 1 : Number(methods[method]);
return removed > 0 && methods.total - removed >= 1;
export function canRemoveAuthenticationMethod(
methods: AuthenticationMethods,
method: 'local' | 'discord' | 'email' | 'passkey',
): boolean {
const removed = method === 'passkey' ? 1 : Number(methods[method]);
return removed > 0 && methods.total - removed >= 1;
}
+54 -19
View File
@@ -5,27 +5,62 @@ import { randomUUID } from 'node:crypto';
export const PASSKEY_RP_NAME = 'SHD.build';
export function passkeyContext(url: URL): { rpID: string; origin: string } {
const configuredOrigin = env.APP_ORIGIN?.replace(/\/+$/, '');
const origin = configuredOrigin || url.origin;
const hostname = new URL(origin).hostname;
return {
rpID: env.PASSKEY_RP_ID || (hostname === 'localhost' || hostname === '127.0.0.1' ? hostname : 'shd.build'),
origin
};
const configuredOrigin = env.APP_ORIGIN?.replace(/\/+$/, '');
const origin = configuredOrigin || url.origin;
const hostname = new URL(origin).hostname;
return {
rpID:
env.PASSKEY_RP_ID ||
(hostname === 'localhost' || hostname === '127.0.0.1'
? hostname
: 'shd.build'),
origin,
};
}
export async function storeChallenge(database: Database, flow: 'registration' | 'authentication', challenge: string, context: { rpID: string; origin: string }, userId?: string): Promise<string> {
const id = randomUUID(), now = new Date();
await (await database.prepare('DELETE FROM webauthn_challenges WHERE expires_at<=?')).run(now.toISOString());
await (await database.prepare(`INSERT INTO webauthn_challenges(id,user_id,flow,challenge,rp_id,origin,expires_at,created_at)
VALUES(?,?,?,?,?,?,?,?)`)).run(id, userId ?? null, flow, challenge, context.rpID, context.origin, new Date(now.getTime() + 5 * 60_000).toISOString(), now.toISOString());
return id;
export async function storeChallenge(
database: Database,
flow: 'registration' | 'authentication',
challenge: string,
context: { rpID: string; origin: string },
userId?: string,
): Promise<string> {
const id = randomUUID(),
now = new Date();
await (
await database.prepare(
'DELETE FROM webauthn_challenges WHERE expires_at<=?',
)
).run(now.toISOString());
await (
await database.prepare(`INSERT INTO webauthn_challenges(id,user_id,flow,challenge,rp_id,origin,expires_at,created_at)
VALUES(?,?,?,?,?,?,?,?)`)
).run(
id,
userId ?? null,
flow,
challenge,
context.rpID,
context.origin,
new Date(now.getTime() + 5 * 60_000).toISOString(),
now.toISOString(),
);
return id;
}
export async function consumeChallenge(database: Database, id: string, flow: 'registration' | 'authentication', userId?: string) {
const row = await (await database.prepare(`SELECT * FROM webauthn_challenges WHERE id=? AND flow=? AND expires_at>?
AND (? IS NULL OR user_id=?)`)).get(id, flow, new Date().toISOString(), userId ?? null, userId ?? null);
if (!row) return null;
await (await database.prepare('DELETE FROM webauthn_challenges WHERE id=?')).run(id);
return row;
export async function consumeChallenge(
database: Database,
id: string,
flow: 'registration' | 'authentication',
userId?: string,
) {
const row = await (
await database.prepare(`SELECT * FROM webauthn_challenges WHERE id=? AND flow=? AND expires_at>?
AND (? IS NULL OR user_id=?)`)
).get(id, flow, new Date().toISOString(), userId ?? null, userId ?? null);
if (!row) return null;
await (
await database.prepare('DELETE FROM webauthn_challenges WHERE id=?')
).run(id);
return row;
}
+11 -8
View File
@@ -5,14 +5,17 @@ import { hash, verify, argon2id } from 'argon2';
* algorithm version, and work parameters alongside the derived hash.
*/
export function hashPassword(password: string): Promise<string> {
return hash(password, {
type: argon2id,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1
});
return hash(password, {
type: argon2id,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1,
});
}
export function verifyPassword(passwordHash: string, candidate: string): Promise<boolean> {
return verify(passwordHash, candidate);
export function verifyPassword(
passwordHash: string,
candidate: string,
): Promise<boolean> {
return verify(passwordHash, candidate);
}
+195 -117
View File
@@ -8,109 +8,166 @@ const COOKIE_NAME = 'shd_session';
const SESSION_SECONDS = 60 * 60 * 24 * 30;
export type AuthUser = {
id: string;
username: string;
loginUsername: string | null;
displayName: string;
publicSlug: string | null;
role: string;
discordLinked: boolean;
avatarUrl: string;
id: string;
username: string;
loginUsername: string | null;
displayName: string;
publicSlug: string | null;
role: string;
discordLinked: boolean;
avatarUrl: string;
};
type LoginRequestContext = {
request?: Request;
getClientAddress?: () => string;
failureReason?: string | null;
credentialId?: string | null;
sessionId?: string | null;
riskFlags?: string[] | null;
request?: Request;
getClientAddress?: () => string;
failureReason?: string | null;
credentialId?: string | null;
sessionId?: string | null;
riskFlags?: string[] | null;
};
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
return createHash('sha256').update(token).digest('hex');
}
export async function createSession(cookies: Cookies, userId: string): Promise<string> {
const database = await getDatabase();
const token = randomBytes(32).toString('base64url');
const sessionId = randomUUID();
const now = new Date();
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
const insert = await database.prepare(
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)'
);
export async function createSession(
cookies: Cookies,
userId: string,
): Promise<string> {
const database = await getDatabase();
const token = randomBytes(32).toString('base64url');
const sessionId = randomUUID();
const now = new Date();
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
const insert = await database.prepare(
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)',
);
await insert.run(
sessionId,
userId,
hashToken(token),
expiresAt.toISOString(),
now.toISOString()
);
await insert.run(
sessionId,
userId,
hashToken(token),
expiresAt.toISOString(),
now.toISOString(),
);
cookies.set(COOKIE_NAME, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
maxAge: SESSION_SECONDS
});
return sessionId;
cookies.set(COOKIE_NAME, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
maxAge: SESSION_SECONDS,
});
return sessionId;
}
function describeClient(userAgent: string) {
const clientName = /Edg\//.test(userAgent) ? 'Edge' : /Firefox\//.test(userAgent) ? 'Firefox' :
/Chrome\//.test(userAgent) ? 'Chrome' : /Safari\//.test(userAgent) ? 'Safari' : 'Other';
const operatingSystem = /Windows/.test(userAgent) ? 'Windows' : /Android/.test(userAgent) ? 'Android' :
/iPhone|iPad/.test(userAgent) ? 'iOS/iPadOS' : /Mac OS X/.test(userAgent) ? 'macOS' :
/Linux/.test(userAgent) ? 'Linux' : 'Other';
const deviceType = /Mobile|Android|iPhone/.test(userAgent) ? 'Mobile' : /iPad|Tablet/.test(userAgent) ? 'Tablet' : 'Desktop';
return { clientName, operatingSystem, deviceType };
const clientName = /Edg\//.test(userAgent)
? 'Edge'
: /Firefox\//.test(userAgent)
? 'Firefox'
: /Chrome\//.test(userAgent)
? 'Chrome'
: /Safari\//.test(userAgent)
? 'Safari'
: 'Other';
const operatingSystem = /Windows/.test(userAgent)
? 'Windows'
: /Android/.test(userAgent)
? 'Android'
: /iPhone|iPad/.test(userAgent)
? 'iOS/iPadOS'
: /Mac OS X/.test(userAgent)
? 'macOS'
: /Linux/.test(userAgent)
? 'Linux'
: 'Other';
const deviceType = /Mobile|Android|iPhone/.test(userAgent)
? 'Mobile'
: /iPad|Tablet/.test(userAgent)
? 'Tablet'
: 'Desktop';
return { clientName, operatingSystem, deviceType };
}
export async function purgeExpiredLoginMetadata(): Promise<void> {
const database = await getDatabase();
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await (await database.prepare(`UPDATE login_events SET ip_address=NULL,user_agent=NULL,client_name=NULL,
const database = await getDatabase();
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await (
await database.prepare(`UPDATE login_events SET ip_address=NULL,user_agent=NULL,client_name=NULL,
operating_system=NULL,device_type=NULL,location_country=NULL,location_region=NULL,location_city=NULL,
credential_id=NULL,session_id=NULL,sensitive_purged_at=?
WHERE created_at<? AND sensitive_purged_at IS NULL`)).run(now, cutoff);
WHERE created_at<? AND sensitive_purged_at IS NULL`)
).run(now, cutoff);
}
export async function recordLogin(userId: string | null, provider: 'password' | 'discord' | 'magic_link' | 'passkey', succeeded: boolean, context: LoginRequestContext = {}): Promise<void> {
const database = await getDatabase();
const userAgent = context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null;
const headers = context.request?.headers;
const locationCountry = headers?.get('cf-ipcountry') ?? headers?.get('x-vercel-ip-country') ?? headers?.get('x-geo-country');
const locationRegion = headers?.get('x-vercel-ip-country-region') ?? headers?.get('x-geo-region');
const locationCity = headers?.get('x-vercel-ip-city') ?? headers?.get('x-geo-city');
const client = describeClient(userAgent ?? '');
const riskFlags = context.riskFlags ?? (!succeeded
? [userId ? 'failed_authentication' : 'unknown_account_attempt']
: []);
let ipAddress: string | null = null;
try { ipAddress = context.getClientAddress?.() ?? null; } catch { /* unavailable in some development adapters */ }
await (await database.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent,
export async function recordLogin(
userId: string | null,
provider: 'password' | 'discord' | 'magic_link' | 'passkey',
succeeded: boolean,
context: LoginRequestContext = {},
): Promise<void> {
const database = await getDatabase();
const userAgent =
context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null;
const headers = context.request?.headers;
const locationCountry =
headers?.get('cf-ipcountry') ??
headers?.get('x-vercel-ip-country') ??
headers?.get('x-geo-country');
const locationRegion =
headers?.get('x-vercel-ip-country-region') ?? headers?.get('x-geo-region');
const locationCity =
headers?.get('x-vercel-ip-city') ?? headers?.get('x-geo-city');
const client = describeClient(userAgent ?? '');
const riskFlags =
context.riskFlags ??
(!succeeded
? [userId ? 'failed_authentication' : 'unknown_account_attempt']
: []);
let ipAddress: string | null = null;
try {
ipAddress = context.getClientAddress?.() ?? null;
} catch {
/* unavailable in some development adapters */
}
await (
await database.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent,
client_name,operating_system,device_type,location_country,location_region,location_city,
failure_reason,risk_flags,credential_id,session_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
randomUUID(), userId, provider, succeeded ? 1 : 0, ipAddress, userAgent,
client.clientName, client.operatingSystem, client.deviceType,
locationCountry?.slice(0, 100) ?? null, locationRegion?.slice(0, 100) ?? null, locationCity?.slice(0, 100) ?? null,
context.failureReason ?? null, riskFlags.length ? riskFlags.join(',') : null,
context.credentialId ?? null, context.sessionId ?? null, new Date().toISOString()
);
await purgeExpiredLoginMetadata();
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
randomUUID(),
userId,
provider,
succeeded ? 1 : 0,
ipAddress,
userAgent,
client.clientName,
client.operatingSystem,
client.deviceType,
locationCountry?.slice(0, 100) ?? null,
locationRegion?.slice(0, 100) ?? null,
locationCity?.slice(0, 100) ?? null,
context.failureReason ?? null,
riskFlags.length ? riskFlags.join(',') : null,
context.credentialId ?? null,
context.sessionId ?? null,
new Date().toISOString(),
);
await purgeExpiredLoginMetadata();
}
export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null> {
const token = cookies.get(COOKIE_NAME);
if (!token) return null;
export async function getSessionUser(
cookies: Cookies,
): Promise<AuthUser | null> {
const token = cookies.get(COOKIE_NAME);
if (!token) return null;
const database = await getDatabase();
const statement = await database.prepare(`
const database = await getDatabase();
const statement = await database.prepare(`
SELECT users.id, users.username, users.login_username, COALESCE(user_profiles.display_name,users.username) AS display_name,
user_profiles.public_slug,
users.role, users.discord_id, user_avatars.source AS avatar_source,
@@ -125,50 +182,71 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE sessions.token_hash = ? AND sessions.expires_at > ?
`);
const user = await statement.get(hashToken(token), new Date().toISOString());
const user = await statement.get(hashToken(token), new Date().toISOString());
if (!user) {
cookies.delete(COOKIE_NAME, { path: '/' });
return null;
}
const now = new Date();
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > now;
if (user.deleted_at || user.blocked_at || banned) {
await (await database.prepare('DELETE FROM sessions WHERE token_hash=?')).run(hashToken(token));
cookies.delete(COOKIE_NAME, { path: '/' });
return null;
}
if (!user.last_active_at || now.getTime() - new Date(String(user.last_active_at)).getTime() > 5 * 60 * 1000) {
await (await database.prepare(`INSERT INTO user_status(user_id,last_active_at,updated_at) VALUES(?,?,?)
ON CONFLICT(user_id) DO UPDATE SET last_active_at=excluded.last_active_at,updated_at=excluded.updated_at`))
.run(String(user.id), now.toISOString(), now.toISOString());
}
if (!user) {
cookies.delete(COOKIE_NAME, { path: '/' });
return null;
}
const now = new Date();
const banned =
typeof user.banned_until === 'string' && new Date(user.banned_until) > now;
if (user.deleted_at || user.blocked_at || banned) {
await (
await database.prepare('DELETE FROM sessions WHERE token_hash=?')
).run(hashToken(token));
cookies.delete(COOKIE_NAME, { path: '/' });
return null;
}
if (
!user.last_active_at ||
now.getTime() - new Date(String(user.last_active_at)).getTime() >
5 * 60 * 1000
) {
await (
await database.prepare(`INSERT INTO user_status(user_id,last_active_at,updated_at) VALUES(?,?,?)
ON CONFLICT(user_id) DO UPDATE SET last_active_at=excluded.last_active_at,updated_at=excluded.updated_at`)
).run(String(user.id), now.toISOString(), now.toISOString());
}
return {
id: String(user.id),
username: String(user.username),
loginUsername: typeof user.login_username === 'string' ? user.login_username : null,
displayName: String(user.display_name),
publicSlug: typeof user.public_slug === 'string' ? user.public_slug : null,
role: String(user.role),
discordLinked: typeof user.discord_id === 'string',
avatarUrl: avatarUrl(user.avatar_source, user.avatar_value, String(user.id), user.avatar_updated_at)
};
return {
id: String(user.id),
username: String(user.username),
loginUsername:
typeof user.login_username === 'string' ? user.login_username : null,
displayName: String(user.display_name),
publicSlug: typeof user.public_slug === 'string' ? user.public_slug : null,
role: String(user.role),
discordLinked: typeof user.discord_id === 'string',
avatarUrl: avatarUrl(
user.avatar_source,
user.avatar_value,
String(user.id),
user.avatar_updated_at,
),
};
}
export async function destroySession(cookies: Cookies): Promise<void> {
const token = cookies.get(COOKIE_NAME);
const token = cookies.get(COOKIE_NAME);
if (token) {
const database = await getDatabase();
const session = await (await database.prepare('SELECT id FROM sessions WHERE token_hash=?')).get(hashToken(token));
if (session?.id) {
await (await database.prepare('UPDATE login_events SET session_ended_at=?,session_end_reason=? WHERE session_id=?'))
.run(new Date().toISOString(), 'user_logout', session.id);
}
const statement = await database.prepare('DELETE FROM sessions WHERE token_hash = ?');
await statement.run(hashToken(token));
}
if (token) {
const database = await getDatabase();
const session = await (
await database.prepare('SELECT id FROM sessions WHERE token_hash=?')
).get(hashToken(token));
if (session?.id) {
await (
await database.prepare(
'UPDATE login_events SET session_ended_at=?,session_end_reason=? WHERE session_id=?',
)
).run(new Date().toISOString(), 'user_logout', session.id);
}
const statement = await database.prepare(
'DELETE FROM sessions WHERE token_hash = ?',
);
await statement.run(hashToken(token));
}
cookies.delete(COOKIE_NAME, { path: '/' });
cookies.delete(COOKIE_NAME, { path: '/' });
}
+82 -52
View File
@@ -3,68 +3,98 @@ import { getDatabase } from '$lib/server/database';
import { createNotification } from '$lib/server/notifications';
export const AUTOMATED_MESSAGE_EVENTS = [
{ value: 'account.created', label: 'ACCOUNT CREATED', description: 'After a new SHD.build account is created' }
{
value: 'account.created',
label: 'ACCOUNT CREATED',
description: 'After a new SHD.build account is created',
},
] as const;
export type AutomatedMessageEvent = (typeof AUTOMATED_MESSAGE_EVENTS)[number]['value'];
export type AutomatedMessageEvent =
(typeof AUTOMATED_MESSAGE_EVENTS)[number]['value'];
type MessageRow = Record<string, unknown>;
function parseActions(value: unknown): Array<{ label: string; href: string }> {
try {
const parsed = JSON.parse(String(value ?? '[]'));
if (!Array.isArray(parsed)) return [];
return parsed
.filter((item) => item && typeof item.label === 'string' && typeof item.href === 'string')
.map((item) => ({ label: item.label.slice(0, 60), href: item.href.slice(0, 500) }))
.slice(0, 8);
} catch {
return [];
}
try {
const parsed = JSON.parse(String(value ?? '[]'));
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(item) =>
item &&
typeof item.label === 'string' &&
typeof item.href === 'string',
)
.map((item) => ({
label: item.label.slice(0, 60),
href: item.href.slice(0, 500),
}))
.slice(0, 8);
} catch {
return [];
}
}
async function deliver(message: MessageRow, deliveryId: string, userId: string) {
const notificationId = await createNotification({
userId,
category: 'isac',
source: String(message.source),
title: String(message.title),
body: String(message.body),
link: typeof message.link === 'string' ? message.link : null,
actions: parseActions(message.actions_json),
mandatory: true
});
if (!notificationId) return;
const database = await getDatabase();
await (await database.prepare(`UPDATE automated_system_message_deliveries
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`))
.run(new Date().toISOString(), notificationId, deliveryId);
async function deliver(
message: MessageRow,
deliveryId: string,
userId: string,
) {
const notificationId = await createNotification({
userId,
category: 'isac',
source: String(message.source),
title: String(message.title),
body: String(message.body),
link: typeof message.link === 'string' ? message.link : null,
actions: parseActions(message.actions_json),
mandatory: true,
});
if (!notificationId) return;
const database = await getDatabase();
await (
await database.prepare(`UPDATE automated_system_message_deliveries
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`)
).run(new Date().toISOString(), notificationId, deliveryId);
}
export async function triggerAutomatedSystemMessages(input: {
eventKey: AutomatedMessageEvent;
userId: string;
eventId: string;
eventKey: AutomatedMessageEvent;
userId: string;
eventId: string;
}) {
const database = await getDatabase();
const messages = await (await database.prepare(
'SELECT * FROM automated_system_messages WHERE event_key=? AND enabled=1 ORDER BY name'
)).all(input.eventKey);
const now = new Date();
for (const message of messages) {
const existing = await (await database.prepare(`SELECT id FROM automated_system_message_deliveries
WHERE message_id=? AND user_id=? AND event_key=? AND event_id=?`))
.get(message.id, input.userId, input.eventKey, input.eventId);
if (existing) continue;
const deliveryId = randomUUID();
const scheduledFor = new Date(now.getTime() + Number(message.send_delay_minutes ?? 0) * 60_000);
await (await database.prepare(`INSERT OR IGNORE INTO automated_system_message_deliveries
const database = await getDatabase();
const messages = await (
await database.prepare(
'SELECT * FROM automated_system_messages WHERE event_key=? AND enabled=1 ORDER BY name',
)
).all(input.eventKey);
const now = new Date();
for (const message of messages) {
const existing = await (
await database.prepare(`SELECT id FROM automated_system_message_deliveries
WHERE message_id=? AND user_id=? AND event_key=? AND event_id=?`)
).get(message.id, input.userId, input.eventKey, input.eventId);
if (existing) continue;
const deliveryId = randomUUID();
const scheduledFor = new Date(
now.getTime() + Number(message.send_delay_minutes ?? 0) * 60_000,
);
await (
await database.prepare(`INSERT OR IGNORE INTO automated_system_message_deliveries
(id,message_id,user_id,event_key,event_id,scheduled_for,created_at)
VALUES(?,?,?,?,?,?,?)`)).run(
deliveryId, message.id, input.userId, input.eventKey, input.eventId,
scheduledFor.toISOString(), now.toISOString()
);
if (Number(message.send_delay_minutes ?? 0) === 0) {
await deliver(message, deliveryId, input.userId);
}
}
VALUES(?,?,?,?,?,?,?)`)
).run(
deliveryId,
message.id,
input.userId,
input.eventKey,
input.eventId,
scheduledFor.toISOString(),
now.toISOString(),
);
if (Number(message.send_delay_minutes ?? 0) === 0) {
await deliver(message, deliveryId, input.userId);
}
}
}
+176 -117
View File
@@ -14,149 +14,208 @@ const USER_FILE_PATTERN = /^[0-9a-f-]{36}\.webp$/i;
export class AvatarValidationError extends Error {}
export function avatarStorageDirectory(): string {
return path.resolve(process.env.TDR_AVATAR_STORAGE_PATH || path.join(process.cwd(), 'data', 'avatars'));
return path.resolve(
process.env.TDR_AVATAR_STORAGE_PATH ||
path.join(process.cwd(), 'data', 'avatars'),
);
}
function storageKey(userId: string): string {
if (!/^[0-9a-f-]{36}$/i.test(userId)) throw new Error('Invalid user identifier');
return `${userId}.webp`;
if (!/^[0-9a-f-]{36}$/i.test(userId))
throw new Error('Invalid user identifier');
return `${userId}.webp`;
}
function storagePath(key: string): string {
if (!USER_FILE_PATTERN.test(key)) throw new Error('Invalid avatar storage key');
return path.join(avatarStorageDirectory(), key);
if (!USER_FILE_PATTERN.test(key))
throw new Error('Invalid avatar storage key');
return path.join(avatarStorageDirectory(), key);
}
export async function processAvatarUpload(file: File): Promise<Buffer> {
if (!file.size || file.size > MAX_AVATAR_BYTES) {
throw new AvatarValidationError('Choose an image no larger than 5 MiB.');
}
const input = Buffer.from(await file.arrayBuffer());
try {
const image = sharp(input, { limitInputPixels: MAX_AVATAR_PIXELS, failOn: 'warning' });
const metadata = await image.metadata();
if (!metadata.format || !['jpeg', 'png', 'webp', 'avif'].includes(metadata.format)) {
throw new AvatarValidationError('Use a JPEG, PNG, WebP, or AVIF image.');
}
return await image
.rotate()
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' })
.webp({ quality: 82, effort: 5 })
.toBuffer();
} catch (error) {
if (error instanceof AvatarValidationError) throw error;
throw new AvatarValidationError('That image could not be processed. Try another JPEG, PNG, WebP, or AVIF file.');
}
if (!file.size || file.size > MAX_AVATAR_BYTES) {
throw new AvatarValidationError('Choose an image no larger than 5 MiB.');
}
const input = Buffer.from(await file.arrayBuffer());
try {
const image = sharp(input, {
limitInputPixels: MAX_AVATAR_PIXELS,
failOn: 'warning',
});
const metadata = await image.metadata();
if (
!metadata.format ||
!['jpeg', 'png', 'webp', 'avif'].includes(metadata.format)
) {
throw new AvatarValidationError('Use a JPEG, PNG, WebP, or AVIF image.');
}
return await image
.rotate()
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' })
.webp({ quality: 82, effort: 5 })
.toBuffer();
} catch (error) {
if (error instanceof AvatarValidationError) throw error;
throw new AvatarValidationError(
'That image could not be processed. Try another JPEG, PNG, WebP, or AVIF file.',
);
}
}
export async function saveUploadedAvatar(
database: Database,
userId: string,
output: Buffer,
options: { queueReview: boolean } = { queueReview: true }
database: Database,
userId: string,
output: Buffer,
options: { queueReview: boolean } = { queueReview: true },
): Promise<{ uploadedAt: string; reviewId: string | null }> {
const key = storageKey(userId);
const directory = avatarStorageDirectory();
await mkdir(directory, { recursive: true });
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
const now = new Date().toISOString();
await (await database.prepare(`INSERT INTO user_avatars(user_id,source,value,byte_size,updated_at)
const key = storageKey(userId);
const directory = avatarStorageDirectory();
await mkdir(directory, { recursive: true });
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO user_avatars(user_id,source,value,byte_size,updated_at)
VALUES(?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET
source=excluded.source,value=excluded.value,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
.run(userId, 'upload', key, output.length, now);
await (await database.prepare("UPDATE avatar_reviews SET status='superseded',reviewed_at=? WHERE user_id=? AND status='pending'"))
.run(now, userId);
let reviewId: string | null = null;
if (options.queueReview) {
reviewId = await queueCurrentAvatarReview(
database,
userId,
{
origin: 'automated',
summary: 'Automated safety checks flagged a custom avatar for Oversight review.'
}
);
}
return { uploadedAt: now, reviewId };
source=excluded.source,value=excluded.value,byte_size=excluded.byte_size,updated_at=excluded.updated_at`)
).run(userId, 'upload', key, output.length, now);
await (
await database.prepare(
"UPDATE avatar_reviews SET status='superseded',reviewed_at=? WHERE user_id=? AND status='pending'",
)
).run(now, userId);
let reviewId: string | null = null;
if (options.queueReview) {
reviewId = await queueCurrentAvatarReview(database, userId, {
origin: 'automated',
summary:
'Automated safety checks flagged a custom avatar for Oversight review.',
});
}
return { uploadedAt: now, reviewId };
}
export async function queueCurrentAvatarReview(
database: Database,
userId: string,
options: {
origin: 'automated' | 'user_report' | 'legacy';
summary?: string;
} = { origin: 'user_report' }
database: Database,
userId: string,
options: {
origin: 'automated' | 'user_report' | 'legacy';
summary?: string;
} = { origin: 'user_report' },
): Promise<string | null> {
const avatar = await (await database.prepare(`SELECT source,value,byte_size,updated_at
FROM user_avatars WHERE user_id=?`)).get(userId);
if (avatar?.source !== 'upload' || typeof avatar.value !== 'string') return null;
const existing = await (await database.prepare(
"SELECT id FROM avatar_reviews WHERE user_id=? AND avatar_updated_at=? AND status='pending'"
)).get(userId, avatar.updated_at);
if (existing?.id) return String(existing.id);
const reviewId = randomUUID();
await (await database.prepare(`INSERT INTO avatar_reviews
const avatar = await (
await database.prepare(`SELECT source,value,byte_size,updated_at
FROM user_avatars WHERE user_id=?`)
).get(userId);
if (avatar?.source !== 'upload' || typeof avatar.value !== 'string')
return null;
const existing = await (
await database.prepare(
"SELECT id FROM avatar_reviews WHERE user_id=? AND avatar_updated_at=? AND status='pending'",
)
).get(userId, avatar.updated_at);
if (existing?.id) return String(existing.id);
const reviewId = randomUUID();
await (
await database.prepare(`INSERT INTO avatar_reviews
(id,user_id,avatar_updated_at,storage_key,byte_size,status,review_origin,submitted_at)
VALUES(?,?,?,?,?,'pending',?,?)`)).run(
reviewId,
userId,
avatar.updated_at,
avatar.value,
avatar.byte_size,
options.origin,
new Date().toISOString()
);
await notifyOversightReview({
category: 'avatars',
itemId: reviewId,
title: 'Avatar awaiting review',
summary: options.summary ?? 'An Agent report requested Oversight review of a custom avatar.',
link: '/isac/avatar-oversight?status=pending'
}).catch((error) => console.error('Unable to notify Avatar Oversight reviewers', error));
return reviewId;
VALUES(?,?,?,?,?,'pending',?,?)`)
).run(
reviewId,
userId,
avatar.updated_at,
avatar.value,
avatar.byte_size,
options.origin,
new Date().toISOString(),
);
await notifyOversightReview({
category: 'avatars',
itemId: reviewId,
title: 'Avatar awaiting review',
summary:
options.summary ??
'An Agent report requested Oversight review of a custom avatar.',
link: '/isac/avatar-oversight?status=pending',
}).catch((error) =>
console.error('Unable to notify Avatar Oversight reviewers', error),
);
return reviewId;
}
export async function selectPresetAvatar(database: Database, userId: string, preset: string): Promise<void> {
if (!isAvatarPreset(preset)) throw new AvatarValidationError('Choose one of the available SHD avatars.');
const existing = await (await database.prepare('SELECT source,value FROM user_avatars WHERE user_id=?')).get(userId);
const now = new Date().toISOString();
await (await database.prepare(`INSERT INTO user_avatars(user_id,source,value,byte_size,updated_at)
export async function selectPresetAvatar(
database: Database,
userId: string,
preset: string,
): Promise<void> {
if (!isAvatarPreset(preset))
throw new AvatarValidationError('Choose one of the available SHD avatars.');
const existing = await (
await database.prepare(
'SELECT source,value FROM user_avatars WHERE user_id=?',
)
).get(userId);
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO user_avatars(user_id,source,value,byte_size,updated_at)
VALUES(?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET
source=excluded.source,value=excluded.value,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
.run(userId, 'preset', preset, null, now);
await (await database.prepare("UPDATE avatar_reviews SET status='withdrawn',reviewed_at=? WHERE user_id=? AND status='pending'"))
.run(now, userId);
if (existing?.source === 'upload' && typeof existing.value === 'string') {
await unlink(storagePath(existing.value)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
source=excluded.source,value=excluded.value,byte_size=excluded.byte_size,updated_at=excluded.updated_at`)
).run(userId, 'preset', preset, null, now);
await (
await database.prepare(
"UPDATE avatar_reviews SET status='withdrawn',reviewed_at=? WHERE user_id=? AND status='pending'",
)
).run(now, userId);
if (existing?.source === 'upload' && typeof existing.value === 'string') {
await unlink(storagePath(existing.value)).catch(
(error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
},
);
}
}
export async function readUploadedAvatar(database: Database, userId: string): Promise<Buffer | null> {
const avatar = await (await database.prepare(
"SELECT value FROM user_avatars WHERE user_id=? AND source='upload'"
)).get(userId);
if (typeof avatar?.value !== 'string') return null;
try {
return await readFile(storagePath(avatar.value));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
export async function readUploadedAvatar(
database: Database,
userId: string,
): Promise<Buffer | null> {
const avatar = await (
await database.prepare(
"SELECT value FROM user_avatars WHERE user_id=? AND source='upload'",
)
).get(userId);
if (typeof avatar?.value !== 'string') return null;
try {
return await readFile(storagePath(avatar.value));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
}
export async function deleteUploadedAvatar(database: Database, userId: string): Promise<void> {
const avatar = await (await database.prepare('SELECT source,value FROM user_avatars WHERE user_id=?')).get(userId);
await (await database.prepare('DELETE FROM user_avatars WHERE user_id=?')).run(userId);
await (await database.prepare("UPDATE avatar_reviews SET status='removed',reviewed_at=COALESCE(reviewed_at,?) WHERE user_id=? AND status='pending'"))
.run(new Date().toISOString(), userId);
if (avatar?.source === 'upload' && typeof avatar.value === 'string') {
await unlink(storagePath(avatar.value)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
export async function deleteUploadedAvatar(
database: Database,
userId: string,
): Promise<void> {
const avatar = await (
await database.prepare(
'SELECT source,value FROM user_avatars WHERE user_id=?',
)
).get(userId);
await (
await database.prepare('DELETE FROM user_avatars WHERE user_id=?')
).run(userId);
await (
await database.prepare(
"UPDATE avatar_reviews SET status='removed',reviewed_at=COALESCE(reviewed_at,?) WHERE user_id=? AND status='pending'",
)
).run(new Date().toISOString(), userId);
if (avatar?.source === 'upload' && typeof avatar.value === 'string') {
await unlink(storagePath(avatar.value)).catch(
(error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
},
);
}
}
export { AVATAR_PRESETS, avatarUrl };
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { commentRemovalMode } from './comment-removal';
describe('comment removal mode', () => {
it('deletes a leaf comment', () => {
expect(commentRemovalMode(false)).toBe('delete');
});
it('retains a placeholder when replies depend on the comment', () => {
expect(commentRemovalMode(true)).toBe('placeholder');
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { getDatabase } from '$lib/server/database';
type Database = Awaited<ReturnType<typeof getDatabase>>;
export const commentRemovalMode = (
hasReplies: boolean,
): 'placeholder' | 'delete' => (hasReplies ? 'placeholder' : 'delete');
export async function removePublicComment(input: {
database: Database;
commentId: string;
removedBy: string;
removalReason: 'Removed by poster' | 'Removed by Community Oversight';
}) {
const reply = await (
await input.database.prepare(
"SELECT id FROM comments WHERE parent_id=? AND status IN ('visible','removed') LIMIT 1",
)
).get(input.commentId);
if (commentRemovalMode(Boolean(reply)) === 'delete') {
const deleted = await (
await input.database.prepare(
"DELETE FROM comments WHERE id=? AND status='visible'",
)
).run(input.commentId);
return { changed: Boolean(deleted.changes), retainedPlaceholder: false };
}
const removedAt = new Date().toISOString();
const removed = await (
await input.database.prepare(`UPDATE comments
SET status='removed',body_markdown='',removed_at=?,removed_by=?,removal_reason=?,updated_at=?
WHERE id=? AND status='visible'`)
).run(
removedAt,
input.removedBy,
input.removalReason,
removedAt,
input.commentId,
);
if (removed.changes) {
await (
await input.database.prepare(
'UPDATE comment_threads SET pinned_comment_id=NULL WHERE pinned_comment_id=?',
)
).run(input.commentId);
}
return {
changed: Boolean(removed.changes),
retainedPlaceholder: Boolean(removed.changes),
};
}
+190 -120
View File
@@ -1,136 +1,206 @@
import { describe, expect, it } from 'vitest';
import {
assessCommunityModeration,
findLocalModerationMatches,
normalizeModerationTerm,
parseModerationTermBatch,
type CommunityModerationPolicy
assessCommunityModeration,
findLocalModerationMatches,
normalizeModerationTerm,
parseModerationTermBatch,
type CommunityModerationPolicy,
} from './community-moderation';
const policy = (overrides: Partial<CommunityModerationPolicy> = {}): CommunityModerationPolicy => ({
automatedEnabled: true,
autoPublishClean: true,
holdOnProviderError: true,
scanComments: true,
scanProfiles: true,
scanGuides: true,
scanAvatars: true,
providerModel: 'omni-moderation-latest',
detectPii: true,
maxLinks: 1,
duplicateWindowSeconds: 300,
policyVersion: 1,
thresholds: [{
category: 'harassment',
enabled: true,
reviewThreshold: 0.5,
priorityThreshold: 0.85
}],
terms: [],
...overrides
const policy = (
overrides: Partial<CommunityModerationPolicy> = {},
): CommunityModerationPolicy => ({
automatedEnabled: true,
autoPublishClean: true,
holdOnProviderError: true,
scanComments: true,
scanProfiles: true,
scanGuides: true,
scanAvatars: true,
providerModel: 'omni-moderation-latest',
detectPii: true,
maxLinks: 1,
duplicateWindowSeconds: 300,
policyVersion: 1,
thresholds: [
{
category: 'harassment',
enabled: true,
reviewThreshold: 0.5,
priorityThreshold: 0.85,
},
],
terms: [],
...overrides,
});
describe('community moderation policy', () => {
it('normalizes configured terms consistently', () => {
expect(normalizeModerationTerm(' Rogue\u00a0Agent ')).toBe('rogue agent');
});
it('holds provider-required Guide content when classification fails', () => {
expect(
assessCommunityModeration({
policy: policy({ automatedEnabled: false, holdOnProviderError: false }),
contentType: 'guide',
providerFlagged: false,
categoryScores: {},
ruleMatches: [],
providerError: 'unavailable',
providerRequired: true,
}).decision,
).toBe('error');
});
it('parses pasted terms with automatic word and phrase matching', () => {
expect(parseModerationTermBatch('weapon\nkill, machine gun; assault rifle')).toEqual({
terms: [
{ value: 'weapon', normalizedValue: 'weapon', matchMode: 'word' },
{ value: 'kill', normalizedValue: 'kill', matchMode: 'word' },
{ value: 'machine gun', normalizedValue: 'machine gun', matchMode: 'phrase' },
{ value: 'assault rifle', normalizedValue: 'assault rifle', matchMode: 'phrase' }
],
duplicateCount: 0
});
});
it('normalizes configured terms consistently', () => {
expect(normalizeModerationTerm(' Rogue\u00a0Agent ')).toBe('rogue agent');
});
it('deduplicates pasted terms after compatibility and case normalization', () => {
const parsed = parseModerationTermBatch('Weapon\nweapon\nMACHINE GUN\nmachine gun');
expect(parsed.terms).toEqual([
{ value: 'Weapon', normalizedValue: 'weapon', matchMode: 'word' },
{ value: 'MACHINE GUN', normalizedValue: 'machine gun', matchMode: 'phrase' }
]);
expect(parsed.duplicateCount).toBe(2);
});
it('parses pasted terms with automatic word and phrase matching', () => {
expect(
parseModerationTermBatch('weapon\nkill, machine gun; assault rifle'),
).toEqual({
terms: [
{ value: 'weapon', normalizedValue: 'weapon', matchMode: 'word' },
{ value: 'kill', normalizedValue: 'kill', matchMode: 'word' },
{
value: 'machine gun',
normalizedValue: 'machine gun',
matchMode: 'phrase',
},
{
value: 'assault rifle',
normalizedValue: 'assault rifle',
matchMode: 'phrase',
},
],
duplicateCount: 0,
});
});
it('allows an exact local exception to suppress a matching banned term', () => {
const matches = findLocalModerationMatches('Rogue Agent tactics', 'comment', policy({
detectPii: false,
terms: [
{ id: 'ban', listType: 'ban', matchMode: 'phrase', value: 'Rogue Agent', normalizedValue: 'rogue agent', scopes: ['comment'], enabled: true },
{ id: 'allow', listType: 'allow', matchMode: 'phrase', value: 'Rogue Agent', normalizedValue: 'rogue agent', scopes: ['comment'], enabled: true }
]
}));
expect(matches).toEqual([]);
});
it('deduplicates pasted terms after compatibility and case normalization', () => {
const parsed = parseModerationTermBatch(
'Weapon\nweapon\nMACHINE GUN\nmachine gun',
);
expect(parsed.terms).toEqual([
{ value: 'Weapon', normalizedValue: 'weapon', matchMode: 'word' },
{
value: 'MACHINE GUN',
normalizedValue: 'machine gun',
matchMode: 'phrase',
},
]);
expect(parsed.duplicateCount).toBe(2);
});
it('finds scoped banned terms, PII, and excessive links', () => {
const matches = findLocalModerationMatches(
'Contact test@example.com about forbidden plans at https://one.test and https://two.test',
'comment',
policy({
terms: [{
id: 'ban',
listType: 'ban',
matchMode: 'word',
value: 'forbidden',
normalizedValue: 'forbidden',
scopes: ['comment'],
enabled: true
}]
})
);
expect(matches.map((match) => match.kind)).toEqual(['banned_term', 'pii', 'link_limit']);
});
it('allows an exact local exception to suppress a matching banned term', () => {
const matches = findLocalModerationMatches(
'Rogue Agent tactics',
'comment',
policy({
detectPii: false,
terms: [
{
id: 'ban',
listType: 'ban',
matchMode: 'phrase',
value: 'Rogue Agent',
normalizedValue: 'rogue agent',
scopes: ['comment'],
enabled: true,
},
{
id: 'allow',
listType: 'allow',
matchMode: 'phrase',
value: 'Rogue Agent',
normalizedValue: 'rogue agent',
scopes: ['comment'],
enabled: true,
},
],
}),
);
expect(matches).toEqual([]);
});
it('routes priority scores ahead of ordinary review scores', () => {
expect(assessCommunityModeration({
policy: policy(),
contentType: 'comment',
providerFlagged: true,
categoryScores: { harassment: 0.9 },
ruleMatches: []
}).decision).toBe('priority_review');
});
it('finds scoped banned terms, PII, and excessive links', () => {
const matches = findLocalModerationMatches(
'Contact test@example.com about forbidden plans at https://one.test and https://two.test',
'comment',
policy({
terms: [
{
id: 'ban',
listType: 'ban',
matchMode: 'word',
value: 'forbidden',
normalizedValue: 'forbidden',
scopes: ['comment'],
enabled: true,
},
],
}),
);
expect(matches.map((match) => match.kind)).toEqual([
'banned_term',
'pii',
'link_limit',
]);
});
it('holds provider failures under the default fail-closed policy', () => {
expect(assessCommunityModeration({
policy: policy(),
contentType: 'profile_bio',
providerFlagged: false,
categoryScores: {},
ruleMatches: [],
providerError: 'timeout'
}).decision).toBe('error');
});
it('routes priority scores ahead of ordinary review scores', () => {
expect(
assessCommunityModeration({
policy: policy(),
contentType: 'comment',
providerFlagged: true,
categoryScores: { harassment: 0.9 },
ruleMatches: [],
}).decision,
).toBe('priority_review');
});
it('publishes clean content only when auto publication is enabled', () => {
expect(assessCommunityModeration({
policy: policy(),
contentType: 'comment',
providerFlagged: false,
categoryScores: { harassment: 0.01 },
ruleMatches: []
}).decision).toBe('approved');
expect(assessCommunityModeration({
policy: policy({ autoPublishClean: false }),
contentType: 'comment',
providerFlagged: false,
categoryScores: { harassment: 0.01 },
ruleMatches: []
}).decision).toBe('review');
});
it('holds provider failures under the default fail-closed policy', () => {
expect(
assessCommunityModeration({
policy: policy(),
contentType: 'profile_bio',
providerFlagged: false,
categoryScores: {},
ruleMatches: [],
providerError: 'timeout',
}).decision,
).toBe('error');
});
it('publishes locally clean content when provider scanning is disabled', () => {
expect(assessCommunityModeration({
policy: policy({ automatedEnabled: false }),
contentType: 'comment',
providerFlagged: false,
categoryScores: {},
ruleMatches: []
}).decision).toBe('approved');
});
it('publishes clean content only when auto publication is enabled', () => {
expect(
assessCommunityModeration({
policy: policy(),
contentType: 'comment',
providerFlagged: false,
categoryScores: { harassment: 0.01 },
ruleMatches: [],
}).decision,
).toBe('approved');
expect(
assessCommunityModeration({
policy: policy({ autoPublishClean: false }),
contentType: 'comment',
providerFlagged: false,
categoryScores: { harassment: 0.01 },
ruleMatches: [],
}).decision,
).toBe('review');
});
it('publishes locally clean content when provider scanning is disabled', () => {
expect(
assessCommunityModeration({
policy: policy({ automatedEnabled: false }),
contentType: 'comment',
providerFlagged: false,
categoryScores: {},
ruleMatches: [],
}).decision,
).toBe('approved');
});
});
File diff suppressed because it is too large Load Diff
+386 -336
View File
@@ -2,174 +2,185 @@ import { connect } from '@tursodatabase/database';
import { describe, expect, it } from 'vitest';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import {
conditionalRuleDraftFromRows,
loadConditionalRuleDraft,
saveConditionalRuleInTransaction
conditionalRuleDraftFromRows,
loadConditionalRuleDraft,
saveConditionalRuleInTransaction,
} from './conditional-effects.js';
const rule = {
id: 'rule-1',
simulation_readiness: 'simulation_ready',
scenario_inputs_json: '[]'
id: 'rule-1',
simulation_readiness: 'simulation_ready',
scenario_inputs_json: '[]',
};
const clause = {
id: 'clause-1',
rule_id: 'rule-1',
clause_key: 'hit',
label: 'Hit',
event_kind: 'weapon_hit',
conditions_json: '{"kind":"true"}',
reducer_json: '{"kind":"none"}',
mutations_json: '[]',
sort_order: 1,
active: 1
id: 'clause-1',
rule_id: 'rule-1',
clause_key: 'hit',
label: 'Hit',
event_kind: 'weapon_hit',
conditions_json: '{"kind":"true"}',
reducer_json: '{"kind":"none"}',
mutations_json: '[]',
sort_order: 1,
active: 1,
};
const outcome = {
rule_id: 'rule-1',
clause_id: 'clause-1',
effect_key: 'damage',
outcome_key: 'Damage',
application_kind: 'active_modifier',
effect_scope: 'self',
stat_attribute_id: 'weapon-damage',
value_basis: 'flat',
value_scaling: 'flat',
damage_basis: null,
application_phase: 'while_active',
combat_timing: 'current',
resource_kind: null,
state_pool_id: null,
min_state_value: null,
max_state_value: null,
description_slot: 1,
value: 3,
value_expression_json: null
rule_id: 'rule-1',
clause_id: 'clause-1',
effect_key: 'damage',
outcome_key: 'Damage',
application_kind: 'active_modifier',
effect_scope: 'self',
stat_attribute_id: 'weapon-damage',
value_basis: 'flat',
value_scaling: 'flat',
damage_basis: null,
application_phase: 'while_active',
combat_timing: 'current',
resource_kind: null,
state_pool_id: null,
min_state_value: null,
max_state_value: null,
description_slot: 1,
value: 3,
value_expression_json: null,
};
describe('conditional mechanic persistence adapter', () => {
it('rejects divergent metadata across value variants', () => {
expect(() => conditionalRuleDraftFromRows(
rule,
[],
[clause],
[
{ ...outcome, variant_key: 'I' },
{ ...outcome, variant_key: 'II', application_phase: 'after_event', value: 5 }
],
['I', 'II']
)).toThrow(/inconsistent application_phase metadata/);
});
it('rejects divergent metadata across value variants', () => {
expect(() =>
conditionalRuleDraftFromRows(
rule,
[],
[clause],
[
{ ...outcome, variant_key: 'I' },
{
...outcome,
variant_key: 'II',
application_phase: 'after_event',
value: 5,
},
],
['I', 'II'],
),
).toThrow(/inconsistent application_phase metadata/);
});
it('loads consistent value variants into one outcome', () => {
const draft = conditionalRuleDraftFromRows(
rule,
[],
[clause],
[
{ ...outcome, variant_key: 'I' },
{ ...outcome, variant_key: 'II', value: 5 }
],
['I', 'II']
);
it('loads consistent value variants into one outcome', () => {
const draft = conditionalRuleDraftFromRows(
rule,
[],
[clause],
[
{ ...outcome, variant_key: 'I' },
{ ...outcome, variant_key: 'II', value: 5 },
],
['I', 'II'],
);
expect(draft?.clauses[0].outcomes[0].variants).toEqual({
I: { value: 3 },
II: { value: 5 }
});
expect(draft?.clauses[0].outcomes[0]).not.toHaveProperty('delivery');
expect(draft?.clauses[0].outcomes[0]).not.toHaveProperty('durationSeconds');
});
expect(draft?.clauses[0].outcomes[0].variants).toEqual({
I: { value: 3 },
II: { value: 5 },
});
expect(draft?.clauses[0].outcomes[0]).not.toHaveProperty('delivery');
expect(draft?.clauses[0].outcomes[0]).not.toHaveProperty('durationSeconds');
});
it('loads continuous delivery metadata and normalizes legacy instant variants', () => {
const continuous = conditionalRuleDraftFromRows(
rule,
[],
[clause],
[
{
...outcome,
application_kind: 'restore_health',
stat_attribute_id: null,
resource_kind: 'health',
delivery_mode: 'continuous',
delivery_duration_seconds: 3,
variant_key: 'I'
},
{
...outcome,
application_kind: 'restore_health',
stat_attribute_id: null,
resource_kind: 'health',
delivery_mode: 'continuous',
delivery_duration_seconds: 3,
variant_key: 'II'
}
],
['I', 'II']
);
expect(continuous?.clauses[0].outcomes[0]).toMatchObject({
delivery: 'continuous',
durationSeconds: 3
});
it('loads continuous delivery metadata and normalizes legacy instant variants', () => {
const continuous = conditionalRuleDraftFromRows(
rule,
[],
[clause],
[
{
...outcome,
application_kind: 'restore_health',
stat_attribute_id: null,
resource_kind: 'health',
delivery_mode: 'continuous',
delivery_duration_seconds: 3,
variant_key: 'I',
},
{
...outcome,
application_kind: 'restore_health',
stat_attribute_id: null,
resource_kind: 'health',
delivery_mode: 'continuous',
delivery_duration_seconds: 3,
variant_key: 'II',
},
],
['I', 'II'],
);
expect(continuous?.clauses[0].outcomes[0]).toMatchObject({
delivery: 'continuous',
durationSeconds: 3,
});
expect(() => conditionalRuleDraftFromRows(
rule,
[],
[clause],
[
{ ...outcome, variant_key: 'I' },
{ ...outcome, variant_key: 'II', delivery_mode: 'instant' }
],
['I', 'II']
)).not.toThrow();
});
expect(() =>
conditionalRuleDraftFromRows(
rule,
[],
[clause],
[
{ ...outcome, variant_key: 'I' },
{ ...outcome, variant_key: 'II', delivery_mode: 'instant' },
],
['I', 'II'],
),
).not.toThrow();
});
it('rejects divergent continuous-delivery durations across variants', () => {
expect(() => conditionalRuleDraftFromRows(
rule,
[],
[clause],
[
{
...outcome,
variant_key: 'I',
delivery_mode: 'continuous',
delivery_duration_seconds: 3
},
{
...outcome,
variant_key: 'II',
delivery_mode: 'continuous',
delivery_duration_seconds: 4
}
],
['I', 'II']
)).toThrow(/inconsistent delivery_duration_seconds metadata/);
});
it('rejects divergent continuous-delivery durations across variants', () => {
expect(() =>
conditionalRuleDraftFromRows(
rule,
[],
[clause],
[
{
...outcome,
variant_key: 'I',
delivery_mode: 'continuous',
delivery_duration_seconds: 3,
},
{
...outcome,
variant_key: 'II',
delivery_mode: 'continuous',
delivery_duration_seconds: 4,
},
],
['I', 'II'],
),
).toThrow(/inconsistent delivery_duration_seconds metadata/);
});
it('round-trips continuous delivery and direct event predicates through normalized rows', async () => {
const database = await connect(':memory:');
await database.exec(`CREATE TABLE conditional_effect_rules (
it('round-trips continuous delivery and direct event predicates through normalized rows', async () => {
const database = await connect(':memory:');
await database.exec(`CREATE TABLE conditional_effect_rules (
id TEXT PRIMARY KEY, source_type TEXT, source_id TEXT, configured INTEGER,
script_version INTEGER, simulation_readiness TEXT, scenario_inputs_json TEXT,
created_at TEXT, updated_at TEXT, UNIQUE(source_type,source_id)
)`);
await database.exec(`CREATE TABLE conditional_effect_state_pools (
await database.exec(`CREATE TABLE conditional_effect_state_pools (
id TEXT PRIMARY KEY, rule_id TEXT, state_key TEXT, label TEXT, state_kind TEXT,
state_scope TEXT, initial_value REAL, minimum_value REAL, maximum_value REAL,
best_case_value REAL, duration_seconds REAL, expiry_policy TEXT, refresh_policy TEXT,
parameters_json TEXT, sort_order INTEGER, created_at TEXT, updated_at TEXT
)`);
await database.exec(`CREATE TABLE conditional_effect_clauses (
await database.exec(`CREATE TABLE conditional_effect_clauses (
id TEXT PRIMARY KEY, rule_id TEXT, clause_key TEXT, label TEXT, event_kind TEXT,
recipe_key TEXT, recipe_parameters_json TEXT, conditions_json TEXT, reducer_json TEXT,
weight_expression_json TEXT, emission_kind TEXT, state_pool_id TEXT, mutations_json TEXT,
cooldown_seconds REAL, output_branch TEXT, sort_order INTEGER, active INTEGER,
created_at TEXT, updated_at TEXT
)`);
await database.exec(`CREATE TABLE conditional_effect_outcomes (
await database.exec(`CREATE TABLE conditional_effect_outcomes (
id TEXT PRIMARY KEY, rule_id TEXT, clause_id TEXT, state_pool_id TEXT, effect_key TEXT,
variant_key TEXT, outcome_key TEXT, application_kind TEXT, effect_scope TEXT,
stat_attribute_id TEXT, value REAL, value_expression_json TEXT, value_basis TEXT,
@@ -179,206 +190,245 @@ describe('conditional mechanic persistence adapter', () => {
description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT
)`);
await saveConditionalRuleInTransaction(database, 'talent', 'predatory-aura', {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [{
key: 'marked-target',
label: 'Marked Target',
stateKind: 'target_mark',
scope: 'target',
initial: 0,
min: 0,
max: 1,
expiresAfterSecondsVariants: {
I: 12,
II: 18
}
} as ConditionalMechanicDraftV1['pools'][number] & {
expiresAfterSecondsVariants: Record<string, number>;
}],
clauses: [{
key: 'weapon-kill',
label: 'Weapon kill',
event: 'enemy_killed',
if: {
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'lt',
right: 75
},
reducer: { kind: 'none' },
mutations: [{
kind: 'set',
pool: 'marked-target',
entity: { kind: 'event_target' },
value: 1
}],
outcomes: [{
key: 'restore-health',
label: 'Restore Health',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
resource: 'health',
basis: 'flat',
delivery: 'continuous',
durationSeconds: 3,
targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40 },
variants: {
I: { value: 240 },
II: { value: 336 }
}
}, {
key: 'target-detonation',
label: 'Target Detonation',
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
statePool: 'marked-target',
perStack: true,
variants: {
I: {
value: 240,
expression: {
op: 'multiply',
left: 240,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e'
}
},
right: 100
}
}
},
II: {
value: 336,
expression: {
op: 'multiply',
left: 336,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e'
}
},
right: 100
}
}
}
}
}]
}, {
key: 'extra-health-state',
label: 'Extra Health state',
event: 'extra_health_changed',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.hasExtraHealth' },
operator: 'eq',
right: true
},
reducer: { kind: 'none' },
mutations: [],
outcomes: []
}]
});
await saveConditionalRuleInTransaction(
database,
'talent',
'predatory-aura',
{
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [
{
key: 'marked-target',
label: 'Marked Target',
stateKind: 'target_mark',
scope: 'target',
initial: 0,
min: 0,
max: 1,
expiresAfterSecondsVariants: {
I: 12,
II: 18,
},
} as ConditionalMechanicDraftV1['pools'][number] & {
expiresAfterSecondsVariants: Record<string, number>;
},
],
clauses: [
{
key: 'weapon-kill',
label: 'Weapon kill',
event: 'enemy_killed',
if: {
kind: 'compare',
left: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current',
},
operator: 'lt',
right: 75,
},
reducer: { kind: 'none' },
mutations: [
{
kind: 'set',
pool: 'marked-target',
entity: { kind: 'event_target' },
value: 1,
},
],
outcomes: [
{
key: 'restore-health',
label: 'Restore Health',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
resource: 'health',
basis: 'flat',
delivery: 'continuous',
durationSeconds: 3,
targeting: {
kind: 'within_radius',
population: 'allies',
radiusMeters: 40,
},
variants: {
I: { value: 240 },
II: { value: 336 },
},
},
{
key: 'target-detonation',
label: 'Target Detonation',
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
statePool: 'marked-target',
perStack: true,
variants: {
I: {
value: 240,
expression: {
op: 'multiply',
left: 240,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e',
},
},
right: 100,
},
},
},
II: {
value: 336,
expression: {
op: 'multiply',
left: 336,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e',
},
},
right: 100,
},
},
},
},
},
],
},
{
key: 'extra-health-state',
label: 'Extra Health state',
event: 'extra_health_changed',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.hasExtraHealth' },
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [],
},
],
},
);
const saved = await (await database.prepare(
'SELECT delivery_mode,delivery_duration_seconds FROM conditional_effect_outcomes ORDER BY variant_key'
)).all();
expect(saved).toEqual([
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 },
{ delivery_mode: 'instant', delivery_duration_seconds: null },
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 },
{ delivery_mode: 'instant', delivery_duration_seconds: null }
]);
expect(await (await database.prepare(
'SELECT duration_seconds,parameters_json FROM conditional_effect_state_pools'
)).get()).toEqual({
duration_seconds: null,
parameters_json: JSON.stringify({
expiresAfterSecondsVariants: {
I: 12,
II: 18
}
})
});
const loaded = await loadConditionalRuleDraft(
database,
'talent',
'predatory-aura',
['I', 'II']
);
expect(loaded?.clauses[0].outcomes[0]).toMatchObject({
delivery: 'continuous',
durationSeconds: 3,
targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40 },
variants: {
I: { value: 240 },
II: { value: 336 }
}
});
expect(loaded?.clauses[0].if).toEqual({
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'lt',
right: 75
});
expect(loaded?.pools[0]).toMatchObject({
key: 'marked-target',
stateKind: 'target_mark',
scope: 'target',
expiresAfterSecondsVariants: {
I: 12,
II: 18
}
});
expect(loaded?.clauses[0].mutations).toEqual([{
kind: 'set',
pool: 'marked-target',
entity: { kind: 'event_target' },
value: 1
}]);
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
statePool: 'marked-target',
perStack: true,
variants: {
I: {
value: 240,
expression: expect.objectContaining({
right: expect.objectContaining({
left: {
ref: {
source: 'attribute',
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e'
}
}
})
})
},
II: { value: 336 }
}
});
expect(loaded?.clauses[1]).toMatchObject({
event: 'extra_health_changed',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.hasExtraHealth' },
operator: 'eq',
right: true
}
});
});
const saved = await (
await database.prepare(
'SELECT delivery_mode,delivery_duration_seconds FROM conditional_effect_outcomes ORDER BY variant_key',
)
).all();
expect(saved).toEqual([
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 },
{ delivery_mode: 'instant', delivery_duration_seconds: null },
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 },
{ delivery_mode: 'instant', delivery_duration_seconds: null },
]);
expect(
await (
await database.prepare(
'SELECT duration_seconds,parameters_json FROM conditional_effect_state_pools',
)
).get(),
).toEqual({
duration_seconds: null,
parameters_json: JSON.stringify({
expiresAfterSecondsVariants: {
I: 12,
II: 18,
},
}),
});
const loaded = await loadConditionalRuleDraft(
database,
'talent',
'predatory-aura',
['I', 'II'],
);
expect(loaded?.clauses[0].outcomes[0]).toMatchObject({
delivery: 'continuous',
durationSeconds: 3,
targeting: {
kind: 'within_radius',
population: 'allies',
radiusMeters: 40,
},
variants: {
I: { value: 240 },
II: { value: 336 },
},
});
expect(loaded?.clauses[0].if).toEqual({
kind: 'compare',
left: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current',
},
operator: 'lt',
right: 75,
});
expect(loaded?.pools[0]).toMatchObject({
key: 'marked-target',
stateKind: 'target_mark',
scope: 'target',
expiresAfterSecondsVariants: {
I: 12,
II: 18,
},
});
expect(loaded?.clauses[0].mutations).toEqual([
{
kind: 'set',
pool: 'marked-target',
entity: { kind: 'event_target' },
value: 1,
},
]);
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
statePool: 'marked-target',
perStack: true,
variants: {
I: {
value: 240,
expression: expect.objectContaining({
right: expect.objectContaining({
left: {
ref: {
source: 'attribute',
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e',
},
},
}),
}),
},
II: { value: 336 },
},
});
expect(loaded?.clauses[1]).toMatchObject({
event: 'extra_health_changed',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.hasExtraHealth' },
operator: 'eq',
right: true,
},
});
});
});
+516 -367
View File
@@ -1,19 +1,23 @@
import {
parseConditionalMechanicDraft,
type ConditionalMechanicClauseDraft,
type ConditionalMechanicDraftV1,
type ConditionalMechanicOutcomeDraft,
type ConditionalMechanicPoolDraft
parseConditionalMechanicDraft,
type ConditionalMechanicClauseDraft,
type ConditionalMechanicDraftV1,
type ConditionalMechanicOutcomeDraft,
type ConditionalMechanicPoolDraft,
} from '$lib/calculations/mechanic-editor';
import type { MechanicOutcome, MechanicResource, NumericExpression } from '$lib/calculations/mechanic';
import type {
MechanicOutcome,
MechanicResource,
NumericExpression,
} from '$lib/calculations/mechanic';
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
const sourceTypes = [
'talent',
'os_protocol_talent',
'skill_chip_talent',
'specialization_tree_rank'
'talent',
'os_protocol_talent',
'skill_chip_talent',
'specialization_tree_rank',
] as const;
export type ConditionalSourceType = (typeof sourceTypes)[number];
@@ -25,252 +29,360 @@ type ClauseRow = Record<string, unknown>;
type OutcomeRow = Record<string, unknown>;
const parseJson = <T>(value: unknown, fallback: T): T => {
try {
const parsed = JSON.parse(String(value ?? ''));
return parsed === null || parsed === undefined ? fallback : parsed as T;
} catch {
return fallback;
}
try {
const parsed = JSON.parse(String(value ?? ''));
return parsed === null || parsed === undefined ? fallback : (parsed as T);
} catch {
return fallback;
}
};
const applicationKind = (outcome: ConditionalMechanicOutcomeDraft) => {
if (outcome.kind === 'attribute_modifier') return 'active_modifier';
if (outcome.kind === 'direct_damage') return 'direct_damage';
if (outcome.kind === 'resource_cost') return 'resource_cost';
if (outcome.kind === 'weapon_reload') return 'weapon_reload';
if (outcome.kind === 'marker') return 'marker';
if (outcome.resource === 'armor') return 'restore_armor';
if (outcome.resource === 'ammo') return 'restore_ammo';
if (outcome.resource === 'signature_ability_charge') return 'restore_signature_ability_charge';
return 'restore_health';
if (outcome.kind === 'attribute_modifier') return 'active_modifier';
if (outcome.kind === 'direct_damage') return 'direct_damage';
if (outcome.kind === 'resource_cost') return 'resource_cost';
if (outcome.kind === 'weapon_reload') return 'weapon_reload';
if (outcome.kind === 'marker') return 'marker';
if (outcome.resource === 'armor') return 'restore_armor';
if (outcome.resource === 'ammo') return 'restore_ammo';
if (outcome.resource === 'signature_ability_charge')
return 'restore_signature_ability_charge';
return 'restore_health';
};
const outcomeKind = (row: OutcomeRow): MechanicOutcome['kind'] => {
if (row.application_kind === 'active_modifier') return 'attribute_modifier';
if (row.application_kind === 'direct_damage') return 'direct_damage';
if (row.application_kind === 'resource_cost') return 'resource_cost';
if (row.application_kind === 'weapon_reload') return 'weapon_reload';
if (row.application_kind === 'marker') return 'marker';
return 'restoration';
if (row.application_kind === 'active_modifier') return 'attribute_modifier';
if (row.application_kind === 'direct_damage') return 'direct_damage';
if (row.application_kind === 'resource_cost') return 'resource_cost';
if (row.application_kind === 'weapon_reload') return 'weapon_reload';
if (row.application_kind === 'marker') return 'marker';
return 'restoration';
};
const outcomeResource = (row: OutcomeRow): MechanicResource | undefined => {
if (
row.resource_kind === 'health'
|| row.resource_kind === 'armor'
|| row.resource_kind === 'ammo'
|| row.resource_kind === 'signature_ability_charge'
) {
return row.resource_kind;
}
if (row.application_kind === 'restore_health') return 'health';
if (row.application_kind === 'restore_armor') return 'armor';
if (row.application_kind === 'restore_ammo') return 'ammo';
if (row.application_kind === 'restore_signature_ability_charge') return 'signature_ability_charge';
return undefined;
if (
row.resource_kind === 'health' ||
row.resource_kind === 'armor' ||
row.resource_kind === 'ammo' ||
row.resource_kind === 'signature_ability_charge'
) {
return row.resource_kind;
}
if (row.application_kind === 'restore_health') return 'health';
if (row.application_kind === 'restore_armor') return 'armor';
if (row.application_kind === 'restore_ammo') return 'ammo';
if (row.application_kind === 'restore_signature_ability_charge')
return 'signature_ability_charge';
return undefined;
};
const outcomeMetadataColumns = [
'outcome_key',
'application_kind',
'effect_scope',
'stat_attribute_id',
'value_basis',
'value_scaling',
'damage_basis',
'application_phase',
'combat_timing',
'resource_kind',
'delivery_mode',
'delivery_duration_seconds',
'targeting_json',
'state_pool_id',
'min_state_value',
'max_state_value',
'description_slot'
'outcome_key',
'application_kind',
'effect_scope',
'stat_attribute_id',
'value_basis',
'value_scaling',
'damage_basis',
'application_phase',
'combat_timing',
'resource_kind',
'delivery_mode',
'delivery_duration_seconds',
'targeting_json',
'state_pool_id',
'min_state_value',
'max_state_value',
'description_slot',
] as const;
const comparableMetadataValue = (column: typeof outcomeMetadataColumns[number], value: unknown) => {
if (column === 'delivery_mode') return value === undefined || value === null ? 'instant' : value;
return value === undefined ? null : value;
const comparableMetadataValue = (
column: (typeof outcomeMetadataColumns)[number],
value: unknown,
) => {
if (column === 'delivery_mode')
return value === undefined || value === null ? 'instant' : value;
return value === undefined ? null : value;
};
function assertVariantMetadataConsistent(effectKey: string, variants: OutcomeRow[]) {
const first = variants[0];
for (const variant of variants.slice(1)) {
const mismatch = outcomeMetadataColumns.find((column) =>
comparableMetadataValue(column, variant[column]) !== comparableMetadataValue(column, first[column])
);
if (mismatch) {
throw new Error(
`Conditional outcome "${effectKey}" has inconsistent ${mismatch} metadata across value variants.`
);
}
}
function assertVariantMetadataConsistent(
effectKey: string,
variants: OutcomeRow[],
) {
const first = variants[0];
for (const variant of variants.slice(1)) {
const mismatch = outcomeMetadataColumns.find(
(column) =>
comparableMetadataValue(column, variant[column]) !==
comparableMetadataValue(column, first[column]),
);
if (mismatch) {
throw new Error(
`Conditional outcome "${effectKey}" has inconsistent ${mismatch} metadata across value variants.`,
);
}
}
}
export function parseConditionalRulePayload(
raw: FormDataEntryValue | string | null,
valueColumns: ConditionalValueColumn[],
options: {
allowedAttributeIds?: ReadonlySet<string>;
allowedRecipeKeys?: ReadonlySet<string>;
programId?: string;
programName?: string;
} = {}
raw: FormDataEntryValue | string | null,
valueColumns: ConditionalValueColumn[],
options: {
allowedAttributeIds?: ReadonlySet<string>;
allowedRecipeKeys?: ReadonlySet<string>;
programId?: string;
programName?: string;
} = {},
) {
const result = parseConditionalMechanicDraft(raw, {
variantKeys: valueColumns.map((column) => column.variantKey),
allowedAttributeIds: options.allowedAttributeIds,
allowedRecipeKeys: options.allowedRecipeKeys,
programId: options.programId,
programName: options.programName
});
if (!result.ok) throw new Error(result.errors.join(' '));
return result.value.draft;
const result = parseConditionalMechanicDraft(raw, {
variantKeys: valueColumns.map((column) => column.variantKey),
allowedAttributeIds: options.allowedAttributeIds,
allowedRecipeKeys: options.allowedRecipeKeys,
programId: options.programId,
programName: options.programName,
});
if (!result.ok) throw new Error(result.errors.join(' '));
return result.value.draft;
}
export function conditionalRuleDraftFromRows(
rule: RuleRow | null | undefined,
poolRows: PoolRow[],
clauseRows: ClauseRow[],
outcomeRows: OutcomeRow[],
variantKeys: readonly string[]
rule: RuleRow | null | undefined,
poolRows: PoolRow[],
clauseRows: ClauseRow[],
outcomeRows: OutcomeRow[],
variantKeys: readonly string[],
): ConditionalMechanicDraftV1 | null {
if (!rule) return null;
const pools = poolRows
.filter((row) => row.rule_id === rule.id)
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
.map((row): ConditionalMechanicPoolDraft => {
const parameters = parseJson<Record<string, unknown>>(row.parameters_json, {});
const expiresAfterSecondsVariants = parameters.expiresAfterSecondsVariants;
return {
key: String(row.state_key),
label: String(row.label),
stateKind: String(row.state_kind) as ConditionalMechanicPoolDraft['stateKind'],
scope: String(row.state_scope) as ConditionalMechanicPoolDraft['scope'],
initial: Number(row.initial_value),
...(row.minimum_value === null || row.minimum_value === undefined ? {} : { min: Number(row.minimum_value) }),
...(row.maximum_value === null || row.maximum_value === undefined ? {} : { max: Number(row.maximum_value) }),
...(row.best_case_value === null || row.best_case_value === undefined ? {} : { bestCaseValue: Number(row.best_case_value) }),
...(row.duration_seconds === null || row.duration_seconds === undefined ? {} : { expiresAfterSeconds: Number(row.duration_seconds) }),
...(expiresAfterSecondsVariants && typeof expiresAfterSecondsVariants === 'object'
&& !Array.isArray(expiresAfterSecondsVariants)
? { expiresAfterSecondsVariants: expiresAfterSecondsVariants as Record<string, number> }
: {})
};
});
const poolKeyById = new Map(poolRows.map((row) => [String(row.id), String(row.state_key)]));
const rowsByClause = new Map<string, OutcomeRow[]>();
for (const row of outcomeRows.filter((item) => item.rule_id === rule.id)) {
const key = String(row.clause_id ?? '');
rowsByClause.set(key, [...(rowsByClause.get(key) ?? []), row]);
}
const clauses = clauseRows
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
.map((row): ConditionalMechanicClauseDraft => {
const grouped = new Map<string, OutcomeRow[]>();
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
const key = String(outcome.effect_key);
grouped.set(key, [...(grouped.get(key) ?? []), outcome]);
}
const outcomes = [...grouped.entries()].map(([key, variants]): ConditionalMechanicOutcomeDraft => {
assertVariantMetadataConsistent(key, variants);
const first = variants[0];
const resource = outcomeResource(first);
const delivery = first.delivery_mode === undefined || first.delivery_mode === null
? 'instant'
: String(first.delivery_mode);
const durationSeconds = first.delivery_duration_seconds === undefined
|| first.delivery_duration_seconds === null
? null
: Number(first.delivery_duration_seconds);
if (delivery !== 'instant' && delivery !== 'continuous') {
throw new Error(`Conditional outcome "${key}" has an unsupported delivery mode.`);
}
if (delivery === 'continuous' && (!Number.isFinite(durationSeconds) || Number(durationSeconds) <= 0)) {
throw new Error(`Conditional outcome "${key}" continuous delivery requires a positive duration.`);
}
if (delivery === 'instant' && durationSeconds !== null) {
throw new Error(`Conditional outcome "${key}" instant delivery cannot set a duration.`);
}
return {
key,
label: String(first.outcome_key || key),
phase: String(first.application_phase) as ConditionalMechanicOutcomeDraft['phase'],
kind: outcomeKind(first),
scope: String(first.effect_scope) as ConditionalMechanicOutcomeDraft['scope'],
basis: String(first.value_basis) as ConditionalMechanicOutcomeDraft['basis'],
...(resource ? { resource } : {}),
...(first.combat_timing ? { combatTiming: String(first.combat_timing) as 'pre_event' | 'current' } : {}),
...(delivery === 'continuous'
? { delivery, durationSeconds: durationSeconds as number }
: {}),
...(first.targeting_json
? { targeting: parseJson(first.targeting_json, undefined) }
: {}),
...(first.stat_attribute_id ? { attributeId: String(first.stat_attribute_id) } : {}),
...(first.state_pool_id && poolKeyById.has(String(first.state_pool_id))
? { statePool: poolKeyById.get(String(first.state_pool_id)) }
: {}),
perStack: first.value_scaling === 'per_state_unit',
...(first.min_state_value === null || first.min_state_value === undefined ? {} : { minStacks: Number(first.min_state_value) }),
...(first.max_state_value === null || first.max_state_value === undefined ? {} : { maxStacks: Number(first.max_state_value) }),
...(first.description_slot === null || first.description_slot === undefined ? {} : { descriptionSlot: Number(first.description_slot) }),
variants: Object.fromEntries(variantKeys.map((variant) => {
const variantRow = variants.find((candidate) => candidate.variant_key === variant);
return [variant, {
value: Number(variantRow?.value ?? 0),
...(variantRow?.value_expression_json
? { expression: parseJson<NumericExpression>(variantRow.value_expression_json, Number(variantRow.value ?? 0)) }
: {})
}];
}))
};
});
return {
key: String(row.clause_key),
label: String(row.label),
event: String(row.event_kind) as ConditionalMechanicClauseDraft['event'],
...(row.recipe_key ? { recipeKey: String(row.recipe_key) } : {}),
if: parseJson(row.conditions_json, { kind: 'true' }),
reducer: parseJson(row.reducer_json, { kind: 'none' }),
...(row.weight_expression_json
? { weight: parseJson<NumericExpression>(row.weight_expression_json, 1) }
: {}),
...(row.cooldown_seconds === null || row.cooldown_seconds === undefined
? {}
: { cooldownSeconds: Number(row.cooldown_seconds) }),
mutations: parseJson(row.mutations_json, []),
outcomes
};
});
return {
version: 1,
simulationReadiness: String(rule.simulation_readiness ?? 'captured_only') as ConditionalMechanicDraftV1['simulationReadiness'],
scenarioInputs: parseJson(rule.scenario_inputs_json, []),
pools,
clauses
};
if (!rule) return null;
const pools = poolRows
.filter((row) => row.rule_id === rule.id)
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
.map((row): ConditionalMechanicPoolDraft => {
const parameters = parseJson<Record<string, unknown>>(
row.parameters_json,
{},
);
const expiresAfterSecondsVariants =
parameters.expiresAfterSecondsVariants;
return {
key: String(row.state_key),
label: String(row.label),
stateKind: String(
row.state_kind,
) as ConditionalMechanicPoolDraft['stateKind'],
scope: String(row.state_scope) as ConditionalMechanicPoolDraft['scope'],
initial: Number(row.initial_value),
...(row.minimum_value === null || row.minimum_value === undefined
? {}
: { min: Number(row.minimum_value) }),
...(row.maximum_value === null || row.maximum_value === undefined
? {}
: { max: Number(row.maximum_value) }),
...(row.best_case_value === null || row.best_case_value === undefined
? {}
: { bestCaseValue: Number(row.best_case_value) }),
...(row.duration_seconds === null || row.duration_seconds === undefined
? {}
: { expiresAfterSeconds: Number(row.duration_seconds) }),
...(expiresAfterSecondsVariants &&
typeof expiresAfterSecondsVariants === 'object' &&
!Array.isArray(expiresAfterSecondsVariants)
? {
expiresAfterSecondsVariants:
expiresAfterSecondsVariants as Record<string, number>,
}
: {}),
};
});
const poolKeyById = new Map(
poolRows.map((row) => [String(row.id), String(row.state_key)]),
);
const rowsByClause = new Map<string, OutcomeRow[]>();
for (const row of outcomeRows.filter((item) => item.rule_id === rule.id)) {
const key = String(row.clause_id ?? '');
rowsByClause.set(key, [...(rowsByClause.get(key) ?? []), row]);
}
const clauses = clauseRows
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
.map((row): ConditionalMechanicClauseDraft => {
const grouped = new Map<string, OutcomeRow[]>();
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
const key = String(outcome.effect_key);
grouped.set(key, [...(grouped.get(key) ?? []), outcome]);
}
const outcomes = [...grouped.entries()].map(
([key, variants]): ConditionalMechanicOutcomeDraft => {
assertVariantMetadataConsistent(key, variants);
const first = variants[0];
const resource = outcomeResource(first);
const delivery =
first.delivery_mode === undefined || first.delivery_mode === null
? 'instant'
: String(first.delivery_mode);
const durationSeconds =
first.delivery_duration_seconds === undefined ||
first.delivery_duration_seconds === null
? null
: Number(first.delivery_duration_seconds);
if (delivery !== 'instant' && delivery !== 'continuous') {
throw new Error(
`Conditional outcome "${key}" has an unsupported delivery mode.`,
);
}
if (
delivery === 'continuous' &&
(!Number.isFinite(durationSeconds) || Number(durationSeconds) <= 0)
) {
throw new Error(
`Conditional outcome "${key}" continuous delivery requires a positive duration.`,
);
}
if (delivery === 'instant' && durationSeconds !== null) {
throw new Error(
`Conditional outcome "${key}" instant delivery cannot set a duration.`,
);
}
return {
key,
label: String(first.outcome_key || key),
phase: String(
first.application_phase,
) as ConditionalMechanicOutcomeDraft['phase'],
kind: outcomeKind(first),
scope: String(
first.effect_scope,
) as ConditionalMechanicOutcomeDraft['scope'],
basis: String(
first.value_basis,
) as ConditionalMechanicOutcomeDraft['basis'],
...(resource ? { resource } : {}),
...(first.combat_timing
? {
combatTiming: String(first.combat_timing) as
'pre_event' | 'current',
}
: {}),
...(delivery === 'continuous'
? { delivery, durationSeconds: durationSeconds as number }
: {}),
...(first.targeting_json
? { targeting: parseJson(first.targeting_json, undefined) }
: {}),
...(first.stat_attribute_id
? { attributeId: String(first.stat_attribute_id) }
: {}),
...(first.state_pool_id &&
poolKeyById.has(String(first.state_pool_id))
? { statePool: poolKeyById.get(String(first.state_pool_id)) }
: {}),
perStack: first.value_scaling === 'per_state_unit',
...(first.min_state_value === null ||
first.min_state_value === undefined
? {}
: { minStacks: Number(first.min_state_value) }),
...(first.max_state_value === null ||
first.max_state_value === undefined
? {}
: { maxStacks: Number(first.max_state_value) }),
...(first.description_slot === null ||
first.description_slot === undefined
? {}
: { descriptionSlot: Number(first.description_slot) }),
variants: Object.fromEntries(
variantKeys.map((variant) => {
const variantRow = variants.find(
(candidate) => candidate.variant_key === variant,
);
return [
variant,
{
value: Number(variantRow?.value ?? 0),
...(variantRow?.value_expression_json
? {
expression: parseJson<NumericExpression>(
variantRow.value_expression_json,
Number(variantRow.value ?? 0),
),
}
: {}),
},
];
}),
),
};
},
);
return {
key: String(row.clause_key),
label: String(row.label),
event: String(
row.event_kind,
) as ConditionalMechanicClauseDraft['event'],
...(row.recipe_key ? { recipeKey: String(row.recipe_key) } : {}),
if: parseJson(row.conditions_json, { kind: 'true' }),
reducer: parseJson(row.reducer_json, { kind: 'none' }),
...(row.weight_expression_json
? {
weight: parseJson<NumericExpression>(
row.weight_expression_json,
1,
),
}
: {}),
...(row.cooldown_seconds === null || row.cooldown_seconds === undefined
? {}
: { cooldownSeconds: Number(row.cooldown_seconds) }),
mutations: parseJson(row.mutations_json, []),
outcomes,
};
});
return {
version: 1,
simulationReadiness: String(
rule.simulation_readiness ?? 'captured_only',
) as ConditionalMechanicDraftV1['simulationReadiness'],
scenarioInputs: parseJson(rule.scenario_inputs_json, []),
pools,
clauses,
};
}
export async function loadConditionalRuleDraft(
database: Database,
sourceType: ConditionalSourceType,
sourceId: string,
variantKeys: readonly string[]
database: Database,
sourceType: ConditionalSourceType,
sourceId: string,
variantKeys: readonly string[],
) {
const rule = await (await database.prepare(
'SELECT * FROM conditional_effect_rules WHERE source_type=? AND source_id=?'
)).get(sourceType, sourceId);
if (!rule) return null;
const [pools, clauses, outcomes] = await Promise.all([
(await database.prepare('SELECT * FROM conditional_effect_state_pools WHERE rule_id=? ORDER BY sort_order')).all(rule.id),
(await database.prepare('SELECT * FROM conditional_effect_clauses WHERE rule_id=? ORDER BY sort_order')).all(rule.id),
(await database.prepare('SELECT * FROM conditional_effect_outcomes WHERE rule_id=? ORDER BY sort_order,variant_key')).all(rule.id)
]);
return conditionalRuleDraftFromRows(rule, pools, clauses, outcomes, variantKeys);
const rule = await (
await database.prepare(
'SELECT * FROM conditional_effect_rules WHERE source_type=? AND source_id=?',
)
).get(sourceType, sourceId);
if (!rule) return null;
const [pools, clauses, outcomes] = await Promise.all([
(
await database.prepare(
'SELECT * FROM conditional_effect_state_pools WHERE rule_id=? ORDER BY sort_order',
)
).all(rule.id),
(
await database.prepare(
'SELECT * FROM conditional_effect_clauses WHERE rule_id=? ORDER BY sort_order',
)
).all(rule.id),
(
await database.prepare(
'SELECT * FROM conditional_effect_outcomes WHERE rule_id=? ORDER BY sort_order,variant_key',
)
).all(rule.id),
]);
return conditionalRuleDraftFromRows(
rule,
pools,
clauses,
outcomes,
variantKeys,
);
}
/**
@@ -279,18 +391,22 @@ export async function loadConditionalRuleDraft(
* source record and append-only audit event.
*/
export async function saveConditionalRuleInTransaction(
database: Database,
sourceType: ConditionalSourceType,
sourceId: string,
draft: ConditionalMechanicDraftV1
database: Database,
sourceType: ConditionalSourceType,
sourceId: string,
draft: ConditionalMechanicDraftV1,
) {
if (!sourceTypes.includes(sourceType)) throw new Error('Unsupported conditional source.');
const existing = await (await database.prepare(
'SELECT id,created_at FROM conditional_effect_rules WHERE source_type=? AND source_id=?'
)).get(sourceType, sourceId);
const now = new Date().toISOString();
const ruleId = String(existing?.id ?? randomUUID());
await (await database.prepare(`INSERT INTO conditional_effect_rules
if (!sourceTypes.includes(sourceType))
throw new Error('Unsupported conditional source.');
const existing = await (
await database.prepare(
'SELECT id,created_at FROM conditional_effect_rules WHERE source_type=? AND source_id=?',
)
).get(sourceType, sourceId);
const now = new Date().toISOString();
const ruleId = String(existing?.id ?? randomUUID());
await (
await database.prepare(`INSERT INTO conditional_effect_rules
(id,source_type,source_id,configured,script_version,simulation_readiness,
scenario_inputs_json,created_at,updated_at)
VALUES(?,?,?,1,1,?,?,?,?)
@@ -299,150 +415,183 @@ export async function saveConditionalRuleInTransaction(
script_version=1,
simulation_readiness=excluded.simulation_readiness,
scenario_inputs_json=excluded.scenario_inputs_json,
updated_at=excluded.updated_at`)).run(
ruleId,
sourceType,
sourceId,
draft.simulationReadiness,
JSON.stringify(draft.scenarioInputs),
String(existing?.created_at ?? now),
now
);
await (await database.prepare('DELETE FROM conditional_effect_outcomes WHERE rule_id=?')).run(ruleId);
await (await database.prepare('DELETE FROM conditional_effect_clauses WHERE rule_id=?')).run(ruleId);
await (await database.prepare('DELETE FROM conditional_effect_state_pools WHERE rule_id=?')).run(ruleId);
updated_at=excluded.updated_at`)
).run(
ruleId,
sourceType,
sourceId,
draft.simulationReadiness,
JSON.stringify(draft.scenarioInputs),
String(existing?.created_at ?? now),
now,
);
await (
await database.prepare(
'DELETE FROM conditional_effect_outcomes WHERE rule_id=?',
)
).run(ruleId);
await (
await database.prepare(
'DELETE FROM conditional_effect_clauses WHERE rule_id=?',
)
).run(ruleId);
await (
await database.prepare(
'DELETE FROM conditional_effect_state_pools WHERE rule_id=?',
)
).run(ruleId);
const poolIds = new Map<string, string>();
for (const [index, pool] of draft.pools.entries()) {
const expiresAfterSecondsVariants = (
pool as ConditionalMechanicPoolDraft & {
expiresAfterSecondsVariants?: Record<string, number>;
}
).expiresAfterSecondsVariants;
const poolId = randomUUID();
poolIds.set(pool.key, poolId);
await (await database.prepare(`INSERT INTO conditional_effect_state_pools
const poolIds = new Map<string, string>();
for (const [index, pool] of draft.pools.entries()) {
const expiresAfterSecondsVariants = (
pool as ConditionalMechanicPoolDraft & {
expiresAfterSecondsVariants?: Record<string, number>;
}
).expiresAfterSecondsVariants;
const poolId = randomUUID();
poolIds.set(pool.key, poolId);
await (
await database.prepare(`INSERT INTO conditional_effect_state_pools
(id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value,
maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy,
parameters_json,sort_order,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
poolId,
ruleId,
pool.key,
pool.label,
pool.stateKind,
pool.scope,
pool.initial,
pool.min ?? null,
pool.max ?? Math.max(1, pool.bestCaseValue ?? pool.initial ?? 1),
pool.bestCaseValue ?? null,
pool.expiresAfterSeconds ?? null,
'clear_all',
'refresh',
JSON.stringify(expiresAfterSecondsVariants
? { expiresAfterSecondsVariants }
: {}),
index + 1,
now,
now
);
}
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
poolId,
ruleId,
pool.key,
pool.label,
pool.stateKind,
pool.scope,
pool.initial,
pool.min ?? null,
pool.max ?? Math.max(1, pool.bestCaseValue ?? pool.initial ?? 1),
pool.bestCaseValue ?? null,
pool.expiresAfterSeconds ?? null,
'clear_all',
'refresh',
JSON.stringify(
expiresAfterSecondsVariants ? { expiresAfterSecondsVariants } : {},
),
index + 1,
now,
now,
);
}
let outcomeSortOrder = 0;
for (const [clauseIndex, clause] of draft.clauses.entries()) {
const clauseId = randomUUID();
await (await database.prepare(`INSERT INTO conditional_effect_clauses
let outcomeSortOrder = 0;
for (const [clauseIndex, clause] of draft.clauses.entries()) {
const clauseId = randomUUID();
await (
await database.prepare(`INSERT INTO conditional_effect_clauses
(id,rule_id,clause_key,label,event_kind,recipe_key,recipe_parameters_json,
conditions_json,reducer_json,weight_expression_json,emission_kind,state_pool_id,
mutations_json,cooldown_seconds,output_branch,sort_order,active,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
clauseId,
ruleId,
clause.key,
clause.label || clause.key,
clause.event,
clause.recipeKey ?? null,
'{}',
JSON.stringify(clause.if ?? { kind: 'true' }),
JSON.stringify(clause.reducer),
clause.weight === undefined ? null : JSON.stringify(clause.weight),
'each_match',
null,
JSON.stringify(clause.mutations),
clause.cooldownSeconds ?? null,
'active',
clauseIndex + 1,
1,
now,
now
);
for (const [outcomeIndex, outcome] of clause.outcomes.entries()) {
for (const [variantKey, variant] of Object.entries(outcome.variants)) {
outcomeSortOrder += 1;
await (await database.prepare(`INSERT INTO conditional_effect_outcomes
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
clauseId,
ruleId,
clause.key,
clause.label || clause.key,
clause.event,
clause.recipeKey ?? null,
'{}',
JSON.stringify(clause.if ?? { kind: 'true' }),
JSON.stringify(clause.reducer),
clause.weight === undefined ? null : JSON.stringify(clause.weight),
'each_match',
null,
JSON.stringify(clause.mutations),
clause.cooldownSeconds ?? null,
'active',
clauseIndex + 1,
1,
now,
now,
);
for (const [outcomeIndex, outcome] of clause.outcomes.entries()) {
for (const [variantKey, variant] of Object.entries(outcome.variants)) {
outcomeSortOrder += 1;
await (
await database.prepare(`INSERT INTO conditional_effect_outcomes
(id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,
application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
resource_kind,delivery_mode,delivery_duration_seconds,
targeting_json,min_state_value,max_state_value,description_slot,sort_order,
created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
randomUUID(),
ruleId,
clauseId,
outcome.statePool ? poolIds.get(outcome.statePool) ?? null : null,
outcome.key,
variantKey,
outcome.label,
applicationKind(outcome),
outcome.scope,
outcome.attributeId ?? null,
variant.value,
variant.expression === undefined ? null : JSON.stringify(variant.expression),
outcome.basis,
outcome.perStack === false ? 'flat' : 'per_state_unit',
null,
outcome.kind === 'direct_damage'
? outcome.basis === 'weapon_base_percent' ? 'weapon_base_percent' : 'flat'
: null,
outcome.phase,
outcome.combatTiming ?? 'current',
outcome.resource ?? null,
outcome.delivery ?? 'instant',
outcome.durationSeconds ?? null,
outcome.targeting === undefined ? null : JSON.stringify(outcome.targeting),
outcome.minStacks ?? null,
outcome.maxStacks ?? null,
outcome.descriptionSlot ?? null,
outcomeSortOrder,
now,
now
);
}
}
}
return ruleId;
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
randomUUID(),
ruleId,
clauseId,
outcome.statePool ? (poolIds.get(outcome.statePool) ?? null) : null,
outcome.key,
variantKey,
outcome.label,
applicationKind(outcome),
outcome.scope,
outcome.attributeId ?? null,
variant.value,
variant.expression === undefined
? null
: JSON.stringify(variant.expression),
outcome.basis,
outcome.perStack === false ? 'flat' : 'per_state_unit',
null,
outcome.kind === 'direct_damage'
? outcome.basis === 'weapon_base_percent'
? 'weapon_base_percent'
: 'flat'
: null,
outcome.phase,
outcome.combatTiming ?? 'current',
outcome.resource ?? null,
outcome.delivery ?? 'instant',
outcome.durationSeconds ?? null,
outcome.targeting === undefined
? null
: JSON.stringify(outcome.targeting),
outcome.minStacks ?? null,
outcome.maxStacks ?? null,
outcome.descriptionSlot ?? null,
outcomeSortOrder,
now,
now,
);
}
}
}
return ruleId;
}
export async function conditionalRuleSnapshot(
database: Database,
sourceType: ConditionalSourceType,
sourceId: string
database: Database,
sourceType: ConditionalSourceType,
sourceId: string,
) {
const rule = await (await database.prepare(
'SELECT * FROM conditional_effect_rules WHERE source_type=? AND source_id=?'
)).get(sourceType, sourceId);
if (!rule) return null;
return {
rule,
statePools: await (await database.prepare(
'SELECT * FROM conditional_effect_state_pools WHERE rule_id=? ORDER BY sort_order'
)).all(rule.id),
clauses: await (await database.prepare(
'SELECT * FROM conditional_effect_clauses WHERE rule_id=? ORDER BY sort_order'
)).all(rule.id),
outcomes: await (await database.prepare(
'SELECT * FROM conditional_effect_outcomes WHERE rule_id=? ORDER BY variant_key,outcome_key,sort_order'
)).all(rule.id)
};
const rule = await (
await database.prepare(
'SELECT * FROM conditional_effect_rules WHERE source_type=? AND source_id=?',
)
).get(sourceType, sourceId);
if (!rule) return null;
return {
rule,
statePools: await (
await database.prepare(
'SELECT * FROM conditional_effect_state_pools WHERE rule_id=? ORDER BY sort_order',
)
).all(rule.id),
clauses: await (
await database.prepare(
'SELECT * FROM conditional_effect_clauses WHERE rule_id=? ORDER BY sort_order',
)
).all(rule.id),
outcomes: await (
await database.prepare(
'SELECT * FROM conditional_effect_outcomes WHERE rule_id=? ORDER BY variant_key,outcome_key,sort_order',
)
).all(rule.id),
};
}
+14 -10
View File
@@ -3,14 +3,18 @@ import { getDatabase } from '$lib/server/database';
export const CONTRIBUTOR_GUIDELINES_VERSION = '2026-07-25';
export async function getContributorMode(userId: string) {
const database = await getDatabase();
const agreement = await (await database.prepare(
'SELECT guidelines_version,accepted_at,enabled,enabled_at,disabled_at FROM contributor_agreements WHERE user_id=?'
)).get(userId);
return {
accepted: agreement?.guidelines_version === CONTRIBUTOR_GUIDELINES_VERSION,
enabled: agreement?.guidelines_version === CONTRIBUTOR_GUIDELINES_VERSION && Number(agreement?.enabled) === 1,
agreement: agreement ?? null,
version: CONTRIBUTOR_GUIDELINES_VERSION
};
const database = await getDatabase();
const agreement = await (
await database.prepare(
'SELECT guidelines_version,accepted_at,enabled,enabled_at,disabled_at FROM contributor_agreements WHERE user_id=?',
)
).get(userId);
return {
accepted: agreement?.guidelines_version === CONTRIBUTOR_GUIDELINES_VERSION,
enabled:
agreement?.guidelines_version === CONTRIBUTOR_GUIDELINES_VERSION &&
Number(agreement?.enabled) === 1,
agreement: agreement ?? null,
version: CONTRIBUTOR_GUIDELINES_VERSION,
};
}
+280 -151
View File
@@ -6,185 +6,314 @@ import { recordIsacAudit } from '$lib/server/audit';
import { getContributorMode } from '$lib/server/contributor-mode';
import { notifyOversightReview } from '$lib/server/oversight-notifications';
export const DATA_MODULES = ['configuration', 'calculations', 'specializations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'] as const;
export type DataModule = typeof DATA_MODULES[number];
export const DATA_MODULES = [
'configuration',
'calculations',
'specializations',
'attributes',
'talents',
'brands',
'gear',
'weapons',
'weaponmods',
'gearmods',
'protocols',
'skillchips',
] as const;
export type DataModule = (typeof DATA_MODULES)[number];
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
configuration: { saveCoreRanges: { saved: 'configurationSaved' } },
calculations: {
saveRevision: { saved: 'revisionSaved', mode: 'revisionMode' },
saveCalibration: { saved: 'calibrationSaved', mode: 'calibrationMode' }
},
attributes: { save: { saved: 'saved', mode: 'editorMode' } },
brands: { save: { saved: 'brandSaved', mode: 'brandMode' } },
gear: {
save: { saved: 'gearSaved', mode: 'gearMode' },
saveAugmentProfile: { saved: 'augmentProfileSaved' }
},
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
protocols: {
save: { saved: 'protocolSaved', mode: 'protocolMode' },
saveProfile: { saved: 'profileSaved' }
},
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
specializations: {
saveProgression: { saved: 'progressionSaved' },
saveSpecialization: { saved: 'specializationSaved', mode: 'specializationMode' },
saveFocus: { saved: 'focusSaved', mode: 'focusMode' },
saveAbility: { saved: 'abilitySaved', mode: 'abilityMode' },
saveCombatEffect: { saved: 'combatEffectSaved', mode: 'combatEffectMode' },
saveFocusAbilities: { saved: 'focusAbilitiesSaved' },
saveNode: { saved: 'nodeSaved', mode: 'nodeMode' }
}
const proposalResultKeys: Partial<
Record<DataModule, Record<string, { saved: string; mode?: string }>>
> = {
configuration: { saveCoreRanges: { saved: 'configurationSaved' } },
calculations: {
saveRevision: { saved: 'revisionSaved', mode: 'revisionMode' },
saveCalibration: { saved: 'calibrationSaved', mode: 'calibrationMode' },
},
attributes: { save: { saved: 'saved', mode: 'editorMode' } },
brands: { save: { saved: 'brandSaved', mode: 'brandMode' } },
gear: {
save: { saved: 'gearSaved', mode: 'gearMode' },
saveAugmentProfile: { saved: 'augmentProfileSaved' },
},
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
protocols: {
save: { saved: 'protocolSaved', mode: 'protocolMode' },
saveProfile: { saved: 'profileSaved' },
},
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
specializations: {
saveProgression: { saved: 'progressionSaved' },
saveSpecialization: {
saved: 'specializationSaved',
mode: 'specializationMode',
},
saveFocus: { saved: 'focusSaved', mode: 'focusMode' },
saveAbility: { saved: 'abilitySaved', mode: 'abilityMode' },
saveCombatEffect: { saved: 'combatEffectSaved', mode: 'combatEffectMode' },
saveFocusAbilities: { saved: 'focusAbilitiesSaved' },
saveNode: { saved: 'nodeSaved', mode: 'nodeMode' },
},
};
export function proposalActionResult(module: DataModule, action: string, edit: boolean, proposalId: string) {
const keys = proposalResultKeys[module]?.[action];
return keys
? { [keys.saved]: true, ...(keys.mode ? { [keys.mode]: edit ? 'edit' : 'add' } : {}), proposalId, resultToken: randomUUID() }
: { proposalQueued: true, proposalId, resultToken: randomUUID() };
export function proposalActionResult(
module: DataModule,
action: string,
edit: boolean,
proposalId: string,
) {
const keys = proposalResultKeys[module]?.[action];
return keys
? {
[keys.saved]: true,
...(keys.mode ? { [keys.mode]: edit ? 'edit' : 'add' } : {}),
proposalId,
resultToken: randomUUID(),
}
: { proposalQueued: true, proposalId, resultToken: randomUUID() };
}
type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
| { name: string; kind: 'text'; value: string }
| {
name: string;
kind: 'file';
value: string;
fileName: string;
type: string;
};
type PendingDefinition = {
module: DataModule;
action: string;
kind: string;
fields?: string[];
module: DataModule;
action: string;
kind: string;
fields?: string[];
};
async function serialize(form: FormData): Promise<SerializedEntry[]> {
const entries: SerializedEntry[] = [];
for (const [name, value] of form.entries()) {
if (typeof value === 'string') entries.push({ name, kind: 'text', value });
else if (value.size > 0) entries.push({
name,
kind: 'file',
value: Buffer.from(await value.arrayBuffer()).toString('base64'),
fileName: value.name,
type: value.type
});
}
return entries;
const entries: SerializedEntry[] = [];
for (const [name, value] of form.entries()) {
if (typeof value === 'string') entries.push({ name, kind: 'text', value });
else if (value.size > 0)
entries.push({
name,
kind: 'file',
value: Buffer.from(await value.arrayBuffer()).toString('base64'),
fileName: value.name,
type: value.type,
});
}
return entries;
}
export function deserializeProposal(payload: string): FormData {
const form = new FormData();
for (const entry of JSON.parse(payload) as SerializedEntry[]) {
if (entry.kind === 'text') form.append(entry.name, entry.value);
else form.append(entry.name, new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }), entry.fileName);
}
return form;
const form = new FormData();
for (const entry of JSON.parse(payload) as SerializedEntry[]) {
if (entry.kind === 'text') form.append(entry.name, entry.value);
else
form.append(
entry.name,
new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }),
entry.fileName,
);
}
return form;
}
export async function refreshProposalDependencies(proposalId: string, payload: string) {
const database = await getDatabase();
const text = serializedTextValues(payload).map((entry) => entry.value).join('\n');
const candidates = await (await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE id<>? AND draft_entity_id IS NOT NULL`)).all(proposalId);
const dependencies = candidates.filter((candidate) => text.includes(String(candidate.draft_entity_id)));
await (await database.prepare('DELETE FROM data_change_proposal_dependencies WHERE proposal_id=?')).run(proposalId);
for (const dependency of dependencies) {
await (await database.prepare('INSERT INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)')).run(proposalId, dependency.id);
}
return dependencies.map((dependency) => String(dependency.id));
export async function refreshProposalDependencies(
proposalId: string,
payload: string,
) {
const database = await getDatabase();
const text = serializedTextValues(payload)
.map((entry) => entry.value)
.join('\n');
const candidates = await (
await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE id<>? AND draft_entity_id IS NOT NULL`)
).all(proposalId);
const dependencies = candidates.filter((candidate) =>
text.includes(String(candidate.draft_entity_id)),
);
await (
await database.prepare(
'DELETE FROM data_change_proposal_dependencies WHERE proposal_id=?',
)
).run(proposalId);
for (const dependency of dependencies) {
await (
await database.prepare(
'INSERT INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
)
).run(proposalId, dependency.id);
}
return dependencies.map((dependency) => String(dependency.id));
}
const serializedTextValues = (payload: string) =>
(JSON.parse(payload) as SerializedEntry[]).filter((entry): entry is Extract<SerializedEntry, { kind: 'text' }> => entry.kind === 'text');
(JSON.parse(payload) as SerializedEntry[]).filter(
(entry): entry is Extract<SerializedEntry, { kind: 'text' }> =>
entry.kind === 'text',
);
export async function getPendingEntities(_userId: string, definitions: PendingDefinition[]): Promise<Array<{
id: string;
proposalId: string;
pending: true;
active: number;
kind: string;
name: string;
proposerName: string;
[key: string]: unknown;
}>> {
if (!definitions.length) return [];
const database = await getDatabase();
const clauses = definitions.map(() => '(proposal.module=? AND proposal.action=?)').join(' OR ');
const args = definitions.flatMap(({ module, action }) => [module, action]);
const rows = await (await database.prepare(`SELECT proposal.id,proposal.module,proposal.action,proposal.draft_entity_id,proposal.payload_json,proposal.created_at,
export async function getPendingEntities(
_userId: string,
definitions: PendingDefinition[],
): Promise<
Array<{
id: string;
proposalId: string;
pending: true;
active: number;
kind: string;
name: string;
proposerName: string;
[key: string]: unknown;
}>
> {
if (!definitions.length) return [];
const database = await getDatabase();
const clauses = definitions
.map(() => '(proposal.module=? AND proposal.action=?)')
.join(' OR ');
const args = definitions.flatMap(({ module, action }) => [module, action]);
const rows = await (
await database.prepare(`SELECT proposal.id,proposal.module,proposal.action,proposal.draft_entity_id,proposal.payload_json,proposal.created_at,
COALESCE(profile.display_name,proposer.username) AS proposer_name
FROM data_change_proposals proposal
JOIN users proposer ON proposer.id=proposal.proposed_by
LEFT JOIN user_profiles profile ON profile.user_id=proposer.id
WHERE proposal.status='pending' AND proposal.entity_id IS NULL AND (${clauses})
ORDER BY proposal.created_at DESC`)).all(...args);
return rows.map((row) => {
const definition = definitions.find((item) => item.module === row.module && item.action === row.action)!;
const values = serializedTextValues(String(row.payload_json));
const field = (name: string) => values.find((entry) => entry.name === name)?.value ?? '';
const fieldValue = (name: string) => {
const matches = values.filter((entry) => entry.name === name).map((entry) => entry.value);
return matches.length > 1 ? matches : matches[0] ?? '';
};
return {
id: String(row.draft_entity_id),
proposalId: String(row.id),
pending: true,
active: 1,
kind: definition.kind,
name: field('name'),
proposerName: String(row.proposer_name),
...Object.fromEntries((definition.fields ?? []).map((name) => [name, fieldValue(name)]))
};
});
ORDER BY proposal.created_at DESC`)
).all(...args);
return rows.map((row) => {
const definition = definitions.find(
(item) => item.module === row.module && item.action === row.action,
)!;
const values = serializedTextValues(String(row.payload_json));
const field = (name: string) =>
values.find((entry) => entry.name === name)?.value ?? '';
const fieldValue = (name: string) => {
const matches = values
.filter((entry) => entry.name === name)
.map((entry) => entry.value);
return matches.length > 1 ? matches : (matches[0] ?? '');
};
return {
id: String(row.draft_entity_id),
proposalId: String(row.id),
pending: true,
active: 1,
kind: definition.kind,
name: field('name'),
proposerName: String(row.proposer_name),
...Object.fromEntries(
(definition.fields ?? []).map((name) => [name, fieldValue(name)]),
),
};
});
}
export async function queueDataProposal(user: AuthUser, module: DataModule, action: string, form: FormData) {
const id = randomUUID();
const entityId = String(form.get('id') ?? '').trim() || null;
const draftEntityId = entityId ? null : randomUUID();
const name = String(form.get('name') ?? '').trim();
const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`;
const now = new Date().toISOString();
const payload = JSON.stringify(await serialize(form));
const database = await getDatabase();
await (await database.prepare(`INSERT INTO data_change_proposals
(id,module,action,entity_id,draft_entity_id,summary,payload_json,status,proposed_by,created_at)
VALUES(?,?,?,?,?,?,?,'pending',?,?)`)).run(id, module, action, entityId, draftEntityId, summary, payload, user.id, now);
const text = serializedTextValues(payload).map((entry) => entry.value).join('\n');
const candidates = await (await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE status='pending' AND draft_entity_id IS NOT NULL`)).all();
const dependencies = candidates.filter((candidate) => candidate.id !== id && text.includes(String(candidate.draft_entity_id)));
for (const dependency of dependencies) {
await (await database.prepare('INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)')).run(id, dependency.id);
}
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: 'proposal.created',
entityId: id,
before: null,
after: { module, action, entityId, draftEntityId, dependencies: dependencies.map((item) => item.id), summary }
});
await notifyOversightReview({
category: 'data_changes',
itemId: id,
title: 'Data change awaiting review',
summary: `${summary} was submitted to the Oversight queue and is ready for review.`,
link: `/isac/data-moderation?search=${encodeURIComponent(name || entityId || id)}`
}).catch((error) => console.error('Unable to notify Data Oversight reviewers', error));
return { id, entityId, draftEntityId };
export async function queueDataProposal(
user: AuthUser,
module: DataModule,
action: string,
form: FormData,
) {
const id = randomUUID();
const contributorNote = String(form.get('contributorNote') ?? '')
.normalize('NFKC')
.trim();
if (
contributorNote.length > 1_000 ||
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(contributorNote)
) {
throw new Error(
'Contributor rationale must contain at most 1,000 visible characters.',
);
}
form.delete('contributorNote');
const entityId = String(form.get('id') ?? '').trim() || null;
const draftEntityId = entityId ? null : randomUUID();
const name = String(form.get('name') ?? '').trim();
const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`;
const now = new Date().toISOString();
const payload = JSON.stringify(await serialize(form));
const database = await getDatabase();
await (
await database.prepare(`INSERT INTO data_change_proposals
(id,module,action,entity_id,draft_entity_id,summary,contributor_note,payload_json,status,proposed_by,created_at)
VALUES(?,?,?,?,?,?,?,?,'pending',?,?)`)
).run(
id,
module,
action,
entityId,
draftEntityId,
summary,
contributorNote || null,
payload,
user.id,
now,
);
const text = serializedTextValues(payload)
.map((entry) => entry.value)
.join('\n');
const candidates = await (
await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE status='pending' AND draft_entity_id IS NOT NULL`)
).all();
const dependencies = candidates.filter(
(candidate) =>
candidate.id !== id && text.includes(String(candidate.draft_entity_id)),
);
for (const dependency of dependencies) {
await (
await database.prepare(
'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
)
).run(id, dependency.id);
}
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: 'proposal.created',
entityId: id,
before: null,
after: {
module,
action,
entityId,
draftEntityId,
dependencies: dependencies.map((item) => item.id),
summary,
contributorNote: contributorNote || null,
},
});
await notifyOversightReview({
category: 'data_changes',
itemId: id,
title: 'Data change awaiting review',
summary: `${summary} was submitted to the Oversight queue and is ready for review.`,
link: `/isac/data-moderation?search=${encodeURIComponent(name || entityId || id)}`,
}).catch((error) =>
console.error('Unable to notify Data Oversight reviewers', error),
);
return { id, entityId, draftEntityId };
}
export async function shouldModerateDataMutation(user: AuthUser | null) {
const permissions = await getPermissions(user);
const eligibleToPropose = permissions.includes('*') || permissions.includes('data.propose');
const contributorMode = user && eligibleToPropose ? await getContributorMode(user.id) : null;
return {
canManage: permissions.includes('*') || permissions.includes('data.manage'),
canPropose: eligibleToPropose,
contributorEnabled: Boolean(contributorMode?.enabled)
};
const permissions = await getPermissions(user);
const eligibleToPropose =
permissions.includes('*') || permissions.includes('data.propose');
const contributorMode =
user && eligibleToPropose ? await getContributorMode(user.id) : null;
return {
canManage: permissions.includes('*') || permissions.includes('data.manage'),
canPropose: eligibleToPropose,
contributorEnabled: Boolean(contributorMode?.enabled),
};
}
+6 -5
View File
@@ -11,11 +11,12 @@ let databasePromise: Promise<Database> | undefined;
* imported into browser bundles.
*/
export function getDatabase(): Promise<Database> {
const configuredPath = env.TDR_DATABASE_PATH || './data/shd-build-development.db';
const configuredPath =
env.TDR_DATABASE_PATH || './data/shd-build-development.db';
if (!databasePromise) {
databasePromise = connect(resolve(configuredPath));
}
if (!databasePromise) {
databasePromise = connect(resolve(configuredPath));
}
return databasePromise;
return databasePromise;
}
+67
View File
@@ -0,0 +1,67 @@
import {
gameDataDiscussionId,
gameDataTargetTypes,
type GameDataTargetType,
} from '$lib/game-data-targets';
import { getDatabase } from '$lib/server/database';
import { renderUserMarkdown } from '$lib/markdown';
const targetTables: Record<GameDataTargetType, string> = {
gear: 'gear',
weapon: 'weapons',
weapon_mod: 'weapon_mods',
gear_mod: 'gear_mods',
os_protocol: 'os_protocols',
talent: 'talents',
brand_set: 'brand_sets',
skill_chip: 'skill_chip_sets',
specialization: 'specializations',
};
export const isGameDataTargetType = (
value: string,
): value is GameDataTargetType =>
(gameDataTargetTypes as readonly string[]).includes(value);
export async function gameDataTargetExists(
type: GameDataTargetType,
id: string,
) {
const database = await getDatabase();
return Boolean(
await (
await database.prepare(
`SELECT id FROM ${targetTables[type]} WHERE id=? AND active=1`,
)
).get(id),
);
}
export async function gameDataDiscussionSummary(
type: GameDataTargetType,
ids: string[],
) {
if (!ids.length)
return new Map<string, { count: number; pinnedBodyHtml: string | null }>();
const database = await getDatabase();
const rows = await (
await database.prepare(`SELECT thread.target_id,COUNT(comments.id) AS count,
pinned.body_markdown AS pinned_body
FROM comment_threads thread
LEFT JOIN comments ON comments.thread_id=thread.id AND comments.status='visible'
LEFT JOIN comments pinned ON pinned.id=thread.pinned_comment_id AND pinned.status='visible'
WHERE thread.target_type='game_data' AND thread.target_id IN (${ids.map(() => '?').join(',')})
GROUP BY thread.id`)
).all(...ids.map((id) => gameDataDiscussionId(type, id)));
return new Map(
rows.map((row) => [
String(row.target_id).slice(type.length + 1),
{
count: Number(row.count),
pinnedBodyHtml: row.pinned_body
? renderUserMarkdown(row.pinned_body)
: null,
},
]),
);
}
+414 -221
View File
@@ -3,298 +3,491 @@ import { randomUUID } from 'node:crypto';
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
import {
BackgroundRemovalError,
removeBackgroundPreview,
} from './remove-background';
export const GAME_ICON_TYPES = [
'talent', 'brand_set', 'os_protocol', 'skill_chip',
'specialization', 'specialization_ability', 'specialization_node'
'talent',
'brand_set',
'os_protocol',
'skill_chip',
'specialization',
'specialization_ability',
'specialization_node',
] as const;
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
export const MAX_GAME_ICON_PIXELS = 12_000_000;
export const GAME_ICON_SIZE = 256;
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
const STORAGE_KEY_PATTERN = /^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
const STORAGE_KEY_PATTERN =
/^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
talent: 'Talents',
brand_set: 'Brands',
os_protocol: 'OS Protocols',
skill_chip: 'Skill Chips',
specialization: 'Specializations',
specialization_ability: 'Specializations',
specialization_node: 'Specializations'
talent: 'Talents',
brand_set: 'Brands',
os_protocol: 'OS Protocols',
skill_chip: 'Skill Chips',
specialization: 'Specializations',
specialization_ability: 'Specializations',
specialization_node: 'Specializations',
};
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
talent: 'Talent',
brand_set: 'Brand Set',
os_protocol: 'OS Protocol',
skill_chip: 'Skill Chip',
specialization: 'Specialization',
specialization_ability: 'Specialization Ability',
specialization_node: 'Specialization Tree Node'
talent: 'Talent',
brand_set: 'Brand Set',
os_protocol: 'OS Protocol',
skill_chip: 'Skill Chip',
specialization: 'Specialization',
specialization_ability: 'Specialization Ability',
specialization_node: 'Specialization Tree Node',
};
export class GameDataIconValidationError extends Error {}
export function gameDataIconStorageDirectory(): string {
return path.resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || path.join(process.cwd(), 'data', 'game-icons'));
return path.resolve(
process.env.TDR_GAME_ICON_STORAGE_PATH ||
path.join(process.cwd(), 'data', 'game-icons'),
);
}
function assertType(type: string): asserts type is GameDataIconType {
if (!GAME_ICON_TYPES.includes(type as GameDataIconType)) throw new Error('Invalid game-data icon type');
if (!GAME_ICON_TYPES.includes(type as GameDataIconType))
throw new Error('Invalid game-data icon type');
}
function storageKey(type: GameDataIconType, entityId: string): string {
if (!ENTITY_ID_PATTERN.test(entityId)) throw new Error('Invalid game-data entity identifier');
return `${type}-${entityId}.webp`;
if (!ENTITY_ID_PATTERN.test(entityId))
throw new Error('Invalid game-data entity identifier');
return `${type}-${entityId}.webp`;
}
function storagePath(key: string): string {
if (!STORAGE_KEY_PATTERN.test(key)) throw new Error('Invalid game-data icon storage key');
return path.join(gameDataIconStorageDirectory(), key);
if (!STORAGE_KEY_PATTERN.test(key))
throw new Error('Invalid game-data icon storage key');
return path.join(gameDataIconStorageDirectory(), key);
}
export function gameDataIconUrl(type: GameDataIconType, entityId: string, updatedAt?: unknown): string {
assertType(type);
if (!ENTITY_ID_PATTERN.test(entityId)) return '';
const version = typeof updatedAt === 'string' ? `?v=${encodeURIComponent(updatedAt)}` : '';
return `/icons/game-data/${type}/${entityId}${version}`;
export function gameDataIconUrl(
type: GameDataIconType,
entityId: string,
updatedAt?: unknown,
): string {
assertType(type);
if (!ENTITY_ID_PATTERN.test(entityId)) return '';
const version =
typeof updatedAt === 'string' ? `?v=${encodeURIComponent(updatedAt)}` : '';
return `/icons/game-data/${type}/${entityId}${version}`;
}
export async function processGameDataIconUpload(file: File): Promise<Buffer> {
if (!file.size || file.size > MAX_GAME_ICON_BYTES) {
throw new GameDataIconValidationError('Choose a transparent icon no larger than 2 MiB.');
}
let input = Buffer.from(await file.arrayBuffer());
try {
const looksLikeSvg = file.type === 'image/svg+xml' || input.subarray(0, 512).toString('utf8').toLowerCase().includes('<svg');
if (looksLikeSvg) {
const source = input.toString('utf8');
if (/<(?:script|foreignObject|image|iframe|object|embed)\b/i.test(source)
|| /<!ENTITY\b/i.test(source)
|| /\b(?:href|xlink:href)\s*=\s*["'](?!#)/i.test(source)
|| /@import\b/i.test(source)
|| /url\(\s*["']?(?!#)/i.test(source)) {
throw new GameDataIconValidationError('SVG icons cannot contain scripts, embedded images, or external resources.');
}
}
let image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
let metadata = await image.metadata();
if (!metadata.format || !['jpeg', 'png', 'webp', 'avif', 'svg'].includes(metadata.format)) {
throw new GameDataIconValidationError('Use a JPEG, SVG, PNG, WebP, or AVIF icon.');
}
let stats = await image.stats();
if (!metadata.hasAlpha || stats.isOpaque) {
input = await removeBackgroundPreview(input);
image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
metadata = await image.metadata();
stats = await image.stats();
}
if (!metadata.hasAlpha || stats.isOpaque) {
throw new GameDataIconValidationError('The processed icon must contain transparent background pixels.');
}
return await image
.rotate()
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 })
.extend({
top: 2, right: 2, bottom: 2, left: 2,
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.resize(GAME_ICON_SIZE, GAME_ICON_SIZE, {
fit: 'contain',
position: 'centre',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.webp({ quality: 88, alphaQuality: 100, effort: 5 })
.toBuffer();
} catch (error) {
if (error instanceof GameDataIconValidationError) throw error;
if (error instanceof BackgroundRemovalError) throw new GameDataIconValidationError(error.message);
throw new GameDataIconValidationError('That icon could not be processed. Use a transparent SVG, PNG, WebP, or AVIF file.');
}
if (!file.size || file.size > MAX_GAME_ICON_BYTES) {
throw new GameDataIconValidationError(
'Choose a transparent icon no larger than 2 MiB.',
);
}
let input = Buffer.from(await file.arrayBuffer());
try {
const looksLikeSvg =
file.type === 'image/svg+xml' ||
input.subarray(0, 512).toString('utf8').toLowerCase().includes('<svg');
if (looksLikeSvg) {
const source = input.toString('utf8');
if (
/<(?:script|foreignObject|image|iframe|object|embed)\b/i.test(source) ||
/<!ENTITY\b/i.test(source) ||
/\b(?:href|xlink:href)\s*=\s*["'](?!#)/i.test(source) ||
/@import\b/i.test(source) ||
/url\(\s*["']?(?!#)/i.test(source)
) {
throw new GameDataIconValidationError(
'SVG icons cannot contain scripts, embedded images, or external resources.',
);
}
}
let image = sharp(input, {
limitInputPixels: MAX_GAME_ICON_PIXELS,
failOn: 'warning',
});
let metadata = await image.metadata();
if (
!metadata.format ||
!['jpeg', 'png', 'webp', 'avif', 'svg'].includes(metadata.format)
) {
throw new GameDataIconValidationError(
'Use a JPEG, SVG, PNG, WebP, or AVIF icon.',
);
}
let stats = await image.stats();
if (!metadata.hasAlpha || stats.isOpaque) {
input = await removeBackgroundPreview(input);
image = sharp(input, {
limitInputPixels: MAX_GAME_ICON_PIXELS,
failOn: 'warning',
});
metadata = await image.metadata();
stats = await image.stats();
}
if (!metadata.hasAlpha || stats.isOpaque) {
throw new GameDataIconValidationError(
'The processed icon must contain transparent background pixels.',
);
}
return await image
.rotate()
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 })
.extend({
top: 2,
right: 2,
bottom: 2,
left: 2,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.resize(GAME_ICON_SIZE, GAME_ICON_SIZE, {
fit: 'contain',
position: 'centre',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.webp({ quality: 88, alphaQuality: 100, effort: 5 })
.toBuffer();
} catch (error) {
if (error instanceof GameDataIconValidationError) throw error;
if (error instanceof BackgroundRemovalError)
throw new GameDataIconValidationError(error.message);
throw new GameDataIconValidationError(
'That icon could not be processed. Use a transparent SVG, PNG, WebP, or AVIF file.',
);
}
}
const normalizeTags = (values: unknown[]) => [...new Set(values
.flatMap((value) => String(value ?? '').split(','))
.map((value) => value.normalize('NFKC').trim().toLocaleLowerCase())
.filter(Boolean))].slice(0, 20);
const normalizeTags = (values: unknown[]) =>
[
...new Set(
values
.flatMap((value) => String(value ?? '').split(','))
.map((value) => value.normalize('NFKC').trim().toLocaleLowerCase())
.filter(Boolean),
),
].slice(0, 20);
async function libraryMetadata(database: Database, type: GameDataIconType, entityId: string) {
switch (type) {
case 'talent': {
const row = await (await database.prepare(`SELECT talent.name,talent.category,scope.scope
async function libraryMetadata(
database: Database,
type: GameDataIconType,
entityId: string,
) {
switch (type) {
case 'talent': {
const row = await (
await database.prepare(`SELECT talent.name,talent.category,scope.scope
FROM talents talent LEFT JOIN talent_weapon_scopes scope ON scope.talent_id=talent.id
WHERE talent.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.category === 'Vest' ? 'Body Armor' : row?.category, row?.scope] };
}
case 'brand_set': {
const row = await (await database.prepare('SELECT name FROM brand_sets WHERE id=?')).get(entityId);
return { name: row?.name, tags: ['gear', 'set bonus'] };
}
case 'os_protocol': {
const row = await (await database.prepare('SELECT name,quality,primary_attribute_type FROM os_protocols WHERE id=?')).get(entityId);
return { name: row?.name, tags: [row?.quality, row?.primary_attribute_type] };
}
case 'skill_chip': {
const row = await (await database.prepare(`SELECT chip.name,ability.name AS ability_name,specialization.name AS specialization_name
WHERE talent.id=?`)
).get(entityId);
return {
name: row?.name,
tags: [
row?.category === 'Vest' ? 'Body Armor' : row?.category,
row?.scope,
],
};
}
case 'brand_set': {
const row = await (
await database.prepare('SELECT name FROM brand_sets WHERE id=?')
).get(entityId);
return { name: row?.name, tags: ['gear', 'set bonus'] };
}
case 'os_protocol': {
const row = await (
await database.prepare(
'SELECT name,quality,primary_attribute_type FROM os_protocols WHERE id=?',
)
).get(entityId);
return {
name: row?.name,
tags: [row?.quality, row?.primary_attribute_type],
};
}
case 'skill_chip': {
const row = await (
await database.prepare(`SELECT chip.name,ability.name AS ability_name,specialization.name AS specialization_name
FROM skill_chip_sets chip
LEFT JOIN specialization_abilities ability ON ability.id=chip.specialization_ability_id
LEFT JOIN specializations specialization ON specialization.id=ability.specialization_id
WHERE chip.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.ability_name, row?.specialization_name] };
}
case 'specialization': {
const row = await (await database.prepare('SELECT name FROM specializations WHERE id=?')).get(entityId);
return { name: row?.name, tags: ['class'] };
}
case 'specialization_ability': {
const row = await (await database.prepare(`SELECT ability.name,specialization.name AS specialization_name
WHERE chip.id=?`)
).get(entityId);
return {
name: row?.name,
tags: [row?.ability_name, row?.specialization_name],
};
}
case 'specialization': {
const row = await (
await database.prepare('SELECT name FROM specializations WHERE id=?')
).get(entityId);
return { name: row?.name, tags: ['class'] };
}
case 'specialization_ability': {
const row = await (
await database.prepare(`SELECT ability.name,specialization.name AS specialization_name
FROM specialization_abilities ability
JOIN specializations specialization ON specialization.id=ability.specialization_id
WHERE ability.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.specialization_name, 'ability'] };
}
case 'specialization_node': {
const row = await (await database.prepare(`SELECT node.name,node.row_number,focus.name AS focus_name,
WHERE ability.id=?`)
).get(entityId);
return { name: row?.name, tags: [row?.specialization_name, 'ability'] };
}
case 'specialization_node': {
const row = await (
await database.prepare(`SELECT node.name,node.row_number,focus.name AS focus_name,
specialization.name AS specialization_name
FROM specialization_tree_nodes node
JOIN specialization_focuses focus ON focus.id=node.focus_id
JOIN specializations specialization ON specialization.id=focus.specialization_id
WHERE node.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.specialization_name, row?.focus_name, `row ${row?.row_number}`, 'tree node'] };
}
}
return { name: null, tags: [] };
WHERE node.id=?`)
).get(entityId);
return {
name: row?.name,
tags: [
row?.specialization_name,
row?.focus_name,
`row ${row?.row_number}`,
'tree node',
],
};
}
}
return { name: null, tags: [] };
}
async function uniqueLibraryName(database: Database, preferred: string, type: GameDataIconType, entityId: string) {
for (const candidate of [
preferred,
`${preferred} · ${LIBRARY_LABEL[type]}`,
`${preferred} · ${entityId.slice(0, 8)}`
]) {
const normalized = candidate.slice(0, 80);
const conflict = await (await database.prepare('SELECT id FROM managed_images WHERE name=? COLLATE NOCASE')).get(normalized);
if (!conflict) return normalized;
}
return `${preferred.slice(0, 64)} · ${entityId.slice(0, 8)}`;
async function uniqueLibraryName(
database: Database,
preferred: string,
type: GameDataIconType,
entityId: string,
) {
for (const candidate of [
preferred,
`${preferred} · ${LIBRARY_LABEL[type]}`,
`${preferred} · ${entityId.slice(0, 8)}`,
]) {
const normalized = candidate.slice(0, 80);
const conflict = await (
await database.prepare(
'SELECT id FROM managed_images WHERE name=? COLLATE NOCASE',
)
).get(normalized);
if (!conflict) return normalized;
}
return `${preferred.slice(0, 64)} · ${entityId.slice(0, 8)}`;
}
async function registerLibraryIcon(
database: Database,
type: GameDataIconType,
entityId: string,
key: string,
byteSize: number,
updatedAt: string,
actorId: string
database: Database,
type: GameDataIconType,
entityId: string,
key: string,
byteSize: number,
updatedAt: string,
actorId: string,
) {
const existing = await (await database.prepare(
'SELECT * FROM managed_images WHERE source_entity_type=? AND source_entity_id=?'
)).get(type, entityId);
const metadata = await libraryMetadata(database, type, entityId);
const preferredName = String(metadata.name ?? `${LIBRARY_LABEL[type]} ${entityId.slice(0, 8)}`);
const derivedTags = normalizeTags(['isac', 'game data', type.replaceAll('_', ' '), preferredName, ...metadata.tags]);
if (existing) {
const previousTags = (() => {
try { return JSON.parse(String(existing.tags_json)); } catch { return []; }
})().filter((tag: unknown) => String(tag).toLocaleLowerCase() !== 'vest');
await (await database.prepare(`UPDATE managed_images SET
const existing = await (
await database.prepare(
'SELECT * FROM managed_images WHERE source_entity_type=? AND source_entity_id=?',
)
).get(type, entityId);
const metadata = await libraryMetadata(database, type, entityId);
const preferredName = String(
metadata.name ?? `${LIBRARY_LABEL[type]} ${entityId.slice(0, 8)}`,
);
const derivedTags = normalizeTags([
'isac',
'game data',
type.replaceAll('_', ' '),
preferredName,
...metadata.tags,
]);
if (existing) {
const previousTags = (() => {
try {
return JSON.parse(String(existing.tags_json));
} catch {
return [];
}
})().filter((tag: unknown) => String(tag).toLocaleLowerCase() !== 'vest');
await (
await database.prepare(`UPDATE managed_images SET
category=?,tags_json=?,storage_key=?,original_filename=?,byte_size=?,width=256,height=256,updated_at=?
WHERE id=?`)).run(
LIBRARY_CATEGORY[type], JSON.stringify(normalizeTags([...previousTags, ...derivedTags])),
key, key, byteSize, updatedAt, existing.id
);
return String(existing.id);
}
const name = await uniqueLibraryName(database, preferredName, type, entityId);
const id = randomUUID();
await (await database.prepare(`INSERT INTO managed_images
WHERE id=?`)
).run(
LIBRARY_CATEGORY[type],
JSON.stringify(normalizeTags([...previousTags, ...derivedTags])),
key,
key,
byteSize,
updatedAt,
existing.id,
);
return String(existing.id);
}
const name = await uniqueLibraryName(database, preferredName, type, entityId);
const id = randomUUID();
await (
await database.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
id, name, LIBRARY_CATEGORY[type], JSON.stringify(derivedTags), 1, 0, key, key, byteSize, 256, 256,
type, entityId, actorId, updatedAt, updatedAt
);
return id;
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
id,
name,
LIBRARY_CATEGORY[type],
JSON.stringify(derivedTags),
1,
0,
key,
key,
byteSize,
256,
256,
type,
entityId,
actorId,
updatedAt,
updatedAt,
);
return id;
}
export async function saveGameDataIcon(
database: Database,
type: GameDataIconType,
entityId: string,
output: Buffer,
actorId: string
database: Database,
type: GameDataIconType,
entityId: string,
output: Buffer,
actorId: string,
): Promise<string> {
const key = storageKey(type, entityId);
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
const now = new Date().toISOString();
await (await database.prepare(`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at)
const key = storageKey(type, entityId);
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at)
VALUES(?,?,?,?,?) ON CONFLICT(entity_type,entity_id) DO UPDATE SET
storage_key=excluded.storage_key,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
.run(type, entityId, key, output.length, now);
await registerLibraryIcon(database, type, entityId, key, output.length, now, actorId);
return now;
storage_key=excluded.storage_key,byte_size=excluded.byte_size,updated_at=excluded.updated_at`)
).run(type, entityId, key, output.length, now);
await registerLibraryIcon(
database,
type,
entityId,
key,
output.length,
now,
actorId,
);
return now;
}
export async function deleteGameDataIcon(database: Database, type: GameDataIconType, entityId: string): Promise<void> {
const icon = await (await database.prepare('SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId);
await (await database.prepare('DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?')).run(type, entityId);
await (await database.prepare('DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?')).run(type, entityId);
if (typeof icon?.storage_key === 'string') {
await unlink(storagePath(icon.storage_key)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
export async function deleteGameDataIcon(
database: Database,
type: GameDataIconType,
entityId: string,
): Promise<void> {
const icon = await (
await database.prepare(
'SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).get(type, entityId);
await (
await database.prepare(
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).run(type, entityId);
await (
await database.prepare(
'DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?',
)
).run(type, entityId);
if (typeof icon?.storage_key === 'string') {
await unlink(storagePath(icon.storage_key)).catch(
(error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
},
);
}
}
export async function readGameDataIcon(database: Database, type: string, entityId: string): Promise<Buffer | null> {
assertType(type);
if (!ENTITY_ID_PATTERN.test(entityId)) return null;
const icon = await (await database.prepare('SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId);
if (typeof icon?.storage_key !== 'string') return null;
try {
return await readFile(storagePath(icon.storage_key));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
export async function readGameDataIcon(
database: Database,
type: string,
entityId: string,
): Promise<Buffer | null> {
assertType(type);
if (!ENTITY_ID_PATTERN.test(entityId)) return null;
const icon = await (
await database.prepare(
'SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).get(type, entityId);
if (typeof icon?.storage_key !== 'string') return null;
try {
return await readFile(storagePath(icon.storage_key));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
}
export async function readIconMutation(form: FormData): Promise<{ output: Buffer | null; remove: boolean }> {
const file = form.get('icon');
const remove = form.get('removeIcon') === '1';
if (file instanceof File && file.size > 0) return { output: await processGameDataIconUpload(file), remove: false };
return { output: null, remove };
export async function readIconMutation(
form: FormData,
): Promise<{ output: Buffer | null; remove: boolean }> {
const file = form.get('icon');
const remove = form.get('removeIcon') === '1';
if (file instanceof File && file.size > 0)
return { output: await processGameDataIconUpload(file), remove: false };
return { output: null, remove };
}
export async function applyIconMutation(
database: Database,
type: GameDataIconType,
entityId: string,
mutation: { output: Buffer | null; remove: boolean },
actorId: string
database: Database,
type: GameDataIconType,
entityId: string,
mutation: { output: Buffer | null; remove: boolean },
actorId: string,
): Promise<void> {
if (mutation.output) await saveGameDataIcon(database, type, entityId, mutation.output, actorId);
else if (mutation.remove) await deleteGameDataIcon(database, type, entityId);
else {
const icon = await (await database.prepare(
'SELECT storage_key,byte_size,updated_at FROM game_data_icons WHERE entity_type=? AND entity_id=?'
)).get(type, entityId);
if (typeof icon?.storage_key === 'string') {
await registerLibraryIcon(
database, type, entityId, icon.storage_key, Number(icon.byte_size), String(icon.updated_at), actorId
);
}
}
if (mutation.output)
await saveGameDataIcon(database, type, entityId, mutation.output, actorId);
else if (mutation.remove) await deleteGameDataIcon(database, type, entityId);
else {
const icon = await (
await database.prepare(
'SELECT storage_key,byte_size,updated_at FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).get(type, entityId);
if (typeof icon?.storage_key === 'string') {
await registerLibraryIcon(
database,
type,
entityId,
icon.storage_key,
Number(icon.byte_size),
String(icon.updated_at),
actorId,
);
}
}
}
export async function gameDataIconMetadata(database: Database, type: GameDataIconType, entityId: string) {
return await (await database.prepare(`SELECT icon.byte_size,icon.updated_at,
export async function gameDataIconMetadata(
database: Database,
type: GameDataIconType,
entityId: string,
) {
return (
(await (
await database.prepare(`SELECT icon.byte_size,icon.updated_at,
image.id AS library_image_id,image.category AS library_category,image.tags_json AS library_tags_json
FROM game_data_icons icon
LEFT JOIN managed_images image
ON image.source_entity_type=icon.entity_type AND image.source_entity_id=icon.entity_id
WHERE icon.entity_type=? AND icon.entity_id=?`)).get(type, entityId) ?? null;
WHERE icon.entity_type=? AND icon.entity_id=?`)
).get(type, entityId)) ?? null
);
}

Some files were not shown because too many files have changed in this diff Show More