import { chromium } from 'playwright'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; import { mkdirSync } from 'fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SHOT_DIR = resolve(__dirname, 'assets', 'screenshots'); const APP = 'http://localhost:5173'; const DM_EMAIL = 'dm@darkwatch.test'; const DM_PASS = 'password'; const ONLY = process.argv.includes('--only') ? process.argv[process.argv.indexOf('--only') + 1] : null; mkdirSync(SHOT_DIR, { recursive: true }); // ── helpers ──────────────────────────────────────────────────────────────── async function shot(page, name, fn) { if (ONLY && name !== ONLY) return; console.log(` capturing ${name}...`); await fn(page); await page.screenshot({ path: resolve(SHOT_DIR, `${name}.png`), fullPage: false, }); console.log(` ✓ ${name}.png`); } async function login(page) { await page.goto(`${APP}/login`); await page.fill('input[type="email"]', DM_EMAIL); await page.fill('input[type="password"]', DM_PASS); await page.click('button[type="submit"]'); await page.waitForURL(`${APP}/`); } async function openCampaign(page) { // Seeded campaign is named "Tomb of the Serpent King" await page.click('text=Tomb of the Serpent King'); await page.waitForURL(/\/campaign\/\d+/); await page.waitForTimeout(1500); // socket connect + data load } // Navigate back to a clean campaign view (no modals, no combat) async function resetToCleanCampaign(page) { const url = page.url(); await page.goto(url); await page.waitForTimeout(1500); } // ── captures ─────────────────────────────────────────────────────────────── async function captureDmCards(page) { // The DM compact card grid is the default campaign view. // Navigate back to campaign root to ensure no modals are open. await resetToCleanCampaign(page); } async function captureCharacterSheet(page) { // Character cards have an inline border-left-width style unique to them. // CSS modules hash class names, so we use the stable inline style instead. // Click the top of the card (header/name area) — the middle of the card // has a vitalsRow with stopPropagation that would block the sheet from opening. const card = page.locator('[style*="border-left-width: 3px"]').first(); await card.waitFor({ state: 'visible', timeout: 5000 }); await card.click({ position: { x: 80, y: 18 } }); // top portion = avatar/name row await page.waitForTimeout(800); // Screenshot is taken here (modal open, stat block visible) // Close the modal after so subsequent captures start clean } async function captureCharacterCreation(page) { // Navigate back to ensure no modals are open (CharacterDetail overlay // from the previous capture doesn't respond to Escape — needs a page reload) await resetToCleanCampaign(page); // Button text is "+ Add Character" await page.click('button:has-text("Add Character")'); await page.waitForTimeout(400); // Step 1: fill name (placeholder: "Tharyn the Bold…") const nameInput = page.locator('input[placeholder*="Tharyn"]').first(); await nameInput.fill('Screenshot Hero'); // Advance to step 2 (Ability Scores — shows the stat rolling UI) await page.click('button:has-text("Next")'); await page.waitForTimeout(400); // Screenshot is taken here (stat grid + "Reroll All" button visible) // Cancel the wizard after } async function captureDiceRoll(page) { // Close any open wizard/modal and return to campaign view await page.keyboard.press('Escape'); await page.waitForTimeout(300); await resetToCleanCampaign(page); // Open the first character sheet — click the header area to avoid the // vitalsRow which has stopPropagation const card = page.locator('[style*="border-left-width: 3px"]').first(); await card.waitFor({ state: 'visible', timeout: 5000 }); await card.click({ position: { x: 80, y: 18 } }); await page.waitForTimeout(800); // Click a stat dice button — title is "Roll 1d20±N (Shift: advantage...)" // The stat block may be scrolled below the fold in the modal — use scrollIntoViewIfNeeded const statDie = page.locator('button[title^="Roll 1d20"]').first(); await statDie.waitFor({ state: 'attached', timeout: 5000 }); await statDie.scrollIntoViewIfNeeded(); await page.waitForTimeout(200); await statDie.click(); await page.waitForTimeout(2200); // wait for 3D dice animation to settle // Screenshot captures the DiceTray overlay + roll log } async function captureSpellcasting(page) { // Need a Wizard or Priest character to show the spell panel. // Re-use one if it already exists (avoids accumulating characters across runs). await resetToCleanCampaign(page); let casterCard = page.locator('[style*="border-left-width: 3px"]') .filter({ hasText: /Wizard|Priest/ }).first(); const hasCaster = await casterCard.isVisible().catch(() => false); if (!hasCaster) { // Create a Wizard character await page.click('button:has-text("Add Character")'); await page.waitForTimeout(400); const nameInput = page.locator('input[placeholder*="Tharyn"]').first(); await nameInput.fill('Morgana'); // Select Wizard from the Class dropdown (trigger shows "Fighter" by default) await page.locator('button:has-text("Fighter")').first().click(); await page.waitForTimeout(200); await page.locator('button:has-text("Wizard")').last().click(); await page.waitForTimeout(200); // Step 1 → Step 2 (stats) await page.click('button:has-text("Next")'); await page.waitForTimeout(300); // Step 2 → Step 3 (background) await page.click('button:has-text("Next")'); await page.waitForTimeout(300); // Pick a random background and advance await page.click('button:has-text("Random Background")'); await page.waitForTimeout(200); await page.click('button:has-text("Next")'); await page.waitForTimeout(300); // Create the character await page.click('button:has-text("Create Character")'); await page.waitForTimeout(1200); // Re-resolve the locator after creation casterCard = page.locator('[style*="border-left-width: 3px"]') .filter({ hasText: /Wizard|Priest/ }).first(); } await casterCard.waitFor({ state: 'visible', timeout: 5000 }); await casterCard.click({ position: { x: 80, y: 18 } }); await page.waitForTimeout(600); // Enter edit mode to add a spell await page.click('button:has-text("Edit")'); await page.waitForTimeout(300); // Open the spell picker await page.click('button:has-text("Add Spell")'); await page.waitForTimeout(500); // wait for spell list to load // Pick the first available spell const firstSpell = page.locator('[class*="pickerItem"]').first(); await firstSpell.waitFor({ state: 'visible', timeout: 5000 }); await firstSpell.click(); await page.waitForTimeout(300); // Exit edit mode await page.click('button:has-text("Done")'); await page.waitForTimeout(300); // Cast the spell to get an exhausted state (shows the exhausted indicator) const castBtn = page.locator('button:has-text("Cast")').first(); await castBtn.waitFor({ state: 'visible', timeout: 3000 }); await castBtn.click(); await page.waitForTimeout(800); // Close any cast result modal const resultClose = page.locator('button:has-text("Close"), button:has-text("OK")').first(); if (await resultClose.isVisible().catch(() => false)) { await resultClose.click(); await page.waitForTimeout(200); } // Screenshot captures the spell list with slot/exhausted state visible } async function captureAtmosphere(page) { // Navigate back to ensure the campaign view is accessible (no open modals) await resetToCleanCampaign(page); // Open the atmosphere panel — trigger button has title="Atmosphere effects" await page.click('button[title="Atmosphere effects"]'); await page.waitForTimeout(300); // Toggle Fog on — the toggle button has aria-label="Toggle Fog" await page.click('button[aria-label="Toggle Fog"]'); await page.waitForTimeout(800); // wait for fog to render // Screenshot captures fog effect over the campaign view // Close the atmosphere panel after so it doesn't clutter other captures await page.keyboard.press('Escape'); await page.waitForTimeout(200); } async function captureInitiativeActive(page) { await resetToCleanCampaign(page); // Open combat start modal — button text is "⚔ Combat" await page.click('button:has-text("Combat")'); await page.waitForTimeout(400); // Add an enemy so the Roll Initiative button is fully useful // (characters are pre-selected by default, so canStart is already true) const enemyNameInput = page.locator('input[placeholder*="Goblin"], input[placeholder*="enemy" i], input[placeholder*="name" i]').first(); if (await enemyNameInput.isVisible().catch(() => false)) { await enemyNameInput.fill('Goblin'); const hpInput = page.locator('input[placeholder*="HP"], input[placeholder*="hp" i], input[type="number"]').first(); if (await hpInput.isVisible().catch(() => false)) { await hpInput.fill('5'); } } // Click "Roll Initiative ⚔" to start combat await page.click('button:has-text("Roll Initiative")'); await page.waitForTimeout(1200); // wait for initiative state to sync via socket // Screenshot captures the initiative tracker with party + enemies rolled } // ── main ─────────────────────────────────────────────────────────────────── async function run() { console.log('Starting Darkwatch screenshot capture...'); console.log(`App: ${APP}`); if (ONLY) console.log(`Only: ${ONLY}`); console.log(''); const browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, }); const page = await context.newPage(); await login(page); console.log('✓ Logged in as DM'); await openCampaign(page); console.log('✓ Campaign open'); console.log(''); await shot(page, 'dm-cards', captureDmCards); await shot(page, 'character-sheet', captureCharacterSheet); await shot(page, 'character-creation',captureCharacterCreation); await shot(page, 'dice-roll', captureDiceRoll); await shot(page, 'spellcasting', captureSpellcasting); await shot(page, 'atmosphere', captureAtmosphere); await shot(page, 'initiative-active', captureInitiativeActive); await browser.close(); console.log(''); console.log('Done! Screenshots saved to site/assets/screenshots/'); } run().catch(err => { console.error(err); process.exit(1); });