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
+166
View File
@@ -91,6 +91,172 @@ describe('article documents', () => {
).toBeNull();
});
it('accepts and canonicalizes metadata emitted by the Tiptap editor', () => {
const parsed = parseArticleDocument({
version: 1,
document: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Read more',
marks: [
{
type: 'link',
attrs: {
href: 'https://shd.build/news',
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
title: 'News archive',
},
},
],
},
],
},
{
type: 'orderedList',
attrs: { start: 1, type: null },
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'First item' }],
},
],
},
],
},
],
},
});
expect(parsed?.document.document.content).toEqual([
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Read more',
marks: [
{
type: 'link',
attrs: {
href: 'https://shd.build/news',
title: 'News archive',
},
},
],
},
],
},
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'First item' }],
},
],
},
],
},
]);
expect(parsed?.plainText).toContain('News archive');
});
it('preserves supported ordered-list numbering', () => {
const parsed = parseArticleDocument({
version: 1,
document: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 2, type: 'A' },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph' }],
},
],
},
],
},
});
expect(parsed?.document.document.content?.[0]).toMatchObject({
type: 'orderedList',
attrs: { start: 2, type: 'A' },
});
expect(parseArticleDocument(parsed?.document)?.document).toEqual(
parsed?.document,
);
});
it('rejects unknown link metadata and non-default ordered lists', () => {
const documentWith = (content: unknown[]) => ({
version: 1,
document: { type: 'doc', content },
});
expect(
parseArticleDocument(
documentWith([
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'unsafe metadata',
marks: [
{
type: 'link',
attrs: {
href: 'https://shd.build',
onclick: 'alert(1)',
},
},
],
},
],
},
]),
),
).toBeNull();
expect(
parseArticleDocument(
documentWith([
{
type: 'orderedList',
attrs: { start: 0, type: 'decimal' },
content: [],
},
]),
),
).toBeNull();
expect(
parseArticleDocument(
documentWith([
{
type: 'orderedList',
attrs: { start: 1, type: 1 },
content: [],
},
]),
),
).toBeNull();
});
it('rejects duplicate embed keys and unknown envelope fields', () => {
const embed = {
type: 'buildEmbed',
+63 -5
View File
@@ -10,7 +10,7 @@ const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/;
export type ArticleMark = {
type: 'bold' | 'italic' | 'code' | 'link';
attrs?: { href: string };
attrs?: { href: string; title?: string };
};
export type ArticleNode = {
@@ -53,7 +53,17 @@ const safeLink = (value: unknown) => {
}
};
function parseMarks(value: unknown): ArticleMark[] | null {
const nullableBoundedString = (value: unknown, maximum: number) =>
value === undefined ||
value === null ||
(typeof value === 'string' &&
value.length <= maximum &&
!/\p{Cc}/u.test(value));
function parseMarks(
value: unknown,
onLinkTitle: (title: string) => void,
): ArticleMark[] | null {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > 4) return null;
const seen = new Set<string>();
@@ -77,10 +87,31 @@ function parseMarks(value: unknown): ArticleMark[] | null {
!isRecord(candidate.attrs)
)
return null;
if (!exactKeys(candidate.attrs, ['href'])) return null;
if (
!exactKeys(candidate.attrs, [
'href',
'target',
'rel',
'class',
'title',
]) ||
!nullableBoundedString(candidate.attrs.target, 100) ||
!nullableBoundedString(candidate.attrs.rel, 500) ||
!nullableBoundedString(candidate.attrs.class, 500) ||
!nullableBoundedString(candidate.attrs.title, 500)
)
return null;
const href = safeLink(candidate.attrs.href);
if (!href) return null;
marks.push({ type: 'link', attrs: { href } });
const title =
typeof candidate.attrs.title === 'string'
? candidate.attrs.title.normalize('NFKC').trim()
: '';
if (title) onLinkTitle(title);
marks.push({
type: 'link',
attrs: { href, ...(title ? { title } : {}) },
});
}
return marks;
}
@@ -134,7 +165,9 @@ export function parseArticleDocument(value: unknown): {
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
textLength += [...text].length;
if (textLength > MAX_ARTICLE_TEXT_CHARACTERS) return null;
const marks = parseMarks(candidate.marks);
const marks = parseMarks(candidate.marks, (title) =>
textParts.push(title),
);
if (!marks) return null;
textParts.push(text);
return marks.length ? { type, text, marks } : { type, text };
@@ -211,6 +244,31 @@ export function parseArticleDocument(value: unknown): {
)
return null;
attrs = { level: Number(candidate.attrs.level) };
} else if (type === 'orderedList' && candidate.attrs !== undefined) {
if (
!isRecord(candidate.attrs) ||
!exactKeys(candidate.attrs, ['start', 'type'])
)
return null;
const start = candidate.attrs.start ?? 1;
const listType = candidate.attrs.type;
if (
!Number.isInteger(start) ||
Number(start) < 1 ||
Number(start) > 1_000_000 ||
(listType !== null &&
listType !== undefined &&
(typeof listType !== 'string' ||
!['1', 'a', 'A', 'i', 'I'].includes(listType)))
)
return null;
const canonicalType =
typeof listType === 'string' && listType !== '1' ? listType : undefined;
attrs = {
...(Number(start) === 1 ? {} : { start: Number(start) }),
...(canonicalType ? { type: canonicalType } : {}),
};
if (!Object.keys(attrs).length) attrs = undefined;
} else if (candidate.attrs !== undefined) {
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
return null;
+103
View File
@@ -39,6 +39,7 @@ describe('summarizeLoadout', () => {
it('returns an empty deterministic summary for a partial draft', () => {
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
stats: [],
abilityStats: [],
activeBonuses: [],
equippedTalents: [],
});
@@ -214,6 +215,108 @@ describe('summarizeLoadout', () => {
]);
});
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
const draft = createDraft();
draft.chips[0] = {
setId: 'chip-set',
quality: 'Superior',
primary: { statId: 'skill-duration', value: 12 },
secondary: [
{ statId: 'agent-stat', value: 4 },
{ statId: 'other-secondary', value: 6 },
],
};
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
attribute('agent-stat', 'Agent Stat', { unit: '%' }),
attribute('other-secondary', 'Other Secondary', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability-1',
twoPiece: null,
talent: null,
},
],
}),
);
expect(stat(summary, 'skill-duration')).toBeUndefined();
expect(stat(summary, 'agent-stat')?.value).toBe(4);
expect(stat(summary, 'other-secondary')?.value).toBe(6);
expect(summary.abilityStats).toEqual([
{
abilityId: 'ability-1',
stats: [
{
id: 'skill-duration',
value: 12,
sourceCount: 1,
sources: [
{
source: 'Skill Chip 1 primary',
chipIndex: 0,
chipSetId: 'chip-set',
value: 12,
},
],
},
],
},
]);
});
it.each([
[0, 10],
[1, 20],
[2, 30],
[3, 40],
])(
'scales a three-chip 2-piece bonus with %i High-end chips',
(highEndCount, expectedValue) => {
const draft = createDraft();
for (let index = 0; index < 3; index += 1) {
draft.chips[index].setId = 'chip-set';
draft.chips[index].quality =
index < highEndCount ? 'High-end' : 'Standard';
}
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-damage', 'Skill Damage', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability',
twoPiece: {
stat_attribute_id: 'skill-damage',
min_value: 10,
max_value: 40,
},
talent: null,
},
],
}),
);
expect(stat(summary, 'skill-damage')?.value).toBe(expectedValue);
expect(summary.activeBonuses[0]).toMatchObject({
count: 3,
value: expectedValue,
});
},
);
it('scales Protocol primary stats and Talent descriptions independently', () => {
const draft = createDraft();
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
+114 -6
View File
@@ -7,6 +7,10 @@ export type LoadoutCatalog = {
brands: any[];
weapons: any[];
talents: any[];
skillChipProfile?: {
primaryOptions: any[];
secondaryOptions: any[];
};
chipSets: any[];
protocols: any[];
specializations: any[];
@@ -14,6 +18,20 @@ export type LoadoutCatalog = {
export type LoadoutSummary = {
stats: Array<{ id: string; value: number; sourceCount: number }>;
abilityStats: Array<{
abilityId: string;
stats: Array<{
id: string;
value: number;
sourceCount: number;
sources: Array<{
source: string;
chipIndex: number;
chipSetId: string;
value: number;
}>;
}>;
}>;
activeBonuses: any[];
equippedTalents: any[];
};
@@ -139,6 +157,55 @@ export function summarizeLoadout(
const brandCounts = new Map<string, number>();
const activeBonuses: any[] = [];
const equippedTalents: any[] = [];
const abilityValues = new Map<string, Map<string, number>>();
const abilitySources = new Map<
string,
Map<
string,
Array<{
source: string;
chipIndex: number;
chipSetId: string;
value: number;
}>
>
>();
const addAbilityStat = (
abilityId: string,
statId: string,
value: unknown,
chipIndex: number,
chipSetId: string,
) => {
if (
!abilityId ||
!statId ||
!catalogAttribute(catalog, statId) ||
!Number.isFinite(Number(value))
)
return;
if (!abilityValues.has(abilityId)) abilityValues.set(abilityId, new Map());
const valuesForAbility = abilityValues.get(abilityId)!;
valuesForAbility.set(
statId,
applyAttributeValue(
catalog,
valuesForAbility.get(statId) ?? 0,
statId,
Number(value),
),
);
if (!abilitySources.has(abilityId))
abilitySources.set(abilityId, new Map());
const sourcesForAbility = abilitySources.get(abilityId)!;
if (!sourcesForAbility.has(statId)) sourcesForAbility.set(statId, []);
sourcesForAbility.get(statId)!.push({
source: `Skill Chip ${chipIndex + 1} primary`,
chipIndex,
chipSetId,
value: Number(value),
});
};
for (const slot of gearSlots) {
const selection = draft.gear[slot];
@@ -259,17 +326,36 @@ export function summarizeLoadout(
string,
{ count: number; highEnd: number; item: any }
>();
const validChipRoll = (
kind: 'primaryOptions' | 'secondaryOptions',
quality: string,
roll: { statId: string; value: number },
) => {
if (!catalog.skillChipProfile) return true;
const range = catalog.skillChipProfile[kind].find(
(option) => option.statId === roll.statId,
)?.ranges?.[quality];
return (
range &&
roll.value >= Number(range.min) &&
roll.value <= Number(range.max)
);
};
for (let index = 0; index < 9; index += 1) {
const selection = draft.chips[index];
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
if (!item) continue;
add(
selection.primary.statId,
selection.primary.value,
`Skill Chip ${index + 1} primary`,
);
if (validChipRoll('primaryOptions', selection.quality, selection.primary))
addAbilityStat(
String(item.specialization_ability_id ?? ''),
selection.primary.statId,
selection.primary.value,
index,
String(item.id),
);
for (const roll of selection.secondary)
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
if (validChipRoll('secondaryOptions', selection.quality, roll))
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
group.count += 1;
if (selection.quality === 'High-end') group.highEnd += 1;
@@ -390,6 +476,28 @@ export function summarizeLoadout(
catalogAttribute(catalog, right.id)?.name ?? '',
),
),
abilityStats: [...abilityValues].map(([abilityId, abilityStatValues]) => ({
abilityId,
stats: [...abilityStatValues]
.map(([id, value]) => {
const statSources = abilitySources.get(abilityId)?.get(id) ?? [];
return {
id,
value,
sourceCount: statSources.length,
sources: statSources,
};
})
.sort(
(left, right) =>
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
catalogAttribute(catalog, right.id)?.category ?? '',
) ||
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
catalogAttribute(catalog, right.id)?.name ?? '',
),
),
})),
activeBonuses,
equippedTalents,
};
+62 -2
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { createDraft, parseLoadoutDraft } from './loadout.js';
import {
createDraft,
parseLoadoutDraft,
skillChipSecondaryCapacity,
type SkillChipQuality,
} from './loadout.js';
const copyDraft = () => structuredClone(createDraft());
@@ -9,6 +14,13 @@ describe('parseLoadoutDraft', () => {
const parsed = parseLoadoutDraft(draft);
expect(parsed).toEqual(draft);
expect(parsed?.version).toBe(5);
expect(parsed?.chips[0]).toEqual({
setId: '',
quality: 'Standard',
primary: { statId: '', value: 0 },
secondary: [],
});
expect(parsed?.calculationMode).toBe('maximum');
expect(parsed).not.toBe(draft);
parsed!.gear.Holster.level = 1;
@@ -23,13 +35,61 @@ describe('parseLoadoutDraft', () => {
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
it('rejects the retired version 4 schema', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 4;
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('rejects a version 5 draft that does not contain exactly nine chips', () => {
const draft = copyDraft();
draft.chips.pop();
expect(parseLoadoutDraft(draft)).toBeNull();
});
it.each([
['Standard', 0],
['Specialized', 1],
['Superior', 2],
['High-end', 2],
] as const)(
'accepts %s chips with exactly %i secondary rolls',
(quality, capacity) => {
const draft = copyDraft();
draft.chips[0].quality = quality;
draft.chips[0].primary = { statId: 'primary', value: 10 };
draft.chips[0].secondary = Array.from(
{ length: capacity },
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
);
expect(skillChipSecondaryCapacity[quality]).toBe(capacity);
expect(parseLoadoutDraft(draft)).toEqual(draft);
},
);
it.each([
['Standard', 1],
['Specialized', 0],
['Specialized', 2],
['Superior', 1],
['High-end', 3],
] as Array<[SkillChipQuality, number]>)(
'rejects %s chips with %i secondary rolls',
(quality, capacity) => {
const draft = copyDraft();
draft.chips[0].quality = quality;
draft.chips[0].secondary = Array.from(
{ length: capacity },
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
);
expect(parseLoadoutDraft(draft)).toBeNull();
},
);
it('rejects a draft missing the unified combat profile', () => {
const draft = copyDraft() as unknown as Record<string, unknown>;
delete draft.combatProfile;
+25 -9
View File
@@ -1,4 +1,17 @@
export type Roll = { statId: string; value: number };
export const skillChipQualities = [
'Standard',
'Specialized',
'Superior',
'High-end',
] as const;
export type SkillChipQuality = (typeof skillChipQualities)[number];
export const skillChipSecondaryCapacity: Record<SkillChipQuality, number> = {
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
};
export type GearSelection = {
itemId: string;
level: number;
@@ -26,9 +39,9 @@ export type WeaponSelection = {
};
export type ChipSelection = {
setId: string;
quality: 'Superior' | 'High-end';
quality: SkillChipQuality;
primary: Roll;
secondary: [Roll, Roll];
secondary: Roll[];
};
export type ProtocolSelection = {
itemId: string;
@@ -68,7 +81,7 @@ export type SpecializationSelection = {
nodeRanks: Record<string, number>;
};
export type LoadoutDraft = {
version: 4;
version: 5;
buildName: string;
calculationMode: CalculationMode;
gear: Record<string, GearSelection>;
@@ -127,9 +140,9 @@ export const blankWeapon = (): WeaponSelection => ({
});
export const blankChip = (): ChipSelection => ({
setId: '',
quality: 'Superior',
quality: 'Standard',
primary: blankRoll(),
secondary: [blankRoll(), blankRoll()],
secondary: [],
});
export const defaultCombatProfile = (): CombatProfile => ({
encounterSeconds: 60,
@@ -148,7 +161,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
extraHealthActive: false,
});
export const createDraft = (): LoadoutDraft => ({
version: 4,
version: 5,
buildName: 'Untitled loadout',
calculationMode: 'maximum',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
@@ -242,9 +255,12 @@ const isChipSelection = (value: unknown): value is ChipSelection =>
isRecord(value) &&
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
typeof value.setId === 'string' &&
(value.quality === 'Superior' || value.quality === 'High-end') &&
skillChipQualities.some((quality) => value.quality === quality) &&
isRoll(value.primary) &&
isRollPair(value.secondary);
Array.isArray(value.secondary) &&
value.secondary.length ===
skillChipSecondaryCapacity[value.quality as SkillChipQuality] &&
value.secondary.every(isRoll);
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
isRecord(value) &&
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
@@ -353,7 +369,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
'skillActivations',
'specialization',
]) ||
value.version !== 4 ||
value.version !== 5 ||
typeof value.buildName !== 'string' ||
!['maximum', 'custom'].includes(String(value.calculationMode))
)
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import {
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
mechanicTargetingForMode,
mechanicTargetingMode,
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
@@ -200,6 +202,110 @@ describe('conditional mechanic authoring payload', () => {
});
});
it('exposes assigned-ability activity for kill and assist event conditions', () => {
expect(registeredEventFields('enemy_killed')).toEqual(
expect.arrayContaining([
{
path: 'values.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
]),
);
expect(registeredEventFields('assist')).toEqual(
expect.arrayContaining([
{
path: 'values.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
]),
);
const value = draft();
value.clauses[0].event = 'enemy_killed';
value.clauses[0].recipeKey = undefined;
value.clauses[0].reducer = { kind: 'none' };
value.clauses[0].if = eventFieldComparisonPredicate({
path: 'values.assignedAbilityActive',
operator: 'eq',
value: true,
});
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.programs.I.clauses[0]).toMatchObject({
when: 'enemy_killed',
if: value.clauses[0].if,
});
});
it('parses deterministic outcome and clause proc chances with ability mutations', () => {
const value = draft();
value.clauses[0].event = 'skill_hit';
value.clauses[0].recipeKey = undefined;
value.clauses[0].procChancePercent = 10;
value.clauses[0].requiresAssignedAbilityActive = true;
value.clauses[0].outcomes[0].probabilityPercent = 29;
value.clauses[0].outcomes[0].statePool = undefined;
value.clauses[0].outcomes[0].perStack = false;
value.pools = [
{
key: 'extension-stacks',
label: 'Extension stacks',
stateKind: 'stack',
scope: 'agent',
initial: 0,
min: 0,
max: 8,
},
{
key: 'extension-cooldown',
label: 'Extension cooldown',
stateKind: 'flag',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 15,
},
];
value.clauses[0].mutations = [
{ kind: 'add', pool: 'extension-stacks', value: 1 },
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
];
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.programs.I.clauses[0]).toMatchObject({
procChancePercent: 10,
requiresAssignedAbilityActive: true,
outcomes: [{ probabilityPercent: 29 }],
});
expect(result.value.programs.I.clauses[0].mutations).toEqual(
expect.arrayContaining([
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
]),
);
});
it('rejects an unregistered Extra Health event field deterministically', () => {
const value = draft();
value.clauses[0].event = 'extra_health_changed';
@@ -735,4 +841,125 @@ describe('conditional mechanic authoring payload', () => {
);
}
});
it('exposes aiming and round-trips event-target explosion editor state', () => {
expect(registeredEventFields('weapon_hit')).toContainEqual({
path: 'values.aiming',
label: 'Aiming down sights',
type: 'boolean',
});
const targeting = mechanicTargetingForMode('event_target_explosion');
expect(targeting).toEqual({
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
});
expect(mechanicTargetingMode(targeting)).toBe('event_target_explosion');
expect(mechanicTargetingForMode('none', targeting)).toBeUndefined();
const valid = draft();
valid.clauses[0].if = eventFieldComparisonPredicate({
path: 'values.aiming',
operator: 'eq',
value: true,
});
valid.clauses[0].outcomes[0] = {
...valid.clauses[0].outcomes[0],
kind: 'direct_damage',
scope: 'target',
phase: 'after_event',
attributeId: undefined,
targeting,
};
const parsed = parseConditionalMechanicDraft(valid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(parsed.ok).toBe(true);
if (parsed.ok) {
expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual(
targeting,
);
}
});
it('rejects incomplete, mis-scoped, and non-event area damage', () => {
for (const configure of [
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].outcomes[0].targeting = {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 0,
};
},
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].outcomes[0].scope = 'self';
},
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].outcomes[0].phase = 'while_active';
},
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].event = 'enemy_killed';
},
]) {
const invalid = draft();
invalid.clauses[0].outcomes[0] = {
...invalid.clauses[0].outcomes[0],
kind: 'direct_damage',
scope: 'target',
phase: 'after_event',
attributeId: undefined,
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
};
configure(invalid);
expect(
parseConditionalMechanicDraft(invalid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
}).ok,
).toBe(false);
}
});
it('requires explicit equality for optional aiming evidence', () => {
for (const predicate of [
{
kind: 'compare' as const,
left: { source: 'event' as const, path: 'values.aiming' },
operator: 'ne' as const,
right: true,
},
{
kind: 'not' as const,
predicate: {
kind: 'compare' as const,
left: { source: 'event' as const, path: 'values.aiming' },
operator: 'eq' as const,
right: true,
},
},
{
kind: 'compare' as const,
left: { source: 'event' as const, path: 'values.aiming' },
operator: 'ne' as const,
right: { source: 'combat' as const, path: 'currentHealth' },
},
]) {
const invalid = draft();
invalid.clauses[0].if = predicate;
expect(
parseConditionalMechanicDraft(invalid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
}).ok,
).toBe(false);
}
});
});
+114 -23
View File
@@ -1,12 +1,14 @@
import {
MECHANIC_EVENT_KINDS,
MECHANIC_EVENT_REGISTRY,
mechanicOutcomeTargetingError,
validateMechanicProgram,
type ClauseMutation,
type MechanicClause,
type MechanicEventKind,
type MechanicOutcome,
type MechanicOutcomeScope,
type MechanicOutcomeTargeting,
type MechanicProgram,
type MechanicResource,
type NumericExpression,
@@ -198,6 +200,44 @@ export function registeredEventFields(event: MechanicEventKind) {
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
}
export type MechanicTargetingMode =
| 'none'
| 'allies_within_radius'
| 'enemies_within_radius'
| 'event_target_explosion';
export function mechanicTargetingMode(
targeting: MechanicOutcomeTargeting | undefined,
): MechanicTargetingMode {
if (!targeting) return 'none';
if (targeting.kind === 'event_target_area') return 'event_target_explosion';
return targeting.population === 'allies'
? 'allies_within_radius'
: 'enemies_within_radius';
}
export function mechanicTargetingForMode(
mode: MechanicTargetingMode,
current?: MechanicOutcomeTargeting,
): MechanicOutcomeTargeting | undefined {
if (mode === 'none') return undefined;
const radiusMeters =
current?.radiusMeters ?? (mode === 'event_target_explosion' ? 5 : 20);
if (mode === 'event_target_explosion') {
return {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters,
};
}
return {
kind: 'within_radius',
population: mode === 'allies_within_radius' ? 'allies' : 'enemies',
radiusMeters,
};
}
export type StatePoolThresholdComparison = {
pool: string;
operator: PredicateComparisonOperator;
@@ -258,6 +298,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
'maxStacks',
'targeting',
'descriptionSlot',
'probabilityPercent',
],
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
kind: [
@@ -291,6 +332,25 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
continuous:
'Valid only for restoration. The value is the total restoration, delivered at a constant rate across durationSeconds. Each activation creates an independent stream; overlapping streams add and overheal is lost when it occurs.',
},
probabilitySemantics:
'probabilityPercent is an optional inclusive 0 through 100 percentage. In custom simulation, each eligible outcome rolls independently from the evaluation random stream. Maximum mode resolves every nonzero probability as successful.',
targeting: {
withinRadius: {
kind: 'within_radius',
population: ['allies', 'enemies'],
radiusMeters: 'positive finite number',
semantics:
'Legacy radius metadata. It preserves authored range but does not select or fan out runtime targets.',
},
eventTargetArea: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 'positive finite number',
semantics:
'Valid only for target-scoped direct damage in on_event or after_event phases on a weapon_hit clause. The triggering event must supply an authoritative event-target area sample. Every valid enemy at or inside the radius receives one equal damage emission, ordered by entity ID.',
},
},
} as const;
const phases = new Set<OutcomePhase>(
CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase,
@@ -371,6 +431,10 @@ function isReference(value: unknown): value is ValueReference {
);
case 'equipment':
return typeof value.path === 'string' && Boolean(value.path.trim());
case 'assigned_ability':
return (
value.path === 'active' || value.path === 'remainingDurationSeconds'
);
case 'attribute':
return (
typeof value.attributeId === 'string' &&
@@ -527,6 +591,17 @@ function isMutation(value: unknown): value is ClauseMutation {
isExpression(value.value))
);
}
if (value.kind === 'extend_duration') {
return value.target === 'assigned_ability' && isExpression(value.seconds);
}
if (value.kind === 'start_cooldown_on_max') {
return (
typeof value.stackPool === 'string' &&
Boolean(value.stackPool.trim()) &&
typeof value.cooldownPool === 'string' &&
Boolean(value.cooldownPool.trim())
);
}
return (
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
typeof value.pool === 'string' &&
@@ -724,30 +799,21 @@ function parseOutcome(
) {
errors.push(`${path} description slot must be a positive integer.`);
}
if (value.targeting !== undefined) {
if (
!object(value.targeting) ||
value.targeting.kind !== 'within_radius' ||
!['allies', 'enemies'].includes(String(value.targeting.population)) ||
!finite(value.targeting.radiusMeters) ||
value.targeting.radiusMeters <= 0
) {
errors.push(`${path} has invalid radius targeting.`);
} else if (
value.targeting.population === 'allies' &&
value.scope !== 'allies' &&
value.scope !== 'self_and_allies'
) {
errors.push(
`${path} ally radius targeting requires allies or self-and-allies scope.`,
);
} else if (
value.targeting.population === 'enemies' &&
value.scope !== 'target'
) {
errors.push(`${path} enemy radius targeting requires target scope.`);
}
if (
value.probabilityPercent !== undefined &&
(!finite(value.probabilityPercent) ||
value.probabilityPercent < 0 ||
value.probabilityPercent > 100)
) {
errors.push(`${path} probability must be from 0 through 100 percent.`);
}
const targetingError = mechanicOutcomeTargetingError({
kind,
scope: value.scope as MechanicOutcomeScope,
phase: value.phase as OutcomePhase,
targeting: value.targeting,
});
if (targetingError) errors.push(`${path} ${targetingError}`);
if (!object(value.variants)) {
errors.push(`${path} variants must be an object.`);
return null;
@@ -828,6 +894,22 @@ function parseClauses(
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
errors.push(`${path} has an invalid weight expression.`);
}
if (
candidate.procChancePercent !== undefined &&
(!finite(candidate.procChancePercent) ||
candidate.procChancePercent < 0 ||
candidate.procChancePercent > 100)
) {
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
}
if (
candidate.requiresAssignedAbilityActive !== undefined &&
typeof candidate.requiresAssignedAbilityActive !== 'boolean'
) {
errors.push(
`${path} assigned-ability activity requirement must be a boolean.`,
);
}
if (
candidate.cooldownSeconds !== undefined &&
(!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
@@ -902,6 +984,15 @@ function programsForDraft(
? {}
: { cooldownSeconds: clause.cooldownSeconds }),
...(clause.weight === undefined ? {} : { weight: clause.weight }),
...(clause.procChancePercent === undefined
? {}
: { procChancePercent: clause.procChancePercent }),
...(clause.requiresAssignedAbilityActive === undefined
? {}
: {
requiresAssignedAbilityActive:
clause.requiresAssignedAbilityActive,
}),
mutations: clause.mutations,
outcomes: clause.outcomes.map(
({
@@ -200,6 +200,143 @@ describe('conditional-effect persistence adapter', () => {
});
});
it('preserves event-target area damage semantics on an outcome', () => {
const result = adaptConditionalEffectProgram(
{
rule: rule(),
statePools: [],
clauses: [
clause({
trigger_id: null,
event_kind: 'weapon_hit',
state_pool_id: null,
mutations_json: '[]',
}),
],
outcomes: [
outcome({
application_kind: 'direct_damage',
effect_scope: 'target',
stat_attribute_id: null,
application_phase: 'after_event',
state_pool_id: null,
targeting_json: JSON.stringify({
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
}),
}),
],
triggers: [],
},
{ name: 'Critical Explosion', variantKey: 'I' },
);
expect(result.errors).toEqual([]);
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
kind: 'direct_damage',
scope: 'target',
phase: 'after_event',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
});
});
it('adapts assigned-ability activity predicates for kill and assist events', () => {
for (const eventKind of ['enemy_killed', 'assist'] as const) {
const result = adaptConditionalEffectProgram(
{
rule: rule(),
statePools: [],
clauses: [
clause({
trigger_id: null,
event_kind: eventKind,
parameters_json: '{}',
state_pool_id: null,
mutations_json: '[]',
conditions_json: JSON.stringify({
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
}),
}),
],
outcomes: [outcome({ state_pool_id: null })],
triggers: [],
},
{ variantKey: 'I' },
);
expect(result).toMatchObject({ status: 'READY', errors: [] });
expect(result.program?.clauses[0]).toMatchObject({
when: eventKind,
if: {
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
},
});
}
});
it('preserves outcome probability metadata', () => {
const result = adaptConditionalEffectProgram(
{
rule: rule(),
statePools: [],
clauses: [
clause({
trigger_id: null,
event_kind: 'shot_fired',
recipe_parameters_json: JSON.stringify({
procChancePercent: 10,
requiresAssignedAbilityActive: true,
}),
parameters_json: '{}',
state_pool_id: null,
mutations_json: '[]',
}),
],
outcomes: [
outcome({
state_pool_id: null,
application_kind: 'restore_ammo',
effect_scope: 'self',
stat_attribute_id: null,
resource_kind: 'ammo',
probability_percent: 29,
}),
],
triggers: [],
},
{ variantKey: 'I' },
);
expect(result).toMatchObject({ status: 'READY', errors: [] });
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
probabilityPercent: 29,
resource: 'ammo',
});
expect(result.program?.clauses[0]).toMatchObject({
procChancePercent: 10,
requiresAssignedAbilityActive: true,
});
});
it('loads continuous restoration delivery metadata', () => {
const result = adaptConditionalEffectProgram(
{
+54 -1
View File
@@ -54,6 +54,7 @@ export type ConditionalEffectClauseRow = {
event_kind?: DatabaseValue;
recipe_key?: DatabaseValue;
parameters_json: DatabaseValue;
recipe_parameters_json?: DatabaseValue;
conditions_json: DatabaseValue;
reducer_json: DatabaseValue;
weight_expression_json?: DatabaseValue;
@@ -85,6 +86,7 @@ export type ConditionalEffectOutcomeRow = {
delivery_mode?: DatabaseValue;
delivery_duration_seconds?: DatabaseValue;
targeting_json?: DatabaseValue;
probability_percent?: DatabaseValue;
value_scaling: DatabaseValue;
unit: DatabaseValue;
damage_basis: DatabaseValue;
@@ -205,6 +207,9 @@ function isValueReference(value: unknown) {
if (value.source === 'event' || value.source === 'equipment') {
return typeof value.path === 'string' && Boolean(value.path.trim());
}
if (value.source === 'assigned_ability') {
return value.path === 'active' || value.path === 'remainingDurationSeconds';
}
if (value.source === 'combat') {
return (
typeof value.path === 'string' &&
@@ -313,6 +318,19 @@ function isMutation(value: unknown): value is ClauseMutation {
isNumericExpression(value.value))
);
}
if (value.kind === 'extend_duration') {
return (
value.target === 'assigned_ability' && isNumericExpression(value.seconds)
);
}
if (value.kind === 'start_cooldown_on_max') {
return (
typeof value.stackPool === 'string' &&
Boolean(value.stackPool.trim()) &&
typeof value.cooldownPool === 'string' &&
Boolean(value.cooldownPool.trim())
);
}
return (
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
typeof value.pool === 'string' &&
@@ -731,6 +749,17 @@ function parseOutcome(
if (delivery === 'instant' && durationSeconds !== null) {
errors.push(`${path} instant delivery cannot set a duration.`);
}
const probabilityPercent = number(row.probability_percent);
if (
row.probability_percent !== undefined &&
row.probability_percent !== null &&
row.probability_percent !== '' &&
(probabilityPercent === null ||
probabilityPercent < 0 ||
probabilityPercent > 100)
) {
errors.push(`${path} probability must be from 0 through 100 percent.`);
}
let targeting: MechanicOutcome['targeting'] | undefined;
if (
row.targeting_json !== null &&
@@ -766,6 +795,7 @@ function parseOutcome(
? { delivery, durationSeconds }
: {}),
...(targeting ? { targeting } : {}),
...(probabilityPercent === null ? {} : { probabilityPercent }),
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
? { attributeId: text(row.stat_attribute_id) }
: {}),
@@ -903,7 +933,7 @@ export function adaptConditionalEffectProgram(
continue;
}
const parametersJson = parseJson(
row.parameters_json,
row.recipe_parameters_json ?? row.parameters_json,
`${path} parameters`,
errors,
);
@@ -976,6 +1006,25 @@ export function adaptConditionalEffectProgram(
}
}
const recipeKey = nullableText(row.recipe_key);
const procChancePercent = parametersJson.value.procChancePercent;
if (
procChancePercent !== undefined &&
(!finite(procChancePercent) ||
procChancePercent < 0 ||
procChancePercent > 100)
) {
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
}
const requiresAssignedAbilityActive =
parametersJson.value.requiresAssignedAbilityActive;
if (
requiresAssignedAbilityActive !== undefined &&
typeof requiresAssignedAbilityActive !== 'boolean'
) {
errors.push(
`${path} assigned-ability activity requirement must be a boolean.`,
);
}
clauses.push({
id,
label: text(row.label) || text(row.clause_key) || undefined,
@@ -989,6 +1038,10 @@ export function adaptConditionalEffectProgram(
? {}
: { cooldownSeconds: number(row.cooldown_seconds) as number }),
...(weight === undefined ? {} : { weight }),
...(typeof procChancePercent === 'number' ? { procChancePercent } : {}),
...(typeof requiresAssignedAbilityActive === 'boolean'
? { requiresAssignedAbilityActive }
: {}),
mutations: mutationsJson.value,
...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}),
});
+677
View File
@@ -19,6 +19,276 @@ const custom = (
inputs,
});
describe('deterministic mechanic probabilities and assigned abilities', () => {
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
const program: MechanicProgram = {
id: 'ammo-refill',
name: 'Ammo Refill',
pools: [
{
key: 'tactical-link-window',
label: 'Tactical Link window',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 6,
},
],
clauses: [
{
id: 'start-window',
when: 'signature_ability_deployed',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
},
{
id: 'refresh-window',
when: 'enemy_killed',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
},
{
id: 'recover-ammo',
when: 'shot_fired',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'tactical-link-window' },
operator: 'eq',
right: 1,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'recover-one-ammo',
label: 'Recover one ammo',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
basis: 'flat',
resource: 'ammo',
value: 1,
probabilityPercent: 29,
},
],
},
],
};
const events = [
{
kind: 'signature_ability_deployed' as const,
at: 0,
values: { assignedAbilityDurationSeconds: 6 },
},
{ kind: 'shot_fired' as const, at: 1 },
{ kind: 'shot_fired' as const, at: 2 },
{ kind: 'enemy_killed' as const, at: 5 },
{ kind: 'shot_fired' as const, at: 6 },
{ kind: 'shot_fired' as const, at: 11 },
];
const first = evaluateMechanicProgram(
program,
events,
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
);
const second = evaluateMechanicProgram(
program,
events,
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
);
expect(first.status).toBe('OK');
expect(first.randomSeed).toBe(1);
expect(first.outcomes.map((outcome) => outcome.at)).toEqual([2]);
expect(first.outcomes[0]).toMatchObject({
probabilityPercent: 29,
probabilityRoll: expect.any(Number),
appliedValue: 1,
});
expect(first.finalCombat?.currentAmmo).toBe(6);
expect(second.outcomes).toEqual(first.outcomes);
});
it('gates a proc before mutations, extends a live assigned ability, and starts cooldown on the eighth stack', () => {
const program: MechanicProgram = {
id: 'tactical-loop',
name: 'Tactical Loop',
pools: [
{
key: 'extension-stacks',
label: 'Extension stacks',
scope: 'agent',
initial: 7,
min: 0,
max: 8,
},
{
key: 'extension-cooldown',
label: 'Extension cooldown',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 15,
},
],
clauses: [
{
id: 'extend-tactical-link',
when: 'skill_hit',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'extension-cooldown' },
operator: 'eq',
right: 0,
},
requiresAssignedAbilityActive: true,
procChancePercent: 10,
reducer: { kind: 'none' },
mutations: [
{ kind: 'add', pool: 'extension-stacks', value: 1 },
{
kind: 'extend_duration',
target: 'assigned_ability',
seconds: 0.3,
},
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
],
},
],
};
const success = evaluateMechanicProgram(
program,
[
{
kind: 'signature_ability_deployed',
at: 0,
values: { assignedAbilityDurationSeconds: 6 },
},
{ kind: 'skill_hit', at: 5.9 },
{ kind: 'skill_hit', at: 6.1 },
],
{ mode: 'custom', encounterSeconds: 7, randomSeed: 7 },
);
const failed = evaluateMechanicProgram(
program,
[
{
kind: 'signature_ability_deployed',
at: 0,
values: { assignedAbilityDurationSeconds: 6 },
},
{ kind: 'skill_hit', at: 5.9 },
],
{ mode: 'custom', encounterSeconds: 7, randomSeed: 1 },
);
expect(success.status).toBe('OK');
expect(success.emissions).toHaveLength(1);
const extension = success.emissions[0].mutations.find(
(mutation) => mutation.kind === 'extend_duration',
);
expect(extension).toMatchObject({ kind: 'extend_duration' });
expect(extension?.before).toBeCloseTo(0.1);
expect(extension?.after).toBeCloseTo(0.4);
expect(success.emissions[0].mutations).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'start_cooldown_on_max',
pool: 'extension-cooldown',
after: 1,
}),
]),
);
expect(success.finalState).toEqual(
expect.arrayContaining([
expect.objectContaining({
pool: 'extension-stacks',
value: 8,
}),
expect.objectContaining({
pool: 'extension-cooldown',
value: 1,
expiresAt: 20.9,
}),
]),
);
expect(failed.emissions).toEqual([]);
});
it('does not extend an assigned ability at its exact expiry and rejects invalid chance data', () => {
const boundaryProgram: MechanicProgram = {
id: 'exact-expiry',
name: 'Exact expiry',
clauses: [
{
id: 'extend',
when: 'skill_hit',
requiresAssignedAbilityActive: true,
procChancePercent: 100,
reducer: { kind: 'none' },
mutations: [
{
kind: 'extend_duration',
target: 'assigned_ability',
seconds: 0.3,
},
],
},
],
};
expect(
evaluateMechanicProgram(
boundaryProgram,
[
{
kind: 'signature_ability_deployed',
at: 0,
values: { assignedAbilityDurationSeconds: 1 },
},
{ kind: 'skill_hit', at: 1 },
],
{ mode: 'custom', encounterSeconds: 2, randomSeed: 7 },
).emissions,
).toEqual([]);
const invalid = structuredClone(boundaryProgram);
invalid.clauses[0].procChancePercent = 101;
expect(validateMechanicProgram(invalid)).toMatchObject({
ok: false,
errors: [
expect.stringContaining('proc chance must be from 0 through 100'),
],
});
const invalidOutcome = structuredClone(boundaryProgram);
invalidOutcome.clauses[0].outcomes = [
{
id: 'bad-chance',
label: 'Bad chance',
phase: 'after_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
value: 1,
probabilityPercent: -1,
},
];
expect(validateMechanicProgram(invalidOutcome)).toMatchObject({
ok: false,
errors: [
expect.stringContaining('probability must be from 0 through 100'),
],
});
});
});
describe('structured mechanic runtime', () => {
it('advertises the authoritative weapon-hit target and headshot fields', () => {
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
@@ -32,6 +302,11 @@ describe('structured mechanic runtime', () => {
},
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
{
path: 'values.aiming',
label: 'Aiming down sights',
type: 'boolean',
},
{
path: 'values.behindCover',
label: 'Agent behind cover',
@@ -305,6 +580,161 @@ describe('structured mechanic runtime', () => {
});
});
it('matches kills and assists only while the assigned ability is active', () => {
const weaponCriticalHitChance = 'weapon-critical-hit-chance';
const activeOutcome = {
id: 'tactical-link-critical-chance',
label: 'Weapon Critical Hit Chance',
phase: 'while_active' as const,
kind: 'attribute_modifier' as const,
scope: 'self' as const,
basis: 'flat' as const,
value: 10.2,
attributeId: weaponCriticalHitChance,
statePool: 'tactical-link-kill-bonus',
};
const activePredicate = {
kind: 'compare' as const,
left: {
source: 'event' as const,
path: 'values.assignedAbilityActive',
},
operator: 'eq' as const,
right: true,
};
const program: MechanicProgram = {
id: 'tactical-link-kill-bonus',
name: 'Tactical Link Kill Bonus',
pools: [
{
key: 'tactical-link-kill-bonus',
label: 'Tactical Link kill bonus',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 10,
},
],
clauses: [
{
id: 'kill-during-tactical-link',
when: 'enemy_killed',
if: activePredicate,
reducer: { kind: 'none' },
mutations: [
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
],
outcomes: [activeOutcome],
},
{
id: 'assist-during-tactical-link',
when: 'assist',
if: activePredicate,
reducer: { kind: 'none' },
mutations: [
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
],
outcomes: [{ ...activeOutcome }],
},
],
};
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
const result = evaluateMechanicProgram(
program,
[
{
kind: 'enemy_killed',
at: 0,
values: { assignedAbilityActive: true },
},
{
kind: 'assist',
at: 4,
values: { assignedAbilityActive: false },
},
{ kind: 'enemy_killed', at: 6 },
{
kind: 'assist',
at: 10,
values: { assignedAbilityActive: true },
},
],
custom(19),
{
maxHealth: 1_000,
currentHealth: 1_000,
attributes: { [weaponCriticalHitChance]: 0 },
},
);
expect(result.status).toBe('OK');
expect(result.outcomes.map((outcome) => outcome.at)).toEqual([0, 10]);
expect(result.finalState).toContainEqual(
expect.objectContaining({
pool: 'tactical-link-kill-bonus',
value: 1,
expiresAt: 20,
}),
);
});
it('rejects invalid assigned-ability activity predicates and event values', () => {
const program: MechanicProgram = {
id: 'assigned-ability-activity',
name: 'Assigned ability activity',
clauses: [
{
id: 'kill',
when: 'enemy_killed',
if: {
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [],
},
],
};
const invalidPredicate = structuredClone(program);
invalidPredicate.clauses[0].if = {
kind: 'compare',
left: { source: 'event', path: 'values.assignedAbilityActive' },
operator: 'eq',
right: 'yes',
};
expect(validateMechanicProgram(invalidPredicate)).toMatchObject({
ok: false,
errors: [
expect.stringContaining('Assigned ability active with a boolean value'),
],
});
expect(
evaluateMechanicProgram(
program,
[
{
kind: 'enemy_killed',
at: 0,
values: { assignedAbilityActive: 'yes' },
} as unknown as MechanicEvent,
],
custom(1),
),
).toMatchObject({
status: 'INVALID',
errors: [
expect.stringContaining('Assigned ability active must be a boolean'),
],
});
});
it('executes imported talent trigger families as first-class events', () => {
const eventKinds: MechanicEventKind[] = [
'ally_downed',
@@ -2550,4 +2980,251 @@ describe('mechanic validation and pseudo-script', () => {
expect(result.script).toContain('TO ALLIES WITHIN 40m');
expect(result.script).toContain('TO ENEMIES WITHIN 20m');
});
it('applies aimed critical-hit area damage once to each valid enemy at or inside the radius', () => {
const program: MechanicProgram = {
id: 'aimed-critical-explosion',
name: 'Aimed Critical Explosion',
clauses: [
{
id: 'aimed-critical-hit',
when: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{
kind: 'compare',
left: { source: 'event', path: 'values.critical' },
operator: 'eq',
right: true,
},
{
kind: 'compare',
left: { source: 'event', path: 'values.aiming' },
operator: 'eq',
right: true,
},
],
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'explosion',
label: 'Explosion',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
value: {
op: 'multiply',
left: 20,
right: {
ref: { source: 'attribute', attributeId: 'firepower' },
},
},
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
probabilityPercent: 100,
},
],
},
],
};
const result = evaluateMechanicProgram(
program,
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy-b',
values: { critical: true, aiming: true },
areaTargetSample: {
center: 'event_target',
validTargets: [
{
entityId: 'enemy-c',
population: 'enemy',
distanceMeters: 5.001,
},
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
{ entityId: 'ally-a', population: 'ally', distanceMeters: 2 },
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
],
},
},
],
custom(5),
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
);
expect(result.status).toBe('OK');
expect(result.outcomes.map((outcome) => outcome.targetId)).toEqual([
'enemy-a',
'enemy-b',
]);
expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([
2000, 2000,
]);
expect(
new Set(result.outcomes.map((outcome) => outcome.probabilityRoll)).size,
).toBe(1);
expect(result.outcomes[0].probabilityRoll).not.toBeNull();
expect(result.script).toContain(
'EXPLOSION CENTERED ON EVENT TARGET; APPLY TO VALID ENEMIES WITHIN 5m',
);
const maximum = evaluateMechanicProgram(
program,
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy-b',
values: { critical: true, aiming: true },
areaTargetSample: {
center: 'event_target',
validTargets: [
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
],
},
},
],
{ mode: 'maximum', encounterSeconds: 5 },
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
);
expect(maximum.outcomes.map((outcome) => outcome.targetId)).toEqual([
'enemy-a',
'enemy-b',
]);
expect(
maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0),
).toBe(true);
});
it('requires authoritative area evidence only when an area outcome activates', () => {
const program: MechanicProgram = {
id: 'area-evidence',
name: 'Area Evidence',
clauses: [
{
id: 'critical-hit',
when: 'weapon_hit',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.critical' },
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'explosion',
label: 'Explosion',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
value: 1,
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
},
],
},
],
};
expect(
evaluateMechanicProgram(
program,
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy',
values: { critical: false },
},
],
custom(5),
),
).toMatchObject({ status: 'OK', outcomes: [] });
expect(
evaluateMechanicProgram(
program,
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy',
values: { critical: true },
},
],
custom(5),
),
).toMatchObject({
status: 'INVALID',
errors: [
expect.stringContaining('authoritative event-target area sample'),
],
});
});
it('rejects malformed and duplicate area-target samples', () => {
const result = evaluateMechanicProgram(
{ id: 'sample-validation', name: 'Sample Validation', clauses: [] },
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy',
areaTargetSample: {
center: 'event_target',
validTargets: [
{ entityId: 'enemy', population: 'enemy', distanceMeters: 0 },
{ entityId: 'enemy', population: 'enemy', distanceMeters: 1 },
],
},
},
],
custom(5),
);
expect(result).toMatchObject({
status: 'INVALID',
errors: [expect.stringContaining('duplicates entity "enemy"')],
});
const oversizedTargets = Array.from({ length: 257 }, (_, index) => ({
entityId: index === 0 ? 'enemy' : `enemy-${index}`,
population: 'enemy' as const,
distanceMeters: index === 0 ? 0 : 1,
}));
expect(
evaluateMechanicProgram(
{ id: 'sample-bound', name: 'Sample Bound', clauses: [] },
[
{
kind: 'weapon_hit',
at: 1,
targetId: 'enemy',
areaTargetSample: {
center: 'event_target',
validTargets: oversizedTargets,
},
},
],
custom(5),
),
).toMatchObject({
status: 'INVALID',
errors: [expect.stringContaining('cannot contain more than 256')],
});
});
});
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -24,6 +24,16 @@
function link(mark: ArticleMark) {
return mark.type === 'link' ? mark.attrs?.href ?? '#' : '#';
}
function orderedListStart(node: ArticleNode) {
const start = Number(node.attrs?.start ?? 1);
return Number.isInteger(start) && start > 1 ? start : undefined;
}
function orderedListType(node: ArticleNode): 'a' | 'A' | 'i' | 'I' | undefined {
const type = node.attrs?.type;
return ['a', 'A', 'i', 'I'].includes(String(type)) ? type as 'a' | 'A' | 'i' | 'I' : undefined;
}
</script>
{#snippet renderText(text: string, marks: ArticleMark[], index = 0)}
@@ -32,7 +42,7 @@
{#if mark.type === 'bold'}<strong>{@render renderText(text, marks, index + 1)}</strong>
{:else if mark.type === 'italic'}<em>{@render renderText(text, marks, index + 1)}</em>
{:else if mark.type === 'code'}<code>{@render renderText(text, marks, index + 1)}</code>
{:else}<a href={link(mark)} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
{:else}<a href={link(mark)} title={mark.attrs?.title} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
{/if}
{/snippet}
@@ -46,7 +56,7 @@
{:else if node.type === 'heading' && Number(node.attrs?.level) === 2}<h2>{@render children(node)}</h2>
{:else if node.type === 'heading'}<h3>{@render children(node)}</h3>
{:else if node.type === 'bulletList'}<ul>{@render children(node)}</ul>
{:else if node.type === 'orderedList'}<ol>{@render children(node)}</ol>
{:else if node.type === 'orderedList'}<ol start={orderedListStart(node)} type={orderedListType(node)}>{@render children(node)}</ol>
{:else if node.type === 'listItem'}<li>{@render children(node)}</li>
{:else if node.type === 'blockquote'}<blockquote>{@render children(node)}</blockquote>
{:else if node.type === 'horizontalRule'}<hr/>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import ArticleContent from '$lib/components/ArticleContent.svelte';
import ArticleDiscussion from '$lib/components/ArticleDiscussion.svelte';
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import { untrack } from 'svelte';
let { data, form = null } = $props();
@@ -25,7 +25,7 @@
<header class="article-reader-head">
<div class="article-reader-channel"><span>{data.article.channel ?? (data.article.kind === 'guide' ? 'COMMUNITY GUIDE' : 'NEWS')}</span>{#if data.article.featured}<b>FEATURED</b>{/if}{#if data.article.recommended}<b>RECOMMENDED</b>{/if}</div>
<h1>{data.article.title}</h1><p>{data.article.deck}</p>
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>OPEN NEWSROOM</a>{/if}</div></div>
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>Edit Article</a>{/if}</div></div>
{#if data.article.source}<aside class="article-source-notice"><div><span>OFFICIAL SOURCE</span><strong>Originally published by {data.article.source.name}</strong></div><a href={data.article.source.url} target="_blank" rel="noopener noreferrer">READ ON UBISOFT</a></aside>{/if}
{#if data.tags.length}<nav class="article-reader-tags" aria-label="Article tags">{#each data.tags as tag}<a href={`/${path}?tag=${encodeURIComponent(tag.slug)}`}>{tag.name}</a>{/each}</nav>{/if}
</header>
+43 -5
View File
@@ -6,6 +6,7 @@
import type { GameIconName } from '$lib/game-icons';
import type { DatabaseItem } from '$lib/database/items';
import MarkdownContent from './MarkdownContent.svelte';
import WeaponModSlotIcon from './WeaponModSlotIcon.svelte';
let {
item,
@@ -16,9 +17,21 @@
hideDiscussionUtility?: boolean;
anchor?: boolean;
} = $props();
let protocolLevel = $state(1);
let talentUpgrades = $state(0);
const number = (value: number) => Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
const valueWithUnit = (value: number, unit: string) => `${number(value)}${unit ? (unit === '%' ? '%' : ` ${unit}`) : ''}`;
const protocolPrimaryValue = () => item.protocolPreview
? item.protocolPreview.primaryBaseValue * (1 + (protocolLevel - 1) / 49)
: 0;
const talentDescription = () => item.protocolPreview?.talentDescription?.replace(/\{(\d+)\}/g, (placeholder, raw) => {
const effect = item.protocolPreview?.talentEffects[Number(raw) - 1];
return effect ? valueWithUnit(effect.baseValue * (1 + talentUpgrades / 5), effect.unit) : placeholder;
});
</script>
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'}>
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'} class:protocol-quality-standard={item.topBorderTone === 'standard'} class:protocol-quality-specialized={item.topBorderTone === 'specialized'} class:protocol-quality-superior={item.topBorderTone === 'superior'} class:protocol-quality-highend={item.topBorderTone === 'highend'}>
<header>
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
<div class="database-item-icon">
@@ -31,13 +44,38 @@
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
<div class="database-header-status">
{#if item.headerMetaIcon}<GameIcon name={item.headerMetaIcon}/>{/if}
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge">{badge}</span>{/each}
{#if item.headerBadge}<span class="database-header-badge">{item.headerBadge}</span>{/if}
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge" class:exotic={badge === 'EXOTIC'}>{badge}</span>{/each}
{#if item.headerBadge}<span class="database-header-badge" class:protocol-badge-standard={item.headerBadgeTone === 'standard'} class:protocol-badge-specialized={item.headerBadgeTone === 'specialized'} class:protocol-badge-superior={item.headerBadgeTone === 'superior'} class:protocol-badge-highend={item.headerBadgeTone === 'highend'}>{item.headerBadge}</span>{/if}
</div>
{/if}
</header>
{#if item.badges?.length}<div class="database-item-badges">{#each item.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
{#if item.protocolPreview}
<section class="database-protocol-preview">
<div class="database-protocol-primary"><span>{item.protocolPreview.primaryStat}</span><b>{Number.isFinite(protocolPrimaryValue()) ? valueWithUnit(protocolPrimaryValue(), item.protocolPreview.primaryUnit) : 'NOT CONFIGURED'}</b></div>
{#if item.protocolPreview.talentDescription}
<div class="database-protocol-talent"><p>{talentDescription()}</p></div>
{/if}
<div class="database-protocol-controls"><label class="database-protocol-level"><span>LVL {protocolLevel}</span><input type="range" min="1" max="50" step="1" bind:value={protocolLevel} aria-label={`${item.name} level`}/></label>{#if item.protocolPreview.talentDescription}<div class="database-protocol-diamonds" role="group" aria-label={`${item.name} talent upgrades`}>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrades >= upgrade} aria-label={`Set talent upgrades to ${upgrade}`} aria-pressed={talentUpgrades === upgrade} onclick={() => talentUpgrades = talentUpgrades === upgrade ? 0 : upgrade}>◆</button>{/each}</div>{/if}</div>
</section>
{:else}
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
{/if}
{#if item.weaponModSlots}
<div class="database-weapon-mod-slots">
<div><span>MOD SLOTS</span></div>
{#if item.weaponModSlots.length}
<ul aria-label={`${item.name} Weapon Mod slots`}>
{#each item.weaponModSlots as modSlot}
<li class:upgrade-slot={modSlot.unlockedAtTierOne} class:has-slot-icon={Boolean(modSlot.iconUrl)} title={`${modSlot.slot}${modSlot.unlockedAtTierOne ? ' - Unlocked when this weapon upgrades to Tier 1' : ''}`}>
{#if modSlot.iconUrl}<WeaponModSlotIcon slot={modSlot.slot} iconUrl={modSlot.iconUrl}/><span class="sr-only">{modSlot.slot}</span>{:else}<span>{modSlot.slot}</span>{/if}
{#if modSlot.unlockedAtTierOne}<small>T1+</small>{/if}
</li>
{/each}
</ul>
{:else}<p>MOD SLOT DATA NOT CONFIGURED</p>{/if}
</div>
{/if}
{#if item.lines?.length}
<div class="database-item-lines">{#each item.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
{/if}
@@ -53,7 +91,7 @@
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
</div>
{:else}
{:else if !item.protocolPreview}
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
{/if}
@@ -0,0 +1,125 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import {
MOD_QUALITIES,
MOD_SECONDARY_CAPACITY,
MOD_SECONDARY_QUALITIES,
type ModSecondaryRange
} from '$lib/mods/secondary-pool';
let {
kind,
pool,
stats,
error,
saved = false,
resultToken = ''
}: {
kind: 'Weapon Mod' | 'Gear Mod';
pool: ModSecondaryRange[];
stats: Array<{ id: string; name: string; unit: string }>;
error?: string;
saved?: boolean;
resultToken?: string;
} = $props();
const poolId = $derived(kind === 'Weapon Mod' ? 'weapon-mod-secondary-pool' : 'gear-mod-secondary-pool');
const action = '?/saveSecondaryPool';
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const blankSecondary = (): ModSecondaryRange => ({
statId: '',
values: Object.fromEntries(
MOD_SECONDARY_QUALITIES.map((quality) => [quality, {
level1Min: 0,
level1Max: 0,
level20Min: 0,
level20Max: 0
}])
) as ModSecondaryRange['values']
});
let editing = $state(false);
let draft = $state<ModSecondaryRange[]>([]);
let handledResultToken = $state('');
function openEditor() {
draft = pool.length ? structuredClone(pool) : [blankSecondary(), blankSecondary()];
editing = true;
}
function addSecondary() {
if (draft.length < 50) draft = [...draft, blankSecondary()];
}
function removeSecondary(index: number) {
if (draft.length > 2) draft = draft.filter((_, itemIndex) => itemIndex !== index);
}
$effect(() => {
if (saved && resultToken && resultToken !== handledResultToken) {
handledResultToken = resultToken;
editing = false;
}
});
</script>
<section class="mod-secondary-pool-registry">
<div class="selection-head">
<div>
<span>SHARED {kind.toUpperCase()} SECONDARY POOL</span>
<small>ONE CANONICAL POOL FOR EVERY REGULAR {kind.toUpperCase()}</small>
</div>
<button type="button" class="row-action" onclick={openEditor}>EDIT SHARED POOL →</button>
</div>
<div class="mod-secondary-capacity">
{#each MOD_QUALITIES as quality}
<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{MOD_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {MOD_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>
{/each}
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{pool.length}</strong><small>SHARED OPTIONS</small></span>
</div>
</section>
{#if editing}
<div class="dashboard-editor mod-secondary-pool-editor">
<div class="editor-title">
<div><p class="eyebrow">SHARED CONFIGURATION</p><h3>{kind.toUpperCase()} SECONDARY ATTRIBUTES</h3></div>
<button aria-label={`Close ${kind} secondary pool editor`} onclick={() => editing = false}>×</button>
</div>
<PendingForm {action}>
<input type="hidden" name="id" value={poolId}/>
<input type="hidden" name="name" value={`Shared ${kind} Secondary Pool`}/>
<input type="hidden" name="secondaryRanges" value={JSON.stringify(draft)}/>
<p class="console-note">Standard quality has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Ranges scale linearly from Level 1 to Level 20.</p>
<div class="selection-head pool-editor-head">
<div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END LEVEL ENDPOINTS</small></div>
<button type="button" class="row-action" onclick={addSecondary} disabled={draft.length >= 50}>ADD ATTRIBUTE +</button>
</div>
<div class="mod-secondary-pool-options">
{#each draft as range, index}
<div class="mod-secondary-pool-option">
<strong>{String(index + 1).padStart(2, '0')}</strong>
<SearchableAssignment
items={stats}
mode="single"
compact
subjectLabel="POSSIBLE ATTRIBUTE"
searchPlaceholder="Search attributes…"
excludedIds={draft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)}
bind:value={range.statId}
/>
{#each MOD_SECONDARY_QUALITIES as quality}
<div class="mod-secondary-quality-range">
<b class={qualityClass(quality)}>{quality.toUpperCase()}</b>
<label><span>LEVEL 1</span><div><input type="number" step="any" bind:value={range.values[quality].level1Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level1Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 maximum`} required/></div></label>
<label><span>LEVEL 20</span><div><input type="number" step="any" bind:value={range.values[quality].level20Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level20Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 maximum`} required/></div></label>
</div>
{/each}
<button type="button" aria-label={`Remove shared secondary attribute option ${index + 1}`} onclick={() => removeSecondary(index)} disabled={draft.length <= 2}>×</button>
</div>
{/each}
</div>
{#if error}<p class="error">{error}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
</PendingForm>
</div>
{/if}
@@ -29,8 +29,6 @@
gear_max: 'gearMax',
weapon_min: 'weaponMin',
weapon_max: 'weaponMax',
skill_chip_min: 'chipMin',
skill_chip_max: 'chipMax',
cap_value: 'capValue'
};
return proposed[names[key] ?? key];
@@ -46,6 +44,8 @@
);
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]);
const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) as any[]);
const skillChipFields = $derived([
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
@@ -256,7 +256,7 @@
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('current', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('current', `${source[0]}_max`))}</strong></div></div>
{/each}
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('current', 'cap_value'))}</strong></div>
@@ -273,13 +273,24 @@
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('proposed', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('proposed', `${source[0]}_max`))}</strong></div></div>
{/each}
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
</div>
</article>
</div>
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>BEFORE</b></header>
<div class="proposal-structured-list">{#each currentSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{:else}<p>SHARED PROFILE NOT CONFIGURED</p>{/each}</div>
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>AFTER</b></header>
<div class="proposal-structured-list">{#each proposedSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{/each}</div>
</article>
</div>
{:else if module === 'skillchips' && action === 'save'}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
@@ -287,7 +298,7 @@
{#if skillChipRecord}
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
<div><span>3-PIECE TALENT</span><strong>{currentSnapshot?.talent ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
@@ -301,7 +312,7 @@
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
<div><span>3-PIECE TALENT</span><strong>{proposed.hasTalent === '1' ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
@@ -0,0 +1,128 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import {
SKILL_CHIP_QUALITIES,
SKILL_CHIP_SECONDARY_CAPACITY,
SKILL_CHIP_SECONDARY_QUALITIES,
type SkillChipProfile
} from '$lib/skill-chips/profiles';
let {
primaryProfiles,
primaryProfileBase,
secondaryPool,
stats,
primaryError,
secondaryError,
primarySaved = false,
secondarySaved = false,
resultToken = ''
}: {
primaryProfiles: SkillChipProfile[];
primaryProfileBase: SkillChipProfile[];
secondaryPool: SkillChipProfile[];
stats: Array<{ id: string; name: string; unit: string }>;
primaryError?: string;
secondaryError?: string;
primarySaved?: boolean;
secondarySaved?: boolean;
resultToken?: string;
} = $props();
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const blankValues = (qualities: readonly string[]) => Object.fromEntries(
qualities.map((quality) => [quality, { min: '', max: '' }])
);
const hydrated = (profile: SkillChipProfile, qualities: readonly string[]) => ({
statId: profile.statId,
values: Object.fromEntries(qualities.map((quality) => [quality, {
min: profile.values[quality]?.min ?? '',
max: profile.values[quality]?.max ?? ''
}]))
});
const blankSecondary = () => ({ statId: '', values: blankValues(SKILL_CHIP_SECONDARY_QUALITIES) });
let primaryEditing = $state(false);
let secondaryEditing = $state(false);
let primaryDraft = $state<any[]>([]);
let secondaryDraft = $state<any[]>([]);
let handledPrimaryToken = $state('');
let handledSecondaryToken = $state('');
function openPrimary() {
primaryDraft = primaryProfiles.map((profile) => hydrated(profile, SKILL_CHIP_QUALITIES));
secondaryEditing = false;
primaryEditing = true;
}
function openSecondary() {
const loaded: any[] = secondaryPool.map((profile) =>
hydrated(profile, SKILL_CHIP_SECONDARY_QUALITIES)
);
while (loaded.length < 2) loaded.push(blankSecondary());
secondaryDraft = loaded;
primaryEditing = false;
secondaryEditing = true;
}
$effect(() => {
if (primarySaved && resultToken && resultToken !== handledPrimaryToken) {
handledPrimaryToken = resultToken;
primaryEditing = false;
}
if (secondarySaved && resultToken && resultToken !== handledSecondaryToken) {
handledSecondaryToken = resultToken;
secondaryEditing = false;
}
});
</script>
<div class="skill-chip-shared-config">
<section class="mod-secondary-pool-registry">
<div class="selection-head"><div><span>SHARED PRIMARY PROFILE</span><small>FOUR ABILITY-SCOPED STATS · ONE RANGE PER QUALITY</small></div><button type="button" class="row-action" onclick={openPrimary}>EDIT PRIMARY RANGES →</button></div>
<div class="mod-secondary-capacity">
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{primaryProfiles.filter((profile) => profile.values[quality]).length}</strong><small>OF 4 CONFIGURED</small></span>{/each}
<span class="pool-total"><b>CALCULATION</b><strong>—</strong><small>NOT YET DEFINED</small></span>
</div>
</section>
<section class="mod-secondary-pool-registry">
<div class="selection-head"><div><span>SHARED SECONDARY POOL</span><small>ONE CANONICAL ATTRIBUTE POOL FOR EVERY SKILL CHIP</small></div><button type="button" class="row-action" onclick={openSecondary}>EDIT SHARED POOL →</button></div>
<div class="mod-secondary-capacity">
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{SKILL_CHIP_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {SKILL_CHIP_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>{/each}
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{secondaryPool.length}</strong><small>SHARED OPTIONS</small></span>
</div>
</section>
</div>
{#if primaryEditing}
<div class="dashboard-editor skill-chip-profile-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP PRIMARY RANGES</h3></div><button aria-label="Close primary profile editor" onclick={() => primaryEditing = false}>×</button></div>
<PendingForm action="?/savePrimaryProfile">
<input type="hidden" name="id" value="skill-chip-primary-profile"/><input type="hidden" name="name" value="Shared Skill Chip Primary Profile"/><input type="hidden" name="baseProfiles" value={JSON.stringify(primaryProfileBase)}/><input type="hidden" name="profiles" value={JSON.stringify(primaryDraft)}/>
<p class="console-note">Every Skill Chip rolls one of these four ability-scoped primary stats. Configure the direct minimum and maximum for each quality. Current combat calculations do not apply these values.</p>
<div class="skill-chip-profile-options fixed">
{#each primaryDraft as profile}
<div class="skill-chip-profile-option"><div><strong>{stats.find((item) => item.id === profile.statId)?.name ?? 'UNKNOWN ATTRIBUTE'}</strong><small>{stats.find((item) => item.id === profile.statId)?.unit || 'FLAT'} · ABILITY SCOPED</small></div>{#each SKILL_CHIP_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} maximum`} required/></span></label>{/each}</div>
{/each}
</div>
{#if primaryError}<p class="error">{primaryError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => primaryEditing = false}>CANCEL</button><button class="primary">SAVE PRIMARY PROFILE →</button></div>
</PendingForm>
</div>
{/if}
{#if secondaryEditing}
<div class="dashboard-editor skill-chip-profile-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP SECONDARY POOL</h3></div><button aria-label="Close secondary pool editor" onclick={() => secondaryEditing = false}>×</button></div>
<PendingForm action="?/saveSecondaryPool">
<input type="hidden" name="id" value="skill-chip-secondary-pool"/><input type="hidden" name="name" value="Shared Skill Chip Secondary Pool"/><input type="hidden" name="baseProfiles" value={JSON.stringify(secondaryPool)}/><input type="hidden" name="profiles" value={JSON.stringify(secondaryDraft)}/>
<p class="console-note">Standard has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Skill Chips have no item level, so each quality uses one direct range.</p>
<div class="selection-head pool-editor-head"><div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END RANGES</small></div><button type="button" class="row-action" onclick={() => secondaryDraft = [...secondaryDraft, blankSecondary()]} disabled={secondaryDraft.length >= 50}>ADD ATTRIBUTE +</button></div>
<div class="skill-chip-profile-options">
{#each secondaryDraft as profile, index}
<div class="skill-chip-profile-option selectable"><strong>{String(index + 1).padStart(2, '0')}</strong><SearchableAssignment items={stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={secondaryDraft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)} bind:value={profile.statId}/>{#each SKILL_CHIP_SECONDARY_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} maximum`} required/></span></label>{/each}<button type="button" aria-label={`Remove secondary option ${index + 1}`} onclick={() => secondaryDraft = secondaryDraft.filter((_, itemIndex) => itemIndex !== index)} disabled={secondaryDraft.length <= 2}>×</button></div>
{/each}
</div>
{#if secondaryError}<p class="error">{secondaryError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => secondaryEditing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
</PendingForm>
</div>
{/if}
@@ -0,0 +1,29 @@
<script lang="ts">
let {
slot,
iconUrl,
decorative = true,
size = 24
}: { slot: string; iconUrl?: string | null; decorative?: boolean; size?: number } = $props();
</script>
{#if iconUrl}
<img
class="weapon-mod-slot-icon"
src={iconUrl}
alt={decorative ? '' : `${slot} Weapon Mod slot`}
loading="lazy"
width={size}
height={size}
style={`--weapon-mod-slot-icon-size: ${size}px`}
/>
{/if}
<style>
img {
width: var(--weapon-mod-slot-icon-size);
height: var(--weapon-mod-slot-icon-size);
flex: 0 0 var(--weapon-mod-slot-icon-size);
object-fit: contain;
}
</style>
@@ -0,0 +1,212 @@
<script lang="ts">
import IconUploadField from '$lib/components/IconUploadField.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import WeaponModSlotIcon from '$lib/components/WeaponModSlotIcon.svelte';
type SlotIcon = {
id: string;
name: string;
iconUrl: string;
updatedAt: string;
};
let {
slots,
error = '',
saved = false,
resultToken = ''
}: {
slots: SlotIcon[];
error?: string;
saved?: boolean;
resultToken?: string;
} = $props();
let editing = $state<SlotIcon | null>(null);
let handledResultToken = $state('');
$effect(() => {
if (saved && resultToken && resultToken !== handledResultToken) {
handledResultToken = resultToken;
editing = null;
}
});
</script>
<section class="slot-icon-registry" aria-labelledby="slot-icon-registry-title">
<div class="selection-head">
<div>
<span id="slot-icon-registry-title">SHARED MOD SLOT ICONS</span>
<small>ONE CANONICAL ICON FOR EACH WEAPON MOD SLOT</small>
</div>
<b>{slots.every((slot) => slot.iconUrl) ? 'READY' : 'CONFIG REQUIRED'}</b>
</div>
<div class="slot-icon-grid">
{#each slots as slot}
<button
type="button"
class:configured={Boolean(slot.iconUrl)}
aria-pressed={editing?.id === slot.id}
onclick={() => editing = slot}
>
<span class="slot-icon-preview">
{#if slot.iconUrl}
<WeaponModSlotIcon slot={slot.name} iconUrl={slot.iconUrl}/>
{:else}
<i aria-hidden="true">?</i>
{/if}
</span>
<span><strong>{slot.name}</strong><small>{slot.iconUrl ? 'ICON REGISTERED' : 'ICON REQUIRED'}</small></span>
<b>EDIT</b>
</button>
{/each}
</div>
</section>
{#if editing}
<section class="dashboard-editor slot-icon-editor" aria-labelledby="slot-icon-editor-title">
<div class="editor-title">
<div><p class="eyebrow">SHARED MOD SLOT ARTWORK</p><h3 id="slot-icon-editor-title">{editing.name.toUpperCase()}</h3></div>
<button aria-label="Close slot icon editor" onclick={() => editing = null}>×</button>
</div>
<PendingForm action="?/saveSlotIcon" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="name" value={`${editing.name} Slot`}/>
<input type="hidden" name="slotType" value={editing.name}/>
<input type="hidden" name="baseUpdatedAt" value={editing.updatedAt}/>
<div class="slot-icon-editor-body">
<div class="slot-icon-editor-identity">
<span class="slot-icon-preview large">
{#if editing.iconUrl}
<WeaponModSlotIcon slot={editing.name} iconUrl={editing.iconUrl} size={56}/>
{:else}
<i aria-hidden="true">?</i>
{/if}
</span>
<div><small>CANONICAL SLOT</small><strong>{editing.name}</strong><p>Weapon cards use this artwork anywhere the slot is available.</p></div>
</div>
<IconUploadField currentUrl={editing.iconUrl} label={`${editing.name.toUpperCase()} SLOT ICON`} resetToken={`${editing.id}:${editing.updatedAt}`}/>
</div>
{#if error}<p class="error" role="alert">{error}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE SLOT ICON →</button></div>
</PendingForm>
</section>
{/if}
<style>
.slot-icon-registry {
margin: 10px 0;
padding: 10px;
border: 1px solid var(--shd-border);
border-left: 2px solid var(--shd-info);
background: #0d1011;
}
.slot-icon-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
margin-top: 8px;
}
.slot-icon-grid > button {
min-height: 62px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
padding: 8px;
border: 1px solid var(--shd-border);
background: #101415;
color: var(--shd-text);
text-align: left;
cursor: pointer;
}
.slot-icon-grid > button:hover,
.slot-icon-grid > button:focus-visible,
.slot-icon-grid > button[aria-pressed='true'] {
border-color: var(--shd-orange);
background: #191712;
}
.slot-icon-grid > button:focus-visible {
outline: 2px solid var(--shd-orange);
outline-offset: 2px;
}
.slot-icon-grid > button > span:nth-child(2) {
min-width: 0;
display: grid;
gap: 3px;
}
.slot-icon-grid strong,
.slot-icon-grid small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slot-icon-grid strong {
font-size: var(--shd-type-label);
letter-spacing: .04em;
}
.slot-icon-grid small,
.slot-icon-grid > button > b {
color: var(--shd-text-muted);
font-size: var(--shd-type-micro);
letter-spacing: .07em;
}
.slot-icon-grid > button.configured small,
.slot-icon-grid > button.configured > b {
color: var(--shd-info);
}
.slot-icon-preview {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 1px solid #313839;
background: #090b0c;
}
.slot-icon-preview i {
color: #586164;
font-size: 20px;
font-style: normal;
font-weight: 900;
}
.slot-icon-editor {
border-top-color: var(--shd-info);
}
.slot-icon-editor-body {
display: grid;
grid-template-columns: minmax(260px, .7fr) minmax(360px, 1.3fr);
gap: 12px;
}
.slot-icon-editor-identity {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--shd-border);
background: #0d1011;
}
.slot-icon-preview.large {
width: 76px;
height: 76px;
flex: 0 0 76px;
}
.slot-icon-editor-identity > div {
display: grid;
gap: 5px;
}
.slot-icon-editor-identity small,
.slot-icon-editor-identity p {
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
}
.slot-icon-editor-identity p {
margin: 0;
}
@media (max-width: 900px) {
.slot-icon-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.slot-icon-editor-body { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
.slot-icon-grid { grid-template-columns: 1fr; }
}
</style>
@@ -9,6 +9,8 @@
blankConditionalMechanicDraft,
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
mechanicTargetingForMode,
mechanicTargetingMode,
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
@@ -21,6 +23,7 @@
type ConditionalMechanicPoolDraft,
type EditablePredicateScalar,
type EventFieldComparison,
type MechanicTargetingMode,
type PredicateComparisonOperator,
type StatePoolThresholdComparison
} from '$lib/calculations/mechanic-editor';
@@ -182,7 +185,9 @@
}
function eventPredicateOperatorOptions(clause: ConditionalMechanicClauseDraft) {
return selectedEventPredicateField(clause)?.type === 'number'
const field = selectedEventPredicateField(clause);
if (field?.path === 'values.aiming') return predicateOperatorOptions.filter((option) => option.value === 'eq');
return field?.type === 'number'
? predicateOperatorOptions
: predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne');
}
@@ -190,15 +195,21 @@
function setClauseEvent(clauseIndex: number, event: string) {
const nextEvent = event as ConditionalMechanicClauseDraft['event'];
const clause = rule.clauses[clauseIndex];
const outcomes = nextEvent === 'weapon_hit'
? clause.outcomes
: clause.outcomes.map((outcome) => outcome.targeting?.kind === 'event_target_area'
? { ...outcome, targeting: undefined }
: outcome);
const fields = registeredEventFields(nextEvent);
const comparison = readEventFieldComparison(clause.if);
if (!comparison || !fields.length) {
updateClause(clauseIndex, { event: nextEvent });
updateClause(clauseIndex, { event: nextEvent, outcomes });
return;
}
const field = fields[0];
updateClause(clauseIndex, {
event: nextEvent,
outcomes,
if: eventFieldComparisonPredicate({
path: field.path,
operator: 'eq',
@@ -403,6 +414,69 @@
updateClause(clauseIndex, { outcomes });
}
function targetingOptions(clause: ConditionalMechanicClauseDraft, outcome: ConditionalMechanicOutcomeDraft) {
if (outcome.scope === 'allies' || outcome.scope === 'self_and_allies') {
return [
{ value: 'none', label: 'NONE' },
{ value: 'allies_within_radius', label: 'ALLIES IN RADIUS' }
];
}
if (outcome.scope === 'target') {
return [
{ value: 'none', label: 'NONE' },
{ value: 'enemies_within_radius', label: 'ENEMIES IN RADIUS · LEGACY' },
...(outcome.kind === 'direct_damage' && clause.event === 'weapon_hit'
? [{ value: 'event_target_explosion', label: 'HIT-TARGET EXPLOSION' }]
: [])
];
}
return [{ value: 'none', label: 'NONE' }];
}
function setOutcomeScope(clauseIndex: number, outcomeIndex: number, scope: MechanicOutcomeScope) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
const targeting = outcome.targeting;
const compatible = !targeting
|| (targeting.kind === 'event_target_area' && scope === 'target' && outcome.kind === 'direct_damage')
|| (targeting.kind === 'within_radius' && targeting.population === 'enemies' && scope === 'target')
|| (targeting.kind === 'within_radius' && targeting.population === 'allies' && (scope === 'allies' || scope === 'self_and_allies'));
updateOutcome(clauseIndex, outcomeIndex, { scope, targeting: compatible ? targeting : undefined });
}
function setOutcomeKind(clauseIndex: number, outcomeIndex: number, kind: ConditionalMechanicOutcomeDraft['kind']) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
updateOutcome(clauseIndex, outcomeIndex, {
kind,
resource: kind === 'restoration' || kind === 'resource_cost' ? outcome.resource ?? 'health' : undefined,
attributeId: kind === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,
delivery: kind === 'restoration' ? outcome.delivery : undefined,
durationSeconds: kind === 'restoration' ? outcome.durationSeconds : undefined,
targeting: outcome.targeting?.kind === 'event_target_area' && kind !== 'direct_damage'
? undefined
: outcome.targeting
});
}
function setOutcomeTargeting(clauseIndex: number, outcomeIndex: number, mode: MechanicTargetingMode) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
updateOutcome(clauseIndex, outcomeIndex, {
targeting: mechanicTargetingForMode(mode, outcome.targeting),
phase: mode === 'event_target_explosion' && outcome.phase !== 'on_event' && outcome.phase !== 'after_event'
? 'after_event'
: outcome.phase
});
}
function phaseOptions(outcome: ConditionalMechanicOutcomeDraft) {
return [
{ value: 'on_event', label: 'ON EVENT' },
{ value: 'after_event', label: 'AFTER EVENT' },
{ value: 'while_active', label: 'WHILE ACTIVE', disabled: outcome.targeting?.kind === 'event_target_area' },
{ value: 'on_enter', label: 'ON ENTER', disabled: outcome.targeting?.kind === 'event_target_area' },
{ value: 'on_exit', label: 'ON EXIT', disabled: outcome.targeting?.kind === 'event_target_area' }
];
}
function updateVariant(clauseIndex: number, outcomeIndex: number, variant: string, raw: string) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
const value = Number(raw);
@@ -684,6 +758,50 @@
});
}
function addAbilityDurationExtension(clauseIndex: number) {
updateClause(clauseIndex, {
mutations: [
...rule.clauses[clauseIndex].mutations,
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 }
]
});
}
function addCooldownOnMax(clauseIndex: number) {
const stackPool = rule.pools.find((pool) => pool.max && pool.max > 0)?.key;
const cooldownPool = rule.pools.find((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds)?.key;
if (!stackPool || !cooldownPool || stackPool === cooldownPool) return;
updateClause(clauseIndex, {
mutations: [
...rule.clauses[clauseIndex].mutations,
{ kind: 'start_cooldown_on_max', stackPool, cooldownPool }
]
});
}
function updateAbilityDurationExtension(clauseIndex: number, mutationIndex: number, raw: string) {
const mutations = [...rule.clauses[clauseIndex].mutations];
const seconds = Number(raw);
mutations[mutationIndex] = {
kind: 'extend_duration',
target: 'assigned_ability',
seconds: Number.isFinite(seconds) ? seconds : 0
};
updateClause(clauseIndex, { mutations });
}
function updateCooldownOnMax(
clauseIndex: number,
mutationIndex: number,
patch: { stackPool?: string; cooldownPool?: string }
) {
const mutations = [...rule.clauses[clauseIndex].mutations];
const current = mutations[mutationIndex];
if (current.kind !== 'start_cooldown_on_max') return;
mutations[mutationIndex] = { ...current, ...patch };
updateClause(clauseIndex, { mutations });
}
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
const mutations = [...rule.clauses[clauseIndex].mutations];
const current = mutations[mutationIndex];
@@ -981,6 +1099,20 @@
{:else if conditionModel(clause) === 'advanced'}
<p class="advanced-condition-note">This saved condition remains active and is preserved unchanged. Use the generated mechanic preview to inspect it.</p>
{/if}
{#if allowAssignedAbility}
<div class="ability-proc-controls">
<SegmentedChoice
legend="ASSIGNED ABILITY"
name={`${idPrefix}-assigned-ability-${clauseIndex}`}
options={[{value:'false',label:'ANY STATE'},{value:'true',label:'MUST BE ACTIVE'}]}
value={String(clause.requiresAssignedAbilityActive ?? false)}
onchange={(values) => updateClause(clauseIndex,{requiresAssignedAbilityActive:values[0] === 'true' ? true : undefined})}
/>
<label class="field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
</div>
{:else}
<label class="field proc-chance-field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
{/if}
</div>
<div class="reducer-controls">
<SegmentedChoice
@@ -1007,38 +1139,53 @@
</div>
<div class="mutation-panel">
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button></div>
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><div class="mutation-actions"><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button>{#if allowAssignedAbility}<button type="button" class="secondary compact-action" onclick={() => addAbilityDurationExtension(clauseIndex)}>EXTEND ABILITY +</button>{/if}<button type="button" class="secondary compact-action" onclick={() => addCooldownOnMax(clauseIndex)} disabled={rule.pools.filter((pool) => pool.max && pool.max > 0).length < 2}>ADD MAX COOLDOWN +</button></div></div>
{#each clause.mutations as mutation, mutationIndex}
<div class="mutation-row">
<SegmentedChoice
legend="OPERATION"
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
/>
<SegmentedChoice
legend="STATE POOL"
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
columns={Math.min(4,Math.max(1,rule.pools.length))}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
/>
{#if mutation.kind !== 'clear'}
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
{/if}
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
{#if mutation.kind === 'extend_duration'}
<div class="mutation-row ability-mutation-row">
<div class="branch-readout"><span>OPERATION</span><strong>EXTEND ASSIGNED ABILITY</strong></div>
<label class="field">SECONDS<input type="number" min="0" step="any" value={typeof mutation.seconds === 'number' ? mutation.seconds : 0} oninput={(event) => updateAbilityDurationExtension(clauseIndex,mutationIndex,event.currentTarget.value)}/><small>Applies only while the assigned ability remains active.</small></label>
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{:else if mutation.kind === 'start_cooldown_on_max'}
<div class="mutation-row cooldown-mutation-row">
<div class="branch-readout"><span>OPERATION</span><strong>START COOLDOWN ON MAX</strong></div>
<SegmentedChoice legend="STACK POOL" name={`${idPrefix}-cooldown-stack-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.max && pool.max > 0).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.stackPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{stackPool:values[0]})}/>
<SegmentedChoice legend="COOLDOWN POOL" name={`${idPrefix}-cooldown-pool-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds && pool.key !== mutation.stackPool).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.cooldownPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{cooldownPool:values[0]})}/>
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{:else}
<div class="mutation-row">
<SegmentedChoice
legend="STATE OWNER"
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
legend="OPERATION"
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
/>
{/if}
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
<SegmentedChoice
legend="STATE POOL"
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
columns={Math.min(4,Math.max(1,rule.pools.length))}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
/>
{#if mutation.kind !== 'clear'}
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
{/if}
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
<SegmentedChoice
legend="STATE OWNER"
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
/>
{/if}
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{/if}
{:else}
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
{/each}
@@ -1049,25 +1196,24 @@
<div class="outcome-card">
<div class="outcome-grid">
<label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,delivery:values[0] === 'restoration' ? outcome.delivery : undefined,durationSeconds:values[0] === 'restoration' ? outcome.durationSeconds : undefined})}/>
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => setOutcomeKind(clauseIndex,outcomeIndex,values[0] as ConditionalMechanicOutcomeDraft['kind'])}/>
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div>
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => setOutcomeScope(clauseIndex,outcomeIndex,values[0] as MechanicOutcomeScope)}/>
{#if outcome.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'}
<div class="delivery-controls">
<SegmentedChoice
legend="AREA TARGETING"
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
options={[{value:'none',label:'NONE'},{value:'allies',label:'ALLIES IN RADIUS'},{value:'enemies',label:'ENEMIES IN RADIUS'}]}
value={outcome.targeting?.population ?? 'none'}
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
targeting: values[0] === 'none'
? undefined
: {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20}
})}
options={targetingOptions(clause,outcome)}
value={mechanicTargetingMode(outcome.targeting)}
onchange={(values) => setOutcomeTargeting(clauseIndex,outcomeIndex,values[0] as MechanicTargetingMode)}
/>
{#if outcome.targeting}
<label class="field">RADIUS (METERS)<input type="number" min="0.01" step="any" value={outcome.targeting.radiusMeters} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{targeting:{...outcome.targeting!,radiusMeters:Number(event.currentTarget.value)}})}/></label>
{#if outcome.targeting.kind === 'event_target_area'}
<p class="area-targeting-note"><strong>EVENT TARGET CENTER</strong><span>Damages each valid enemy at or inside the radius once. Runtime events must supply the authoritative target sample.</span></p>
{/if}
{/if}
</div>
{/if}
@@ -1154,8 +1300,9 @@
<label class="field">{column.label} VALUE<input type="number" step="any" value={outcome.variants[column.variantKey]?.value ?? 0} oninput={(event) => updateVariant(clauseIndex,outcomeIndex,column.variantKey,event.currentTarget.value)}/></label>
{/each}
</div>
<label class="field probability-field">OUTCOME CHANCE · %<input type="number" min="0" max="100" step="any" value={outcome.probabilityPercent ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{probabilityPercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Each eligible outcome rolls independently. Maximum Potential resolves each nonzero chance as successful.</small></label>
<div class="outcome-footer">
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={[{value:'on_event',label:'ON EVENT'},{value:'after_event',label:'AFTER EVENT'},{value:'while_active',label:'WHILE ACTIVE'},{value:'on_enter',label:'ON ENTER'},{value:'on_exit',label:'ON EXIT'}]} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={phaseOptions(outcome)} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{outcomes:clause.outcomes.filter((_,index) => index !== outcomeIndex)})}>REMOVE OUTCOME</button>
</div>
</div>
@@ -1198,6 +1345,8 @@
.branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0}
.branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
.condition-controls{display:grid;grid-template-columns:minmax(260px,.7fr) minmax(320px,1.3fr);align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
.ability-proc-controls{display:grid;grid-template-columns:minmax(240px,.7fr) minmax(180px,.4fr);align-items:end;gap:var(--shd-gap-sm)}
.ability-proc-controls .field small,.proc-chance-field small,.probability-field small,.ability-mutation-row .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
.health-boundaries{display:grid;grid-template-columns:repeat(2,minmax(150px,1fr));gap:var(--shd-gap-sm)}
.health-boundaries .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
.predicate-fields{display:grid;grid-template-columns:minmax(170px,1fr) minmax(190px,.8fr);align-items:end;gap:var(--shd-gap-sm)}
@@ -1207,12 +1356,15 @@
.branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)}
.reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
.mutation-panel{margin:0 var(--shd-gap-sm) var(--shd-gap-sm);border:1px solid var(--shd-border-soft)}
.mutation-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
.mutation-row{display:grid;grid-template-columns:minmax(260px,1fr) minmax(220px,1fr) minmax(110px,.35fr) minmax(240px,1fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
.ability-mutation-row{grid-template-columns:minmax(240px,1fr) minmax(180px,.5fr) auto}.cooldown-mutation-row{grid-template-columns:minmax(210px,.8fr) minmax(220px,1fr) minmax(220px,1fr) auto}.probability-field{max-width:260px}
.outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
.delivery-controls{display:grid;grid-template-columns:minmax(260px,1fr) minmax(150px,.35fr);align-items:end;gap:8px}.delivery-controls>.field{margin:0}
.area-targeting-note{grid-column:1/-1;display:grid;gap:2px;margin:0;padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-model-surface);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.area-targeting-note strong{color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
+15
View File
@@ -3,6 +3,11 @@ import type { GameDataTargetType } from '$lib/game-data-targets';
export type DatabaseBadge = { label: string; tone?: string };
export type DatabaseMeter = { label: string; value: number };
export type DatabaseWeaponModSlot = {
slot: string;
unlockedAtTierOne: boolean;
iconUrl?: string;
};
export type DatabaseFact = {
label: string;
value: string;
@@ -26,8 +31,10 @@ export type DatabaseItem = {
renderUrl?: string;
gameIcon?: GameIconName;
headerBadge?: string;
headerBadgeTone?: string;
headerBadges?: string[];
headerMetaIcon?: GameIconName;
topBorderTone?: string;
badges?: DatabaseBadge[];
facts: DatabaseFact[];
factLayout?: 'grid' | 'stack';
@@ -35,9 +42,17 @@ export type DatabaseItem = {
lines?: DatabaseFact[];
afterLines?: DatabaseFact[];
iconGroups?: DatabaseIconGroup[];
weaponModSlots?: DatabaseWeaponModSlot[];
description?: string;
secondaryDescription?: string;
descriptionStyle?: 'prose' | 'data';
protocolPreview?: {
primaryStat: string;
primaryUnit: string;
primaryBaseValue: number;
talentDescription?: string;
talentEffects: Array<{ baseValue: number; unit: string }>;
};
};
export type DatabaseRelatedItem = {
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import {
MOD_SECONDARY_CAPACITY,
normalizeModSecondaryPool,
} from './secondary-pool';
const endpoints = (
level1Min: number | string,
level1Max: number | string,
level20Min: number | string,
level20Max: number | string,
) => ({ level1Min, level1Max, level20Min, level20Max });
const range = (statId: string) => ({
statId,
values: {
Specialized: endpoints('1.5', '2.5', '3.5', '4.5'),
Superior: endpoints(2, 3, 4, 5),
'High-end': endpoints(3, 4, 5, 6),
},
});
describe('shared Mod secondary pool', () => {
it('defines secondary capacity for every Mod quality', () => {
expect(MOD_SECONDARY_CAPACITY).toEqual({
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
});
});
it('normalizes Attribute IDs and valid Level 1/20 endpoints', () => {
const result = normalizeModSecondaryPool([
range(' critical-hit-chance '),
range('critical-hit-damage'),
]);
expect(result.error).toBeNull();
expect(result.pool).toEqual([
{
statId: 'critical-hit-chance',
values: {
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
Superior: endpoints(2, 3, 4, 5),
'High-end': endpoints(3, 4, 5, 6),
},
},
{
statId: 'critical-hit-damage',
values: {
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
Superior: endpoints(2, 3, 4, 5),
'High-end': endpoints(3, 4, 5, 6),
},
},
]);
});
it('rejects duplicate Attributes after ID normalization', () => {
const result = normalizeModSecondaryPool([
range('critical-hit-chance'),
range(' critical-hit-chance '),
]);
expect(result).toEqual({
pool: [],
error: 'Choose a unique Attribute for every shared pool option.',
});
});
it.each([
[
'missing quality ranges',
{
...range('critical-hit-chance'),
values: {
Specialized: endpoints(1, 2, 3, 4),
Superior: endpoints(2, 3, 4, 5),
},
},
],
[
'reversed ranges',
{
...range('critical-hit-chance'),
values: {
...range('critical-hit-chance').values,
Specialized: endpoints(2, 1, 3, 4),
},
},
],
[
'Level 20 regression',
{
...range('critical-hit-chance'),
values: {
...range('critical-hit-chance').values,
Specialized: endpoints(2, 3, 1, 2),
},
},
],
])('rejects %s', (_label, invalidRange) => {
const result = normalizeModSecondaryPool([
invalidRange,
range('critical-hit-damage'),
]);
expect(result).toEqual({
pool: [],
error:
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
});
});
});
+102
View File
@@ -0,0 +1,102 @@
export const MOD_QUALITIES = [
'Standard',
'Specialized',
'Superior',
'High-end',
] as const;
export const MOD_SECONDARY_QUALITIES = [
'Specialized',
'Superior',
'High-end',
] as const;
export const MOD_SECONDARY_CAPACITY = {
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
} as const satisfies Record<(typeof MOD_QUALITIES)[number], number>;
export type ModSecondaryQuality = (typeof MOD_SECONDARY_QUALITIES)[number];
export type ModSecondaryRange = {
statId: string;
values: Record<
ModSecondaryQuality,
{
level1Min: number;
level1Max: number;
level20Min: number;
level20Max: number;
}
>;
};
export function normalizeModSecondaryPool(input: unknown): {
pool: ModSecondaryRange[];
error: string | null;
} {
if (!Array.isArray(input) || input.length < 2 || input.length > 50) {
return {
pool: [],
error: 'Configure between two and 50 shared secondary Attributes.',
};
}
const pool = input.map((candidate) => {
const range = candidate as Partial<ModSecondaryRange>;
return {
statId: typeof range.statId === 'string' ? range.statId.trim() : '',
values: Object.fromEntries(
MOD_SECONDARY_QUALITIES.map((quality) => {
const values = range.values?.[quality] as
| Partial<ModSecondaryRange['values'][ModSecondaryQuality]>
| undefined;
return [
quality,
{
level1Min: Number(values?.level1Min),
level1Max: Number(values?.level1Max),
level20Min: Number(values?.level20Min),
level20Max: Number(values?.level20Max),
},
];
}),
) as ModSecondaryRange['values'],
};
});
if (
pool.some((range) => !range.statId) ||
new Set(pool.map((range) => range.statId)).size !== pool.length
) {
return {
pool: [],
error: 'Choose a unique Attribute for every shared pool option.',
};
}
if (
pool.some((range) =>
MOD_SECONDARY_QUALITIES.some((quality) => {
const values = range.values[quality];
return (
Object.values(values).some((value) => !Number.isFinite(value)) ||
values.level1Max < values.level1Min ||
values.level20Max < values.level20Min ||
values.level20Min < values.level1Min ||
values.level20Max < values.level1Max
);
}),
)
) {
return {
pool: [],
error:
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
};
}
return { pool, error: null };
}
@@ -94,7 +94,7 @@ describe('AI mechanic authoring prompt', () => {
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds']),
);
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
]),
);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 7',
'Prompt contract version: 10',
);
const advertised = structuredClone(
@@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => {
expect(parsed.ok).toBe(true);
});
it('advertises the canonical Extra Health change event contract in version 7', () => {
it('advertises the canonical Extra Health change event contract in version 10', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
@@ -196,9 +196,9 @@ describe('AI mechanic authoring prompt', () => {
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 7',
'Prompt contract version: 10',
);
expect(input.canonicalCatalog.events).toContainEqual({
key: 'extra_health_changed',
@@ -222,6 +222,144 @@ describe('AI mechanic authoring prompt', () => {
});
});
it('advertises assigned-ability activity for kill and assist predicates', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [
MECHANIC_EVENT_REGISTRY.enemy_killed,
MECHANIC_EVENT_REGISTRY.assist,
],
actions: [],
recipeKeys: [],
}),
);
for (const event of input.canonicalCatalog.events) {
expect(event.fields).toEqual(
expect.arrayContaining([
{
path: 'values.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
]),
);
}
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('values.assignedAbilityActive'),
]),
);
});
it('advertises and round-trips aimed event-target area damage', () => {
const protocolRequest: AiMechanicAuthoringRequest = {
...request,
context: {
sourceKind: 'os_protocol_talent',
sourceName: 'Untitled mechanic',
sourceDescription:
'Critical Hits performed during aiming create an explosion with a radius of 5 meters around the target that deals damage equal to 2000% Firepower to them.',
},
valueColumns: [{ key: 'base', variantKey: 'base', label: 'BASE' }],
allowProtocolUpgradeScaling: true,
};
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request: protocolRequest,
attributes: [
{
id: 'firepower-id',
name: 'Firepower',
description: null,
unit: '',
type: null,
category: null,
calculationType: null,
},
],
events: [MECHANIC_EVENT_REGISTRY.weapon_hit],
actions: [],
recipeKeys: [],
}),
);
expect(input.canonicalCatalog.events[0].fields).toContainEqual({
path: 'values.aiming',
label: 'Aiming down sights',
type: 'boolean',
});
expect(
input.canonicalCatalog.grammar.outcome.targeting.eventTargetArea,
).toMatchObject({
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
});
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('values.aiming'),
expect.stringContaining('event_target_area'),
]),
);
const advertised = structuredClone(
input.canonicalCatalog.grammar.aimedCriticalAreaDamageShape,
);
const outcome = advertised.clauses[0].outcomes[0];
const base = JSON.parse(
JSON.stringify(
outcome.variants['replace-with-each-required-variant-key'],
).replaceAll(
'replace-with-an-exact-catalog-attribute-id',
'firepower-id',
),
);
outcome.variants = { base };
const parsed = parseConditionalMechanicDraft(advertised, {
variantKeys: ['base'],
allowedAttributeIds: new Set(['firepower-id']),
});
expect(parsed.ok).toBe(true);
});
it('advertises reproducible outcome and clause proc probabilities in version 10', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [MECHANIC_EVENT_REGISTRY.skill_hit],
actions: [],
recipeKeys: [],
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
expect.arrayContaining([
'procChancePercent',
'requiresAssignedAbilityActive',
]),
);
expect(input.canonicalCatalog.grammar.mutationKinds).toEqual(
expect.arrayContaining(['extend_duration', 'start_cooldown_on_max']),
);
expect(
input.canonicalCatalog.grammar.probabilityOutcomeShape,
).toMatchObject({
probabilityPercent: 29,
resource: 'ammo',
});
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('procChancePercent'),
expect.stringContaining('reproducible seeded random stream'),
]),
);
});
it('advertises authoritative current Health percentage with strict band semantics', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
+107 -2
View File
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 7;
export const AI_MECHANIC_PROMPT_VERSION = 10;
const mechanicsGrammar = {
version: 1,
@@ -40,7 +40,14 @@ const mechanicsGrammar = {
},
clause: {
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
optional: ['label', 'recipeKey', 'cooldownSeconds', 'weight'],
optional: [
'label',
'recipeKey',
'cooldownSeconds',
'weight',
'procChancePercent',
'requiresAssignedAbilityActive',
],
},
predicateKinds: ['true', 'exists', 'compare', 'all', 'any', 'not'],
compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'],
@@ -63,6 +70,8 @@ const mechanicsGrammar = {
'derive',
'clear',
'transfer',
'extend_duration',
'start_cooldown_on_max',
],
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
valueReferences: [
@@ -70,6 +79,7 @@ const mechanicsGrammar = {
'scenario',
'combat',
'equipment',
'assigned_ability',
'attribute',
'reducer',
'pool',
@@ -97,6 +107,7 @@ const mechanicsGrammar = {
},
},
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
{ ref: { source: 'assigned_ability', path: 'remainingDurationSeconds' } },
{
ref: {
source: 'attribute',
@@ -200,6 +211,16 @@ const mechanicsGrammar = {
value: 'all',
clearSource: true,
},
{
kind: 'extend_duration',
target: 'assigned_ability',
seconds: 0.3,
},
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
],
outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES,
continuousRestorationOutcomeShape: {
@@ -232,6 +253,83 @@ const mechanicsGrammar = {
},
},
},
probabilityOutcomeShape: {
key: 'recover-ammo',
label: 'Recover Ammo',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
basis: 'flat',
resource: 'ammo',
probabilityPercent: 29,
variants: {
'replace-with-each-required-variant-key': { value: 1 },
},
},
aimedCriticalAreaDamageShape: {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'aimed-critical-explosion',
label: 'Aimed critical hit explosion',
event: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.critical' },
operator: 'eq',
right: true,
},
{
kind: 'compare',
left: { source: 'event', path: 'values.aiming' },
operator: 'eq',
right: true,
},
],
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'critical-explosion-damage',
label: 'Critical explosion damage',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
variants: {
'replace-with-each-required-variant-key': {
value: 2000,
expression: {
op: 'multiply',
left: 20,
right: {
ref: {
source: 'attribute',
attributeId: 'replace-with-an-exact-catalog-attribute-id',
},
},
},
},
},
},
],
},
],
},
preciseTraumaShape: {
version: 1,
simulationReadiness: 'ready',
@@ -453,9 +551,16 @@ export function buildAiMechanicAuthoringInput(input: {
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.',
'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.',
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.',
'When allowAssignedAbility is true, requiresAssignedAbilityActive gates a clause on the runtime assigned-ability state. skill_deployed and signature_ability_deployed may supply values.assignedAbilityDurationSeconds to begin that state. At an exact expiry timestamp, the ability expires before a later clause can extend it.',
'procChancePercent is a clause-level independent percentage roll after its predicate and cooldown checks but before the reducer, mutations, and outcomes. Use it when a successful proc changes state. probabilityPercent is an outcome-level independent percentage roll for one outcome only.',
'Custom simulation uses a reproducible seeded random stream. Maximum Potential resolves every nonzero chance as successful. Never use clause weight as a proc chance.',
'extend_duration may target only assigned_ability and does nothing while the ability is inactive. start_cooldown_on_max starts a configured expiring Agent cooldown pool only when a mutation in the same clause transitions its capped stack pool to its maximum.',
'If cadence is not stated, ask rather than assuming a one-second interval.',
'If wording contains separate actions such as restoration on a periodic snapshot and a health cost on every shot, represent them as separate clauses.',
'A pool with expiresAfterSeconds refreshes the shared expiry for every current stack whenever any mutation writes that pool. Use it for mechanics where gaining a stack refreshes the duration of the full stack pool.',
@@ -9,21 +9,35 @@ describe('AI mechanic authoring rate limit', () => {
beforeEach(() => resetAiMechanicRateLimitForTests());
it('allows only one in-flight request per account', () => {
const release = reserveAiMechanicGeneration('agent-1', 1_000);
expect(() => reserveAiMechanicGeneration('agent-1', 1_001)).toThrow(
const release = reserveAiMechanicGeneration('agent-1', false, 1_000);
expect(() => reserveAiMechanicGeneration('agent-1', false, 1_001)).toThrow(
AiMechanicRateLimitError,
);
release();
expect(() => reserveAiMechanicGeneration('agent-1', 1_002)).not.toThrow();
expect(() =>
reserveAiMechanicGeneration('agent-1', false, 1_002),
).not.toThrow();
});
it('enforces the hourly account limit', () => {
for (let request = 0; request < 10; request += 1) {
const release = reserveAiMechanicGeneration('agent-1', 10_000 + request);
const release = reserveAiMechanicGeneration(
'agent-1',
false,
10_000 + request,
);
release();
}
expect(() => reserveAiMechanicGeneration('agent-1', 10_100)).toThrow(
expect(() => reserveAiMechanicGeneration('agent-1', false, 10_100)).toThrow(
'This account has reached the hourly mechanic authoring limit.',
);
});
it('does not limit administrators', () => {
const releases = Array.from({ length: 101 }, () =>
reserveAiMechanicGeneration('admin-1', true, 10_000),
);
for (const release of releases) release();
});
});
@@ -27,7 +27,12 @@ const retryAfter = (requests: number[], now: number) =>
* duplicate submissions and basic abuse; OpenAI project budgets remain the hard
* cross-instance spending boundary.
*/
export function reserveAiMechanicGeneration(userId: string, now = Date.now()) {
export function reserveAiMechanicGeneration(
userId: string,
isAdmin = false,
now = Date.now(),
) {
if (isAdmin) return () => {};
if (inFlightUsers.has(userId)) {
throw new AiMechanicRateLimitError(
'A mechanic authoring request is already running for this account.',
+2 -1
View File
@@ -34,6 +34,7 @@ export async function recordIsacAudit(input: {
export async function auditRecord(
table: string,
id: string,
suppliedDatabase?: Database,
): Promise<Record<string, unknown> | null> {
if (
![
@@ -56,7 +57,7 @@ export async function auditRecord(
) {
throw new Error('Unsupported audit table');
}
const database = await getDatabase();
const database = suppliedDatabase ?? (await getDatabase());
return (
(await (
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
+54 -2
View File
@@ -187,6 +187,7 @@ describe('conditional mechanic persistence adapter', () => {
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
combat_timing TEXT, resource_kind TEXT, delivery_mode TEXT,
delivery_duration_seconds REAL, targeting_json TEXT, min_state_value REAL, max_state_value REAL,
probability_percent REAL,
description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT
)`);
@@ -220,6 +221,8 @@ describe('conditional mechanic persistence adapter', () => {
key: 'weapon-kill',
label: 'Weapon kill',
event: 'enemy_killed',
procChancePercent: 29,
requiresAssignedAbilityActive: true,
if: {
kind: 'compare',
left: {
@@ -255,6 +258,7 @@ describe('conditional mechanic persistence adapter', () => {
population: 'allies',
radiusMeters: 40,
},
probabilityPercent: 29,
variants: {
I: { value: 240 },
II: { value: 336 },
@@ -263,12 +267,18 @@ describe('conditional mechanic persistence adapter', () => {
{
key: 'target-detonation',
label: 'Target Detonation',
phase: 'on_exit',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
statePool: 'marked-target',
perStack: true,
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
variants: {
I: {
value: 240,
@@ -322,6 +332,23 @@ describe('conditional mechanic persistence adapter', () => {
mutations: [],
outcomes: [],
},
{
key: 'tactical-link-assist',
label: 'Tactical Link assist',
event: 'assist',
if: {
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [],
},
],
},
);
@@ -366,11 +393,23 @@ describe('conditional mechanic persistence adapter', () => {
population: 'allies',
radiusMeters: 40,
},
probabilityPercent: 29,
variants: {
I: { value: 240 },
II: { value: 336 },
},
});
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
});
expect(loaded?.clauses[0].if).toEqual({
kind: 'compare',
left: {
@@ -381,6 +420,10 @@ describe('conditional mechanic persistence adapter', () => {
operator: 'lt',
right: 75,
});
expect(loaded?.clauses[0]).toMatchObject({
procChancePercent: 29,
requiresAssignedAbilityActive: true,
});
expect(loaded?.pools[0]).toMatchObject({
key: 'marked-target',
stateKind: 'target_mark',
@@ -399,7 +442,7 @@ describe('conditional mechanic persistence adapter', () => {
},
]);
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
phase: 'on_exit',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
statePool: 'marked-target',
@@ -430,5 +473,14 @@ describe('conditional mechanic persistence adapter', () => {
right: true,
},
});
expect(loaded?.clauses[2]).toMatchObject({
event: 'assist',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.assignedAbilityActive' },
operator: 'eq',
right: true,
},
});
});
});
+32 -3
View File
@@ -90,6 +90,7 @@ const outcomeMetadataColumns = [
'delivery_mode',
'delivery_duration_seconds',
'targeting_json',
'probability_percent',
'state_pool_id',
'min_state_value',
'max_state_value',
@@ -205,6 +206,10 @@ export function conditionalRuleDraftFromRows(
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
.map((row): ConditionalMechanicClauseDraft => {
const parameters = parseJson<Record<string, unknown>>(
row.recipe_parameters_json,
{},
);
const grouped = new Map<string, OutcomeRow[]>();
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
const key = String(outcome.effect_key);
@@ -268,6 +273,10 @@ export function conditionalRuleDraftFromRows(
...(first.targeting_json
? { targeting: parseJson(first.targeting_json, undefined) }
: {}),
...(first.probability_percent === null ||
first.probability_percent === undefined
? {}
: { probabilityPercent: Number(first.probability_percent) }),
...(first.stat_attribute_id
? { attributeId: String(first.stat_attribute_id) }
: {}),
@@ -329,6 +338,15 @@ export function conditionalRuleDraftFromRows(
),
}
: {}),
...(typeof parameters.procChancePercent === 'number'
? { procChancePercent: parameters.procChancePercent }
: {}),
...(typeof parameters.requiresAssignedAbilityActive === 'boolean'
? {
requiresAssignedAbilityActive:
parameters.requiresAssignedAbilityActive,
}
: {}),
...(row.cooldown_seconds === null || row.cooldown_seconds === undefined
? {}
: { cooldownSeconds: Number(row.cooldown_seconds) }),
@@ -495,7 +513,17 @@ export async function saveConditionalRuleInTransaction(
clause.label || clause.key,
clause.event,
clause.recipeKey ?? null,
'{}',
JSON.stringify({
...(clause.procChancePercent === undefined
? {}
: { procChancePercent: clause.procChancePercent }),
...(clause.requiresAssignedAbilityActive === undefined
? {}
: {
requiresAssignedAbilityActive:
clause.requiresAssignedAbilityActive,
}),
}),
JSON.stringify(clause.if ?? { kind: 'true' }),
JSON.stringify(clause.reducer),
clause.weight === undefined ? null : JSON.stringify(clause.weight),
@@ -518,9 +546,9 @@ export async function saveConditionalRuleInTransaction(
application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
resource_kind,delivery_mode,delivery_duration_seconds,
targeting_json,min_state_value,max_state_value,description_slot,sort_order,
targeting_json,probability_percent,min_state_value,max_state_value,description_slot,sort_order,
created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
randomUUID(),
ruleId,
@@ -552,6 +580,7 @@ export async function saveConditionalRuleInTransaction(
outcome.targeting === undefined
? null
: JSON.stringify(outcome.targeting),
outcome.probabilityPercent ?? null,
outcome.minStacks ?? null,
outcome.maxStacks ?? null,
outcome.descriptionSlot ?? null,
+20 -3
View File
@@ -37,14 +37,31 @@ const proposalResultKeys: Partial<
saveAugmentProfile: { saved: 'augmentProfileSaved' },
},
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
weaponmods: {
save: { saved: 'weaponModSaved', mode: 'weaponModMode' },
saveSlotIcon: { saved: 'weaponModSlotIconSaved' },
saveSecondaryPool: {
saved: 'weaponModSecondaryPoolSaved',
mode: 'weaponModSecondaryPoolMode',
},
},
gearmods: {
save: { saved: 'gearModSaved', mode: 'gearModMode' },
saveSecondaryPool: {
saved: 'gearModSecondaryPoolSaved',
mode: 'gearModSecondaryPoolMode',
},
},
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
protocols: {
save: { saved: 'protocolSaved', mode: 'protocolMode' },
saveProfile: { saved: 'profileSaved' },
},
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
skillchips: {
save: { saved: 'skillChipSaved', mode: 'skillChipMode' },
savePrimaryProfile: { saved: 'skillChipPrimaryProfileSaved' },
saveSecondaryPool: { saved: 'skillChipSecondaryPoolSaved' },
},
specializations: {
saveProgression: { saved: 'progressionSaved' },
saveSpecialization: {
+12 -1
View File
@@ -7,6 +7,7 @@ import {
BackgroundRemovalError,
removeBackgroundPreview,
} from './remove-background';
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
export const GAME_ICON_TYPES = [
'talent',
@@ -16,6 +17,7 @@ export const GAME_ICON_TYPES = [
'specialization',
'specialization_ability',
'specialization_node',
'weapon_mod_slot',
] as const;
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
@@ -23,7 +25,7 @@ export const MAX_GAME_ICON_PIXELS = 12_000_000;
export const GAME_ICON_SIZE = 256;
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
const STORAGE_KEY_PATTERN =
/^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
/^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i;
const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
talent: 'Talents',
brand_set: 'Brands',
@@ -32,6 +34,7 @@ const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
specialization: 'Specializations',
specialization_ability: 'Specializations',
specialization_node: 'Specializations',
weapon_mod_slot: 'Weapon Mods',
};
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
talent: 'Talent',
@@ -41,6 +44,7 @@ const LIBRARY_LABEL: Record<GameDataIconType, string> = {
specialization: 'Specialization',
specialization_ability: 'Specialization Ability',
specialization_node: 'Specialization Tree Node',
weapon_mod_slot: 'Weapon Mod Slot',
};
export class GameDataIconValidationError extends Error {}
@@ -255,6 +259,13 @@ async function libraryMetadata(
],
};
}
case 'weapon_mod_slot': {
const definition = weaponModSlotDefinitionById(entityId);
return {
name: definition ? `${definition.name} Slot` : null,
tags: ['weapon mod', 'slot', definition?.name],
};
}
}
return { name: null, tags: [] };
}
+167 -2
View File
@@ -1,17 +1,24 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import sharp from 'sharp';
const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() }));
const { mkdirMock, rmMock, writeFileMock } = vi.hoisted(() => ({
mkdirMock: vi.fn(),
rmMock: vi.fn(),
writeFileMock: vi.fn(),
}));
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof import('node:fs/promises')>()),
mkdir: mkdirMock,
rm: rmMock,
writeFile: writeFileMock,
}));
import { GAME_ICON_TYPES } from './game-data-icons';
import {
GAME_DATA_RENDER_TYPES,
ManagedImageDeletionError,
applyGameDataRender,
deleteManagedImage,
processManagedImage,
} from './managed-images';
@@ -26,6 +33,16 @@ class FakeDatabase {
prepare(sql: string) {
return Promise.resolve({
get: async (...args: unknown[]) => {
if (
sql.startsWith('SELECT * FROM managed_images') &&
sql.includes('source_entity_type')
) {
return [...this.images.values()].find(
(row) =>
row.source_entity_type === args[0] &&
row.source_entity_id === args[1],
);
}
if (sql.startsWith('SELECT * FROM managed_images'))
return this.images.get(String(args[0]));
if (sql.startsWith('SELECT * FROM game_data_icons')) {
@@ -42,7 +59,64 @@ class FakeDatabase {
}
if (sql.startsWith('DELETE FROM managed_images')) {
const row = this.images.get(String(args[0]));
if (row?.storage_key === args[1]) this.images.delete(String(args[0]));
if (!sql.includes('storage_key') || row?.storage_key === args[1])
this.images.delete(String(args[0]));
return;
}
if (sql.startsWith('UPDATE managed_images SET')) {
const row = this.images.get(String(args[7]));
if (!row) throw new Error('Missing managed image for update');
this.images.set(String(args[7]), {
...row,
category: args[0],
tags_json: args[1],
is_icon: 0,
force_white: 0,
original_filename: args[2],
byte_size: args[3],
width: args[4],
height: args[5],
updated_at: args[6],
});
return;
}
if (sql.startsWith('INSERT INTO managed_images')) {
const [
id,
name,
category,
tagsJson,
isIcon,
forceWhite,
storageKey,
originalFilename,
byteSize,
width,
height,
sourceType,
sourceEntityId,
createdBy,
createdAt,
updatedAt,
] = args;
this.images.set(String(id), {
id,
name,
category,
tags_json: tagsJson,
is_icon: isIcon,
force_white: forceWhite,
storage_key: storageKey,
original_filename: originalFilename,
byte_size: byteSize,
width,
height,
source_entity_type: sourceType,
source_entity_id: sourceEntityId,
created_by: createdBy,
created_at: createdAt,
updated_at: updatedAt,
});
return;
}
throw new Error(`Unexpected run: ${sql}`);
@@ -210,3 +284,94 @@ describe('processManagedImage', () => {
expect(metadata.height).toBe(1000);
});
});
describe('applyGameDataRender', () => {
beforeEach(() => {
mkdirMock.mockReset().mockResolvedValue(undefined);
rmMock.mockReset().mockResolvedValue(undefined);
writeFileMock.mockReset().mockResolvedValue(undefined);
});
it.each(GAME_DATA_RENDER_TYPES)(
'persists and returns authoritative %s render metadata',
async (type) => {
const database = new FakeDatabase();
const result = await applyGameDataRender(
database as never,
type,
sourceId,
'Test Entity',
{
file: new File(['source'], 'source.png', { type: 'image/png' }),
remove: false,
processed: {
output: Buffer.from('processed-render'),
width: 1000,
height: 1000,
},
},
'operator-1',
);
expect(result).toMatchObject({
original_filename: 'source.png',
byte_size: Buffer.byteLength('processed-render'),
width: 1000,
height: 1000,
source_entity_type: `${type}_render`,
source_entity_id: sourceId,
created_by: 'operator-1',
created_at: expect.any(String),
updated_at: expect.any(String),
});
expect(writeFileMock).toHaveBeenCalledOnce();
},
);
it('returns refreshed replacement metadata without rewriting creator provenance', async () => {
const database = new FakeDatabase();
database.images.set(imageId, {
id: imageId,
name: 'Ability render',
category: 'Specializations',
tags_json: '[]',
is_icon: 0,
force_white: 0,
storage_key: `library-${imageId}.webp`,
original_filename: 'old.png',
byte_size: 3,
width: 100,
height: 100,
source_entity_type: 'specialization_ability_render',
source_entity_id: sourceId,
created_by: 'original-operator',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
});
const result = await applyGameDataRender(
database as never,
'specialization_ability',
sourceId,
'Ability',
{
file: new File(['new'], 'new.png', { type: 'image/png' }),
remove: false,
processed: {
output: Buffer.from('replacement'),
width: 1000,
height: 1000,
},
},
'replacement-operator',
);
expect(result).toMatchObject({
id: imageId,
original_filename: 'new.png',
byte_size: Buffer.byteLength('replacement'),
created_by: 'original-operator',
created_at: '2026-01-01T00:00:00.000Z',
});
});
});
+12 -26
View File
@@ -41,7 +41,7 @@ export type GameDataRenderType = (typeof GAME_DATA_RENDER_TYPES)[number];
const MAX_PIXELS = 24_000_000;
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
const KEY_PATTERN =
/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i;
export class ManagedImageValidationError extends Error {}
export class ManagedImageDeletionError extends Error {}
@@ -250,7 +250,7 @@ export async function applyGameDataRender(
);
await (
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
original_filename=?,byte_size=?,width=?,height=?,created_by=?,updated_at=? WHERE id=?`)
original_filename=?,byte_size=?,width=?,height=?,updated_at=? WHERE id=?`)
).run(
category[type],
tags,
@@ -258,19 +258,14 @@ export async function applyGameDataRender(
processed.output.length,
processed.width,
processed.height,
actorId,
now,
existing.id,
);
return {
...existing,
category: category[type],
tags_json: tags,
byte_size: processed.output.length,
width: processed.width,
height: processed.height,
updated_at: now,
};
return (
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(existing.id)) ?? null
);
}
const id = randomUUID();
const storageKey = await saveManagedImageFile(id, processed.output);
@@ -298,20 +293,11 @@ export async function applyGameDataRender(
now,
now,
);
return {
id,
name,
category: category[type],
tags_json: tags,
is_icon: 0,
storage_key: storageKey,
byte_size: processed.output.length,
width: processed.width,
height: processed.height,
source_entity_type: sourceType,
source_entity_id: entityId,
updated_at: now,
};
return (
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(id)) ?? null
);
}
export async function deleteManagedImage(database: Database, id: string) {
+100
View File
@@ -0,0 +1,100 @@
import type { Database } from '@tursodatabase/database';
import {
MOD_SECONDARY_QUALITIES,
normalizeModSecondaryPool,
type ModSecondaryRange,
} from '$lib/mods/secondary-pool';
export type ModSecondaryPoolKind = 'weapon' | 'gear';
function tables(kind: ModSecondaryPoolKind) {
return {
options: `${kind}_mod_secondary_options`,
values: `${kind}_mod_secondary_quality_values`,
};
}
export async function getModSecondaryPool(
database: Database,
kind: ModSecondaryPoolKind,
): Promise<ModSecondaryRange[]> {
const table = tables(kind);
const [options, values] = await Promise.all([
(
await database.prepare(`SELECT stat_attribute_id FROM ${table.options}
ORDER BY stat_attribute_id`)
).all(),
(
await database.prepare(`SELECT * FROM ${table.values}
ORDER BY stat_attribute_id,quality`)
).all(),
]);
const pool = options.map((option) => {
const optionValues = values.filter(
(value) => value.stat_attribute_id === option.stat_attribute_id,
);
if (
optionValues.length !== MOD_SECONDARY_QUALITIES.length ||
MOD_SECONDARY_QUALITIES.some(
(quality) => !optionValues.some((value) => value.quality === quality),
)
) {
throw new Error(
`${kind} Mod secondary Attribute ${String(option.stat_attribute_id)} does not have a complete quality profile.`,
);
}
return {
statId: String(option.stat_attribute_id),
values: Object.fromEntries(
optionValues.map((value) => [
String(value.quality),
{
level1Min: Number(value.level_1_min),
level1Max: Number(value.level_1_max),
level20Min: Number(value.level_20_min),
level20Max: Number(value.level_20_max),
},
]),
) as ModSecondaryRange['values'],
};
});
if (!pool.length) return [];
const normalized = normalizeModSecondaryPool(pool);
if (normalized.error) {
throw new Error(`${kind} Mod secondary pool: ${normalized.error}`);
}
return normalized.pool;
}
export async function replaceModSecondaryPool(
database: Database,
kind: ModSecondaryPoolKind,
pool: ModSecondaryRange[],
) {
const table = tables(kind);
await database.exec(`DELETE FROM ${table.values}`);
await database.exec(`DELETE FROM ${table.options}`);
for (const range of pool) {
await (
await database.prepare(
`INSERT INTO ${table.options}(stat_attribute_id) VALUES(?)`,
)
).run(range.statId);
for (const quality of MOD_SECONDARY_QUALITIES) {
const values = range.values[quality];
await (
await database.prepare(`INSERT INTO ${table.values}
(stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max)
VALUES(?,?,?,?,?,?)`)
).run(
range.statId,
quality,
values.level1Min,
values.level1Max,
values.level20Min,
values.level20Max,
);
}
}
}
@@ -0,0 +1,96 @@
import { connect } from '@tursodatabase/database';
import { afterEach, describe, expect, it } from 'vitest';
import {
getOsProtocolProfileAttributeIds,
type OsProtocolAttributeType,
} from './os-protocol-profiles';
const databases: Array<Awaited<ReturnType<typeof connect>>> = [];
afterEach(async () => {
await Promise.all(databases.splice(0).map((database) => database.close()));
});
async function databaseWithAttributes() {
const database = await connect(':memory:');
databases.push(database);
await database.exec(`CREATE TABLE stat_attributes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
unit TEXT NOT NULL,
active INTEGER NOT NULL
)`);
for (const [id, name, unit] of [
['firepower-flat', 'Firepower', ''],
['firepower-percent', 'Firepower', '%'],
['toughness-flat', 'Toughness', ''],
['toughness-percent', 'Toughness', '%'],
['engineering-flat', 'Engineering', ''],
['weapon-damage-flat', 'Weapon Damage', ''],
['weapon-damage-percent', 'Weapon Damage', '%'],
['skill-damage-percent', 'Skill Damage', '%'],
['health-flat', 'Health', ''],
]) {
await (
await database.prepare(
'INSERT INTO stat_attributes(id,name,unit,active) VALUES(?,?,?,1)',
)
).run(id, name, unit);
}
return database;
}
describe('OS Protocol profile Attributes', () => {
it.each([
[
'Firepower',
{
Core: 'firepower-flat',
Damage: 'weapon-damage-flat',
Health: 'health-flat',
},
],
[
'Toughness',
{
Core: 'toughness-flat',
Damage: 'weapon-damage-flat',
Health: 'health-flat',
},
],
[
'Engineering',
{
Core: 'engineering-flat',
Damage: 'skill-damage-percent',
Health: 'health-flat',
},
],
] as const)('resolves the exact %s role rows', async (type, expected) => {
const database = await databaseWithAttributes();
await expect(
getOsProtocolProfileAttributeIds(
database,
type as OsProtocolAttributeType,
),
).resolves.toEqual(expected);
});
it('reports a missing required role without selecting a similarly named row', async () => {
const database = await databaseWithAttributes();
await (
await database.prepare(
"DELETE FROM stat_attributes WHERE id='weapon-damage-flat'",
)
).run();
await expect(
getOsProtocolProfileAttributeIds(database, 'Firepower'),
).resolves.toEqual({
Core: 'firepower-flat',
Damage: null,
Health: 'health-flat',
});
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { Database } from '@tursodatabase/database';
export const OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS = {
Firepower: {
Core: { name: 'Firepower', unit: '' },
Damage: { name: 'Weapon Damage', unit: '' },
Health: { name: 'Health', unit: '' },
},
Toughness: {
Core: { name: 'Toughness', unit: '' },
Damage: { name: 'Weapon Damage', unit: '' },
Health: { name: 'Health', unit: '' },
},
Engineering: {
Core: { name: 'Engineering', unit: '' },
Damage: { name: 'Skill Damage', unit: '%' },
Health: { name: 'Health', unit: '' },
},
} as const;
export type OsProtocolAttributeType =
keyof typeof OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS;
export type OsProtocolSecondaryRole = 'Core' | 'Damage' | 'Health';
export async function getOsProtocolProfileAttributeIds(
database: Database,
attributeType: OsProtocolAttributeType,
) {
const specs = OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS[attributeType];
const entries = await Promise.all(
(
Object.entries(specs) as Array<
[OsProtocolSecondaryRole, { name: string; unit: string }]
>
).map(async ([role, spec]) => {
const row = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1 AND name=? COLLATE NOCASE AND unit=?',
)
).get(spec.name, spec.unit);
return [role, row ? String(row.id) : null] as const;
}),
);
return Object.fromEntries(entries) as Record<
OsProtocolSecondaryRole,
string | null
>;
}
+27
View File
@@ -1,5 +1,6 @@
import type { Database } from '@tursodatabase/database';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
export async function loadSharedBuildSummaryCatalog(database: Database) {
const [
@@ -16,6 +17,10 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
talentTemplates,
chipSets,
chipTwoPiece,
chipPrimaryOptions,
chipPrimaryQualityValues,
chipSecondaryOptions,
chipSecondaryQualityValues,
chipTalents,
chipTalentEffects,
protocols,
@@ -83,6 +88,18 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
(
await database.prepare('SELECT * FROM skill_chip_primary_quality_values')
).all(),
(
await database.prepare('SELECT * FROM skill_chip_secondary_options')
).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_secondary_quality_values',
)
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
await database.prepare(
@@ -161,6 +178,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
),
})),
})),
skillChipProfile: {
primaryOptions: hydrateSkillChipProfileRows(
chipPrimaryOptions,
chipPrimaryQualityValues,
),
secondaryOptions: hydrateSkillChipProfileRows(
chipSecondaryOptions,
chipSecondaryQualityValues,
),
},
chipSets: chipSets.map((set) => {
const talent = chipTalents.find(
(item) => item.skill_chip_set_id === set.id,
+117
View File
@@ -0,0 +1,117 @@
import type { Database } from '@tursodatabase/database';
import {
SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES,
SKILL_CHIP_QUALITIES,
SKILL_CHIP_SECONDARY_QUALITIES,
type SkillChipProfile,
} from '$lib/skill-chips/profiles';
export async function getSkillChipPrimaryAttributeIds(database: Database) {
const rows = await (
await database.prepare(
`SELECT id,name FROM stat_attributes
WHERE active=1 AND name IN (${SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES.map(() => '?').join(',')})
ORDER BY name COLLATE NOCASE`,
)
).all(...SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES);
if (rows.length !== SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES.length)
throw new Error(
'All four fixed Skill Chip primary Attributes must be active.',
);
return rows.map((row) => String(row.id));
}
async function getProfiles(
database: Database,
optionTable: string,
valueTable: string,
) {
const [options, values] = await Promise.all([
(
await database.prepare(
`SELECT stat_attribute_id FROM ${optionTable} ORDER BY stat_attribute_id`,
)
).all(),
(
await database.prepare(
`SELECT * FROM ${valueTable} ORDER BY stat_attribute_id,quality`,
)
).all(),
]);
if (!options.length) return [];
return options.map((option) => ({
statId: String(option.stat_attribute_id),
values: Object.fromEntries(
values
.filter((value) => value.stat_attribute_id === option.stat_attribute_id)
.map((value) => [
String(value.quality),
{ min: Number(value.min_value), max: Number(value.max_value) },
]),
),
}));
}
export const getSkillChipPrimaryProfiles = (database: Database) =>
getProfiles(
database,
'skill_chip_primary_options',
'skill_chip_primary_quality_values',
);
export const getSkillChipSecondaryPool = (database: Database) =>
getProfiles(
database,
'skill_chip_secondary_options',
'skill_chip_secondary_quality_values',
);
async function replaceProfiles(
database: Database,
optionTable: string,
valueTable: string,
profiles: SkillChipProfile[],
qualities: readonly string[],
) {
await database.exec(`DELETE FROM ${valueTable}`);
await database.exec(`DELETE FROM ${optionTable}`);
for (const profile of profiles) {
await (
await database.prepare(
`INSERT INTO ${optionTable}(stat_attribute_id) VALUES(?)`,
)
).run(profile.statId);
for (const quality of qualities) {
const range = profile.values[quality];
await (
await database.prepare(
`INSERT INTO ${valueTable}(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`,
)
).run(profile.statId, quality, range.min, range.max);
}
}
}
export const replaceSkillChipPrimaryProfiles = (
database: Database,
profiles: SkillChipProfile[],
) =>
replaceProfiles(
database,
'skill_chip_primary_options',
'skill_chip_primary_quality_values',
profiles,
SKILL_CHIP_QUALITIES,
);
export const replaceSkillChipSecondaryPool = (
database: Database,
profiles: SkillChipProfile[],
) =>
replaceProfiles(
database,
'skill_chip_secondary_options',
'skill_chip_secondary_quality_values',
profiles,
SKILL_CHIP_SECONDARY_QUALITIES,
);
+39 -24
View File
@@ -17,37 +17,51 @@ export async function refreshSpecializationFocusReady(
abilityCount: 0,
chipReadyCount: 0,
};
const [config, abilityCountRow, chipReadyRow, nodes, rankProfiles] =
await Promise.all([
(
await database.prepare(
"SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'",
)
).get(),
(
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
const [
config,
abilityCountRow,
profileRow,
chipReadyRow,
nodes,
rankProfiles,
] = await Promise.all([
(
await database.prepare(
"SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'",
)
).get(),
(
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id
WHERE link.focus_id=? AND ability.active=1`)
).get(focusId),
(
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
).get(focusId),
(
await database.prepare(`SELECT
(SELECT COUNT(*) FROM skill_chip_primary_options)=4
AND (SELECT COUNT(*) FROM skill_chip_primary_quality_values)=16
AND (SELECT COUNT(*) FROM skill_chip_secondary_options)>=2
AND (SELECT COUNT(*) FROM skill_chip_secondary_quality_values)=
(SELECT COUNT(*)*3 FROM skill_chip_secondary_options) AS ready`)
).get(),
(
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
WHERE link.focus_id=? AND (
SELECT COUNT(*) FROM skill_chip_sets chips
WHERE chips.specialization_ability_id=ability.id AND chips.active=1
)=3`)
).get(focusId),
(
await database.prepare(
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number',
)
).all(focusId),
(
await database.prepare(
'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank',
)
).all(),
]);
).get(focusId),
(
await database.prepare(
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number',
)
).all(focusId),
(
await database.prepare(
'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank',
)
).all(),
]);
const pointForRank = new Map(
rankProfiles.map((profile) => [
Number(profile.rank),
@@ -69,6 +83,7 @@ export async function refreshSpecializationFocusReady(
Number(focus.active) === 1 &&
abilityCount === 3 &&
chipReadyCount === 3 &&
Number(profileRow?.ready) === 1 &&
nodes.length > 0 &&
contiguousRows &&
pointTotal === availablePoints;
+179
View File
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest';
import {
normalizeSkillChipProfiles,
SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES,
SKILL_CHIP_QUALITIES,
SKILL_CHIP_SECONDARY_CAPACITY,
SKILL_CHIP_SECONDARY_QUALITIES,
} from './profiles';
const primaryIds = [
'skill-duration',
'skill-radius',
'skill-cooldown-recovery',
'skill-intensity',
] as const;
const ranges = (qualities: readonly string[], offset = 0) =>
Object.fromEntries(
qualities.map((quality, index) => [
quality,
{ min: String(offset + index + 1), max: offset + index + 2 },
]),
);
const profile = (statId: string, qualities: readonly string[], offset = 0) => ({
statId,
values: ranges(qualities, offset),
});
describe('Skill Chip shared profiles', () => {
it('defines the exact four fixed primary Attributes and all four qualities', () => {
expect(SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES).toEqual([
'Skill Duration',
'Skill Radius',
'Skill Cooldown Recovery',
'Skill Intensity',
]);
expect(SKILL_CHIP_QUALITIES).toEqual([
'Standard',
'Specialized',
'Superior',
'High-end',
]);
});
it('defines the 0/1/2/2 secondary capacity mapping', () => {
expect(SKILL_CHIP_SECONDARY_CAPACITY).toEqual({
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
});
});
it('normalizes exactly the four fixed primary profiles across every quality', () => {
const raw = primaryIds.map((statId, index) =>
profile(` ${statId} `, SKILL_CHIP_QUALITIES, index * 10),
);
const result = normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, {
expectedStatIds: primaryIds,
minimumOptions: 4,
});
expect(result.error).toBeNull();
expect(result.profiles).toHaveLength(4);
expect(result.profiles.map(({ statId }) => statId)).toEqual(primaryIds);
expect(result.profiles[0]).toEqual({
statId: 'skill-duration',
values: {
Standard: { min: 1, max: 2 },
Specialized: { min: 2, max: 3 },
Superior: { min: 3, max: 4 },
'High-end': { min: 4, max: 5 },
},
});
});
it('rejects a primary profile that does not contain the exact fixed set', () => {
const raw = primaryIds.map((statId) =>
profile(statId, SKILL_CHIP_QUALITIES),
);
raw[3] = profile('weapon-damage', SKILL_CHIP_QUALITIES);
expect(
normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, {
expectedStatIds: primaryIds,
minimumOptions: 4,
}),
).toEqual({
profiles: [],
error:
'The primary profile must contain the four fixed Skill Attributes.',
});
});
it('normalizes secondary profiles across only the three supported qualities', () => {
expect(SKILL_CHIP_SECONDARY_QUALITIES).toEqual([
'Specialized',
'Superior',
'High-end',
]);
const result = normalizeSkillChipProfiles(
[
profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES),
profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES, 10),
],
SKILL_CHIP_SECONDARY_QUALITIES,
{ minimumOptions: 2 },
);
expect(result.error).toBeNull();
expect(result.profiles[0]).toEqual({
statId: 'critical-hit-chance',
values: {
Specialized: { min: 1, max: 2 },
Superior: { min: 2, max: 3 },
'High-end': { min: 3, max: 4 },
},
});
expect(result.profiles[0].values).not.toHaveProperty('Standard');
});
it('rejects duplicate Attributes after trimming IDs', () => {
const result = normalizeSkillChipProfiles(
[
profile('critical-hit-chance', SKILL_CHIP_SECONDARY_QUALITIES),
profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES),
],
SKILL_CHIP_SECONDARY_QUALITIES,
{ minimumOptions: 2 },
);
expect(result).toEqual({
profiles: [],
error: 'Choose a unique Attribute for every shared option.',
});
});
it.each([
[
'missing ranges',
{
Specialized: { min: 1, max: 2 },
Superior: { min: 2, max: 3 },
},
'High-end requires a minimum and maximum.',
],
[
'reversed ranges',
{
Specialized: { min: 2, max: 1 },
Superior: { min: 2, max: 3 },
'High-end': { min: 3, max: 4 },
},
'Specialized requires a valid non-negative range.',
],
[
'negative ranges',
{
Specialized: { min: -1, max: 2 },
Superior: { min: 2, max: 3 },
'High-end': { min: 3, max: 4 },
},
'Specialized requires a valid non-negative range.',
],
])('rejects %s', (_label, values, error) => {
const result = normalizeSkillChipProfiles(
[
{ statId: 'critical-hit-chance', values },
profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES),
],
SKILL_CHIP_SECONDARY_QUALITIES,
{ minimumOptions: 2 },
);
expect(result).toEqual({ profiles: [], error });
});
});
+127
View File
@@ -0,0 +1,127 @@
export const SKILL_CHIP_QUALITIES = [
'Standard',
'Specialized',
'Superior',
'High-end',
] as const;
export const SKILL_CHIP_SECONDARY_QUALITIES = [
'Specialized',
'Superior',
'High-end',
] as const;
export const SKILL_CHIP_SECONDARY_CAPACITY = {
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
} as const satisfies Record<(typeof SKILL_CHIP_QUALITIES)[number], number>;
export const SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES = [
'Skill Duration',
'Skill Radius',
'Skill Cooldown Recovery',
'Skill Intensity',
] as const;
export type SkillChipRange = { min: number; max: number };
export type SkillChipProfile = {
statId: string;
values: Record<string, SkillChipRange>;
};
export function hydrateSkillChipProfileRows(
options: Array<Record<string, unknown>>,
qualityValues: Array<Record<string, unknown>>,
) {
return options.map((option) => ({
statId: String(option.stat_attribute_id),
ranges: Object.fromEntries(
qualityValues
.filter((range) => range.stat_attribute_id === option.stat_attribute_id)
.map((range) => [
String(range.quality),
{ min: Number(range.min_value), max: Number(range.max_value) },
]),
),
}));
}
const finite = (value: unknown) => {
if (value === '' || value === null || value === undefined) return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
};
export function normalizeSkillChipProfiles(
raw: unknown,
qualities: readonly string[],
options: {
expectedStatIds?: readonly string[];
minimumOptions?: number;
} = {},
): { profiles: SkillChipProfile[]; error: string | null } {
if (!Array.isArray(raw))
return { profiles: [], error: 'The shared profile payload is invalid.' };
if (raw.length < (options.minimumOptions ?? 1) || raw.length > 50)
return {
profiles: [],
error: `Configure ${options.minimumOptions ?? 1}–50 shared Attribute options.`,
};
const profiles: SkillChipProfile[] = [];
const seen = new Set<string>();
for (const entry of raw) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
return { profiles: [], error: 'A shared Attribute option is invalid.' };
const candidate = entry as Record<string, unknown>;
const statId = String(candidate.statId ?? '').trim();
if (!statId || seen.has(statId))
return {
profiles: [],
error: 'Choose a unique Attribute for every shared option.',
};
seen.add(statId);
const rawValues = candidate.values;
if (!rawValues || typeof rawValues !== 'object' || Array.isArray(rawValues))
return {
profiles: [],
error: 'Every shared Attribute needs quality ranges.',
};
const values: Record<string, SkillChipRange> = {};
for (const quality of qualities) {
const rawRange = (rawValues as Record<string, unknown>)[quality];
if (!rawRange || typeof rawRange !== 'object' || Array.isArray(rawRange))
return {
profiles: [],
error: `${quality} requires a minimum and maximum.`,
};
const range = rawRange as Record<string, unknown>;
const min = finite(range.min);
const max = finite(range.max);
if (min === null || max === null || min < 0 || max < min)
return {
profiles: [],
error: `${quality} requires a valid non-negative range.`,
};
values[quality] = { min, max };
}
profiles.push({ statId, values });
}
if (options.expectedStatIds) {
const expected = [...options.expectedStatIds].sort();
const received = profiles.map((profile) => profile.statId).sort();
if (
expected.length !== received.length ||
expected.some((statId, index) => statId !== received[index])
)
return {
profiles: [],
error:
'The primary profile must contain the four fixed Skill Attributes.',
};
}
return { profiles, error: null };
}
+49
View File
@@ -1,10 +1,15 @@
import { describe, expect, it } from 'vitest';
import {
WEAPON_MOD_SLOT_DEFINITIONS,
WEAPON_MOD_SLOT_TYPES,
excludedTierZeroWeaponModSlot,
expandSharedWeaponModSlots,
normalizeWeaponModSlotPayload,
publicWeaponModSlots,
sharedWeaponModSlots,
validateWeaponModSlots,
weaponModSlotDefinitionById,
weaponModSlotDefinitionByName,
weaponModSlotsComplete,
type WeaponModSlotConfig,
} from './mod-slots';
@@ -15,6 +20,23 @@ const configuration = (tier: number, slots: string[]): WeaponModSlotConfig => ({
});
describe('weapon mod slot configuration', () => {
it('assigns one stable UUID to every canonical slot type', () => {
expect(WEAPON_MOD_SLOT_DEFINITIONS).toHaveLength(8);
expect(new Set(WEAPON_MOD_SLOT_DEFINITIONS.map(({ id }) => id)).size).toBe(
8,
);
expect(WEAPON_MOD_SLOT_DEFINITIONS.map(({ name }) => name)).toEqual(
WEAPON_MOD_SLOT_TYPES,
);
for (const definition of WEAPON_MOD_SLOT_DEFINITIONS) {
expect(definition.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(weaponModSlotDefinitionById(definition.id)).toBe(definition);
expect(weaponModSlotDefinitionByName(definition.name)).toBe(definition);
}
});
it('accepts missing and partial slot data for later configuration', () => {
const slots = [
configuration(0, []),
@@ -99,4 +121,31 @@ describe('weapon mod slot configuration', () => {
]),
).toEqual([configuration(1, ['Optics', 'Muzzle', 'Barrel'])]);
});
it('marks the slot gained by a native T0 weapon at T1', () => {
const slots = expandSharedWeaponModSlots(
['Magazine', 'Optics', 'Barrel', 'Muzzle', 'Underbarrel'],
'Barrel',
);
expect(publicWeaponModSlots(0, slots)).toEqual([
{ slot: 'Optics', unlockedAtTierOne: false },
{ slot: 'Muzzle', unlockedAtTierOne: false },
{ slot: 'Underbarrel', unlockedAtTierOne: false },
{ slot: 'Magazine', unlockedAtTierOne: false },
{ slot: 'Barrel', unlockedAtTierOne: true },
]);
});
it('shows native T1 slots without an upgrade-only state', () => {
expect(
publicWeaponModSlots(1, [
configuration(1, ['Magazine', 'Optics', 'Muzzle']),
]),
).toEqual([
{ slot: 'Optics', unlockedAtTierOne: false },
{ slot: 'Muzzle', unlockedAtTierOne: false },
{ slot: 'Magazine', unlockedAtTierOne: false },
]);
});
});
+59 -9
View File
@@ -1,19 +1,42 @@
export const WEAPON_MOD_SLOT_TYPES = [
'Optics',
'Muzzle',
'Underbarrel',
'Magazine',
'Barrel',
'Upper Receiver',
'Lower Receiver',
'Trigger',
export const WEAPON_MOD_SLOT_DEFINITIONS = [
{ id: '28ffc3b1-5d98-4273-a9af-67dbff3e5c76', name: 'Optics' },
{ id: 'c4598f51-2cb0-4a20-a3a7-6d8983a26598', name: 'Muzzle' },
{ id: '85ca3493-1871-4d22-950a-ed718fd547e0', name: 'Underbarrel' },
{ id: '4d809a35-8285-4bff-be98-a0effe1d257e', name: 'Magazine' },
{ id: '0e157cfe-975c-441d-8f49-72983161e7b1', name: 'Barrel' },
{ id: '551fd7c8-3e5b-4c89-8437-9b901e89e8aa', name: 'Upper Receiver' },
{ id: '72573367-6e91-4312-9cc3-710ede4f078e', name: 'Lower Receiver' },
{ id: '8dda5edb-6079-4a23-a556-cd9be4521c70', name: 'Trigger' },
] as const;
export type WeaponModSlotType =
(typeof WEAPON_MOD_SLOT_DEFINITIONS)[number]['name'];
export const WEAPON_MOD_SLOT_TYPES = WEAPON_MOD_SLOT_DEFINITIONS.map(
(definition) => definition.name,
) as WeaponModSlotType[];
export function weaponModSlotDefinitionById(id: string) {
return WEAPON_MOD_SLOT_DEFINITIONS.find((definition) => definition.id === id);
}
export function weaponModSlotDefinitionByName(name: string) {
return WEAPON_MOD_SLOT_DEFINITIONS.find(
(definition) => definition.name === name,
);
}
export type WeaponModSlotConfig = {
tier: number;
slots: string[];
};
export type PublicWeaponModSlot = {
slot: string;
unlockedAtTierOne: boolean;
iconUrl?: string;
};
export function normalizeWeaponModSlotPayload(
payload: unknown,
): WeaponModSlotConfig[] {
@@ -52,6 +75,33 @@ export function sharedWeaponModSlots(
];
}
export function publicWeaponModSlots(
nativeTier: number,
configurations: WeaponModSlotConfig[],
): PublicWeaponModSlot[] {
const nativeSlots =
configurations.find((row) => Number(row.tier) === nativeTier)?.slots ?? [];
const displayedSlots =
nativeTier === 0
? sharedWeaponModSlots(configurations).length
? sharedWeaponModSlots(configurations)
: nativeSlots
: nativeSlots;
const ordered = [...new Set(displayedSlots)].sort(
(left, right) =>
WEAPON_MOD_SLOT_TYPES.indexOf(
left as (typeof WEAPON_MOD_SLOT_TYPES)[number],
) -
WEAPON_MOD_SLOT_TYPES.indexOf(
right as (typeof WEAPON_MOD_SLOT_TYPES)[number],
),
);
return ordered.map((slot) => ({
slot,
unlockedAtTierOne: nativeTier === 0 && !nativeSlots.includes(slot),
}));
}
export function excludedTierZeroWeaponModSlot(
sharedSlots: string[],
configurations: WeaponModSlotConfig[],