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. */
browser?: Browser;
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 {
private accounts: Account[] = [];
private ownedBrowser: Browser | null = null;
private browser: Browser | null;
private headedBrowser: Browser | null = null;
private activeSessions: Session[] = [];
constructor(private opts: SessionPoolOptions) {
@@ -221,6 +229,10 @@ export class SessionPool {
/**
* Launch the browser if not provided, create N contexts, log each in via
* 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[]> {
await this.ensureAccounts(count);
@@ -229,13 +241,52 @@ export class SessionPool {
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[] = [];
for (let i = 0; i < count; 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);
const page = await context.newPage();
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);
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> {
for (const session of this.activeSessions) {
try {
@@ -301,5 +352,13 @@ export class SessionPool {
this.ownedBrowser = 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
const credFile = path.resolve(__dirname, '.env.stresstest');
const headedHostCount = watch === 'tiled' ? rooms : 0;
const pool = new SessionPool({
targetUrl,
inviteCode,
credFile,
logger,
headedHostCount,
});
const coordinator = new RoomCoordinator();
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 onSignal = (sig: string) => {