Adding skills & isac talent updates

This commit is contained in:
jb
2026-07-29 20:56:22 -04:00
parent f1b473ffa8
commit 65a4987cad
68 changed files with 5486 additions and 569 deletions
@@ -15,6 +15,7 @@ import {
} from '$lib/server/ai/calculation-authoring-schema';
import { generateAiCalculationDraft } from '$lib/server/ai/calculation-authoring-service';
import { AI_CALCULATION_PROMPT_VERSION } from '$lib/server/ai/calculation-authoring-prompt';
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
@@ -26,7 +27,7 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
'ai.calculation.generate'
);
const origin = request.headers.get('origin');
if (origin && origin !== url.origin) {
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json(
{ message: 'Cross-origin calculation authoring requests are not allowed.' },
{ status: 403 }
@@ -15,6 +15,7 @@ import {
AiMechanicRateLimitError,
reserveAiMechanicGeneration
} from '$lib/server/ai/mechanic-authoring-rate-limit';
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
@@ -26,7 +27,7 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
'ai.mechanic.generate'
);
const origin = request.headers.get('origin');
if (origin && origin !== url.origin) {
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json(
{ message: 'Cross-origin mechanic authoring requests are not allowed.' },
{ status: 403 }
@@ -5,6 +5,7 @@ import {
parseMechanicGapReport,
submitMechanicGapReport
} from '$lib/server/ai/mechanic-gap-report';
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
@@ -16,7 +17,7 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
'ai.mechanic.generate'
);
const origin = request.headers.get('origin');
if (origin && origin !== url.origin) {
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json({ message: 'Cross-origin mechanic gap reports are not allowed.' }, { status: 403 });
}
const contentLength = Number(request.headers.get('content-length') ?? 0);
+12 -7
View File
@@ -413,7 +413,8 @@
summary: selectedFocus
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES',
iconUrl: selectedFocus?.specialization.iconUrl
iconUrl: selectedFocus?.specialization.iconUrl,
issue: !selectedFocus ? 'REQUIRED FOR CALCULATION' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined
});
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
const ability = selectedFocus?.abilities[index];
@@ -425,7 +426,8 @@
name: ability?.name ?? 'NOT ASSIGNED',
meta: ability?.attack?.damage_type ?? '',
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
iconUrl: ability?.iconUrl
iconUrl: ability?.iconUrl,
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : undefined
};
}));
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
@@ -438,7 +440,8 @@
name: item?.name ?? 'EMPTY SLOT',
meta: item?.category ?? '',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON',
iconUrl: weaponIcon(item?.category)
iconUrl: weaponIcon(item?.category),
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
};
}));
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
@@ -455,7 +458,8 @@
name: item?.name ?? 'EMPTY SLOT',
meta: item ? `T${item.tier}` : '',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR',
iconUrl: gearIcon(slot)
iconUrl: gearIcon(slot),
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
};
}));
const protocolCell = $derived<MatrixCell>({
@@ -466,7 +470,8 @@
summary: protocolItem()
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
: 'SELECT ONE PROTOCOL',
iconUrl: protocolItem()?.iconUrl
iconUrl: protocolItem()?.iconUrl,
issue: protocolItem() ? undefined : 'REQUIRED FOR CALCULATION'
});
const loadoutConfigured = $derived(Boolean(
selectedFocus
@@ -764,8 +769,8 @@
<aside class="loadout-summary">
<div class="summary-head"><div><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2></div><b>{summary.stats.length}</b></div>
<div class="summary-core">{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}<div><GameIcon name={name.toLowerCase() as GameIconName} decorative/><span>{name}<b>{stat ? unitValue(stat.value,stat.id) : '0'}</b></span></div>{/each}</div>
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result}/>{/each}</div>
<div class="builder-combat-results"><SkillCalculationResult result={skillCombatResult} abilityNames={skillAbilityNames}/></div>
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result} resolverHint="Use the warning markers in the loadout matrix."/>{/each}</div>
<div class="builder-combat-results"><SkillCalculationResult result={skillCombatResult} abilityNames={skillAbilityNames} resolverHint="Open the marked doctrine or ability cell."/></div>
<div class="summary-scroll">
<div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div>
<div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div>
@@ -1,7 +1,10 @@
<script lang="ts">
import PseudoScript from '$lib/components/PseudoScript.svelte';
import { TRIGGER_ACTION_REGISTRY } from '$lib/calculations/conditional';
import { MECHANIC_EVENT_REGISTRY } from '$lib/calculations/mechanic';
import {
MECHANIC_COMBAT_REFERENCE_REGISTRY,
MECHANIC_EVENT_REGISTRY
} from '$lib/calculations/mechanic';
type AttributeVariable = {
id: string;
@@ -57,13 +60,14 @@
{ token: 'COOLDOWN', meaning: 'Blocks that branch from activating again for the configured duration.' },
{ token: 'WEIGHT', meaning: 'Applies a scenario-dependent contribution to a branch.' },
{ token: 'ADD / SET / CONSUME / CLEAR / TRANSFER', meaning: 'Changes a named state pool.' },
{ token: 'APPLY', meaning: 'Emits an Attribute modifier, damage, restoration, resource cost, or marker.' }
{ token: 'APPLY / RELOAD', meaning: 'Emits an Attribute modifier, damage, restoration, resource cost, weapon action, or marker.' }
];
const valueSources = [
{ syntax: 'EVENT.<path>', use: 'A value carried by the current event, such as target distance or critical state.' },
{ syntax: 'SCENARIO.<key>', use: 'A builder assumption supplied by Maximum Potential or Custom Scenario mode.' },
{ syntax: 'COMBAT.<path>', use: 'The current calculated combat state, including current or maximum Health.' },
{ syntax: 'COMBAT.currentHealth', use: MECHANIC_COMBAT_REFERENCE_REGISTRY.currentHealth.description },
{ syntax: 'COMBAT.currentHealthPercent', use: `${MECHANIC_COMBAT_REFERENCE_REGISTRY.currentHealthPercent.description} “Below” conditions use a strict < comparison.` },
{ syntax: 'PRE-EVENT COMBAT.<path>', use: 'Combat state captured before the current branch changes it.' },
{ syntax: 'ATTRIBUTE[<id>]', use: 'The authoritative loadout total for a stable Attribute ID. Percentage Attributes use their displayed percentage value.' },
{ syntax: 'EQUIPMENT.<path>', use: 'A value from the equipped item, including OS Protocol talent upgrades.' },
@@ -108,6 +112,7 @@
{ token: 'DIRECT DAMAGE', detail: 'Deals damage from a flat value or a configured percentage basis.' },
{ token: 'RESTORATION', detail: 'Restores Health from a flat value or Health basis.' },
{ token: 'RESOURCE COST', detail: 'Consumes Health or another modeled resource in branch order.' },
{ token: 'WEAPON RELOAD', detail: 'Reloads the inactive weapon after the configured delay. A new activation refreshes a pending reload timer.' },
{ token: 'MARKER', detail: 'Emits a typed state marker without changing a numeric Attribute.' }
];
+117 -36
View File
@@ -20,7 +20,7 @@ import {
numberedTalentOutcomes
} from '$lib/calculations/talent-description';
type SharedEffect = { slot: number; statId?: string; valueI: number; valueII: number };
type SharedEffect = { slot: number; statId?: string; valueI: number; valueII?: number };
type TalentErrorArea = 'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes';
type TalentValidationError = { area: TalentErrorArea; message: string };
@@ -70,7 +70,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
FROM talents`)).get(),
getPendingEntities(user.id, [
{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category', 'descriptionTemplate', 'conditionalRule'] }
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category', 'descriptionTemplate', 'conditionalRule', 'tierIIDocumented'] }
])
]);
const pendingStats = pending.filter((item) => item.kind === 'stat');
@@ -79,7 +79,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: filter === 'slot2' ? item.category === 'Weapon Talent 2'
: filter === 'backpack' ? item.category === 'Backpack'
: filter === 'body-armor' ? item.category === 'Body Armor'
: filter === 'exotic' ? false : true
: filter === 'exotic' ? item.category === 'Exotic Weapon' : true
).filter((item) =>
!search || String(item.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())
).map((item) => ({
@@ -92,9 +92,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
conditionalRule: item.conditionalRule ?? null,
ownerWeaponId: null,
ownerWeaponName: null,
tiers: ['I', 'II'].map((tier) => ({ tier, description: String(item.descriptionTemplate || 'PENDING TALENT CONFIGURATION') }))
tierIIDocumented: item.category !== 'Exotic Weapon' || String(item.tierIIDocumented ?? '') === '1',
tiers: (item.category === 'Exotic Weapon' && String(item.tierIIDocumented ?? '') !== '1' ? ['I'] : ['I', 'II'])
.map((tier) => ({ tier, description: String(item.descriptionTemplate || 'PENDING TALENT CONFIGURATION') }))
}));
const configuredTalents = await Promise.all(talents.map(async (talent) => ({
const configuredTalents = await Promise.all(talents.map(async (talent) => {
const talentTiers = tiers.filter((tier) => tier.talent_id === talent.id);
const weaponScope = weaponScopes.find((scope) => scope.talent_id === talent.id)?.scope;
const isExoticTalent = talent.category === 'Weapon' && weaponScope === 'Exotic';
const tierIIDocumented = !isExoticTalent || talentTiers.some((tier) => tier.tier === 'II');
return {
...talent,
iconUrl: (() => { const icon = icons.find((item) => item.entity_id === talent.id); return icon ? gameDataIconUrl('talent', String(talent.id), icon.updated_at) : ''; })(),
category: talent.category === 'Vest'
@@ -109,14 +116,21 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
? 'Weapon · Slot Unassigned'
: talent.category,
descriptionTemplate: templates.find((item) => item.talent_id === talent.id)?.template ?? '',
conditionalRule: await loadConditionalRuleDraft(db, 'talent', String(talent.id), ['I', 'II']),
conditionalRule: await loadConditionalRuleDraft(
db,
'talent',
String(talent.id),
tierIIDocumented ? ['I', 'II'] : ['I']
),
ownerWeaponId: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.id ?? null,
ownerWeaponName: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.name ?? null,
tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({
tierIIDocumented,
tiers: talentTiers.map((tier) => ({
...tier,
effects: effects.filter((effect) => effect.talent_tier_id === tier.id)
}))
})));
};
}));
return {
talents: [...pendingTalents, ...configuredTalents],
stats: [...pendingStats, ...stats],
@@ -161,7 +175,7 @@ const renderDescription = (
const effect = effects.find((item) => item.slot === Number(expression));
return effect
? displayValue(
tier === 'I' ? effect.valueI : effect.valueII,
tier === 'I' ? effect.valueI : Number(effect.valueII),
effect.statId ? units.get(effect.statId) : undefined
)
: placeholder;
@@ -186,16 +200,20 @@ export const actions: Actions = {
throw error;
}
const name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
const isExotic = category === 'Exotic Weapon';
const tierIIDocumented = !isExotic || String(form.get('tierIIDocumented') ?? '') === '1';
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
const db = await getDatabase();
const conditionalRuleRaw = form.get('conditionalRule');
let conditionalRule;
try {
const activeStats = await (await db.prepare('SELECT id FROM stat_attributes WHERE active=1')).all();
conditionalRule = parseConditionalRulePayload(conditionalRuleRaw, [
{ key: 'tierI', variantKey: 'I' },
{ key: 'tierII', variantKey: 'II' }
], {
conditionalRule = parseConditionalRulePayload(conditionalRuleRaw, tierIIDocumented
? [
{ key: 'tierI', variantKey: 'I' },
{ key: 'tierII', variantKey: 'II' }
]
: [{ key: 'tierI', variantKey: 'I' }], {
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY))
});
@@ -206,32 +224,57 @@ export const actions: Actions = {
}]);
}
const cooldownSeconds = conditionalRule.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null;
const durationSeconds = conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule.pools.length
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
const durationForTier = (tier: 'I' | 'II') => {
const pool = conditionalRule.pools.find((item) =>
item.expiresAfterSeconds !== undefined
|| item.expiresAfterSecondsVariants?.[tier] !== undefined);
return pool?.expiresAfterSecondsVariants?.[tier] ?? pool?.expiresAfterSeconds ?? null;
};
const durationSeconds = durationForTier('I');
const stackPools = conditionalRule.pools.filter((pool) => pool.stateKind === 'stack');
const maxStacks = stackPools.length
? Math.max(...stackPools.map((pool) => pool.max ?? 1))
: null;
const descriptionOutcomes = numberedTalentOutcomes(conditionalRule);
const sharedEffects: SharedEffect[] = descriptionOutcomes
.map((effect) => ({
slot: Number(effect.descriptionSlot),
...(effect.kind === 'attribute_modifier' && effect.attributeId ? { statId: effect.attributeId } : {}),
valueI: effect.variants.I.value,
valueII: effect.variants.II.value
}));
slot: Number(effect.descriptionSlot),
...(effect.attributeId ? { statId: effect.attributeId } : {}),
valueI: effect.variants.I.value,
...(tierIIDocumented ? { valueII: effect.variants.II.value } : {})
}));
const validationErrors: TalentValidationError[] = [];
const requiresCompleteMechanic = conditionalRule.simulationReadiness !== 'captured_only';
if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) {
validationErrors.push({ area: 'identity', message: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' });
const requiresCompleteMechanic = isExotic || conditionalRule.simulationReadiness !== 'captured_only';
if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Exotic Weapon', 'Backpack', 'Body Armor'].includes(category)) {
validationErrors.push({ area: 'identity', message: 'Provide a valid talent name and category.' });
}
if (!descriptionTemplate) validationErrors.push({ area: 'description', message: 'Enter a shared description template.' });
if (requiresCompleteMechanic && !conditionalRule.clauses.length) {
validationErrors.push({ area: 'mechanic', message: 'Add at least one mechanic branch or mark the talent CAPTURE ONLY.' });
validationErrors.push({
area: 'mechanic',
message: isExotic
? 'Exotic talents require at least one modeled Tier I mechanic branch.'
: 'Add at least one mechanic branch or mark the talent CAPTURE ONLY.'
});
}
if (requiresCompleteMechanic && !sharedEffects.length) {
validationErrors.push({ area: 'outcomes', message: 'Add at least one numbered value outcome or mark the talent CAPTURE ONLY.' });
validationErrors.push({
area: 'outcomes',
message: isExotic
? 'Exotic talents require at least one numbered Tier I value outcome.'
: 'Add at least one numbered value outcome or mark the talent CAPTURE ONLY.'
});
}
if (sharedEffects.some((effect) => !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) {
validationErrors.push({ area: 'outcomes', message: 'Every numbered outcome needs valid Tier I and Tier II values.' });
if (sharedEffects.some((effect) =>
!Number.isFinite(effect.valueI)
|| (tierIIDocumented && !Number.isFinite(effect.valueII))
)) {
validationErrors.push({
area: 'outcomes',
message: tierIIDocumented
? 'Every numbered outcome needs valid Tier I and Tier II values.'
: 'Every numbered outcome needs a valid Tier I value.'
});
}
const missingSlots = missingTalentDescriptionSlots(descriptionTemplate, descriptionOutcomes);
if (requiresCompleteMechanic && missingSlots.length) {
@@ -241,7 +284,11 @@ export const actions: Actions = {
});
validationErrors.push({ area: 'outcomes', message: 'Add or renumber outcomes to match every numbered description placeholder.' });
}
if (requiresCompleteMechanic && descriptionTemplate.includes('{duration}') && durationSeconds === null) {
if (
requiresCompleteMechanic
&& descriptionTemplate.includes('{duration}')
&& (durationForTier('I') === null || (tierIIDocumented && durationForTier('II') === null))
) {
validationErrors.push({ area: 'description', message: 'The description uses {duration}, but no duration is defined.' });
validationErrors.push({ area: 'mechanic', message: 'Define a duration for the {duration} description placeholder.' });
}
@@ -249,8 +296,21 @@ export const actions: Actions = {
validationErrors.push({ area: 'description', message: 'The description uses {max_stacks}, but no maximum stack count is defined.' });
validationErrors.push({ area: 'mechanic', message: 'Define a pool maximum for the {max_stacks} description placeholder.' });
}
const ownerWeapon = id
? await (await db.prepare('SELECT id,name FROM weapons WHERE exotic_talent_id=?')).get(id)
: null;
if (ownerWeapon && !isExotic) {
validationErrors.push({
area: 'identity',
message: `This talent is owned by ${String(ownerWeapon.name)} and must remain an Exotic Weapon talent.`
});
}
if (validationErrors.length) return reject(400, validationErrors);
const storedCategory = category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon Talent') ? 'Weapon' : category;
const storedCategory = category === 'Body Armor'
? 'Vest'
: category.startsWith('Weapon Talent') || isExotic
? 'Weapon'
: category;
if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) {
return reject(409, [{ area: 'identity', message: 'That talent already exists in this category.' }]);
}
@@ -270,6 +330,9 @@ export const actions: Actions = {
icon: await gameDataIconMetadata(db, 'talent', id),
template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id),
tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id),
weaponScope: await (await db.prepare('SELECT scope FROM talent_weapon_scopes WHERE talent_id=?')).get(id),
weaponSlot: await (await db.prepare('SELECT slot FROM weapon_talent_slots WHERE talent_id=?')).get(id),
ownerWeapon,
conditional: await conditionalRuleSnapshot(db, 'talent', id)
} : null;
await db.exec('BEGIN');
@@ -279,25 +342,38 @@ export const actions: Actions = {
if (storedCategory === 'Weapon') {
await (await db.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`))
.run(talentId, 'Standard');
await (await db.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`))
.run(talentId, category === 'Weapon Talent 1' ? 1 : 2);
.run(talentId, isExotic ? 'Exotic' : 'Standard');
if (isExotic) {
await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId);
} else {
await (await db.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`))
.run(talentId, category === 'Weapon Talent 1' ? 1 : 2);
}
} else {
await (await db.prepare('DELETE FROM talent_weapon_scopes WHERE talent_id=?')).run(talentId);
await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId);
}
await (await db.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?)
ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(talentId, descriptionTemplate, now);
for (const tier of ['I', 'II'] as const) {
const description = renderDescription(descriptionTemplate, sharedEffects, units, tier, durationSeconds, maxStacks);
if (!tierIIDocumented) {
await (await db.prepare("DELETE FROM talent_tiers WHERE talent_id=? AND tier='II'")).run(talentId);
}
for (const tier of (tierIIDocumented ? ['I', 'II'] : ['I']) as Array<'I' | 'II'>) {
const description = renderDescription(descriptionTemplate, sharedEffects, units, tier, durationForTier(tier), maxStacks);
await (await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)).run(randomUUID(), talentId, tier, description, '', '', now, now);
const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier);
const tierId = String(tierRow?.id);
await (await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
const storedLegacyAttributes = new Set<string>();
for (const outcome of descriptionOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) continue;
// Conditional mechanics can apply the same attribute in several
// mutually exclusive branches. The legacy projection has one row
// per attribute and is not the runtime source for modeled talents.
if (storedLegacyAttributes.has(outcome.attributeId)) continue;
storedLegacyAttributes.add(outcome.attributeId);
await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(
randomUUID(),
tierId,
@@ -319,6 +395,11 @@ export const actions: Actions = {
record: await auditRecord('talents', talentId),
icon: await gameDataIconMetadata(db, 'talent', talentId),
category,
tierIIDocumented,
weaponScope: await (await db.prepare('SELECT scope FROM talent_weapon_scopes WHERE talent_id=?')).get(talentId),
weaponSlot: await (await db.prepare('SELECT slot FROM weapon_talent_slots WHERE talent_id=?')).get(talentId),
ownerWeapon: await (await db.prepare('SELECT id,name FROM weapons WHERE exotic_talent_id=?')).get(talentId),
tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(talentId),
descriptionTemplate,
conditional: await conditionalRuleSnapshot(db, 'talent', talentId)
}
+67 -9
View File
@@ -11,7 +11,11 @@
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { tick } from 'svelte';
let { data, form } = $props();
const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value }));
const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Exotic Weapon', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value }));
const tierIIDocumentationOptions = [
{ value: 'documented', label: 'DOCUMENTED' },
{ value: 'not_documented', label: 'NOT YET DOCUMENTED' }
];
let talent = $state<any>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
@@ -44,7 +48,7 @@
pools: [],
clauses: []
});
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', conditionalRule: blankRule(), modelingOpen: false });
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', tierIIDocumented: true, tierIIDocumentation: 'documented', conditionalRule: blankRule(), modelingOpen: false });
const editTalent = (row: any) => {
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
const one = tier('I'), two = tier('II');
@@ -53,10 +57,51 @@
descriptionTemplate: row.descriptionTemplate || one.description,
tierIDescription: one.description,
tierIIDescription: two.description,
tierIIDocumented: row.category !== 'Exotic Weapon' || Boolean(row.tierIIDocumented),
tierIIDocumentation: row.category === 'Exotic Weapon' && !row.tierIIDocumented ? 'not_documented' : 'documented',
conditionalRule: row.conditionalRule ?? blankRule(),
modelingOpen: Boolean(row.conditionalRule?.clauses?.length)
};
};
const setTierIIDocumented = (documented: boolean) => {
if (!talent) return;
talent.tierIIDocumented = documented;
talent.tierIIDocumentation = documented ? 'documented' : 'not_documented';
talent.conditionalRule = {
...talent.conditionalRule,
pools: talent.conditionalRule.pools.map((pool: any) => {
if (!pool.expiresAfterSecondsVariants) return pool;
const tierI = pool.expiresAfterSecondsVariants.I;
return {
...pool,
expiresAfterSecondsVariants: documented
? { ...pool.expiresAfterSecondsVariants, I: tierI, II: pool.expiresAfterSecondsVariants.II ?? tierI }
: { I: tierI }
};
}),
clauses: talent.conditionalRule.clauses.map((clause: any) => ({
...clause,
outcomes: clause.outcomes.map((outcome: any) => ({
...outcome,
variants: documented
? {
...outcome.variants,
I: outcome.variants.I,
II: structuredClone(outcome.variants.II ?? outcome.variants.I ?? { value: 0 })
}
: { I: outcome.variants.I }
}))
}))
};
};
const setCategory = (category: string) => {
if (!talent) return;
talent.category = category;
if (category !== 'Exotic Weapon' && !talent.tierIIDocumented) setTierIIDocumented(true);
};
const valueColumns = () => talent?.category === 'Exotic Weapon' && !talent.tierIIDocumented
? [{ key: 'tierI', variantKey: 'I', label: 'TIER I' }]
: [{ key: 'tierI', variantKey: 'I', label: 'TIER I' }, { key: 'tierII', variantKey: 'II', label: 'TIER II' }];
const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name);
const tierDescription = (tiers: Array<{ tier: unknown; description?: unknown }>, name: string) => {
const description = tiers.find((item) => item.tier === name)?.description;
@@ -70,16 +115,22 @@
};
const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`;
const cooldownLine = (value: unknown) => value === null || value === undefined || value === '' ? '' : `Talent Cooldown: ${mechanicValue(value as string | number, 'second')}`;
const preview = (tier: 'I' | 'II') => talent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = talent.conditionalRule.pools.length ? Math.max(...talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
const preview = (tier: 'I' | 'II') => {
if (tier === 'II' && talent.category === 'Exotic Weapon' && !talent.tierIIDocumented) return 'NOT YET DOCUMENTED';
return talent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const durationPool = talent.conditionalRule.pools.find((pool: any) =>
pool.expiresAfterSeconds !== undefined || pool.expiresAfterSecondsVariants?.[tier] !== undefined);
const duration = durationPool?.expiresAfterSecondsVariants?.[tier] ?? durationPool?.expiresAfterSeconds;
const stackPools = talent.conditionalRule.pools.filter((pool: any) => pool.stateKind === 'stack');
const maxStacks = stackPools.length ? Math.max(...stackPools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack');
const effect = talent.conditionalRule.clauses
.flatMap((clause: any) => clause.outcomes)
.find((outcome: any) => outcome.descriptionSlot === Number(expression));
return effect ? displayValue(effect, tier) : placeholder;
});
});
};
$effect(() => {
if (!form?.resultToken || form.resultToken === handledResultToken) return;
handledResultToken = form.resultToken;
@@ -103,6 +154,7 @@
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="conditionalRule" value={JSON.stringify(talent.conditionalRule)}/>
<input type="hidden" name="tierIIDocumented" value={talent.tierIIDocumented ? '1' : '0'}/>
{#if talentErrors.length}
<div class="validation-summary" role="alert" tabindex="-1" bind:this={validationSummary}>
<strong>CHECK {new Set(talentErrors.map((error) => error.area)).size} HIGHLIGHTED {new Set(talentErrors.map((error) => error.area)).size === 1 ? 'AREA' : 'AREAS'}</strong>
@@ -120,10 +172,16 @@
</div>
<div id="talent-identity" class="talent-identity" class:validation-area-error={hasErrors(['identity'])}>
<label class="field">NAME<input name="name" bind:value={talent.name} required aria-invalid={hasErrors(['identity']) ? 'true' : undefined}/></label>
<SegmentedChoice legend="CATEGORY / SLOT" name="category" options={categoryOptions} bind:value={talent.category} required/>
<SegmentedChoice legend="CATEGORY / SLOT" name="category" options={categoryOptions} bind:value={talent.category} onchange={(values) => setCategory(values[0])} required/>
{#each errorsFor('identity') as error}<small class="field-error">{error.message}</small>{/each}
</div>
</div>
{#if talent.category === 'Exotic Weapon'}
<div class="form-grid">
<SegmentedChoice legend="TIER II DOCUMENTATION" name="tierIIDocumentation" options={tierIIDocumentationOptions} bind:value={talent.tierIIDocumentation} onchange={(values) => setTierIIDocumented(values[0] === 'documented')} required/>
<p class="console-note">{talent.tierIIDocumented ? 'Tier I and Tier II values will be modeled and saved.' : 'Tier I is required. Tier II will remain absent until its game values are documented.'}</p>
</div>
{/if}
<label id="talent-description" class="field template-field" class:validation-area-error={hasErrors(['description'])}>SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required aria-invalid={hasErrors(['description']) ? 'true' : undefined}></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small>{#each errorsFor('description') as error}<small class="field-error">{error.message}</small>{/each}</label>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
<details id="talent-mechanic" class="modeling-disclosure" class:validation-area-error={hasErrors(['mechanic', 'outcomes'])} bind:open={talent.modelingOpen}>
@@ -134,7 +192,7 @@
<div class="modeling-content">
<MechanicProgramEditor
attributes={data.stats}
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
valueColumns={valueColumns()}
idPrefix={`talent-${talent.id || 'new'}`}
aiContext={{
sourceKind: 'talent',
@@ -157,4 +215,4 @@
{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : item.conditionalRule?.clauses?.length ? 'MODELED' : 'CONFIG REQUIRED'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{item.category === 'Exotic Weapon' && !item.tierIIDocumented ? 'NOT YET DOCUMENTED' : tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : item.conditionalRule?.clauses?.length ? 'MODELED' : 'CONFIG REQUIRED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
+50 -161
View File
@@ -6,15 +6,8 @@ import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
import { normalizeWeaponModSlotPayload, validateWeaponModSlots, WEAPON_MOD_SLOT_TYPES } from '$lib/weapons/mod-slots';
@@ -22,26 +15,6 @@ const categories: readonly string[] = WEAPON_CATEGORIES;
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const rangeNames = ['damage'] as const;
const modSlotTypes = WEAPON_MOD_SLOT_TYPES;
type ExoticEffect = { statId: string | null; valueI: number; valueII: number };
const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`;
const renderTalentDescription = (template: string, effects: ExoticEffect[], units: Map<string, unknown>, tier: 'I' | 'II', duration: number | null, maxStacks: number | null) =>
template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder, expression) => {
if (expression === 'duration') return duration === null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks === null ? placeholder : mechanicValue(maxStacks, 'stack');
const effect = effects[Number(expression) - 1];
if (!effect) return placeholder;
const value = tier === 'I' ? effect.valueI : effect.valueII;
const unitValue = effect.statId ? units.get(effect.statId) : '';
const unit = unitValue === 'flat' ? '' : String(unitValue ?? '');
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
});
const mechanicOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
rule.clauses.flatMap((clause) => clause.outcomes);
const descriptionOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot)
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
async function bonusProfileSnapshot(category: string) {
const database = await getDatabase();
@@ -87,7 +60,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])];
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, categoryBonuses, talent1Options, talent2Options, stats, exoticTemplates, talentIcons, pending] = await Promise.all([
const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, categoryBonuses, talent1Options, talent2Options, exoticTalentOptions, stats, talentIcons, pending] = await Promise.all([
(await database.prepare(`SELECT * FROM weapons ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(),
@@ -107,15 +80,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id
WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' AND weapon_talent_slots.slot=2
ORDER BY talents.name COLLATE NOCASE`)).all(),
(await database.prepare(`SELECT talents.id,talents.name,weapons.id AS owner_weapon_id,weapons.name AS owner_weapon_name,
EXISTS(SELECT 1 FROM talent_tiers WHERE talent_tiers.talent_id=talents.id AND talent_tiers.tier='II') AS tier_ii_documented
FROM talents
JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id
LEFT JOIN weapons ON weapons.exotic_talent_id=talents.id
WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Exotic'
ORDER BY talents.name COLLATE NOCASE`)).all(),
(await database.prepare("SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE")).all(),
(await database.prepare(`SELECT talent_description_templates.talent_id,talent_description_templates.template,talents.name,
talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks
FROM talent_description_templates JOIN talents ON talents.id=talent_description_templates.talent_id`)).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(),
getPendingEntities(user.id, [
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] },
{ module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids', 'modSlots', 'exoticTalentName', 'exoticDescriptionTemplate', 'exoticConditionalRule'] }
{ module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids', 'modSlots', 'exoticTalentId'] }
])
]);
const pendingTalents = pending.filter((item) => item.kind === 'talent');
@@ -141,13 +118,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
native_tier: Number(weapon.nativeTier),
damage_type: weapon.damageType,
is_exotic: weapon.isExotic ? 1 : 0,
exotic_talent_id: String(weapon.exoticTalentId ?? ''),
reload_speed: weapon.reloadSpeed,
tiers: [],
modSlots: (() => { try { return JSON.parse(String(weapon.modSlots ?? '[]')); } catch { return []; } })(),
talent1Ids: Array.isArray(weapon.talent1Ids) ? weapon.talent1Ids : [weapon.talent1Ids].filter(Boolean),
talent2Ids: Array.isArray(weapon.talent2Ids) ? weapon.talent2Ids : [weapon.talent2Ids].filter(Boolean),
augmentEffects: [],
exoticTalent: null
augmentEffects: []
})).sort((left, right) => {
const valueFor = (weapon: any) => sort.key === 'class' ? `${weapon.category}:${weapon.native_tier}`
: sort.key === 'fixed' ? Number(weapon.rpm ?? 0)
@@ -162,34 +139,32 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: 'base', numeric: true });
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
});
const configuredWeapons = await Promise.all(weapons.map(async (weapon) => {
const talentId = typeof weapon.exotic_talent_id === 'string' ? weapon.exotic_talent_id : '';
const conditionalRule = talentId
? await loadConditionalRuleDraft(database, 'talent', talentId, ['I', 'II'])
: null;
return {
const configuredWeapons = weapons.map((weapon) => ({
...weapon,
tiers: tierRanges.filter((range) => range.weapon_id === weapon.id),
modSlots: [0,1,2].map((tier) => ({ tier, slots: modSlots.filter((slot) => slot.weapon_id === weapon.id && Number(slot.tier) === tier).map((slot) => String(slot.slot_type)) })),
talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)),
talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)),
augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id),
baseBonuses: categoryBonuses.filter((effect) => effect.weapon_category === weapon.category),
exoticTalent: talentId ? {
id: talentId,
iconUrl: (() => { const icon = talentIcons.find((item) => item.entity_id === talentId); return icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''; })(),
name: String(exoticTemplates.find((item) => item.talent_id === talentId)?.name ?? ''),
cooldown_seconds: exoticTemplates.find((item) => item.talent_id === talentId)?.cooldown_seconds ?? '',
duration_seconds: exoticTemplates.find((item) => item.talent_id === talentId)?.duration_seconds ?? '',
max_stacks: exoticTemplates.find((item) => item.talent_id === talentId)?.max_stacks ?? '',
descriptionTemplate: String(exoticTemplates.find((item) => item.talent_id === talentId)?.template ?? ''),
conditionalRule
} : null
}}));
baseBonuses: categoryBonuses.filter((effect) => effect.weapon_category === weapon.category)
}));
return {
weapons: [...pendingWeapons, ...configuredWeapons],
talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options].map(talentOptionWithIcon),
talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options].map(talentOptionWithIcon),
exoticTalentOptions: exoticTalentOptions.map((talent) => {
const talentId = String(talent.id);
const icon = talentIcons.find((item) => String(item.entity_id) === talentId);
return {
id: talentId,
name: String(talent.name),
ownerWeaponId: talent.owner_weapon_id ? String(talent.owner_weapon_id) : '',
ownerWeaponName: talent.owner_weapon_name ? String(talent.owner_weapon_name) : '',
tierIIDocumented: Boolean(talent.tier_ii_documented),
showIcon: true,
iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''
};
}),
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'weapon').length, pending: pending.filter((item) => item.kind === 'weapon').length },
pagination,
@@ -257,9 +232,6 @@ export const actions: Actions = {
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
let exoticIconMutation;
try { exoticIconMutation = await readIconMutation(form); }
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { weaponError: error.message }); throw error; }
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const category = String(form.get('category') ?? '');
@@ -280,38 +252,8 @@ export const actions: Actions = {
return normalizeWeaponModSlotPayload(JSON.parse(String(form.get('modSlots') ?? '[]')));
} catch { return []; }
})();
const existingExoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : '';
const exoticTalentName = isExotic ? String(form.get('exoticTalentName') ?? '').normalize('NFKC').trim() : '';
const exoticDescriptionTemplate = isExotic ? String(form.get('exoticDescriptionTemplate') ?? '').trim() : '';
const exoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : '';
const database = await getDatabase();
let exoticConditionalRule = null;
if (isExotic) {
const rawRule = form.get('exoticConditionalRule');
try {
const activeStats = await (await database.prepare('SELECT id FROM stat_attributes WHERE active=1')).all();
exoticConditionalRule = parseConditionalRulePayload(rawRule, [
{ key: 'tierI', variantKey: 'I' },
{ key: 'tierII', variantKey: 'II' }
], {
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY))
});
} catch (error) {
return fail(400, { weaponError: error instanceof Error ? error.message : 'The exotic talent mechanic is invalid.' });
}
}
const exoticCooldownSeconds = exoticConditionalRule?.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null;
const exoticDurationSeconds = exoticConditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null;
const exoticMaxStacks = exoticConditionalRule?.pools.length
? Math.max(...exoticConditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
const exoticEffects: ExoticEffect[] = exoticConditionalRule ? descriptionOutcomes(exoticConditionalRule)
.filter((outcome) => outcome.kind === 'attribute_modifier')
.map((outcome) => ({
statId: outcome.attributeId ?? null,
valueI: outcome.variants.I.value,
valueII: outcome.variants.II.value
})) : [];
if (name.length < 2 || name.length > 80) return fail(400, { weaponError: 'Weapon names must be 2–80 characters.' });
if (!categories.includes(category) || !damageTypes.includes(damageType) || !Number.isInteger(nativeTier) || nativeTier < 0 || nativeTier > 2) {
@@ -329,15 +271,7 @@ export const actions: Actions = {
}
const modSlotError = validateWeaponModSlots(nativeTier, modSlots);
if (modSlotError) return fail(400, { weaponError: modSlotError });
if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticConditionalRule)) {
return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, and modeled conditional mechanic.' });
}
if (isExotic) {
const placeholders = [...exoticDescriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
if (placeholders.some((index) => index < 1 || index > exoticEffects.length)) return fail(400, { weaponError: 'The exotic talent description references a missing effect.' });
if (exoticDescriptionTemplate.includes('{duration}') && exoticDurationSeconds === null) return fail(400, { weaponError: 'The exotic talent description uses {duration}, but no duration is defined.' });
if (exoticDescriptionTemplate.includes('{max_stacks}') && exoticMaxStacks === null) return fail(400, { weaponError: 'The exotic talent description uses {max_stacks}, but no maximum stack count is defined.' });
}
if (isExotic && !exoticTalentId) return fail(400, { weaponError: 'Select a registered exotic talent for this weapon.' });
const tiers = [];
try {
@@ -374,22 +308,23 @@ export const actions: Actions = {
AND weapon_talent_slots.slot=? AND talents.id IN (${ids.map(() => '?').join(',')})`)).get(slot, ...ids);
if (Number(validTalents?.count) !== ids.length) return fail(400, { weaponError: `One or more selected Talent ${slot} options are unavailable or assigned to the wrong slot.` });
}
const statIds = [...new Set([
...augmentEffects.map((effect) => effect.statId),
...exoticEffects.map((effect) => effect.statId).filter((value): value is string => Boolean(value))
])];
const statIds = [...new Set(augmentEffects.map((effect) => effect.statId))];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
if (Number(validStats?.count) !== statIds.length) return fail(400, { weaponError: 'One or more tactical augmentation attributes are unavailable.' });
if (isExotic) {
if (existingExoticTalentId) {
const owned = id ? await (await database.prepare('SELECT id FROM weapons WHERE id=? AND exotic_talent_id=?')).get(id, existingExoticTalentId) : null;
if (!owned) return fail(400, { weaponError: 'The submitted exotic talent is not owned by this weapon.' });
const shared = await (await database.prepare('SELECT id FROM weapons WHERE exotic_talent_id=? AND id<>?')).get(existingExoticTalentId, id || 'new');
if (shared) return fail(409, { weaponError: 'That exotic talent is already owned by another weapon and cannot be shared.' });
}
if (await (await database.prepare("SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category='Weapon' AND id<>?")).get(exoticTalentName, existingExoticTalentId || 'new')) {
return fail(409, { weaponError: 'An exotic talent with that name already exists.' });
}
const selectedExoticTalent = isExotic
? await (await database.prepare(`SELECT talents.id,talents.name,weapons.id AS owner_weapon_id,
EXISTS(SELECT 1 FROM talent_tiers WHERE talent_tiers.talent_id=talents.id AND talent_tiers.tier='II') AS tier_ii_documented
FROM talents
JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id
LEFT JOIN weapons ON weapons.exotic_talent_id=talents.id
WHERE talents.id=? AND talents.active=1 AND talents.category='Weapon'
AND talent_weapon_scopes.scope='Exotic'`)).get(exoticTalentId)
: null;
if (isExotic && !selectedExoticTalent) {
return fail(400, { weaponError: 'The selected exotic talent is unavailable or is not classified for exotic weapons.' });
}
if (selectedExoticTalent?.owner_weapon_id && String(selectedExoticTalent.owner_weapon_id) !== id) {
return fail(409, { weaponError: 'That exotic talent is already assigned to another weapon.' });
}
if (id && !await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id)) {
return fail(404, { weaponError: 'Weapon not found.' });
@@ -405,71 +340,25 @@ export const actions: Actions = {
const weaponId = id || randomUUID();
const now = new Date().toISOString();
const exoticTalentId = isExotic ? existingExoticTalentId || randomUUID() : null;
const before = id ? {
record: await auditRecord('weapons', id),
tiers: await (await database.prepare('SELECT * FROM weapon_tier_ranges WHERE weapon_id=? ORDER BY tier')).all(id),
talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id),
augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id),
modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id),
exoticTalent: existingExoticTalentId ? {
record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId),
icon: await gameDataIconMetadata(database, 'talent', existingExoticTalentId),
template: await (await database.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(existingExoticTalentId),
tiers: await (await database.prepare('SELECT * FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(existingExoticTalentId),
conditional: await conditionalRuleSnapshot(database, 'talent', existingExoticTalentId)
} : null
modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id)
} : null;
await database.exec('BEGIN');
try {
if (isExotic && exoticTalentId) {
if (existingExoticTalentId) {
await (await database.prepare("UPDATE talents SET name=?,category='Weapon',cooldown_seconds=?,duration_seconds=?,max_stacks=?,active=1,updated_at=? WHERE id=?")).run(exoticTalentName, exoticCooldownSeconds, exoticDurationSeconds, exoticMaxStacks, now, exoticTalentId);
} else {
await (await database.prepare("INSERT INTO talents(id,name,category,cooldown_seconds,duration_seconds,max_stacks,active,created_at,updated_at) VALUES(?,?,'Weapon',?,?,?,1,?,?)")).run(exoticTalentId, exoticTalentName, exoticCooldownSeconds, exoticDurationSeconds, exoticMaxStacks, now, now);
}
await (await database.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,'Exotic')
ON CONFLICT(talent_id) DO UPDATE SET scope='Exotic'`)).run(exoticTalentId);
await (await database.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(exoticTalentId);
await (await database.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?)
ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(exoticTalentId, exoticDescriptionTemplate, now);
const effectStatIds = exoticEffects.map((effect) => effect.statId).filter((value): value is string => Boolean(value));
const unitRows = effectStatIds.length
? await (await database.prepare(`SELECT id,unit FROM stat_attributes WHERE id IN (${effectStatIds.map(() => '?').join(',')})`)).all(...effectStatIds)
: [];
const units = new Map(unitRows.map((row) => [String(row.id), row.unit]));
for (const talentTier of ['I', 'II'] as const) {
const description = renderTalentDescription(exoticDescriptionTemplate, exoticEffects, units, talentTier, exoticDurationSeconds, exoticMaxStacks);
await (await database.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`))
.run(randomUUID(), exoticTalentId, talentTier, description, '', '', now, now);
const tierRow = await (await database.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(exoticTalentId, talentTier);
const tierId = String(tierRow?.id);
await (await database.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
for (const effect of exoticEffects.filter((outcome) => outcome.statId)) {
await (await database.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)'))
.run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now);
}
}
await saveConditionalRuleInTransaction(database, 'talent', exoticTalentId, exoticConditionalRule!);
await applyIconMutation(database, 'talent', exoticTalentId, exoticIconMutation, operator.id);
}
if (id) {
await (await database.prepare(`UPDATE weapons SET name=?,category=?,is_exotic=?,native_tier=?,damage_type=?,rpm=?,magazine=?,
reload_speed=?,accuracy=?,stability=?,exotic_talent_id=?,active=1,updated_at=? WHERE id=?`))
.run(name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, id);
.run(name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, isExotic ? exoticTalentId : null, now, id);
} else {
await (await database.prepare(`INSERT INTO weapons(id,name,category,is_exotic,native_tier,damage_type,rpm,magazine,reload_speed,
accuracy,stability,exotic_talent_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`))
.run(weaponId, name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, now);
.run(weaponId, name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, isExotic ? exoticTalentId : null, now, now);
}
const previousExoticTalentId = typeof before?.record?.exotic_talent_id === 'string' ? before.record.exotic_talent_id : '';
if (!isExotic && previousExoticTalentId) {
const anotherOwner = await (await database.prepare('SELECT id FROM weapons WHERE exotic_talent_id=? AND id<>?')).get(previousExoticTalentId, weaponId);
if (!anotherOwner) await (await database.prepare('UPDATE talents SET active=0,updated_at=? WHERE id=?')).run(now, previousExoticTalentId);
}
await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId);
await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId);
for (const tier of tiers) {
const core = sharedCoreRanges.find((range) => Number(range.tier) === tier.tier);
await (await database.prepare(`INSERT INTO weapon_tier_ranges(weapon_id,tier,damage_min,damage_max,firepower_min,
@@ -488,7 +377,7 @@ export const actions: Actions = {
await (await database.prepare('INSERT INTO weapon_augmentation_effects(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)'))
.run(randomUUID(), weaponId, effect.statId, effect.value, effect.order);
}
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, conditional: await conditionalRuleSnapshot(database, 'talent', String(exoticTalentId)) } : null } });
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, exoticTalent: selectedExoticTalent ? { id: String(selectedExoticTalent.id), name: String(selectedExoticTalent.name), tierIIDocumented: Boolean(selectedExoticTalent.tier_ii_documented) } : null } });
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
+30 -50
View File
@@ -7,10 +7,7 @@
import KpiFilters from '$lib/components/KpiFilters.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
import IconUploadField from '$lib/components/IconUploadField.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
import {
excludedTierZeroWeaponModSlot,
@@ -26,16 +23,10 @@
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true }));
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 });
const blankConditionalRule = (): ConditionalMechanicDraftV1 => ({
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
clauses:[{key:'primary',label:'Primary',event:'weapon_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'equipped_weapon',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{I:{value:0},II:{value:0}}}]}]
});
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', descriptionTemplate: '', conditionalRule: blankConditionalRule() });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), sharedModSlots: [], t0ExcludedSlot: '', talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), sharedModSlots: [], t0ExcludedSlot: '', talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state<any | null>(null);
let bonusEditing = $state<any | null>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
let openedRequestedWeapon = $state(false);
const edit = (weapon: any) => {
const tiers = [0, 1, 2].map((tier) => {
@@ -44,7 +35,7 @@
});
const modSlots = [0,1,2].map((tier) => ({ tier, slots: [...(weapon.modSlots?.find((row: any) => Number(row.tier) === tier)?.slots ?? [])] }));
const sharedSlots = sharedWeaponModSlots(modSlots);
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, sharedModSlots: sharedSlots, t0ExcludedSlot: excludedTierZeroWeaponModSlot(sharedSlots, modSlots), talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, conditionalRule: weapon.exoticTalent.conditionalRule ?? blankConditionalRule() } : blankExoticTalent() };
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, sharedModSlots: sharedSlots, t0ExcludedSlot: excludedTierZeroWeaponModSlot(sharedSlots, modSlots), talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })) };
bonusEditing = null;
};
const editBonusProfile = (profile: any) => {
@@ -70,25 +61,20 @@
const setExotic = (isExotic: boolean) => {
if (!editing) return;
editing.is_exotic = isExotic ? 1 : 0;
if (isExotic && !editing.exoticTalent) editing.exoticTalent = blankExoticTalent();
if (!isExotic) editing.exotic_talent_id = '';
};
const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`;
const exoticOutcomeValue = (effect: any, tier: 'I' | 'II') => {
const value = effect?.variants?.[tier]?.value;
if (value === '' || value === null || value === undefined) return '';
if (effect.kind !== 'attribute_modifier') return String(value);
const stat = data.stats.find((item) => item.id === effect.attributeId);
const unit = stat?.unit === 'flat' ? '' : stat?.unit ?? '';
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
};
const exoticPreview = (tier: 'I' | 'II') => editing.exoticTalent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = editing.exoticTalent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = editing.exoticTalent.conditionalRule.pools.length ? Math.max(...editing.exoticTalent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack');
const available = editing.exoticTalent.conditionalRule.clauses.flatMap((clause: any) => clause.outcomes);
const value = exoticOutcomeValue(available.find((outcome: any) => outcome.descriptionSlot === Number(expression)), tier);
return value || placeholder;
const exoticTalentOptions = () => data.exoticTalentOptions.map((talent: any) => {
const assignedElsewhere = Boolean(talent.ownerWeaponId && talent.ownerWeaponId !== editing?.id);
return {
...talent,
detail: `${talent.ownerWeaponName
? `ASSIGNED TO ${String(talent.ownerWeaponName).toLocaleUpperCase()}`
: 'AVAILABLE FOR ASSIGNMENT'} · TIER II ${talent.tierIIDocumented ? 'DOCUMENTED' : 'NOT YET DOCUMENTED'}`,
disabled: assignedElsewhere,
disabledReason: assignedElsewhere
? `Already assigned to ${talent.ownerWeaponName}`
: ''
};
});
const addAugmentEffect = () => {
if (editing && editing.augmentEffects.length < 2) editing.augmentEffects = [...editing.augmentEffects, { stat_attribute_id: '', value: 0, sort_order: 2 }];
@@ -105,10 +91,9 @@
if (requested) edit(requested);
openedRequestedWeapon = true;
}
if (form?.weaponSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.weaponMode === 'add') iconResetToken += 1;
editing = form.weaponMode === 'add' ? blank() : null;
if (form?.weaponSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
editing = form.weaponMode === 'add' ? blank() : null;
}
if (form?.weaponBonusProfileSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
@@ -162,24 +147,19 @@
<label class="field exotic-toggle">EXOTIC STATUS<span title="Exotic weapon"><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/><GameIcon name="exotic" decorative/><b class="sr-only">EXOTIC WEAPON</b></span></label>
</div>
{#if Number(editing.is_exotic)}
<input type="hidden" name="exoticTalentId" value={editing.exoticTalent.id}/>
<input type="hidden" name="exoticConditionalRule" value={JSON.stringify(editing.exoticTalent.conditionalRule)}/>
<section class="weapon-exotic-talent">
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>OWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED</small></div><b>{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
<div class="exotic-icon-identity"><IconUploadField currentUrl={editing.exoticTalent.iconUrl} label="EXOTIC TALENT ICON" resetToken={iconResetToken}/><div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder={'Hits grant +{1} for {duration}…'} required></textarea><small>Use numbered expressions for effect values and named expressions for duration and stacks.</small></label></div></div>
<p class="talent-template-note">DESCRIPTION EXPRESSIONS · {'{duration}'} and {'{max_stacks}'} render the defined mechanics. Cooldown is displayed separately as a standardized Talent Cooldown line.</p>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{exoticPreview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{exoticPreview('II') || 'Description preview'}</p></div></div>
<MechanicProgramEditor
attributes={data.stats}
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
idPrefix={`weapon-exotic-${editing.exoticTalent.id || 'new'}`}
aiContext={{
sourceKind: 'exotic_weapon_talent',
sourceName: editing?.exoticTalent?.name ?? '',
sourceDescription: editing?.exoticTalent?.descriptionTemplate ?? ''
}}
bind:rule={editing.exoticTalent.conditionalRule}
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>SELECT ONE TALENT FROM THE EXOTIC WEAPON REGISTRY</small></div><b>{editing.exotic_talent_id ? '1 / 1 ASSIGNED' : 'REQUIRED'}</b></div>
<SearchableAssignment
items={exoticTalentOptions()}
fieldName="exoticTalentId"
mode="single"
subjectLabel="EXOTIC TALENT"
searchPlaceholder="Search exotic talents…"
emptySelectionLabel="NO EXOTIC TALENT ASSIGNED"
bind:value={editing.exotic_talent_id}
/>
<p class="weapon-mod-slot-note"><b>REFERENCE ONLY</b><span>Create and model exotic talents in the Talent registry. An exotic talent can belong to one weapon, and Tier II may remain not yet documented.</span></p>
{#if data.exoticTalentOptions.length === 0}<p class="error">Create an active Exotic Weapon talent in the Talent registry before saving this weapon.</p>{/if}
</section>
{/if}
<div class="selection-head"><div><span>FIXED WEAPON ATTRIBUTES</span><small>UNCHANGED ACROSS TIERS</small></div><b>BASE HANDLING</b></div>
@@ -224,7 +204,7 @@
{#if data.talent1Options.length === 0 || data.talent2Options.length === 0}<p class="error">Create and classify at least one active Talent 1 and Talent 2 weapon talent before adding weapons.</p>{/if}
{#if data.stats.length === 0}<p class="error">Create an active attribute before configuring tactical augmentation 01.</p>{/if}
{#if form?.weaponError}<p class="error">{form.weaponError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="moderation-action danger weapon-archive-action" formaction="?/archive" name="id" value={editing.id}><b aria-hidden="true">⌁</b> ARCHIVE WEAPON</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.talent1Options.length === 0 || data.talent2Options.length === 0 || data.stats.length === 0}>SAVE WEAPON →</button></div></div>
<div class="form-actions split-actions"><div>{#if editing.id}<button class="moderation-action danger weapon-archive-action" formaction="?/archive" name="id" value={editing.id}><b aria-hidden="true">⌁</b> ARCHIVE WEAPON</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.talent1Options.length === 0 || data.talent2Options.length === 0 || data.stats.length === 0 || (Number(editing.is_exotic) === 1 && !editing.exotic_talent_id)}>SAVE WEAPON →</button></div></div>
</PendingForm>
</div>
{/snippet}