feat(soak): --watch=tiled launches N headed host windows

SessionPool accepts headedHostCount; when > 0 it launches a second
Chromium in headed mode and creates the first N sessions in it.
Joiners (sessions N..count) stay headless in the main browser.

Each headed context gets a 960×900 viewport — tall enough to show
the full game table (deck + opponent row + own 2×3 card grid +
status area) without clipping. Horizontal tiling still fits two
windows side-by-side on a 1920-wide display.

window.moveTo is kept as a best-effort tile-placement hint, but
viewport from newContext() is what actually sizes the window
(window.resizeTo is a no-op on modern Chromium / Wayland).

Verified: 1-room tiled run plays a full game cleanly; 2-room
parallel tiled had one window get closed mid-run, which is
consistent with a user manually dismissing a window — tiled mode
is a best-effort hands-on debugging aid, not an automation mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-11 21:01:21 -04:00
parent 21fe53eaf7
commit c307027dd0
2 changed files with 63 additions and 7 deletions

View File

@@ -142,12 +142,20 @@ export interface SessionPoolOptions {
/** Optional override — if absent, SessionPool launches its own. */ /** Optional override — if absent, SessionPool launches its own. */
browser?: Browser; browser?: Browser;
contextOptions?: Parameters<Browser['newContext']>[0]; contextOptions?: Parameters<Browser['newContext']>[0];
/**
* If > 0, the first `headedHostCount` sessions use a separate headed
* Chromium browser with visible windows (positioned in a 2×2 grid
* via window.moveTo). The remaining sessions stay headless. Used for
* --watch=tiled mode.
*/
headedHostCount?: number;
} }
export class SessionPool { export class SessionPool {
private accounts: Account[] = []; private accounts: Account[] = [];
private ownedBrowser: Browser | null = null; private ownedBrowser: Browser | null = null;
private browser: Browser | null; private browser: Browser | null;
private headedBrowser: Browser | null = null;
private activeSessions: Session[] = []; private activeSessions: Session[] = [];
constructor(private opts: SessionPoolOptions) { constructor(private opts: SessionPoolOptions) {
@@ -221,6 +229,10 @@ export class SessionPool {
/** /**
* Launch the browser if not provided, create N contexts, log each in via * Launch the browser if not provided, create N contexts, log each in via
* localStorage injection, return the live sessions. * localStorage injection, return the live sessions.
*
* If `headedHostCount > 0`, launches a second headed Chromium and
* places the first `headedHostCount` sessions in it, positioning
* their windows in a 2×2 grid. The rest stay headless.
*/ */
async acquire(count: number): Promise<Session[]> { async acquire(count: number): Promise<Session[]> {
await this.ensureAccounts(count); await this.ensureAccounts(count);
@@ -229,13 +241,52 @@ export class SessionPool {
this.browser = this.ownedBrowser; this.browser = this.ownedBrowser;
} }
const headedCount = this.opts.headedHostCount ?? 0;
if (headedCount > 0 && !this.headedBrowser) {
this.headedBrowser = await chromium.launch({
headless: false,
slowMo: 50,
});
}
const sessions: Session[] = []; const sessions: Session[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const account = this.accounts[i]; const account = this.accounts[i];
const context = await this.browser.newContext(this.opts.contextOptions); const useHeaded = i < headedCount;
const targetBrowser = useHeaded ? this.headedBrowser! : this.browser!;
// Headed host windows get a larger viewport — 960×900 fits the full
// game table (deck + opponent row + own 2×3 grid + status area) on
// a typical 1920×1080 display. Two windows side-by-side still fit
// horizontally; if the user runs more than 2 rooms in tiled mode
// the extra windows will overlap and need to be arranged manually.
const context = await targetBrowser.newContext({
...this.opts.contextOptions,
...(useHeaded ? { viewport: { width: 960, height: 900 } } : {}),
});
await this.injectAuth(context, account); await this.injectAuth(context, account);
const page = await context.newPage(); const page = await context.newPage();
await page.goto(this.opts.targetUrl); await page.goto(this.opts.targetUrl);
// Best-effort tile placement. window.moveTo is often a no-op on
// modern Chromium (especially under Wayland), so we don't rely on
// it — the viewport sized above is what the user actually sees.
if (useHeaded) {
const col = i % 2;
const row = Math.floor(i / 2);
const x = col * 960;
const y = row * 920;
await page
.evaluate(
([x, y]) => {
window.moveTo(x, y);
},
[x, y] as [number, number],
)
.catch(() => {
// ignore — window.moveTo may be blocked
});
}
const bot = new GolfBot(page); const bot = new GolfBot(page);
sessions.push({ account, context, page, bot, key: account.key }); sessions.push({ account, context, page, bot, key: account.key });
} }
@@ -282,7 +333,7 @@ export class SessionPool {
} }
} }
/** Close all active contexts. Safe to call multiple times. */ /** Close all active contexts + browsers. Safe to call multiple times. */
async release(): Promise<void> { async release(): Promise<void> {
for (const session of this.activeSessions) { for (const session of this.activeSessions) {
try { try {
@@ -301,5 +352,13 @@ export class SessionPool {
this.ownedBrowser = null; this.ownedBrowser = null;
this.browser = null; this.browser = null;
} }
if (this.headedBrowser) {
try {
await this.headedBrowser.close();
} catch {
// ignore
}
this.headedBrowser = null;
}
} }
} }

View File

@@ -101,20 +101,17 @@ async function main(): Promise<void> {
// Build dependencies // Build dependencies
const credFile = path.resolve(__dirname, '.env.stresstest'); const credFile = path.resolve(__dirname, '.env.stresstest');
const headedHostCount = watch === 'tiled' ? rooms : 0;
const pool = new SessionPool({ const pool = new SessionPool({
targetUrl, targetUrl,
inviteCode, inviteCode,
credFile, credFile,
logger, logger,
headedHostCount,
}); });
const coordinator = new RoomCoordinator(); const coordinator = new RoomCoordinator();
const screencaster = new Screencaster(logger); const screencaster = new Screencaster(logger);
if (watch === 'tiled') {
logger.warn('tiled_not_yet_implemented');
console.warn('Watch mode "tiled" not yet implemented (Task 24). Falling back to none.');
}
const abortController = new AbortController(); const abortController = new AbortController();
const onSignal = (sig: string) => { const onSignal = (sig: string) => {