429 lines
27 KiB
Svelte
429 lines
27 KiB
Svelte
<script lang="ts">
|
||
type Entry = { name: string; kind: 'text' | 'file'; value?: string; fileName?: string; type?: string };
|
||
|
||
let {
|
||
module,
|
||
action,
|
||
payload,
|
||
currentSnapshot = null
|
||
}: {
|
||
module: string;
|
||
action: string;
|
||
payload: string;
|
||
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 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) => {
|
||
if (typeof value !== 'string' || (!value.startsWith('[') && !value.startsWith('{'))) return null;
|
||
try { return JSON.parse(value); } catch { return null; }
|
||
};
|
||
const attributeValue = (side: 'current' | 'proposed', key: string) => {
|
||
if (side === 'current') return currentSnapshot?.[key];
|
||
const names: Record<string, string> = {
|
||
calculation_type: 'calculationType',
|
||
gear_min: 'gearMin',
|
||
gear_max: 'gearMax',
|
||
weapon_min: 'weaponMin',
|
||
weapon_max: 'weaponMax',
|
||
cap_value: 'capValue'
|
||
};
|
||
return proposed[names[key] ?? key];
|
||
};
|
||
const specializationKind = $derived(
|
||
action === 'saveProgression' ? 'PROGRESSION PROFILE'
|
||
: action === 'saveSpecialization' ? 'SPECIALIZATION'
|
||
: action === 'saveFocus' ? 'FOCUS'
|
||
: action === 'saveAbility' ? 'CLASS ABILITY'
|
||
: action === 'saveCombatEffect' ? 'SHARED COMBAT EFFECT'
|
||
: action === 'saveFocusAbilities' ? 'FOCUS ABILITY SET'
|
||
: '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'
|
||
? [
|
||
{ label: 'XP POINTS', current: specializationRecord?.xp_points, proposed: proposed.xpPoints },
|
||
{ label: 'SHD TECH CACHE POINTS', current: specializationRecord?.shd_cache_points, proposed: proposed.cachePoints }
|
||
]
|
||
: action === 'saveSpecialization'
|
||
? [
|
||
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
|
||
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
|
||
]
|
||
: action === 'saveFocus'
|
||
? [
|
||
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
|
||
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
|
||
{ label: 'PRIMARY ATTRIBUTE', current: specializationRecord?.primary_attribute, proposed: proposed.primaryAttribute },
|
||
{ label: 'DISPLAY ORDER', current: specializationRecord?.display_order, proposed: proposed.displayOrder },
|
||
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
|
||
]
|
||
: action === 'saveAbility'
|
||
? [
|
||
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
|
||
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
|
||
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
|
||
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
|
||
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
|
||
]
|
||
: action === 'saveCombatEffect'
|
||
? [
|
||
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
|
||
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
|
||
{ label: 'DESCRIPTION TEMPLATE', current: specializationRecord?.description_template, proposed: proposed.descriptionTemplate }
|
||
]
|
||
: action === 'saveNode'
|
||
? [
|
||
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
|
||
{ label: 'FOCUS', current: specializationRecord?.focus_id, proposed: proposed.focusId },
|
||
{ label: 'ROW', current: specializationRecord?.row_number, proposed: proposed.rowNumber },
|
||
{ label: 'COLUMN', current: specializationRecord?.column_number, proposed: proposed.columnNumber },
|
||
{ label: 'MAXIMUM RANK', current: specializationRecord?.max_rank, proposed: proposed.maxRank },
|
||
{ label: 'DESCRIPTION TEMPLATE', current: specializationRecord?.description_template, proposed: proposed.descriptionTemplate }
|
||
]
|
||
: [
|
||
{ label: 'FOCUS', current: specializationRecord?.name ?? specializationRecord?.id, proposed: proposed.id },
|
||
{ label: 'ABILITY SLOT 1 · TREE', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 1)?.ability_id, proposed: proposed.abilityId1 },
|
||
{ label: 'ABILITY SLOT 2 · COMBAT SKILLS', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 2)?.ability_id, proposed: proposed.abilityId2 },
|
||
{ label: 'ABILITY SLOT 3 · TREE', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 3)?.ability_id, proposed: proposed.abilityId3 }
|
||
]
|
||
);
|
||
const proposedEffects = $derived((jsonValue(proposed.effectsJson) ?? []) as any[]);
|
||
const proposedRanks = $derived((jsonValue(proposed.ranksJson) ?? []) as any[]);
|
||
const currentAbilityEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
|
||
const currentAbilityAttack = $derived((currentSnapshot?.attack ?? null) as Record<string, unknown> | null);
|
||
const proposedAbilityAttack = $derived((jsonValue(proposed.attackJson) ?? null) as Record<string, unknown> | null);
|
||
const currentNodeRanks = $derived((currentSnapshot?.ranks ?? []) as any[]);
|
||
const currentNodeEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
|
||
const currentNodeCosts = $derived((currentSnapshot?.costs ?? []) as any[]);
|
||
const currentRankProfiles = $derived((currentSnapshot?.rankProfiles ?? []) as any[]);
|
||
const calculationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||
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 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();
|
||
const baseDamage = attack.baseDamage ?? attack.base_damage;
|
||
const damageType = attack.damageType ?? attack.damage_type;
|
||
const hits = attack.hitsPerActivation ?? attack.hits_per_activation;
|
||
const castSeconds = attack.castSeconds ?? attack.cast_seconds;
|
||
const targetCap = attack.targetCap ?? attack.target_cap;
|
||
const chargeCount = attack.chargeCount ?? attack.charge_count;
|
||
const chargePolicy = attack.chargePolicy ?? attack.charge_policy;
|
||
const lines = [
|
||
`BASE DAMAGE · ${shown(baseDamage)} · ${readableToken(damageType)}`,
|
||
`DELIVERY · ${readableToken(delivery)} · ${shown(hits)} HIT${Number(hits) === 1 ? '' : 'S'} PER ACTIVATION · TARGET CAP ${shown(targetCap)}`,
|
||
`TIMING · ${shown(castSeconds)}s CAST`,
|
||
`CHARGES · ${shown(chargeCount)} · ${readableToken(chargePolicy)}`
|
||
];
|
||
if (delivery === 'periodic') {
|
||
const interval = attack.tickIntervalSeconds ?? attack.tick_interval_seconds;
|
||
const duration = attack.damageDurationSeconds ?? attack.damage_duration_seconds;
|
||
lines.splice(3, 0, `PERIODIC WINDOW · EVERY ${shown(interval)}s FOR ${shown(duration)}s`);
|
||
}
|
||
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>
|
||
|
||
<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>
|
||
<b>{currentSnapshot ? 'BEFORE / AFTER' : 'NEW RECORD'}</b>
|
||
</div>
|
||
|
||
{#if module === 'attributes' && action === 'save'}
|
||
<div class="proposal-compare-grid">
|
||
<article class="proposal-editor-preview before">
|
||
<header><span>CURRENT LIVE</span><b>{currentSnapshot ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
|
||
{#if currentSnapshot}
|
||
<div class="proposal-attribute-grid">
|
||
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('current', 'name'))}</strong></div>
|
||
<div class="preview-field description"><span>DESCRIPTION · OPTIONAL</span><strong>{shown(attributeValue('current', 'description'))}</strong></div>
|
||
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
|
||
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
|
||
{/each}
|
||
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
|
||
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('current', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('current', `${source[0]}_max`))}</strong></div></div>
|
||
{/each}
|
||
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('current', 'cap_value'))}</strong></div>
|
||
</div>
|
||
{:else}
|
||
<div class="proposal-empty-state"><b>NEW ATTRIBUTE DEFINITION</b><span>No live registry record will be replaced.</span></div>
|
||
{/if}
|
||
</article>
|
||
<article class="proposal-editor-preview after">
|
||
<header><span>PROPOSED DEFINITION</span><b>AFTER</b></header>
|
||
<div class="proposal-attribute-grid">
|
||
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('proposed', 'name'))}</strong></div>
|
||
<div class="preview-field description"><span>DESCRIPTION · OPTIONAL</span><strong>{shown(attributeValue('proposed', 'description'))}</strong></div>
|
||
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
|
||
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
|
||
{/each}
|
||
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
|
||
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('proposed', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('proposed', `${source[0]}_max`))}</strong></div></div>
|
||
{/each}
|
||
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
|
||
<div class="proposal-compare-grid">
|
||
<article class="proposal-editor-preview before">
|
||
<header><span>CURRENT LIVE · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>BEFORE</b></header>
|
||
<div class="proposal-structured-list">{#each currentSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{:else}<p>SHARED PROFILE NOT CONFIGURED</p>{/each}</div>
|
||
</article>
|
||
<article class="proposal-editor-preview after">
|
||
<header><span>PROPOSED · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>AFTER</b></header>
|
||
<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">
|
||
<header><span>CURRENT LIVE · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>{calculationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
|
||
{#if calculationRecord}<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(calculationRecord.name)}</strong></div><div><span>STATE</span><strong>{shown(calculationRecord.status)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{(currentSnapshot?.stages as any[])?.length ?? 0}</strong></div><div><span>WEAPON PROFILES</span><strong>{(currentSnapshot?.profiles as any[])?.length ?? 0}</strong></div><div><span>SKILL PROFILES</span><strong>{(currentSnapshot?.skillProfiles as any[])?.length ?? 0}</strong></div>{/if}</div>{:else}<div class="proposal-empty-state"><b>NEW {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</b><span>No live registry record will be replaced.</span></div>{/if}
|
||
</article>
|
||
<article class="proposal-editor-preview after">
|
||
<header><span>PROPOSED · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>AFTER</b></header>
|
||
<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(proposed.name)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{proposedCalculationStages.length}</strong></div><div><span>WEAPON PROFILES</span><strong>{proposedCalculationProfiles.length}</strong></div><div><span>SKILL PROFILES</span><strong>{proposedSkillCalculationProfiles.length}</strong></div><div><span>BASE REVISION</span><strong>{shown(proposed.basedOnRevisionId)}</strong></div>{:else}<div><span>STATE</span><strong>{shown(proposed.status)}</strong></div><div><span>MEASURED WEAPON</span><strong>{proposed.selectedWeapon === '1' ? 'SECONDARY' : 'PRIMARY'}</strong></div><div><span>CORE OBSERVATIONS</span><strong>{['bodyHit','criticalBodyHit','headshot','criticalHeadshot','magazineSeconds','reloadSeconds'].filter((field) => proposed[field]).length} / 6</strong></div>{/if}</div>
|
||
{#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)}
|
||
<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>
|
||
{#if specializationRecord}
|
||
<div class="proposal-field-summary specialization-fields">
|
||
{#each specializationFields as field}
|
||
<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>
|
||
{/each}
|
||
</div>
|
||
{#if action === 'saveAbility'}
|
||
<div class="proposal-structured-list">
|
||
<header><span>STRUCTURED EFFECTS</span><b>{currentAbilityEffects.length}</b></header>
|
||
{#each currentAbilityEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
|
||
</div>
|
||
<div class="proposal-structured-list">
|
||
<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}
|
||
<section><header><span>RANK {profile.rank}</span><b>{profile.point_cost} POINTS</b></header><p>{Number(profile.credit_cost).toLocaleString()} CREDITS</p></section>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{:else}
|
||
<div class="proposal-empty-state"><b>NEW {specializationKind}</b><span>No live registry record will be replaced.</span></div>
|
||
{/if}
|
||
</article>
|
||
<article class="proposal-editor-preview after">
|
||
<header><span>PROPOSED · {specializationKind}</span><b>AFTER</b></header>
|
||
<div class="proposal-field-summary specialization-fields">
|
||
{#each specializationFields as field}
|
||
<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>
|
||
{/each}
|
||
</div>
|
||
{#if action === 'saveAbility'}
|
||
<div class="proposal-structured-list">
|
||
<header><span>STRUCTURED EFFECTS</span><b>{proposedEffects.length}</b></header>
|
||
{#each proposedEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
|
||
</div>
|
||
<div class="proposal-structured-list">
|
||
<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}
|
||
<section><header><span>RANK {rank}</span><b>{shown(proposed[`rank${rank}PointCost`])} POINTS</b></header><p>{Number(proposed[`rank${rank}CreditCost`] || 0).toLocaleString()} CREDITS</p></section>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</article>
|
||
</div>
|
||
{:else}
|
||
<div class="proposal-editor-preview generic after">
|
||
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
|
||
<div class="proposal-field-summary">
|
||
{#each entries as entry}
|
||
<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}
|
||
<strong>{shown(entry.value)}</strong>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</section>
|