Files
ttrpg_manager/e2e/maps.spec.ts
T
NilsBriggen f61fabadb5 Phase 4: dynamic vision, walls & doors
- src/lib/map/vision.ts (pure, tested): blockingSegments (walls + closed doors),
  segment intersection, computeVisibleCells (raycast viewer→cell-centre, sight radius).
- BattleMap += dynamicVision + sightRadiusFeet (Dexie v12, backfilled).
- MapEditor: new "Walls" tool (draw wall polylines; click a door to open/close),
  a "Vision" toggle (auto-enables fog and reveals from the party), sight-radius field,
  and "Reveal from party". Moving a PC token accumulates revealed cells via LoS when
  Vision is on. Doors/walls render for the GM (amber=closed, green=open).
- Networked players need no new protocol: revealed cells already travel in the snapshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:02:49 +02:00

161 lines
7.7 KiB
TypeScript

import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(async () => {
indexedDB.deleteDatabase('ttrpg-manager');
localStorage.clear();
});
await page.reload();
});
test('maps: upload, place token, reveal all, show to players', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('VTT');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Maps' }).click();
await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png');
// Editor loads (Move tool present)
await expect(page.getByRole('button', { name: 'move', exact: true })).toBeVisible();
// Add a token
await page.getByRole('button', { name: '+ Token' }).click();
await expect(page.getByTestId('map-token')).toHaveCount(1);
// Reveal tool exposes the contextual fog controls; reveal all
await page.getByRole('button', { name: 'reveal', exact: true }).click();
await page.getByText('Fog', { exact: true }).locator('input[type=checkbox]').check();
await page.getByRole('button', { name: 'Reveal all' }).click();
// Show to players, then the player view renders the battle map
await page.getByRole('button', { name: 'Show to players' }).click();
await page.getByRole('link', { name: 'Open player view ↗' }).click();
await expect(page.getByRole('heading', { name: 'Battle map' })).toBeVisible();
await expect(page.getByTestId('map-token')).toHaveCount(1);
});
test('maps: import a Universal VTT (.dd2vtt) map', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('VTTimport');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Maps' }).click();
await page.locator('input[type="file"]').setInputFiles('e2e/fixtures/sample.dd2vtt');
// Imported map opens in the editor with the file's pixels_per_grid as the grid size.
await expect(page.getByTestId('map-viewport')).toBeVisible();
await expect(page.getByLabel('Grid cell size')).toHaveValue('100');
await expect(page.getByRole('button', { name: 'Export .uvtt' })).toBeVisible();
});
test('maps: walls tool + dynamic vision controls', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Vision');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Maps' }).click();
await page.locator('input[type="file"]').setInputFiles('e2e/fixtures/sample.dd2vtt');
await expect(page.getByTestId('map-viewport')).toBeVisible();
await page.getByRole('button', { name: 'walls', exact: true }).click();
await expect(page.getByRole('button', { name: /Reveal from party/ })).toBeVisible();
await expect(page.getByRole('button', { name: /Clear walls/ })).toBeVisible();
const vision = page.locator('label', { hasText: 'Vision' }).locator('input[type=checkbox]');
await vision.check();
await expect(vision).toBeChecked();
});
test('maps: zoom controls scale the canvas and fit resets', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Zoom');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Maps' }).click();
await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png');
const viewport = page.getByTestId('map-viewport');
await expect(viewport).toBeVisible();
const scaleOf = async () => Number(await viewport.getAttribute('data-zoom'));
// Let the initial fit settle, then zooming in must increase the zoom…
await page.getByRole('button', { name: 'Fit map to view' }).click();
const fitZoom = await scaleOf();
expect(fitZoom).toBeGreaterThan(0);
await page.getByRole('button', { name: 'Zoom in' }).click();
await page.getByRole('button', { name: 'Zoom in' }).click();
const zoomedIn = await scaleOf();
expect(zoomedIn).toBeGreaterThan(fitZoom);
// …and Fit brings it back to the fit value.
await page.getByRole('button', { name: 'Fit map to view' }).click();
expect(await scaleOf()).toBeCloseTo(fitZoom, 5);
});
test('maps: place a token from a planned encounter via the palette', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Enc');
await page.getByRole('button', { name: 'Create' }).click();
// A planned (not-yet-started) encounter with a monster.
await page.getByRole('link', { name: 'Combat' }).click();
await page.getByRole('button', { name: '+ New encounter' }).first().click();
await page.locator('input[data-autofocus]').fill('Ambush');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByLabel('Name').fill('Bandit');
await page.getByRole('button', { name: 'Add', exact: true }).click();
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Maps' }).click();
await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png');
// The palette's Encounter section surfaces the planned encounter's combatant.
await expect(page.getByText('Encounter', { exact: true })).toBeVisible();
await page.getByRole('button', { name: /Bandit/ }).click();
await expect(page.getByTestId('map-token')).toHaveCount(1);
await expect(page.getByTestId('map-token')).toContainText('Bandit');
});
test('maps: a character portrait becomes the token icon', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Art');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Characters' }).click();
await createCharacter(page, 'Aria');
// Upload a portrait on the sheet; it renders in the header avatar.
await page.getByLabel('Portrait').setInputFiles('public/pwa-512x512.png');
await expect(page.locator('label img').first()).toBeVisible();
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Maps' }).click();
await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png');
// Placing the PC drops a token that shows the portrait image.
await page.getByRole('button', { name: /Aria/ }).click();
await expect(page.getByTestId('map-token').first().locator('img')).toBeVisible();
});
test('maps: place a party token from the palette', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Party');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Characters' }).click();
await createCharacter(page, 'Aria');
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Maps' }).click();
await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png');
// Palette lists the PC; clicking it drops a linked token.
await page.getByRole('button', { name: /Aria/ }).click();
await expect(page.getByTestId('map-token')).toHaveCount(1);
await expect(page.getByTestId('map-token')).toContainText('Aria');
});