feat: add AI-assisted ISAC authoring

This commit is contained in:
JB
2026-07-27 14:53:36 -04:00
parent 4eb754e7c8
commit efb0ccdc7e
37 changed files with 3531 additions and 50 deletions
@@ -0,0 +1,456 @@
<script lang="ts">
import { page } from '$app/state';
import PseudoScript from '$lib/components/PseudoScript.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import type {
AiCalculationAuthoringResponse,
AiCalculationProfileDraft,
AiCalculationRevisionDraft,
AiCalculationStageDraft,
AiCalculationTranscriptEntry,
AiSkillCalculationProfileDraft
} from '$lib/ai/calculation-authoring';
import { onDestroy, tick } from 'svelte';
export type CalculationStageDraft = {
stageKey: string;
label: string;
operator: string;
displayOrder: number;
lockedPosition: string | null;
};
export type CalculationProfileDraft = {
statAttributeId: string;
stageKey: string;
weight: number | string;
softCap: number | string | null;
hardCap: number | string | null;
curveType: string;
curveParameter: number | string | null;
weaponCategory: string;
};
export type SkillCalculationProfileDraft = {
statAttributeId: string;
stageKey: string;
operator: 'add' | 'multiply';
weight: number | string;
};
export type CalculationRevisionDraft = {
id: string;
basedOnRevisionId: string;
name: string;
notes: string;
stages: CalculationStageDraft[];
profiles: CalculationProfileDraft[];
skillProfiles: SkillCalculationProfileDraft[];
};
type Answer = string | number | boolean;
let {
currentRevision,
onapply,
disabled = false
}: {
currentRevision: CalculationRevisionDraft;
onapply: (draft: AiCalculationRevisionDraft) => void;
disabled?: boolean;
} = $props();
const permissions = $derived((page.data.permissions as string[] | undefined) ?? []);
const canGenerate = $derived(permissions.includes('*') || permissions.includes('ai.calculation.generate'));
const canUse = $derived(canGenerate && !disabled);
let expanded = $state(false);
let requirement = $state('');
let response = $state<AiCalculationAuthoringResponse | null>(null);
let transcript = $state<AiCalculationTranscriptEntry[]>([]);
let answers = $state<Record<string, Answer | undefined>>({});
let pending = $state(false);
let errorMessage = $state('');
let activityMessage = $state('');
let confirmApply = $state(false);
let resultRegion = $state<HTMLElement>();
let requestController: AbortController | undefined;
const questions = $derived(response?.status === 'clarification_required' ? response.questions : []);
const allQuestionsAnswered = $derived(
questions.length > 0 && questions.every((question) => {
const answer = answers[question.id];
if (typeof answer === 'string') return answer.trim().length > 0;
if (typeof answer === 'number') return Number.isFinite(answer);
return typeof answer === 'boolean';
})
);
const scriptLines = $derived(
response?.status === 'draft_ready' ? response.pseudoScript.split('\n') : []
);
const beforeCounts = $derived({
stages: currentRevision.stages.length,
weaponProfiles: currentRevision.profiles.length,
skillProfiles: currentRevision.skillProfiles.length
});
const afterCounts = $derived(
response?.status === 'draft_ready'
? {
stages: response.draft.stages.length,
weaponProfiles: response.draft.profiles.length,
skillProfiles: response.draft.skillProfiles.length
}
: beforeCounts
);
onDestroy(() => requestController?.abort());
function setAnswer(questionId: string, answer: Answer | undefined) {
answers = { ...answers, [questionId]: answer };
}
function resetResult() {
requestController?.abort();
response = null;
transcript = [];
answers = {};
pending = false;
errorMessage = '';
activityMessage = '';
confirmApply = false;
}
async function focusResult() {
await tick();
resultRegion?.focus();
}
function requestRevision(): AiCalculationRevisionDraft {
const optionalNumber = (value: number | string | null) => {
if (value === '' || value === null) return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
};
return {
name: currentRevision.name,
notes: currentRevision.notes,
stages: currentRevision.stages.map((stage): AiCalculationStageDraft => ({
...stage,
operator: stage.operator as AiCalculationStageDraft['operator'],
lockedPosition: stage.lockedPosition as AiCalculationStageDraft['lockedPosition']
})),
profiles: currentRevision.profiles.map((profile): AiCalculationProfileDraft => ({
...profile,
weight: Number(profile.weight),
softCap: optionalNumber(profile.softCap),
hardCap: optionalNumber(profile.hardCap),
curveType: profile.curveType as AiCalculationProfileDraft['curveType'],
curveParameter: optionalNumber(profile.curveParameter)
})),
skillProfiles: currentRevision.skillProfiles.map((profile): AiSkillCalculationProfileDraft => ({
...profile,
stageKey: profile.stageKey as AiSkillCalculationProfileDraft['stageKey'],
weight: Number(profile.weight)
}))
};
}
async function requestDraft(nextTranscript = transcript) {
if (!canUse || pending) return;
if (!requirement.trim()) {
errorMessage = 'Describe the calculation change before requesting a draft.';
activityMessage = '';
return;
}
requestController?.abort();
requestController = new AbortController();
pending = true;
errorMessage = '';
activityMessage = '';
confirmApply = false;
try {
const request = await fetch('/api/isac/calculation-assistant', {
method: 'POST',
headers: { 'content-type': 'application/json' },
signal: requestController.signal,
body: JSON.stringify({
instructions: requirement.trim(),
baseRevisionId: currentRevision.id || currentRevision.basedOnRevisionId || null,
currentDraft: requestRevision(),
transcript: nextTranscript
})
});
const contentType = request.headers.get('content-type') ?? '';
const payload = contentType.includes('application/json') ? await request.json() : null;
if (!request.ok) {
throw new Error(
typeof payload?.message === 'string'
? payload.message
: `The calculation assistant could not complete the request (${request.status}).`
);
}
if (
!payload
|| !['clarification_required', 'draft_ready', 'capability_gap'].includes(payload.status)
) throw new Error('The calculation assistant returned an unrecognized response.');
response = payload as AiCalculationAuthoringResponse;
answers = {};
activityMessage = response.status === 'clarification_required'
? 'Clarification is required before a reliable revision can be prepared.'
: response.status === 'draft_ready'
? 'Draft revision ready. No manual fields have been changed.'
: 'The current calculation model cannot represent every requested behavior.';
await focusResult();
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
errorMessage = error instanceof Error
? error.message
: 'The calculation assistant could not complete the request.';
await focusResult();
} finally {
pending = false;
}
}
function submitClarifications() {
if (!allQuestionsAnswered || response?.status !== 'clarification_required') return;
const entries = response.questions.map((question): AiCalculationTranscriptEntry => ({
questionId: question.id,
question: question.question,
answer: String(answers[question.id])
}));
const nextTranscript = [...transcript, ...entries];
transcript = nextTranscript;
void requestDraft(nextTranscript);
}
function restartAnalysis() {
resetResult();
void requestDraft([]);
}
function applyDraft() {
if (response?.status !== 'draft_ready') return;
onapply($state.snapshot(response.draft));
confirmApply = false;
activityMessage = 'AI draft applied to the unsaved revision. Review every field before saving.';
}
</script>
<section class="calculation-assistant" class:locked={!canGenerate}>
<header>
<div>
<span>ISAC AUTHORING ASSIST</span>
<strong>CALCULATION REVISION DRAFTING</strong>
</div>
<b class:available={canGenerate}>{canGenerate ? 'AUTHORIZED' : 'PERMISSION REQUIRED'}</b>
<button
type="button"
class="assistant-toggle"
disabled={!canGenerate}
aria-expanded={expanded}
aria-controls="calculation-assistant-workspace"
onclick={() => expanded = !expanded}
>
{expanded ? 'CLOSE ASSISTANT' : 'OPEN ASSISTANT'}
</button>
</header>
{#if !canGenerate}
<p class="permission-note">
AI calculation drafting is optional. The manual revision editor remains available.
Request <b>AI calculation generation</b> permission from an administrator to use this helper.
</p>
{:else if !expanded}
<p class="assistant-summary">
Describe a calculation change in plain language, then review a typed revision draft before
applying it to this unsaved editor.
</p>
{/if}
{#if expanded}
<div class="assistant-workspace" id="calculation-assistant-workspace">
<label class="requirement-field">
<span>CALCULATION REQUIREMENT</span>
<textarea
rows="5"
bind:value={requirement}
disabled={!canUse || pending}
placeholder="Describe the calculation stages, Attribute behavior, caps, curves, or skill bindings that should change."
></textarea>
</label>
<div class="request-actions">
<p>
The assistant can only draft supported calculation data. It cannot save, publish, or
activate a revision.
</p>
<button
type="button"
class="primary"
disabled={!canUse || pending || !requirement.trim()}
aria-busy={pending}
onclick={() => {
if (response || transcript.length) restartAnalysis();
else void requestDraft();
}}
>
{pending ? 'ANALYZING CALCULATION…' : response || transcript.length ? 'RESTART ANALYSIS' : 'GENERATE REVISION'}
</button>
</div>
<div
class="result-region"
bind:this={resultRegion}
tabindex="-1"
aria-live="polite"
aria-busy={pending}
>
{#if errorMessage}
<div class="assistant-message error" role="alert">
<strong>REQUEST FAILED</strong>
<p>{errorMessage}</p>
</div>
{:else if response?.status === 'clarification_required'}
<div class="result-heading">
<div><span>CLARIFICATION REQUIRED</span><strong>{questions.length} {questions.length === 1 ? 'QUESTION' : 'QUESTIONS'}</strong></div>
<small>Answer only what materially affects calculation behavior.</small>
</div>
<div class="question-stack">
{#each questions as question, index}
<section class="question-card">
<header>
<span>{String(index + 1).padStart(2, '0')}</span>
<div><strong>{question.question}</strong><small>{question.reason}</small></div>
</header>
{#if question.answerType === 'boolean'}
<SegmentedChoice
legend="ANSWER"
name={`calculation-ai-${question.id}`}
options={[{ value: 'true', label: 'YES' }, { value: 'false', label: 'NO' }]}
value={answers[question.id] === undefined ? '' : String(answers[question.id])}
onchange={(values) => setAnswer(question.id, values[0] === 'true')}
/>
{:else if question.answerType === 'choice' && question.options.length}
<SegmentedChoice
legend="ANSWER"
name={`calculation-ai-${question.id}`}
options={question.options.map((option) => ({ value: option, label: option.toUpperCase() }))}
value={typeof answers[question.id] === 'string' ? answers[question.id] as string : ''}
columns={Math.min(3, question.options.length)}
onchange={(values) => setAnswer(question.id, values[0])}
/>
{:else}
<label class="answer-field">
<span>ANSWER</span>
<input
type={question.answerType === 'number' ? 'number' : 'text'}
step={question.answerType === 'number' ? 'any' : undefined}
value={answers[question.id] ?? ''}
oninput={(event) => setAnswer(
question.id,
question.answerType === 'number'
? event.currentTarget.value === '' ? undefined : Number(event.currentTarget.value)
: event.currentTarget.value
)}
/>
</label>
{/if}
</section>
{/each}
</div>
<div class="result-actions">
<button type="button" class="secondary" onclick={resetResult}>CANCEL QUESTIONS</button>
<button type="button" class="primary" disabled={!allQuestionsAnswered || pending} aria-busy={pending} onclick={submitClarifications}>
{pending ? 'REFINING REVISION…' : 'SUBMIT ANSWERS'}
</button>
</div>
{:else if response?.status === 'capability_gap'}
<div class="result-heading capability">
<div><span>DEVELOPER HANDOFF</span><strong>CALCULATION MODEL GAP</strong></div>
<small>The assistant did not invent unsupported runtime behavior.</small>
</div>
<p class="result-summary">{response.summary}</p>
<div class="gap-stack">
{#each response.gaps as gap}
<article class="gap-card">
<header><span>AREA</span><strong>{gap.area}</strong></header>
<dl>
<div><dt>REQUIRED BEHAVIOR</dt><dd>{gap.requiredBehavior}</dd></div>
<div><dt>WHY UNSUPPORTED</dt><dd>{gap.whyUnsupported}</dd></div>
<div><dt>DEVELOPER CHANGE</dt><dd>{gap.developerChange}</dd></div>
{#if gap.workaround}<div><dt>CURRENT WORKAROUND</dt><dd>{gap.workaround}</dd></div>{/if}
</dl>
</article>
{/each}
</div>
<div class="result-actions"><button type="button" class="secondary" onclick={resetResult}>REVISE REQUIREMENT</button></div>
{:else if response?.status === 'draft_ready'}
<div class="result-heading ready">
<div><span>DRAFT READY</span><strong>REVIEW CALCULATION REVISION</strong></div>
<small>No manual fields have been changed.</small>
</div>
<p class="result-summary">{response.summary}</p>
<div class="change-counts" aria-label="Current and proposed revision counts">
<div><span>STAGES</span><b>{beforeCounts.stages}</b><i>→</i><strong>{afterCounts.stages}</strong></div>
<div><span>WEAPON PROFILES</span><b>{beforeCounts.weaponProfiles}</b><i>→</i><strong>{afterCounts.weaponProfiles}</strong></div>
<div><span>SKILL PROFILES</span><b>{beforeCounts.skillProfiles}</b><i>→</i><strong>{afterCounts.skillProfiles}</strong></div>
</div>
{#if response.assumptions.length || response.warnings.length}
<div class="review-notes">
{#if response.assumptions.length}<section><strong>ASSUMPTIONS</strong><ul>{#each response.assumptions as item}<li>{item}</li>{/each}</ul></section>{/if}
{#if response.warnings.length}<section class="warnings"><strong>REVIEW WARNINGS</strong><ul>{#each response.warnings as item}<li>{item}</li>{/each}</ul></section>{/if}
</div>
{/if}
<PseudoScript title="PROPOSED CALCULATION REVISION" lines={scriptLines} />
{#if confirmApply}
<div class="apply-confirm" role="alert">
<div>
<strong>APPLY THIS DRAFT TO THE UNSAVED REVISION?</strong>
<p>
Stages {beforeCounts.stages} → {afterCounts.stages}; weapon profiles
{beforeCounts.weaponProfiles} → {afterCounts.weaponProfiles}; skill profiles
{beforeCounts.skillProfiles} → {afterCounts.skillProfiles}. This does not save or
activate the revision.
</p>
</div>
<div><button type="button" class="secondary" onclick={() => confirmApply = false}>CANCEL</button><button type="button" class="primary" onclick={applyDraft}>CONFIRM APPLY</button></div>
</div>
{:else}
<div class="result-actions">
<button type="button" class="secondary" onclick={resetResult}>DISCARD DRAFT</button>
<button type="button" class="primary" onclick={() => confirmApply = true}>APPLY TO REVISION</button>
</div>
{/if}
{:else if pending}
<div class="assistant-message pending">
<strong>ANALYZING CALCULATION</strong>
<p>Comparing the requested behavior with supported stages, Attributes, caps, and curves.</p>
</div>
{/if}
</div>
{#if activityMessage}<p class="activity-message" role="status">{activityMessage}</p>{/if}
</div>
{/if}
</section>
<style>
.calculation-assistant{border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface)}
.calculation-assistant>header{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:var(--shd-gap-sm);padding:7px 9px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
.calculation-assistant>header>div,.result-heading>div{display:grid;gap:2px}.calculation-assistant>header span,.result-heading span{color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}
.calculation-assistant>header b{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.calculation-assistant>header b.available{border-color:color-mix(in srgb,var(--shd-info) 55%,var(--shd-border));color:var(--shd-info)}
.assistant-toggle{min-height:36px;padding:0 11px;border:1px solid var(--shd-info);background:transparent;color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}.assistant-toggle:hover,.assistant-toggle:focus-visible{background:color-mix(in srgb,var(--shd-info) 12%,var(--shd-surface))}.assistant-toggle:disabled{cursor:not-allowed;opacity:.5}
.assistant-toggle:active,.request-actions button:active,.result-actions button:active,.apply-confirm button:active{transform:translateY(1px);filter:brightness(.84)}
.permission-note,.assistant-summary{margin:0;padding:9px 11px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}.permission-note b{color:var(--shd-text)}.locked{border-left-color:var(--shd-border)}
.assistant-workspace{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}.requirement-field,.answer-field{display:grid;gap:5px;color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.requirement-field textarea,.answer-field input{width:100%;box-sizing:border-box;border:1px solid var(--shd-border);border-radius:0;background:#0d0f10;color:var(--shd-text)}.requirement-field textarea{min-height:118px;padding:10px;resize:vertical;font-size:var(--shd-type-body);font-weight:400;letter-spacing:0;line-height:1.5}.answer-field input{min-height:42px;padding:0 10px;font-size:var(--shd-type-supporting);font-weight:400;letter-spacing:0}
.request-actions,.result-actions,.apply-confirm{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm)}.request-actions p{max-width:70ch;margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.request-actions button,.result-actions button,.apply-confirm button{min-height:40px;padding:0 13px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}.request-actions button:disabled,.result-actions button:disabled{cursor:wait;opacity:.58}
.result-region{display:grid;gap:var(--shd-gap-sm);outline:none}.result-region:focus-visible{outline:1px solid var(--shd-orange);outline-offset:3px}.result-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.result-heading small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.result-heading.capability{border-left:2px solid var(--shd-danger)}.result-heading.capability span{color:var(--shd-danger)}.result-heading.ready{border-left:2px solid var(--shd-success)}.result-heading.ready span{color:var(--shd-success)}
.assistant-message{display:grid;gap:3px;padding:10px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}.assistant-message p,.result-summary,.activity-message{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}.assistant-message.error{border-left-color:var(--shd-danger)}.assistant-message.error strong{color:var(--shd-danger)}
.question-stack,.gap-stack{display:grid;gap:var(--shd-gap-xs)}.question-card,.gap-card{display:grid;gap:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.question-card>header{display:grid;grid-template-columns:auto minmax(0,1fr);gap:9px;align-items:start}.question-card>header>span{display:grid;place-items:center;width:28px;height:28px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900}.question-card>header>div{display:grid;gap:3px}.question-card small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.gap-card>header{display:flex;align-items:center;gap:8px}.gap-card>header span,.gap-card dt,.review-notes strong,.change-counts span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.gap-card dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--shd-border-soft);border-left:1px solid var(--shd-border-soft)}.gap-card dl>div{display:grid;gap:4px;padding:8px;border-right:1px solid var(--shd-border-soft);border-bottom:1px solid var(--shd-border-soft)}.gap-card dd{margin:0;color:var(--shd-text);font-size:var(--shd-type-supporting);line-height:1.45}
.change-counts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--shd-gap-xs)}.change-counts>div{display:grid;grid-template-columns:minmax(0,1fr) auto auto auto;align-items:center;gap:7px;padding:8px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.change-counts b{color:var(--shd-text-muted)}.change-counts i{color:var(--shd-text-dim);font-style:normal}.change-counts strong{color:var(--shd-orange)}
.review-notes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-xs)}.review-notes section{padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}.review-notes section.warnings{border-left-color:var(--shd-orange)}.review-notes ul{display:grid;gap:5px;margin:6px 0 0;padding-left:18px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.apply-confirm{padding:9px;border:1px solid var(--shd-orange);background:color-mix(in srgb,var(--shd-orange) 8%,var(--shd-surface))}.apply-confirm>div:first-child{display:grid;gap:3px}.apply-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.apply-confirm>div:last-child{display:flex;gap:7px}.activity-message{padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
@media(max-width:760px){.calculation-assistant>header{grid-template-columns:1fr auto}.calculation-assistant>header b{justify-self:end}.assistant-toggle{grid-column:1/-1;width:100%;min-height:44px}.result-heading,.request-actions,.result-actions,.apply-confirm{align-items:stretch;flex-direction:column}.request-actions button,.result-actions button,.apply-confirm button{width:100%;min-height:44px}.apply-confirm>div:last-child{display:grid;grid-template-columns:1fr 1fr}.gap-card dl,.review-notes,.change-counts{grid-template-columns:1fr}}
@media(prefers-reduced-motion:reduce){.assistant-toggle,.request-actions button,.result-actions button,.apply-confirm button{transition:none}}
</style>
@@ -0,0 +1,566 @@
<script lang="ts">
import { page } from '$app/state';
import PseudoScript from '$lib/components/PseudoScript.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import type {
AiMechanicAuthoringResponse,
AiMechanicContext,
AiMechanicTranscriptEntry,
AiMechanicValueColumn
} from '$lib/ai/mechanic-authoring';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { onDestroy, tick, untrack } from 'svelte';
type ClarificationAnswer = string | number | boolean;
let {
context,
currentDraft,
valueColumns,
idPrefix = 'mechanic',
allowAssignedAbility = false,
allowProtocolUpgradeScaling = false,
disabled = false,
onapply
}: {
context: AiMechanicContext;
currentDraft: ConditionalMechanicDraftV1;
valueColumns: AiMechanicValueColumn[];
idPrefix?: string;
allowAssignedAbility?: boolean;
allowProtocolUpgradeScaling?: boolean;
disabled?: boolean;
onapply: (draft: ConditionalMechanicDraftV1) => void;
} = $props();
const permissions = $derived((page.data.permissions as string[] | undefined) ?? []);
const canGenerate = $derived(permissions.includes('*') || permissions.includes('ai.mechanic.generate'));
const canUseAssistant = $derived(canGenerate && !disabled);
let expanded = $state(false);
let sourceText = $state('');
let response = $state<AiMechanicAuthoringResponse | null>(null);
let transcript = $state<AiMechanicTranscriptEntry[]>([]);
let answers = $state<Record<string, ClarificationAnswer | undefined>>({});
let pending = $state(false);
let errorMessage = $state('');
let activityMessage = $state('');
let confirmApply = $state(false);
let resultRegion = $state<HTMLElement>();
let requestController: AbortController | undefined;
let trackedContextKey = $state('');
const contextIdentity = $derived(`${context.sourceKind}:${context.sourceName}`);
const contextKey = $derived(
`${idPrefix}-${context.sourceKind}-${context.sourceName}`
.toLocaleLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
|| 'mechanic'
);
const questions = $derived(
response?.status === 'clarification_required' ? response.questions : []
);
const allQuestionsAnswered = $derived(
questions.length > 0 && questions.every((question) => {
const answer = answers[question.id];
if (typeof answer === 'string') return answer.trim().length > 0;
if (typeof answer === 'number') return Number.isFinite(answer);
return typeof answer === 'boolean';
})
);
const pseudoScriptLines = $derived(
response?.status === 'draft_ready'
? response.pseudoScript.split('\n')
: []
);
const draftReadiness = $derived(
response?.status !== 'draft_ready'
? undefined
: response.draft.simulationReadiness === 'ready'
? 'READY'
: response.draft.simulationReadiness === 'requires_assumptions'
? 'REQUIRES ASSUMPTIONS'
: 'CAPTURED ONLY'
);
const currentDraftCounts = $derived(summarizeDraft(currentDraft));
const proposedDraftCounts = $derived(
response?.status === 'draft_ready' ? summarizeDraft(response.draft) : null
);
function summarizeDraft(draft: ConditionalMechanicDraftV1) {
return {
branches: draft.clauses.length,
pools: draft.pools.length,
controls: draft.scenarioInputs.length,
outcomes: draft.clauses.reduce((total, clause) => total + clause.outcomes.length, 0)
};
}
function countLabel(value: number, singular: string) {
return `${value} ${singular}${value === 1 ? '' : 's'}`;
}
function formatDraftCounts(counts: ReturnType<typeof summarizeDraft>) {
return [
countLabel(counts.branches, 'branch'),
countLabel(counts.pools, 'pool'),
countLabel(counts.controls, 'control'),
countLabel(counts.outcomes, 'outcome')
].join(' · ');
}
$effect(() => {
const nextKey = contextIdentity;
if (nextKey === trackedContextKey) return;
trackedContextKey = nextKey;
untrack(() => resetAssistant(context.sourceDescription ?? ''));
});
onDestroy(() => requestController?.abort());
function resetAssistant(description = context.sourceDescription ?? '') {
requestController?.abort();
sourceText = description;
response = null;
transcript = [];
answers = {};
pending = false;
errorMessage = '';
activityMessage = '';
confirmApply = false;
}
function setAnswer(questionId: string, answer: ClarificationAnswer | undefined) {
answers = { ...answers, [questionId]: answer };
}
async function focusResult() {
await tick();
resultRegion?.focus();
}
async function requestDraft(nextTranscript = transcript) {
if (!canUseAssistant || pending) return;
if (!sourceText.trim()) {
errorMessage = 'Enter the mechanic wording from the game before requesting a draft.';
activityMessage = '';
return;
}
requestController?.abort();
requestController = new AbortController();
pending = true;
errorMessage = '';
activityMessage = '';
confirmApply = false;
try {
const request = await fetch('/api/isac/mechanic-assistant', {
method: 'POST',
headers: { 'content-type': 'application/json' },
signal: requestController.signal,
body: JSON.stringify({
context: { ...context, sourceDescription: sourceText.trim() },
currentDraft,
valueColumns,
allowAssignedAbility,
allowProtocolUpgradeScaling,
transcript: nextTranscript
})
});
const contentType = request.headers.get('content-type') ?? '';
const payload = contentType.includes('application/json')
? await request.json()
: null;
if (!request.ok) {
throw new Error(
typeof payload?.message === 'string'
? payload.message
: `The authoring assistant could not complete the request (${request.status}).`
);
}
if (
!payload
|| !['clarification_required', 'draft_ready', 'capability_gap'].includes(payload.status)
) {
throw new Error('The authoring assistant returned an unrecognized response.');
}
response = payload as AiMechanicAuthoringResponse;
answers = {};
activityMessage = response.status === 'clarification_required'
? 'Clarification required before a reliable draft can be created.'
: response.status === 'draft_ready'
? 'Draft ready for review. Nothing has been applied or saved.'
: 'The current mechanic language cannot represent every requested behavior.';
await focusResult();
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
errorMessage = error instanceof Error
? error.message
: 'The authoring assistant could not complete the request.';
await focusResult();
} finally {
pending = false;
}
}
function submitClarifications() {
if (!allQuestionsAnswered || response?.status !== 'clarification_required') return;
const entries = response.questions.map((question): AiMechanicTranscriptEntry => ({
questionId: question.id,
question: question.question,
answer: String(answers[question.id])
}));
const nextTranscript = [...transcript, ...entries];
transcript = nextTranscript;
void requestDraft(nextTranscript);
}
function restartAnalysis() {
response = null;
transcript = [];
answers = {};
errorMessage = '';
activityMessage = '';
confirmApply = false;
void requestDraft([]);
}
function applyDraft() {
if (response?.status !== 'draft_ready') return;
onapply($state.snapshot(response.draft));
confirmApply = false;
activityMessage = 'AI draft applied to the manual editor. Review it before saving this item.';
}
</script>
<section class="authoring-assistant" class:locked={!canGenerate} aria-labelledby={`${contextKey}-assistant-title`}>
<header>
<div>
<span>ISAC AUTHORING ASSIST</span>
<strong id={`${contextKey}-assistant-title`}>AI MECHANIC DRAFTING</strong>
</div>
<b class:available={canGenerate}>{canGenerate ? 'AUTHORIZED' : 'PERMISSION REQUIRED'}</b>
<button
type="button"
class="assistant-toggle"
aria-expanded={expanded}
aria-controls={`${contextKey}-assistant-workspace`}
disabled={!canGenerate}
onclick={() => expanded = !expanded}
>
{expanded ? 'CLOSE ASSISTANT' : 'OPEN ASSISTANT'}
</button>
</header>
{#if !canGenerate}
<p class="permission-note">
AI assistance is optional. Manual mechanic authoring remains available below.
Request <b>AI mechanic generation</b> permission from an administrator to use this helper.
</p>
{:else if !expanded}
<p class="assistant-summary">
Convert exact in-game wording into a reviewable mechanic draft. The assistant cannot save,
publish, or replace the manual editor.
</p>
{/if}
{#if expanded}
<div class="assistant-workspace" id={`${contextKey}-assistant-workspace`}>
<div class="source-heading">
<div>
<span>SOURCE WORDING</span>
<strong>{context.sourceName || 'UNTITLED MECHANIC'}</strong>
</div>
<small>Paste the wording exactly as it appears in the game.</small>
</div>
<label class="source-field">
<span>MECHANIC DESCRIPTION</span>
<textarea
rows="5"
bind:value={sourceText}
disabled={!canUseAssistant || pending}
placeholder="Enter the complete in-game talent or effect wording."
></textarea>
</label>
<div class="request-actions">
<p>
Generated data is a draft. Applying it never saves the parent record and does not
remove access to the manual controls.
</p>
<button
type="button"
class="primary request-button"
disabled={!canUseAssistant || pending || !sourceText.trim()}
aria-busy={pending}
onclick={() => response || transcript.length ? restartAnalysis() : requestDraft()}
>
{pending ? 'ANALYZING MECHANIC…' : transcript.length ? 'RESTART ANALYSIS' : 'GENERATE DRAFT'}
</button>
</div>
<div
class="result-region"
bind:this={resultRegion}
tabindex="-1"
aria-live="polite"
aria-busy={pending}
>
{#if errorMessage}
<div class="assistant-message error" role="alert">
<strong>REQUEST FAILED</strong>
<p>{errorMessage}</p>
</div>
{:else if response?.status === 'clarification_required'}
<div class="result-heading">
<div>
<span>CLARIFICATION REQUIRED</span>
<strong>{response.questions.length} {response.questions.length === 1 ? 'QUESTION' : 'QUESTIONS'}</strong>
</div>
<small>Answers are used only to refine this mechanic draft.</small>
</div>
<div class="question-stack">
{#each response.questions as question, index}
<section class="question-card">
<header>
<span>{String(index + 1).padStart(2, '0')}</span>
<div>
<strong>{question.question}</strong>
<small>{question.reason}</small>
</div>
</header>
{#if question.answerType === 'boolean'}
<SegmentedChoice
legend="ANSWER"
name={`${contextKey}-${question.id}`}
options={[
{ value: 'true', label: 'YES' },
{ value: 'false', label: 'NO' }
]}
value={answers[question.id] === undefined ? '' : String(answers[question.id])}
onchange={(values) => setAnswer(question.id, values[0] === 'true')}
/>
{:else if question.answerType === 'choice' && question.options?.length}
<SegmentedChoice
legend="ANSWER"
name={`${contextKey}-${question.id}`}
options={question.options.map((option) => ({ value: option, label: option.toUpperCase() }))}
value={typeof answers[question.id] === 'string' ? answers[question.id] as string : ''}
columns={Math.min(3, question.options.length)}
onchange={(values) => setAnswer(question.id, values[0])}
/>
{:else}
<label class="answer-field">
<span>ANSWER</span>
<input
type={question.answerType === 'number' ? 'number' : 'text'}
step={question.answerType === 'number' ? 'any' : undefined}
value={answers[question.id] ?? ''}
oninput={(event) => setAnswer(
question.id,
question.answerType === 'number'
? event.currentTarget.value === ''
? undefined
: Number(event.currentTarget.value)
: event.currentTarget.value
)}
/>
</label>
{/if}
</section>
{/each}
</div>
<div class="result-actions">
<button type="button" class="secondary" onclick={() => resetAssistant(sourceText)}>
CANCEL QUESTIONS
</button>
<button
type="button"
class="primary"
disabled={!allQuestionsAnswered || pending}
aria-busy={pending}
onclick={submitClarifications}
>
{pending ? 'REFINING DRAFT…' : 'SUBMIT ANSWERS'}
</button>
</div>
{:else if response?.status === 'capability_gap'}
<div class="result-heading capability">
<div>
<span>DEVELOPER HANDOFF</span>
<strong>MECHANIC LANGUAGE GAP</strong>
</div>
<small>The assistant did not invent unsupported calculation behavior.</small>
</div>
<p class="gap-summary">{response.summary}</p>
<div class="gap-stack">
{#each response.gaps as gap}
<article class="gap-card">
<header><span>AREA</span><strong>{gap.area}</strong></header>
<dl>
<div><dt>REQUIRED BEHAVIOR</dt><dd>{gap.requiredBehavior}</dd></div>
<div><dt>WHY UNSUPPORTED</dt><dd>{gap.whyUnsupported}</dd></div>
<div><dt>DEVELOPER CHANGE</dt><dd>{gap.developerChange}</dd></div>
{#if gap.workaround}<div><dt>CURRENT WORKAROUND</dt><dd>{gap.workaround}</dd></div>{/if}
</dl>
</article>
{/each}
</div>
<div class="result-actions">
<button type="button" class="secondary" onclick={() => resetAssistant(sourceText)}>
REVISE SOURCE WORDING
</button>
</div>
{:else if response?.status === 'draft_ready'}
<div class="result-heading ready">
<div>
<span>DRAFT READY</span>
<strong>REVIEW BEFORE APPLICATION</strong>
</div>
<small>No changes have been applied or saved.</small>
</div>
<p class="draft-summary">{response.summary}</p>
{#if response.assumptions.length || response.warnings.length}
<div class="review-notes">
{#if response.assumptions.length}
<section>
<strong>ASSUMPTIONS</strong>
<ul>{#each response.assumptions as assumption}<li>{assumption}</li>{/each}</ul>
</section>
{/if}
{#if response.warnings.length}
<section class="warnings">
<strong>REVIEW WARNINGS</strong>
<ul>{#each response.warnings as warning}<li>{warning}</li>{/each}</ul>
</section>
{/if}
</div>
{/if}
<PseudoScript
title="PROPOSED MECHANIC PROGRAM"
lines={pseudoScriptLines}
readiness={draftReadiness}
/>
{#if confirmApply}
<div class="apply-confirm" role="alert">
<div>
<strong>APPLY THIS DRAFT TO THE MANUAL EDITOR?</strong>
<p>
This replaces the current unsaved mechanic fields. It does not save or publish
the parent record.
</p>
{#if proposedDraftCounts}
<div class="replacement-summary">
<span><b>CURRENT</b>{formatDraftCounts(currentDraftCounts)}</span>
<span><b>PROPOSED</b>{formatDraftCounts(proposedDraftCounts)}</span>
</div>
{/if}
</div>
<div>
<button type="button" class="secondary" onclick={() => confirmApply = false}>
CANCEL
</button>
<button type="button" class="primary" onclick={applyDraft}>
CONFIRM APPLY
</button>
</div>
</div>
{:else}
<div class="result-actions">
<button type="button" class="secondary" onclick={() => resetAssistant(sourceText)}>
DISCARD DRAFT
</button>
<button type="button" class="primary" onclick={() => confirmApply = true}>
APPLY TO EDITOR
</button>
</div>
{/if}
{:else if pending}
<div class="assistant-message pending">
<strong>ANALYZING MECHANIC</strong>
<p>Comparing the source wording with supported events, attributes, and value models.</p>
</div>
{/if}
</div>
{#if activityMessage}
<p class="activity-message" role="status">{activityMessage}</p>
{/if}
</div>
{/if}
</section>
<style>
.authoring-assistant{border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface)}
.authoring-assistant>header{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:var(--shd-gap-sm);padding:7px 9px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
.authoring-assistant>header>div,.source-heading>div,.result-heading>div{display:grid;gap:2px}
.authoring-assistant>header span,.source-heading span,.result-heading span{color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}
.authoring-assistant>header b{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
.authoring-assistant>header b.available{border-color:color-mix(in srgb,var(--shd-info) 55%,var(--shd-border));color:var(--shd-info)}
.assistant-toggle{min-height:36px;padding:0 11px;border:1px solid var(--shd-info);background:transparent;color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}
.assistant-toggle:hover,.assistant-toggle:focus-visible{background:color-mix(in srgb,var(--shd-info) 12%,var(--shd-surface))}
.assistant-toggle:disabled{cursor:not-allowed;opacity:.5}
.assistant-toggle:active,.request-button:active,.result-actions button:active,.apply-confirm button:active{transform:translateY(1px);filter:brightness(.84)}
.permission-note,.assistant-summary{margin:0;padding:9px 11px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
.permission-note b{color:var(--shd-text)}
.locked{border-left-color:var(--shd-border)}
.assistant-workspace{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
.source-heading,.result-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}
.source-heading small,.result-heading small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}
.source-field,.answer-field{display:grid;gap:5px;color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.source-field textarea,.answer-field input{width:100%;box-sizing:border-box;border:1px solid var(--shd-border);border-radius:0;background:#0d0f10;color:var(--shd-text);font:inherit}
.source-field textarea{min-height:118px;padding:10px;resize:vertical;font-size:var(--shd-type-body);font-weight:400;letter-spacing:0;line-height:1.5}
.answer-field input{min-height:42px;padding:0 10px;font-size:var(--shd-type-supporting);font-weight:400;letter-spacing:0}
.source-field :is(textarea,input):focus-visible,.answer-field input:focus-visible{border-color:var(--shd-orange);outline:1px solid color-mix(in srgb,var(--shd-orange) 55%,transparent);outline-offset:1px}
.request-actions,.result-actions,.apply-confirm{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm)}
.request-actions p{max-width:70ch;margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.request-actions button,.result-actions button,.apply-confirm button{min-height:40px;padding:0 13px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}
.request-actions button:disabled,.result-actions button:disabled{cursor:wait;opacity:.58}
.result-region{display:grid;gap:var(--shd-gap-sm);outline:none}
.result-region:focus-visible{outline:1px solid var(--shd-orange);outline-offset:3px}
.result-heading.capability{border-left:2px solid var(--shd-danger)}.result-heading.capability span{color:var(--shd-danger)}
.result-heading.ready{border-left:2px solid var(--shd-success)}.result-heading.ready span{color:var(--shd-success)}
.assistant-message{display:grid;gap:3px;padding:10px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
.assistant-message p,.gap-summary,.draft-summary,.activity-message{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
.assistant-message.error{border-left-color:var(--shd-danger)}.assistant-message.error strong{color:var(--shd-danger)}
.question-stack,.gap-stack{display:grid;gap:var(--shd-gap-xs)}
.question-card,.gap-card{display:grid;gap:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}
.question-card>header{display:grid;grid-template-columns:auto minmax(0,1fr);gap:9px;align-items:start}
.question-card>header>span{display:grid;place-items:center;width:28px;height:28px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900}
.question-card>header>div{display:grid;gap:3px}.question-card small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.gap-summary,.draft-summary{padding:0 2px;color:var(--shd-text)}
.gap-card>header{display:flex;align-items:center;gap:8px}.gap-card>header span,.gap-card dt,.review-notes strong{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.gap-card dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--shd-border-soft);border-left:1px solid var(--shd-border-soft)}
.gap-card dl>div{display:grid;gap:4px;padding:8px;border-right:1px solid var(--shd-border-soft);border-bottom:1px solid var(--shd-border-soft)}
.gap-card dd{margin:0;color:var(--shd-text);font-size:var(--shd-type-supporting);line-height:1.45}
.review-notes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-xs)}
.review-notes section{padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
.review-notes section.warnings{border-left-color:var(--shd-orange)}
.review-notes ul{display:grid;gap:5px;margin:6px 0 0;padding-left:18px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.apply-confirm{padding:9px;border:1px solid var(--shd-orange);background:color-mix(in srgb,var(--shd-orange) 8%,var(--shd-surface))}
.apply-confirm>div:first-child{display:grid;gap:3px}.apply-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.apply-confirm>div:last-child{display:flex;gap:7px}
.replacement-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px;margin-top:4px}.replacement-summary span{display:grid;gap:2px;padding:5px 7px;border:1px solid var(--shd-border-soft);color:var(--shd-text);font-size:var(--shd-type-supporting)}.replacement-summary b{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
.activity-message{padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
@media(max-width:760px){
.authoring-assistant>header{grid-template-columns:1fr auto}.authoring-assistant>header b{justify-self:end}.assistant-toggle{grid-column:1/-1;width:100%;min-height:44px}
.source-heading,.result-heading,.request-actions,.result-actions,.apply-confirm{align-items:stretch;flex-direction:column}
.request-actions button,.result-actions button,.apply-confirm button{width:100%;min-height:44px}
.apply-confirm>div:last-child{display:grid;grid-template-columns:1fr 1fr}
.replacement-summary{grid-template-columns:1fr}
.gap-card dl,.review-notes{grid-template-columns:1fr}
}
@media(prefers-reduced-motion:reduce){
.assistant-toggle,.request-button,.result-actions button,.apply-confirm button{transition:none}
}
</style>
@@ -3,6 +3,8 @@
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicLanguageHelp from '$lib/components/MechanicLanguageHelp.svelte';
import PseudoScript from '$lib/components/PseudoScript.svelte';
import MechanicAuthoringAssistant from '$lib/components/isac/MechanicAuthoringAssistant.svelte';
import type { AiMechanicContext } from '$lib/ai/mechanic-authoring';
import {
blankConditionalMechanicDraft,
parseConditionalMechanicDraft,
@@ -40,6 +42,7 @@
idPrefix = 'mechanic',
allowAssignedAbility = false,
allowProtocolUpgradeScaling = false,
aiContext,
disabled = false
}: {
rule?: ConditionalMechanicDraftV1;
@@ -48,6 +51,7 @@
idPrefix?: string;
allowAssignedAbility?: boolean;
allowProtocolUpgradeScaling?: boolean;
aiContext?: AiMechanicContext;
disabled?: boolean;
} = $props();
@@ -463,6 +467,19 @@
<MechanicLanguageHelp />
</div>
{#if aiContext}
<MechanicAuthoringAssistant
context={aiContext}
currentDraft={rule}
{valueColumns}
{idPrefix}
{allowAssignedAbility}
{allowProtocolUpgradeScaling}
{disabled}
onapply={setRule}
/>
{/if}
<SegmentedChoice
legend="SIMULATION READINESS"
name={`${idPrefix}-readiness`}