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:
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user