feat: persist authored mechanic revisions
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,16 @@
|
||||
<script lang="ts">
|
||||
type Entry = { name: string; kind: 'text' | 'file'; value?: string; fileName?: string; type?: string };
|
||||
import {
|
||||
formatMechanicToken,
|
||||
formatMechanicValue,
|
||||
normalizePhase2OwnerPreview,
|
||||
parseProposalPreviewPayload,
|
||||
safePreviewValue,
|
||||
isPreviewFieldHidden,
|
||||
type AuthoredSourcePreview,
|
||||
type AttachmentPreview,
|
||||
type MechanicPreviewState,
|
||||
type ProposalPreviewEntry
|
||||
} from './ProposalChangePreview.helpers';
|
||||
|
||||
let {
|
||||
module,
|
||||
@@ -13,8 +24,10 @@
|
||||
currentSnapshot?: Record<string, unknown> | null;
|
||||
} = $props();
|
||||
|
||||
const entries = $derived(JSON.parse(payload) as Entry[]);
|
||||
const proposed = $derived(Object.fromEntries(entries.filter((entry) => entry.kind === 'text').map((entry) => [entry.name, entry.value ?? ''])));
|
||||
const parsedPayload = $derived(parseProposalPreviewPayload(payload));
|
||||
const entries = $derived(parsedPayload.entries as ProposalPreviewEntry[]);
|
||||
const proposed = $derived(parsedPayload.fields);
|
||||
const phase2OwnerPreview = $derived(normalizePhase2OwnerPreview(module, action, payload, currentSnapshot));
|
||||
const label = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
|
||||
const shown = (value: unknown) => value === null || value === undefined || value === '' ? 'NOT SET' : String(value);
|
||||
const jsonValue = (value: unknown) => {
|
||||
@@ -43,16 +56,8 @@
|
||||
: 'TREE NODE'
|
||||
);
|
||||
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||||
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||||
const currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]);
|
||||
const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) as any[]);
|
||||
const skillChipFields = $derived([
|
||||
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
|
||||
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
|
||||
{ label: '2-PIECE ATTRIBUTE', current: (currentSnapshot?.twoPiece as any)?.stat_attribute_id, proposed: proposed.twoPieceStatId },
|
||||
{ label: '2-PIECE RANGE', current: `${(currentSnapshot?.twoPiece as any)?.min_value ?? '—'} – ${(currentSnapshot?.twoPiece as any)?.max_value ?? '—'}`, proposed: `${proposed.twoPieceMin || '—'} – ${proposed.twoPieceMax || '—'}` },
|
||||
{ label: '3-PIECE TALENT', current: (currentSnapshot?.talent as any)?.name, proposed: proposed.talentName }
|
||||
]);
|
||||
const specializationFields = $derived(
|
||||
action === 'saveProgression'
|
||||
? [
|
||||
@@ -116,23 +121,80 @@
|
||||
const proposedCalculationStages = $derived((jsonValue(proposed.stagesJson) ?? []) as any[]);
|
||||
const proposedCalculationProfiles = $derived((jsonValue(proposed.profilesJson) ?? []) as any[]);
|
||||
const proposedSkillCalculationProfiles = $derived((jsonValue(proposed.skillProfilesJson) ?? []) as any[]);
|
||||
const phase2Record = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||||
const phase2TargetDisplay = (side: 'current' | 'proposed') =>
|
||||
side === 'current'
|
||||
? phase2OwnerPreview?.canonicalTargetId
|
||||
: phase2OwnerPreview?.canonicalTargetId ?? 'ASSIGNED ON APPROVAL';
|
||||
const phase2HasCurrentTarget = $derived(Boolean(phase2OwnerPreview?.canonicalTargetId));
|
||||
const ownerIdentityFields = (side: 'current' | 'proposed', family: NonNullable<typeof phase2OwnerPreview>['family']) => {
|
||||
const value = (key: string) => side === 'current' ? phase2Record?.[key] : proposed[key];
|
||||
if (family === 'talent') return [
|
||||
{ label: 'NAME', value: value('name') },
|
||||
{ label: 'CATEGORY', value: value('category') },
|
||||
{ label: 'OWNER TYPE', value: side === 'current' ? (currentSnapshot?.authoredSource as any)?.owner?.type : 'talent' },
|
||||
{ label: 'OWNER ID', value: phase2TargetDisplay(side) }
|
||||
];
|
||||
if (family === 'protocol-talent') return [
|
||||
{ label: 'NAME', value: value('name') },
|
||||
{ label: 'QUALITY', value: value('quality') },
|
||||
{ label: 'ATTRIBUTE TYPE', value: side === 'current' ? phase2Record?.primary_attribute_type : proposed.attributeType },
|
||||
{ label: 'PRIMARY ATTRIBUTE', value: side === 'current' ? (currentSnapshot?.primaryEffect as any)?.stat_attribute_id : proposed.primaryStatId },
|
||||
{ label: 'TALENT', value: side === 'current' ? (currentSnapshot?.talent as any)?.name : proposed.hasTalent === '1' ? proposed.talentName : 'NOT ASSIGNED' }
|
||||
];
|
||||
if (family === 'skill-chip-talent') return [
|
||||
{ label: 'NAME', value: value('name') },
|
||||
{ label: 'SPECIALIZATION ABILITY', value: side === 'current' ? phase2Record?.specialization_ability_id : proposed.abilityId },
|
||||
{ label: '2-PIECE ATTRIBUTE', value: side === 'current' ? (currentSnapshot?.twoPiece as any)?.stat_attribute_id : proposed.twoPieceStatId },
|
||||
{ label: '2-PIECE RANGE', value: side === 'current' ? `${(currentSnapshot?.twoPiece as any)?.min_value ?? '—'} – ${(currentSnapshot?.twoPiece as any)?.max_value ?? '—'}` : `${proposed.twoPieceMin || '—'} – ${proposed.twoPieceMax || '—'}` },
|
||||
{ label: '3-PIECE TALENT', value: side === 'current' ? (currentSnapshot?.talent as any)?.name : proposed.hasTalent === '1' ? proposed.talentName : 'NOT ASSIGNED' }
|
||||
];
|
||||
if (family === 'specialization-node') return [
|
||||
{ label: 'NAME', value: value('name') },
|
||||
{ label: 'FOCUS', value: side === 'current' ? phase2Record?.focus_id : proposed.focusId },
|
||||
{ label: 'ROW', value: side === 'current' ? phase2Record?.row_number : proposed.rowNumber },
|
||||
{ label: 'COLUMN', value: side === 'current' ? phase2Record?.column_number : proposed.columnNumber },
|
||||
{ label: 'MAXIMUM RANK', value: side === 'current' ? phase2Record?.max_rank : proposed.maxRank },
|
||||
{ label: 'RANK DEFINITIONS', value: side === 'current' ? (currentSnapshot?.ranks as any[])?.length ?? 0 : (jsonValue(proposed.ranksJson) as any[])?.length ?? 0 }
|
||||
];
|
||||
return [
|
||||
{ label: 'NAME', value: value('name') },
|
||||
{ label: 'SPECIALIZATION', value: side === 'current' ? phase2Record?.specialization_id : proposed.specializationId },
|
||||
{ label: 'DESCRIPTION TEMPLATE', value: side === 'current' ? phase2Record?.description_template : proposed.descriptionTemplate },
|
||||
{ label: 'ATTACHMENTS', value: side === 'current' ? (currentSnapshot?.attachments as any[])?.length ?? 0 : phase2OwnerPreview?.proposedAttachments.length ?? 0 }
|
||||
];
|
||||
};
|
||||
const ownerRelationshipRows = (side: 'current' | 'proposed', family: NonNullable<typeof phase2OwnerPreview>['family']) => {
|
||||
if (family === 'talent') {
|
||||
if (side === 'proposed') return [`CATEGORY · ${shown(proposed.category)}`, `RECORD ID · ${shown(phase2TargetDisplay(side))}`];
|
||||
const relationships = (currentSnapshot?.relationships ?? {}) as any;
|
||||
return [
|
||||
`WEAPON SCOPE · ${shown(relationships.weaponScope?.scope)}`,
|
||||
`WEAPON SLOT · ${shown(relationships.weaponSlot?.slot)}`,
|
||||
`OWNER WEAPON · ${shown(relationships.ownerWeapon?.id)}${relationships.ownerWeapon?.name ? ` · ${relationships.ownerWeapon.name}` : ''}`,
|
||||
`GEAR TALENTS · ${(relationships.gearTalents ?? []).map((item: any) => item.gear_id).join(', ') || 'NONE'}`,
|
||||
`WEAPON TALENT OPTIONS · ${(relationships.weaponTalentOptions ?? []).map((item: any) => `${item.weapon_id} · SLOT ${item.talent_slot}`).join(', ') || 'NONE'}`
|
||||
];
|
||||
}
|
||||
if (family === 'protocol-talent') return side === 'current'
|
||||
? [`PROTOCOL ID · ${shown(phase2TargetDisplay(side))}`, `TALENT ID · ${shown((currentSnapshot?.talent as any)?.id)}`]
|
||||
: [`PROTOCOL ID · ${shown(phase2TargetDisplay(side))}`, `TALENT · ${proposed.hasTalent === '1' ? shown(proposed.talentName) : 'NOT ASSIGNED'}`];
|
||||
if (family === 'skill-chip-talent') return side === 'current'
|
||||
? [`SKILL CHIP SET ID · ${shown(phase2TargetDisplay(side))}`, `TALENT ID · ${shown((currentSnapshot?.talent as any)?.id)}`]
|
||||
: [`SKILL CHIP SET ID · ${shown(phase2TargetDisplay(side))}`, `TALENT · ${proposed.hasTalent === '1' ? shown(proposed.talentName) : 'NOT ASSIGNED'}`];
|
||||
if (family === 'specialization-node') return side === 'current'
|
||||
? [`NODE ID · ${shown(phase2TargetDisplay(side))}`, `FOCUS ID · ${shown(phase2Record?.focus_id)}`]
|
||||
: [`NODE ID · ${shown(phase2TargetDisplay(side))}`, `FOCUS ID · ${shown(proposed.focusId)}`];
|
||||
return side === 'current'
|
||||
? [`COMBAT EFFECT ID · ${shown(phase2TargetDisplay(side))}`, `ATTACHMENT IDS · ${(currentSnapshot?.attachments as any[] ?? []).map((item: any) => item.id).join(', ') || 'NONE'}`]
|
||||
: [`COMBAT EFFECT ID · ${shown(phase2TargetDisplay(side))}`, `FAN-OUT ATTACHMENTS · ${phase2OwnerPreview?.proposedAttachments.length ?? 0}`];
|
||||
};
|
||||
const effectSummary = (effect: any) => {
|
||||
const target = effect.statId || effect.stat_attribute_id || effect.abilityId || effect.ability_id || effect.label || 'CUSTOM EFFECT';
|
||||
const value = effect.value === null || effect.value === undefined || effect.value === '' ? '' : `${effect.value}${effect.unit ? ` ${effect.unit}` : ''}`;
|
||||
return `${target}${value ? ` · ${value}` : ''}`;
|
||||
};
|
||||
const conditionalRuleSummary = (effect: any) => {
|
||||
const rule = effect.conditionalRule ?? {};
|
||||
const clauses = Array.isArray(rule.clauses) ? rule.clauses : [];
|
||||
const outcomes = clauses.reduce((total: number, clause: any) =>
|
||||
total + (Array.isArray(clause.outcomes) ? clause.outcomes.length : 0), 0);
|
||||
return `${effect.effectId} · ${readableToken(rule.simulationReadiness)} · ${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'} · ${outcomes} OUTCOME${outcomes === 1 ? '' : 'S'}`;
|
||||
};
|
||||
const readableToken = (value: unknown) => shown(value).replaceAll('_', ' ').toUpperCase();
|
||||
const storedJson = (value: unknown, fallback: unknown) => {
|
||||
if (typeof value !== 'string') return value ?? fallback;
|
||||
try { return JSON.parse(value); } catch { return fallback; }
|
||||
};
|
||||
const attackProfileLines = (attack: Record<string, unknown> | null) => {
|
||||
if (!attack || ('enabled' in attack && !attack.enabled)) return [];
|
||||
const delivery = String(attack.delivery ?? '').toLowerCase();
|
||||
@@ -156,90 +218,100 @@
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
const conditionalRuleLines = (source: unknown) => {
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return [];
|
||||
const wrapper = source as Record<string, any>;
|
||||
const persisted = wrapper.rule && typeof wrapper.rule === 'object';
|
||||
const rule = persisted ? wrapper.rule : wrapper;
|
||||
const inputs = persisted
|
||||
? storedJson(rule.scenario_inputs_json, [])
|
||||
: rule.scenarioInputs;
|
||||
const pools = persisted ? wrapper.statePools : rule.pools;
|
||||
const clauses = persisted ? wrapper.clauses : rule.clauses;
|
||||
const lines = [
|
||||
`READINESS · ${readableToken(rule.simulationReadiness ?? rule.simulation_readiness)}`,
|
||||
`MODEL · ${Array.isArray(inputs) ? inputs.length : 0} SCENARIO INPUTS · ${Array.isArray(pools) ? pools.length : 0} STATE POOLS · ${Array.isArray(clauses) ? clauses.length : 0} BRANCHES`
|
||||
];
|
||||
if (Array.isArray(inputs)) {
|
||||
for (const input of inputs) {
|
||||
lines.push(`INPUT · ${shown(input.label ?? input.key)} · ${readableToken(input.kind)} · ${readableToken(input.readiness ?? 'ready')}`);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(pools)) {
|
||||
for (const pool of pools) {
|
||||
const poolKey = pool.key ?? pool.state_key;
|
||||
const scope = pool.scope ?? pool.state_scope;
|
||||
const maximum = pool.max ?? pool.maximum_value;
|
||||
const expiry = pool.expiresAfterSeconds ?? pool.duration_seconds;
|
||||
lines.push(`STATE · ${shown(poolKey)} · ${readableToken(scope)} · MAX ${shown(maximum)}${expiry ? ` · EXPIRES ${expiry}s` : ''}`);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(clauses)) {
|
||||
for (const [index, clause] of clauses.entries()) {
|
||||
const event = clause.event ?? clause.event_kind;
|
||||
const recipe = clause.recipeKey ?? clause.recipe_key;
|
||||
const reducer = persisted
|
||||
? storedJson(clause.reducer_json, { kind: 'none' })
|
||||
: clause.reducer;
|
||||
const mutations = persisted
|
||||
? storedJson(clause.mutations_json, [])
|
||||
: clause.mutations;
|
||||
const branchOutcomes = persisted
|
||||
? (wrapper.outcomes ?? []).filter((outcome: any) => outcome.clause_id === clause.id)
|
||||
: clause.outcomes ?? [];
|
||||
lines.push([
|
||||
`BRANCH ${index + 1}`,
|
||||
readableToken(event),
|
||||
recipe ? `RECIPE ${readableToken(recipe)}` : '',
|
||||
`REDUCER ${readableToken(reducer?.kind)}`,
|
||||
clause.cooldownSeconds ?? clause.cooldown_seconds
|
||||
? `${clause.cooldownSeconds ?? clause.cooldown_seconds}s COOLDOWN`
|
||||
: '',
|
||||
`${Array.isArray(mutations) ? mutations.length : 0} MUTATIONS`,
|
||||
`${branchOutcomes.length} OUTCOMES`
|
||||
].filter(Boolean).join(' · '));
|
||||
for (const outcome of branchOutcomes) {
|
||||
if (persisted) {
|
||||
lines.push([
|
||||
`↳ ${readableToken(outcome.variant_key)}`,
|
||||
readableToken(outcome.application_kind),
|
||||
readableToken(outcome.effect_scope),
|
||||
outcome.stat_attribute_id || readableToken(outcome.resource_kind ?? outcome.value_basis),
|
||||
`${shown(outcome.value)}${outcome.value_expression_json ? ' · EXPRESSION' : ''}`,
|
||||
readableToken(outcome.application_phase)
|
||||
].join(' · '));
|
||||
continue;
|
||||
}
|
||||
const variants = outcome.variants && typeof outcome.variants === 'object'
|
||||
? Object.entries(outcome.variants).map(([variant, configured]: [string, any]) =>
|
||||
`${readableToken(variant)} ${shown(configured?.value)}${configured?.expression ? ' (EXPRESSION)' : ''}`
|
||||
).join(' · ')
|
||||
: 'NO VARIANTS';
|
||||
lines.push([
|
||||
`↳ ${shown(outcome.label ?? outcome.key)}`,
|
||||
readableToken(outcome.kind),
|
||||
readableToken(outcome.scope),
|
||||
outcome.attributeId || readableToken(outcome.resource ?? outcome.basis),
|
||||
variants,
|
||||
readableToken(outcome.phase)
|
||||
].join(' · '));
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet authoredSourceBlock(source: AuthoredSourcePreview | null)}
|
||||
{#if source}
|
||||
<section class="proposal-mechanic-section authored-source-preview">
|
||||
<header><span>AUTHORED SOURCE · VERSION {shown(source.version)}</span><b>{source.variants.length} ORDERED VARIANTS</b></header>
|
||||
<div class="proposal-source-template"><span>TEMPLATE</span><p>{source.template}</p></div>
|
||||
{#if source.specialization}
|
||||
<div class="proposal-source-identity">
|
||||
<div><span>RANK DESCRIPTION</span><p>{shown(source.specialization.rankDescription)}</p></div>
|
||||
<div><span>REUSABLE EFFECT ID</span><p>{shown(source.specialization.effectId)}</p></div>
|
||||
<div><span>REUSABLE EFFECT TEMPLATE</span><p>{shown(source.specialization.effectTemplate)}</p></div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="proposal-source-variants">
|
||||
{#each source.variants as variant, variantIndex}
|
||||
<article>
|
||||
<header><span>VARIANT {variantIndex + 1} · {variant.key}</span><b>{variant.slots.length} SLOTS</b></header>
|
||||
<p class="proposal-materialized-text">{variant.materialized || 'NOT MATERIALIZED'}</p>
|
||||
{#each variant.slots as slot, slotIndex}
|
||||
<small>SLOT {slotIndex + 1} · ORDER {slot.order} · {slot.key} = {shown(slot.value)}{slot.unit ? ` · UNIT ${slot.unit}` : ''}{slot.attributeId ? ` · ATTRIBUTE ${slot.attributeId}` : ''}</small>
|
||||
{:else}<small>NO TEMPLATE SLOTS</small>{/each}
|
||||
</article>
|
||||
{:else}<p class="proposal-structured-empty">NO AUTHORED VARIANTS</p>{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet mechanicStateBlock(state: MechanicPreviewState, heading: string)}
|
||||
<section class="proposal-mechanic-section mechanic-state-preview">
|
||||
<header><span>{heading}</span><b>{state.selectedRevision ? `REVISION ${shown(state.selectedRevision.revisionNumber)}` : state.modelDraft ? 'MODEL SUBMISSION' : 'NO MODEL SELECTED'}</b></header>
|
||||
<div class="proposal-model-summary">
|
||||
<div><span>SELECTION STATE</span><strong>{state.selectedRevision ? formatMechanicToken(state.selectedRevisionStatus) : state.modelDraft ? 'PENDING REVISION' : 'ABSENT'}</strong></div>
|
||||
<div><span>READINESS</span><strong>{formatMechanicToken((state.selectedRevision ?? state.modelDraft)?.readiness)}</strong></div>
|
||||
<div><span>SCENARIO INPUTS</span><strong>{(state.selectedRevision ?? state.modelDraft)?.scenarioInputs.length ?? 0}</strong></div>
|
||||
<div><span>STATE POOLS</span><strong>{(state.selectedRevision ?? state.modelDraft)?.pools.length ?? 0}</strong></div>
|
||||
<div><span>CLAUSES</span><strong>{(state.selectedRevision ?? state.modelDraft)?.clauses.length ?? 0}</strong></div>
|
||||
</div>
|
||||
{#if state.selectedRevision ?? state.modelDraft}
|
||||
{@const model = (state.selectedRevision ?? state.modelDraft)!}
|
||||
<div class="proposal-graph-summary">
|
||||
<section><header><span>SCENARIO INPUTS</span><b>{model.scenarioInputs.length}</b></header>
|
||||
{#each model.scenarioInputs as input}<p>INPUT · {input.label} · {formatMechanicToken(input.kind)} · {formatMechanicToken(input.readiness)}{input.min !== null || input.max !== null ? ` · RANGE ${shown(input.min)}–${shown(input.max)}` : ''}{input.defaultValue !== null ? ` · DEFAULT ${shown(input.defaultValue)}` : ''}</p>{:else}<p>NO SCENARIO INPUTS</p>{/each}
|
||||
</section>
|
||||
<section><header><span>STATE POOLS</span><b>{model.pools.length}</b></header>
|
||||
{#each model.pools as pool}<p>POOL · {pool.key} · {pool.label} · KIND {formatMechanicToken(pool.stateKind)} · {formatMechanicToken(pool.scope)} · INITIAL {shown(pool.initial)} · RANGE {shown(pool.min)}–{shown(pool.max)}{pool.bestCaseValue !== null ? ` · BEST ${shown(pool.bestCaseValue)}` : ''}{pool.expiresAfterSeconds !== null ? ` · EXPIRES ${shown(pool.expiresAfterSeconds)}s` : ''}</p>{:else}<p>NO STATE POOLS</p>{/each}
|
||||
</section>
|
||||
<section><header><span>CLAUSE GRAPH</span><b>{model.clauses.length}</b></header>
|
||||
{#each model.clauses as clause, clauseIndex}
|
||||
<article class="proposal-clause">
|
||||
<p><b>CLAUSE {clauseIndex + 1} · {clause.label}</b> · EVENT {formatMechanicToken(clause.event)}{clause.recipeKey ? ` · RECIPE ${formatMechanicToken(clause.recipeKey)}` : ''} · REDUCER {formatMechanicToken((clause.reducer as any)?.kind)}{clause.cooldownSeconds !== null ? ` · COOLDOWN ${shown(clause.cooldownSeconds)}s` : ''}</p>
|
||||
<small>CONDITION · {formatMechanicValue(clause.condition)} · MUTATIONS {clause.mutations.length}</small>
|
||||
{#each clause.mutations as mutation, mutationIndex}<small>MUTATION {mutationIndex + 1} · {formatMechanicValue(mutation)}</small>{/each}
|
||||
{#each clause.outcomes as outcome, outcomeIndex}
|
||||
<div class="proposal-outcome"><small>OUTCOME {outcomeIndex + 1} · {outcome.label} · {formatMechanicToken(outcome.kind)} · {formatMechanicToken(outcome.scope)} · {formatMechanicToken(outcome.phase)} · BASIS {formatMechanicToken(outcome.basis)}{outcome.attributeId ? ` · ATTRIBUTE ${outcome.attributeId}` : ''}{outcome.statePool ? ` · POOL ${outcome.statePool}` : ''}{outcome.resource ? ` · RESOURCE ${formatMechanicToken(outcome.resource)}` : ''}{outcome.perStack !== null ? ` · PER STACK ${outcome.perStack ? 'YES' : 'NO'}` : ''}</small>{#each outcome.variants as variant}<small>VARIANT {variant.key} · VALUE {formatMechanicValue(variant.value)}{variant.expression !== null ? ` · EXPRESSION ${formatMechanicValue(variant.expression)}` : ''}</small>{/each}</div>
|
||||
{/each}
|
||||
</article>
|
||||
{:else}<p>NO CLAUSES</p>{/each}
|
||||
</section>
|
||||
</div>
|
||||
{:else}<p class="proposal-structured-empty">NO IMMUTABLE MODEL REVISION IS SELECTED.</p>{/if}
|
||||
</section>
|
||||
{/snippet}
|
||||
|
||||
{#snippet projectionBlock(state: MechanicPreviewState)}
|
||||
<section class="proposal-mechanic-section proposal-projection-preview">
|
||||
<header><span>PUBLIC PROJECTION</span><b>{formatMechanicToken(state.projection.status)}</b></header>
|
||||
{#if state.projection.variants.length}
|
||||
<p class="proposal-projection-meta">PROJECTION SOURCE · {state.selectedRevision ? `REVISION ${shown(state.selectedRevision.revisionNumber)}` : 'AUTHORED FALLBACK'}</p>
|
||||
{#each state.projection.variants as variant}<p class="proposal-projection-variant"><b>{variant.key}</b><span>STATUS {formatMechanicToken(variant.status)} · DISPLAY SOURCE {formatMechanicToken(variant.displaySource)}</span><strong>{variant.text || 'NO PUBLIC TEXT'}</strong></p>{/each}
|
||||
{:else}<p class="proposal-structured-empty">NO PUBLIC PROJECTION AVAILABLE.</p>{/if}
|
||||
</section>
|
||||
{/snippet}
|
||||
|
||||
{#snippet mechanicBlocks(state: MechanicPreviewState, heading: string)}
|
||||
{@render authoredSourceBlock(state.authoredSource)}
|
||||
{@render mechanicStateBlock(state, heading)}
|
||||
{@render projectionBlock(state)}
|
||||
{/snippet}
|
||||
|
||||
{#snippet attachmentBlock(attachments: AttachmentPreview[], heading: string)}
|
||||
<section class="proposal-mechanic-section attachment-preview">
|
||||
<header><span>{heading}</span><b>{attachments.length} ATTACHMENTS</b></header>
|
||||
{#each attachments as attachment, attachmentIndex}
|
||||
<article>
|
||||
<header><span>ATTACHMENT {attachmentIndex + 1} · {attachment.attachmentId}</span><b>RANK {shown(attachment.rank)} · ORDER {shown(attachment.effectOrder)}</b></header>
|
||||
<p>NODE ID · {shown(attachment.nodeId)} · COMBAT EFFECT ID · {shown(attachment.effectId)}</p>
|
||||
{@render mechanicBlocks(attachment.mechanic, 'ATTACHMENT MECHANIC')}
|
||||
</article>
|
||||
{:else}<p class="proposal-structured-empty">NO ATTACHMENTS</p>{/each}
|
||||
</section>
|
||||
{/snippet}
|
||||
|
||||
<section class="proposal-change-preview" aria-label="Proposed data comparison">
|
||||
<div class="proposal-preview-head">
|
||||
<div><span>CHANGE PREVIEW</span><small>LIVE REGISTRY COMPARED WITH CONTRIBUTOR SUBMISSION</small></div>
|
||||
@@ -281,6 +353,42 @@
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{:else if phase2OwnerPreview}
|
||||
<div class="proposal-compare-grid phase2-owner-preview">
|
||||
<article class="proposal-editor-preview before">
|
||||
<header><span>CURRENT LIVE · {phase2OwnerPreview.family.toUpperCase()}</span><b>{phase2HasCurrentTarget ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
|
||||
{#if phase2HasCurrentTarget}
|
||||
<div class="proposal-field-summary owner-identity-fields">{#each ownerIdentityFields('current', phase2OwnerPreview.family) as field}<div><span>{field.label}</span><strong>{shown(field.value)}</strong></div>{/each}</div>
|
||||
<div class="proposal-relationships"><header><span>RELATIONSHIPS</span><b>LIVE</b></header>{#each ownerRelationshipRows('current', phase2OwnerPreview.family) as line}<p>{line}</p>{/each}</div>
|
||||
{#if ['talent','protocol-talent','skill-chip-talent'].includes(phase2OwnerPreview.family)}
|
||||
{@render mechanicBlocks(phase2OwnerPreview.currentMechanic, 'MECHANIC MODEL')}
|
||||
{:else}
|
||||
{#if phase2OwnerPreview.family === 'specialization-node'}
|
||||
<div class="proposal-rank-preview">
|
||||
{#each currentNodeRanks as rank}<section><header><span>RANK {rank.rank}</span><b>{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} STATIC EFFECTS</b></header><p>{shown(rank.description)}</p>{#each currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)) as effect}<small>{effectSummary(effect)}</small>{/each}<small>{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>{:else}<p class="proposal-structured-empty">NO RANK DEFINITIONS</p>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{@render attachmentBlock(phase2OwnerPreview.currentAttachments, phase2OwnerPreview.family === 'combat-effect' ? 'REUSABLE EFFECT FAN-OUT' : 'NODE ATTACHMENTS')}
|
||||
{/if}
|
||||
{:else}<div class="proposal-empty-state"><b>NO LIVE {phase2OwnerPreview.family.toUpperCase()} RECORD</b><span>The proposed owner state will be created on approval.</span></div>{/if}
|
||||
</article>
|
||||
<article class="proposal-editor-preview after">
|
||||
<header><span>PROPOSED · {phase2OwnerPreview.family.toUpperCase()}</span><b>AFTER</b></header>
|
||||
<div class="proposal-field-summary owner-identity-fields">{#each ownerIdentityFields('proposed', phase2OwnerPreview.family) as field}<div><span>{field.label}</span><strong>{shown(field.value)}</strong></div>{/each}</div>
|
||||
<div class="proposal-relationships"><header><span>RELATIONSHIPS</span><b>PROPOSED</b></header>{#each ownerRelationshipRows('proposed', phase2OwnerPreview.family) as line}<p>{line}</p>{/each}</div>
|
||||
{#if phase2OwnerPreview.selectionIntent}<p class="proposal-selection-intent">MODEL REQUEST · {formatMechanicToken(phase2OwnerPreview.selectionIntent)}</p>{/if}
|
||||
{#if ['talent','protocol-talent','skill-chip-talent'].includes(phase2OwnerPreview.family)}
|
||||
{@render mechanicBlocks(phase2OwnerPreview.proposedMechanic, 'MECHANIC MODEL AFTER APPROVAL')}
|
||||
{:else}
|
||||
{#if phase2OwnerPreview.family === 'specialization-node'}
|
||||
<div class="proposal-rank-preview">
|
||||
{#each proposedRanks as rank}<section><header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC EFFECTS</b></header><p>{shown(rank.description)}</p>{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>{:else}<p class="proposal-structured-empty">NO RANK DEFINITIONS</p>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{@render attachmentBlock(phase2OwnerPreview.proposedAttachments, phase2OwnerPreview.family === 'combat-effect' ? 'REUSABLE EFFECT FAN-OUT AFTER APPROVAL' : 'NODE ATTACHMENTS AFTER APPROVAL')}
|
||||
{/if}
|
||||
</article>
|
||||
</div>
|
||||
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
|
||||
<div class="proposal-compare-grid">
|
||||
<article class="proposal-editor-preview before">
|
||||
@@ -292,35 +400,6 @@
|
||||
<div class="proposal-structured-list">{#each proposedSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{/each}</div>
|
||||
</article>
|
||||
</div>
|
||||
{:else if module === 'skillchips' && action === 'save'}
|
||||
<div class="proposal-compare-grid">
|
||||
<article class="proposal-editor-preview before">
|
||||
<header><span>CURRENT LIVE · SKILL CHIP</span><b>{skillChipRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
|
||||
{#if skillChipRecord}
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
|
||||
<div><span>3-PIECE TALENT</span><strong>{currentSnapshot?.talent ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
||||
{#each conditionalRuleLines(currentSnapshot?.conditional) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="proposal-empty-state"><b>NEW SKILL CHIP</b><span>No live registry record will be replaced.</span></div>
|
||||
{/if}
|
||||
</article>
|
||||
<article class="proposal-editor-preview after">
|
||||
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
|
||||
<div><span>3-PIECE TALENT</span><strong>{proposed.hasTalent === '1' ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
||||
{#each conditionalRuleLines(jsonValue(proposed.conditionalRule)) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{:else if module === 'calculations'}
|
||||
<div class="proposal-compare-grid calculation-proposal-preview">
|
||||
<article class="proposal-editor-preview before">
|
||||
@@ -333,7 +412,7 @@
|
||||
{#if action === 'saveRevision'}<div class="proposal-rank-preview">{#each proposedCalculationStages as stage}<section><header><span>{stage.label}</span><b>{stage.operator?.replaceAll('_',' ').toUpperCase()}</b></header><p>ORDER {stage.displayOrder} · {stage.lockedPosition?.toUpperCase() ?? 'REORDERABLE'}</p></section>{/each}</div>{/if}
|
||||
</article>
|
||||
</div>
|
||||
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveCombatEffect','saveFocusAbilities','saveNode'].includes(action)}
|
||||
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities'].includes(action)}
|
||||
<div class="proposal-compare-grid specialization-proposal-preview">
|
||||
<article class="proposal-editor-preview before">
|
||||
<header><span>CURRENT LIVE · {specializationKind}</span><b>{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
|
||||
@@ -352,12 +431,6 @@
|
||||
<header><span>INTRINSIC ATTACK PROFILE</span><b>{currentAbilityAttack ? 'CONFIGURED' : 'NONE'}</b></header>
|
||||
{#each attackProfileLines(currentAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
|
||||
</div>
|
||||
{:else if action === 'saveNode'}
|
||||
<div class="proposal-rank-preview">
|
||||
{#each currentNodeRanks as rank}
|
||||
<section><header><span>RANK {rank.rank}</span><b>{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} EFFECTS</b></header><p>{rank.description}</p><small>{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if action === 'saveProgression'}
|
||||
<div class="proposal-rank-preview">
|
||||
{#each currentRankProfiles as profile}
|
||||
@@ -385,18 +458,6 @@
|
||||
<header><span>INTRINSIC ATTACK PROFILE</span><b>{attackProfileLines(proposedAbilityAttack).length ? 'CONFIGURED' : 'NONE'}</b></header>
|
||||
{#each attackProfileLines(proposedAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
|
||||
</div>
|
||||
{:else if action === 'saveNode'}
|
||||
<div class="proposal-rank-preview">
|
||||
{#each proposedRanks as rank}
|
||||
<section>
|
||||
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC · {rank.combatEffects?.length ?? 0} CONDITIONAL</b></header>
|
||||
<p>{rank.description || 'NO RANK DESCRIPTION'}</p>
|
||||
{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}
|
||||
{#each rank.combatEffects ?? [] as effect}<small>{conditionalRuleSummary(effect)}</small>{/each}
|
||||
<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if action === 'saveProgression'}
|
||||
<div class="proposal-rank-preview">
|
||||
{#each [1,2,3] as rank}
|
||||
@@ -411,19 +472,94 @@
|
||||
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
|
||||
<div class="proposal-field-summary">
|
||||
{#each entries as entry}
|
||||
{@const safeValue = entry.kind === 'text' ? safePreviewValue(entry.name, entry.value) : ''}
|
||||
{#if !isPreviewFieldHidden(entry.name)}
|
||||
<div>
|
||||
<span>{label(entry.name)}</span>
|
||||
{#if entry.kind === 'file'}
|
||||
<strong>{entry.fileName || 'UPLOADED FILE'}</strong><small>{entry.type || 'FILE EVIDENCE'}</small>
|
||||
{:else if jsonValue(entry.value)}
|
||||
<strong>{Array.isArray(jsonValue(entry.value)) ? `${jsonValue(entry.value).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'}</strong>
|
||||
<small>{entry.value}</small>
|
||||
{:else if jsonValue(safeValue)}
|
||||
<strong>{Array.isArray(jsonValue(safeValue)) ? `${jsonValue(safeValue).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'}</strong>
|
||||
<small>{safeValue}</small>
|
||||
{:else}
|
||||
<strong>{shown(entry.value)}</strong>
|
||||
<strong>{shown(safeValue)}</strong>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.phase2-owner-preview { align-items: start; }
|
||||
.owner-identity-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.proposal-relationships,
|
||||
.proposal-mechanic-section { display: grid; gap: 5px; padding: 0 8px 8px; }
|
||||
.proposal-relationships > header,
|
||||
.proposal-mechanic-section > header,
|
||||
.proposal-source-variants article > header,
|
||||
.attachment-preview article > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 29px; padding: 6px 8px; border: 1px solid var(--shd-border); background: #111415; }
|
||||
.proposal-relationships > header span,
|
||||
.proposal-relationships > header b,
|
||||
.proposal-mechanic-section > header span,
|
||||
.proposal-mechanic-section > header b,
|
||||
.proposal-source-variants article > header span,
|
||||
.proposal-source-variants article > header b,
|
||||
.attachment-preview article > header span,
|
||||
.attachment-preview article > header b { font-size: var(--shd-type-telemetry); letter-spacing: .65px; }
|
||||
.proposal-relationships > header b,
|
||||
.proposal-mechanic-section > header b,
|
||||
.proposal-source-variants article > header b,
|
||||
.attachment-preview article > header b { color: var(--shd-orange); }
|
||||
.proposal-relationships p,
|
||||
.proposal-selection-intent,
|
||||
.proposal-mechanic-section > p,
|
||||
.proposal-mechanic-section > article > p,
|
||||
.proposal-source-variants article > small,
|
||||
.attachment-preview article > p { margin: 0; padding: 6px 8px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
|
||||
.proposal-source-template { display: grid; gap: 4px; padding: 8px; border: 1px solid var(--shd-border); background: #0d0f10; }
|
||||
.proposal-source-identity { display: grid; gap: 5px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.proposal-source-identity > div { display: grid; gap: 4px; min-width: 0; padding: 7px 8px; border: 1px solid var(--shd-border); background: #0d0f10; }
|
||||
.proposal-source-identity span { color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); letter-spacing: .7px; }
|
||||
.proposal-source-identity p { margin: 0; color: var(--shd-text); font-size: var(--shd-type-supporting); line-height: 1.4; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.proposal-source-template span,
|
||||
.proposal-model-summary span,
|
||||
.proposal-projection-meta,
|
||||
.proposal-projection-variant span { color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); letter-spacing: .7px; }
|
||||
.proposal-source-template p,
|
||||
.proposal-materialized-text { margin: 0; color: var(--shd-text); font-size: var(--shd-type-body); line-height: 1.4; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.proposal-source-variants,
|
||||
.proposal-graph-summary { display: grid; gap: 5px; }
|
||||
.proposal-source-variants article { display: grid; gap: 4px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
|
||||
.proposal-source-variants article > small { background: #111415; }
|
||||
.proposal-model-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 5px; }
|
||||
.proposal-model-summary > div { display: grid; gap: 4px; min-width: 0; padding: 7px; border: 1px solid var(--shd-border); background: #0d0f10; }
|
||||
.proposal-model-summary strong { color: var(--shd-text); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
|
||||
.proposal-graph-summary > section { display: grid; gap: 4px; }
|
||||
.proposal-graph-summary > section > header { display: flex; justify-content: space-between; gap: 8px; padding: 6px 8px; border: 1px solid var(--shd-border); background: #111415; }
|
||||
.proposal-graph-summary > section > header span,
|
||||
.proposal-graph-summary > section > header b { font-size: var(--shd-type-telemetry); letter-spacing: .65px; }
|
||||
.proposal-graph-summary > section > header b { color: var(--shd-orange); }
|
||||
.proposal-graph-summary > section > p { margin: 0; padding: 6px 8px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
|
||||
.proposal-clause { display: grid; gap: 3px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
|
||||
.proposal-clause > p,
|
||||
.proposal-clause > small,
|
||||
.proposal-outcome > small { margin: 0; padding: 5px 6px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
|
||||
.proposal-clause > p { color: var(--shd-text); }
|
||||
.proposal-outcome { display: grid; gap: 3px; margin-left: 8px; }
|
||||
.proposal-projection-preview { gap: 5px; }
|
||||
.proposal-projection-meta { margin: 0; padding: 0 2px; }
|
||||
.proposal-projection-variant { display: grid; grid-template-columns: 42px 1fr; gap: 5px; margin: 0; padding: 6px; border: 1px solid var(--shd-border); background: #0d0f10; }
|
||||
.proposal-projection-variant > b { color: var(--shd-orange); font-size: var(--shd-type-telemetry); }
|
||||
.proposal-projection-variant > strong { grid-column: 1 / -1; color: var(--shd-text); font-size: var(--shd-type-supporting); line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.attachment-preview { padding-top: 0; }
|
||||
.attachment-preview > article { display: grid; gap: 5px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
|
||||
.attachment-preview > article .proposal-mechanic-section { padding-right: 0; padding-left: 0; }
|
||||
.proposal-structured-empty { margin: 0; padding: 8px; border: 1px dashed var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); text-align: center; }
|
||||
@media (max-width: 800px) {
|
||||
.owner-identity-fields, .proposal-model-summary, .proposal-source-identity { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.proposal-model-summary > div:last-child { grid-column: span 2; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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