feat: add source-owned combat mechanic programs
This commit is contained in:
@@ -105,6 +105,8 @@
|
||||
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[]);
|
||||
@@ -112,11 +114,129 @@
|
||||
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">
|
||||
@@ -168,7 +288,10 @@
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
|
||||
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
|
||||
<div><span>TALENT STRUCTURE</span><strong>{(currentSnapshot?.talentTriggers as any[])?.length ?? 0} TRIGGERS · {(currentSnapshot?.talentEffects as any[])?.length ?? 0} EFFECTS</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>
|
||||
@@ -179,7 +302,10 @@
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
|
||||
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
|
||||
<div><span>TALENT STRUCTURE</span><strong>{Array.isArray(proposed.triggerIds) ? proposed.triggerIds.length : Number(Boolean(proposed.triggerIds))} TRIGGERS · {((jsonValue(proposed.talentEffects) ?? []) as any[]).length} EFFECTS</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>
|
||||
@@ -187,11 +313,11 @@
|
||||
<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>ATTRIBUTE PROFILES</span><strong>{(currentSnapshot?.profiles 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}
|
||||
{#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>ATTRIBUTE PROFILES</span><strong>{proposedCalculationProfiles.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>
|
||||
<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>
|
||||
@@ -210,6 +336,10 @@
|
||||
<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}
|
||||
@@ -239,6 +369,10 @@
|
||||
<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}
|
||||
@@ -246,7 +380,7 @@
|
||||
<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>{effect.effectId} · {effect.triggerCount} HITS / {effect.triggerWindowSeconds}s · {effect.maxStacks} STACKS · {effect.modifiers?.length ?? 0} MODIFIERS</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}
|
||||
|
||||
Reference in New Issue
Block a user