darkwatch/site/screenshots.js
Aaron Wood 9607462a7c Add death timer to brochure with screenshot
Adds a new feature row (Row 8) describing the dying mechanic — 1d4+CON
countdown, recovery saves, and permanent death. Screenshot generated via
the Playwright script showing a dying character card with red border in
the DM view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:53:02 -04:00

325 lines
13 KiB
JavaScript

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
await page.click('button[aria-label="Toggle Fog"]');
await page.waitForTimeout(400);
// Toggle Fire on and set intensity to 25%
// Effects order: fog(0), fire(1), rain(2), embers(3) — fire is 2nd slider
await page.click('button[aria-label="Toggle Fire"]');
await page.waitForTimeout(300);
await page.evaluate(() => {
const sliders = document.querySelectorAll('input[type="range"]');
const fireSlider = sliders[1];
fireSlider.value = '25';
fireSlider.dispatchEvent(new Event('input', { bubbles: true }));
fireSlider.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await page.waitForTimeout(1200); // wait for both effects to render
// Close the atmosphere panel so it doesn't clutter the screenshot
await page.click('button[title="Atmosphere effects"]');
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
}
async function captureDeathTimer(page) {
await resetToCleanCampaign(page);
// Start combat with an enemy so the initiative tracker is visible
await page.click('button:has-text("Combat")');
await page.waitForTimeout(400);
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('Skeleton');
const hpInput = page.locator('input[placeholder*="HP"], input[placeholder*="hp" i], input[type="number"]').first();
if (await hpInput.isVisible().catch(() => false)) {
await hpInput.fill('8');
}
}
// Roll initiative to enter the active phase
await page.click('button:has-text("Roll Initiative")');
await page.waitForTimeout(1500);
// Bring the first character to 0 HP via the API — server rolls dying timer automatically
await page.evaluate(async () => {
const campaignId = Number(location.pathname.match(/\/campaign\/(\d+)/)?.[1]);
const res = await fetch(`/api/campaigns/${campaignId}/characters`, { credentials: 'include' });
const chars = await res.json();
if (chars.length > 0) {
await fetch(`/api/characters/${chars[0].id}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hp_current: 0 }),
});
}
});
await page.waitForTimeout(1500); // wait for socket broadcast + React re-render
// Screenshot shows initiative tracker with dying character (skull tag + Roll Recovery button)
// and DM card with pulsing red border + skull countdown
}
// ── 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 shot(page, 'death-timer', captureDeathTimer);
await browser.close();
console.log('');
console.log('Done! Screenshots saved to site/assets/screenshots/');
}
run().catch(err => {
console.error(err);
process.exit(1);
});