feat: expand ISAC content modeling
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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=?`)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user