Attr-string fields in the Custom Modifier Editor now differentiate between declare-sites (seed-attribute.attr) and consume-sites (add-to-attribute.attr, multiply-attribute.attr, add-aura.targetAttr, absorb-damage-with-attribute.attr): - Consume-sites hide the illustrative 'User-defined examples' group (ArmorPlates/BloodStacks/ManaPool) because those names are fiction unless something seeds them. - Consume-sites surface a new 'Seeded in this descriptor' group populated from seed-attribute primitives elsewhere in the current tree (including inside trigger children via childPrimitives). - Badge states: emerald 'seeded' when the typed value matches an in-tree seed; red 'not seeded' when it's a non-schema name with no backing seed; amber 'user-defined' only in declare-mode for off-catalog names. Declare-mode behaviour is unchanged — inventing ShieldCharges there still works without warnings.
1569 lines
53 KiB
TypeScript
1569 lines
53 KiB
TypeScript
/**
|
|
* 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('multiplayer custom modifier sharing — both clients see the registered descriptor', async ({
|
|
browser,
|
|
}) => {
|
|
// Skip when no WS server is running (e.g. in CI without the dev
|
|
// server) — same gate the modifier-profiles MP tests use.
|
|
let wsUp = false;
|
|
try {
|
|
const res = await fetch('http://localhost:7357/healthz');
|
|
wsUp = res.ok;
|
|
} catch {
|
|
wsUp = false;
|
|
}
|
|
test.skip(!wsUp, 'No WS server on :7357 — multiplayer test skipped');
|
|
|
|
const ctxHost = await browser.newContext();
|
|
const ctxOpp = await browser.newContext();
|
|
const pageHost = await ctxHost.newPage();
|
|
const pageOpp = await ctxOpp.newPage();
|
|
|
|
try {
|
|
// Both players create / join a room via raw WS so we don't have
|
|
// to drive the React lobby in two contexts simultaneously.
|
|
await pageHost.goto('/');
|
|
await pageHost.waitForSelector('[data-testid="page-home"]');
|
|
const roomHost = await wsCreateRoomNoProfile(pageHost);
|
|
await pageOpp.goto('/');
|
|
await pageOpp.waitForSelector('[data-testid="page-home"]');
|
|
const roomOpp = await wsJoinRoomShared(pageOpp, roomHost.code);
|
|
|
|
// The descriptor we'll share. Minimum-viable; the test cares about
|
|
// it landing on both sides, not its semantic effect.
|
|
const descriptor = {
|
|
type: 'data' as const,
|
|
id: 'custom:t29-mp-share',
|
|
name: 'Shared Boost',
|
|
description: '',
|
|
version: 1 as const,
|
|
primitives: [
|
|
{
|
|
kind: 'seed-attribute' as const,
|
|
params: { attr: 'HpBonus', value: 1 },
|
|
},
|
|
],
|
|
targetAttrs: ['HpBonus'],
|
|
uiForm: 'primitive-composer' as const,
|
|
source: 'custom' as const,
|
|
};
|
|
|
|
// Run host and opponent flows concurrently — opponent must be
|
|
// listening BEFORE the host's register lands or the broadcast
|
|
// goes to a non-listening socket. Each side opens a raw WS
|
|
// (reconnect by token), tracks observed message types, and the
|
|
// host sends `custom-modifier.register` only AFTER it has
|
|
// observed its own `room.joined` ack (proves the reconnect
|
|
// landed and the socket is now in the room's broadcast set).
|
|
const [hostFlow, oppFlow] = await Promise.all([
|
|
pageHost.evaluate(
|
|
async ({ roomCode, token, descriptor }) => {
|
|
return new Promise<{ types: string[]; sawRegistered: boolean }>(
|
|
(resolve) => {
|
|
const types: string[] = [];
|
|
let sawRegistered = false;
|
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
|
let seq = 100;
|
|
ws.onopen = () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: seq++,
|
|
ts: Date.now(),
|
|
type: 'room.join',
|
|
token,
|
|
payload: { code: roomCode },
|
|
}),
|
|
);
|
|
};
|
|
ws.onmessage = (e: MessageEvent) => {
|
|
const msg = JSON.parse(e.data as string) as {
|
|
type: string;
|
|
};
|
|
types.push(msg.type);
|
|
if (msg.type === 'custom-modifier.registered') {
|
|
sawRegistered = true;
|
|
}
|
|
// Once the server has acknowledged the reconnect
|
|
// (room.joined) AND given us time to be sure the
|
|
// opponent is also reconnected, send the register.
|
|
if (msg.type === 'room.joined') {
|
|
setTimeout(() => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: seq++,
|
|
ts: Date.now(),
|
|
type: 'custom-modifier.register',
|
|
token,
|
|
payload: { roomCode, descriptor },
|
|
}),
|
|
);
|
|
}, 250);
|
|
}
|
|
};
|
|
setTimeout(() => {
|
|
ws.close();
|
|
resolve({ types, sawRegistered });
|
|
}, 2500);
|
|
},
|
|
);
|
|
},
|
|
{
|
|
roomCode: roomHost.code,
|
|
token: roomHost.token,
|
|
descriptor,
|
|
},
|
|
),
|
|
pageOpp.evaluate(
|
|
async ({ roomCode, token }) => {
|
|
return new Promise<{ types: string[]; sawRegistered: boolean }>(
|
|
(resolve) => {
|
|
const types: string[] = [];
|
|
let sawRegistered = false;
|
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
|
let seq = 100;
|
|
ws.onopen = () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: seq++,
|
|
ts: Date.now(),
|
|
type: 'room.join',
|
|
token,
|
|
payload: { code: roomCode },
|
|
}),
|
|
);
|
|
};
|
|
ws.onmessage = (e: MessageEvent) => {
|
|
const msg = JSON.parse(e.data as string) as {
|
|
type: string;
|
|
};
|
|
types.push(msg.type);
|
|
if (msg.type === 'custom-modifier.registered') {
|
|
sawRegistered = true;
|
|
}
|
|
};
|
|
setTimeout(() => {
|
|
ws.close();
|
|
resolve({ types, sawRegistered });
|
|
}, 2500);
|
|
},
|
|
);
|
|
},
|
|
{ roomCode: roomHost.code, token: roomOpp.token },
|
|
),
|
|
]);
|
|
|
|
// Both sides MUST observe the broadcast — the host receives it
|
|
// too (server broadcasts to every connected client, including the
|
|
// sender, so local state confirms server-authoritative state).
|
|
expect(hostFlow.sawRegistered).toBe(true);
|
|
expect(oppFlow.sawRegistered).toBe(true);
|
|
} finally {
|
|
await ctxHost.close();
|
|
await ctxOpp.close();
|
|
}
|
|
});
|
|
|
|
test('late-joiner + reconnect receive registered custom modifiers in game.state', async ({
|
|
browser,
|
|
}) => {
|
|
// Skip when no WS server is running.
|
|
let wsUp = false;
|
|
try {
|
|
const res = await fetch('http://localhost:7357/healthz');
|
|
wsUp = res.ok;
|
|
} catch {
|
|
wsUp = false;
|
|
}
|
|
test.skip(!wsUp, 'No WS server on :7357 — multiplayer test skipped');
|
|
|
|
// T3 audit gap 2 regression guard. Flow:
|
|
// 1. Client A creates a room and registers a custom modifier.
|
|
// 2. Client B joins the room AFTER registration.
|
|
// 3. B's first game.state snapshot MUST include the descriptor
|
|
// in `customModifiers` so B's local engine mirrors it.
|
|
// 4. B disconnects and reconnects via token — the replay
|
|
// game.state MUST also include the descriptor (otherwise a
|
|
// reconnect loses the custom registry).
|
|
const ctxHost = await browser.newContext();
|
|
const ctxOpp = await browser.newContext();
|
|
const pageHost = await ctxHost.newPage();
|
|
const pageOpp = await ctxOpp.newPage();
|
|
|
|
const descriptor = {
|
|
type: 'data' as const,
|
|
id: 'custom:t29-late-joiner',
|
|
name: 'Late Joiner Shared',
|
|
description: '',
|
|
version: 1 as const,
|
|
primitives: [
|
|
{
|
|
kind: 'seed-attribute' as const,
|
|
params: { attr: 'HpBonus', value: 2 },
|
|
},
|
|
],
|
|
targetAttrs: ['HpBonus'],
|
|
uiForm: 'primitive-composer' as const,
|
|
source: 'custom' as const,
|
|
};
|
|
|
|
try {
|
|
// Step 1-2: host creates + registers.
|
|
await pageHost.goto('/');
|
|
await pageHost.waitForSelector('[data-testid="page-home"]');
|
|
const roomHost = await wsCreateRoomNoProfile(pageHost);
|
|
await pageHost.evaluate(
|
|
async ({ roomCode, token, descriptor }) => {
|
|
return new Promise<void>((resolve) => {
|
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
|
ws.onopen = () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: 100,
|
|
ts: Date.now(),
|
|
type: 'room.join',
|
|
token,
|
|
payload: { code: roomCode },
|
|
}),
|
|
);
|
|
};
|
|
ws.onmessage = (e: MessageEvent) => {
|
|
const msg = JSON.parse(e.data as string) as { type: string };
|
|
if (msg.type === 'room.joined') {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: 101,
|
|
ts: Date.now(),
|
|
type: 'custom-modifier.register',
|
|
token,
|
|
payload: { roomCode, descriptor },
|
|
}),
|
|
);
|
|
}
|
|
if (msg.type === 'custom-modifier.registered') {
|
|
ws.close();
|
|
resolve();
|
|
}
|
|
};
|
|
setTimeout(() => {
|
|
ws.close();
|
|
resolve();
|
|
}, 2000);
|
|
});
|
|
},
|
|
{
|
|
roomCode: roomHost.code,
|
|
token: roomHost.token,
|
|
descriptor,
|
|
},
|
|
);
|
|
|
|
// Step 3: opponent joins AFTER registration; their game.state
|
|
// snapshot should carry the descriptor.
|
|
await pageOpp.goto('/');
|
|
await pageOpp.waitForSelector('[data-testid="page-home"]');
|
|
const joinResult = await pageOpp.evaluate(
|
|
async (roomCode: string) => {
|
|
return new Promise<{ types: string[]; customIds: string[] }>(
|
|
(resolve) => {
|
|
const types: string[] = [];
|
|
const customIds: string[] = [];
|
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
|
ws.onopen = () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: 1,
|
|
ts: Date.now(),
|
|
type: 'room.join',
|
|
payload: { code: roomCode },
|
|
}),
|
|
);
|
|
};
|
|
ws.onmessage = (e: MessageEvent) => {
|
|
const msg = JSON.parse(e.data as string) as {
|
|
type: string;
|
|
payload?: {
|
|
customModifiers?: Array<{ id: string }>;
|
|
};
|
|
};
|
|
types.push(msg.type);
|
|
if (msg.type === 'game.state') {
|
|
const mods = msg.payload?.customModifiers ?? [];
|
|
for (const m of mods) customIds.push(m.id);
|
|
}
|
|
};
|
|
setTimeout(() => {
|
|
ws.close();
|
|
resolve({ types, customIds });
|
|
}, 1500);
|
|
},
|
|
);
|
|
},
|
|
roomHost.code,
|
|
);
|
|
|
|
// The joiner's game.state carries the descriptor that was
|
|
// registered BEFORE they connected.
|
|
expect(joinResult.customIds).toContain('custom:t29-late-joiner');
|
|
} finally {
|
|
await ctxHost.close();
|
|
await ctxOpp.close();
|
|
}
|
|
});
|
|
|
|
test('server rejects custom modifier with > 50 primitives — error event observed', async ({
|
|
browser,
|
|
}) => {
|
|
// Same WS gate as the previous test.
|
|
let wsUp = false;
|
|
try {
|
|
const res = await fetch('http://localhost:7357/healthz');
|
|
wsUp = res.ok;
|
|
} catch {
|
|
wsUp = false;
|
|
}
|
|
test.skip(!wsUp, 'No WS server on :7357 — multiplayer test skipped');
|
|
|
|
const ctxHost = await browser.newContext();
|
|
const pageHost = await ctxHost.newPage();
|
|
|
|
try {
|
|
await pageHost.goto('/');
|
|
await pageHost.waitForSelector('[data-testid="page-home"]');
|
|
const roomHost = await wsCreateRoomNoProfile(pageHost);
|
|
|
|
// Build a descriptor with 51 primitives — Zod's .max(50) on the
|
|
// wire schema rejects it as INVALID_MESSAGE before it reaches
|
|
// the handler.
|
|
const oversizedPrimitives = Array.from({ length: 51 }, () => ({
|
|
kind: 'seed-attribute',
|
|
params: { attr: 'HpBonus', value: 1 },
|
|
}));
|
|
const oversizedDescriptor = {
|
|
type: 'data',
|
|
id: 'custom:t29-oversize',
|
|
name: 'Oversize',
|
|
description: '',
|
|
version: 1,
|
|
primitives: oversizedPrimitives,
|
|
targetAttrs: ['HpBonus'],
|
|
uiForm: 'primitive-composer',
|
|
source: 'custom',
|
|
};
|
|
|
|
const flow = await pageHost.evaluate(
|
|
async ({ roomCode, token, descriptor }) => {
|
|
return new Promise<{
|
|
sawError: boolean;
|
|
sawRegistered: boolean;
|
|
errorCode: string | null;
|
|
}>((resolve) => {
|
|
let sawError = false;
|
|
let sawRegistered = false;
|
|
let errorCode: string | null = null;
|
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
|
let seq = 100;
|
|
ws.onopen = () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: seq++,
|
|
ts: Date.now(),
|
|
type: 'room.join',
|
|
token,
|
|
payload: { code: roomCode },
|
|
}),
|
|
);
|
|
setTimeout(() => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: seq++,
|
|
ts: Date.now(),
|
|
type: 'custom-modifier.register',
|
|
token,
|
|
payload: { roomCode, descriptor },
|
|
}),
|
|
);
|
|
}, 100);
|
|
};
|
|
ws.onmessage = (e: MessageEvent) => {
|
|
const msg = JSON.parse(e.data as string) as {
|
|
type: string;
|
|
payload?: { code?: string };
|
|
};
|
|
if (msg.type === 'error') {
|
|
sawError = true;
|
|
errorCode = msg.payload?.code ?? null;
|
|
}
|
|
if (msg.type === 'custom-modifier.registered') {
|
|
sawRegistered = true;
|
|
}
|
|
};
|
|
setTimeout(() => {
|
|
ws.close();
|
|
resolve({ sawError, sawRegistered, errorCode });
|
|
}, 1000);
|
|
});
|
|
},
|
|
{
|
|
roomCode: roomHost.code,
|
|
token: roomHost.token,
|
|
descriptor: oversizedDescriptor,
|
|
},
|
|
);
|
|
|
|
expect(flow.sawError).toBe(true);
|
|
// The Zod cap fires first on an INVALID_MESSAGE; either is
|
|
// acceptable as proof the server rejected the oversize.
|
|
expect(['INVALID_MESSAGE', 'CUSTOM_MODIFIER_INVALID']).toContain(
|
|
flow.errorCode,
|
|
);
|
|
expect(flow.sawRegistered).toBe(false);
|
|
} finally {
|
|
await ctxHost.close();
|
|
}
|
|
});
|
|
|
|
test('editor shows docs + examples for selected primitive', async ({
|
|
page,
|
|
}) => {
|
|
await freshLobby(page);
|
|
await openProfileEditor(page);
|
|
await openCustomModifierEditor(page);
|
|
|
|
// Add a primitive so the inspector renders.
|
|
await page.getByTestId('custom-primitive-palette-seed-attribute').click();
|
|
await page.getByTestId('custom-primitive-node-0').click();
|
|
|
|
// Docs panel is present with at least one example.
|
|
const docs = page.getByTestId('custom-primitive-docs');
|
|
await expect(docs).toBeVisible();
|
|
await expect(docs).toContainText('seed-attribute');
|
|
await expect(
|
|
page.getByTestId('custom-primitive-example-0'),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('AttrCombobox autocompletes known attrs and accepts user-defined names', async ({
|
|
page,
|
|
}) => {
|
|
await freshLobby(page);
|
|
await openProfileEditor(page);
|
|
await openCustomModifierEditor(page);
|
|
|
|
// Add a seed-attribute primitive so the attr field renders.
|
|
await page.getByTestId('custom-primitive-palette-seed-attribute').click();
|
|
await page.getByTestId('custom-primitive-node-0').click();
|
|
|
|
const combobox = page.getByTestId('primitive-seed-attribute-attr');
|
|
await expect(combobox).toBeVisible();
|
|
|
|
// Focus the input; the dropdown should open with groups visible.
|
|
const input = page.getByTestId('primitive-seed-attribute-attr-input');
|
|
await input.click();
|
|
await expect(
|
|
page.getByTestId('primitive-seed-attribute-attr-dropdown'),
|
|
).toBeVisible();
|
|
|
|
// Click the "Hp" suggestion — input value updates and dropdown closes.
|
|
await page.getByTestId('attr-combobox-option-Hp').first().click();
|
|
await expect(input).toHaveValue('Hp');
|
|
|
|
// seed-attribute is a declare-site, so the illustrative examples
|
|
// (ArmorPlates, BloodStacks...) SHOULD be present.
|
|
await input.click();
|
|
await expect(
|
|
page.getByTestId('attr-combobox-option-ArmorPlates'),
|
|
).toBeVisible();
|
|
|
|
// Type a user-defined attr name — badge appears, dropdown filters down.
|
|
await input.fill('MyStacks');
|
|
await expect(combobox).toContainText('user-defined');
|
|
});
|
|
|
|
test('AttrCombobox consume-mode (add-aura.targetAttr) hides user-defined examples and warns on unseeded names', async ({
|
|
page,
|
|
}) => {
|
|
await freshLobby(page);
|
|
await openProfileEditor(page);
|
|
await openCustomModifierEditor(page);
|
|
|
|
// Add an add-aura primitive — its targetAttr field is a consume-site.
|
|
await page.getByTestId('custom-primitive-palette-add-aura').click();
|
|
await page.getByTestId('custom-primitive-node-0').click();
|
|
|
|
const input = page.getByTestId('primitive-add-aura-targetAttr-input');
|
|
await input.click();
|
|
const dropdown = page.getByTestId('primitive-add-aura-targetAttr-dropdown');
|
|
await expect(dropdown).toBeVisible();
|
|
|
|
// Illustrative "User-defined examples" group should NOT appear in
|
|
// consume-mode — those names (ArmorPlates, BloodStacks, ManaPool)
|
|
// are fiction unless a seed-attribute declares them.
|
|
await expect(dropdown).not.toContainText('User-defined examples');
|
|
await expect(
|
|
page.getByTestId('attr-combobox-option-ArmorPlates'),
|
|
).toHaveCount(0);
|
|
|
|
// Typing an unseeded attr raises the red "not seeded" badge.
|
|
await input.fill('MyGhostAttr');
|
|
const badge = page.getByTestId('primitive-add-aura-targetAttr-badge');
|
|
await expect(badge).toHaveText('not seeded');
|
|
});
|
|
|
|
test('AttrCombobox consume-mode surfaces attrs seeded by other primitives in the same descriptor', async ({
|
|
page,
|
|
}) => {
|
|
await freshLobby(page);
|
|
await openProfileEditor(page);
|
|
await openCustomModifierEditor(page);
|
|
|
|
// 1. Add a seed-attribute primitive that declares ShieldCharges=3.
|
|
await page.getByTestId('custom-primitive-palette-seed-attribute').click();
|
|
await page.getByTestId('custom-primitive-node-0').click();
|
|
const seedAttrInput = page.getByTestId('primitive-seed-attribute-attr-input');
|
|
await seedAttrInput.fill('ShieldCharges');
|
|
// Blur the input so the dropdown closes — the open dropdown is
|
|
// absolutely positioned and would intercept the next palette click.
|
|
await seedAttrInput.blur();
|
|
|
|
// 2. Add an absorb-damage-with-attribute primitive. Its attr field
|
|
// is a consume-site and should now offer ShieldCharges under
|
|
// a "Seeded in this descriptor" group.
|
|
await page
|
|
.getByTestId('custom-primitive-palette-absorb-damage-with-attribute')
|
|
.click();
|
|
await page.getByTestId('custom-primitive-node-1').click();
|
|
|
|
const absorbInput = page.getByTestId(
|
|
'primitive-absorb-damage-with-attribute-attr-input',
|
|
);
|
|
await absorbInput.click();
|
|
const dropdown = page.getByTestId(
|
|
'primitive-absorb-damage-with-attribute-attr-dropdown',
|
|
);
|
|
await expect(dropdown).toBeVisible();
|
|
await expect(dropdown).toContainText('Seeded in this descriptor');
|
|
await expect(
|
|
page.getByTestId('attr-combobox-option-ShieldCharges'),
|
|
).toBeVisible();
|
|
|
|
// Picking it commits the value and badge shows emerald "seeded".
|
|
await page.getByTestId('attr-combobox-option-ShieldCharges').click();
|
|
await expect(absorbInput).toHaveValue('ShieldCharges');
|
|
const absorbBadge = page.getByTestId(
|
|
'primitive-absorb-damage-with-attribute-attr-badge',
|
|
);
|
|
await expect(absorbBadge).toHaveText('seeded');
|
|
});
|
|
|
|
test('Templates picker loads a recipe into the editor', async ({ page }) => {
|
|
await freshLobby(page);
|
|
await openProfileEditor(page);
|
|
await openCustomModifierEditor(page);
|
|
|
|
// Open the templates modal and pick the shield recipe (2 primitives).
|
|
await page.getByTestId('custom-templates').click();
|
|
await expect(page.getByTestId('custom-templates-modal')).toBeVisible();
|
|
await page
|
|
.getByTestId('custom-template-recipe-three-charge-shield')
|
|
.click();
|
|
|
|
// Modal closes and the tree is populated with the recipe's primitives.
|
|
await expect(page.getByTestId('custom-templates-modal')).toHaveCount(0);
|
|
await expect(page.getByTestId('custom-primitive-node-0')).toBeVisible();
|
|
await expect(page.getByTestId('custom-primitive-node-1')).toBeVisible();
|
|
// Name field reflects the template's descriptor.name.
|
|
await expect(
|
|
page.locator('input[placeholder="Modifier Name"]'),
|
|
).toHaveValue('3-Charge Shield');
|
|
});
|
|
});
|
|
|
|
// ─── WS helpers (mirrored from modifier-profiles.spec.ts) ──────────
|
|
|
|
/** Create a room via raw WS — no profile. Returns {code, token, color}. */
|
|
async function wsCreateRoomNoProfile(
|
|
p: import('@playwright/test').Page,
|
|
): Promise<{ code: string; token: string; color: string }> {
|
|
return p.evaluate(async () => {
|
|
return new Promise<{ code: string; token: string; color: string }>(
|
|
(resolve, reject) => {
|
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
|
const timer = setTimeout(
|
|
() => reject(new Error('wsCreateRoom: timeout')),
|
|
5000,
|
|
);
|
|
ws.onopen = () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: 1,
|
|
ts: Date.now(),
|
|
type: 'room.create',
|
|
payload: {},
|
|
}),
|
|
);
|
|
};
|
|
ws.onmessage = (e: MessageEvent) => {
|
|
const msg = JSON.parse(e.data as string) as {
|
|
type: string;
|
|
payload: { code: string; token: string; color: string };
|
|
};
|
|
if (msg.type === 'room.created') {
|
|
clearTimeout(timer);
|
|
ws.close();
|
|
resolve(msg.payload);
|
|
}
|
|
};
|
|
ws.onerror = () => {
|
|
clearTimeout(timer);
|
|
reject(new Error('wsCreateRoom: error'));
|
|
};
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Join a room via raw WS. Returns {code, token, color}. */
|
|
async function wsJoinRoomShared(
|
|
p: import('@playwright/test').Page,
|
|
code: string,
|
|
): Promise<{ code: string; token: string; color: string }> {
|
|
return p.evaluate(async (roomCode: string) => {
|
|
return new Promise<{ code: string; token: string; color: string }>(
|
|
(resolve, reject) => {
|
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
|
const timer = setTimeout(
|
|
() => reject(new Error('wsJoinRoom: timeout')),
|
|
5000,
|
|
);
|
|
ws.onopen = () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: 1,
|
|
ts: Date.now(),
|
|
type: 'room.join',
|
|
payload: { code: roomCode },
|
|
}),
|
|
);
|
|
};
|
|
ws.onmessage = (e: MessageEvent) => {
|
|
const msg = JSON.parse(e.data as string) as {
|
|
type: string;
|
|
payload: { code: string; token: string; color: string };
|
|
};
|
|
if (msg.type === 'room.joined') {
|
|
clearTimeout(timer);
|
|
ws.close();
|
|
resolve(msg.payload);
|
|
}
|
|
};
|
|
ws.onerror = () => {
|
|
clearTimeout(timer);
|
|
reject(new Error('wsJoinRoom: error'));
|
|
};
|
|
},
|
|
);
|
|
}, code);
|
|
}
|