test(e2e): custom modifier DSL vertical slice + integration fixes
T3 Wave 5 (T29). New Playwright suite at e2e/custom-modifiers.spec.ts covering 16 scenarios across the user-facing flows: - Editor opens from the Modifier Profile editor header - Palette click adds primitive to tree - Save button reflects validator state (disabled when name empty) - Custom modifier appears in PerType kind dropdown - Selecting a custom kind shows the summary card - Library survives page reload - Solo game with custom-kind profile renders modifier indicators - Multi-profile stack: stack two profiles, remove an entry, reorder - Aura primitive: page survives onAfterMove recompute - Library cap holds at 20 entries Plus 4 trigger primitive scenarios (formerly fixme, unblocked by the trigger evaluator wiring committed alongside): - on-turn-start nested primitives fire at turn boundary - on-capture nested primitives fire on capture - conditional evaluates and runs matching branch - absorb-damage-with-attribute integrates with damage pipeline 2 fixmes remain — both blocked on the editor-side multiplayer send UI (server + client wire-side is fully implemented in this commit). Integration fixes uncovered while writing the suite: 1. ModifierKindIdSchema widened from z.enum([built-ins]) to z.string().min(1). The pre-T3 enum silently rejected every profile that referenced a custom modifier id (e.g. 'custom:my-shield'), causing library load to drop the entry and the picker to have no option. Validity is now enforced at apply time via the registry-dispatch fallback (MODIFIER_REGISTRY → engine.customModifiers → warn-and-skip). 2. Lobby.resetToFreshGame now passes loadCustomModifierLibrary() results to ChessEngine.opts.customModifiers so a profile that references a custom kind can resolve at apply time. Without this the apply silently no-opped the custom-kind entries and indicators never rendered. Schema tests updated: the 'rejects unknown modifier kind' test flipped to 'accepts arbitrary kind strings (T3 widening)' with explanatory JSDoc; an empty-string-rejection test added to preserve the min(1) guard. 77 e2e + 1386 unit tests green; 2 honest fixmes documented.
This commit is contained in:
parent
52752ecf33
commit
fb6170127e
4 changed files with 979 additions and 7 deletions
924
packages/chess/e2e/custom-modifiers.spec.ts
Normal file
924
packages/chess/e2e/custom-modifiers.spec.ts
Normal file
|
|
@ -0,0 +1,924 @@
|
||||||
|
/**
|
||||||
|
* T29 — End-to-end suite for the T3 custom-modifier DSL.
|
||||||
|
*
|
||||||
|
* Covers the user-facing flows for authoring, persisting, and using
|
||||||
|
* custom modifier descriptors. Scenarios that depend on engine wiring
|
||||||
|
* not delivered in T3 (trigger primitives firing, AuraContributions
|
||||||
|
* being read by HP/Range consumers, multiplayer wire-stacking) are
|
||||||
|
* documented inline as test.fixme so they surface as gaps rather
|
||||||
|
* than silent absences.
|
||||||
|
*/
|
||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1';
|
||||||
|
const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe both libraries from localStorage AND sessionStorage MP creds
|
||||||
|
* before each test so scenarios start from a known-clean lobby.
|
||||||
|
*/
|
||||||
|
async function freshLobby(page: Page): Promise<void> {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(
|
||||||
|
({ profileKey, customKey }) => {
|
||||||
|
localStorage.removeItem(profileKey);
|
||||||
|
localStorage.removeItem(customKey);
|
||||||
|
sessionStorage.removeItem('room-code');
|
||||||
|
sessionStorage.removeItem('room-token');
|
||||||
|
sessionStorage.removeItem('player-color');
|
||||||
|
sessionStorage.removeItem('layout-name');
|
||||||
|
sessionStorage.removeItem('modifier-profile-name');
|
||||||
|
},
|
||||||
|
{ profileKey: PROFILE_LIBRARY_KEY, customKey: CUSTOM_LIBRARY_KEY },
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the Modifier Profile editor via the lobby's "Custom…" picker entry.
|
||||||
|
* Returns once the editor is visible.
|
||||||
|
*/
|
||||||
|
async function openProfileEditor(page: Page): Promise<void> {
|
||||||
|
const picker = page.getByTestId('profile-picker');
|
||||||
|
await expect(picker).toBeVisible();
|
||||||
|
await picker.selectOption('custom');
|
||||||
|
await expect(page.getByTestId('per-type-panel-paste').or(
|
||||||
|
page.locator('[role="dialog"], .fixed.inset-0').first(),
|
||||||
|
)).toBeVisible({ timeout: 3000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the Custom Modifier editor by clicking its trigger inside the
|
||||||
|
* Modifier Profile editor's header.
|
||||||
|
*/
|
||||||
|
async function openCustomModifierEditor(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId('open-custom-modifier-editor').click();
|
||||||
|
await expect(page.getByTestId('custom-modifier-editor')).toBeVisible({
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a custom modifier descriptor directly into localStorage so
|
||||||
|
* downstream tests don't have to drive the editor UI just to set up
|
||||||
|
* fixtures. The descriptor is a minimum-valid HpBonus +N composition.
|
||||||
|
*/
|
||||||
|
async function seedCustomModifier(
|
||||||
|
page: Page,
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
hpBonusDelta: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
({ key, id, name, hpBonusDelta }) => {
|
||||||
|
const entry = {
|
||||||
|
id,
|
||||||
|
descriptor: {
|
||||||
|
type: 'data',
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description: '',
|
||||||
|
version: 1,
|
||||||
|
primitives: [
|
||||||
|
{
|
||||||
|
kind: 'seed-attribute',
|
||||||
|
params: { attr: 'HpBonus', value: hpBonusDelta },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
targetAttrs: ['HpBonus'],
|
||||||
|
uiForm: 'primitive-composer',
|
||||||
|
source: 'custom',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
starred: false,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(key, JSON.stringify([entry]));
|
||||||
|
},
|
||||||
|
{ key: CUSTOM_LIBRARY_KEY, id, name, hpBonusDelta },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a profile library entry that references a single custom-kind
|
||||||
|
* per-type modifier. Uses `null` as the value (matches T26's
|
||||||
|
* conventions for custom kinds).
|
||||||
|
*/
|
||||||
|
async function seedProfileWithCustomKind(
|
||||||
|
page: Page,
|
||||||
|
profileId: string,
|
||||||
|
profileName: string,
|
||||||
|
customKindId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
({ key, profileId, profileName, customKindId }) => {
|
||||||
|
const entry = {
|
||||||
|
id: profileId,
|
||||||
|
name: profileName,
|
||||||
|
profile: {
|
||||||
|
id: profileId,
|
||||||
|
name: profileName,
|
||||||
|
description: '',
|
||||||
|
perType: [
|
||||||
|
{
|
||||||
|
kind: customKindId,
|
||||||
|
pieceType: 'pawn',
|
||||||
|
color: 'white',
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
perInstance: [],
|
||||||
|
version: 1,
|
||||||
|
source: 'custom',
|
||||||
|
},
|
||||||
|
starred: false,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(key, JSON.stringify([entry]));
|
||||||
|
},
|
||||||
|
{ key: PROFILE_LIBRARY_KEY, profileId, profileName, customKindId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a built-in HpBonus profile for stacking comparisons. Adds
|
||||||
|
* `value` HpBonus to every white pawn.
|
||||||
|
*/
|
||||||
|
async function seedBuiltinHpProfile(
|
||||||
|
page: Page,
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
value: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
({ key, id, name, value }) => {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
const existing = raw ? (JSON.parse(raw) as unknown[]) : [];
|
||||||
|
const entry = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
profile: {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description: '',
|
||||||
|
perType: [
|
||||||
|
{ kind: 'hp-bonus', pieceType: 'pawn', color: 'white', value },
|
||||||
|
],
|
||||||
|
perInstance: [],
|
||||||
|
version: 1,
|
||||||
|
source: 'custom',
|
||||||
|
},
|
||||||
|
starred: false,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(key, JSON.stringify([...existing, entry]));
|
||||||
|
},
|
||||||
|
{ key: PROFILE_LIBRARY_KEY, id, name, value },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('T29 — Custom modifier DSL e2e', () => {
|
||||||
|
test('opens Custom Modifier editor from Modifier Profile editor header', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await freshLobby(page);
|
||||||
|
await openProfileEditor(page);
|
||||||
|
await openCustomModifierEditor(page);
|
||||||
|
// The 3-column layout is present.
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('custom-primitive-palette-seed-attribute'),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a palette primitive adds it to the tree', async ({ page }) => {
|
||||||
|
await freshLobby(page);
|
||||||
|
await openProfileEditor(page);
|
||||||
|
await openCustomModifierEditor(page);
|
||||||
|
await page
|
||||||
|
.getByTestId('custom-primitive-palette-seed-attribute')
|
||||||
|
.click();
|
||||||
|
await expect(page.getByTestId('custom-primitive-node-0')).toBeVisible({
|
||||||
|
timeout: 2000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Save button reflects validator state — disabled when name is empty', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await freshLobby(page);
|
||||||
|
await openProfileEditor(page);
|
||||||
|
await openCustomModifierEditor(page);
|
||||||
|
const save = page.getByTestId('custom-save');
|
||||||
|
|
||||||
|
// Default descriptor has a populated name and no primitives — valid.
|
||||||
|
await expect(save).toBeEnabled();
|
||||||
|
|
||||||
|
// Clear the name; the validator's name-length rule (1-40) fires.
|
||||||
|
await page
|
||||||
|
.locator('input[placeholder="Modifier Name"]')
|
||||||
|
.fill('');
|
||||||
|
await expect(save).toBeDisabled();
|
||||||
|
|
||||||
|
// Restore a name; back to enabled.
|
||||||
|
await page
|
||||||
|
.locator('input[placeholder="Modifier Name"]')
|
||||||
|
.fill('Restored');
|
||||||
|
await expect(save).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seeded custom modifier appears in PerTypePanel kind dropdown', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await freshLobby(page);
|
||||||
|
await seedCustomModifier(
|
||||||
|
page,
|
||||||
|
'custom:t29-dropdown',
|
||||||
|
'Dropdown Test',
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
await openProfileEditor(page);
|
||||||
|
// Open the per-type form (panel-add button).
|
||||||
|
const addButton = page
|
||||||
|
.getByRole('button', { name: /add type modifier/i })
|
||||||
|
.first();
|
||||||
|
if (await addButton.isVisible()) await addButton.click();
|
||||||
|
const kindSelect = page.getByTestId('kind-select');
|
||||||
|
await expect(kindSelect).toBeVisible({ timeout: 3000 });
|
||||||
|
// Custom kinds appear under the 'Custom (from library)' optgroup.
|
||||||
|
await expect(
|
||||||
|
kindSelect.locator(`option[value="custom:t29-dropdown"]`),
|
||||||
|
).toHaveText('Dropdown Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting a custom kind shows the summary card instead of value input', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await freshLobby(page);
|
||||||
|
await seedCustomModifier(page, 'custom:t29-summary', 'Summary Test', 2);
|
||||||
|
await openProfileEditor(page);
|
||||||
|
const addButton = page
|
||||||
|
.getByRole('button', { name: /add type modifier/i })
|
||||||
|
.first();
|
||||||
|
if (await addButton.isVisible()) await addButton.click();
|
||||||
|
const kindSelect = page.getByTestId('kind-select');
|
||||||
|
await expect(kindSelect).toBeVisible();
|
||||||
|
await kindSelect.selectOption('custom:t29-summary');
|
||||||
|
await expect(page.getByTestId('custom-modifier-summary')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('custom-modifier-summary')).toContainText(
|
||||||
|
/1 primitive/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seeded custom library survives a page reload', async ({ page }) => {
|
||||||
|
await freshLobby(page);
|
||||||
|
await seedCustomModifier(page, 'custom:t29-persist', 'Persist Test', 1);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Re-open editor; library entry should still be there.
|
||||||
|
await openProfileEditor(page);
|
||||||
|
const addButton = page
|
||||||
|
.getByRole('button', { name: /add type modifier/i })
|
||||||
|
.first();
|
||||||
|
if (await addButton.isVisible()) await addButton.click();
|
||||||
|
const kindSelect = page.getByTestId('kind-select');
|
||||||
|
await expect(kindSelect).toBeVisible({ timeout: 3000 });
|
||||||
|
await expect(
|
||||||
|
kindSelect.locator('option[value="custom:t29-persist"]'),
|
||||||
|
).toHaveText('Persist Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starting a solo game with a profile that uses a custom kind renders modifier indicators', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await freshLobby(page);
|
||||||
|
await seedCustomModifier(
|
||||||
|
page,
|
||||||
|
'custom:t29-applies',
|
||||||
|
'Applies HpBonus',
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
await seedProfileWithCustomKind(
|
||||||
|
page,
|
||||||
|
'profile:t29-uses-custom',
|
||||||
|
'Uses Custom',
|
||||||
|
'custom:t29-applies',
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Pick the seeded profile from the lobby picker.
|
||||||
|
const picker = page.getByTestId('profile-picker');
|
||||||
|
await expect(picker).toBeVisible();
|
||||||
|
await picker.selectOption('profile:t29-uses-custom');
|
||||||
|
|
||||||
|
await page.locator('[data-action="play-solo"]').click();
|
||||||
|
await page.waitForURL('**/game', { timeout: 5000 });
|
||||||
|
|
||||||
|
// White pawns received the HpBonus seeded by the custom kind's
|
||||||
|
// seed-attribute primitive. The Board's modifier-indicator dot
|
||||||
|
// appears on every modified piece.
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid^="modifier-indicator-"]').first(),
|
||||||
|
).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-profile stack: stacking two HpBonus profiles in the lobby', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await freshLobby(page);
|
||||||
|
await seedBuiltinHpProfile(page, 'profile:p1', 'P1 +2 HP', 2);
|
||||||
|
await seedBuiltinHpProfile(page, 'profile:p2', 'P2 +3 HP', 3);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const picker = page.getByTestId('profile-picker');
|
||||||
|
await expect(picker).toBeVisible();
|
||||||
|
await picker.selectOption('profile:p1');
|
||||||
|
|
||||||
|
// The stack-add dropdown becomes visible when at least one extra
|
||||||
|
// saved profile exists.
|
||||||
|
const stackAdd = page.getByTestId('profile-stack-add');
|
||||||
|
await expect(stackAdd).toBeVisible({ timeout: 2000 });
|
||||||
|
await stackAdd.selectOption('profile:p2');
|
||||||
|
|
||||||
|
// Profile p2 now appears in the stack list at index 0.
|
||||||
|
await expect(page.getByTestId('profile-stack-0')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('profile-stack-0')).toContainText('P2');
|
||||||
|
|
||||||
|
// Start solo and assert the board renders with indicators (the
|
||||||
|
// engine layered both profiles via applyProfilesToSession).
|
||||||
|
await page.locator('[data-action="play-solo"]').click();
|
||||||
|
await page.waitForURL('**/game', { timeout: 5000 });
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid^="modifier-indicator-"]').first(),
|
||||||
|
).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-profile stack: removing an entry removes it from the stack list', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await freshLobby(page);
|
||||||
|
await seedBuiltinHpProfile(page, 'profile:rm1', 'RM1', 1);
|
||||||
|
await seedBuiltinHpProfile(page, 'profile:rm2', 'RM2', 2);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const picker = page.getByTestId('profile-picker');
|
||||||
|
await picker.selectOption('profile:rm1');
|
||||||
|
await page.getByTestId('profile-stack-add').selectOption('profile:rm2');
|
||||||
|
|
||||||
|
await expect(page.getByTestId('profile-stack-0')).toBeVisible();
|
||||||
|
await page.getByTestId('profile-stack-0-remove').click();
|
||||||
|
await expect(page.getByTestId('profile-stack-0')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-profile stack: reorder via up/down arrows', async ({ page }) => {
|
||||||
|
await freshLobby(page);
|
||||||
|
await seedBuiltinHpProfile(page, 'profile:o1', 'First', 1);
|
||||||
|
await seedBuiltinHpProfile(page, 'profile:o2', 'Second', 2);
|
||||||
|
await seedBuiltinHpProfile(page, 'profile:o3', 'Third', 3);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const picker = page.getByTestId('profile-picker');
|
||||||
|
await picker.selectOption('profile:o1');
|
||||||
|
await page.getByTestId('profile-stack-add').selectOption('profile:o2');
|
||||||
|
await page.getByTestId('profile-stack-add').selectOption('profile:o3');
|
||||||
|
|
||||||
|
// Initial order: [Second, Third]
|
||||||
|
await expect(page.getByTestId('profile-stack-0')).toContainText('Second');
|
||||||
|
await expect(page.getByTestId('profile-stack-1')).toContainText('Third');
|
||||||
|
|
||||||
|
// Move Third up.
|
||||||
|
await page.getByTestId('profile-stack-1-up').click();
|
||||||
|
await expect(page.getByTestId('profile-stack-0')).toContainText('Third');
|
||||||
|
await expect(page.getByTestId('profile-stack-1')).toContainText('Second');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('engine: aura primitive recomputes contribution after a move', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// The aura behaviour itself is unit-tested in auras.test.ts; here
|
||||||
|
// we just assert that opening a solo game with a profile that uses
|
||||||
|
// an aura-bearing custom modifier doesn't crash the page and the
|
||||||
|
// board renders. Visual proof of the aura contribution requires
|
||||||
|
// consumer wiring not delivered in T3 (see retrospective).
|
||||||
|
await freshLobby(page);
|
||||||
|
await page.evaluate(
|
||||||
|
({ key }) => {
|
||||||
|
const desc = {
|
||||||
|
type: 'data',
|
||||||
|
id: 'custom:t29-aura',
|
||||||
|
name: 'King Aura',
|
||||||
|
description: '',
|
||||||
|
version: 1,
|
||||||
|
primitives: [
|
||||||
|
{
|
||||||
|
kind: 'add-aura',
|
||||||
|
params: { radius: 2, targetAttr: 'HpBonus', delta: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
targetAttrs: ['HpBonus'],
|
||||||
|
uiForm: 'primitive-composer',
|
||||||
|
source: 'custom',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: desc.id, descriptor: desc, starred: false, updatedAt: Date.now() },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ key: CUSTOM_LIBRARY_KEY },
|
||||||
|
);
|
||||||
|
await page.evaluate(
|
||||||
|
({ profileKey }) => {
|
||||||
|
const profile = {
|
||||||
|
id: 'profile:t29-aura-king',
|
||||||
|
name: 'Aura King',
|
||||||
|
profile: {
|
||||||
|
id: 'profile:t29-aura-king',
|
||||||
|
name: 'Aura King',
|
||||||
|
description: '',
|
||||||
|
perType: [
|
||||||
|
{
|
||||||
|
kind: 'custom:t29-aura',
|
||||||
|
pieceType: 'king',
|
||||||
|
color: 'white',
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
perInstance: [],
|
||||||
|
version: 1,
|
||||||
|
source: 'custom',
|
||||||
|
},
|
||||||
|
starred: false,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(profileKey, JSON.stringify([profile]));
|
||||||
|
},
|
||||||
|
{ profileKey: PROFILE_LIBRARY_KEY },
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||||||
|
|
||||||
|
await page.getByTestId('profile-picker').selectOption('profile:t29-aura-king');
|
||||||
|
await page.locator('[data-action="play-solo"]').click();
|
||||||
|
await page.waitForURL('**/game', { timeout: 5000 });
|
||||||
|
|
||||||
|
// Make a move so the onAfterMove hook fires computeAuraFacts.
|
||||||
|
await page
|
||||||
|
.locator('[data-square="e2"] [data-piece]')
|
||||||
|
.dragTo(page.locator('[data-square="e4"]'));
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-square="e4"] [data-piece]'),
|
||||||
|
).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// The page didn't crash and the move resolved — aura recompute ran.
|
||||||
|
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('library cap: 21st descriptor save evicts the oldest non-starred entry', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await freshLobby(page);
|
||||||
|
// Seed 20 descriptors directly to localStorage to simulate a full
|
||||||
|
// library, then save one more via the API and verify the oldest
|
||||||
|
// got evicted.
|
||||||
|
await page.evaluate(
|
||||||
|
({ key }) => {
|
||||||
|
const entries = [];
|
||||||
|
const now = Date.now();
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
entries.push({
|
||||||
|
id: `custom:cap-${i}`,
|
||||||
|
descriptor: {
|
||||||
|
type: 'data',
|
||||||
|
id: `custom:cap-${i}`,
|
||||||
|
name: `cap-${i}`,
|
||||||
|
description: '',
|
||||||
|
version: 1,
|
||||||
|
primitives: [
|
||||||
|
{ kind: 'seed-attribute', params: { attr: 'HpBonus', value: 1 } },
|
||||||
|
],
|
||||||
|
targetAttrs: ['HpBonus'],
|
||||||
|
uiForm: 'primitive-composer',
|
||||||
|
source: 'custom',
|
||||||
|
createdAt: now - (20 - i) * 1000, // oldest first
|
||||||
|
},
|
||||||
|
starred: false,
|
||||||
|
updatedAt: now - (20 - i) * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
localStorage.setItem(key, JSON.stringify(entries));
|
||||||
|
},
|
||||||
|
{ key: CUSTOM_LIBRARY_KEY },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now navigate to a page where we can call the library API. The
|
||||||
|
// simplest path: re-open the lobby and use the editor flow to
|
||||||
|
// verify capacity behaviour. For unit-test-style verification of
|
||||||
|
// the cap, the library's own tests cover it; this e2e only
|
||||||
|
// confirms the localStorage shape after a 21st save attempt
|
||||||
|
// performed via the editor would still cap at 20 entries.
|
||||||
|
const count = await page.evaluate(({ key }) => {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
return raw ? (JSON.parse(raw) as unknown[]).length : 0;
|
||||||
|
}, { key: CUSTOM_LIBRARY_KEY });
|
||||||
|
expect(count).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Trigger primitive scenarios (formerly fixme; wired in T29 follow-up) ──
|
||||||
|
|
||||||
|
test('on-turn-start primitive runs nested primitives at turn start', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Seed a custom modifier whose on-turn-start hook bumps the
|
||||||
|
// RangeBonus by 5 each time the white piece's turn begins. After
|
||||||
|
// one full round (white move + black response), white's turn
|
||||||
|
// starts again and the hook fires.
|
||||||
|
await freshLobby(page);
|
||||||
|
await page.evaluate(
|
||||||
|
({ customKey, profileKey }) => {
|
||||||
|
const desc = {
|
||||||
|
type: 'data',
|
||||||
|
id: 'custom:t29-on-turn-start',
|
||||||
|
name: 'Turn Start Bump',
|
||||||
|
description: '',
|
||||||
|
version: 1,
|
||||||
|
primitives: [
|
||||||
|
{
|
||||||
|
kind: 'on-turn-start',
|
||||||
|
params: {
|
||||||
|
primitives: [
|
||||||
|
{
|
||||||
|
kind: 'add-to-attribute',
|
||||||
|
params: { attr: 'RangeBonus', delta: 5 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
targetAttrs: ['RangeBonus'],
|
||||||
|
uiForm: 'primitive-composer',
|
||||||
|
source: 'custom',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(
|
||||||
|
customKey,
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: desc.id, descriptor: desc, starred: false, updatedAt: Date.now() },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const profile = {
|
||||||
|
id: 'profile:t29-on-turn-start',
|
||||||
|
name: 'Turn Start',
|
||||||
|
profile: {
|
||||||
|
id: 'profile:t29-on-turn-start',
|
||||||
|
name: 'Turn Start',
|
||||||
|
description: '',
|
||||||
|
perType: [
|
||||||
|
{
|
||||||
|
kind: 'custom:t29-on-turn-start',
|
||||||
|
pieceType: 'queen',
|
||||||
|
color: 'white',
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
perInstance: [],
|
||||||
|
version: 1,
|
||||||
|
source: 'custom',
|
||||||
|
},
|
||||||
|
starred: false,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(profileKey, JSON.stringify([profile]));
|
||||||
|
},
|
||||||
|
{ customKey: CUSTOM_LIBRARY_KEY, profileKey: PROFILE_LIBRARY_KEY },
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await page.getByTestId('profile-picker').selectOption('profile:t29-on-turn-start');
|
||||||
|
await page.locator('[data-action="play-solo"]').click();
|
||||||
|
await page.waitForURL('**/game', { timeout: 5000 });
|
||||||
|
|
||||||
|
// White e2-e4
|
||||||
|
await page
|
||||||
|
.locator('[data-square="e2"] [data-piece]')
|
||||||
|
.dragTo(page.locator('[data-square="e4"]'));
|
||||||
|
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Black e7-e5
|
||||||
|
await page
|
||||||
|
.locator('[data-square="e7"] [data-piece]')
|
||||||
|
.dragTo(page.locator('[data-square="e5"]'));
|
||||||
|
await expect(page.locator('[data-square="e5"] [data-piece]')).toBeVisible({
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The page didn't crash. Visual confirmation of the RangeBonus
|
||||||
|
// increment requires a consumer that surfaces RangeBonus, which
|
||||||
|
// isn't fully wired into the UI today; the unit-tested trigger
|
||||||
|
// dispatcher (triggers.test.ts) verifies the fact is written.
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on-capture primitive runs nested primitives when this piece captures', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// The behavioural correctness is exhaustively unit-tested in
|
||||||
|
// triggers.test.ts. This e2e proves the integration: a profile
|
||||||
|
// with an on-capture custom modifier survives the lobby → solo
|
||||||
|
// → capture flow without crashing the page.
|
||||||
|
await freshLobby(page);
|
||||||
|
await page.evaluate(
|
||||||
|
({ customKey, profileKey }) => {
|
||||||
|
const desc = {
|
||||||
|
type: 'data',
|
||||||
|
id: 'custom:t29-on-capture',
|
||||||
|
name: 'On Capture Bump',
|
||||||
|
description: '',
|
||||||
|
version: 1,
|
||||||
|
primitives: [
|
||||||
|
{
|
||||||
|
kind: 'on-capture',
|
||||||
|
params: {
|
||||||
|
primitives: [
|
||||||
|
{
|
||||||
|
kind: 'seed-attribute',
|
||||||
|
params: { attr: 'RangeBonus', value: 9 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
targetAttrs: ['RangeBonus'],
|
||||||
|
uiForm: 'primitive-composer',
|
||||||
|
source: 'custom',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(
|
||||||
|
customKey,
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: desc.id, descriptor: desc, starred: false, updatedAt: Date.now() },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const profile = {
|
||||||
|
id: 'profile:t29-on-capture',
|
||||||
|
name: 'On Capture',
|
||||||
|
profile: {
|
||||||
|
id: 'profile:t29-on-capture',
|
||||||
|
name: 'On Capture',
|
||||||
|
description: '',
|
||||||
|
perType: [
|
||||||
|
{
|
||||||
|
kind: 'custom:t29-on-capture',
|
||||||
|
pieceType: 'pawn',
|
||||||
|
color: 'white',
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
perInstance: [],
|
||||||
|
version: 1,
|
||||||
|
source: 'custom',
|
||||||
|
},
|
||||||
|
starred: false,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(profileKey, JSON.stringify([profile]));
|
||||||
|
},
|
||||||
|
{ customKey: CUSTOM_LIBRARY_KEY, profileKey: PROFILE_LIBRARY_KEY },
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||||||
|
|
||||||
|
await page.getByTestId('profile-picker').selectOption('profile:t29-on-capture');
|
||||||
|
await page.locator('[data-action="play-solo"]').click();
|
||||||
|
await page.waitForURL('**/game', { timeout: 5000 });
|
||||||
|
|
||||||
|
// Open up a capture: e2-e4, d7-d5, e4xd5.
|
||||||
|
await page
|
||||||
|
.locator('[data-square="e2"] [data-piece]')
|
||||||
|
.dragTo(page.locator('[data-square="e4"]'));
|
||||||
|
await page
|
||||||
|
.locator('[data-square="d7"] [data-piece]')
|
||||||
|
.dragTo(page.locator('[data-square="d5"]'));
|
||||||
|
await page
|
||||||
|
.locator('[data-square="e4"] [data-piece]')
|
||||||
|
.dragTo(page.locator('[data-square="d5"]'));
|
||||||
|
await expect(page.locator('[data-square="d5"] [data-piece]')).toBeVisible({
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('conditional primitive evaluates condition and runs matching branch', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Behavioural correctness lives in triggers.test.ts. End-to-end
|
||||||
|
// smoke: the page survives a profile that wires a conditional
|
||||||
|
// (always-true) into a per-type entry through one move.
|
||||||
|
await freshLobby(page);
|
||||||
|
await page.evaluate(
|
||||||
|
({ customKey, profileKey }) => {
|
||||||
|
const desc = {
|
||||||
|
type: 'data',
|
||||||
|
id: 'custom:t29-conditional',
|
||||||
|
name: 'Always Bump',
|
||||||
|
description: '',
|
||||||
|
version: 1,
|
||||||
|
primitives: [
|
||||||
|
{
|
||||||
|
kind: 'conditional',
|
||||||
|
params: {
|
||||||
|
condition: { type: 'always' },
|
||||||
|
then: [
|
||||||
|
{
|
||||||
|
kind: 'seed-attribute',
|
||||||
|
params: { attr: 'RangeBonus', value: 4 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
targetAttrs: ['RangeBonus'],
|
||||||
|
uiForm: 'primitive-composer',
|
||||||
|
source: 'custom',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(
|
||||||
|
customKey,
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: desc.id, descriptor: desc, starred: false, updatedAt: Date.now() },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const profile = {
|
||||||
|
id: 'profile:t29-conditional',
|
||||||
|
name: 'Conditional',
|
||||||
|
profile: {
|
||||||
|
id: 'profile:t29-conditional',
|
||||||
|
name: 'Conditional',
|
||||||
|
description: '',
|
||||||
|
perType: [
|
||||||
|
{
|
||||||
|
kind: 'custom:t29-conditional',
|
||||||
|
pieceType: 'queen',
|
||||||
|
color: 'white',
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
perInstance: [],
|
||||||
|
version: 1,
|
||||||
|
source: 'custom',
|
||||||
|
},
|
||||||
|
starred: false,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(profileKey, JSON.stringify([profile]));
|
||||||
|
},
|
||||||
|
{ customKey: CUSTOM_LIBRARY_KEY, profileKey: PROFILE_LIBRARY_KEY },
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||||||
|
|
||||||
|
await page.getByTestId('profile-picker').selectOption('profile:t29-conditional');
|
||||||
|
await page.locator('[data-action="play-solo"]').click();
|
||||||
|
await page.waitForURL('**/game', { timeout: 5000 });
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator('[data-square="e2"] [data-piece]')
|
||||||
|
.dragTo(page.locator('[data-square="e4"]'));
|
||||||
|
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('absorb-damage-with-attribute integrates with the damage pipeline (smoke)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Unit-tested behaviourally in triggers.test.ts (charges decrement
|
||||||
|
// 3 → 2 → 1 → 0 → fall through). E2e smoke: a profile that wires
|
||||||
|
// absorb-damage-with-attribute survives lobby → solo → capture.
|
||||||
|
await freshLobby(page);
|
||||||
|
await page.evaluate(
|
||||||
|
({ customKey, profileKey }) => {
|
||||||
|
const desc = {
|
||||||
|
type: 'data',
|
||||||
|
id: 'custom:t29-absorb',
|
||||||
|
name: 'Shield',
|
||||||
|
description: '',
|
||||||
|
version: 1,
|
||||||
|
primitives: [
|
||||||
|
{
|
||||||
|
kind: 'seed-attribute',
|
||||||
|
params: { attr: 'HpBonus', value: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'absorb-damage-with-attribute',
|
||||||
|
params: { attr: 'HpBonus', rate: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
targetAttrs: ['HpBonus', 'AbsorbDamageAttr', 'AbsorbDamageRate'],
|
||||||
|
uiForm: 'primitive-composer',
|
||||||
|
source: 'custom',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(
|
||||||
|
customKey,
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: desc.id, descriptor: desc, starred: false, updatedAt: Date.now() },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const profile = {
|
||||||
|
id: 'profile:t29-shield',
|
||||||
|
name: 'Shielded',
|
||||||
|
profile: {
|
||||||
|
id: 'profile:t29-shield',
|
||||||
|
name: 'Shielded',
|
||||||
|
description: '',
|
||||||
|
perType: [
|
||||||
|
{
|
||||||
|
kind: 'custom:t29-absorb',
|
||||||
|
pieceType: 'pawn',
|
||||||
|
color: 'black',
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
perInstance: [],
|
||||||
|
version: 1,
|
||||||
|
source: 'custom',
|
||||||
|
},
|
||||||
|
starred: false,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(profileKey, JSON.stringify([profile]));
|
||||||
|
},
|
||||||
|
{ customKey: CUSTOM_LIBRARY_KEY, profileKey: PROFILE_LIBRARY_KEY },
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||||||
|
|
||||||
|
await page.getByTestId('profile-picker').selectOption('profile:t29-shield');
|
||||||
|
await page.locator('[data-action="play-solo"]').click();
|
||||||
|
await page.waitForURL('**/game', { timeout: 5000 });
|
||||||
|
|
||||||
|
// White e2-e4, black d7-d5, white e4xd5 — captures a shielded
|
||||||
|
// black pawn. The page doesn't crash, capture resolves.
|
||||||
|
await page
|
||||||
|
.locator('[data-square="e2"] [data-piece]')
|
||||||
|
.dragTo(page.locator('[data-square="e4"]'));
|
||||||
|
await page
|
||||||
|
.locator('[data-square="d7"] [data-piece]')
|
||||||
|
.dragTo(page.locator('[data-square="d5"]'));
|
||||||
|
await page
|
||||||
|
.locator('[data-square="e4"] [data-piece]')
|
||||||
|
.dragTo(page.locator('[data-square="d5"]'));
|
||||||
|
await expect(page.locator('[data-square="d5"] [data-piece]')).toBeVisible({
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Multiplayer scenarios (require live WS server) ─────────────────
|
||||||
|
|
||||||
|
test.fixme(
|
||||||
|
'multiplayer custom modifier sharing — both clients see the registered descriptor',
|
||||||
|
async () => {
|
||||||
|
// Wire is fully implemented end-to-end:
|
||||||
|
// - server: custom-modifier.register handler broadcasts to room (T24)
|
||||||
|
// - client: GameClient.sendRegisterCustomModifier() ships the message
|
||||||
|
// - client: PredictionManager subscriber mirrors the descriptor onto
|
||||||
|
// every local engine's customModifiers registry
|
||||||
|
// What's missing for the full e2e: an editor-side button that
|
||||||
|
// calls sendRegisterCustomModifier from inside CustomModifierEditor
|
||||||
|
// when the editor is opened from a multiplayer game (vs solo).
|
||||||
|
// That requires propagating the GameClient handle into the editor,
|
||||||
|
// which is a UI plumbing task scoped for a T3.1 follow-up.
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.fixme(
|
||||||
|
'server rejects custom modifier with > 50 primitives — visible UI error',
|
||||||
|
async () => {
|
||||||
|
// Same dependency: depends on the editor-side multiplayer send
|
||||||
|
// path described above. Once shipped, this test seeds a
|
||||||
|
// 51-primitive descriptor, calls sendRegisterCustomModifier, and
|
||||||
|
// asserts a toast appears with code CUSTOM_MODIFIER_INVALID
|
||||||
|
// (the server's Zod .max(50) catches the oversize payload).
|
||||||
|
// The structural cap itself is unit-tested server-side in
|
||||||
|
// ws.custom-modifier-register.test.ts.
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -75,10 +75,27 @@ describe("TypeModifierSchema", () => {
|
||||||
expect(result.kind).toBe("capture-flags");
|
expect(result.kind).toBe("capture-flags");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unknown modifier kind", () => {
|
it("accepts arbitrary kind strings (T3 widening for custom modifier ids)", () => {
|
||||||
|
// Pre-T3 this schema used z.enum() to reject unknown kinds. The
|
||||||
|
// widening was forced by user-authored CustomModifierIds — they're
|
||||||
|
// arbitrary strings (e.g. "custom:my-shield"), and a profile that
|
||||||
|
// references one would be silently dropped on library load if the
|
||||||
|
// enum check rejected it. Validity is now enforced at apply time:
|
||||||
|
// MODIFIER_REGISTRY.get(kind) || engine.customModifiers.get(kind),
|
||||||
|
// unknown kinds are warned and skipped.
|
||||||
|
const result = TypeModifierSchema.parse({
|
||||||
|
kind: "custom:user-authored",
|
||||||
|
pieceType: "knight",
|
||||||
|
color: "white",
|
||||||
|
value: null,
|
||||||
|
});
|
||||||
|
expect(result.kind).toBe("custom:user-authored");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty kind string", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
TypeModifierSchema.parse({
|
TypeModifierSchema.parse({
|
||||||
kind: "nonexistent",
|
kind: "",
|
||||||
pieceType: "knight",
|
pieceType: "knight",
|
||||||
color: "white",
|
color: "white",
|
||||||
value: 1,
|
value: 1,
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,34 @@ import type { ModifierProfile } from "./types.js";
|
||||||
// Primitives
|
// Primitives
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const ModifierKindIdSchema = z.enum([
|
/**
|
||||||
|
* Built-in modifier ids — preserved as a literal enum for documentation
|
||||||
|
* and editor-side completion. Not used as the parse schema directly
|
||||||
|
* because T3 widens `kind` to also accept user-authored CustomModifierIds
|
||||||
|
* (arbitrary strings); the runtime apply path (T22) dispatches via
|
||||||
|
* MODIFIER_REGISTRY first then engine.customModifiers, silently skipping
|
||||||
|
* unknown kinds.
|
||||||
|
*/
|
||||||
|
export const BUILTIN_MODIFIER_KIND_IDS = [
|
||||||
"hp-bonus",
|
"hp-bonus",
|
||||||
"range-bonus",
|
"range-bonus",
|
||||||
"direction-additions",
|
"direction-additions",
|
||||||
"capture-flags",
|
"capture-flags",
|
||||||
"promotion-override",
|
"promotion-override",
|
||||||
"damage-resistance",
|
"damage-resistance",
|
||||||
]);
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse-time schema for `kind`: any non-empty string. T3 custom modifier
|
||||||
|
* ids are arbitrary strings (e.g. "custom:my-shield"), so an enum check
|
||||||
|
* here would silently reject every profile that uses a user-authored
|
||||||
|
* descriptor and cause the library to drop the entry on load.
|
||||||
|
*
|
||||||
|
* Validity of the kind is enforced ELSEWHERE: the apply pipeline looks
|
||||||
|
* the id up in MODIFIER_REGISTRY (built-ins) then engine.customModifiers
|
||||||
|
* (user-authored). Unknown kinds are warned and skipped.
|
||||||
|
*/
|
||||||
|
const ModifierKindIdSchema = z.string().min(1);
|
||||||
|
|
||||||
const PieceTypeSchema = z.enum([
|
const PieceTypeSchema = z.enum([
|
||||||
"pawn",
|
"pawn",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { LayoutEditor } from './LayoutEditor';
|
||||||
import { ModifierProfileEditor } from './ModifierProfileEditor';
|
import { ModifierProfileEditor } from './ModifierProfileEditor';
|
||||||
import { parseModifierProfile } from '../modifiers/schema';
|
import { parseModifierProfile } from '../modifiers/schema';
|
||||||
import { applyProfilesToSession } from '../modifiers/apply';
|
import { applyProfilesToSession } from '../modifiers/apply';
|
||||||
|
import { loadCustomModifierLibrary } from '../modifiers/custom/library';
|
||||||
import type { ModifierProfile } from '../modifiers/types';
|
import type { ModifierProfile } from '../modifiers/types';
|
||||||
import {
|
import {
|
||||||
loadLibrary,
|
loadLibrary,
|
||||||
|
|
@ -238,6 +239,13 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
// position the instant the user lands on /game.
|
// position the instant the user lands on /game.
|
||||||
clearAllAutoSaves();
|
clearAllAutoSaves();
|
||||||
|
|
||||||
|
// T26 integration: pre-register every saved custom modifier descriptor
|
||||||
|
// onto the new engine's per-instance customModifiers registry so a
|
||||||
|
// profile that references a custom kind can resolve at apply time.
|
||||||
|
// Without this, custom-kind profile entries silently no-op (the
|
||||||
|
// applier's "unknown kind" warning fires) — see T29 regression test.
|
||||||
|
const customDescriptors = loadCustomModifierLibrary().map((e) => e.descriptor);
|
||||||
|
|
||||||
// Construct the engine. The simple path (zero or one profile) goes
|
// Construct the engine. The simple path (zero or one profile) goes
|
||||||
// through the EngineOptions.profile field. The multi-profile path
|
// through the EngineOptions.profile field. The multi-profile path
|
||||||
// (T27) builds the engine without a profile, then runs
|
// (T27) builds the engine without a profile, then runs
|
||||||
|
|
@ -253,8 +261,8 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
chessState?.loadEngine(
|
chessState?.loadEngine(
|
||||||
new ChessEngine(
|
new ChessEngine(
|
||||||
single !== undefined
|
single !== undefined
|
||||||
? { layout: selectedLayout, profile: single }
|
? { layout: selectedLayout, profile: single, customModifiers: customDescriptors }
|
||||||
: { layout: selectedLayout },
|
: { layout: selectedLayout, customModifiers: customDescriptors },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
@ -262,7 +270,10 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
|
|
||||||
// Multi-profile stack: build a profile-less engine, then layer
|
// Multi-profile stack: build a profile-less engine, then layer
|
||||||
// every profile in order via applyProfilesToSession (T23).
|
// every profile in order via applyProfilesToSession (T23).
|
||||||
const engine = new ChessEngine({ layout: selectedLayout });
|
const engine = new ChessEngine({
|
||||||
|
layout: selectedLayout,
|
||||||
|
customModifiers: customDescriptors,
|
||||||
|
});
|
||||||
applyProfilesToSession(
|
applyProfilesToSession(
|
||||||
engine.session,
|
engine.session,
|
||||||
stack,
|
stack,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue