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");
|
||||
});
|
||||
|
||||
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(() =>
|
||||
TypeModifierSchema.parse({
|
||||
kind: "nonexistent",
|
||||
kind: "",
|
||||
pieceType: "knight",
|
||||
color: "white",
|
||||
value: 1,
|
||||
|
|
|
|||
|
|
@ -17,14 +17,34 @@ import type { ModifierProfile } from "./types.js";
|
|||
// 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",
|
||||
"range-bonus",
|
||||
"direction-additions",
|
||||
"capture-flags",
|
||||
"promotion-override",
|
||||
"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([
|
||||
"pawn",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { LayoutEditor } from './LayoutEditor';
|
|||
import { ModifierProfileEditor } from './ModifierProfileEditor';
|
||||
import { parseModifierProfile } from '../modifiers/schema';
|
||||
import { applyProfilesToSession } from '../modifiers/apply';
|
||||
import { loadCustomModifierLibrary } from '../modifiers/custom/library';
|
||||
import type { ModifierProfile } from '../modifiers/types';
|
||||
import {
|
||||
loadLibrary,
|
||||
|
|
@ -238,6 +239,13 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
// position the instant the user lands on /game.
|
||||
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
|
||||
// through the EngineOptions.profile field. The multi-profile path
|
||||
// (T27) builds the engine without a profile, then runs
|
||||
|
|
@ -253,8 +261,8 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
chessState?.loadEngine(
|
||||
new ChessEngine(
|
||||
single !== undefined
|
||||
? { layout: selectedLayout, profile: single }
|
||||
: { layout: selectedLayout },
|
||||
? { layout: selectedLayout, profile: single, customModifiers: customDescriptors }
|
||||
: { layout: selectedLayout, customModifiers: customDescriptors },
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
|
@ -262,7 +270,10 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
|
||||
// Multi-profile stack: build a profile-less engine, then layer
|
||||
// every profile in order via applyProfilesToSession (T23).
|
||||
const engine = new ChessEngine({ layout: selectedLayout });
|
||||
const engine = new ChessEngine({
|
||||
layout: selectedLayout,
|
||||
customModifiers: customDescriptors,
|
||||
});
|
||||
applyProfilesToSession(
|
||||
engine.session,
|
||||
stack,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue