feat: persist authored mechanic revisions

This commit is contained in:
jb
2026-09-02 15:39:50 -04:00
parent 98f7f51e46
commit 7e392a1f37
56 changed files with 20720 additions and 3279 deletions
@@ -0,0 +1,355 @@
<script lang="ts">
import type {
MechanicAuthoredSlot,
MechanicAuthoredVariant,
} from '$lib/calculations/mechanic-authored-description';
export type AuthoredVariantLabel = { key: string; label: string };
let {
template = $bindable(),
variants = $bindable(),
variantLabels,
idPrefix = 'authored-description',
disabled = false,
}: {
template: string;
variants: MechanicAuthoredVariant[];
variantLabels: AuthoredVariantLabel[];
idPrefix?: string;
disabled?: boolean;
} = $props();
const tokenPattern = /\{([^{}]+)\}/g;
const allowedNamedTokens = new Set(['duration', 'max_stacks']);
const tokens = $derived.by(() => {
const ordered: string[] = [];
for (const match of template.matchAll(tokenPattern)) {
if (!ordered.includes(match[1])) ordered.push(match[1]);
}
return ordered;
});
const invalidTokens = $derived(
tokens.filter(
(token) =>
!allowedNamedTokens.has(token) && !/^[1-9]\d*$/.test(token),
),
);
function materialized(
sourceTemplate: string,
slots: MechanicAuthoredSlot[],
) {
const values = new Map(slots.map((slot) => [slot.key, slot.value]));
return sourceTemplate.replace(tokenPattern, (placeholder, token: string) =>
values.get(token) || placeholder,
);
}
function reconcileVariants(
sourceTemplate: string,
sourceVariants: MechanicAuthoredVariant[],
) {
return variantLabels.map((requiredVariant) => {
const existing = sourceVariants.find(
(variant) => variant.key === requiredVariant.key,
);
const existingSlots = new Map(
(existing?.slots ?? []).map((slot) => [slot.key, slot]),
);
const slots = tokens.map((token, order) => ({
...(existingSlots.get(token) ?? { key: token, value: '', order }),
key: token,
order,
}));
return {
key: requiredVariant.key,
materialized: materialized(sourceTemplate, slots),
slots,
};
});
}
function updateTemplate(value: string) {
template = value;
variants = reconcileVariants(value, variants);
}
function updateSlot(variantKey: string, token: string, value: string) {
variants = variants.map((variant) => {
if (variant.key !== variantKey) return variant;
const slots = tokens.map((currentToken, order) => {
const existing = variant.slots.find(
(slot) => slot.key === currentToken,
);
return {
...(existing ?? { key: currentToken, value: '', order }),
key: currentToken,
value: currentToken === token ? value : (existing?.value ?? ''),
order,
};
});
return {
...variant,
slots,
materialized: materialized(template, slots),
};
});
}
function slotValue(variantKey: string, token: string) {
return (
variants
.find((variant) => variant.key === variantKey)
?.slots.find((slot) => slot.key === token)?.value ?? ''
);
}
function preview(variantKey: string) {
return (
variants.find((variant) => variant.key === variantKey)?.materialized ??
template
);
}
</script>
<section class="authored-editor" aria-labelledby={`${idPrefix}-title`}>
<header>
<div>
<p class="eyebrow">PUBLIC FALLBACK SOURCE</p>
<h4 id={`${idPrefix}-title`}>AUTHORED DESCRIPTION</h4>
<p>Define exact public prose independently from combat modeling.</p>
</div>
<span class="variant-count">
{variantLabels.length} {variantLabels.length === 1 ? 'VARIANT' : 'VARIANTS'}
</span>
</header>
<label class="field template-field">
SHARED DESCRIPTION TEMPLATE
<textarea
id={`${idPrefix}-template`}
value={template}
oninput={(event) => updateTemplate(event.currentTarget.value)}
placeholder={'After {1} hits, gain {2}% Weapon Damage for {duration} seconds.'}
{disabled}
required
></textarea>
<small>
Use numbered tokens such as <code>{'{1}'}</code>, plus
<code>{'{duration}'}</code> and <code>{'{max_stacks}'}</code>. Keep fixed
units and Attribute wording in the template. Enter only the numbers that
change by variant below; the combat model assigns Attribute semantics.
</small>
</label>
{#if invalidTokens.length}
<p class="token-error" role="alert">
Unsupported {invalidTokens.length === 1 ? 'token' : 'tokens'}:
{invalidTokens.map((token) => `{${token}}`).join(', ')}
</p>
{/if}
{#if tokens.length}
<div class="matrix-scroll">
<table>
<thead>
<tr>
<th scope="col">AUTHORED VALUE</th>
{#each variantLabels as variant}
<th scope="col">{variant.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each tokens as token}
<tr>
<th scope="row"><code>{`{${token}}`}</code></th>
{#each variantLabels as variant}
<td>
<label class="sr-only" for={`${idPrefix}-${variant.key}-${token}`}>
{variant.label} value for {`{${token}}`}
</label>
<input
id={`${idPrefix}-${variant.key}-${token}`}
type="number"
step="any"
inputmode="decimal"
value={slotValue(variant.key, token)}
oninput={(event) =>
updateSlot(variant.key, token, event.currentTarget.value)}
{disabled}
required
/>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="empty-tokens">
Add a supported token to the shared template to define variant values.
</p>
{/if}
<div class="previews">
{#each variantLabels as variant}
<article>
<span>{variant.label} EXACT PREVIEW</span>
<p>{preview(variant.key) || 'Description preview'}</p>
</article>
{/each}
</div>
</section>
<style>
.authored-editor {
border: 1px solid var(--border, #333b44);
background: color-mix(in srgb, var(--surface, #12171d) 94%, transparent);
}
header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid var(--border, #333b44);
}
header h4,
header p {
margin: 0;
}
header h4 {
margin-top: 0.2rem;
}
header > div > p:last-child {
margin-top: 0.35rem;
color: var(--muted, #97a3af);
}
.variant-count {
flex: 0 0 auto;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border, #46515c);
color: var(--muted, #a4aeb8);
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.template-field {
margin: 1rem 1.1rem 0;
}
.template-field code,
th code {
color: var(--info, #6cb9ed);
}
.matrix-scroll {
margin: 1rem 1.1rem;
overflow-x: auto;
}
table {
width: 100%;
min-width: 34rem;
border-collapse: collapse;
}
th,
td {
border: 1px solid var(--border, #333b44);
padding: 0.55rem;
text-align: left;
}
thead th {
background: var(--surface-dark, #0d1217);
color: var(--muted, #a4aeb8);
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
letter-spacing: 0.08em;
}
tbody th {
width: 10rem;
background: color-mix(in srgb, var(--surface, #12171d) 80%, black);
}
td input {
width: 100%;
min-width: 8rem;
}
.previews {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 0.75rem;
padding: 0 1.1rem 1.1rem;
}
.previews article {
padding: 0.8rem 0.9rem;
border-left: 3px solid var(--info, #6cb9ed);
background: var(--surface-dark, #0d1217);
}
.previews span {
color: var(--info, #6cb9ed);
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.previews p {
margin: 0.4rem 0 0;
white-space: pre-wrap;
}
.token-error,
.empty-tokens {
margin: 1rem 1.1rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--border, #46515c);
color: var(--muted, #a4aeb8);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
}
.token-error {
border-color: var(--danger, #b7524c);
color: var(--danger-light, #ff8b84);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 40rem) {
header {
align-items: stretch;
flex-direction: column;
}
.variant-count {
align-self: flex-start;
}
}
</style>