feat: expand ISAC content modeling
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s

This commit is contained in:
jb
2026-08-03 01:23:20 -04:00
parent d9924a95b9
commit 57068b61cd
90 changed files with 9635 additions and 1270 deletions
+237 -167
View File
@@ -35,27 +35,15 @@ import {
saveConditionalRuleInTransaction,
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import {
getOsProtocolProfileAttributeIds,
type OsProtocolAttributeType,
} from '$lib/server/os-protocol-profiles';
import type { Database } from '@tursodatabase/database';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
const secondaryRoles = ['Core', 'Damage', 'Health'];
const expectedSecondaryAttributes: Record<string, Record<string, string>> = {
Firepower: {
Core: 'Firepower',
Damage: 'All Weapon Damage',
Health: 'Health',
},
Toughness: {
Core: 'Toughness',
Damage: 'All Weapon Damage',
Health: 'Health',
},
Engineering: {
Core: 'Engineering',
Damage: 'Skill Damage',
Health: 'Health',
},
};
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
render
@@ -88,15 +76,15 @@ const descriptionOutcomes = (
Number(left.descriptionSlot) - Number(right.descriptionSlot),
);
async function protocolSnapshot(id: string) {
const database = await getDatabase();
async function protocolSnapshot(id: string, suppliedDatabase?: Database) {
const database = suppliedDatabase ?? (await getDatabase());
const talent = await (
await database.prepare(
'SELECT * FROM os_protocol_talents WHERE protocol_id=?',
)
).get(id);
return {
record: await auditRecord('os_protocols', id),
record: await auditRecord('os_protocols', id, database),
icon: await gameDataIconMetadata(database, 'os_protocol', id),
render: renderAuditMetadata(
await (
@@ -129,8 +117,8 @@ async function protocolSnapshot(id: string) {
};
}
async function profileSnapshot(id: string) {
const database = await getDatabase();
async function profileSnapshot(id: string, suppliedDatabase?: Database) {
const database = suppliedDatabase ?? (await getDatabase());
return {
record:
(await (
@@ -292,6 +280,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'attributeType',
'primaryStatId',
'primaryBaseValue',
'hasTalent',
'talentName',
'descriptionTemplate',
'conditionalRule',
@@ -341,17 +330,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
profile.quality === protocol.quality &&
profile.primary_attribute_type === protocol.attributeType,
) ?? null,
talent: {
name: protocol.talentName,
description_template: protocol.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
} catch {
return null;
}
})(),
},
talent:
protocol.hasTalent === '1'
? {
name: protocol.talentName,
description_template: protocol.descriptionTemplate,
conditionalRule: (() => {
try {
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
} catch {
return null;
}
})(),
}
: null,
}));
const configuredProtocols = await Promise.all(
protocols.map(async (protocol) => {
@@ -450,27 +442,26 @@ export const actions: Actions = {
profileError: 'Choose a valid quality and attribute type.',
});
const database = await getDatabase();
const expected = expectedSecondaryAttributes[attributeType];
const statRows = await (
await database.prepare(
`SELECT id,name FROM stat_attributes WHERE active=1 AND name IN (?,?,?) COLLATE NOCASE`,
)
).all(expected.Core, expected.Damage, expected.Health);
const statByName = new Map(
statRows.map((stat) => [
String(stat.name).toLowerCase(),
String(stat.id),
]),
const attributeIds = await getOsProtocolProfileAttributeIds(
database,
attributeType as OsProtocolAttributeType,
);
if (statRows.length !== 3)
if (Object.values(attributeIds).some((id) => !id))
return fail(400, {
profileError: `Create active stats named ${Object.values(expected).join(', ')} before configuring this profile.`,
profileError:
'Create the required active core, damage, and Health Attributes before configuring this profile.',
});
const effects = secondaryRoles.map((role) => ({
role,
statId: statByName.get(expected[role].toLowerCase()) ?? '',
value: Number(form.get(`value${role}`)),
}));
const effects = secondaryRoles.map((role) => {
const rawValue = form.get(`value${role}`);
return {
role,
statId: attributeIds[role as keyof typeof attributeIds] ?? '',
value:
typeof rawValue === 'string' && rawValue.trim()
? Number(rawValue)
: Number.NaN,
};
});
if (
effects.some(
(effect) =>
@@ -481,47 +472,58 @@ export const actions: Actions = {
profileError:
'Every shared secondary attribute requires a non-negative value.',
});
const existing = await (
await database.prepare(
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
)
).get(quality, attributeType);
const profileId = String(existing?.id ?? randomUUID()),
now = new Date().toISOString();
const before = existing ? await profileSnapshot(profileId) : null;
if (existing)
await (
await database.prepare(
'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?',
const saveProfile = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const existing = await (
await tx.prepare(
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
)
).run(now, profileId);
else
).get(quality, attributeType);
const profileId = String(existing?.id ?? randomUUID());
const now = new Date().toISOString();
const before = existing ? await profileSnapshot(profileId, tx) : null;
if (existing)
await (
await tx.prepare(
'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?',
)
).run(now, profileId);
else
await (
await tx.prepare(
'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)',
)
).run(profileId, quality, attributeType, now, now);
await (
await database.prepare(
'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)',
await tx.prepare(
'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?',
)
).run(profileId, quality, attributeType, now, now);
await (
await database.prepare(
'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?',
)
).run(profileId);
for (const effect of effects)
await (
await database.prepare(
'INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)',
)
).run(randomUUID(), profileId, effect.role, effect.statId, effect.value);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: existing
? 'os_protocol_profile.updated'
: 'os_protocol_profile.created',
entityId: profileId,
before,
after: await profileSnapshot(profileId),
).run(profileId);
for (const effect of effects)
await (
await tx.prepare(
'INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)',
)
).run(
randomUUID(),
profileId,
effect.role,
effect.statId,
effect.value,
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: existing
? 'os_protocol_profile.updated'
: 'os_protocol_profile.created',
entityId: profileId,
before,
after: await profileSnapshot(profileId, tx),
database: tx,
});
});
await saveProfile.immediate();
return { profileSaved: true, resultToken: randomUUID() };
},
save: async ({ request, cookies }) => {
@@ -559,7 +561,12 @@ export const actions: Actions = {
const quality = String(form.get('quality') ?? '');
const attributeType = String(form.get('attributeType') ?? '');
const primaryStatId = String(form.get('primaryStatId') ?? '');
const primaryBaseValue = Number(form.get('primaryBaseValue'));
const primaryBaseValueRaw = form.get('primaryBaseValue');
const primaryBaseValue =
typeof primaryBaseValueRaw === 'string' && primaryBaseValueRaw.trim()
? Number(primaryBaseValueRaw)
: Number.NaN;
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '')
.normalize('NFKC')
.trim();
@@ -567,22 +574,27 @@ export const actions: Actions = {
.normalize('NFKC')
.trim();
const database = await getDatabase();
let conditionalRule;
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
null;
try {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare('SELECT id FROM stat_attributes WHERE active=1')
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[{ key: 'base', variantKey: 'base' }],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
if (hasTalent) {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1',
)
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[{ key: 'base', variantKey: 'base' }],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
}
} catch (error) {
return fail(400, {
protocolError:
@@ -608,29 +620,39 @@ export const actions: Actions = {
protocolError:
'Choose the protocol’s unique primary stat and a non-negative level-1 base value.',
});
const outcomes = mechanicOutcomes(conditionalRule);
const numberedOutcomes = descriptionOutcomes(conditionalRule);
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
const numberedOutcomes = conditionalRule
? descriptionOutcomes(conditionalRule)
: [];
const durationSeconds =
conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule.pools.length
const maxStacks = conditionalRule?.pools.length
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
const requiresCompleteMechanic =
hasTalent && conditionalRule?.simulationReadiness !== 'captured_only';
if (
talentName.length < 2 ||
talentName.length > 80 ||
!descriptionTemplate ||
!conditionalRule.clauses.length ||
!outcomes.length
hasTalent &&
(talentName.length < 2 || talentName.length > 80 || !descriptionTemplate)
)
return fail(400, {
protocolError:
'The unique talent requires a name, description template, mechanic branch, and at least one outcome.',
'The assigned Talent requires a name and description template.',
});
if (
requiresCompleteMechanic &&
(!conditionalRule?.clauses.length || !outcomes.length)
)
return fail(400, {
protocolError:
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
});
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map(
(match) => Number(match[1]),
);
if (
requiresCompleteMechanic &&
placeholders.some(
(index) =>
!numberedOutcomes.some(
@@ -642,23 +664,49 @@ export const actions: Actions = {
protocolError:
'Description placeholders must reference configured numbered outcomes.',
});
if (descriptionTemplate.includes('{duration}') && durationSeconds === null)
if (
requiresCompleteMechanic &&
descriptionTemplate.includes('{duration}') &&
durationSeconds === null
)
return fail(400, {
protocolError:
'The description uses {duration}, but no expiring state pool is defined.',
});
if (descriptionTemplate.includes('{max_stacks}') && !maxStacks)
if (
requiresCompleteMechanic &&
descriptionTemplate.includes('{max_stacks}') &&
!maxStacks
)
return fail(400, {
protocolError:
'The description uses {max_stacks}, but no state pool maximum is defined.',
});
if (
!(await (
const profileAttributeIds = await getOsProtocolProfileAttributeIds(
database,
attributeType as OsProtocolAttributeType,
);
const completeProfile =
Object.values(profileAttributeIds).every(Boolean) &&
(await (
await database.prepare(
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
`SELECT profile.id FROM os_protocol_secondary_profiles profile
JOIN os_protocol_secondary_effects effect ON effect.profile_id=profile.id
WHERE profile.quality=? AND profile.primary_attribute_type=?
GROUP BY profile.id
HAVING COUNT(effect.id)=3
AND SUM(CASE WHEN effect.effect_role='Core' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Damage' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
AND SUM(CASE WHEN effect.effect_role='Health' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1`,
)
).get(quality, attributeType))
)
).get(
quality,
attributeType,
profileAttributeIds.Core,
profileAttributeIds.Damage,
profileAttributeIds.Health,
));
if (!completeProfile)
return fail(400, {
protocolError:
'Configure the shared secondary profile for this quality and attribute type first.',
@@ -679,11 +727,21 @@ export const actions: Actions = {
).get(id)
: null;
if (
await (
existingTalent &&
!hasTalent &&
form.get('confirmTalentRemoval') !== 'REMOVE'
)
return fail(400, {
protocolError:
'Confirm removal of the existing Talent and mechanic program.',
});
if (
hasTalent &&
(await (
await database.prepare(
'SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(talentName, existingTalent?.id ?? 'new')
).get(talentName, existingTalent?.id ?? 'new'))
)
return fail(409, {
protocolError: 'That protocol talent name is already in use.',
@@ -701,71 +759,83 @@ export const actions: Actions = {
return fail(400, {
protocolError: 'One or more selected attributes are unavailable.',
});
const protocolId = id || randomUUID(),
const protocolId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
talentId = String(existingTalent?.id ?? randomUUID()),
now = new Date().toISOString();
const before = id ? await protocolSnapshot(id) : null;
if (id && !before?.record)
return fail(404, { protocolError: 'OS Protocol not found.' });
await database.exec('BEGIN');
try {
const saveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const before = id ? await protocolSnapshot(id, tx) : null;
if (id && !before?.record)
return fail(404, { protocolError: 'OS Protocol not found.' });
if (id) {
await (
await database.prepare(
await tx.prepare(
'UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?',
)
).run(name, quality, attributeType, now, id);
} else
await (
await database.prepare(
await tx.prepare(
'INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)',
)
).run(protocolId, name, quality, attributeType, now, now);
await (
await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
await tx.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)
).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
await (
await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
await (
await database.prepare(
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
)
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
if (hasTalent && conditionalRule) {
await (
await database.prepare(
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)',
await tx.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
await (
await tx.prepare(
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
)
).run(
randomUUID(),
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
await (
await tx.prepare(
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)',
)
).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.base.value,
compatibilitySortOrder,
);
}
await saveConditionalRuleInTransaction(
tx,
'os_protocol_talent',
talentId,
outcome.attributeId,
outcome.variants.base.value,
compatibilitySortOrder,
conditionalRule,
);
} else if (existingTalent) {
await (
await tx.prepare(
"DELETE FROM conditional_effect_rules WHERE source_type='os_protocol_talent' AND source_id=?",
)
).run(String(existingTalent.id));
await (
await tx.prepare('DELETE FROM os_protocol_talents WHERE id=?')
).run(String(existingTalent.id));
}
await saveConditionalRuleInTransaction(
database,
'os_protocol_talent',
talentId,
conditionalRule,
);
await applyIconMutation(
database,
tx,
'os_protocol',
protocolId,
iconMutation,
operator.id,
);
await applyGameDataRender(
database,
tx,
'os_protocol',
protocolId,
name,
@@ -778,13 +848,13 @@ export const actions: Actions = {
action: id ? 'os_protocol.updated' : 'os_protocol.created',
entityId: protocolId,
before,
after: await protocolSnapshot(protocolId),
after: await protocolSnapshot(protocolId, tx),
database: tx,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return null;
});
const transactionResult = await saveProtocol.immediate();
if (transactionResult) return transactionResult;
return {
protocolSaved: true,
protocolMode: id ? 'edit' : 'add',