feat: persist authored mechanic revisions
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user