Add structured talent mechanics
This commit is contained in:
@@ -13,9 +13,12 @@ const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||
const rangeNames = ['damage', 'firepower', 'toughness', 'engineering'] as const;
|
||||
type ExoticEffect = { statId: string; valueI: number; valueII: number };
|
||||
|
||||
const renderTalentDescription = (template: string, effects: ExoticEffect[], units: Map<string, unknown>, tier: 'I' | 'II') =>
|
||||
template.replace(/\{(\d+)\}/g, (placeholder, rawIndex) => {
|
||||
const effect = effects[Number(rawIndex) - 1];
|
||||
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 unit = units.get(effect.statId) === 'flat' ? '' : String(units.get(effect.statId) ?? '');
|
||||
@@ -53,7 +56,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
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 id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE")).all(),
|
||||
(await database.prepare(`SELECT talent_description_templates.talent_id,talent_description_templates.template,talents.name
|
||||
(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 talent_tiers.id,talent_tiers.talent_id,talent_tiers.tier,talent_tiers.description
|
||||
FROM talent_tiers JOIN talents ON talents.id=talent_tiers.talent_id WHERE talents.category='Weapon'`)).all(),
|
||||
@@ -82,6 +86,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
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 ?? ''),
|
||||
triggerIds: [...new Set([
|
||||
...exoticTierTriggers.filter((link) => link.talent_tier_id === tierI?.id),
|
||||
@@ -109,6 +116,11 @@ function number(form: FormData, name: string): number {
|
||||
return Number(form.get(name));
|
||||
}
|
||||
|
||||
function optionalPositiveNumber(form: FormData, name: string): number | null {
|
||||
const raw = String(form.get(name) ?? '').trim();
|
||||
return raw ? Number(raw) : null;
|
||||
}
|
||||
|
||||
function readRange(form: FormData, tier: number, range: (typeof rangeNames)[number]) {
|
||||
const min = number(form, `tier${tier}${range}Min`);
|
||||
const max = number(form, `tier${tier}${range}Max`);
|
||||
@@ -141,6 +153,9 @@ export const actions: Actions = {
|
||||
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 exoticCooldownSeconds = isExotic ? optionalPositiveNumber(form, 'exoticCooldownSeconds') : null;
|
||||
const exoticDurationSeconds = isExotic ? optionalPositiveNumber(form, 'exoticDurationSeconds') : null;
|
||||
const exoticMaxStacks = isExotic ? optionalPositiveNumber(form, 'exoticMaxStacks') : null;
|
||||
const exoticTriggerIds = isExotic ? [...new Set(form.getAll('exoticTriggerIds').map(String))] : [];
|
||||
let exoticEffects: ExoticEffect[] = [];
|
||||
try {
|
||||
@@ -168,6 +183,12 @@ export const actions: Actions = {
|
||||
if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticTriggerIds.length || !exoticEffects.length)) {
|
||||
return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, trigger, and at least one effect.' });
|
||||
}
|
||||
if (isExotic && [exoticCooldownSeconds, exoticDurationSeconds].some((value) => value !== null && (!Number.isFinite(value) || value <= 0))) {
|
||||
return fail(400, { weaponError: 'Exotic talent cooldown and duration must be positive numbers when defined.' });
|
||||
}
|
||||
if (isExotic && exoticMaxStacks !== null && (!Number.isInteger(exoticMaxStacks) || exoticMaxStacks <= 0)) {
|
||||
return fail(400, { weaponError: 'Exotic talent maximum stacks must be a positive whole number when defined.' });
|
||||
}
|
||||
if (isExotic && (exoticEffects.some((effect) => !effect.statId || !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))
|
||||
|| new Set(exoticEffects.map((effect) => effect.statId)).size !== exoticEffects.length)) {
|
||||
return fail(400, { weaponError: 'Every exotic talent effect requires one unique attribute and valid Tier I and Tier II values.' });
|
||||
@@ -175,6 +196,8 @@ export const actions: Actions = {
|
||||
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.' });
|
||||
}
|
||||
|
||||
const tiers = [];
|
||||
@@ -268,9 +291,9 @@ export const actions: Actions = {
|
||||
} : null;
|
||||
if (isExotic && exoticTalentId) {
|
||||
if (existingExoticTalentId) {
|
||||
await (await database.prepare("UPDATE talents SET name=?,category='Weapon',active=1,updated_at=? WHERE id=?")).run(exoticTalentName, now, exoticTalentId);
|
||||
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,active,created_at,updated_at) VALUES(?,?,'Weapon',1,?,?)")).run(exoticTalentId, exoticTalentName, now, now);
|
||||
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);
|
||||
@@ -280,7 +303,7 @@ export const actions: Actions = {
|
||||
const unitRows = await (await database.prepare(`SELECT id,unit FROM stat_attributes WHERE id IN (${exoticEffects.map(() => '?').join(',')})`)).all(...exoticEffects.map((effect) => effect.statId));
|
||||
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);
|
||||
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);
|
||||
@@ -330,7 +353,7 @@ export const actions: Actions = {
|
||||
await (await database.prepare('INSERT INTO weapon_base_attribute_bonuses(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, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : 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, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), cooldownSeconds: exoticCooldownSeconds, durationSeconds: exoticDurationSeconds, maxStacks: exoticMaxStacks, descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : null } });
|
||||
return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
|
||||
Reference in New Issue
Block a user