Additive 'Create scenario' path (v0.31): the home screen's create-scenario card opens a ScenarioForm (pick one of 3 light era templates + a free-text description + an editable, era-prefilled start year) whose 'Generate world' posts the draft request; the CheckpointReview screen shows the generated world (per-nation stats/territory/relations + backstory) and the advisor's checkpoint list as an approve/remove checklist, then the player picks a nation to lead and finalizes into a saved, playable game. Both LLM steps ride the scenario client (deterministic stub under LLM_STUB=1); a failure toasts and stays on the current screen. The turn stub and static presets are untouched. New e2e covers the full deterministic flow (near-future 2035, remove checkpoint 3, lead Boreas, finalize, one empty jump) and the 3-template manifest.
254 lines
13 KiB
TypeScript
254 lines
13 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
|
|
|
/**
|
|
* Dynamic scenario generation — end to end (v0.31, #52). Fully deterministic
|
|
* under the SCENARIO stub (the Playwright webServer boots with LLM_STUB=1, so
|
|
* the two generation calls ride the deterministic transport in
|
|
* server/stubScenario.ts — the turn stub in server/stub.ts is untouched).
|
|
*
|
|
* The stub returns a FIXED 3-nation world and a FIXED 3-checkpoint list for
|
|
* the generate step, and — on the finalize step — refines that world in a
|
|
* deterministic way that depends on which checkpoints the player approved
|
|
* (each approved checkpoint naming one of the fixed nations, by its first 4
|
|
* letters, raises that nation's stability by 5). So the whole two-step
|
|
* "Create scenario" path plus the resulting game is reproducible:
|
|
*
|
|
* home → "Create scenario" → pick the Near Future era (2035) + a description
|
|
* → generate (draft: alba/boreas/gamma + 3 checkpoints) → review (remove the
|
|
* 3rd checkpoint, keep the first two) → lead Boreas → finalize → a saved,
|
|
* playable game (turn 1, date 2035-01-01, player Boreas) → one empty jump
|
|
* (turn 2, date 2035-01-03, Boreas military 55→60, allied with Alba, and no
|
|
* territory change — Russia shares no land border with France or Spain).
|
|
*
|
|
* The e2e webServer shares one SQLite (data/e2e) across tests, so the test
|
|
* captures the *specific* id of the game it creates (from the POST
|
|
* /api/scenarios/commit response) rather than assuming "the only game".
|
|
*/
|
|
|
|
/** The saved-game shape the assertions read back from GET /api/games/<id>. */
|
|
interface SavedGame {
|
|
id: string;
|
|
presetId: string;
|
|
presetTitle: string;
|
|
playerNationId: string;
|
|
turn: number;
|
|
date: string;
|
|
version: number;
|
|
pending: unknown[];
|
|
unowned: string[];
|
|
pendingNationOps: unknown[];
|
|
markers: unknown[];
|
|
events: Array<{ type: string; outcome: string; title: string }>;
|
|
nations: Record<
|
|
string,
|
|
{
|
|
name: string;
|
|
stats: { military: number; economy: number; stability: number; population: number };
|
|
territory: string[];
|
|
relations: Record<string, string>;
|
|
}
|
|
>;
|
|
regionStats: Record<
|
|
string,
|
|
{ military: number; economy: number; stability: number; population: number }
|
|
>;
|
|
}
|
|
|
|
/** Poll until `location.hash` equals `expected` (hash writes happen in effects). */
|
|
async function waitHash(page: Page, expected: string): Promise<void> {
|
|
await page.waitForFunction(
|
|
(exp) => window.location.hash === exp,
|
|
expected,
|
|
{ timeout: 15000 },
|
|
);
|
|
}
|
|
|
|
/** Open the "Create scenario" form from the home screen. */
|
|
async function openScenarioForm(page: Page): Promise<void> {
|
|
await page.goto('/');
|
|
// Home: the create-scenario card is additive (first in the grid); the five
|
|
// static presets are still all present alongside it.
|
|
await expect(page.getByTestId('create-scenario-card')).toBeVisible({ timeout: 15000 });
|
|
await expect(page.getByTestId('preset-card')).toHaveCount(5);
|
|
await page.getByTestId('create-scenario').click();
|
|
await expect(page.getByTestId('scenario-form')).toBeVisible({ timeout: 15000 });
|
|
}
|
|
|
|
test.describe('History — dynamic scenario generation (LLM_STUB=1)', () => {
|
|
test('the era template manifest renders three light templates', async ({ page }) => {
|
|
test.setTimeout(60000);
|
|
await openScenarioForm(page);
|
|
|
|
// Exactly three era-template cards, each carrying its id and default year.
|
|
const cards = page.getByTestId('template-card');
|
|
await expect(cards).toHaveCount(3, { timeout: 15000 });
|
|
await expect(page.locator('[data-template-id="ww2"]')).toContainText('1939');
|
|
await expect(page.locator('[data-template-id="present"]')).toContainText('2026');
|
|
await expect(page.locator('[data-template-id="near-future"]')).toContainText('2035');
|
|
});
|
|
|
|
test('full create → review → finalize → play flow is deterministic', async ({ page }) => {
|
|
test.setTimeout(120000);
|
|
await openScenarioForm(page);
|
|
|
|
// --- 1 · Pick the Near Future era (its default year pre-fills the field).
|
|
const nf = page.locator('[data-template-id="near-future"]');
|
|
await nf.click();
|
|
await expect(nf).toHaveAttribute('data-selected', 'true');
|
|
// The year field is pre-filled from the era (editable, a real value).
|
|
await expect(page.getByTestId('scenario-year')).toHaveValue('2035');
|
|
|
|
// --- 2 · Describe the world.
|
|
const description =
|
|
'A cold space race decides the new cold war: two orbital blocs hold the arctic shipping lanes at knife-edge.';
|
|
await page.getByTestId('scenario-description').fill(description);
|
|
|
|
// --- Step 1 · Generate the draft world + the advisor's checkpoint list.
|
|
const generate = page.getByTestId('generate-scenario');
|
|
await expect(generate).toBeEnabled();
|
|
const [draftResp] = await Promise.all([
|
|
page.waitForResponse(
|
|
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/scenarios/draft',
|
|
),
|
|
generate.click(),
|
|
]);
|
|
expect(draftResp.status()).toBe(200);
|
|
const draft = (await draftResp.json()) as {
|
|
world: {
|
|
nations: Array<{ id: string; name: string; territory: string[] }>;
|
|
};
|
|
checkpoints: string[];
|
|
};
|
|
// The stub's fixed 3-nation world (FRA / RUS / ESP) and 3 checkpoints.
|
|
expect(draft.world.nations.map((n) => n.id).sort()).toEqual(['alba', 'boreas', 'gamma']);
|
|
expect(draft.world.nations.find((n) => n.id === 'boreas')?.territory).toEqual(['RUS']);
|
|
expect(draft.checkpoints).toHaveLength(3);
|
|
|
|
// --- Review screen: the generated world + the checkpoint checklist.
|
|
await expect(page.getByTestId('scenario-review')).toBeVisible({ timeout: 15000 });
|
|
// Header shows the era's name + year and the description.
|
|
await expect(page.getByRole('heading', { name: 'Near Future' })).toBeVisible();
|
|
await expect(page.getByTestId('scenario-review')).toContainText('2035');
|
|
await expect(page.getByTestId('scenario-review')).toContainText(description);
|
|
// The three draft nations (with their ISO_A3 territory) and the backstory.
|
|
// Scope to the draft-nation cards (the nation-pick cards share the
|
|
// data-nation-id attribute but carry a different testid).
|
|
await expect(page.getByTestId('draft-nation')).toHaveCount(3);
|
|
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="alba"]')).toContainText('FRA');
|
|
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="boreas"]')).toContainText('RUS');
|
|
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="gamma"]')).toContainText('ESP');
|
|
await expect(page.getByTestId('draft-event')).toHaveCount(1);
|
|
// Three checkpoints, all approved by default, in the stub's fixed order.
|
|
const cps = page.getByTestId('checkpoint-item');
|
|
await expect(cps).toHaveCount(3);
|
|
await expect(cps.nth(0)).toHaveAttribute('data-approved', 'true');
|
|
await expect(cps.nth(1)).toHaveAttribute('data-approved', 'true');
|
|
await expect(cps.nth(2)).toHaveAttribute('data-approved', 'true');
|
|
await expect(cps.nth(0)).toContainText('Alba dominates');
|
|
await expect(cps.nth(1)).toContainText('Boreas fortifies');
|
|
await expect(cps.nth(2)).toContainText('Gamma brokers');
|
|
// Three nation-pick cards; finalize is disabled until a nation is chosen.
|
|
await expect(page.getByTestId('scenario-nation-card')).toHaveCount(3);
|
|
await expect(page.getByTestId('finalize-scenario')).toBeDisabled();
|
|
|
|
// --- Review: remove checkpoint 3, keep 1 + 2.
|
|
await cps.nth(2).getByTestId('checkpoint-toggle').click();
|
|
await expect(cps.nth(2)).toHaveAttribute('data-approved', 'false');
|
|
await expect(cps.nth(0)).toHaveAttribute('data-approved', 'true');
|
|
await expect(cps.nth(1)).toHaveAttribute('data-approved', 'true');
|
|
await expect(page.getByTestId('scenario-review')).toContainText('2 of 3 approved');
|
|
|
|
// --- Review: choose Boreas (Russia) to lead.
|
|
const boreasCard = page.locator('[data-testid="scenario-nation-card"][data-nation-id="boreas"]');
|
|
await boreasCard.getByTestId('scenario-lead-nation').click();
|
|
await expect(boreasCard).toHaveAttribute('data-selected', 'true');
|
|
await expect(page.getByTestId('finalize-scenario')).toBeEnabled();
|
|
|
|
// --- Step 2 · Finalize: commit the approved draft into a saved game.
|
|
const [commitResp] = await Promise.all([
|
|
page.waitForResponse(
|
|
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/scenarios/commit',
|
|
),
|
|
page.getByTestId('finalize-scenario').click(),
|
|
]);
|
|
expect(commitResp.status()).toBe(200);
|
|
const { id } = (await commitResp.json()) as { id: string };
|
|
expect(id).toBeTruthy();
|
|
|
|
// --- The new game mounts at turn 1, leading Boreas, dated 2035-01-01.
|
|
await expect(page.getByTestId('world-map')).toBeVisible({ timeout: 20000 });
|
|
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
|
await expect(page.getByTestId('game-date')).toHaveText('2035-01-01');
|
|
await expect(page.getByTestId('player-nation')).toHaveText('Boreas');
|
|
// The scenario title (the era name) is shown in the top bar.
|
|
await expect(page.getByTestId('top-bar')).toContainText('Near Future');
|
|
// Boreas's stats: the finalized world (its approved checkpoint raised
|
|
// stability 50→55); per-region stats are seeded to equal the derived.
|
|
await expect(page.getByTestId('stat-military')).toHaveText('55');
|
|
await expect(page.getByTestId('stat-economy')).toHaveText('70');
|
|
await expect(page.getByTestId('stat-stability')).toHaveText('55');
|
|
await expect(page.getByTestId('stat-population')).toHaveText('60');
|
|
// One backstory event; three nations on the legend.
|
|
await expect(page.getByTestId('event-item')).toHaveCount(1);
|
|
await expect(page.getByTestId('map-legend-item')).toHaveCount(3, { timeout: 15000 });
|
|
await expect(page.getByTestId('map-legend-item').filter({ hasText: 'Boreas' })).toBeVisible();
|
|
// The deep link lands on this exact game.
|
|
await waitHash(page, `#/game/${id}`);
|
|
|
|
// --- One empty jump: the world advances on its own (the turn stub).
|
|
// The button sits at the bottom of a scrollable window body and may be
|
|
// outside the viewport; a native DOM click bypasses the viewport check.
|
|
await page.getByTestId('jump-forward').evaluate((el) => (el as HTMLElement).click());
|
|
|
|
// Turn 2, date +2 days, Boreas military 55→60.
|
|
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
|
|
await expect(page.getByTestId('stat-military')).toHaveText('60', { timeout: 20000 });
|
|
await expect(page.getByTestId('game-date')).toHaveText('2035-01-03', { timeout: 20000 });
|
|
// Two events now (the empty-round "world moves on" on top, then the
|
|
// backstory); both political/neutral.
|
|
await expect(page.getByTestId('event-item')).toHaveCount(2);
|
|
await expect(page.getByTestId('event-item').first()).toContainText('The world moves on');
|
|
await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('political');
|
|
await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('neutral');
|
|
await expect(page.getByTestId('event-item').nth(1)).toContainText('The new order takes shape');
|
|
// No territory change: Russia shares no land border with France or Spain,
|
|
// so the frontier is empty and no conquest is emitted.
|
|
await expect(page.getByTestId('map-legend-item')).toHaveCount(3, { timeout: 15000 });
|
|
|
|
// --- The saved game is a coherent dynamic GameState on the server.
|
|
const saved = await page.evaluate(async (gid) => {
|
|
const r = await fetch(`/api/games/${gid}`);
|
|
return { status: r.status, body: (await r.json()) as { game: SavedGame } };
|
|
}, id);
|
|
expect(saved.status).toBe(200);
|
|
const g = saved.body.game;
|
|
expect(g.presetId).toBe('dynamic');
|
|
expect(g.presetTitle).toBe('Near Future');
|
|
expect(g.playerNationId).toBe('boreas');
|
|
expect(g.turn).toBe(2);
|
|
expect(g.date).toBe('2035-01-03');
|
|
expect(g.version).toBe(2); // created at 1, bumped to 2 by the jump
|
|
expect(g.pending).toEqual([]);
|
|
expect(g.unowned).toEqual([]);
|
|
expect(g.pendingNationOps).toEqual([]);
|
|
expect(g.markers).toEqual([]);
|
|
expect(g.events).toHaveLength(2);
|
|
// Boreas after the jump: military bumped to 60, the rest untouched.
|
|
expect(g.nations.boreas.stats).toEqual({
|
|
military: 60,
|
|
economy: 70,
|
|
stability: 55,
|
|
population: 60,
|
|
});
|
|
// The stub allies the player with the first other nation (Alba).
|
|
expect(g.nations.boreas.relations.alba).toBe('ally');
|
|
// Every owned region key carries a seeded per-region stats entry.
|
|
const allKeys = new Set<string>();
|
|
for (const n of Object.values(g.nations)) for (const k of n.territory) allKeys.add(k);
|
|
expect(allKeys.size).toBeGreaterThan(0);
|
|
for (const k of allKeys) {
|
|
expect(g.regionStats[k]).toBeDefined();
|
|
}
|
|
});
|
|
});
|