Environment Context
cwd: e:\Desktop\Poker shell: powershell current date: 2026-03-26 timezone: America/Toronto
Solver-backed
Goal:
Review your recently changed files for code reuse, quality, and efficiency issues, then fix them.
Run the Playwright test, debug any failures, fix the real root cause, and keep going until the test passes reliably.
Important working rules:
What I want you to do
Phase 1: Inspect
Phase 2: Run the test
Phase 3: Debug the root cause
Phase 4: Re-run and stabilize
Technical expectations
Use Playwright MCP to test the website end to end:
Validation
After fixing, run:
Output format
Critical instruction
Do not stop at a superficial pass.
The task is only done when the Playwright test passes and the underlying issue is fixed with clear, simple, and robust logic.
I’m inspecting the workspace first to locate the Playwright setup, the relevant app flow, and the files changed in the current worktree before I run anything. After that I’ll execute the exact failing e2e path, collect the failure artifacts, and only then make code changes.
Get-Location | Select-Object -ExpandProperty PathExit code: 0 Wall time: 0.4 seconds Output: E:\Desktop\Poker
Get-ChildItem -ForceExit code: 0
Wall time: 0.4 seconds
Output:
目录: E:\Desktop\Poker
Mode LastWriteTime Length Name
---- ------------- ------ ----
d----- 2026-03-25 11:57 PM .codex-tmp
d----- 2026-02-03 11:48 PM .cursor
d--h-- 2026-03-26 1:10 AM .git
d----- 2025-12-28 11:11 PM apps
d----- 2026-03-07 5:08 AM docs
d----- 2026-03-06 6:46 PM infra
d----- 2026-03-25 9:01 PM node_modules
d----- 2025-10-11 4:56 PM packages
d----- 2026-03-24 8:58 PM playwright
d----- 2026-03-26 12:21 AM playwright-report
d----- 2026-03-24 8:57 PM scripts
d----- 2026-03-26 12:21 AM test-results
d----- 2026-03-24 8:57 PM tests
-a---- 2026-03-19 2:59 AM 48 .codex-dev.err.log
-a---- 2026-03-19 2:54 AM 0 .codex-dev.log
-a---- 2026-03-19 2:57 AM 168 .codex-web.err.log
-a---- 2026-03-19 2:58 AM 1151 .codex-web.log
-a---- 2026-03-04 9:59 PM 194 .env
-a---- 2026-03-24 8:51 PM 708 .gitignore
-a---- 2026-03-07 5:14 AM 8489 AGENTS.md
-a---- 2026-03-25 12:38 AM 1574 hand-detail-hang.log
-a---- 2026-03-24 8:51 PM 1297 package.json
-a---- 2026-03-20 8:36 PM 193498 PAI.png
-a---- 2026-03-24 8:57 PM 1101 playwright.config.ts
-a---- 2026-03-24 8:48 PM 193608 pnpm-lock.yaml
-a---- 2026-02-22 6:00 AM 171 pnpm-workspace.yaml
-a---- 2026-03-20 5:40 PM 7370 README.md
-a---- 2025-10-11 5:40 PM 402 tsconfig.json
git status --shortExit code: 0 Wall time: 0.4 seconds Output: M .gitignore M apps/api/src/analysis-pipeline.test.ts M apps/api/src/explain.test.ts M apps/api/src/explain.ts M apps/api/src/game/room-manager.ts M apps/api/src/index.ts M apps/api/src/routes/analysis-rest.ts M apps/api/src/services/analysis-debug-events.ts M apps/api/src/services/analysis-submit.ts M apps/api/src/services/decision-analysis-canonical.ts M apps/api/src/services/hand-actions.test.ts M apps/api/src/services/hand-actions.ts M apps/api/src/services/hand-analysis-pipeline.test.ts M apps/api/src/services/hand-analysis-pipeline.ts M apps/api/src/workers/analysis-worker.boot.ts M apps/api/src/workers/analysis-worker.integration.test.ts M apps/api/src/workers/analysis-worker.logic.ts M apps/api/src/workers/analysis-worker.test.ts M apps/web/src/app/hands/[handId]/page.tsx M apps/web/src/app/hands/hand-detail-page.test.tsx M apps/web/src/app/hands/page.test.tsx M apps/web/src/app/hands/page.tsx D apps/web/src/app/icon.png M apps/web/src/app/layout.tsx M apps/web/src/app/page.tsx M apps/web/src/app/table/[roomId]/page.tsx M apps/web/src/components/analysis/AnalysisDrawerContent.tsx M apps/web/src/components/analysis/StrategyMixPanel.test.tsx M apps/web/src/components/analysis/StrategyMixPanel.tsx M apps/web/src/components/table/ActionBar.tsx M apps/web/src/components/table/RoomActionRail.tsx M apps/web/src/components/table/SeatRing.tsx M apps/web/src/lib/hand-timeline-summary.test.ts M apps/web/src/lib/hand-timeline-summary.ts M apps/web/src/lib/table-replay-snapshot.test.ts M apps/web/src/lib/table-replay-snapshot.ts M apps/web/tsconfig.json M package.json M packages/table/__tests__/engine_acceptance.test.ts M packages/table/src/hand-reducer.test.ts M packages/table/src/hand-reducer.ts M packages/table/src/nlhe-engine.ts M packages/table/src/types.ts M pnpm-lock.yaml ?? apps/api/src/services/decision-analysis-canonical.test.ts ?? apps/api/src/services/decision-analysis-requirements.test.ts ?? apps/api/src/services/decision-analysis-requirements.ts ?? apps/web/public/apple-touch-icon.png ?? apps/web/public/favicon-16x16.png ?? apps/web/public/favicon-32x32.png ?? apps/web/public/icon-192.png ?? apps/web/public/icon-512.png ?? apps/web/src/app/manifest.ts ?? apps/web/src/app/opengraph-image.tsx ?? apps/web/src/app/twitter-image.tsx ?? apps/web/src/lib/site-metadata.ts ?? apps/web/src/lib/site-social-image.tsx ?? playwright.config.ts ?? scripts/save-playwright-auth.mjs ?? tests/
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' --glob '!coverage' --glob '!playwright-report' --glob '!test-results' "playwright|storageState|test\.use|baseURL|auth"Exit code: 0
Wall time: 0.7 seconds
Output:
.gitignore:8:playwright-report/
.gitignore:10:playwright/.auth/
package.json:15: "test:e2e": "playwright test",
package.json:16: "test:e2e:headed": "playwright test --headed",
package.json:17: "test:e2e:debug": "playwright test --debug",
package.json:18: "test:e2e:auth": "node scripts/save-playwright-auth.mjs",
package.json:27: "@playwright/test": "1.51.1",
docs\architecture.md:24:2. **Server-authoritative rules** - the API validates every action (toCall, minRaise, legal moves).
docs\architecture.md:101:- Finish wiring the web client to authoritative `hand.state` payloads.
playwright.config.ts:4:import { defineConfig, devices } from '@playwright/test';
playwright.config.ts:6:const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000';
playwright.config.ts:7:const authStatePath =
playwright.config.ts:9: path.join(process.cwd(), 'playwright', '.auth', 'google-user.json');
playwright.config.ts:23: baseURL,
playwright.config.ts:25: storageState: fs.existsSync(authStatePath) ? authStatePath : undefined,
pnpm-lock.yaml:11: '@playwright/test':
pnpm-lock.yaml:156: version: 1.6.1(next@15.5.12(@playwright/test@1.51.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
pnpm-lock.yaml:159: version: 1.3.1(next@15.5.12(@playwright/test@1.51.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
pnpm-lock.yaml:186: version: 15.5.12(@playwright/test@1.51.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
pnpm-lock.yaml:187: next-auth:
pnpm-lock.yaml:189: version: 4.24.13(next@15.5.12(@playwright/test@1.51.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
pnpm-lock.yaml:865: '@playwright/test@1.51.1':
pnpm-lock.yaml:2289: next-auth@4.24.13:
pnpm-lock.yaml:2292: '@auth/core': 0.34.3
pnpm-lock.yaml:2298: '@auth/core':
pnpm-lock.yaml:2309: '@playwright/test': ^1.51.1
pnpm-lock.yaml:2317: '@playwright/test':
pnpm-lock.yaml:2361: oauth@0.9.15:
pnpm-lock.yaml:2456: playwright-core@1.51.1:
pnpm-lock.yaml:2461: playwright@1.51.1:
pnpm-lock.yaml:3592: '@playwright/test@1.51.1':
pnpm-lock.yaml:3594: playwright: 1.51.1
pnpm-lock.yaml:4013: '@vercel/analytics@1.6.1(next@15.5.12(@playwright/test@1.51.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)':
pnpm-lock.yaml:4015: next: 15.5.12(@playwright/test@1.51.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
pnpm-lock.yaml:4018: '@vercel/speed-insights@1.3.1(next@15.5.12(@playwright/test@1.51.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)':
pnpm-lock.yaml:4020: next: 15.5.12(@playwright/test@1.51.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
pnpm-lock.yaml:5024: next-auth@4.24.13(next@15.5.12(@playwright/test@1.51.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
pnpm-lock.yaml:5030: next: 15.5.12(@playwright/test@1.51.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
pnpm-lock.yaml:5031: oauth: 0.9.15
pnpm-lock.yaml:5039: next@15.5.12(@playwright/test@1.51.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
pnpm-lock.yaml:5057: '@playwright/test': 1.51.1
pnpm-lock.yaml:5092: oauth@0.9.15: {}
pnpm-lock.yaml:5166: playwright-core@1.51.1: {}
pnpm-lock.yaml:5168: playwright@1.51.1:
pnpm-lock.yaml:5170: playwright-core: 1.51.1
docs\room-manager-migration.md:27: - After each update, call a shared `broadcastHandState` helper so clients always receive the latest authoritative snapshot.
docs\README.md:11:- [`packages/table/README.md`](../packages/table/README.md) - authoritative engine API and examples.
README.md:9:- `packages/table`: authoritative table engine
docs\changelog.md:22:- Replaced the legacy event-sourced `HandState` with the authoritative `TableState` engine in `room-manager.ts`.
docs\changelog.md:25:- Documented the follow-up work required on the web client (`usePokerTable`), bot helpers, and integration tests to consume the new authoritative data.
docs\AUTH_SETUP.md:45:- `{NEXTAUTH_URL}/api/auth/callback/google`
docs\AUTH_SETUP.md:46:- `{NEXTAUTH_URL}/api/auth/callback/facebook`
docs\AUTH_SETUP.md:47:- `{NEXTAUTH_URL}/api/auth/callback/apple`
docs\AUTH_SETUP.md:48:- `{NEXTAUTH_URL}/api/auth/callback/azure-ad`
docs\AUTH_SETUP.md:52:- `http://localhost:3000/api/auth/callback/google`
docs\AUTH_SETUP.md:53:- `http://localhost:3000/api/auth/callback/facebook`
docs\AUTH_SETUP.md:54:- `http://localhost:3000/api/auth/callback/azure-ad`
tests\e2e\global.setup.ts:4:import type { FullConfig } from '@playwright/test';
tests\e2e\global.setup.ts:9: path.join(process.cwd(), 'playwright', '.auth', 'google-user.json')
tests\e2e\global.setup.ts:14: const authStatePath = resolveAuthStatePath();
tests\e2e\global.setup.ts:15: if (!fs.existsSync(authStatePath)) {
tests\e2e\global.setup.ts:18: `Missing Playwright auth state at ${authStatePath}.`,
tests\e2e\global.setup.ts:19: 'Run `pnpm test:e2e:auth`, complete Google sign-in in the opened browser, and rerun the e2e test.',
tests\e2e\global.setup.ts:24: const baseURL =
tests\e2e\global.setup.ts:25: config.projects[0]?.use?.baseURL?.toString() ??
tests\e2e\global.setup.ts:37: const response = await fetch(baseURL, {
tests\e2e\global.setup.ts:47: `Web app is not reachable at ${baseURL}. Start the local stack with \`pnpm dev\` before running the Playwright flow. Last error: ${detail}`,
scripts\save-playwright-auth.mjs:4:import { chromium } from '@playwright/test';
scripts\save-playwright-auth.mjs:6:const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000';
scripts\save-playwright-auth.mjs:7:const authStatePath =
scripts\save-playwright-auth.mjs:9: path.join(process.cwd(), 'playwright', '.auth', 'google-user.json');
scripts\save-playwright-auth.mjs:19: const response = await fetch('/api/auth/session', {
scripts\save-playwright-auth.mjs:34: await fs.mkdir(path.dirname(authStatePath), { recursive: true });
scripts\save-playwright-auth.mjs:43: console.log(`Opening ${baseURL}/login`);
scripts\save-playwright-auth.mjs:47: await page.goto(`${baseURL}/login`, { waitUntil: 'domcontentloaded' });
scripts\save-playwright-auth.mjs:49: await context.storageState({ path: authStatePath });
scripts\save-playwright-auth.mjs:50: console.log(`Saved Playwright auth state to ${authStatePath}`);
tests\e2e\analysis-flow.spec.ts:1:import { expect, test, type Locator, type Page, type TestInfo } from '@playwright/test';
tests\e2e\analysis-flow.spec.ts:196: const response = await fetch('/api/auth/session', {
tests\e2e\analysis-flow.spec.ts:337: await test.step('Open the app with an authenticated Google session', async () => {
tests\e2e\analysis-flow.spec.ts:344: 'The stored browser state is not authenticated with Google for this app. Refresh it with `pnpm test:e2e:auth` and rerun the flow.',
scripts\clean.mjs:13: 'playwright-report',
apps\web\package.json:30: "next-auth": "^4.24.13",
apps\web\middleware.ts:1:import { withAuth } from 'next-auth/middleware';
apps\api\prisma\schema.prisma:26: oauthIdentities OAuthIdentity[]
apps\api\prisma\schema.prisma:64: @@map("oauth_identities")
apps\web\src\types\next-auth.d.ts:1:import type { DefaultSession } from 'next-auth';
apps\web\src\types\next-auth.d.ts:3:declare module 'next-auth' {
apps\web\src\types\next-auth.d.ts:16:declare module 'next-auth/jwt' {
apps\api\src\middleware\auth.ts:2:import { verifyAuthToken } from '../auth/jwt.js';
apps\api\src\middleware\auth.ts:3:import { type ActorIdentity } from '../auth/actor.js';
apps\api\src\middleware\auth.ts:15: const rawHeader = req.headers.authorization;
apps\api\src\middleware\auth.ts:29:function authenticateRequest(req: Request, res: Response): ActorIdentity | null {
apps\api\src\middleware\auth.ts:55: req.authTokenPayload = payload;
apps\api\src\middleware\auth.ts:59: const actor = authenticateRequest(req, res);
apps\api\src\middleware\auth.ts:79: const actor = authenticateRequest(req, res);
apps\api\src\middleware\auth.ts:85: res.status(401).json({ error: 'User authentication required' });
apps\web\src\lib\room-auth-recovery.ts:13: `${API_BASE}/api/auth/guest?guestId=${encodeURIComponent(guestId)}`,
apps\web\src\lib\room-auth-recovery.test.ts:5:} from './room-auth-recovery';
apps\web\src\lib\room-auth-recovery.test.ts:7:describe('room auth recovery', () => {
apps\web\src\lib\room-auth-recovery.test.ts:89: 'http://localhost:3001/api/auth/guest?guestId=guest_1',
apps\api\src\llm\explanation-llm-client.test.ts:107: message: 'Invalid auth token',
apps\web\src\lib\auth.ts:1:import type { NextAuthOptions } from 'next-auth';
apps\web\src\lib\auth.ts:2:import AppleProvider from 'next-auth/providers/apple';
apps\web\src\lib\auth.ts:3:import AzureADProvider from 'next-auth/providers/azure-ad';
apps\web\src\lib\auth.ts:4:import CredentialsProvider from 'next-auth/providers/credentials';
apps\web\src\lib\auth.ts:5:import FacebookProvider from 'next-auth/providers/facebook';
apps\web\src\lib\auth.ts:6:import GoogleProvider from 'next-auth/providers/google';
apps\web\src\lib\auth.ts:7:import type { JWT } from 'next-auth/jwt';
apps\web\src\lib\auth.ts:14:} from './auth-api';
apps\web\src\lib\auth.ts:140:export const authOptions: NextAuthOptions = {
apps\web\src\lib\auth.ts:155: async authorize(rawCredentials) {
apps\web\src\lib\auth.ts:191: if (account.type !== 'oauth') return true;
apps\web\src\lib\auth-callback-url.test.ts:2:import { normalizeAuthCallbackUrl, resolveCurrentCallbackUrl } from './auth-callback-url';
apps\web\src\lib\auth-api.ts:66: return postAuthApi<GenericAuthSuccess>('/api/auth/register', {
apps\web\src\lib\auth-api.ts:76: return postAuthApi<AuthApiUser>('/api/auth/login', { email, password });
apps\web\src\lib\auth-api.ts:86: return postAuthApi<AuthApiUser>('/api/auth/oauth', input);
apps\web\src\lib\auth-api.ts:90: return postAuthApi<{ ok: true }>('/api/auth/verify-email', { token });
apps\web\src\lib\auth-api.ts:94: return postAuthApi<{ ok: true }>('/api/auth/resend-verification', { email });
apps\api\src\index.ts:14:import { authRouter } from './routes/auth.js';
apps\api\src\index.ts:27:import { optionalAuth, requireUserAuth } from './middleware/auth.js';
apps\api\src\index.ts:28:import { verifyAuthToken } from './auth/jwt.js';
apps\api\src\index.ts:29:import { normalizeActorId } from './auth/actor.js';
apps\api\src\index.ts:67: const authorization = req.header('authorization');
apps\api\src\index.ts:68: if (!authorization) return null;
apps\api\src\index.ts:69: const [scheme, token] = authorization.split(' ');
apps\api\src\index.ts:104: const authToken = socket.handshake.auth?.token;
apps\api\src\index.ts:105: if (typeof authToken === 'string' && authToken.trim().length > 0) {
apps\api\src\index.ts:106: return authToken.trim();
apps\api\src\index.ts:109: const rawAuthorization = socket.handshake.headers.authorization;
apps\api\src\index.ts:118: const fromAuth = socket.handshake.auth?.[key];
apps\api\src\index.ts:159: next(new Error('Invalid auth token'));
apps\api\src\index.ts:242: return res.status(401).json({ error: 'Unauthorized' });
apps\api\src\index.ts:291:app.use('/api/auth', authRouter);
apps\web\src\app\verify-email\page.tsx:32: `${process.env.NEXT_PUBLIC_API_BASE ?? 'http://localhost:3001'}/api/auth/verify-email`,
apps\web\src\hooks\useSocket.ts:11:export function useSocket(url: string = API_BASE, auth?: SocketAuthOptions) {
apps\web\src\hooks\useSocket.ts:16: const authPayload: Record<string, string> = {};
apps\web\src\hooks\useSocket.ts:17: if (auth?.token) authPayload.token = auth.token;
apps\web\src\hooks\useSocket.ts:18: if (auth?.guestId) authPayload.guestId = auth.guestId;
apps\web\src\hooks\useSocket.ts:19: if (auth?.clientId) authPayload.clientId = auth.clientId;
apps\web\src\hooks\useSocket.ts:22: auth: Object.keys(authPayload).length > 0 ? authPayload : undefined,
apps\web\src\hooks\useSocket.ts:38: }, [auth?.clientId, auth?.guestId, auth?.token, url]);
apps\web\src\hooks\usePokerTable.ts:690:export function usePokerTable(roomId: string, auth: PokerTableAuthInput) {
apps\web\src\hooks\usePokerTable.ts:691: const authToken = auth.token;
apps\web\src\hooks\usePokerTable.ts:692: const authLoading = Boolean(auth.isLoading);
apps\web\src\hooks\usePokerTable.ts:693: const guestId = auth.guestId ?? null;
apps\web\src\hooks\usePokerTable.ts:694: const clientId = auth.clientId ?? null;
apps\web\src\hooks\usePokerTable.ts:936: if (authLoading) {
apps\web\src\hooks\usePokerTable.ts:940: if (!authToken) {
apps\web\src\hooks\usePokerTable.ts:978: auth: {
apps\web\src\hooks\usePokerTable.ts:979: token: authToken,
apps\web\src\hooks\usePokerTable.ts:1154: // NEW: Listen for authoritative hand state from server
apps\web\src\hooks\usePokerTable.ts:1265: // Set current hand from authoritative snapshot (NOT from hand.event)
apps\web\src\hooks\usePokerTable.ts:1858: authLoading,
apps\web\src\hooks\usePokerTable.ts:1859: authToken,
apps\web\src\hooks\usePokerTable.test.ts:115: it('clears a stale table seat when the authoritative room seat is open', () => {
apps\web\src\app\table\[roomId]\page.tsx:4:import { useSession } from 'next-auth/react';
apps\web\src\app\table\[roomId]\page.tsx:118: token: authToken,
apps\web\src\app\table\[roomId]\page.tsx:138: if (!authToken) {
apps\web\src\app\table\[roomId]\page.tsx:152: authToken=[REDACTED]
apps\web\src\app\table\[roomId]\page.tsx:163: authToken: string;
apps\web\src\app\table\[roomId]\page.tsx:175: authToken,
apps\web\src\app\table\[roomId]\page.tsx:309: token: authToken,
apps\web\src\app\table\[roomId]\page.tsx:425: if (!authToken || !connected || joinAttempted) return;
apps\web\src\app\table\[roomId]\page.tsx:428: }, [authToken, connected, inviteCode, joinAttempted, joinRoom]);
apps\web\src\app\table\[roomId]\page.tsx:431: if (!authToken) return;
apps\web\src\app\table\[roomId]\page.tsx:440: Authorization: `Bearer ${authToken}`,
apps\web\src\app\table\[roomId]\page.tsx:463: }, [authToken, inviteCode, roomId, toast]);
apps\web\src\app\table\[roomId]\page.tsx:466: if (!authToken || !session?.user) {
apps\web\src\app\table\[roomId]\page.tsx:477: Authorization: `Bearer ${authToken}`,
apps\web\src\app\table\[roomId]\page.tsx:502: }, [authToken, session?.user?.id]);
apps\web\src\app\table\[roomId]\page.tsx:602: if (!authToken || !targetKey) {
apps\web\src\app\table\[roomId]\page.tsx:616: Authorization: `Bearer ${authToken}`,
apps\web\src\app\table\[roomId]\page.tsx:632: if (!authToken || !targetKey) {
apps\web\src\app\table\[roomId]\page.tsx:645: Authorization: `Bearer ${authToken}`,
apps\web\src\app\table\[roomId]\page.tsx:678: if (!authToken || !pollingTargetKey) {
apps\web\src\app\table\[roomId]\page.tsx:713: }, [authToken, currentHandActionTarget, roomId]);
apps\web\src\app\table\[roomId]\page.tsx:852: if (!authToken || !isHost || !mergedRoom) return;
apps\web\src\app\table\[roomId]\page.tsx:864: Authorization: `Bearer ${authToken}`,
apps\api\src\types\express.d.ts:7: authTokenPayload?: {
apps\api\src\game\socket-handlers.ts:9:} from '../auth/actor.js';
apps\web\src\hooks\useActorAuth.ts:4:import { useSession } from 'next-auth/react';
apps\web\src\hooks\useActorAuth.ts:50: `${API_BASE}/api/auth/guest?guestId=${encodeURIComponent(guestId)}`,
apps\api\src\game\room-manager.ts:22:import { type ActorType } from '../auth/actor.js';
apps\api\src\game\room-manager.start-hand.test.ts:2:import type { ActorType } from '../auth/actor.js';
apps\api\src\game\room-manager.practice-bot.test.ts:2:import type { ActorType } from '../auth/actor.js';
apps\api\src\game\room-manager.bot-removal.test.ts:2:import type { ActorType } from '../auth/actor.js';
apps\web\src\app\settings\page.tsx:4:import { useSession } from 'next-auth/react';
apps\web\src\app\rooms\page.tsx:153: const { token: authToken, isLoading: authLoading } = useActorAuth();
apps\web\src\app\rooms\page.tsx:156: if (authLoading) {
apps\web\src\app\rooms\page.tsx:160: if (!authToken) {
apps\web\src\app\rooms\page.tsx:165: void loadRooms(authToken);
apps\web\src\app\rooms\page.tsx:166: }, [authLoading, authToken]);
apps\web\src\app\rooms\page.tsx:200: if (!authToken) {
apps\web\src\app\rooms\page.tsx:240: onClick={() => void (authToken && loadRooms(authToken))}
apps\api\prisma\migrations\20260208234000_add_oauth_identities\migration.sql:1:CREATE TABLE "oauth_identities" (
apps\api\prisma\migrations\20260208234000_add_oauth_identities\migration.sql:9: CONSTRAINT "oauth_identities_pkey" PRIMARY KEY ("id")
apps\api\prisma\migrations\20260208234000_add_oauth_identities\migration.sql:12:CREATE UNIQUE INDEX "oauth_identities_provider_providerAccountId_key" ON "oauth_identities"("provider", "providerAccountId");
apps\api\prisma\migrations\20260208234000_add_oauth_identities\migration.sql:14:CREATE INDEX "oauth_identities_userId_idx" ON "oauth_identities"("userId");
apps\api\prisma\migrations\20260208234000_add_oauth_identities\migration.sql:16:ALTER TABLE "oauth_identities"
apps\api\prisma\migrations\20260208234000_add_oauth_identities\migration.sql:17:ADD CONSTRAINT "oauth_identities_userId_fkey"
apps\web\src\app\register\page.tsx:104: `${process.env.NEXT_PUBLIC_API_BASE ?? 'http://localhost:3001'}/api/auth/resend-verification`,
apps\solver-service\src\server.ts:161: return res.status(401).json({ error: 'unauthorized' });
apps\web\src\app\profile\page.tsx:5:import { signOut, useSession } from 'next-auth/react';
apps\api\src\routes\analysis-rest.ts:1581: return res.status(403).json({ error: 'Not authorized to cancel this analysis' });
apps\api\src\routes\analysis-rest.ts:1758: return res.status(403).json({ error: 'Not authorized to retry this analysis' });
apps\web\src\app\page.tsx:5:import { signIn, signOut, useSession } from 'next-auth/react';
apps\web\src\app\page.tsx:31:} from '../lib/room-auth-recovery';
apps\web\src\app\page.tsx:801: token: authToken,
apps\web\src\app\page.tsx:825: if (!authToken) {
apps\web\src\app\page.tsx:837: Authorization: `Bearer ${authToken}`,
apps\web\src\app\page.tsx:872: if (!authToken) {
apps\web\src\app\page.tsx:884: Authorization: `Bearer ${authToken}`,
apps\api\src\routes\auth.register-delete.test.ts:39:vi.mock('../auth/email-verification.js', () => ({
apps\api\src\routes\auth.register-delete.test.ts:46:describe('auth register and delete-account', () => {
apps\api\src\routes\auth.register-delete.test.ts:63: const { authRouter } = await import('./auth.js');
apps\api\src\routes\auth.register-delete.test.ts:66: app.use('/api/auth', authRouter);
apps\api\src\routes\auth.register-delete.test.ts:95: const response = await fetch(`${baseUrl}/api/auth/register`, {
apps\api\src\routes\auth.register-delete.test.ts:115: const response = await fetch(`${baseUrl}/api/auth/delete-account`, {
apps\api\src\routes\auth.login.test.ts:32:vi.mock('../auth/email-verification.js', () => ({
apps\api\src\routes\auth.login.test.ts:37:describe('POST /api/auth/login', () => {
apps\api\src\routes\auth.login.test.ts:47: const { authRouter } = await import('./auth.js');
apps\api\src\routes\auth.login.test.ts:51: app.use('/api/auth', authRouter);
apps\api\src\routes\auth.login.test.ts:79: const response = await fetch(`${baseUrl}/api/auth/login`, {
apps\api\src\routes\auth.ts:5:import { signGuestAuthToken } from '../auth/jwt.js';
apps\api\src\routes\auth.ts:10:} from '../auth/email-verification.js';
apps\api\src\routes\auth.ts:13:const authRouter = Router();
apps\api\src\routes\auth.ts:45:const oauthSchema = z.object({
apps\api\src\routes\auth.ts:131:function buildOauthIdentityEmail(provider: string, providerAccountId: string): string {
apps\api\src\routes\auth.ts:138: return `${normalizedProvider}-${accountIdPart}@oauth.local`;
apps\api\src\routes\auth.ts:178: keyPrefix: 'auth:register',
apps\api\src\routes\auth.ts:184: keyPrefix: 'auth:login',
apps\api\src\routes\auth.ts:190: keyPrefix: 'auth:verify-email',
apps\api\src\routes\auth.ts:196: keyPrefix: 'auth:resend-verification',
apps\api\src\routes\auth.ts:237:authRouter.get('/guest', async (req, res) => {
apps\api\src\routes\auth.ts:252:authRouter.post('/register', registerRateLimit, async (req, res) => {
apps\api\src\routes\auth.ts:274: console.error('[auth] Failed to send verification email for existing registration attempt', {
apps\api\src\routes\auth.ts:306: console.error('[auth] Failed to complete registration flow', {
apps\api\src\routes\auth.ts:315:authRouter.post('/login', loginRateLimit, handlePasswordLogin);
apps\api\src\routes\auth.ts:316:authRouter.post('/credentials', loginRateLimit, handlePasswordLogin);
apps\api\src\routes\auth.ts:318:authRouter.post('/oauth', async (req, res) => {
apps\api\src\routes\auth.ts:319: const parsed = oauthSchema.safeParse(req.body);
apps\api\src\routes\auth.ts:351: const oauthVerificationTime = existingIdentity.user.emailVerifiedAt ?? new Date();
apps\api\src\routes\auth.ts:357: emailVerifiedAt: oauthVerificationTime,
apps\api\src\routes\auth.ts:384: const oauthVerificationTime = new Date();
apps\api\src\routes\auth.ts:387: normalizedEmail ?? buildOauthIdentityEmail(provider, providerAccountId);
apps\api\src\routes\auth.ts:394: emailVerifiedAt: oauthVerificationTime,
apps\api\src\routes\auth.ts:410: emailVerifiedAt: existingUser.emailVerifiedAt ?? oauthVerificationTime,
apps\api\src\routes\auth.ts:441:authRouter.get('/verify-email', verifyEmailRateLimit, async (req, res) => {
apps\api\src\routes\auth.ts:456:authRouter.post('/verify-email', verifyEmailRateLimit, async (req, res) => {
apps\api\src\routes\auth.ts:470:authRouter.post('/resend-verification', resendVerificationRateLimit, async (req, res) => {
apps\api\src\routes\auth.ts:494: console.error('[auth] resend verification failed', {
apps\api\src\routes\auth.ts:503:authRouter.delete('/delete-account', async (req, res) => {
apps\api\src\routes\auth.ts:532: console.error('[auth] delete-account failed', {
apps\api\src\routes\auth.ts:540:export { authRouter };
apps\api\src\routes\rooms.route.test.ts:38: it('returns 401 instead of hitting Prisma when the authenticated user no longer exists', async () => {
apps\api\src\routes\me.ts:9:import { requireAuth } from '../middleware/auth.js';
apps\api\src\routes\me.ts:68: callback(new Error('User authentication required'), '');
apps\api\src\routes\me.ts:110: return res.status(401).json({ error: 'User authentication required' });
apps\api\src\routes\me.ts:144: return res.status(401).json({ error: 'User authentication required' });
apps\api\src\routes\me.ts:186: return res.status(401).json({ error: 'User authentication required' });
apps\web\src\app\login\page.tsx:6:import { getProviders, signIn, useSession } from 'next-auth/react';
apps\web\src\app\login\page.tsx:7:import { normalizeAuthCallbackUrl } from '@/lib/auth-callback-url';
apps\web\src\app\login\page.tsx:139: if (status === 'authenticated') {
apps\web\src\app\login\page.tsx:254: `${process.env.NEXT_PUBLIC_API_BASE ?? 'http://localhost:3001'}/api/auth/resend-verification`,
apps\api\src\routes\rooms.ts:13:} from '../auth/actor.js';
apps\web\src\app\layout.tsx:8:import { AuthProvider } from '../components/auth/AuthProvider';
apps\api\src\services\room.service.ts:3:import type { ActorIdentity } from '../auth/actor.js';
apps\web\src\app\hands\page.test.tsx:9:vi.mock('next-auth/react', () => ({
apps\web\src\app\hands\page.test.tsx:11: status: 'authenticated',
apps\web\src\app\hands\hand-detail-page.test.tsx:87:vi.mock('next-auth/react', () => ({
apps\web\src\app\hands\hand-detail-page.test.tsx:89: status: 'authenticated',
apps\web\src\app\hands\page.tsx:5:import { signIn, useSession } from 'next-auth/react';
apps\web\src\components\table\HandTimeline.tsx:2:import { signIn, useSession } from 'next-auth/react';
apps\web\src\components\table\HandTimeline.tsx:64: const authToken = session?.apiToken;
apps\web\src\components\table\HandTimeline.tsx:91: if (!authToken) return;
apps\web\src\components\table\HandTimeline.tsx:108: Authorization: `Bearer ${authToken}`,
apps\web\src\components\table\HandTimeline.tsx:151: }, [authToken, decisionStatuses]);
apps\web\src\components\table\HandTimeline.tsx:239: if (!authToken) {
apps\web\src\components\table\HandTimeline.tsx:259: Authorization: `Bearer ${authToken}`,
apps\web\src\components\table\HandTimeline.test.tsx:9:vi.mock('next-auth/react', () => ({
apps\web\src\app\hands\[handId]\page.tsx:5:import { signIn, useSession } from 'next-auth/react';
apps\web\src\components\profile\ProfileSettingsForm.tsx:5:import { signOut, useSession } from 'next-auth/react';
apps\web\src\app\api\register\route.ts:3:import { AuthApiError, registerWithApi } from '@/lib/auth-api';
apps\web\src\components\table\AnalysisDrawer.tsx:2:import { signIn, useSession } from 'next-auth/react';
apps\web\src\components\table\AnalysisDrawer.tsx:187: options?: { allowNotFound?: boolean; allowConflict?: boolean; authToken?: string | null }
apps\web\src\components\table\AnalysisDrawer.tsx:190: if (options?.authToken) {
apps\web\src\components\table\AnalysisDrawer.tsx:191: headers.set('Authorization', `Bearer ${options.authToken}`);
apps\web\src\components\table\AnalysisDrawer.tsx:233: const authToken = session?.apiToken;
apps\web\src\components\table\AnalysisDrawer.tsx:297: { allowNotFound: true, authToken }
apps\web\src\components\table\AnalysisDrawer.tsx:305: }, [authToken, decisionId]);
apps\web\src\components\table\AnalysisDrawer.tsx:317: { ...options, allowConflict: true, authToken }
apps\web\src\components\table\AnalysisDrawer.tsx:390: [authToken, decisionId, setAnalysisResultSafe, updateStatus]
apps\web\src\components\table\AnalysisDrawer.tsx:394: if (!authToken) {
apps\web\src\components\table\AnalysisDrawer.tsx:400: auth: { token: authToken },
apps\web\src\components\table\AnalysisDrawer.tsx:491: }, [authToken, decisionId, fetchResult, updateStatus]);
apps\web\src\components\table\AnalysisDrawer.tsx:499: if (!authToken) {
apps\web\src\components\table\AnalysisDrawer.tsx:549: { authToken }
apps\web\src\components\table\AnalysisDrawer.tsx:580: }, [authToken, decisionId, fetchResult, fetchStatus, sessionStatus, updateStatus]);
apps\web\src\components\table\AnalysisDrawer.tsx:689: const canAct = Boolean(playerId && roomId && authToken);
apps\web\src\components\table\AnalysisDrawer.tsx:711: { authToken }
apps\web\src\components\table\AnalysisDrawer.tsx:730: }, [actionPending, authToken, canAct, canCancel, decisionId, playerId, roomId, updateStatus]);
apps\web\src\components\table\AnalysisDrawer.tsx:751: { authToken }
apps\web\src\components\table\AnalysisDrawer.tsx:770: }, [actionPending, authToken, canAct, canRetry, decisionId, playerId, roomId, updateStatus]);
apps\web\src\components\table\AnalysisDrawer.tsx:780: if (sessionStatus !== 'loading' && !authToken) {
apps\web\src\components\table\AnalysisDrawer.test.tsx:72:vi.mock('next-auth/react', () => ({
apps\web\src\components\table\AnalysisDrawer.test.tsx:74: status: 'authenticated',
apps\web\src\app\api\auth\[...nextauth]\route.ts:1:import NextAuth from 'next-auth';
apps\web\src\app\api\auth\[...nextauth]\route.ts:2:import { authOptions } from '@/lib/auth';
apps\web\src\app\api\auth\[...nextauth]\route.ts:4:const handler = NextAuth(authOptions);
apps\web\src\components\hands\CoachPanel.tsx:4:import { signIn, useSession } from 'next-auth/react';
apps\web\src\components\CreateRoomModal.tsx:2:import { signOut } from 'next-auth/react';
apps\web\src\components\CreateRoomModal.tsx:10:} from '../lib/room-auth-recovery';
apps\web\src\components\CreateRoomModal.tsx:19: token: authToken,
apps\web\src\components\CreateRoomModal.tsx:37: if (!authToken) {
apps\web\src\components\CreateRoomModal.tsx:51: Authorization: `Bearer ${authToken}`,
apps\web\src\components\CreateRoomModal.tsx:158: disabled={loading || actorAuthLoading || !authToken}
apps\web\src\app\api\account\delete\route.ts:2:import { getServerSession } from 'next-auth';
apps\web\src\app\api\account\delete\route.ts:3:import { authOptions } from '@/lib/auth';
apps\web\src\app\api\account\delete\route.ts:30: const session = await getServerSession(authOptions);
apps\web\src\app\api\account\delete\route.ts:33: return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
apps\web\src\app\api\account\delete\route.ts:42: const response = await fetch(`${readApiBase()}/api/auth/delete-account`, {
apps\web\src\components\auth\UserMenu.tsx:6:import { signIn, signOut, useSession } from 'next-auth/react';
apps\web\src\components\auth\UserMenu.tsx:8:import { resolveCurrentCallbackUrl } from '@/lib/auth-callback-url';
apps\web\src\components\auth\AuthProvider.tsx:4:import { SessionProvider } from 'next-auth/react';
apps\web\src\components\AppHeader.tsx:5:import { signIn, signOut, useSession } from 'next-auth/react';
apps\web\src\components\AppHeader.tsx:8:import { UserMenu } from './auth/UserMenu';
apps\web\src\components\AppHeader.tsx:10:import { resolveCurrentCallbackUrl } from '@/lib/auth-callback-url';
apps\web\src\components\AppHeader.tsx:17:} from '@/lib/room-auth-recovery';
apps\web\src\components\AppHeader.tsx:58: const { token: authToken, actorType, guestId } = useActorAuth();
apps\web\src\components\AppHeader.tsx:66: if (!authToken) {
apps\web\src\components\AppHeader.tsx:78: Authorization: `Bearer ${authToken}`,
apps\web\src\components\AppHeader.tsx:226: {/* Right: auth only */}
I’ve found the Playwright harness and there’s a single e2e flow under tests/e2e/analysis-flow.spec.ts, with global setup enforcing an existing authenticated storage state at playwright/.auth/google-user.json. Next I’m reading the config, setup, and that test to determine the exact route coverage and prerequisites before I run it.
Get-Content playwright.config.tsExit code: 0
Wall time: 0.2 seconds
Output:
import fs from 'node:fs';
import path from 'node:path';
import { defineConfig, devices } from '@playwright/test';
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000';
const authStatePath =
process.env.PLAYWRIGHT_AUTH_STATE ??
path.join(process.cwd(), 'playwright', '.auth', 'google-user.json');
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
timeout: 12 * 60 * 1000,
expect: {
timeout: 30_000,
},
use: {
...devices['Desktop Chrome'],
baseURL,
headless: process.env.PLAYWRIGHT_HEADLESS === '0' ? false : true,
storageState: fs.existsSync(authStatePath) ? authStatePath : undefined,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
globalSetup: path.join(process.cwd(), 'tests', 'e2e', 'global.setup.ts'),
projects: [
{
name: 'chromium',
use: {
browserName: 'chromium',
},
},
],
workers: 1,
});
Get-Content tests\e2e\global.setup.tsExit code: 0
Wall time: 0.2 seconds
Output:
import fs from 'node:fs';
import path from 'node:path';
import type { FullConfig } from '@playwright/test';
function resolveAuthStatePath(): string {
return (
process.env.PLAYWRIGHT_AUTH_STATE ??
path.join(process.cwd(), 'playwright', '.auth', 'google-user.json')
);
}
export default async function globalSetup(config: FullConfig): Promise<void> {
const authStatePath = resolveAuthStatePath();
if (!fs.existsSync(authStatePath)) {
throw new Error(
[
`Missing Playwright auth state at ${authStatePath}.`,
'Run `pnpm test:e2e:auth`, complete Google sign-in in the opened browser, and rerun the e2e test.',
].join(' '),
);
}
const baseURL =
config.projects[0]?.use?.baseURL?.toString() ??
process.env.PLAYWRIGHT_BASE_URL ??
'http://localhost:3000';
const apiBaseURL =
process.env.PLAYWRIGHT_API_BASE_URL ??
process.env.NEXT_PUBLIC_API_BASE ??
'http://localhost:3001';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetch(baseURL, {
redirect: 'manual',
signal: controller.signal,
});
if (!response.ok && ![301, 302, 307, 308].includes(response.status)) {
throw new Error(`unexpected status ${response.status}`);
}
} catch (error) {
const detail = error instanceof Error ? error.message : 'unknown error';
throw new Error(
`Web app is not reachable at ${baseURL}. Start the local stack with \`pnpm dev\` before running the Playwright flow. Last error: ${detail}`,
);
} finally {
clearTimeout(timeout);
}
const apiController = new AbortController();
const apiTimeout = setTimeout(() => apiController.abort(), 10_000);
try {
const response = await fetch(`${apiBaseURL}/api/health`, {
redirect: 'manual',
signal: apiController.signal,
});
if (response.status >= 500) {
throw new Error(`unexpected status ${response.status}`);
}
if (!response.ok) {
throw new Error(`unexpected status ${response.status}`);
}
const payload = (await response.json()) as {
analysisWorker?: {
configured?: boolean;
available?: boolean;
};
};
const workerAvailable = payload.analysisWorker?.available === true;
if (!workerAvailable) {
throw new Error(
[
`Analysis worker is not running at ${apiBaseURL}.`,
'Start the full local stack with `pnpm dev`, or start the worker separately with `START_WORKERS=1 pnpm worker`.',
].join(' '),
);
}
} catch (error) {
const detail = error instanceof Error ? error.message : 'unknown error';
throw new Error(
`API or analysis worker is not ready at ${apiBaseURL}. Start the local API and worker before running the Playwright flow. Last error: ${detail}`,
);
} finally {
clearTimeout(apiTimeout);
}
}
Get-Content tests\e2e\analysis-flow.spec.tsExit code: 0
Wall time: 0.3 seconds
Output:
import { expect, test, type Locator, type Page, type TestInfo } from '@playwright/test';
const API_BASE_URL = process.env.PLAYWRIGHT_API_BASE_URL ?? 'http://localhost:3001';
const PLAY_TIMEOUT_MS = Number.parseInt(
process.env.PLAYWRIGHT_E2E_PLAY_TIMEOUT_MS ?? '240000',
10,
);
const ANALYSIS_TIMEOUT_MS = Number.parseInt(
process.env.PLAYWRIGHT_ANALYSIS_TIMEOUT_MS ?? '480000',
10,
);
const PLAYWRIGHT_HERO_STACK = Number.parseInt(
process.env.PLAYWRIGHT_E2E_HERO_STACK ?? '200',
10,
);
type SessionPayload = {
user?: {
id?: string | null;
email?: string | null;
name?: string | null;
} | null;
apiToken?: string | null;
};
type HandListItem = {
handId: string;
playedAt: string;
roomId: string | null;
analysisStatus: 'waiting' | 'queued' | 'running' | 'complete' | 'failed' | null;
};
type HandsResponse = {
items: HandListItem[];
};
type HandActionStatusPayload = {
pipelineStatus: 'queued' | 'running' | 'blocked' | 'complete' | 'degraded' | 'failed';
analyzeHand: {
status: string;
stage?: string | null;
errorMessage?: string | null;
message?: string | null;
};
analysis: {
status: string;
stage?: string | null;
errorMessage?: string | null;
message?: string | null;
};
overview: {
status: string;
stage?: string | null;
errorMessage?: string | null;
};
blockingDecisions: Array<{
decisionId: string;
label: string;
solverError: string | null;
solverErrorCode: string | null;
stage: string | null;
}>;
decisions: Array<{
decisionId: string;
street: 'preflop' | 'flop' | 'turn' | 'river' | string;
label: string;
status: 'queued' | 'running' | 'llm_only' | 'complete' | 'solver_failed' | 'failed';
stage?: string | null;
errorMessage?: string | null;
solverAvailable: boolean;
}>;
counts: {
total: number;
queued: number;
complete: number;
running: number;
failed: number;
llmOnly: number;
};
};
const REQUIRED_STREETS = ['preflop', 'flop', 'turn', 'river'] as const;
const STREET_LABELS: Record<(typeof REQUIRED_STREETS)[number], string> = {
preflop: 'Preflop',
flop: 'Flop',
turn: 'Turn',
river: 'River',
};
function hasStreetCoverage(status: HandActionStatusPayload, street: (typeof REQUIRED_STREETS)[number]): boolean {
const decision = status.decisions.find((row) => row.street.toLowerCase() === street);
if (!decision) {
return false;
}
if (street === 'preflop') {
return decision.status === 'llm_only';
}
return decision.status === 'complete' && decision.solverAvailable;
}
function summarizeDecisionCoverage(status: HandActionStatusPayload): string {
return status.decisions
.map(
(decision) =>
`${decision.street}:${decision.status}:solver=${decision.solverAvailable}:stage=${decision.stage ?? 'n/a'}:error=${decision.errorMessage ?? 'n/a'}`,
)
.join(' | ');
}
type DiagnosticRecorder = ReturnType<typeof createDiagnosticRecorder>;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createDiagnosticRecorder(page: Page) {
const consoleEntries: Array<{
type: string;
text: string;
location?: string;
}> = [];
const networkEntries: Array<{
kind: 'requestfailed' | 'response';
method: string;
url: string;
status?: number;
failureText?: string | null;
}> = [];
page.on('console', (message) => {
if (!['error', 'warning'].includes(message.type())) {
return;
}
consoleEntries.push({
type: message.type(),
text: message.text(),
location: message.location().url
? `${message.location().url}:${message.location().lineNumber ?? 0}`
: undefined,
});
});
page.on('requestfailed', (request) => {
networkEntries.push({
kind: 'requestfailed',
method: request.method(),
url: request.url(),
failureText: request.failure()?.errorText ?? null,
});
});
page.on('response', (response) => {
if (response.status() < 400) {
return;
}
networkEntries.push({
kind: 'response',
method: response.request().method(),
url: response.url(),
status: response.status(),
});
});
return {
async attach(testInfo: TestInfo) {
if (consoleEntries.length > 0) {
await testInfo.attach('browser-console.json', {
body: JSON.stringify(consoleEntries, null, 2),
contentType: 'application/json',
});
}
if (networkEntries.length > 0) {
await testInfo.attach('network-errors.json', {
body: JSON.stringify(networkEntries, null, 2),
contentType: 'application/json',
});
}
},
};
}
async function isUsable(locator: Locator): Promise<boolean> {
const count = await locator.count();
if (count === 0) {
return false;
}
try {
return (await locator.isVisible()) && (await locator.isEnabled());
} catch {
return false;
}
}
async function readSession(page: Page): Promise<SessionPayload> {
return page.evaluate(async () => {
const response = await fetch('/api/auth/session', {
credentials: 'include',
});
return (await response.json()) as SessionPayload;
});
}
async function readRoomState(page: Page): Promise<{ label: string | null; detail: string | null }> {
const label = await page.getByTestId('room-state-label').textContent().catch(() => null);
const detail = await page.getByTestId('room-state-detail').textContent().catch(() => null);
return {
label: label?.trim() ?? null,
detail: detail?.trim() ?? null,
};
}
async function fetchHands(
page: Page,
apiToken: string,
pageSize = 10,
): Promise<HandsResponse> {
const response = await page.context().request.get(
`${API_BASE_URL}/api/hands?page=1&pageSize=${pageSize}`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
},
);
if (!response.ok()) {
throw new Error(`Hands API failed with ${response.status()}`);
}
return (await response.json()) as HandsResponse;
}
async function fetchHandActionStatus(
page: Page,
apiToken: string,
roomId: string,
handId: string,
): Promise<HandActionStatusPayload> {
const query = new URLSearchParams({
gameId: roomId,
handId,
});
const response = await page.context().request.get(
`${API_BASE_URL}/api/hand-actions/status?${query.toString()}`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
},
);
if (!response.ok()) {
throw new Error(`Hand action status API failed with ${response.status()}`);
}
return (await response.json()) as HandActionStatusPayload;
}
async function activateControl(page: Page, locator: Locator): Promise<void> {
await locator.focus();
await page.keyboard.press('Enter');
}
async function startQuickStartRoom(page: Page): Promise<void> {
const button = page.getByTestId('home-start-playing-button');
await expect(button).toBeEnabled();
let lastHomeError: string | null = null;
for (let attempt = 0; attempt < 2; attempt += 1) {
await activateControl(page, button);
try {
await page.waitForURL(/\/table\/[^/?#]+/, { timeout: 15_000 });
return;
} catch {
const errorToast = page.getByText(
/Unable to start a session|Failed to connect to server|Failed to create room/i,
);
if (await errorToast.first().isVisible().catch(() => false)) {
lastHomeError = (await errorToast.first().textContent())?.trim() ?? null;
}
}
}
throw new Error(
`Start Playing did not reach a table. Last homepage error: ${lastHomeError ?? 'none visible'}`,
);
}
async function pollUntil<T>(params: {
label: string;
timeoutMs: number;
intervalsMs: number[];
operation: () => Promise<T>;
accept: (value: T) => boolean;
failFast?: (value: T) => string | null;
}): Promise<T> {
const startedAt = Date.now();
let attempt = 0;
let lastValue: T | null = null;
while (Date.now() - startedAt < params.timeoutMs) {
const value = await params.operation();
lastValue = value;
const failure = params.failFast?.(value);
if (failure) {
throw new Error(`${params.label} failed early: ${failure}`);
}
if (params.accept(value)) {
return value;
}
const interval =
params.intervalsMs[Math.min(attempt, params.intervalsMs.length - 1)] ?? 2_000;
attempt += 1;
await sleep(interval);
}
throw new Error(
`${params.label} did not complete within ${Math.round(params.timeoutMs / 1000)}s. Last value: ${JSON.stringify(lastValue, null, 2)}`,
);
}
test('runs the full postflop analysis flow and exposes the debug log', async ({ page }, testInfo) => {
test.setTimeout(12 * 60 * 1000);
const diagnostics = createDiagnosticRecorder(page);
const playedActions: Array<{
roomState: string | null;
roomDetail: string | null;
action: string;
}> = [];
let apiToken = '';
let roomId = '';
let targetHand: HandListItem | null = null;
let finalStatus: HandActionStatusPayload | null = null;
try {
await test.step('Open the app with an authenticated Google session', async () => {
await page.goto('/');
await expect(page.getByTestId('home-start-playing-button')).toBeVisible();
const session = await readSession(page);
if (!session.user?.email || !session.apiToken) {
throw new Error(
'The stored browser state is not authenticated with Google for this app. Refresh it with `pnpm test:e2e:auth` and rerun the flow.',
);
}
apiToken = session.apiToken;
});
await test.step('Start a bot room and take a seat', async () => {
await startQuickStartRoom(page);
const roomMatch = page.url().match(/\/table\/([^/?#]+)/);
if (!roomMatch?.[1]) {
throw new Error(`Could not resolve room id from URL ${page.url()}`);
}
roomId = roomMatch[1];
const openSeat = page.locator('[data-testid^="open-seat-"]').first();
if (await isUsable(openSeat)) {
await openSeat.focus();
await page.keyboard.press('Enter');
await expect(page.getByTestId('enter-seat-modal')).toBeVisible();
await page.getByTestId('enter-seat-name-input').fill('Playwright Hero');
await page
.getByTestId('enter-seat-stack-input')
.fill(String(Number.isFinite(PLAYWRIGHT_HERO_STACK) ? PLAYWRIGHT_HERO_STACK : 200));
await page.getByTestId('enter-seat-submit-button').click();
await expect(page.getByTestId('enter-seat-modal')).toBeHidden();
}
const autoRunToggle = page.getByTestId('room-autoplay-toggle');
await expect(autoRunToggle).toBeVisible();
const toggleText = (await autoRunToggle.textContent())?.trim() ?? '';
if (/^Start$/i.test(toggleText)) {
await activateControl(page, autoRunToggle);
}
});
await test.step('Play safe legal actions until analysis is triggered on a postflop hand', async () => {
const primaryAction = page.getByTestId('table-primary-action-button');
const showHandsButton = page.getByTestId('table-show-hands-button');
const analyzeButton = page.getByTestId('room-analyze-hand-button');
let sawPostflopThisHand = false;
let analysisRequested = false;
let idleAfterAnalyze = 0;
const deadline = Date.now() + PLAY_TIMEOUT_MS;
while (Date.now() < deadline) {
const roomState = await readRoomState(page);
const stateLabel = roomState.label;
if (stateLabel === 'Flop' || stateLabel === 'Turn' || stateLabel === 'River') {
sawPostflopThisHand = true;
}
if (!analysisRequested && sawPostflopThisHand && (stateLabel === 'River' || stateLabel === 'Showdown')) {
if (await isUsable(analyzeButton)) {
await activateControl(page, analyzeButton);
analysisRequested = true;
playedActions.push({
roomState: roomState.label,
roomDetail: roomState.detail,
action: 'analyze-hand',
});
console.log('[e2e] analyze-hand', roomState.label, roomState.detail ?? '');
continue;
}
}
if (await isUsable(showHandsButton)) {
await activateControl(page, showHandsButton);
playedActions.push({
roomState: roomState.label,
roomDetail: roomState.detail,
action: 'show-hands',
});
console.log('[e2e] show-hands', roomState.label, roomState.detail ?? '');
idleAfterAnalyze = 0;
continue;
}
if (await isUsable(primaryAction)) {
const label = (await primaryAction.textContent())?.trim() ?? 'primary-action';
await activateControl(page, primaryAction);
playedActions.push({
roomState: roomState.label,
roomDetail: roomState.detail,
action: label,
});
console.log('[e2e] action', label, roomState.label, roomState.detail ?? '');
idleAfterAnalyze = 0;
continue;
}
if (analysisRequested) {
if (stateLabel === 'Preflop') {
break;
}
if (stateLabel === 'Showdown' || /Ready for the next deal/i.test(roomState.detail ?? '')) {
idleAfterAnalyze += 1;
if (idleAfterAnalyze >= 3) {
break;
}
}
}
if (stateLabel === 'Preflop' && sawPostflopThisHand) {
sawPostflopThisHand = false;
}
await sleep(500);
}
const analyzeActionCount = playedActions.filter((entry) => entry.action === 'analyze-hand').length;
if (analyzeActionCount === 0) {
const roomState = await readRoomState(page);
throw new Error(
`Never found a postflop river/showdown spot where Analyze could be triggered. Last room state: ${JSON.stringify(roomState)}. Actions taken: ${JSON.stringify(playedActions, null, 2)}`,
);
}
});
await test.step('Navigate to Hands and locate the resulting hand', async () => {
await page.getByRole('link', { name: /Hand Review|Hands/i }).click();
await expect(page).toHaveURL(/\/hands/);
targetHand = await pollUntil<HandListItem | null>({
label: 'new hand history entry',
timeoutMs: 90_000,
intervalsMs: [1_000, 2_000, 3_000, 5_000],
operation: async () => {
const payload = await fetchHands(page, apiToken);
return payload.items.find((item) => item.roomId === roomId) ?? null;
},
accept: (value) => value !== null,
});
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.getByTestId(`hand-list-item-${targetHand.handId}`)).toBeVisible();
await expect(page.getByTestId(`hand-review-status-${targetHand.handId}`)).toContainText(
/Waiting|Queued|Running|Ready|Failed/i,
);
await page.getByTestId(`hand-review-button-${targetHand.handId}`).click();
await expect(page).toHaveURL(new RegExp(`/hands/${targetHand.handId}(?:\\?|$)`));
});
await test.step('Wait for whole-hand analysis to finish', async () => {
if (!targetHand) {
throw new Error('No target hand available for analysis polling.');
}
finalStatus = await pollUntil<HandActionStatusPayload>({
label: `whole-hand analysis for ${targetHand.handId}`,
timeoutMs: ANALYSIS_TIMEOUT_MS,
intervalsMs: [2_000, 3_000, 5_000, 10_000],
operation: async () =>
fetchHandActionStatus(page, apiToken, roomId, targetHand.handId),
accept: (status) =>
status.pipelineStatus === 'complete' &&
status.overview.status === 'complete' &&
status.analysis.status === 'complete' &&
REQUIRED_STREETS.every((street) => hasStreetCoverage(status, street)),
failFast: (status) => {
if (
status.pipelineStatus === 'failed' ||
status.overview.status === 'failed' ||
status.analysis.status === 'failed'
) {
return `${JSON.stringify(status, null, 2)}\nDecision coverage: ${summarizeDecisionCoverage(status)}`;
}
if (status.pipelineStatus === 'blocked') {
return `${JSON.stringify(status, null, 2)}\nDecision coverage: ${summarizeDecisionCoverage(status)}`;
}
if (status.decisions.some((decision) => decision.status === 'failed' || decision.status === 'solver_failed')) {
return `${JSON.stringify(status, null, 2)}\nDecision coverage: ${summarizeDecisionCoverage(status)}`;
}
return null;
},
});
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.getByTestId('overview-progress-list')).toContainText(/Complete/i);
await expect(page.getByTestId('overview-explanation-panel')).toBeVisible();
const overviewText = (await page.getByTestId('overview-explanation-panel').textContent())?.trim() ?? '';
if (!overviewText) {
throw new Error('The overview explanation panel rendered after analysis completion, but it is empty.');
}
});
await test.step('Open and inspect the debug log', async () => {
const debugPanel = page.getByTestId('ai-debug-panel');
if (!(await debugPanel.isVisible().catch(() => false))) {
throw new Error(
'The overview debug log is not visible. Enable `NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI=1` on the web app and `ANALYSIS_DEBUG_HTTP=1` on the API to inspect the analysis debug payload in this flow.',
);
}
await expect(page.getByTestId('ai-debug-copy-button')).toBeVisible();
const debugPayload = page.getByTestId('ai-debug-payload');
if (!(await debugPayload.isVisible().catch(() => false))) {
throw new Error(
'The whole-hand debug payload did not render after analysis completed. The debug panel is required for this end-to-end flow.',
);
}
const payloadText = await debugPayload.inputValue();
if (!payloadText.trim()) {
throw new Error('The debug payload textarea is empty after analysis completion.');
}
let parsedPayload: unknown;
try {
parsedPayload = JSON.parse(payloadText);
} catch (error) {
const detail = error instanceof Error ? error.message : 'unknown parse failure';
throw new Error(`The debug payload is not valid JSON: ${detail}`);
}
const serializedPayload = JSON.stringify(parsedPayload);
if (!targetHand || !serializedPayload.includes(targetHand.handId)) {
throw new Error(
`The debug payload does not reference the analyzed hand ${targetHand?.handId ?? '<unknown>'}. Payload preview: ${payloadText.slice(0, 500)}`,
);
}
if (!/debugEvents|decisionLogs|handPipeline|requestHash/i.test(serializedPayload)) {
throw new Error(
`The debug payload does not include expected analysis debug data. Payload preview: ${payloadText.slice(0, 500)}`,
);
}
});
await test.step('Verify preflop LLM and postflop solver-plus-LLM content on every street', async () => {
const visitStreet = async (
street: (typeof REQUIRED_STREETS)[number],
expectsStrategy: boolean,
) => {
const streetButton = page.getByTestId(`street-btn-${street}`);
if ((await streetButton.count()) > 0) {
await expect(streetButton).toBeVisible();
await streetButton.click();
const summaryPanel = page.getByTestId('street-summary-panel');
if ((await summaryPanel.count()) > 0) {
await expect(summaryPanel).toBeVisible();
const summaryText = (await summaryPanel.textContent())?.trim() ?? '';
if (!summaryText) {
throw new Error(`The ${street} street summary panel rendered, but it is empty.`);
}
}
}
const decisionButton = page.getByRole('button', {
name: `${STREET_LABELS[street]} 1`,
exact: true,
});
await expect(decisionButton).toBeVisible();
await decisionButton.click();
const notes = page.getByTestId('analysis-notes');
await expect(notes).toBeVisible();
const notesText = (await notes.textContent())?.trim() ?? '';
if (!notesText) {
throw new Error(`The ${street} analysis notes container rendered, but no LLM coaching text was present.`);
}
if (expectsStrategy) {
await expect(page.getByTestId('gto-mix-grid')).toBeVisible();
await expect(page.getByTestId('analyze-button')).toBeVisible();
} else {
await expect(page.getByTestId('gto-mix-grid')).toHaveCount(0);
await expect(page.getByTestId('analyze-button')).toHaveCount(0);
}
};
await visitStreet('preflop', false);
await visitStreet('flop', true);
await visitStreet('turn', true);
await visitStreet('river', true);
});
} finally {
await testInfo.attach('played-actions.json', {
body: JSON.stringify(playedActions, null, 2),
contentType: 'application/json',
});
if (targetHand) {
await testInfo.attach('tracked-hand.json', {
body: JSON.stringify(targetHand, null, 2),
contentType: 'application/json',
});
}
if (finalStatus) {
await testInfo.attach('analysis-status.json', {
body: JSON.stringify(finalStatus, null, 2),
contentType: 'application/json',
});
}
await diagnostics.attach(testInfo);
}
});
Get-Content package.jsonExit code: 0
Wall time: 0.2 seconds
Output:
{
"name": "poker-practice-mvp",
"version": "1.0.0",
"description": "Poker practice platform with GTO solver integration",
"private": true,
"packageManager": "pnpm@10.18.2",
"scripts": {
"dev": "node scripts/dev.mjs",
"dev:rebuild": "cross-env DEV_DOCKER_BUILD=1 node scripts/dev.mjs",
"build": "pnpm --filter @poker/shared build && pnpm --filter @poker/table build && pnpm --filter @poker/api build",
"build:all": "pnpm -r build",
"clean": "node scripts/clean.mjs",
"lint": "pnpm -r lint",
"test": "pnpm -r test",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"test:e2e:auth": "node scripts/save-playwright-auth.mjs",
"ci": "pnpm -r build && pnpm -r test && node scripts/smoke-shared.mjs",
"db:migrate": "pnpm --filter @poker/api db:migrate",
"db:generate": "pnpm --filter @poker/api db:generate",
"typecheck": "pnpm -r typecheck",
"worker": "pnpm --filter @poker/api worker:dev",
"start:worker": "pnpm --filter @poker/api run start:worker"
},
"devDependencies": {
"@playwright/test": "1.51.1",
"concurrently": "^9.1.0",
"cross-env": "^10.1.0",
"tsup": "^8.5.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
The e2e flow covers /, /table/[roomId], /hands, and /hands/[handId], and it depends on a live web app on http://localhost:3000, API on http://localhost:3001, the analysis worker being healthy, plus a valid Playwright auth state file. I’m reading the route/components tied to those selectors now, along with the existing worktree diff, so I can correlate any failure with the recent changes instead of treating the test in isolation.
git diff -- apps/web/src/app/page.tsxExit code: 0
Wall time: 0.4 seconds
Output:
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 07bbd48..f6531f3 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -1100,6 +1100,7 @@ export default function Home() {
>
<button
type="button"
+ data-testid="home-start-playing-button"
onClick={() => void handleQuickStart()}
disabled={anyLoading || quickStartLoading}
className={cn(
git diff -- apps/web/src/app/hands/page.tsxExit code: 0
Wall time: 0.3 seconds
Output:
diff --git a/apps/web/src/app/hands/page.tsx b/apps/web/src/app/hands/page.tsx
index b1cf1b0..55e3fd0 100644
--- a/apps/web/src/app/hands/page.tsx
+++ b/apps/web/src/app/hands/page.tsx
@@ -167,13 +167,14 @@ function NetResultBadge({ value }: { value: number | null }) {
);
}
-function StreetBadge({ street }: { street: string | null }) {
+function StreetBadge({ street, className }: { street: string | null; className?: string }) {
const key = (street ?? 'preflop').toLowerCase();
return (
<span
className={cn(
- 'inline-flex rounded-md px-0 py-0 text-[10px] font-semibold uppercase tracking-[0.16em]',
+ 'inline-block whitespace-nowrap rounded-md px-0 py-0 text-[10px] font-semibold uppercase leading-none tracking-[0.16em] align-baseline',
streetColors[key] ?? streetColors.preflop,
+ className,
)}
>
{key}
@@ -184,9 +185,11 @@ function StreetBadge({ street }: { street: string | null }) {
function ReviewStatusBadge({
hand,
saved,
+ className,
}: {
hand: HandListItem;
saved: boolean;
+ className?: string;
}) {
let label = 'Open';
let tone = 'text-slate-400';
@@ -205,7 +208,7 @@ function ReviewStatusBadge({
}
return (
- <span className={cn('inline-flex items-center text-[11px] font-medium', tone)}>
+ <span className={cn('inline-block whitespace-nowrap text-[11px] font-medium leading-none align-baseline', tone, className)}>
{label}
</span>
);
@@ -648,6 +651,8 @@ export default function HandsPage() {
return (
<div
key={hand.handId}
+ data-testid={`hand-list-item-${hand.handId}`}
+ data-hand-id={hand.handId}
onClick={() => router.push(`/hands/${hand.handId}`)}
className="group relative cursor-pointer overflow-hidden rounded-2xl border border-slate-400/[0.08] bg-[#0f1729]/82 transition-colors hover:bg-[#131d34]/88"
>
@@ -718,13 +723,30 @@ export default function HandsPage() {
</div>
</div>
- <div className="flex min-w-[184px] flex-col items-center gap-2 justify-self-end">
- <div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
- <StreetBadge street={hand.streetReached} />
- <span aria-hidden="true" className="text-white/[0.14]">·</span>
- <ReviewStatusBadge hand={hand} saved={isSaved} />
+ <div
+ data-testid={`hand-review-column-${hand.handId}`}
+ className="flex w-[214px] flex-col items-center gap-2 justify-self-end"
+ >
+ <div
+ data-testid={`hand-review-meta-${hand.handId}`}
+ className="flex min-h-[18px] w-full items-center justify-center gap-2 text-center"
+ >
+ <span data-testid={`hand-review-street-${hand.handId}`}>
+ <StreetBadge street={hand.streetReached} className="text-[12px]/none tracking-[0.18em]" />
+ </span>
+ <span
+ aria-hidden="true"
+ data-testid={`hand-review-meta-separator-${hand.handId}`}
+ className="relative top-px place-self-center h-[4px] w-[4px] shrink-0 rounded-full bg-white/[0.18]"
+ />
+ <span data-testid={`hand-review-status-${hand.handId}`}>
+ <ReviewStatusBadge hand={hand} saved={isSaved} className="text-[12px]/none font-semibold" />
+ </span>
</div>
- <div className="flex items-center justify-center gap-2">
+ <div
+ data-testid={`hand-review-actions-${hand.handId}`}
+ className="grid w-full grid-cols-2 gap-2"
+ >
<button
type="button"
onClick={(event) => {
@@ -733,7 +755,7 @@ export default function HandsPage() {
}}
disabled={savePhase !== null}
className={cn(
- 'inline-flex h-9 min-w-[88px] items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50',
+ 'inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50',
isSaved
? 'border-teal-400/18 bg-teal-500/12 text-teal-50 hover:bg-teal-500/16'
: 'border-slate-400/[0.1] bg-[#111b30] text-slate-200 hover:border-slate-300/[0.14] hover:bg-[#16233d]',
@@ -744,11 +766,12 @@ export default function HandsPage() {
</button>
<button
type="button"
+ data-testid={`hand-review-button-${hand.handId}`}
onClick={(event) => {
event.stopPropagation();
router.push(`/hands/${hand.handId}`);
}}
- className="inline-flex h-9 min-w-[98px] items-center justify-center gap-1.5 rounded-xl bg-[#1488d5] px-3.5 text-xs font-semibold text-white shadow-[0_10px_22px_rgba(20,136,213,0.2)] transition-colors hover:bg-[#2799e4]"
+ className="inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-xl bg-[#1488d5] px-3.5 text-xs font-semibold text-white shadow-[0_10px_22px_rgba(20,136,213,0.2)] transition-colors hover:bg-[#2799e4]"
>
Review
<ArrowRight className="h-3.5 w-3.5" />
@@ -848,16 +871,22 @@ export default function HandsPage() {
{typeof hand.finalPot === 'number' ? hand.finalPot : '-'}
</div>
</div>
- <div className="space-y-1">
+ <div
+ data-testid={`hand-mobile-street-${hand.handId}`}
+ className="space-y-1"
+ >
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Street</div>
- <div>
- <StreetBadge street={hand.streetReached} />
+ <div className="flex min-h-[18px] items-center">
+ <StreetBadge street={hand.streetReached} className="text-[12px]/none font-semibold" />
</div>
</div>
- <div className="space-y-1">
+ <div
+ data-testid={`hand-mobile-status-${hand.handId}`}
+ className="space-y-1"
+ >
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</div>
- <div>
- <ReviewStatusBadge hand={hand} saved={isSaved} />
+ <div className="flex min-h-[18px] items-center">
+ <ReviewStatusBadge hand={hand} saved={isSaved} className="text-[12px]/none font-semibold" />
</div>
</div>
</div>
warning: in the working copy of 'apps/web/src/app/hands/page.tsx', LF will be replaced by CRLF the next time Git touches it
git diff -- apps/web/src/app/hands/[handId]/page.tsxExit code: 0
Wall time: 0.4 seconds
Total output lines: 1595
Output:
diff --git a/apps/web/src/app/hands/[handId]/page.tsx b/apps/web/src/app/hands/[handId]/page.tsx
index 375eeb2..25846f2 100644
--- a/apps/web/src/app/hands/[handId]/page.tsx
+++ b/apps/web/src/app/hands/[handId]/page.tsx
@@ -143,6 +143,8 @@ interface ReportScopeRow {
jobMeta?: unknown;
}
+type HandReviewAnalysisStatus = 'waiting' | 'queued' | 'running' | 'complete' | 'failed' | null;
+
interface HandDetail {
id: string;
roomId: string | null;
@@ -152,6 +154,7 @@ interface HandDetail {
finalPot: number | null;
isComplete: boolean;
runoutAware?: boolean;
+ analysisStatus?: HandReviewAnalysisStatus;
events: HandEventRow[];
decisions: DecisionRow[];
reportsByScope?: Record<string, ReportScopeRow | null>;
@@ -921,6 +924,60 @@ function getOverviewServerMessage(payload: HandActionStatusPayload | null): stri
return null;
}
+function hasOverviewAnalysisActivity(payload: HandActionStatusPayload | null): boolean {
+ if (!payload) {
+ return false;
+ }
+ if (payload.overview?.status === 'queued' || payload.overview?.status === 'running') {
+ return true;
+ }
+ if (payload.pipelineStatus === 'queued' || payload.pipelineStatus === 'running') {
+ return true;
+ }
+ if (payload.analysis.status === 'queued' || payload.analysis.status === 'running') {
+ return true;
+ }
+ return (
+ payload.analyzeHand.status === 'waiting' ||
+ payload.analyzeHand.status === 'pending' ||
+ payload.analyzeHand.status === 'queued' ||
+ payload.analyzeHand.status === 'running'
+ );
+}
+
+function hasOverviewAnalysisUpdate(payload: HandActionStatusPayload | null): boolean {
+ if (!payload) {
+ return false;
+ }
+ return hasOverviewAnalysisActivity(payload) || payload.analyzeHand.status !== 'idle';
+}
+
+function hasTerminalWholeHandAnalysisStatus(status: HandReviewAnalysisStatus | undefined): boolean {
+ return status === 'complete' || status === 'failed';
+}
+
+function shouldAttemptLegacyWholeHandSummaryFallback(params: {
+ report: ReportScopeRow | null | undefined;
+ handAnalysisStatus?: HandReviewAnalysisStatus;
+ overviewStatusPayload?: HandActionStatusPayload | null;
+}): boolean {
+ if (extractWholeHandText(params.report, null)) {
+ return false;
+ }
+
+ const overviewStatus = parseOverviewPipelineStatus(params.overviewStatusPayload ?? null);
+ if (overviewStatus) {
+ return overviewStatus.status === 'complete' || overviewStatus.status === 'failed';
+ }
+
+ const payloadAnalysisStatus = params.overviewStatusPayload?.analysis?.status;
+ if (payloadAnalysisStatus === 'complete' || payloadAnalysisStatus === 'failed') {
+ return true;
+ }
+
+ return hasTerminalWholeHandAnalysisStatus(params.handAnalysisStatus);
+}
+
type DecisionPipelineStatus =
| 'queued'
| 'running'
@@ -1288,6 +1345,10 @@ function mergeDebugEvents(...groups: DebugEvent[][]): DebugEvent[] {
return Array.from(merged.values());
}
+function hasMeaningfulDebugEvents(events: DebugEvent[]): boolean {
+ return events.some((event) => event.level === 'warn' || event.level === 'error');
+}
+
function stripDebugEventFields(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => stripDebugEventFields(item));
@@ -1331,116 +1392,99 @@ function toDecisionStatusSnapshot(response: {
};
}
-function hasMeaningfulDebugEvents(events: DebugEvent[]): boolean {
- return events.some((event) => event.level === 'warn' || event.level === 'error');
+function isMissingSavedPayloadMessage(value: string | null | undefined): boolean {
+ if (!value) {
+ return false;
+ }
+ return /result(?: payload)? was not saved|no recommendation payload was saved/i.test(value);
}
-function renderDebugEventList(events: DebugEvent[], emptyMessage: string): React.ReactNode {
- if (events.length === 0) {
- return (
- <div className="rounded border border-white/[0.08] bg-black/20 px-2 py-1 text-gray-400">
- {emptyMessage}
- </div>
- );
+function hasMissingSavedPayloadIssue(params: {
+ analysisPresent: boolean;
+ pipelineEntry: Pick<DecisionPipelineEntry, 'status'> | null;
+ statusSnapshot: DecisionStatusSnapshot | null;
+}): boolean {
+ if (params.analysisPresent) {
+ return false;
+ }
+ if (
+ params.pipelineEntry?.status === 'complete' ||
+ params.pipelineEntry?.status === 'llm_only'
+ ) {
+ return true;
+ }
+ if (params.statusSnapshot?.status !== 'failed') {
+ return false;
}
return (
- <div className="space-y-1">
- {events.map((event, index) => {
- const dataRecord = event.data ?? null;
- const solverErrorCode =
- dataRecord && typeof dataRecord.solverErrorCode === 'string'
- ? dataRecord.solverErrorCode
- : null;
- const solverStderrTailPreview =
- dataRecord && typeof dataRecord.solverStderrTailPreview === 'string'
- ? dataRecord.solverStderrTailPreview
- : null;
- const stderrTail =
- dataRecord && typeof dataRecord.stderrTail === 'string' ? dataRecord.stderrTail : null;
- const dataWithoutStderr =
- dataRecord && Object.keys(dataRecord).length > 0
- ? (() => {
- const clone = { ...dataRecord } as Record<string, unknown>;
- delete clone.stderrTail;
- delete clone.solverErrorCode;
- delete clone.solverStderrTailPreview;
- return Object.keys(clone).length > 0 ? clone : null;
- })()
- : null;
- return (
- <div
- key={`${event.ts}-${event.source}-${event.message}-${index}`}
- className={cn(
- 'rounded border px-2 py-1 text-[11px]',
- event.level === 'error'
- ? 'border-rose-400/30 bg-rose-500/10'
- : event.level === 'warn'
- ? 'border-amber-300/30 bg-amber-500/10'
- : 'border-white/[0.08] bg-black/20',
- )}
- >
- <div className="flex items-center justify-between gap-2 text-gray-400">
- <span>{event.ts}</span>
- <span
- className={cn(
- 'rounded border px-1 py-0.5 text-[10px] font-semibold uppercase tracking-[0.04em]',
- event.source === 'solver-service'
- ? 'border-sky-300/40 bg-sky-500/15 text-sky-200'
- : event.source === 'api-status'
- ? 'border-violet-300/40 bg-violet-500/15 text-violet-200'
- : 'border-white/[0.16] bg-white/[0.04] text-gray-300',
- )}
- >
- {event.source}
- </span>
- <span
- className={
- event.level === 'error'
- ? 'text-rose-300'
- : event.level === 'warn'
- ? 'text-amber-300'
- : 'text-sky-300'
- }
- >
- {event.level}
- </span>
- </div>
- <div className="text-gray-200">{event.message}</div>
- {solverErrorCode ? (
- <div className="mt-1 text-[10px] text-amber-200">solverErrorCode: {solverErrorCode}</div>
- ) : null}
- {solverStderrTailPreview ? (
- <details className="mt-1 rounded border border-white/[0.08] bg-black/30 p-1">
- <summary className="cursor-pointer text-[10px] font-semibold text-amber-200">
- stderr preview
- </summary>
- <pre className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap break-words text-[10px] text-amber-100">
- {solverStderrTailPreview}
- </pre>
- </details>
- ) : null}
- {dataWithoutStderr ? (
- <pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap break-words rounded border border-white/[0.08] bg-black/30 p-1 text-[10px] text-gray-300">
- {JSON.stringify(dataWithoutStderr, null, 2)}
- </pre>
- ) : null}
- {stderrTail ? (
- <details className="mt-1 rounded border border-white/[0.08] bg-black/30 p-1">
- <summary className="cursor-pointer text-[10px] font-semibold text-amber-200">
- stderr tail
- </summary>
- <pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-words text-[10px] text-amber-100">
- {stderrTail}
- </pre>
- </details>
- ) : null}
- </div>
- );
- })}
- </div>
+ isMissingSavedPayloadMessage(params.statusSnapshot.serverErrorMessage) ||
+ isMissingSavedPayloadMessage(params.statusSnapshot.errorMessage)
);
}
+function mergeDecisionPipelineEntryWithStatusSnapshot(params: {
+ entry: DecisionPipelineEntry | null;
+ statusSnapshot: DecisionStatusSnapshot | null;
+ analysisPresent: boolean;
+}): DecisionPipelineEntry | null {
+ const { entry, statusSnapshot, analysisPresent } = params;
+ if (!entry || !statusSnapshot || !statusSnapshot.ok || statusSnapshot.status === null) {
+ return entry;
+ }
+
+ let nextStatus: DecisionPipelineStatus | null = null;
+ if (statusSnapshot.status === 'queued' || statusSnapshot.status === 'running') {
+ nextStatus = statusSnapshot.status;
+ } else if (statusSnapshot.status === 'failed') {
+ nextStatus =
+ statusSnapshot.serverStatus === 'solver_failed' ||
+ statusSnapshot.serverStatus === 'solver_required' ||
+ statusSnapshot.solverErrorCode === 'hero_combo_unavailable'
+ ? 'solver_failed'
+ : 'failed';
+ } else if (
+ statusSnapshot.status === 'ready' &&
+ !analysisPresent &&
+ hasMissingSavedPayloadIssue({
+ analysisPresent,
+ pipelineEntry: entry,
+ statusSnapshot,
+ })
+ ) {
+ nextStatus = 'failed';
+ }
+
+ if (!nextStatus || nextStatus === entry.status) {
+ return entry;
+ }
+
+ const nextErrorMessage =
+ statusSnapshot.serverErrorMessage ?? statusSnapshot.errorMessage ?? entry.errorMessage;
+ const nextStage =
+ statusSnapshot.serverStatus ??
+ (nextStatus === 'solver_failed'
+ ? 'solver_failed'
+ : nextStatus === 'failed'
+ ? 'failed'
+ : entry.stage);
+
+ return {
+ ...entry,
+ status: nextStatus,
+ stage: nextStage,
+ errorMessage:
+ nextStatus === 'failed' || nextStatus === 'solver_failed'
+ ? nextErrorMessage
+ : entry.errorMessage,
+ solverAvailable: false,
+ solverError:
+ nextStatus === 'solver_failed'
+ ? statusSnapshot.serverErrorMessage ?? statusSnapshot.errorMessage ?? entry.solverError
+ : entry.solverError,
+ solverErrorCode: statusSnapshot.solverErrorCode ?? entry.solverErrorCode,
+ };
+}
+
function overviewProgressMessage(
overview: OverviewPipelineStatus | null,
counts: PipelineCounts | null,
@@ -1598,7 +1642,7 @@ function collectMeaningfulText(value: unknown): string[] {
return Object.values(record).flatMap((item) => collectMeaningfulText(item));
}
-function extractWholeHandText(report: ReportScopeRow | null | undefined, summaryFallback: string | null): string | null {
+function extractReportText(report: ReportScopeRow | null | undefined): string | null {
const reportSource = asString(asRecord(report?.jobMeta)?.source);
const groundedFallback = asBoolean(asRecord(report?.jobMeta)?.groundedFallback) ?? false;
const fromReport =
@@ -1607,6 +1651,15 @@ function extractWholeHandText(report: ReportScopeRow | null | undefined, summary
return fromReport.join('\n\n');
}
+ return null;
+}
+
+function extractWholeHandText(report: ReportScopeRow | null | undefined, summaryFallback: string | null): string | null {
+ const fromReport = extractReportText(report);
+ if (fromReport) {
+ return fromReport;
+ }
+
const fallback = summaryFallback?.trim();
return fallback ? fallback : null;
}
@@ -1652,18 +1705,14 @@ export default function HandReviewPage({ params }: { params: Promise<{ handId: s
const [coachExpanded, setCoachExpanded] = useState(false);
const [mobileAnalysisOpen, setMobileAnalysisOpen] = useState(false);
const [aiDebugCopyState, setAiDebugCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
- const [debugCopyState, setDebugCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
- const [debugSectionOpen, setDebugSectionOpen] = useState(false);
- const [debugNewestFirst, setDebugNewestFirst] = useState(true);
- const [debugSourceFilter, setDebugSourceFilter] = useState<DebugSourceFilter>('all');
- const [expandedOverviewDebugDecisionId, setExpandedOverviewDebugDecisionId] = useState<string | null>(null);
const [decisionDebugById, setDecisionDebugById] = useState<Record<string, DebugEvent[]>>({});
const [decisionDebugErrorById, setDecisionDebugErrorById] = useState<Record<string, string | null>>({});
const [decisionStatusSnapshotById, setDecisionStatusSnapshotById] = useState<Record<string, DecisionStatusSnapshot>>({});
- const [loadingDecisionDebugId, setLoadingDecisionDebugId] = useState<string | null>(null);
+ const [loadingDecisionDebugById, setLoadingDecisionDebugById] = useState<Record<string, true>>({});
const pollRunRef = useRef(0);
const activePollAbortRef = useRef<AbortController | null>(null);
+ const loadingDecisionDebugIdsRef = useRef<Set<string>>(new Set());
const previousHandIdRef = useRef<string | null>(null);
const mountedRef = useRef(true);
const isWideDesktopAnalysisLayout = useMediaQuery('(min-width: 1200px)');
@@ -1675,7 +1724,6 @@ export default function HandReviewPage({ params }: { params: Promise<{ handId: s
'(min-width: 1024px) and (max-height: 640px) and (orientation: landscape)',
);
const isShortPhoneAnalysisLayout = useMediaQuery('(max-width: 699px) and (max-height: 760px)');
- const debugSectionRef = useRef<HTMLDivElement | null>(null);
const replayTableSizeMode: React.ComponentProps<typeof TableReplay>['sizeMode'] = isDesktopAnalysisLayout
? 'analysis-desktop-compact'
: isStackedTabletAnalysisLayout
@@ -1790,6 +1838,28 @@ export default function HandReviewPage({ params }: { params: Promise<{ handId: s
[apiToken, handId],
);
+ const maybeFetchWholeHandSummary = useCallback(
+ async (params: {
+ report: ReportScopeRow | null | undefined;
+ handAnalysisStatus?: HandReviewAnalysisStatus;
+ overviewStatusPayload?: HandActionStatusPayload | null;
+ signal?: AbortSignal;
+ }): Promise<{ attempted: boolean; summary: string | null }> => {
+ if (!shouldAttemptLegacyWholeHandSummaryFallback(params)) {
+ return {
+ attempted: false,
+ summary: null,
+ };
+ }
+
+ return {
+ attempted: true,
+ summary: await fetchWholeHandSummary(params.signal),
+ };
+ },
+ [fetchWholeHandSummary],
+ );
+
const refreshDetailSilently = useCallback(async (signal?: AbortSignal): Promise<HandDetailResponse | null> => {
try {
const next = await fetchHandDetail(signal);
@@ -1963,28 +2033,48 @@ export default function HandReviewPage({ params }: { params: Promise<{ handId: s
const loadDecisionDebugEvents = useCallback(
async (decisionId: string) => {
- setLoadingDecisionDebugId(decisionId);
- const response = await fetchDecisionStatus(decisionId);
- setDecisionStatusSnapshotById((prev) => ({
+ if (loadingDecisionDebugIdsRef.current.has(decisionId)) {
+ return;
+ }
+
+ loadingDecisionDebugIdsRef.current.add(decisionId);
+ setLoadingDecisionDebugById((prev) => ({
...prev,
- [decisionId]: toDecisionStatusSnapshot(response),
+ [decisionId]: true,
}));
- if (response.ok) {
- setDecisionDebugById((prev) => ({
- ...prev,
- [decisionId]: response.debugEvents,
- }));
- setDecisionDebugErrorById((prev) => ({
- ...prev,
- [decisionId]: null,
- }));
- } else {
- setDecisionDebugErrorById((prev) => ({
+
+ try {
+ const response = await fetchDecisionStatus(decisionId);
+ setDecisionStatusSnapshotById((prev) => ({
...prev,
- [decisionId]: response.errorMessage ?? 'Failed to load debug events',
+ [decisionId]: toDecisionStatusSnapshot(response),
}));
+ if (response.ok) {
+ setDecisionDebugById((prev) => ({
+ ...prev,
+ [decisionId]: response.debugEvents,
+ }));
+ setDecisionDebugErrorById((prev) => ({
+ ...prev,
+ [decisionId]: null,
+ }));
+ } else {
+ setDecisionDebugErrorById((prev) => ({
+ ...prev,
+ [decisionId]: response.errorMessage ?? 'Failed to load debug events',
+ }));
+ }
+ } finally {
+ loadingDecisionDebugIdsRef.current.delete(decisionId);
+ setLoadingDecisionDebugById((prev) => {
+ if (!(decisionId in prev)) {
+ return prev;
+ }
+ const next = { ...prev };
+ delete next[decisionId];
+ return next;
+ });
}
- setLoadingDecisionDebugId((current) => (current === decisionId ? null : current));
},
[fetchDecisionStatus],
);
@@ -1996,14 +2086,115 @@ export default function HandReviewPage({ params }: { params: Promise<{ handId: s
const ac = new AbortController();
void (async () => {
const statusResponse = await fetchOverviewActionStatus(ac.signal);
- if (!ac.signal.aborted && statusResponse.ok) {
- setOverviewStatusPayload(statusResponse.payload);
+ if (ac.signal.aborted || !statusResponse.ok) {
+ return;
+ }
+
+ setOverviewStatusPayload(statusResponse.payload);
+
+ if (!hasOverviewAnalysisUpdate(statusResponse.payload)) {
+ return;
+ }
+
+ const refreshedDetail = await refreshDetailSilently(ac.signal);
+ const refreshedSummary = await maybeFetchWholeHandSummary({
+ report: (refreshedDetail?.hand.reportsByScope?.WHOLE_HAND ?? detail?.hand.reportsByScope?.WHOLE_HAND ?? null) as
+ | ReportScopeRow
+ | null,
+ handAnalysisStatus: refreshedDetail?.hand.analysisStatus ?? detail?.hand.analysisStatus ?? null,
+ overviewStatusPayload: statusResponse.payload,
+ signal: ac.signal,
+ });
+
+ if (!ac.signal.aborted && refreshedSummary.attempted) {
+ setWholeHandSummaryFallback(refreshedSummary.summary);
}
})();
return () => {
ac.abort();
};
- }, [apiToken, detail?.hand.id, fetchOverviewActionStatus]);
+ }, [
+ apiToken,
+ detail?.hand.id,
+ fetchOverviewActionStatus,
+ maybeFetchWholeHandSummary,
+ refreshDetailSilently,
+ ]);
+
+ const shouldAutoRefreshOverview = useMemo(() => {
+ if (!apiToken || !detail?.hand.id) {
+ return false;
+ }
+ if (analysisRequestState.phase !== 'idle') {
+ return false;
+ }
+ return hasOverviewAnalysisActivity(overviewStatusPayload);
+ }, [analysisRequestState.phase, apiToken, detail?.hand.id, overviewStatusPayload]);
+
+ useEffect(() => {
+ if (!shouldAutoRefreshOverview) {
+ return;
+ }
+
+ const pollRunId = pollRunRef.current + 1;
+ pollRunRef.current = pollRunId;
+ const pollController = replaceActivePollAbortController();
+ const deadline = Date.now() + 10 * 60_000;
+
+ void (async () => {
+ while (Date.now() < deadline) {
+ await sleep(2000);
+
+ if (!mountedRef.current || pollRunRef.current !== pollRunId) {
+ return;
+ }
+
+ const [statusResponse, refreshedDetail] = await Promise.all([
+ fetchOverviewActionStatus(pollController.signal),
+ refreshDetailSilently(pollController.signal),
+ ]);
+…6729 tokens truncated… debugNewestFirst,
- ),
- error: decisionDebugErrorById[row.decisionId] ?? null,
- };
- }),
- };
- const rawDebugCopyPayload = JSON.stringify(visibleLogsForCopy, null, 2);
-
- return (
- <div
- ref={debugSectionRef}
- data-testid="debug-section"
- className="rounded-md border border-white/[0.14] bg-white/[0.03] p-3 text-xs text-gray-200"
- >
- <div className="flex flex-wrap items-center gap-2">
- <button
- type="button"
- data-testid="debug-section-toggle"
- onClick={() => setDebugSectionOpen((prev) => !prev)}
- className="rounded border border-white/[0.16] bg-white/[0.03] px-2 py-1 text-[11px] font-semibold text-gray-100 hover:bg-white/[0.08]"
- >
- {debugSectionOpen ? 'Hide Debug Log' : 'Debug Log'}
- </button>
- </div>
- {debugSectionOpen ? (
- <div className="mt-2 space-y-3">
- <div className="flex flex-wrap items-center gap-2">
- <button
- type="button"
- data-testid="debug-log-order-toggle"
- onClick={() => setDebugNewestFirst((prev) => !prev)}
- className="rounded border border-white/[0.16] bg-white/[0.03] px-2 py-1 text-[11px] font-semibold text-gray-100 hover:bg-white/[0.08]"
- >
- {debugNewestFirst ? 'Newest First' : 'Oldest First'}
- </button>
- <label className="flex items-center gap-1 text-[11px] text-gray-300">
- Source
- <select
- data-testid="debug-log-source-filter"
- value={debugSourceFilter}
- onChange={(event) =>
- setDebugSourceFilter(event.target.value as DebugSourceFilter)
- }
- className="rounded border border-white/[0.16] bg-black/30 px-1.5 py-1 text-[11px] text-gray-100"
- >
- <option value="all">All</option>
- <option value="api-status">api-status</option>
- <option value="api-worker">api-worker</option>
- <option value="solver-service">solver-service</option>
- </select>
- </label>
- <button
- type="button"
- data-testid="debug-section-copy-button"
- onClick={() => {
- void (async () => {
- const copied = await copyTextToClipboard(rawDebugCopyPayload);
- setDebugCopyState(copied ? 'copied' : 'failed');
- })();
- }}
- className="rounded border border-white/[0.16] bg-white/[0.03] px-2 py-1 text-[11px] font-semibold text-gray-100 hover:bg-white/[0.08]"
- >
- {debugCopyState === 'copied'
- ? 'Copied'
- : debugCopyState === 'failed'
- ? 'Copy failed'
- : 'Copy Raw Logs'}
- </button>
- </div>
-
- <div className="space-y-2">
- <div className="font-semibold text-gray-100">Pipeline Events</div>
- {renderEvents(pipelineDebugEvents, 'No pipeline debug events match current filters.')}
- </div>
-
- {selection.kind === 'overview' || selection.kind === 'street' ? (
- <div className="space-y-2">
- <div className="font-semibold text-gray-100">
- {selection.kind === 'street'
- ? `Decision Previews (${streetLabel(selection.street)})`
- : 'Decision Previews'}
- </div>
- {decisionRowsForDebugSelection.length === 0 ? (
- <div className="rounded border border-white/[0.08] bg-black/20 px-2 py-1 text-gray-400">
- No decision events yet.
- </div>
- ) : (
- <div className="space-y-1">
- {decisionRowsForDebugSelection.map((row) => {
- const isExpanded = expandedOverviewDebugDecisionId === row.decisionId;
- const previewEvents = row.debugEventsPreview ?? [];
- const loadedEvents = decisionDebugById[row.decisionId] ?? [];
- const displayEvents =
- isExpanded && loadedEvents.length > 0 ? loadedEvents : previewEvents;
- const decisionError = decisionDebugErrorById[row.decisionId];
- return (
- <div
- key={`debug-row-${row.decisionId}`}
- className="rounded border border-white/[0.08] bg-black/20 p-2"
- >
- <div className="flex items-center justify-between gap-2">
- <span className="font-semibold text-gray-100">{row.label}</span>
- <button
- type="button"
- data-testid={`debug-decision-expand-${row.decisionId}`}
- onClick={() => toggleOverviewDecisionDebug(row.decisionId)}
- className="rounded border border-white/[0.16] bg-white/[0.03] px-2 py-1 text-[11px] font-semibold text-gray-100 hover:bg-white/[0.08]"
- >
- {isExpanded ? 'Hide Full' : 'View Full'}
- </button>
- </div>
- {renderEvents(previewEvents, 'No preview events match current filters.')}
- {isExpanded ? (
- <div className="mt-1 space-y-1">
- {loadingDecisionDebugId === row.decisionId ? (
- <div className="text-gray-400">Loading full debug...</div>
- ) : null}
- {decisionError ? (
- <div className="text-rose-300">{decisionError}</div>
- ) : (
- renderEvents(
- displayEvents,
- 'No full decision debug events match current filters.',
- )
- )}
- </div>
- ) : null}
- </div>
- );
- })}
- </div>
- )}
- </div>
- ) : null}
-
- {selection.kind === 'decision' ? (
- <div className="space-y-2">
- <div className="flex items-center justify-between gap-2">
- <div className="font-semibold text-gray-100">Decision Events</div>
- <button
- type="button"
- onClick={() => void loadDecisionDebugEvents(selection.decisionId)}
- className="rounded border border-white/[0.16] bg-white/[0.03] px-2 py-1 text-[11px] font-semibold text-gray-100 hover:bg-white/[0.08]"
- >
- Refresh
- </button>
- </div>
- {loadingDecisionDebugId === selection.decisionId ? (
- <div className="text-gray-400">Loading debug events...</div>
- ) : null}
- {selectedDecisionError ? (
- <div className="text-rose-300">{selectedDecisionError}</div>
- ) : (
- renderEvents(selectedDecisionEvents, 'No debug events match current filters.')
- )}
- </div>
- ) : null}
- </div>
- ) : null}
- </div>
- );
- }, [
- debugCopyState,
- debugNewestFirst,
- debugSectionOpen,
- debugSourceFilter,
- decisionDebugById,
- decisionDebugErrorById,
- decisionRowsForDebugSelection,
- expandedOverviewDebugDecisionId,
- loadDecisionDebugEvents,
- loadingDecisionDebugId,
- pipelineDebugEvents,
- selection,
- selectedDecisionEffectiveDebugEvents,
- toggleOverviewDecisionDebug,
- ]);
-
const rightPanelContent = useMemo(() => {
if (!hand) {
return null;
@@ -4300,7 +4250,10 @@ export default function HandReviewPage({ params }: { params: Promise<{ handId: s
if (selection.kind === 'overview') {
if (wholeHandText) {
return (
- <div className="rounded-lg border border-white/[0.08] bg-white/[0.03] p-4 text-sm text-gray-200 whitespace-pre-wrap">
+ <div
+ data-testid="overview-explanation-panel"
+ className="rounded-lg border border-white/[0.08] bg-white/[0.03] p-4 text-sm text-gray-200 whitespace-pre-wrap"
+ >
{wholeHandText}
</div>
);
@@ -4309,32 +4262,13 @@ export default function HandReviewPage({ params }: { params: Promise<{ handId: s
}
if (selection.kind === 'street') {
- return (
- <div className="rounded-lg border border-white/[0.08] bg-white/[0.03] p-4 text-sm text-gray-300">
- {currentStreetHint}
- </div>
- );
+ return null;
}
if (!selectedDecision) {
return null;
}
- const missingCompletedPayload =
- !selectedDecisionAnalysis &&
- (selectedDecisionPipelineEntry?.status === 'complete' ||
- selectedDecisionPipelineEntry?.status === 'llm_only');
- if (missingCompletedPayload) {
- return (
- <div className="rounded-lg border border-amber-300/40 bg-amber-500/10 p-4 text-sm text-amber-100">
- <div className="font-semibold">Analysis unavailable</div>
- <div className="mt-1">
- This decision is marked complete, but no recommendation payload was saved.
- </div>
- </div>
- );
- }
-
if (selectedDecisionHeroComboUnavailable) {
return (
<div className="space-y-3 rounded-lg border border-amber-300/40 bg-amber-500/10 p-4 text-sm text-amber-100">
@@ -4365,34 +4299,8 @@ export default function HandReviewPage({ params }: { params: Promise<{ handId: s
}
if (showDecisionChart && selectedDecisionAnalysis) {
- const explanationDebugEvents = filterAndSortDebugEvents(
- selectedDecisionEffectiveDebugEvents.filter(
- (event) => event.level === 'warn' || event.level === 'error',
- ),
- 'all',
- true,
- );
return (
<div className="space-y-3">
- {selectedDecisionExplanationFailure ? (
- <div className="rounded-lg border border-amber-300/40 bg-amber-500/10 p-3 text-sm text-amber-100">
- <div className="font-semibold">Explanation failed</div>
- <div className="mt-1">
- Reason: {selectedDecisionExplanationFailure}. Solver strategy is still available.
- </div>
- <details className="mt-2 rounded border border-amber-200/30 bg-black/20 p-2">
- <summary className="cursor-pointer text-xs font-semibold text-amber-100">
- Explanation Log
- </summary>
- <div className="mt-2">
- {renderDebugEventList(
- explanationDebugEvents,
- 'No explanation debug events were recorded for this failure.',
- )}
- </div>
- </details>
- </div>
- ) : null}
<AnalysisDrawerContent
title={formatDecisionTitle(selectedDecision)}
actionList={decisionMixActions}
@@ -4412,14 +4320,11 @@ export default function HandReviewPage({ params }: { params: Promise<{ handId: s
/>
);
}, [
- currentStreetHint,
decisionMixActions,
hand,
selectedDecision,
selectedDecisionAnalysis,
- selectedDecisionEffectiveDebugEvents,
selectedDecisionHeroComboUnavailable,
- selectedDecisionExplanationFailure,
selectedDecisionNotes,
selectedDecisionRecommendedActionText,
selectedDecisionStreet,
@@ -4489,20 +4394,22 @@ export default function HandReviewPage({ params }: { params: Promise<{ handId: s
<h2 className="truncate text-sm font-semibold text-white">{selectionDescriptor}</h2>
</div>
</div>
- <button
- type="button"
- data-testid="analyze-button"
- onClick={handleAnalyzeClick}
- disabled={analyzeDisabled}
- className={cn(
- 'h-9 shrink-0 rounded-lg px-4 text-sm font-semibold transition-all',
- analyzeDisabled
- ? 'cursor-not-allowed bg-gray-700/60 text-gray-500'
- : 'bg-indigo-600 text-white shadow-md shadow-indigo-600/20 hover:-translate-y-px hover:bg-indigo-500',
- )}
- >
- {analyzeButtonLabel}
- </button>
+ {canAnalyzeSelection ? (
+ <button
+ type="button"
+ data-testid="analyze-button"
+ onClick={handleAnalyzeClick}
+ disabled={analyzeDisabled}
+ className={cn(
+ 'h-9 shrink-0 rounded-lg px-4 text-sm font-semibold transition-all',
+ analyzeDisabled
+ ? 'cursor-not-allowed bg-gray-700/60 text-gray-500'
+ : 'bg-indigo-600 text-white shadow-md shadow-indigo-600/20 hover:-translate-y-px hover:bg-indigo-500',
+ )}
+ >
+ {analyzeButtonLabel}
+ </button>
+ ) : null}
</div>
{/* Tab bar */}
@@ -4548,9 +4455,8 @@ export default function HandReviewPage({ params }: { params: Promise<{ handId: s
{overviewProgressPanel}
{decisionPipelinePanel}
{analysisStatusPanel}
- {aiDebugPanel}
{rightPanelContent}
- {showDebugSectionPanel ? debugSectionPanel : null}
+ {aiDebugPanel}
</div>
) : coachScope ? (
<CoachPanel
@@ -4777,75 +4683,40 @@ export default function HandReviewPage({ params }: { params: Promise<{ handId: s
Overview
</button>
- {STREETS.map((street, streetIndex) => {
- const streetDecisions = decisionsByStreet[street];
- const onlyDecision = streetDecisions.length === 1 ? streetDecisions[0] : null;
- const nextSelection: Selection =
- streetDecisions.length === 1
- ? { kind: 'decision', decisionId: streetDecisions[0].id }
- : { kind: 'street', street };
- const showStreetButton = streetDecisions.length <= 1;
-
- const isStreetReached = street === 'preflop' ? true : reachedStreets[street];
- const streetIsActive =
- selection.kind === 'street'
- ? selection.street === street
- : selection.kind === 'decision' && onlyDecision !== null && selection.decisionId === onlyDecision.id;
-
- const anyDecisionActive =
- selection.kind === 'decision' &&
- streetDecisions.some((d) => d.id === selection.decisionId);
-
+ {streetNavigationGroups.map((group, groupIndex) => {
return (
- <React.Fragment key={street}>
- {/* Connector line */}
- <div className={cn(
- 'hidden h-px w-4 md:block lg:w-6',
- isStreetReached ? 'bg-white/[0.12]' : 'bg-white/[0.04]',
- )} />
-
- {showStreetButton ? (
- <button
- type="button"
- data-testid={`street-btn-${street}`}
- aria-disabled={!isStreetReached ? 'true' : undefined}
- onClick={() => setSelection(nextSelection)}
- className={cn(
- bottomBarStreetButtonClass,
- streetIsActive
- ? 'bg-indigo-500/20 text-white shadow-[inset_0_0_0_1px_rgba(129,140,248,0.3)]'
- : streetDecisions.length > 0
- ? 'bg-sky-500/[0.08] text-sky-100 shadow-[inset_0_0_0_1px_rgba(125,211,252,0.16)] hover:bg-sky-500/[0.12] hover:text-white'
- : 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200',
- !isStreetReached && 'opacity-40 cursor-default',
- )}
- >
- {streetLabel(street)}
- </button>
+ <React.Fragment key={group.street}>
+ {groupIndex > 0 ? (
+ <div className="hidden h-px w-4 bg-white/[0.12] md:block lg:w-6" />
) : null}
- {streetDecisions.length >= 2
- ? streetDecisions.map((decision, index) => (
- <React.Fragment key={decision.id}>
- {index > 0 && (
- <div className="hidden h-px w-2 bg-white/[0.08] md:block" />
+ {group.items.map((item, itemIndex) => {
+ const isActive =
+ selection.kind === 'decision' && selection.decisionId === item.decisionId;
+
+ return (
+ <React.Fragment key={item.key}>
+ {itemIndex > 0 ? (
+ <div className="hidden h-px w-2 bg-white/[0.08] md:block" />
+ ) : null}
+ <button
+ type="button"
+ data-testid={item.testId}
+ onClick={() => setSelection({ kind: 'decision', decisionId: item.decisionId })}
+ className={cn(
+ item.useStreetButtonStyle ? bottomBarStreetButtonClass : bottomBarDecisionButtonClass,
+ isActive
+ ? item.useStreetButtonStyle
+ ? 'bg-indigo-500/20 text-white shadow-[inset_0_0_0_1px_rgba(129,140,248,0.3)]'
+ : 'bg-sky-500/20 text-white shadow-[inset_0_0_0_1px_rgba(125,211,252,0.3)]'
+ : 'bg-sky-500/[0.08] text-sky-100 shadow-[inset_0_0_0_1px_rgba(125,211,252,0.16)] hover:bg-sky-500/[0.12] hover:text-white',
)}
- <button
- type="button"
- data-testid={`decision-btn-${decision.id}`}
- onClick={() => setSelection({ kind: 'decision', decisionId: decision.id })}
- className={cn(
- bottomBarDecisionButtonClass,
- selection.kind === 'decision' && selection.decisionId === decision.id
- ? 'bg-sky-500/20 text-white shadow-[inset_0_0_0_1px_rgba(125,211,252,0.3)]'
- : 'bg-sky-500/[0.08] text-sky-100 shadow-[inset_0_0_0_1px_rgba(125,211,252,0.16)] hover:bg-sky-500/[0.12] hover:text-white',
- )}
- >
- {streetLabel(street)} {index + 1}
- </button>
- </React.Fragment>
- ))
- : null}
+ >
+ {item.label}
+ </button>
+ </React.Fragment>
+ );
+ })}
</React.Fragment>
);
})}
warning: in the working copy of 'apps/web/src/app/hands/[handId]/page.tsx', LF will be replaced by CRLF the next time Git touches it
git diff -- apps/web/src/app/table/[roomId]/page.tsxExit code: 0
Wall time: 0.4 seconds
Output:
diff --git a/apps/web/src/app/table/[roomId]/page.tsx b/apps/web/src/app/table/[roomId]/page.tsx
index e21b5b7..3b688be 100644
--- a/apps/web/src/app/table/[roomId]/page.tsx
+++ b/apps/web/src/app/table/[roomId]/page.tsx
@@ -1917,7 +1917,7 @@ function TablePageClient({
{/* ---- Enter seat ---- */}
{isEnterSeatModalOpen && (
<div className={modalOverlay}>
- <div className={cn(modalCard, 'max-w-md p-6')}>
+ <div data-testid="enter-seat-modal" className={cn(modalCard, 'max-w-md p-6')}>
<h2 className="text-base font-semibold text-white mb-4">
{isHost ? 'Enter Seat' : 'Request Seat'} {pendingSeatNo !== null ? pendingSeatNo + 1 : ''}
</h2>
@@ -1927,6 +1927,7 @@ function TablePageClient({
Name
</label>
<input
+ data-testid="enter-seat-name-input"
id="seat-name-input"
type="text"
value={tableName}
@@ -1947,6 +1948,7 @@ function TablePageClient({
Stack
</label>
<input
+ data-testid="enter-seat-stack-input"
id="seat-stack-input"
type="number"
min={1}
@@ -1975,6 +1977,7 @@ function TablePageClient({
</button>
<button
type="button"
+ data-testid="enter-seat-submit-button"
onClick={() => void handleEnterSeat()}
disabled={submittingSeat}
className={btnPrimary}
warning: in the working copy of 'apps/web/src/app/table/[roomId]/page.tsx', LF will be replaced by CRLF the next time Git touches it
Get-Content apps\web\src\app\page.tsxExit code: 0
Wall time: 0.6 seconds
Total output lines: 1286
Output:
'use client';
import { useRouter } from 'next/navigation';
import { type ReactNode, useRef, useState } from 'react';
import { signIn, signOut, useSession } from 'next-auth/react';
import {
motion,
useMotionValue,
useTransform,
useSpring,
useScroll,
} from 'motion/react';
import {
Zap,
Users,
BookOpen,
ArrowRight,
Loader2,
Crosshair,
BarChart3,
TrendingUp,
Sparkles,
AlertTriangle,
} from 'lucide-react';
import { useToast } from '../hooks/useToast';
import { API_BASE } from '../lib/api-base';
import { useActorAuth } from '../hooks/useActorAuth';
import {
fetchWithRoomAuthRecovery,
readApiErrorMessage,
} from '../lib/room-auth-recovery';
import { cn } from '../lib/utils';
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const EASE: [number, number, number, number] = [0.25, 0.46, 0.45, 0.94];
const PALETTE = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b'];
const MOCK_ACTIONS = [
{ label: 'CHECK', freq: 43.2, preferred: true, you: false },
{ label: 'BET 1/3 POT', freq: 31.5, preferred: false, you: true },
{ label: 'BET 2/3 POT', freq: 18.8, preferred: false, you: false },
{ label: 'BET POT', freq: 6.5, preferred: false, you: false },
] as const;
/* ------------------------------------------------------------------ */
/* Animation helpers */
/* ------------------------------------------------------------------ */
function FadeIn({
children,
className,
delay = 0,
}: {
children: ReactNode;
className?: string;
delay?: number;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.6, delay, ease: EASE }}
className={className}
>
{children}
</motion.div>
);
}
function SectionLabel({ children }: { children: ReactNode }) {
return (
<span className="inline-block text-xs font-semibold uppercase tracking-[0.2em] text-sky-400">
{children}
</span>
);
}
/* ------------------------------------------------------------------ */
/* Mock playing card */
/* ------------------------------------------------------------------ */
function MockCard({
rank,
suit,
red,
}: {
rank: string;
suit: string;
red?: boolean;
}) {
return (
<div className="flex h-11 w-8 flex-col items-center justify-center rounded-md border border-gray-200/80 bg-white shadow-sm">
<span
className={cn(
'text-[11px] font-bold leading-none',
red ? 'text-red-600' : 'text-slate-900',
)}
>
{rank}
</span>
<span
className={cn(
'-mt-0.5 text-[10px] leading-none',
red ? 'text-red-600' : 'text-slate-900',
)}
>
{suit}
</span>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Hero cockpit 鈥?primary analysis panel */
/* ------------------------------------------------------------------ */
function AnalysisPanelInner() {
const stops = MOCK_ACTIONS.reduce<number[]>((acc, a) => {
acc.push((acc[acc.length - 1] ?? 0) + a.freq);
return acc;
}, []);
const conicGradient = MOCK_ACTIONS.map((_, i) => {
const start = i === 0 ? 0 : stops[i - 1];
return `${PALETTE[i]} ${start}% ${stops[i]}%`;
}).join(', ');
return (
<div className="relative overflow-hidden rounded-2xl border border-white/[0.08] bg-gray-900/80 shadow-2xl shadow-black/40 backdrop-blur-sm">
<div
className="h-px w-full"
style={{
background: `linear-gradient(90deg, transparent, ${PALETTE[0]}40, ${PALETTE[1]}40, ${PALETTE[2]}40, transparent)`,
}}
aria-hidden="true"
/>
<div className="p-5 sm:p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<MockCard rank="A" suit="鈾? />
<MockCard rank="K" suit="鈾? red />
<MockCard rank="7" suit="鈾? />
</div>
<div className="flex items-center gap-3">
<span className="rounded-md border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-sky-300">
Flop
</span>
<span className="text-xs text-gray-400">
Pot{' '}
<span className="font-mono font-semibold text-white">120</span>
</span>
</div>
</div>
<div className="my-4 h-px bg-white/[0.06]" />
<h4 className="text-sm font-bold text-white">Strategy Mix</h4>
<div className="mt-4 grid grid-cols-[auto_1fr] items-start gap-5">
<div className="relative h-[100px] w-[100px] sm:h-[110px] sm:w-[110px]">
<div
className="animate-donut-spin absolute inset-0 rounded-full"
style={{
background: `conic-gradient(from -90deg, ${conicGradient})`,
}}
/>
<div
className="absolute inset-[24%] rounded-full"
style={{ backgroundColor: '#0c1322' }}
/>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[10px] font-medium text-gray-400">
Mix
</span>
</div>
</div>
<div className="space-y-2 pt-0.5">
{MOCK_ACTIONS.map((action, i) => (
<div key={action.label} className="flex items-center gap-2">
<div
className="h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: PALETTE[i] }}
/>
<span className="min-w-0 flex-1 truncate text-xs text-gray-300">
{action.label}
</span>
<span className="shrink-0 text-xs font-semibold tabular-nums text-gray-200">
{action.freq.toFixed(1)}%
</span>
{action.preferred && (
<span className="shrink-0 rounded bg-purple-700/90 px-1.5 py-px text-[9px] font-medium uppercase tracking-wide text-purple-100">
Preferred
</span>
)}
{action.you && (
<span className="shrink-0 rounded bg-blue-700/80 px-1.5 py-px text-[9px] font-medium uppercase tracking-wide text-blue-100">
You
</span>
)}
</div>
))}
</div>
</div>
<div className="mt-4 space-y-1.5">
{MOCK_ACTIONS.map((action, i) => (
<div
key={`bar-${action.label}`}
className="h-1.5 w-full overflow-hidden rounded-full bg-white/[0.04]"
>
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: PALETTE[i] }}
initial={{ width: 0 }}
animate={{ width: `${action.freq}%` }}
transition={{
duration: 0.8,
delay: 0.7 + i * 0.1,
ease: EASE,
}}
/>
</div>
))}
</div>
<p className="mt-4 text-[11px] leading-relaxed text-gray-500">
Your action aligns with the top solver strategy.
</p>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Hero cockpit 鈥?secondary: hand progression */
/* ------------------------------------------------------------------ */
function HandProgressionPanel() {
return (
<div className="w-[200px] rounded-xl border border-white/[0.06] bg-gray-900/70 p-4 backdrop-blur-sm">
<div className="text-[9px] font-medium uppercase tracking-wider text-gray-500">
Hand Progression
</div>
<div className="mt-3 space-y-2.5">
{[
{ street: 'Preflop', cards: 'K鈾?Q鈾?, accent: 'text-gray-300' },
{
street: 'Flop',
cards: 'A鈾?K鈾?7鈾?,
accent: 'text-sky-400/80',
},
{ street: 'Turn', cards: '2鈾?, accent: 'text-sky-400/60' },
].map((s) => (
<div key={s.street} className="flex items-center justify-between">
<span className="text-[10px] text-gray-500">{s.street}</span>
<span className={cn('font-mono text-xs', s.accent)}>
{s.cards}
</span>
</div>
))}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Hero cockpit 鈥?tertiary: sizing analysis */
/* ------------------------------------------------------------------ */
function SizingPanel() {
return (
<div className="w-[180px] rounded-xl border border-white/[0.06] bg-gray-900/70 p-4 backdrop-blur-sm">
<div className="text-[9px] font-medium uppercase tracking-wider text-gray-500">
Sizing Analysis
</div>
<div className="mt-3 space-y-2.5">
<div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-blue-400">You</span>
<span className="font-mono text-xs text-gray-300">80</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-white/[0.04]">
<motion.div
className="h-full rounded-full bg-blue-500"
initial={{ width: 0 }}
animate={{ width: '75%' }}
transition={{ duration: 0.8, delay: 1.5, ease: EASE }}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-sky-400">Solver</span>
<span className="font-mono text-xs text-gray-300">67</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-white/[0.04]">
<motion.div
className="h-full rounded-full bg-sky-500"
initial={{ width: 0 }}
animate={{ width: '63%' }}
transition={{ duration: 0.8, delay: 1.7, ease: EASE }}
/>
</div>
</div>
</div>
<div className="mt-2.5 text-[10px] font-medium text-amber-400/70">
+19% over optimal
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Hero cockpit 鈥?full composition */
/* ------------------------------------------------------------------ */
function HeroCockpit() {
const containerRef = useRef<HTMLDivElement>(null);
const mouseX = useMotionValue(0.5);
const mouseY = useMotionValue(0.5);
const rotateY = useSpring(useTransform(mouseX, [0, 1], [-4, 4]), {
stiffness: 120,
damping: 20,
});
const rotateX = useSpring(useTransform(mouseY, [0, 1], [3, -3]), {
stiffness: 120,
damping: 20,
});
const handleMouseMove = (e: React.MouseEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
mouseX.set((e.clientX - rect.left) / rect.width);
mouseY.set((e.clientY - rect.top) / rect.height);
};
const handleMouseLeave = () => {
mouseX.set(0.5);
mouseY.set(0.5);
};
return (
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.8, delay: 0.3, ease: EASE }}
className="relative mx-auto w-full max-w-[480px] lg:mx-0"
>
{/* Large pulsing glow behind the cockpit */}
<div
className="animate-glow-pulse pointer-events-none absolute -inset-28 rounded-[80px]"
style={{
background:
'radial-gradient(ellipse at 50% 40%, rgba(139,92,246,0.16), rgba(16,185,129,0.09) 40%, transparent 70%)',
}}
aria-hidden="true"
/>
{/* Desktop: full cockpit with 3D tilt */}
<div
ref={containerRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="relative hidden lg:block"
style={{ perspective: 1200 }}
>
<motion.div
style={{ rotateX, rotateY, transformStyle: 'preserve-3d' }}
>
{/* Secondary panel 鈥?hand progression (behind right) */}
<motion.div
className="absolute -right-10 -top-8"
style={{
transform: 'translateZ(-40px) rotate(2deg) scale(0.85)',
zIndex: -1,
}}
initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 0.6, x: 0 }}
transition={{ duration: 0.7, delay: 0.8, ease: EASE }}
>
<HandProgressionPanel />
</motion.div>
{/* Tertiary panel 鈥?sizing (behind left) */}
<motion.div
className="absolute -bottom-6 -left-10"
style={{
transform: 'translateZ(-60px) rotate(-2deg) scale(0.75)',
zIndex: -1,
}}
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 0.5, x: 0 }}
transition={{ duration: 0.7, delay: 1.0, ease: EASE }}
>
<SizingPanel />
</motion.div>
{/* Primary panel 鈥?strategy mix (front) */}
<AnalysisPanelInner />
{/* Floating deviation badge */}
<div className="animate-float pointer-events-none absolute -right-6 -top-4 z-20">
<div className="rounded-lg border border-white/[0.08] bg-gray-900/90 px-3 py-1.5 shadow-xl backdrop-blur-sm">
<div className="text-[9px] font-medium uppercase tracking-wider text-gray-500">
Deviation
</div>
<div className="text-sm font-bold tabular-nums text-sky-400">
鈭?.1%
</div>
</div>
</div>
{/* Floating equity indicator */}
<div className="animate-float-delayed pointer-events-none absolute -bottom-3 -left-5 z-20">
<div className="rounded-lg border border-white/[0.08] bg-gray-900/90 px-3 py-1.5 shadow-xl backdrop-blur-sm">
<div className="text-[9px] font-medium uppercase tracking-wider text-gray-500">
Equity
</div>
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-white/[0.06]">
<motion.div
className="h-full rounded-full bg-sky-400"
initial={{ width: 0 }}
animate={{ width: '62%' }}
transition={{ duration: 1, delay: 1.2, ease: EASE }}
/>
</div>
<span className="text-xs font-semibold tabular-nums text-gray-300">
62%
</span>
</div>
</div>
</div>
</motion.div>
</div>
{/* Mobile: primary panel only, no tilt */}
<div className="lg:hidden">
<AnalysisPanelInner />
</div>
</motion.div>
);
}
/* ------------------------------------------------------------------ */
/* Card burst scroll transition */
/* ------------------------------------------------------------------ */
const CARD_FRAGMENTS = [
{ suit: '鈾?, red: false, angle: 0, dist: 380, rot: -45 },
{ suit: '鈾?, red: true, angle: 30, dist: 460, rot: 30 },
{ suit: '鈾?, red: true, angle: 60, dist: 320, rot: -20 },
{ suit: '鈾?, red: false, angle: 90, dist: 500, rot: 55 },
{ suit: '鈾?, red: false, angle: 120, dist: 400, rot: -35 },
{ suit: '鈾?, red: true, angle: 150, dist: 350, rot: 40 },
{ suit: '鈾?, red: true, angle: 180, dist: 470, rot: -50 },
{ suit: '鈾?, red: false, angle: 210, dist: 430, rot: 25 },
{ suit: '鈾?, red: false, angle: 240, dist: 390, rot: -60 },
{ suit: '鈾?, red: true, angle: 270, dist: 340, rot: 45 },
{ suit: '鈾?, red: true, angle: 300, dist: 480, rot: -30 },
{ suit: '鈾?, red: false, angle: 330, dist: 360, rot: 50 },
];
function CardBurstTransition() {
const sectionRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ['start end', 'end start'],
});
return (
<div
ref={sectionRef}
className="pointer-events-none relative z-10 h-[40vh] overflow-hidden sm:h-[50vh]"
aria-hidden="true"
>
<div className="absolute inset-0 flex items-center justify-center">
{CARD_FRAGMENTS.map((card, i) => {
const rad = (card.angle * Math.PI) / 180;
const tx = Math.cos(rad) * card.dist;
const ty = Math.sin(rad) * card.dist;
const isMobile =
typeof window !== 'undefined' && window.innerWidth < 640;
const scale = isMobile ? 0.5 : 1;
return (
<CardFragment
key={i}
suit={card.suit}
red={card.red}
tx={tx * scale}
ty={ty * scale}
rot={card.rot}
progress={scrollYProgress}
index={i}
/>
);
})}
</div>
</div>
);
}
function CardFragment({
suit,
red,
tx,
ty,
rot,
progress,
index,
}: {
suit: string;
red: boolean;
tx: number;
ty: number;
rot: number;
progress: ReturnType<typeof useScroll>['scrollYProgress'];
index: number;
}) {
const x = useTransform(progress, [0.1, 0.55], [0, tx]);
const y = useTransform(progress, [0.1, 0.55], [0, ty]);
const rotate = useTransform(progress, [0.1, 0.55], [0, rot]);
const opacity = useTransform(
progress,
[0.05, 0.15, 0.45, 0.65],
[0, 0.7, 0.7, 0],
);
const scale = useTransform(progress, [0.1, 0.55], [1, 0.45]);
const glowColor = red ? 'rgba(239,68,68,0.15)' : 'rgba(255,255,255,0.08)';
return (
<motion.div
className="absolute flex h-16 w-11 items-center justify-center rounded-lg border border-white/[0.06] bg-gray-900/40 backdrop-blur-[2px]"
style={{
x,
y,
rotate,
opacity,
scale,
boxShadow: `0 0 20px 4px ${glowColor}`,
willChange: 'transform, opacity',
zIndex: 12 - index,
}}
>
<span
className={cn(
'text-2xl font-light',
red ? 'text-red-500/60' : 'text-white/40',
)}
>
{suit}
</span>
</motion.div>
);
}
/* ------------------------------------------------------------------ */
/* Act 2 鈥?feature visuals */
/* ------------------------------------------------------------------ */
function FeatureDonutBars() {
const stops = MOCK_ACTIONS.reduce<number[]>((acc, a) => {
acc.push((acc[acc.length - 1] ?? 0) + a.freq);
return acc;
}, []);
const conicGradient = MOCK_ACTIONS.map((_, i) => {
const start = i === 0 ? 0 : stops[i - 1];
return `${PALETTE…1878 tokens truncated… </div>
</div>
))}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Page */
/* ------------------------------------------------------------------ */
export default function Home() {
const router = useRouter();
const toast = useToast();
const [quickStartLoading, setQuickStartLoading] = useState(false);
const [startPlayingLoading, setStartPlayingLoading] = useState(false);
const { status } = useSession();
const {
token: authToken,
actorType,
guestId,
isLoading: actorAuthLoading,
} = useActorAuth();
/* Cursor glow tracking (desktop only, no re-renders) */
const cursorX = useMotionValue(-1000);
const cursorY = useMotionValue(-1000);
const smoothCursorX = useSpring(cursorX, { stiffness: 40, damping: 25 });
const smoothCursorY = useSpring(cursorY, { stiffness: 40, damping: 25 });
const handlePageMouseMove = (e: React.MouseEvent) => {
cursorX.set(e.clientX);
cursorY.set(e.clientY);
};
const handlePageMouseLeave = () => {
cursorX.set(-1000);
cursorY.set(-1000);
};
/* ---- handlers (unchanged business logic) ---- */
const handleQuickStart = async () => {
if (!authToken) {
toast.error('Unable to start a session. Please refresh and try again.');
return;
}
setQuickStartLoading(true);
try {
const { response, recoveredAsGuest } = await fetchWithRoomAuthRecovery(
`${API_BASE}/api/rooms/quickstart`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
},
{
actorType,
guestId,
onRecoverUserSession: async () => {
await signOut({ redirect: false });
},
},
);
if (recoveredAsGuest) {
toast.info('Signed-in session expired. Continuing as guest.');
}
if (response.ok) {
const data = await response.json();
if (data.ok && data.data?.roomId) {
toast.success('Room created. Redirecting...');
router.push(`/table/${data.data.roomId}`);
} else {
toast.error('Invalid response from server');
}
} else {
toast.error(
await readApiErrorMessage(response, 'Failed to create room'),
);
}
} catch {
toast.error('Failed to connect to server');
} finally {
setQuickStartLoading(false);
}
};
const handleStartPlaying = async () => {
if (!authToken) {
toast.error('Unable to start a session. Please refresh and try again.');
return;
}
setStartPlayingLoading(true);
try {
const { response, recoveredAsGuest } = await fetchWithRoomAuthRecovery(
`${API_BASE}/api/rooms`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
name: `Live Room ${Date.now()}`,
isPublic: false,
}),
},
{
actorType,
guestId,
onRecoverUserSession: async () => {
await signOut({ redirect: false });
},
},
);
if (recoveredAsGuest) {
toast.info('Signed-in session expired. Continuing as guest.');
}
if (!response.ok) {
toast.error(
await readApiErrorMessage(response, 'Failed to create live room'),
);
return;
}
const data = (await response.json()) as {
roomId?: string;
inviteCode?: string | null;
};
if (!data.roomId) {
toast.error('Invalid response from server');
return;
}
const query = data.inviteCode
? `?invite=${encodeURIComponent(data.inviteCode)}`
: '';
toast.success('Live room created');
router.push(`/table/${data.roomId}${query}`);
} catch {
toast.error('Failed to connect to server');
} finally {
setStartPlayingLoading(false);
}
};
/* ---- derived ---- */
const anyLoading = status === 'loading' || actorAuthLoading;
/* ---- static data ---- */
const featureBlocks = [
{
num: '01',
color: PALETTE[0],
Icon: Crosshair,
title: 'Practice Real Decisions',
description:
"Sit down at a table with AI opponents and play real No-Limit Hold'em hands. No setup, no waiting 鈥?cards on the felt, decisions that matter.",
visual: <FeatureBoardCards />,
reversed: false,
},
{
num: '02',
color: PALETTE[1],
Icon: BarChart3,
title: 'Solver-Backed Analysis',
description:
'Click any postflop decision to run a full GTO computation. See the complete mixed strategy, your deviation from optimal, and exactly which action the solver prefers.',
visual: <FeatureDonutBars />,
reversed: true,
},
{
num: '03',
color: PALETTE[2],
Icon: Sparkles,
title: 'AI-Powered Coaching',
description:
'Get personalized insights from an AI coach that analyzes your sessions. Spot repeated leaks, receive natural-language explanations, and focus your review on the hands that matter most.',
visual: <FeatureCoachInsights />,
reversed: false,
},
];
const actions = [
{
icon: <Users className="h-5 w-5" />,
title: 'Create Live Room',
description: 'Start a private table with friends.',
onClick: () => void handleStartPlaying(),
disabled: anyLoading,
loading: startPlayingLoading,
loadingText: 'Creating...',
},
{
icon: <BookOpen className="h-5 w-5" />,
title: 'Hand Review',
description: 'Review hands with solver analysis.',
onClick: () => router.push('/hands'),
},
];
/* ---- render ---- */
return (
<div
className="relative min-h-dvh"
onMouseMove={handlePageMouseMove}
onMouseLeave={handlePageMouseLeave}
>
{/* =============== Background system =============== */}
<div className="pointer-events-none fixed inset-0" aria-hidden="true">
{/* 1. Base 鈥?deep navy, not black */}
<div
className="absolute inset-0"
style={{ backgroundColor: '#080e1e' }}
/>
{/* 2. Full-canvas atmospheric gradient */}
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(170deg, #080e1e 0%, #0c1428 20%, #0f1332 45%, #0b1624 70%, #080e1e 100%)',
}}
/>
{/* 3. Color bloom layer 鈥?richer, stronger */}
<div
className="absolute inset-0"
style={{
background: [
'radial-gradient(ellipse 80% 50% at 50% -5%, rgba(20,184,166,0.18), transparent 55%)',
'radial-gradient(ellipse 60% 60% at 85% 15%, rgba(139,92,246,0.14), transparent 50%)',
'radial-gradient(ellipse 70% 50% at 15% 75%, rgba(59,130,246,0.10), transparent 50%)',
'radial-gradient(ellipse 60% 40% at 50% 50%, rgba(99,102,241,0.06), transparent 50%)',
].join(', '),
}}
/>
{/* 4. Aurora haze 鈥?slow drifting animated gradient */}
<div
className="animate-aurora absolute inset-0 opacity-60"
style={{
background:
'radial-gradient(ellipse 100% 80% at 40% 30%, rgba(20,184,166,0.08), rgba(139,92,246,0.05) 40%, transparent 70%)',
}}
/>
{/* 5. Spotlight orb 1 鈥?teal, larger */}
<div className="animate-spotlight absolute -left-[200px] -top-[200px] h-[900px] w-[900px] rounded-full bg-[radial-gradient(circle,rgba(20,184,166,0.10),transparent_60%)] blur-[40px]" />
{/* 6. Spotlight orb 2 鈥?violet, larger */}
<div className="animate-spotlight-2 absolute -bottom-[100px] -right-[100px] h-[700px] w-[700px] rounded-full bg-[radial-gradient(circle,rgba(139,92,246,0.08),transparent_60%)] blur-[50px]" />
{/* 7. Cursor-reactive glow 鈥?dual-color, stronger */}
<motion.div
className="fixed hidden h-[700px] w-[700px] rounded-full lg:block"
style={{
left: smoothCursorX,
top: smoothCursorY,
x: '-50%',
y: '-50%',
background:
'radial-gradient(circle, rgba(20,184,166,0.07), rgba(99,102,241,0.03) 40%, transparent 60%)',
filter: 'blur(60px)',
}}
/>
{/* 8. Dot grid */}
<div className="bg-dot-grid absolute inset-0 opacity-[0.025]" />
{/* 9. Noise texture */}
<div className="bg-noise absolute inset-0 opacity-[0.03]" />
</div>
{/* =============== ACT 1: Hero 鈥?solver cockpit =============== */}
<section className="relative z-10 flex min-h-[90vh] items-center">
<div className="mx-auto grid w-full max-w-6xl items-center gap-12 px-6 py-16 sm:py-20 lg:grid-cols-[1fr_auto] lg:gap-16 lg:py-0">
{/* Left: copy + CTAs */}
<div className="max-w-xl">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: EASE }}
>
<SectionLabel>GTO Poker Training</SectionLabel>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.05, ease: EASE }}
className="mt-5 text-5xl font-bold leading-[1.05] tracking-tight text-white sm:text-6xl lg:text-[4.5rem]"
>
Practice poker.
<br />
<span className="text-gray-400">
Analyze every decision.
</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.15, ease: EASE }}
className="mt-6 max-w-lg text-lg leading-relaxed text-gray-400"
>
Play hands against bots, run solver-backed GTO analysis on every
postflop spot, and see exactly where your strategy deviates.
</motion.p>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.28, ease: EASE }}
className="mt-8 flex flex-wrap items-center gap-4"
>
<button
type="button"
data-testid="home-start-playing-button"
onClick={() => void handleQuickStart()}
disabled={anyLoading || quickStartLoading}
className={cn(
'inline-flex h-12 items-center justify-center gap-2.5 rounded-lg bg-sky-600 px-8 text-[15px] font-semibold text-white shadow-lg shadow-sky-600/20 transition-all',
'hover:bg-sky-500 hover:shadow-sky-500/30 active:scale-[0.98]',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950',
'disabled:pointer-events-none disabled:opacity-50',
)}
>
{quickStartLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Zap className="h-4 w-4" />
)}
{quickStartLoading ? 'Creating...' : 'Start Playing'}
</button>
{actorType !== 'user' && !actorAuthLoading ? (
<button
type="button"
onClick={() =>
void signIn(undefined, { callbackUrl: '/' })
}
className="inline-flex h-12 items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-6 text-[15px] font-medium text-gray-300 transition-colors hover:bg-white/[0.08] hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-500"
>
Sign in
<ArrowRight className="h-4 w-4 text-gray-500" />
</button>
) : actorType === 'user' ? (
<button
type="button"
onClick={() => router.push('/hands')}
className="inline-flex h-12 items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-6 text-[15px] font-medium text-gray-300 transition-colors hover:bg-white/[0.08] hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-500"
>
View your hands
<ArrowRight className="h-4 w-4 text-gray-500" />
</button>
) : null}
</motion.div>
</div>
{/* Right: multi-panel solver cockpit */}
<HeroCockpit />
</div>
</section>
{/* =============== Card burst transition =============== */}
<CardBurstTransition />
{/* =============== ACT 2: Inside the Analysis =============== */}
<section className="relative z-10">
<div className="mx-auto max-w-6xl px-6">
<FadeIn className="text-center">
<SectionLabel>Inside the Analysis</SectionLabel>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-white sm:text-4xl">
From decision to insight
</h2>
</FadeIn>
<div className="mt-20 space-y-24 sm:space-y-32 lg:space-y-40">
{featureBlocks.map((feat) => (
<FadeIn key={feat.num} delay={0.05}>
<div
className={cn(
'grid items-center gap-10 lg:grid-cols-2 lg:gap-20',
feat.reversed && 'lg:[direction:rtl]',
)}
>
{/* Text column */}
<div className={cn(feat.reversed && 'lg:[direction:ltr]')}>
<div className="relative">
<span
className="pointer-events-none absolute -left-2 -top-10 select-none text-8xl font-black tabular-nums leading-none sm:-top-14 sm:text-9xl"
style={{ color: feat.color, opacity: 0.07 }}
aria-hidden="true"
>
{feat.num}
</span>
<div className="relative">
<div
className="flex h-10 w-10 items-center justify-center rounded-lg"
style={{ backgroundColor: `${feat.color}15` }}
>
<feat.Icon
className="h-5 w-5"
style={{ color: feat.color }}
/>
</div>
<h3 className="mt-5 text-2xl font-semibold text-white sm:text-3xl">
{feat.title}
</h3>
<p className="mt-4 max-w-md text-base leading-relaxed text-gray-400">
{feat.description}
</p>
</div>
</div>
</div>
{/* Visual column */}
<div className={cn(feat.reversed && 'lg:[direction:ltr]')}>
{feat.visual}
</div>
</div>
</FadeIn>
))}
</div>
</div>
</section>
{/* =============== ACT 3: Conversion =============== */}
<section className="relative z-10 mt-28 sm:mt-36">
<div className="mx-auto h-px max-w-3xl bg-gradient-to-r from-transparent via-sky-500/15 to-transparent" aria-hidden="true" />
<div className="mx-auto max-w-4xl px-6 py-16 sm:py-20">
<FadeIn className="text-center">
<h2 className="text-2xl font-semibold text-white sm:text-3xl">
Ready to play?
</h2>
<p className="mx-auto mt-3 max-w-md text-base text-gray-400">
Jump straight into a hand. Analysis is one click away.
</p>
</FadeIn>
<div className="mx-auto mt-10 grid max-w-3xl gap-4 sm:grid-cols-2">
{actions.map((action, i) => (
<FadeIn key={action.title} delay={0.05 + i * 0.05}>
<button
type="button"
onClick={action.onClick}
disabled={action.disabled || action.loading}
className={cn(
'group relative flex w-full flex-col items-center gap-4 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.025] p-8 text-center transition-all duration-200',
'hover:-translate-y-1 hover:border-sky-500/20 hover:bg-sky-500/[0.04] hover:shadow-2xl hover:shadow-sky-500/[0.08]',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950',
'disabled:pointer-events-none disabled:opacity-40',
)}
>
<div
className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-b from-sky-500/[0.03] to-transparent opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
<div className="relative flex h-12 w-12 items-center justify-center rounded-xl bg-sky-500/10 text-sky-400 transition-colors group-hover:bg-sky-500/20 group-hover:text-sky-300">
{action.loading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
action.icon
)}
</div>
<div className="relative">
<h3 className="text-[15px] font-semibold text-white">
{action.loading && action.loadingText
? action.loadingText
: action.title}
</h3>
<p className="mt-1.5 text-sm text-gray-400">
{action.description}
</p>
</div>
<ArrowRight
className="relative h-4 w-4 text-sky-500/25 transition-all group-hover:translate-x-0.5 group-hover:text-sky-400/60"
aria-hidden="true"
/>
</button>
</FadeIn>
))}
</div>
</div>
</section>
{/* =============== Footer =============== */}
<footer className="relative z-10 border-t border-white/[0.04]">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-8">
<span className="text-sm font-semibold tracking-tight text-gray-500">
PAI Poker
</span>
<span className="text-xs text-gray-600">
© {new Date().getFullYear()}
</span>
</div>
</footer>
</div>
);
}
Get-Content apps\web\src\app\hands\page.tsxExit code: 0
Wall time: 0.6 seconds
Total output lines: 1023
Output:
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { signIn, useSession } from 'next-auth/react';
import {
ChevronLeft,
ChevronRight,
SlidersHorizontal,
X,
Bookmark,
FlaskConical,
BarChart3,
TrendingUp,
ArrowRight,
Search,
Sparkles,
MessageSquare,
Target,
AlertTriangle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/ui/page-header';
import { EmptyState } from '@/components/ui/empty-state';
import { BoardPreview } from '@/components/ui/BoardPreview';
import { CardBack, PlayingCard } from '@/components/table/PlayingCard';
import { useToast } from '@/hooks/useToast';
import { API_BASE } from '@/lib/api-base';
import { cn } from '@/lib/utils';
type HandResultFilter = 'all' | 'win' | 'loss' | 'breakeven';
type GameTypeFilter = 'all' | 'bots' | 'live';
type HandAnalysisStatus = 'waiting' | 'queued' | 'running' | 'complete' | 'failed' | null;
type SaveRequestPhase = 'saving' | 'removing' | null;
interface HandListItem {
handId: string;
playedAt: string;
roomId: string | null;
roomName: string | null;
gameType: 'bots' | 'live';
smallBlind: number;
bigBlind: number;
finalPot: number | null;
seatNo: number | null;
playerName: string | null;
netResult: number | null;
heroCards: unknown;
boardSummary: string | null;
streetReached: string | null;
isComplete: boolean;
saved: boolean;
analyzed: boolean;
analysisStatus: HandAnalysisStatus;
}
interface HandsResponse {
page: number;
pageSize: number;
total: number;
items: HandListItem[];
}
interface HandFilters {
dateFrom: string;
dateTo: string;
smallBlind: string;
bigBlind: string;
potMin: string;
potMax: string;
result: HandResultFilter;
gameType: GameTypeFilter;
saved: boolean;
analyzed: boolean;
}
interface HandActionStatusPayload {
save: {
status: 'idle' | 'pending' | 'completed' | 'failed';
errorMessage: string | null;
};
}
const DEFAULT_FILTERS: HandFilters = {
dateFrom: '',
dateTo: '',
smallBlind: '',
bigBlind: '',
potMin: '',
potMax: '',
result: 'all',
gameType: 'all',
saved: false,
analyzed: false,
};
const PAGE_SIZE = 25;
function toAnalysisStatusLabel(status: HandAnalysisStatus): string {
if (status === 'waiting') return 'Waiting';
if (status === 'queued') return 'Queued';
if (status === 'running') return 'Running';
if (status === 'complete') return 'Ready';
if (status === 'failed') return 'Failed';
return 'None';
}
function parseHeroCards(raw: unknown): Array<{ rank: string; suit: string }> | null {
const cards: Array<{ rank: string; suit: string }> = [];
const appendCardToken = (token: string) => {
const trimmed = token.trim();
if (trimmed.length < 2) return;
const rank = trimmed.slice(0, -1).toUpperCase();
const suit = trimmed.slice(-1).toLowerCase();
if (/^(10|[2-9TJQKA])$/.test(rank) && /^[hdcs]$/.test(suit)) {
cards.push({ rank, suit });
}
};
if (typeof raw === 'string') {
const matches = raw.match(/10[hdcs]|[2-9tjqka][hdcs]/gi) ?? [];
matches.forEach(appendCardToken);
return cards.length > 0 ? cards : null;
}
if (!Array.isArray(raw) || raw.length === 0) return null;
for (const card of raw) {
if (typeof card === 'string') {
appendCardToken(card);
} else if (card && typeof card === 'object') {
const c = card as { rank?: string; suit?: string };
if (typeof c.rank === 'string' && typeof c.suit === 'string') {
appendCardToken(`${c.rank}${c.suit}`);
}
}
}
return cards.length > 0 ? cards : null;
}
const streetColors: Record<string, string> = {
preflop: 'text-slate-500',
flop: 'text-sky-300',
turn: 'text-indigo-200',
river: 'text-cyan-200',
};
function NetResultBadge({ value }: { value: number | null }) {
if (typeof value !== 'number') {
return <span className="text-slate-500">-</span>;
}
const isWin = value > 0;
const isLoss = value < 0;
return (
<span
className={cn(
'inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-bold tabular-nums',
isWin && 'bg-teal-500/12 text-teal-100',
isLoss && 'bg-rose-500/12 text-rose-200',
!isWin && !isLoss && 'bg-[#111a2e] text-slate-400',
)}
>
{value > 0 ? '+' : ''}{value}
</span>
);
}
function StreetBadge({ street, className }: { street: string | null; className?: string }) {
const key = (street ?? 'preflop').toLowerCase();
return (
<span
className={cn(
'inline-block whitespace-nowrap rounded-md px-0 py-0 text-[10px] font-semibold uppercase leading-none tracking-[0.16em] align-baseline',
streetColors[key] ?? streetColors.preflop,
className,
)}
>
{key}
</span>
);
}
function ReviewStatusBadge({
hand,
saved,
className,
}: {
hand: HandListItem;
saved: boolean;
className?: string;
}) {
let label = 'Open';
let tone = 'text-slate-400';
if (hand.analyzed) {
label = toAnalysisStatusLabel(hand.analysisStatus);
tone =
hand.analysisStatus === 'complete'
? 'text-sky-100'
: hand.analysisStatus === 'failed'
? 'text-rose-200'
: 'text-amber-100';
} else if (saved) {
label = 'Saved';
tone = 'text-teal-100';
}
return (
<span className={cn('inline-block whitespace-nowrap text-[11px] font-medium leading-none align-baseline', tone, className)}>
{label}
</span>
);
}
function FilterInput({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wider text-slate-500">
{label}
</label>
{children}
</div>
);
}
const inputClass =
'w-full rounded-lg border border-slate-400/[0.12] bg-[#0f182c]/92 px-3 py-2 text-sm text-white placeholder:text-slate-500 focus:border-sky-400/40 focus:outline-none focus:ring-1 focus:ring-sky-400/18 transition-colors';
export default function HandsPage() {
const router = useRouter();
const toast = useToast();
const { data: session, status } = useSession();
const apiToken = session?.apiToken;
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<HandFilters>(DEFAULT_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<HandFilters>(DEFAULT_FILTERS);
const [data, setData] = useState<HandsResponse>({
page: 1,
pageSize: PAGE_SIZE,
total: 0,
items: [],
});
const [savedStateByHandId, setSavedStateByHandId] = useState<Record<string, boolean>>({});
const [saveRequestPhaseByHandId, setSaveRequestPhaseByHandId] = useState<Record<string, SaveRequestPhase>>({});
const totalPages = useMemo(() => {
return Math.max(1, Math.ceil(data.total / data.pageSize));
}, [data.pageSize, data.total]);
const activeFilterCount = useMemo(() => {
let count = 0;
if (appliedFilters.dateFrom) count++;
if (appliedFilters.dateTo) count++;
if (appliedFilters.smallBlind) count++;
if (appliedFilters.bigBlind) count++;
if (appliedFilters.potMin) count++;
if (appliedFilters.potMax) count++;
if (appliedFilters.result !== 'all') count++;
if (appliedFilters.gameType !== 'all') count++;
return count;
}, [appliedFilters]);
useEffect(() => {
if (!apiToken) {
if (status !== 'loading') {
setLoading(false);
}
return;
}
void loadHands(apiToken, page, appliedFilters);
}, [apiToken, page, appliedFilters, status]);
useEffect(() => {
setSavedStateByHandId((prev) => {
const next = { ...prev };
for (const item of data.items) {
if (typeof next[item.handId] !== 'boolean') {
next[item.handId] = item.saved;
}
}
return next;
});
}, [data.items]);
const loadHands = async (token: string, targetPage: number, activeFilters: HandFilters) => {
setLoading(true);
try {
const query = new URLSearchParams();
query.set('page', String(targetPage));
query.set('pageSize', String(PAGE_SIZE));
if (activeFilters.dateFrom) query.set('dateFrom', activeFilters.dateFrom);
if (activeFilters.dateTo) query.set('dateTo', activeFilters.dateTo);
if (activeFilters.smallBlind) query.set('smallBlind', activeFilters.smallBlind);
if (activeFilters.bigBlind) query.set('bigBlind', activeFilters.bigBlind);
if (activeFilters.potMin) query.set('potMin', activeFilters.potMin);
if (activeFilters.potMax) query.set('potMax', activeFilters.potMax);
if (activeFilters.result !== 'all') query.set('result', activeFilters.result);
if (activeFilters.gameType !== 'all') query.set('gameType', activeFilters.gameType);
if (activeFilters.saved) query.set('saved', 'true');
if (activeFilters.analyzed) query.set('analyzed', 'true');
const res = await fetch(`${API_BASE}/api/hands?${query.toString()}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
toast.error('Failed to load hand history');
return;
}
const payload = (await res.json()) as HandsResponse;
setData(payload);
} catch {
toast.error('Failed to load hand history');
} finally {
setLoading(false);
}
};
const applyDropdownFilters = () => {
setPage(1);
setAppliedFilters((prev) => ({
...filters,
saved: prev.saved,
analyzed: prev.analyzed,
}));
setFiltersOpen(false);
};
const clearAllFilters = () => {
setFilters(DEFAULT_FILTERS);
setAppliedFilters(DEFAULT_FILTERS);
setPage(1);
};
const toggleProminentFilter = (key: 'saved' | 'analyzed') => {
setPage(1);
setFilters((prev) => {
const next = { ...prev, [key]: !prev[key] };
setAppliedFilters((applied) => ({ ...applied, [key]: next[key] }));
return next;
});
};
const handleToggleSaved = async (hand: HandListItem) => {
if (!apiToken) {
await signIn(undefined, { callbackUrl: '/hands' });
return;
}
const currentlySaved = savedStateByHandId[hand.handId] ?? hand.saved;
const nextPhase: SaveRequestPhase = currentlySaved ? 'removing' : 'saving';
setSaveRequestPhaseByHandId((prev) => ({ ...prev, [hand.handId]: nextPhase }));
try {
const response = await fetch(`${API_BASE}/api/hand-actions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
},
body: JSON.stringify({
type: 'SAVE',
handId: hand.handId,
...(hand.roomId ? { gameId: hand.roomId } : {}),
cancel: currentlySaved,
}),
});
const payload = (await response.json().catch(() => null)) as HandActionStatusPayload | { error?: string } | null;
if (!response.ok) {
throw new Error((payload as { error?: string } | null)?.error ?? 'Failed to update saved hand');
}
const savePayload = payload as HandActionStatusPayload;
const nextSaved = savePayload.save.status === 'pending' || savePayload.save.status === 'completed';
setSavedStateByHandId((prev) => ({ ...prev, [hand.handId]: nextSaved }));
setData((prev) => ({
...prev,
items: prev.items.map((item) =>
item.handId === hand.handId
? { ...item, saved: nextSaved }
: item,
),
}));
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update saved hand');
} finally {
setSaveRequestPhaseByHandId((prev) => ({ ...prev, [hand.handId]: null }));
}
};
if (loading) {
return (
<div className="h-full min-h-0 overflow-y-auto">
<div className="mx-auto flex h-full max-w-7xl items-center justify-center px-4 py-6">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/[0.15] border-t-sky-400" />
<span className="text-sm text-slate-400">Loading hands...</span>
</div>
</div>
</div>
);
}
if (!apiToken) {
return (
<div className="h-full min-h-0 overflow-y-auto">
<div className="mx-auto w-full max-w-5xl px-4 py-6 sm:px-6 sm:py-8">
<PageHeader label="Analysis Index" title="Hand History" />
<div className="mt-8">
<EmptyState
icon={<Search className="h-7 w-7" />}
headline="Sign in to review your hands"
description="Your hand history and analysis results will appear here after you sign in."
action={
<button
type="button"
onClick={() => void signIn(undefined, { callbackUrl: '/hands' })}
className="inline-flex items-center gap-2 rounded-xl bg-[#1488d5] px-6 py-3 font-semibold text-white shadow-[0_12px_24px_rgba(20,136,213,0.24)] transition-all hover:-translate-y-0.5 hover:bg-[#2799e4]"
>
Sign in
</button>
}
/>
</div>
</div>
</div>
);
}
return (
<div className="h-full min-h-0 overflow-y-auto">
<div className="mx-auto w-full max-w-7xl px-4 py-5 sm:px-6 sm:py-7">
<PageHeader
label="Analysis Index"
title="Hand History"
description="Review past hands, filter by criteria, and dive into solver-backed analysis."
stats={
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-slate-400">
<span className="inline-flex items-center gap-1.5">
<BarChart3 className="h-3.5 w-3.5 text-sky-300/70" />
<span className="font-semibold text-white">{data.total}</span>
<span>{data.total === 1 ? 'hand' : 'hands'}</span>
</span>
{data.items.length > 0 ? (
<span className="inline-flex items-center gap-1.5">
<TrendingUp className="h-3.5 w-3.5 text-indigo-300/70" />
<span className="font-semibold text-white">{data.items.filter((h) => h.analyzed).length}</span>
<span>analyzed this page</span>
</span>
) : null}
</div>
}
/>
{/* 鈹€鈹€ Filter toolbar 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ */}
<div className="mt-5 flex flex-wrap items-center gap-2 animate-fade-in-up-delay-1">
<button
type="button"
onClick={() => toggleProminentFilter('saved')}
className={cn(
'inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-all',
filters.saved
? 'border-teal-400/20 bg-teal-500/12 text-teal-100'
: 'border-slate-400/[0.08] bg-[#0f172b]/78 text-slate-400 hover:border-slate-300/[0.14] hover:bg-[#13203a]/52 hover:text-white',
)}
>
<Bookmark className="h-3.5 w-3.5" />
Saved
</button>
<button
type="button"
onClick={() => toggleProminentFilter('analyzed')}
className={cn(
'inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-all',
filters.analyzed
? 'border-indigo-400/20 bg-indigo-500/12 text-indigo-100'
: 'border-slate-400/[0.08] bg-[#0f172b]/78 text-slate-400 hover:border-slate-300/[0.14] hover:bg-[#13203a]/52 hover:text-white',
)}
>
<FlaskConical className="h-3.5 w-3.5" />
Analyzed
</button>
<div className="mx-1 hidden h-5 w-px bg-slate-400/[0.08] sm:block" />
<button
type="button"
onClick={() => setFiltersOpen((prev) => !prev)}
className={cn(
'inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-all',
filtersOpen
? 'border-sky-400/20 bg-sky-500/10 text-sky-50'
: 'border-slate-400/[0.08] bg-[#0f172b]/78 text-slate-400 hover:border-slate-300/[0.14] hover:bg-[#13203a]/52 hover:text-white',
)}
>
<SlidersHorizontal className="h-3.5 w-3.5" />
Filters
{activeFilterCount > 0 && (
<span className="ml-0.5 flex h-4.5 min-w-[18px] items-center justify-center rounded-full bg-sky-500/14 px-1 text-[10px] font-bold text-sky-50">
{activeFilterCount}
</span>
)}
</button>
{(activeFilterCount > 0 || filters.saved || filters.analyzed) && (
<button
type="button"
onClick={clearAllFilters}
className="inline-flex items-center gap-1 rounded-lg border border-slate-400/[0.08] px-2.5 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:bg-[#13203a]/42 hover:text-slate-200"
>
<X className="h-3 w-3" />
Clear
</button>
)}
</div>
{/* 鈹€鈹€ Expanded filter panel 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ */}
{filtersOpen && (
<div className="animate-fade-in-up mt-3 rounded-xl border border-slate-400/[0.08] bg-[#0d1528]/82 p-4 sm:p-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<FilterInput label="Date From">
<input
type="date"
title="Date from"
value={filters.dateFrom}
onChange={(e) => setFilters((prev) => ({ ...prev, dateFrom: e.target.value }))}
className={inputClass}
/>
</FilterInput>
<FilterInput label="Date To">
<input
type="date"
title="Date to"
value={filters.dateTo}
onChange={(e) => setFilters((prev) => ({ ...prev, dateTo: e.target.value }))}
className={inputClass}
/>
</FilterInput>
<FilterInput label="Small Blind">
<input
type="number"
min={1}
value={filters.smallBlind}
onChange={(e) => setFilters((prev) => ({ ...prev, smallBlind: e.target.value }))}
className={inputClass}
placeholder="Any"
/>
</FilterInput>
<FilterInput label="Big Blind">
<input
type="number"
min={1}
value={filters.bigBlind}
onChange={(e) => setFilters((prev) => ({ ...prev, bigBlind: e.target.value }))}
…1233 tokens truncated…).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-400">
<span className="font-semibold tabular-nums text-slate-100">
{hand.smallBlind}/{hand.bigBlind}
</span>
<span aria-hidden="true" className="text-white/[0.14]">·</span>
<span className="uppercase tracking-[0.14em] text-slate-500">{hand.gameType}</span>
</div>
</div>
<div className="grid min-w-0 grid-cols-[16px_auto] items-center justify-self-start gap-2">
<div className="flex min-h-[82px] items-center justify-center">
<span className="-rotate-90 whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
Hand
</span>
</div>
<div className="flex min-h-[82px] items-center gap-2.5">
{heroCards
? heroCards.slice(0, 2).map((card, index) => (
<PlayingCard key={index} rank={card.rank} suit={card.suit} size="md" />
))
: (
<>
<CardBack size="md" />
<CardBack size="md" />
</>
)}
</div>
</div>
<div className="grid min-w-0 grid-cols-[16px_minmax(0,1fr)] items-center justify-self-start gap-2.5">
<div className="flex min-h-[82px] items-center justify-center">
<span className="-rotate-90 whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
Board
</span>
</div>
<div className="min-h-[82px] min-w-[296px]">
<BoardPreview boardSummary={hand.boardSummary} showPlaceholders size="md" className="min-h-[82px] min-w-[296px]" />
</div>
</div>
<div className="grid min-w-0 justify-items-center gap-2 text-center justify-self-center">
<div className="w-full">
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Result</div>
<div className="mt-1 flex justify-center">
<NetResultBadge value={hand.netResult} />
</div>
</div>
<div className="w-full">
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Pot</div>
<div className="mt-1 text-sm font-semibold tabular-nums text-slate-100">
{typeof hand.finalPot === 'number' ? hand.finalPot : '-'}
</div>
</div>
</div>
<div
data-testid={`hand-review-column-${hand.handId}`}
className="flex w-[214px] flex-col items-center gap-2 justify-self-end"
>
<div
data-testid={`hand-review-meta-${hand.handId}`}
className="flex min-h-[18px] w-full items-center justify-center gap-2 text-center"
>
<span data-testid={`hand-review-street-${hand.handId}`}>
<StreetBadge street={hand.streetReached} className="text-[12px]/none tracking-[0.18em]" />
</span>
<span
aria-hidden="true"
data-testid={`hand-review-meta-separator-${hand.handId}`}
className="relative top-px place-self-center h-[4px] w-[4px] shrink-0 rounded-full bg-white/[0.18]"
/>
<span data-testid={`hand-review-status-${hand.handId}`}>
<ReviewStatusBadge hand={hand} saved={isSaved} className="text-[12px]/none font-semibold" />
</span>
</div>
<div
data-testid={`hand-review-actions-${hand.handId}`}
className="grid w-full grid-cols-2 gap-2"
>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void handleToggleSaved(hand);
}}
disabled={savePhase !== null}
className={cn(
'inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50',
isSaved
? 'border-teal-400/18 bg-teal-500/12 text-teal-50 hover:bg-teal-500/16'
: 'border-slate-400/[0.1] bg-[#111b30] text-slate-200 hover:border-slate-300/[0.14] hover:bg-[#16233d]',
)}
>
<Bookmark className="h-3.5 w-3.5" />
{saveLabel}
</button>
<button
type="button"
data-testid={`hand-review-button-${hand.handId}`}
onClick={(event) => {
event.stopPropagation();
router.push(`/hands/${hand.handId}`);
}}
className="inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-xl bg-[#1488d5] px-3.5 text-xs font-semibold text-white shadow-[0_10px_22px_rgba(20,136,213,0.2)] transition-colors hover:bg-[#2799e4]"
>
Review
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
<div className="flex flex-col gap-3 p-3.5 lg:hidden">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-sm font-semibold text-slate-100">
{new Date(hand.playedAt).toLocaleDateString()}
</div>
<div className="text-xs text-slate-400">
{new Date(hand.playedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
<div className="flex min-w-0 flex-col items-end gap-2">
<div className="flex flex-wrap items-center justify-end gap-x-2 gap-y-1 text-[11px] text-slate-400">
<span className="font-semibold tabular-nums text-slate-100">
{hand.smallBlind}/{hand.bigBlind}
</span>
<span aria-hidden="true" className="text-white/[0.14]">·</span>
<span className="uppercase tracking-[0.14em] text-slate-500">{hand.gameType}</span>
</div>
<div data-mobile-hand-actions className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void handleToggleSaved(hand);
}}
disabled={savePhase !== null}
className={cn(
'inline-flex h-9 items-center gap-1.5 rounded-xl border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50',
isSaved
? 'border-teal-400/18 bg-teal-500/12 text-teal-50 hover:bg-teal-500/16'
: 'border-slate-400/[0.1] bg-[#111b30] text-slate-200 hover:border-slate-300/[0.14] hover:bg-[#16233d]',
)}
>
<Bookmark className="h-3.5 w-3.5" />
{saveLabel}
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
router.push(`/hands/${hand.handId}`);
}}
className="inline-flex h-9 items-center gap-1.5 rounded-xl bg-[#1488d5] px-3.5 text-xs font-semibold text-white shadow-[0_10px_22px_rgba(20,136,213,0.2)] transition-colors hover:bg-[#2799e4]"
>
Review
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[auto_1fr]">
<div>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Hand</div>
<div className="flex min-h-[78px] items-center gap-2 [--hole-card-w:52px]">
{heroCards
? heroCards.slice(0, 2).map((card, index) => (
<PlayingCard key={index} rank={card.rank} suit={card.suit} size="sm" />
))
: (
<>
<CardBack size="sm" />
<CardBack size="sm" />
</>
)}
</div>
</div>
<div>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Board</div>
<div className="min-h-[78px] min-w-[268px] [--hole-card-w:52px]">
<BoardPreview boardSummary={hand.boardSummary} showPlaceholders size="sm" className="min-h-[78px] min-w-[268px]" />
</div>
</div>
</div>
<div className="grid gap-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
<div className="space-y-1">
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Result</div>
<div>
<NetResultBadge value={hand.netResult} />
</div>
</div>
<div className="space-y-1">
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Pot</div>
<div className="text-sm font-semibold tabular-nums text-slate-100">
{typeof hand.finalPot === 'number' ? hand.finalPot : '-'}
</div>
</div>
<div
data-testid={`hand-mobile-street-${hand.handId}`}
className="space-y-1"
>
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Street</div>
<div className="flex min-h-[18px] items-center">
<StreetBadge street={hand.streetReached} className="text-[12px]/none font-semibold" />
</div>
</div>
<div
data-testid={`hand-mobile-status-${hand.handId}`}
className="space-y-1"
>
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</div>
<div className="flex min-h-[18px] items-center">
<ReviewStatusBadge hand={hand} saved={isSaved} className="text-[12px]/none font-semibold" />
</div>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
{/* Pagination */}
{data.items.length > 0 && (
<div className="mt-4 flex items-center justify-between rounded-xl border border-slate-400/[0.08] bg-[#0d1528]/82 px-4 py-3">
<span className="text-xs text-slate-400">
Page {data.page} of {totalPages}
<span className="ml-2 text-slate-500">{data.total} total</span>
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
disabled={page <= 1}
aria-label="Previous page"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-slate-400/[0.08] bg-[#101a2e] text-slate-400 transition-colors hover:bg-[#16233d] hover:text-white disabled:opacity-30 disabled:hover:bg-[#101a2e]"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
disabled={page >= totalPages}
aria-label="Next page"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-slate-400/[0.08] bg-[#101a2e] text-slate-400 transition-colors hover:bg-[#16233d] hover:text-white disabled:opacity-30 disabled:hover:bg-[#101a2e]"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
)}
</div>
{/* Right: Session Coach sidebar */}
<SessionCoachSidebar
handsCount={data.items.length}
analyzedCount={data.items.filter((h) => h.analyzed).length}
winRate={data.items.length > 0
? Math.round((data.items.filter((h) => typeof h.netResult === 'number' && h.netResult > 0).length / data.items.length) * 100)
: 0
}
/>
</div>
</div>
</div>
);
}
/* 鈹€鈹€ Session Coach Sidebar 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ */
const REVIEW_SPOTS = [
{ icon: AlertTriangle, text: 'Large pot hands with suboptimal river decisions' },
{ icon: Target, text: 'Flop check-raise spots where you bet instead' },
{ icon: TrendingUp, text: 'Hands where you deviated from solver on the turn' },
];
const PROMPT_CHIPS = [
'What is my biggest leak?',
'Which spots should I review first?',
'How is my river play?',
'Summarize my session',
];
function SessionCoachSidebar({
handsCount,
analyzedCount,
winRate,
}: {
handsCount: number;
analyzedCount: number;
winRate: number;
}) {
return (
<aside className="hidden 2xl:block">
<div className="sticky top-24 rounded-[20px] border border-slate-400/[0.08] bg-[#0d1528]/82 p-4">
<div className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-sky-300/80" />
<h3 className="text-sm font-semibold text-white">Session Coach</h3>
</div>
<p className="mt-2 text-xs leading-relaxed text-slate-400">
AI-powered insights based on the hands on this page.
</p>
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-400">
<span><span className="font-semibold text-white">{handsCount}</span> hands</span>
<span aria-hidden="true" className="text-white/[0.14]">·</span>
<span><span className="font-semibold text-white">{winRate}%</span> win rate</span>
<span aria-hidden="true" className="text-white/[0.14]">·</span>
<span><span className="font-semibold text-white">{analyzedCount}</span> analyzed</span>
</div>
<div className="mt-4 border-t border-slate-400/[0.08] pt-4">
<h4 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-slate-300">
<Target className="h-3 w-3 text-sky-300/70" />
Spots to Review
</h4>
<div className="mt-3 space-y-2.5">
{REVIEW_SPOTS.map((spot) => (
<div key={spot.text} className="flex items-start gap-2.5">
<spot.icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-indigo-300/70" />
<span className="text-xs leading-relaxed text-slate-200">{spot.text}</span>
</div>
))}
</div>
</div>
<div className="mt-4 border-t border-slate-400/[0.08] pt-4">
<h4 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-slate-300">
<MessageSquare className="h-3 w-3 text-sky-300/70" />
Ask about these hands
</h4>
<div className="mt-3 flex flex-wrap gap-1.5">
{PROMPT_CHIPS.map((prompt) => (
<button
key={prompt}
type="button"
className="rounded-lg border border-slate-400/[0.08] bg-[#111b30] px-2.5 py-1.5 text-[11px] font-medium text-slate-400 transition-colors hover:bg-[#172545] hover:text-white"
>
{prompt}
</button>
))}
</div>
</div>
</div>
</aside>
);
}
Get-Content apps\web\src\app\hands\[handId]\page.tsxExit code: 1
Wall time: 0.5 seconds
Output:
Get-Content : An object at the specified path apps\web\src\app\hands\[handId]\page.tsx does not exist, or has been
filtered by the -Include or -Exclude parameter.
At line:2 char:1
+ Get-Content apps\web\src\app\hands\[handId]\page.tsx
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (System.String[]:String[]) [Get-Content], Exception
+ FullyQualifiedErrorId : ItemNotFound,Microsoft.PowerShell.Commands.GetContentCommand
Get-Content apps\web\src\app\table\[roomId]\page.tsxExit code: 1
Wall time: 0.5 seconds
Output:
Get-Content : An object at the specified path apps\web\src\app\table\[roomId]\page.tsx does not exist, or has been
filtered by the -Include or -Exclude parameter.
At line:2 char:1
+ Get-Content apps\web\src\app\table\[roomId]\page.tsx
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (System.String[]:String[]) [Get-Content], Exception
+ FullyQualifiedErrorId : ItemNotFound,Microsoft.PowerShell.Commands.GetContentCommand
Plan:
analysis-flow.spec.ts.Plan updated
I’m checking whether the local stack is actually available before invoking Playwright. If it isn’t, I’ll start the repo’s existing pnpm dev flow rather than inventing a separate test harness.
Test-Path playwright\.auth\google-user.jsonExit code: 0 Wall time: 0.2 seconds Output: True
try { (Invoke-WebRequest -Uri http://localhost:3000 -MaximumRedirection 0 -ErrorAction Stop).StatusCode } catch { if ($_.Exception.Response) { $_.Exception.Response.StatusCode.value__ } else { $_.Exception.Message } }Exit code: 0 Wall time: 0.9 seconds Output: Object reference not set to an instance of an object.
try { (Invoke-WebRequest -Uri http://localhost:3001/api/health -ErrorAction Stop).Content } catch { if ($_.Exception.Response) { $_.Exception.Response.StatusCode.value__ } else { $_.Exception.Message } }Exit code: 0 Wall time: 2.5 seconds Output: Object reference not set to an instance of an object.
Get-Content scripts\dev.mjsExit code: 0
Wall time: 0.4 seconds
Output:
import fs from 'node:fs';
import net from 'node:net';
import { spawn } from 'node:child_process';
import process from 'node:process';
import path from 'node:path';
const ROOT_DIR = process.cwd();
const PNPM_BIN = 'pnpm';
const USE_SHELL_FOR_PNPM = process.platform === 'win32';
const DEFAULT_SOLVER_PORT = 4010;
const DEFAULT_DEV_SOLVER_SERVICE_URL = `http://127.0.0.1:${DEFAULT_SOLVER_PORT}`;
const DEFAULT_WEB_PORT = 3000;
const DEFAULT_API_PORT = 3001;
const DEFAULT_LINUX_SOLVER_DIR = '/opt/texassolver';
const DEFAULT_LINUX_SOLVER_EXECUTABLE = 'console_solver';
const DEFAULT_POSTGRES_PORT = 5433;
const DEFAULT_REDIS_PORT = 6379;
const ROOT_ENV_PATH = path.join(ROOT_DIR, '.env');
const SOLVER_SERVICE_ENV_PATH = path.join(
ROOT_DIR,
'apps',
'solver-service',
'.env',
);
const REPO_LOCAL_SOLVER_DIR = path.join(
ROOT_DIR,
'apps',
'solver-service',
'texassolver',
);
const REPO_LOCAL_SOLVER_EXECUTABLE = path.join(
REPO_LOCAL_SOLVER_DIR,
DEFAULT_LINUX_SOLVER_EXECUTABLE,
);
const SOLVER_COMPOSE_FILE = path.join(
'apps',
'solver-service',
'docker-compose.solver.yml',
);
const INFRA_COMPOSE_FILE = path.join('infra', 'docker-compose.yml');
const PROJECT_SOLVER_CONTAINER_NAMES = new Set([
'pokerworker-solver-service-1',
'pokerworker_solver-service_1',
]);
const children = [];
let shuttingDown = false;
let shutdownPromise = null;
function formatExit(code, signal) {
if (typeof code === 'number') return `exit code ${code}`;
if (signal) return `signal ${signal}`;
return 'unknown reason';
}
function formatError(error) {
return error instanceof Error ? error.message : String(error);
}
function normalizeBaseUrl(input) {
return input.trim().replace(/\/+$/, '');
}
function parseEnvFile(filePath) {
if (!fs.existsSync(filePath)) {
return {};
}
const content = fs.readFileSync(filePath, 'utf8');
const env = {};
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex <= 0) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
let value = line.slice(separatorIndex + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
}
function resolveBaseEnv() {
return {
...parseEnvFile(ROOT_ENV_PATH),
...process.env,
};
}
function resolveSolverServiceEnv() {
return {
...parseEnvFile(ROOT_ENV_PATH),
...parseEnvFile(SOLVER_SERVICE_ENV_PATH),
...process.env,
};
}
function withPort(env, port) {
const nextEnv = { ...env };
if (port?.trim()) {
nextEnv.PORT = port.trim();
return nextEnv;
}
delete nextEnv.PORT;
return nextEnv;
}
function pathExists(targetPath) {
return fs.existsSync(targetPath);
}
function resolveRepoLocalSolverRuntime() {
if (!pathExists(REPO_LOCAL_SOLVER_EXECUTABLE)) {
return null;
}
return {
solverDir: REPO_LOCAL_SOLVER_DIR,
executablePath: REPO_LOCAL_SOLVER_EXECUTABLE,
source: 'repo-local',
};
}
function resolveConfiguredLinuxSolverRuntime(baseEnv) {
const configuredSolverDir = baseEnv.TEXASSOLVER_DIR?.trim();
if (!configuredSolverDir) {
return null;
}
const solverDir = path.resolve(configuredSolverDir);
const executablePath = path.join(solverDir, DEFAULT_LINUX_SOLVER_EXECUTABLE);
if (!pathExists(executablePath)) {
throw new Error(
`TEXASSOLVER_DIR points to ${configuredSolverDir}, but ${executablePath} does not exist. Point TEXASSOLVER_DIR at a Linux TexasSolver directory containing console_solver.`,
);
}
return {
solverDir,
executablePath,
source: 'TEXASSOLVER_DIR',
};
}
function resolveLocalSolverRuntime(baseEnv) {
const configuredRuntime = resolveConfiguredLinuxSolverRuntime(baseEnv);
if (configuredRuntime) {
return configuredRuntime;
}
const repoLocalRuntime = resolveRepoLocalSolverRuntime();
if (repoLocalRuntime) {
return repoLocalRuntime;
}
const defaultSolverDir = path.resolve(DEFAULT_LINUX_SOLVER_DIR);
const defaultExecutablePath = path.join(
defaultSolverDir,
DEFAULT_LINUX_SOLVER_EXECUTABLE,
);
if (pathExists(defaultExecutablePath)) {
return {
solverDir: defaultSolverDir,
executablePath: defaultExecutablePath,
source: 'default',
};
}
throw new Error(
`Unable to find a Linux TexasSolver binary. Put console_solver at ${REPO_LOCAL_SOLVER_EXECUTABLE}, set TEXASSOLVER_DIR to a Linux TexasSolver directory, or set TEXASSOLVER_HOST_DIR to a host directory containing console_solver for Docker mode.`,
);
}
function validateDockerSolverDir(candidateDir, source) {
const dockerSolverDir = path.resolve(candidateDir);
const executablePath = path.join(
dockerSolverDir,
DEFAULT_LINUX_SOLVER_EXECUTABLE,
);
if (!pathExists(executablePath)) {
throw new Error(
`${source} points to ${candidateDir}, but ${executablePath} does not exist. Docker solver mode requires a Linux TexasSolver directory containing console_solver.`,
);
}
return {
dockerSolverDir,
executablePath,
source,
};
}
function resolveDockerSolverRuntime(baseEnv) {
const explicitDockerSolverDir = baseEnv.TEXASSOLVER_HOST_DIR?.trim();
if (explicitDockerSolverDir) {
if (pathExists(explicitDockerSolverDir)) {
return validateDockerSolverDir(
explicitDockerSolverDir,
'TEXASSOLVER_HOST_DIR',
);
}
const repoLocalRuntime = resolveRepoLocalSolverRuntime();
if (repoLocalRuntime) {
console.warn(
`[dev] TEXASSOLVER_HOST_DIR does not exist and will be ignored: ${explicitDockerSolverDir}`,
);
console.warn(
`[dev] Falling back to repo-local solver directory: ${repoLocalRuntime.solverDir}`,
);
return {
dockerSolverDir: repoLocalRuntime.solverDir,
executablePath: repoLocalRuntime.executablePath,
source: 'repo-local',
};
}
throw new Error(
`TEXASSOLVER_HOST_DIR does not exist: ${explicitDockerSolverDir}. Put console_solver at ${REPO_LOCAL_SOLVER_EXECUTABLE} or point TEXASSOLVER_HOST_DIR at a host Linux TexasSolver directory.`,
);
}
const repoLocalRuntime = resolveRepoLocalSolverRuntime();
if (!repoLocalRuntime) {
return null;
}
return {
dockerSolverDir: repoLocalRuntime.solverDir,
executablePath: repoLocalRuntime.executablePath,
source: 'repo-local',
};
}
function resolveSolverLaunchMode(baseEnv) {
const dockerRuntime = resolveDockerSolverRuntime(baseEnv);
if (process.platform === 'win32' || process.platform === 'darwin') {
if (!dockerRuntime) {
throw new Error(
`pnpm dev requires a Linux TexasSolver directory on ${process.platform}. Put console_solver at ${REPO_LOCAL_SOLVER_EXECUTABLE} or set TEXASSOLVER_HOST_DIR to a host Linux TexasSolver directory.`,
);
}
return {
mode: 'docker',
...dockerRuntime,
};
}
if (baseEnv.TEXASSOLVER_HOST_DIR?.trim()) {
if (!dockerRuntime) {
throw new Error(
'TEXASSOLVER_HOST_DIR is set, but no usable Linux TexasSolver directory was found.',
);
}
return {
mode: 'docker',
...dockerRuntime,
};
}
return {
mode: 'local',
...resolveLocalSolverRuntime(baseEnv),
};
}
function resolveSolverDevEnv(baseEnv) {
const explicitServiceUrl = baseEnv.SOLVER_SERVICE_URL?.trim();
const legacySolverUrl = baseEnv.SOLVER_URL?.trim();
const source = explicitServiceUrl
? 'SOLVER_SERVICE_URL'
: legacySolverUrl
? 'SOLVER_URL'
: 'default-dev-local';
const solverServiceUrl = normalizeBaseUrl(
explicitServiceUrl || legacySolverUrl || DEFAULT_DEV_SOLVER_SERVICE_URL,
);
const solverUrl = normalizeBaseUrl(legacySolverUrl || solverServiceUrl);
return {
source,
env: {
SOLVER_SERVICE_URL: solverServiceUrl,
SOLVER_URL: solverUrl,
SOLVER_STRICTNESS: baseEnv.SOLVER_STRICTNESS?.trim() || 'warn',
},
};
}
function runCommand(command, args, options = {}) {
const {
env = process.env,
stdio = 'inherit',
allowFailure = false,
shell = false,
} = options;
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: ROOT_DIR,
env,
stdio,
shell,
});
child.on('error', reject);
child.on('exit', (code, signal) => {
if (code === 0) {
resolve();
return;
}
if (allowFailure) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(' ')} failed with ${formatExit(code, signal)}`));
});
});
}
function captureCommand(command, args, options = {}) {
const {
env = process.env,
allowFailure = false,
shell = false,
} = options;
return new Promise((resolve, reject) => {
let stdout = '';
let stderr = '';
const child = spawn(command, args, {
cwd: ROOT_DIR,
env,
stdio: ['ignore', 'pipe', 'pipe'],
shell,
});
child.stdout?.setEncoding('utf8');
child.stderr?.setEncoding('utf8');
child.stdout?.on('data', (chunk) => {
stdout += chunk;
});
child.stderr?.on('data', (chunk) => {
stderr += chunk;
});
child.on('error', reject);
child.on('exit', (code, signal) => {
if (code === 0 || allowFailure) {
resolve({ code, signal, stdout, stderr });
return;
}
reject(new Error(`${command} ${args.join(' ')} failed with ${formatExit(code, signal)}`));
});
});
}
function startService(name, args, env) {
const child = spawn(PNPM_BIN, args, {
cwd: ROOT_DIR,
env,
stdio: 'inherit',
shell: USE_SHELL_FOR_PNPM,
detached: process.platform !== 'win32',
});
const handle = { name, child };
children.push(handle);
child.on('error', (error) => {
if (shuttingDown) return;
console.error(`[dev] Failed to start ${name}:`, error);
void shutdown(1);
});
child.on('exit', (code, signal) => {
if (shuttingDown) return;
const normalized = typeof code === 'number' && code !== 0 ? code : 1;
console.error(`[dev] ${name} exited unexpectedly (${formatExit(code, signal)}).`);
void shutdown(normalized);
});
}
async function waitForExit(child, timeoutMs = 5000) {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
await Promise.race([
new Promise((resolve) => child.once('exit', () => resolve(undefined))),
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
]);
}
async function stopService(handle) {
const { child } = handle;
if (!child.pid || child.exitCode !== null || child.signalCode !== null) {
return;
}
if (process.platform === 'win32') {
await runCommand(
'taskkill',
['/PID', String(child.pid), '/T', '/F'],
{ stdio: 'ignore', allowFailure: true },
);
return;
}
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
try {
child.kill('SIGTERM');
} catch {}
}
await waitForExit(child, 4000);
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
try {
process.kill(-child.pid, 'SIGKILL');
} catch {
try {
child.kill('SIGKILL');
} catch {}
}
}
async function shutdown(code = 0) {
if (shutdownPromise) {
return shutdownPromise;
}
shuttingDown = true;
shutdownPromise = (async () => {
for (const handle of [...children].reverse()) {
await stopService(handle);
}
process.exit(code);
})();
return shutdownPromise;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function canBindPort(port) {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.once('error', (error) => {
if (error?.code === 'EADDRINUSE') {
resolve(false);
return;
}
reject(error);
});
server.listen(port, '127.0.0.1', () => {
server.close(() => resolve(true));
});
});
}
function launchService(name, args, env, description) {
console.log(`[dev] Starting ${description} ...`);
startService(name, args, env);
}
async function isPortInUse(port) {
return !(await canBindPort(port));
}
async function waitForPortToBeFree(port, timeoutMs = 5000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await canBindPort(port)) {
return;
}
await sleep(200);
}
}
function splitOutputLines(value) {
return value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
async function resolveDockerPortOwners(port) {
let result;
try {
result = await captureCommand(
'docker',
['ps', '--filter', `publish=${port}`, '--format', '{{.Names}}\t{{.Ports}}'],
{ allowFailure: true },
);
} catch {
return [];
}
return splitOutputLines(result.stdout)
.map((line) => {
const [name, ports] = line.split('\t');
return {
name: name?.trim() || null,
ports: ports?.trim() || null,
};
})
.filter((owner) => owner.name);
}
async function resolveWindowsPortOwners(port) {
const result = await captureCommand(
'netstat',
['-ano', '-p', 'tcp'],
{ allowFailure: true },
);
const pids = new Set();
const matcher = new RegExp(
String.raw`^\s*TCP\s+\S+:${port}\s+\S+\s+LISTENING\s+(\d+)\s*$`,
'i',
);
for (const line of result.stdout.split(/\r?\n/)) {
const match = line.match(matcher);
if (match?.[1]) {
pids.add(match[1]);
}
}
const owners = [];
for (const pid of pids) {
const detailsResult = await captureCommand(
'powershell',
[
'-NoProfile',
'-Command',
`Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" | Select-Object ProcessId, Name, ExecutablePath, CommandLine | ConvertTo-Json -Compress`,
],
{ allowFailure: true },
);
const rawDetails = detailsResult.stdout.trim();
let details = null;
if (rawDetails) {
try {
details = JSON.parse(rawDetails);
} catch {}
}
if (details) {
owners.push({
pid: String(details.ProcessId ?? pid),
name: typeof details.Name === 'string' ? details.Name : null,
executablePath:
typeof details.ExecutablePath === 'string'
? details.ExecutablePath
: null,
commandLine:
typeof details.CommandLine === 'string' ? details.CommandLine : null,
});
continue;
}
const taskResult = await captureCommand(
'tasklist',
['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'],
{ allowFailure: true },
);
const firstLine = splitOutputLines(taskResult.stdout)[0] ?? '';
const match = firstLine.match(/^"([^"]+)","([^"]+)"/);
owners.push({
pid,
name: match?.[1] || null,
executablePath: null,
commandLine: null,
});
}
return owners.filter((owner) => owner.pid);
}
function truncateText(value, maxLength = 180) {
if (!value) {
return null;
}
const normalized = value.replace(/\s+/g, ' ').trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
}
function isProjectSolverContainerName(name) {
return typeof name === 'string'
? PROJECT_SOLVER_CONTAINER_NAMES.has(name.toLowerCase())
: false;
}
function isLikelyProjectSolverProcess(owner) {
const processName = owner.name?.trim().toLowerCase();
if (processName !== 'node.exe' && processName !== 'node') {
return false;
}
const commandLine = owner.commandLine
?.replace(/\//g, '\\')
.trim()
.toLowerCase();
if (!commandLine) {
return false;
}
const repoRoot = ROOT_DIR.replace(/\//g, '\\').toLowerCase();
const looksLikeSolverEntry =
commandLine.includes('tsx') &&
commandLine.includes('src\\server.ts');
return (
looksLikeSolverEntry &&
(commandLine.includes(repoRoot) ||
commandLine.includes('apps\\solver-service'))
);
}
async function probeSolverHealth(baseUrl, timeoutMs = 2000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${normalizeBaseUrl(baseUrl)}/health`, {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) {
return null;
}
const payload = await response.json();
if (!payload || typeof payload !== 'object') {
return null;
}
return payload;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
function isHealthySolverPayload(payload) {
return (
payload?.ok === true &&
typeof payload?.solverPath === 'string' &&
typeof payload?.resourcesPath === 'string'
);
}
async function buildPortConflictMessage(port) {
const dockerOwners = await resolveDockerPortOwners(port);
if (dockerOwners.length > 0) {
const owner = dockerOwners[0];
return `Port ${port} is already published by Docker container ${owner.name}${owner.ports ? ` (${owner.ports})` : ''}. Stop it with "docker stop ${owner.name}" and rerun pnpm dev.`;
}
if (process.platform === 'win32') {
const processOwners = await resolveWindowsPortOwners(port);
if (processOwners.length > 0) {
const owner = processOwners[0];
const commandLine = truncateText(owner.commandLine);
return `Port ${port} is already in use by PID ${owner.pid}${owner.name ? ` (${owner.name})` : ''}${commandLine ? ` using "${commandLine}"` : ''}. Stop that process and rerun pnpm dev.`;
}
}
return `Port ${port} is already in use. Stop the process using that port and rerun pnpm dev.`;
}
async function assertPortAvailable(port) {
if (!(await isPortInUse(port))) {
return;
}
throw new Error(await buildPortConflictMessage(port));
}
async function ensureInfrastructure(baseEnv) {
const [postgresInUse, redisInUse] = await Promise.all([
isPortInUse(DEFAULT_POSTGRES_PORT),
isPortInUse(DEFAULT_REDIS_PORT),
]);
if (postgresInUse || redisInUse) {
const busyPorts = [];
if (postgresInUse) busyPorts.push(`:${DEFAULT_POSTGRES_PORT}`);
if (redisInUse) busyPorts.push(`:${DEFAULT_REDIS_PORT}`);
console.log(
`[dev] Skipping Docker Postgres/Redis startup because ${busyPorts.join(' and ')} ${busyPorts.length === 1 ? 'is' : 'are'} already in use.`,
);
return;
}
console.log('[dev] Starting Postgres and Redis via Docker ...');
await runCommand('docker', ['compose', '-f', INFRA_COMPOSE_FILE, 'up', '-d'], {
env: baseEnv,
});
}
async function removeLegacySolverContainerIfNeeded() {
const dockerOwners = await resolveDockerPortOwners(DEFAULT_SOLVER_PORT);
const legacyOwner = dockerOwners.find(
(owner) => isProjectSolverContainerName(owner.name),
);
if (!legacyOwner) {
return;
}
console.log(`[dev] Removing stale solver container ${legacyOwner.name} ...`);
await runCommand('docker', ['rm', '-f', legacyOwner.name], {
allowFailure: true,
});
}
async function waitForSolverHealth(baseUrl, timeoutMs = 60_000) {
const healthUrl = `${normalizeBaseUrl(baseUrl)}/health`;
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(healthUrl, {
headers: { Accept: 'application/json' },
});
if (response.ok) {
const payload = await response.json();
if (payload?.ok === true) {
console.log(`[dev] Solver service healthy at ${healthUrl}`);
return;
}
lastError = new Error(
typeof payload?.error === 'string'
? payload.error
: 'health endpoint reported not ready',
);
} else {
lastError = new Error(`HTTP ${response.status}`);
}
} catch (error) {
lastError = error;
}
await sleep(1000);
}
throw new Error(
`solver-service did not become healthy at ${healthUrl}${lastError ? `: ${lastError instanceof Error ? lastError.message : String(lastError)}` : ''}`,
);
}
async function prepareDockerSolverPort(forceDockerBuild) {
const dockerOwners = await resolveDockerPortOwners(DEFAULT_SOLVER_PORT);
const projectDockerOwner = dockerOwners.find((owner) =>
isProjectSolverContainerName(owner.name),
);
const healthPayload = await probeSolverHealth(DEFAULT_DEV_SOLVER_SERVICE_URL);
if (projectDockerOwner) {
if (!forceDockerBuild && isHealthySolverPayload(healthPayload)) {
console.log(
`[dev] Reusing existing Docker solver-service ${projectDockerOwner.name} on :${DEFAULT_SOLVER_PORT}.`,
);
return { reused: true };
}
console.log(
`[dev] Removing project-owned solver listener ${projectDockerOwner.name} from :${DEFAULT_SOLVER_PORT} ...`,
);
await runCommand('docker', ['rm', '-f', projectDockerOwner.name], {
allowFailure: true,
});
await waitForPortToBeFree(DEFAULT_SOLVER_PORT);
}
if (process.platform === 'win32') {
const processOwners = await resolveWindowsPortOwners(DEFAULT_SOLVER_PORT);
const projectProcessOwner = processOwners.find((owner) =>
isLikelyProjectSolverProcess(owner) ||
(isHealthySolverPayload(healthPayload) &&
(owner.name?.trim().toLowerCase() === 'node.exe' ||
owner.name?.trim().toLowerCase() === 'node')),
);
if (projectProcessOwner) {
console.log(
`[dev] Stopping project-owned local solver listener on :${DEFAULT_SOLVER_PORT} from PID ${projectProcessOwner.pid}${projectProcessOwner.name ? ` (${projectProcessOwner.name})` : ''} ...`,
);
await runCommand(
'taskkill',
['/PID', String(projectProcessOwner.pid), '/T', '/F'],
{ stdio: 'ignore', allowFailure: true },
);
await waitForPortToBeFree(DEFAULT_SOLVER_PORT);
}
}
await assertPortAvailable(DEFAULT_SOLVER_PORT);
return { reused: false };
}
async function ensureDockerSolver(baseEnv, solverLaunchMode, forceDockerBuild) {
const dockerEnv = {
...baseEnv,
TEXASSOLVER_HOST_DIR: solverLaunchMode.dockerSolverDir,
};
const portState = await prepareDockerSolverPort(forceDockerBuild);
if (portState.reused) {
return;
}
console.log('[dev] Resetting Docker solver-service container state ...');
await runCommand(
'docker',
['compose', '-f', SOLVER_COMPOSE_FILE, 'down', '--remove-orphans'],
{
env: dockerEnv,
allowFailure: true,
},
);
await removeLegacySolverContainerIfNeeded();
await waitForPortToBeFree(DEFAULT_SOLVER_PORT);
await assertPortAvailable(DEFAULT_SOLVER_PORT);
const dockerComposeArgs = ['compose', '-f', SOLVER_COMPOSE_FILE, 'up', '-d'];
if (forceDockerBuild) {
dockerComposeArgs.push('--build');
}
console.log(
`[dev] Starting Docker solver-service on :${DEFAULT_SOLVER_PORT}${forceDockerBuild ? ' (rebuild enabled)' : ''} ...`,
);
console.log(`[dev] Docker TexasSolver mount: ${solverLaunchMode.dockerSolverDir}`);
await runCommand('docker', dockerComposeArgs, {
env: dockerEnv,
});
await waitForSolverHealth(DEFAULT_DEV_SOLVER_SERVICE_URL);
}
async function ensureLocalSolver(baseEnv, solverServiceEnv, solverLaunchMode) {
await assertPortAvailable(DEFAULT_SOLVER_PORT);
console.log(
`[dev] Starting local solver-service on :${DEFAULT_SOLVER_PORT} using ${solverLaunchMode.executablePath} ...`,
);
startService('solver-service', ['--filter', '@poker/solver-service', 'dev'], {
...baseEnv,
...solverServiceEnv,
TEXASSOLVER_DIR: solverLaunchMode.solverDir,
});
await waitForSolverHealth(DEFAULT_DEV_SOLVER_SERVICE_URL);
}
async function startApiAndWorker(baseEnv, apiEnv) {
console.log('[dev] Starting infrastructure checks for api and worker ...');
await ensureInfrastructure(baseEnv);
launchService('api', ['--filter', '@poker/api', 'dev'], {
...apiEnv,
START_WORKERS: '0',
}, `api on http://localhost:${apiEnv.PORT || DEFAULT_API_PORT}`);
launchService(
'worker',
['--filter', '@poker/api', 'dev:worker'],
{
...apiEnv,
START_WORKERS: '1',
},
'worker',
);
}
async function startSolver(baseEnv, solverServiceEnv, forceDockerBuild) {
const solverLaunchMode = resolveSolverLaunchMode(solverServiceEnv);
if (solverLaunchMode.source === 'repo-local') {
console.log(
`[dev] Using repo-local TexasSolver directory: ${solverLaunchMode.mode === 'docker' ? solverLaunchMode.dockerSolverDir : solverLaunchMode.solverDir}`,
);
}
console.log(
`[dev] Preparing solver startup (${solverLaunchMode.mode === 'docker' ? 'docker' : 'local'}) ...`,
);
if (solverLaunchMode.mode === 'docker') {
await ensureDockerSolver(baseEnv, solverLaunchMode, forceDockerBuild);
return;
}
await ensureLocalSolver(baseEnv, solverServiceEnv, solverLaunchMode);
}
async function main() {
const baseEnv = resolveBaseEnv();
const solverServiceEnv = resolveSolverServiceEnv();
const forceDockerBuild = process.env.DEV_DOCKER_BUILD === '1';
console.log('[dev] Building shared and table packages ...');
await runCommand(PNPM_BIN, ['--filter', '@poker/shared', 'build'], {
env: baseEnv,
shell: USE_SHELL_FOR_PNPM,
});
await runCommand(PNPM_BIN, ['--filter', '@poker/table', 'build'], {
env: baseEnv,
shell: USE_SHELL_FOR_PNPM,
});
const solverEnv = resolveSolverDevEnv(baseEnv);
console.log(
`[dev] Solver URL for api/worker: ${solverEnv.env.SOLVER_SERVICE_URL} (${solverEnv.source})`,
);
const sharedSolverEnv = withPort({ ...baseEnv, ...solverEnv.env });
const webEnv = withPort(
sharedSolverEnv,
baseEnv.WEB_PORT?.trim() || String(DEFAULT_WEB_PORT),
);
const apiEnv = withPort(
sharedSolverEnv,
baseEnv.PORT?.trim() || String(DEFAULT_API_PORT),
);
console.log('[dev] Starting table watcher and web first so localhost:3000 is not gated on solver startup.');
launchService('table', ['--filter', '@poker/table', 'dev'], sharedSolverEnv, 'table watcher');
launchService(
'web',
['--filter', '@poker/web', 'dev'],
webEnv,
`web on http://localhost:${webEnv.PORT || DEFAULT_WEB_PORT}`,
);
const [backendResult, solverResult] = await Promise.allSettled([
startApiAndWorker(baseEnv, apiEnv),
startSolver(baseEnv, solverServiceEnv, forceDockerBuild),
]);
if (backendResult.status === 'rejected') {
console.error(
`[dev] API/worker startup failed before launch: ${formatError(backendResult.reason)}`,
);
console.error(
'[dev] Web remains on http://localhost:3000, but api and worker were not started.',
);
}
if (solverResult.status === 'rejected') {
console.error(
`[dev] Solver unavailable: ${formatError(solverResult.reason)}`,
);
console.error(
'[dev] Web remains on http://localhost:3000. Solver-backed analysis will fail until port 4010 is available and solver-service is healthy.',
);
}
}
process.on('SIGINT', () => {
if (shuttingDown) return;
console.log('\n[dev] Received SIGINT. Shutting down ...');
void shutdown(0);
});
process.on('SIGTERM', () => {
if (shuttingDown) return;
console.log('\n[dev] Received SIGTERM. Shutting down ...');
void shutdown(0);
});
void main().catch((error) => {
console.error('[dev] Startup failed:', error);
void shutdown(1);
});
Get-Content .envExit code: 0 Wall time: 0.3 seconds Output: [REDACTED_ENV_BLOCK]PORT=3001 WEB_PORT=3000 [REDACTED_ENV_BLOCK]
Get-Content apps\api\package.jsonExit code: 0
Wall time: 0.3 seconds
Output:
{
"name": "@poker/api",
"version": "1.0.0",
"type": "module",
"scripts": {
"predev": "node -e \"console.log('skip prisma generate (run pnpm --filter @poker/api db:generate when schema changes)')\"",
"predev:worker": "node -e \"console.log('skip prisma generate (run pnpm --filter @poker/api db:generate when schema changes)')\"",
"dev": "tsx watch src/index.ts",
"dev:worker": "tsx watch src/worker.ts",
"prebuild": "prisma generate",
"build": "tsc",
"lint": "tsc --noEmit",
"start": "node dist/index.js",
"start:api": "node dist/index.js",
"start:api:deploy": "pnpm run db:migrate:deploy && node dist/index.js",
"start:worker": "node dist/worker.js",
"start:worker:deploy": "pnpm run db:migrate:deploy && node dist/worker.js",
"worker:dev": "tsx src/worker.ts",
"worker:analysis": "tsx src/workers/analysis-runner.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"db:migrate": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy",
"db:generate": "prisma generate",
"db:studio": "prisma studio"
},
"dependencies": {
"@poker/shared": "workspace:*",
"@poker/table": "workspace:*",
"@prisma/client": "^6.1.0",
"prisma": "^6.1.0",
"bcrypt": "^6.0.0",
"bullmq": "^5.67.1",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"ioredis": "^5.4.2",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"resend": "^6.3.0",
"socket.io": "^4.8.1",
"undici": "^6.20.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
Get-Content apps\web\package.jsonExit code: 0
Wall time: 0.3 seconds
Output:
{
"name": "@poker/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"lint": "tsc --noEmit",
"start": "next start",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@poker/shared": "workspace:*",
"@poker/table": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-tabs": "^1.1.13",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"bcrypt": "^6.0.0",
"clsx": "^2.1.1",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"framer-motion": "^12.34.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.563.0",
"motion": "^12.34.0",
"next": "^15.5.12",
"next-auth": "^4.24.13",
"react": "^19.0.0",
"react-casino": "^0.2.6",
"react-dom": "^19.0.0",
"react-resizable-panels": "^4.6.2",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^3.24.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.10.2",
"@types/react": "^19.0.6",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
Get-Content apps\solver-service\package.jsonExit code: 0
Wall time: 0.3 seconds
Output:
{
"name": "@poker/solver-service",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"predev": "pnpm --filter @poker/shared build",
"dev": "tsx watch src/server.ts",
"build": "tsc",
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"prestart": "pnpm --filter @poker/shared build",
"start": "node dist/server.js",
"pretest": "pnpm --filter @poker/shared build",
"test": "vitest run"
},
"dependencies": {
"@poker/shared": "workspace:*",
"dotenv": "^16.4.7",
"express": "^4.21.2"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
Before I start the stack, I’m checking the analysis debug UI flags the e2e test depends on. That flow explicitly requires the debug panel to be enabled, so I need to confirm whether the app boots with those settings or whether the failure would just be an environment mismatch.
rg -n --hidden --glob '!node_modules' "NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI|ANALYSIS_DEBUG_HTTP|ai-debug-panel|ai-debug-payload|ai-debug-copy-button"Exit code: 0
Wall time: 0.7 seconds
Output:
README.md:120:- API debug routes are off by default behind `ANALYSIS_DEBUG_HTTP=1`, and the hand-review debug UI is off by default behind `NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI=1`.
apps\api\.env.production.example:44:ANALYSIS_DEBUG_HTTP=0
tests\e2e\analysis-flow.spec.ts:534: const debugPanel = page.getByTestId('ai-debug-panel');
tests\e2e\analysis-flow.spec.ts:537: 'The overview debug log is not visible. Enable `NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI=1` on the web app and `ANALYSIS_DEBUG_HTTP=1` on the API to inspect the analysis debug payload in this flow.',
tests\e2e\analysis-flow.spec.ts:541: await expect(page.getByTestId('ai-debug-copy-button')).toBeVisible();
tests\e2e\analysis-flow.spec.ts:543: const debugPayload = page.getByTestId('ai-debug-payload');
apps\api\src\analysis-pipeline.test.ts:142: delete process.env.ANALYSIS_DEBUG_HTTP;
apps\api\src\analysis-pipeline.test.ts:804: const previousDebugHttp = process.env.ANALYSIS_DEBUG_HTTP;
apps\api\src\analysis-pipeline.test.ts:805: process.env.ANALYSIS_DEBUG_HTTP = '1';
apps\api\src\analysis-pipeline.test.ts:855: delete process.env.ANALYSIS_DEBUG_HTTP;
apps\api\src\analysis-pipeline.test.ts:857: process.env.ANALYSIS_DEBUG_HTTP = previousDebugHttp;
apps\api\src\analysis-pipeline.test.ts:865: const previousDebugHttp = process.env.ANALYSIS_DEBUG_HTTP;
apps\api\src\analysis-pipeline.test.ts:866: process.env.ANALYSIS_DEBUG_HTTP = '1';
apps\api\src\analysis-pipeline.test.ts:964: delete process.env.ANALYSIS_DEBUG_HTTP;
apps\api\src\analysis-pipeline.test.ts:966: process.env.ANALYSIS_DEBUG_HTTP = previousDebugHttp;
apps\api\src\services\hand-actions.ts:197: return process.env.ANALYSIS_DEBUG_HTTP === '1';
apps\api\src\services\hand-actions.test.ts:303: delete process.env.ANALYSIS_DEBUG_HTTP;
apps\api\src\services\hand-actions.test.ts:944: process.env.ANALYSIS_DEBUG_HTTP = '1';
apps\api\src\routes\analysis-rest.ts:51: return process.env.ANALYSIS_DEBUG_HTTP === '1';
apps\web\src\components\table\AnalysisDrawer.tsx:142: return process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI === '1';
apps\web\src\app\hands\[handId]\page.tsx:31: return process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI === '1';
apps\web\src\app\hands\[handId]\page.tsx:4199: data-testid="ai-debug-panel"
apps\web\src\app\hands\[handId]\page.tsx:4211: data-testid="ai-debug-copy-button"
apps\web\src\app\hands\[handId]\page.tsx:4228: data-testid="ai-debug-payload"
apps\web\src\app\hands\hand-detail-page.test.tsx:438: clone.querySelector('[data-testid="ai-debug-panel"]')?.remove();
apps\web\src\app\hands\hand-detail-page.test.tsx:1374: const previousDebugUi = process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI;
apps\web\src\app\hands\hand-detail-page.test.tsx:1375: process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI = '1';
apps\web\src\app\hands\hand-detail-page.test.tsx:1382: const aiDebugPanel = renderResult.container.querySelector('[data-testid="ai-debug-panel"]');
apps\web\src\app\hands\hand-detail-page.test.tsx:1403: delete process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI;
apps\web\src\app\hands\hand-detail-page.test.tsx:1405: process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI = previousDebugUi;
apps\web\src\app\hands\hand-detail-page.test.tsx:1414: const aiDebugPanel = renderResult.container.querySelector('[data-testid="ai-debug-panel"]');
apps\web\src\app\hands\hand-detail-page.test.tsx:1416: expect(renderResult.container.querySelector('[data-testid="ai-debug-copy-button"]')).toBeNull();
apps\web\src\app\hands\hand-detail-page.test.tsx:1432: const aiDebugPanel = renderResult.container.querySelector('[data-testid="ai-debug-panel"]');
apps\web\src\app\hands\hand-detail-page.test.tsx:1435: const copyButton = renderResult.container.querySelector('[data-testid="ai-debug-copy-button"]') as HTMLButtonElement | null;
apps\web\src\app\hands\hand-detail-page.test.tsx:1569: renderResult.container.querySelector('[data-testid="ai-debug-payload"]') as HTMLTextAreaElement | null
apps\web\src\app\hands\hand-detail-page.test.tsx:1586: const previousDebugUi = process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI;
apps\web\src\app\hands\hand-detail-page.test.tsx:1587: process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI = '1';
apps\web\src\app\hands\hand-detail-page.test.tsx:1676: '[data-testid="ai-debug-payload"]',
apps\web\src\app\hands\hand-detail-page.test.tsx:1685: delete process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI;
apps\web\src\app\hands\hand-detail-page.test.tsx:1687: process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI = previousDebugUi;
apps\web\src\app\hands\hand-detail-page.test.tsx:1784: '[data-testid="ai-debug-payload"]',
apps\web\src\app\hands\hand-detail-page.test.tsx:1800: const previousDebugUi = process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI;
apps\web\src\app\hands\hand-detail-page.test.tsx:1802: env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI = '1';
apps\web\src\app\hands\hand-detail-page.test.tsx:1828: expect(renderResult.container.querySelector('[data-testid="ai-debug-panel"]')).not.toBeNull();
apps\web\src\app\hands\hand-detail-page.test.tsx:1837: delete env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI;
apps\web\src\app\hands\hand-detail-page.test.tsx:1839: env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI = previousDebugUi;
apps\web\src\app\hands\hand-detail-page.test.tsx:1972: const previousDebugUi = process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI;
apps\web\src\app\hands\hand-detail-page.test.tsx:1973: delete process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI;
apps\web\src\app\hands\hand-detail-page.test.tsx:2061: expect(renderResult.container.querySelector('[data-testid="ai-debug-panel"]')).toBeNull();
apps\web\src\app\hands\hand-detail-page.test.tsx:2067: delete process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI;
apps\web\src\app\hands\hand-detail-page.test.tsx:2069: process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI = previousDebugUi;
apps\web\src\app\hands\hand-detail-page.test.tsx:2076: const previousDebugUi = process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI;
apps\web\src\app\hands\hand-detail-page.test.tsx:2077: process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI = '1';
apps\web\src\app\hands\hand-detail-page.test.tsx:2163: delete process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI;
apps\web\src\app\hands\hand-detail-page.test.tsx:2165: process.env.NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI = previousDebugUi;
Test-Path apps\solver-service\texassolver\console_solverExit code: 0 Wall time: 0.2 seconds Output: True
Test-Path apps\solver-service\.envExit code: 0 Wall time: 0.2 seconds Output: True
The stack isn’t up, so I’m starting the repo’s own pnpm dev entrypoint with the debug flags the e2e requires. After it comes up, I’ll run the Playwright spec exactly as configured and keep the first failure intact for diagnosis.
$proc = Start-Process -FilePath cmd.exe -ArgumentList '/c', 'set ANALYSIS_DEBUG_HTTP=1&& set NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI=1&& pnpm dev > .codex-dev.log 2>&1' -WorkingDirectory 'E:\Desktop\Poker' -PassThru; $proc.IdExit code: 0 Wall time: 0.3 seconds Output: 17452
Get-Content .codex-dev.log -Tail 80Exit code: 0
Wall time: 0.2 seconds
Output:
> poker-practice-mvp@1.0.0 dev E:\Desktop\Poker
> node scripts/dev.mjs
[dev] Building shared and table packages ...
> @poker/shared@1.0.0 build E:\Desktop\Poker\packages\shared
> tsc
> @poker/table@1.0.0 build E:\Desktop\Poker\packages\table
> tsc -p tsconfig.json
[dev] Solver URL for api/worker: http://127.0.0.1:4010 (SOLVER_SERVICE_URL)
[dev] Starting table watcher and web first so localhost:3000 is not gated on solver startup.
[dev] Starting table watcher ...
[dev] Starting web on http://localhost:3000 ...
[dev] Starting infrastructure checks for api and worker ...
[dev] Using repo-local TexasSolver directory: E:\Desktop\Poker\apps\solver-service\texassolver
[dev] Preparing solver startup (docker) ...
[dev] Starting Postgres and Redis via Docker ...
Container infra-redis-1 Running
Container infra-postgres-1 Running
[dev] Starting api on http://localhost:3001 ...
[dev] Starting worker ...
> @poker/table@1.0.0 dev E:\Desktop\Poker\packages\table
> tsc -w -p tsconfig.json --preserveWatchOutput
> @poker/web@1.0.0 dev E:\Desktop\Poker\apps\web
> next dev
[dev] Reusing existing Docker solver-service pokerworker-solver-service-1 on :4010.
1:14:10 a.m. - Starting compilation in watch mode...
> @poker/api@1.0.0 predev:worker E:\Desktop\Poker\apps\api
> node -e "console.log('skip prisma generate (run pnpm --filter @poker/api db:generate when schema changes)')"
> @poker/api@1.0.0 predev E:\Desktop\Poker\apps\api
> node -e "console.log('skip prisma generate (run pnpm --filter @poker/api db:generate when schema changes)')"
skip prisma generate (run pnpm --filter @poker/api db:generate when schema changes)
> @poker/api@1.0.0 dev:worker E:\Desktop\Poker\apps\api
> tsx watch src/worker.ts
skip prisma generate (run pnpm --filter @poker/api db:generate when schema changes)
> @poker/api@1.0.0 dev E:\Desktop\Poker\apps\api
> tsx watch src/index.ts
1:14:11 a.m. - Found 0 errors. Watching for file changes.
猕?Failed to start server
Error: listen EADDRINUSE: address already in use :::3000
at <unknown> (Error: listen EADDRINUSE: address already in use :::3000)
at new Promise (<anonymous>) {
code: 'EADDRINUSE',
errno: -4091,
syscall: 'listen',
address: '::',
port: 3000
}
[?25h
[dev] web exited unexpectedly (exit code 0).
[CONFIG][solver] using http://127.0.0.1:4010 (SOLVER_SERVICE_URL)
鈥塃LIFECYCLE鈥?Command failed with exit code 1.
try { (Invoke-WebRequest -Uri http://localhost:3000 -MaximumRedirection 0 -ErrorAction Stop).StatusCode } catch { if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { [int]$_.Exception.Response.StatusCode } else { $_.Exception.Message } }Exit code: 0 Wall time: 0.6 seconds Output: Object reference not set to an instance of an object.
try { (Invoke-WebRequest -Uri http://localhost:3001/api/health -ErrorAction Stop).Content } catch { if ($_.Exception.Response) { $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()); $reader.ReadToEnd() } else { $_.Exception.Message } }Exit code: 0 Wall time: 2.5 seconds Output: Object reference not set to an instance of an object.
The repo’s own dev script failed because port 3000 was already occupied by another process, so I’m identifying that owner next. If it’s this same repo, I’ll replace it with a fresh run under the required debug flags; if it’s unrelated, I’ll route around it instead of guessing.
Get-NetTCPConnection -LocalPort 3000 -State Listen | Select-Object -First 5 -Property OwningProcess, LocalAddress, LocalPort, State | Format-ListExit code: 0 Wall time: 1.3 seconds Output: OwningProcess : 24188 LocalAddress : :: LocalPort : 3000 State : Listen
$pid = (Get-NetTCPConnection -LocalPort 3000 -State Listen | Select-Object -First 1 -ExpandProperty OwningProcess); if ($pid) { Get-CimInstance Win32_Process -Filter "ProcessId = $pid" | Select-Object ProcessId, Name, ExecutablePath, CommandLine | Format-List }Exit code: 0
Wall time: 1.9 seconds
Output:
ProcessId : 44244
Name : powershell.exe
ExecutablePath : C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
CommandLine : "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" -Command
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;
$pid = (Get-NetTCPConnection -LocalPort 3000 -State Listen | Select-Object -First 1 -ExpandProperty
OwningProcess); if ($pid) { Get-CimInstance Win32_Process -Filter \"ProcessId = $pid\" |
Select-Object ProcessId, Name, ExecutablePath, CommandLine | Format-List }"
Cannot overwrite variable PID because it is read-only or constant.
At line:2 char:1
+ $pid = (Get-NetTCPConnection -LocalPort 3000 -State Listen | Select-O ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : WriteError: (PID:String) [], SessionStateUnauthorizedAccessException
+ FullyQualifiedErrorId : VariableNotWritable
Get-NetTCPConnection -LocalPort 3001 -State Listen | Select-Object -First 5 -Property OwningProcess, LocalAddress, LocalPort, State | Format-ListExit code: 0 Wall time: 1.3 seconds Output: OwningProcess : 22912 LocalAddress : 0.0.0.0 LocalPort : 3001 State : Listen
$pid = (Get-NetTCPConnection -LocalPort 3001 -State Listen | Select-Object -First 1 -ExpandProperty OwningProcess); if ($pid) { Get-CimInstance Win32_Process -Filter "ProcessId = $pid" | Select-Object ProcessId, Name, ExecutablePath, CommandLine | Format-List }Exit code: 0
Wall time: 1.8 seconds
Output:
ProcessId : 53952
Name : powershell.exe
ExecutablePath : C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
CommandLine : "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" -Command
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;
$pid = (Get-NetTCPConnection -LocalPort 3001 -State Listen | Select-Object -First 1 -ExpandProperty
OwningProcess); if ($pid) { Get-CimInstance Win32_Process -Filter \"ProcessId = $pid\" |
Select-Object ProcessId, Name, ExecutablePath, CommandLine | Format-List }"
Cannot overwrite variable PID because it is read-only or constant.
At line:2 char:1
+ $pid = (Get-NetTCPConnection -LocalPort 3001 -State Listen | Select-O ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : WriteError: (PID:String) [], SessionStateUnauthorizedAccessException
+ FullyQualifiedErrorId : VariableNotWritable
$owner = (Get-NetTCPConnection -LocalPort 3000 -State Listen | Select-Object -First 1 -ExpandProperty OwningProcess); if ($owner) { Get-CimInstance Win32_Process -Filter "ProcessId = $owner" | Select-Object ProcessId, Name, ExecutablePath, CommandLine | Format-List }Exit code: 0
Wall time: 1.7 seconds
Output:
ProcessId : 24188
Name : node.exe
ExecutablePath : C:\Program Files\nodejs\node.exe
CommandLine : "C:\Program Files\nodejs\node.exe" E:\Desktop\Poker\node_modules\.pnpm\next@15.5.12_@playwright+te_6f2
a44a362469f86afb89a3f585df07a\node_modules\next\dist\server\lib\start-server.js
$owner = (Get-NetTCPConnection -LocalPort 3001 -State Listen | Select-Object -First 1 -ExpandProperty OwningProcess); if ($owner) { Get-CimInstance Win32_Process -Filter "ProcessId = $owner" | Select-Object ProcessId, Name, ExecutablePath, CommandLine | Format-List }Exit code: 0
Wall time: 1.8 seconds
Output:
ProcessId : 22912
Name : node.exe
ExecutablePath : C:\Program Files\nodejs\node.exe
CommandLine : "C:\Program Files\nodejs\node.exe" --require
E:\Desktop\Poker\node_modules\.pnpm\tsx@4.20.6\node_modules\tsx\dist\preflight.cjs --import
file:///E:/Desktop/Poker/node_modules/.pnpm/tsx@4.20.6/node_modules/tsx/dist/loader.mjs src/index.ts
$owner = (Get-NetTCPConnection -LocalPort 4010 -State Listen | Select-Object -First 1 -ExpandProperty OwningProcess); if ($owner) { Get-CimInstance Win32_Process -Filter "ProcessId = $owner" | Select-Object ProcessId, Name, ExecutablePath, CommandLine | Format-List }Exit code: 0
Wall time: 1.8 seconds
Output:
ProcessId : 4896
Name : wslrelay.exe
ExecutablePath : C:\Program Files\WSL\wslrelay.exe
CommandLine : --mode 2 --vm-id {7acf372a-7d82-435a-9c1f-0b6a4e02899e} --handle 2864
Ports 3000 and 3001 are already served by this same repo’s Node processes, which explains the startup conflict. I’m enumerating the rest of the repo-owned processes now so I can restart the stack cleanly under the required env flags instead of mixing old and new instances.
Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object { $_.CommandLine -like '*E:\Desktop\Poker*' } | Select-Object ProcessId, CommandLine | Sort-Object ProcessId | Format-Table -WrapExit code: 0
Wall time: 0.7 seconds
Output:
ProcessId CommandLine
--------- -----------
2244 "C:\Program Files\nodejs\node.exe" --require
E:\Desktop\Poker\node_modules\.pnpm\tsx@4.20.6\node_modules\tsx\dist\preflight.cjs --import
file:///E:/Desktop/Poker/node_modules/.pnpm/tsx@4.20.6/node_modules/tsx/dist/loader.mjs
tmp-force-reanalysis.ts
17184 node "E:\Desktop\Poker\packages\table\node_modules\.bin\\..\typescript\bin\tsc" -w -p tsconfig.json
--preserveWatchOutput
17420 node "E:\Desktop\Poker\apps\api\node_modules\.bin\\..\tsx\dist\cli.mjs" "tmp-force-reanalysis.ts"
22912 "C:\Program Files\nodejs\node.exe" --require
E:\Desktop\Poker\node_modules\.pnpm\tsx@4.20.6\node_modules\tsx\dist\preflight.cjs --import
file:///E:/Desktop/Poker/node_modules/.pnpm/tsx@4.20.6/node_modules/tsx/dist/loader.mjs src/index.ts
24188 "C:\Program Files\nodejs\node.exe" E:\Desktop\Poker\node_modules\.pnpm\next@15.5.12_@playwright+te_6f2a44a362
469f86afb89a3f585df07a\node_modules\next\dist\server\lib\start-server.js
29204 node "E:\Desktop\Poker\apps\api\node_modules\.bin\\..\tsx\dist\cli.mjs" watch src/worker.ts
29700 "C:\Program Files\nodejs\node.exe" --require
E:\Desktop\Poker\node_modules\.pnpm\tsx@4.20.6\node_modules\tsx\dist\preflight.cjs --import
file:///E:/Desktop/Poker/node_modules/.pnpm/tsx@4.20.6/node_modules/tsx/dist/loader.mjs src/worker.ts
30008 node "E:\Desktop\Poker\apps\web\node_modules\.bin\\..\next\dist\bin\next" dev
59476 node "E:\Desktop\Poker\apps\api\node_modules\.bin\\..\tsx\dist\cli.mjs" watch src/index.ts
I found an older repo dev stack still running: Next dev, API watch, worker watch, and the table watcher. I’m stopping just those repo watcher processes, then I’ll restart pnpm dev cleanly so the e2e runs against one consistent stack with the debug UI enabled.
foreach ($id in 30008, 59476, 29204, 17184) { taskkill /PID $id /T /F 2>$null | Out-Null }; Start-Sleep -Seconds 2; Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object { $_.CommandLine -like '*E:\Desktop\Poker*' } | Select-Object ProcessId, CommandLine | Sort-Object ProcessId | Format-Table -WrapExit code: 0
Wall time: 4.8 seconds
Output:
ProcessId CommandLine
--------- -----------
2244 "C:\Program Files\nodejs\node.exe" --require
E:\Desktop\Poker\node_modules\.pnpm\tsx@4.20.6\node_modules\tsx\dist\preflight.cjs --import
file:///E:/Desktop/Poker/node_modules/.pnpm/tsx@4.20.6/node_modules/tsx/dist/loader.mjs
tmp-force-reanalysis.ts
17420 node "E:\Desktop\Poker\apps\api\node_modules\.bin\\..\tsx\dist\cli.mjs" "tmp-force-reanalysis.ts"
Remove-Item .codex-dev.log -ErrorAction SilentlyContinue; $proc = Start-Process -FilePath cmd.exe -ArgumentList '/c', 'set ANALYSIS_DEBUG_HTTP=1&& set NEXT_PUBLIC_ENABLE_ANALYSIS_DEBUG_UI=1&& pnpm dev > .codex-dev.log 2>&1' -WorkingDirectory 'E:\Desktop\Poker' -PassThru; $proc.IdExit code: 0 Wall time: 0.3 seconds Output: 56024
$deadline = (Get-Date).AddMinutes(2); do { Start-Sleep -Seconds 3; $webOk = $false; $apiOk = $false; try { $resp = Invoke-WebRequest -Uri http://localhost:3000 -MaximumRedirection 0 -ErrorAction Stop; $webOk = $resp.StatusCode -ge 200 } catch { if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -in 301,302,307,308) { $webOk = $true } }; try { $health = Invoke-RestMethod -Uri http://localhost:3001/api/health -ErrorAction Stop; if ($health.analysisWorker.available -eq $true) { $apiOk = $true } } catch {}; if ($webOk -and $apiOk) { 'READY'; break } } while ((Get-Date) -lt $deadline); if (-not ($webOk -and $apiOk)) { 'NOT_READY'; Get-Content .codex-dev.log -Tail 120 }Exit code: 0
Wall time: 121.2 seconds
Output:
NOT_READY
[dev] Preparing solver startup (docker) ...
[dev] Starting Postgres and Redis via Docker ...
Container infra-redis-1 Running
Container infra-postgres-1 Running
[dev] Starting api on http://localhost:3001 ...
[dev] Starting worker ...
> @poker/table@1.0.0 dev E:\Desktop\Poker\packages\table
> tsc -w -p tsconfig.json --preserveWatchOutput
> @poker/web@1.0.0 dev E:\Desktop\Poker\apps\web
> next dev
[dev] Reusing existing Docker solver-service pokerworker-solver-service-1 on :4010.
1:15:33 a.m. - Starting compilation in watch mode...
> @poker/api@1.0.0 predev E:\Desktop\Poker\apps\api
> node -e "console.log('skip prisma generate (run pnpm --filter @poker/api db:generate when schema changes)')"
> @poker/api@1.0.0 predev:worker E:\Desktop\Poker\apps\api
> node -e "console.log('skip prisma generate (run pnpm --filter @poker/api db:generate when schema changes)')"
skip prisma generate (run pnpm --filter @poker/api db:generate when schema changes)
> @poker/api@1.0.0 dev E:\Desktop\Poker\apps\api
> tsx watch src/index.ts
skip prisma generate (run pnpm --filter @poker/api db:generate when schema changes)
> @poker/api@1.0.0 dev:worker E:\Desktop\Poker\apps\api
> tsx watch src/worker.ts
1:15:34 a.m. - Found 0 errors. Watching for file changes.
鈻?Next.js 15.5.12
- Local: http://localhost:3000
- Network: http://172.19.192.1:3000
- Environments: .env.local
鉁?Starting...
[CONFIG][solver] using http://127.0.0.1:4010 (SOLVER_SERVICE_URL)
[CONFIG][solver] using http://127.0.0.1:4010 (SOLVER_SERVICE_URL)
[WORKER BOOT] cwd= E:\Desktop\Poker\apps\api
[WORKER BOOT] processor { mode: 'inline', sandboxed: false }
[WORKER BOOT] solver timeouts {
solverUrl: 'http://127.0.0.1:4010',
solverUrlSource: 'SOLVER_SERVICE_URL',
solverTargetMs: 300000,
solverTimeoutMs: 600000,
solverHttpTimeoutMs: 630000,
analysisJobTimeoutMs: 750000,
solverHttp408RetryCount: 2,
solverHttpMaxAttempts: 3,
handAnalysisMaxDecisionRetries: 3,
analysisWorkerConcurrency: 8,
workerLimiterMax: 1,
workerLimiterDurationMs: 1000,
solverHttp429CooldownMs: 10000,
queueGlobalSolverSlots: 1,
queueGlobalRateLimitPerSec: 1,
queueRetryAttempts: 3,
queueRetryBackoffMs: 1500
}
[analysis-worker] started
[REDIS] Connected to Redis
[ANALYSIS WORKER] ready
[analysis-queue] global limits configured { solverSlots: 1, solverRateLimitPerSec: 1 }
鉁?Ready in 2.9s
[API BOOT] cwd= E:\Desktop\Poker\apps\api
[API BOOT] redis host= 127.0.0.1:6379
[API BOOT] DATABASE_URL= [REDACTED]
[SERVER] API server running on http://0.0.0.0:3001
鈼?Compiling / ...
[baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D`
鉁?Compiled / in 4.3s (1634 modules)
GET / 200 in 5006ms
鉁?Compiled in 917ms (737 modules)
[REDIS] Connected to Redis
GET / 200 in 140ms
GET / 200 in 133ms
GET / 200 in 135ms
GET / 200 in 142ms
GET / 200 in 132ms
[ANALYSIS WORKER] ready
GET / 200 in 152ms
GET / 200 in 155ms
GET / 200 in 159ms
GET / 200 in 158ms
GET / 200 in 167ms
GET / 200 in 159ms
GET / 200 in 155ms
GET / 200 in 165ms
GET / 200 in 154ms
GET / 200 in 184ms
[ANALYSIS WORKER] ready
GET / 200 in 181ms
GET / 200 in 154ms
GET / 200 in 157ms
GET / 200 in 160ms
GET / 200 in 154ms
GET / 200 in 158ms
GET / 200 in 152ms
GET / 200 in 156ms
[ANALYSIS WORKER] ready
GET / 200 in 153ms
GET / 200 in 163ms
GET / 200 in 159ms
GET / 200 in 156ms
GET / 200 in 134ms
GET / 200 in 137ms
GET / 200 in 138ms
GET / 200 in 137ms
GET / 200 in 126ms
GET / 200 in 142ms
[ANALYSIS WORKER] ready
GET / 200 in 155ms
Invoke-WebRequest -Uri http://localhost:3000 -MaximumRedirection 0 | Select-Object -ExpandProperty StatusCodeExit code: 1
Wall time: 0.6 seconds
Output:
Invoke-WebRequest : Object reference not set to an instance of an object.
At line:2 char:1
+ Invoke-WebRequest -Uri http://localhost:3000 -MaximumRedirection 0 | ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [Invoke-WebRequest], NullReferenceException
+ FullyQualifiedErrorId : System.NullReferenceException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand
Invoke-RestMethod -Uri http://localhost:3001/api/health | ConvertTo-Json -Depth 6Exit code: 0
Wall time: 2.4 seconds
Output:
{
"status": "ok",
"timestamp": "2026-03-26T05:17:48.727Z",
"analysisWorker": {
"configured": false,
"inProcessRunning": false,
"available": true
}
}
Get-Content .codex-dev.log -Tail 40Exit code: 0 Wall time: 0.3 seconds Output: 鉁?Compiled in 917ms (737 modules) [REDIS] Connected to Redis GET / 200 in 140ms GET / 200 in 133ms GET / 200 in 135ms GET / 200 in 142ms GET / 200 in 132ms [ANALYSIS WORKER] ready GET / 200 in 152ms GET / 200 in 155ms GET / 200 in 159ms GET / 200 in 158ms GET / 200 in 167ms GET / 200 in 159ms GET / 200 in 155ms GET / 200 in 165ms GET / 200 in 154ms GET / 200 in 184ms [ANALYSIS WORKER] ready GET / 200 in 181ms GET / 200 in 154ms GET / 200 in 157ms GET / 200 in 160ms GET / 200 in 154ms GET / 200 in 158ms GET / 200 in 152ms GET / 200 in 156ms [ANALYSIS WORKER] ready GET / 200 in 153ms GET / 200 in 163ms GET / 200 in 159ms GET / 200 in 156ms GET / 200 in 134ms GET / 200 in 137ms GET / 200 in 138ms GET / 200 in 137ms GET / 200 in 126ms GET / 200 in 142ms [ANALYSIS WORKER] ready GET / 200 in 155ms
The stack is now healthy enough to run the e2e, so I’m invoking the single configured Playwright spec directly. I’m not touching code yet; the next step is to preserve the first real failure with its artifacts and correlate it to the product path.
pnpm exec playwright test tests/e2e/analysis-flow.spec.ts --project=chromiumExit code: 1
Wall time: 316.8 seconds
Output:
Running 1 test using 1 worker
[e2e] action Call5 Waiting Hand in progress
[e2e] action Call10 Flop Hand in progress
[e2e] action Call13 Turn Hand in progress
[e2e] analyze-hand River Hand in progress
[e2e] action Call21 River Report queued
x 1 [chromium] › tests\e2e\analysis-flow.spec.ts:321:5 › runs the full postflop analysis flow and exposes the debug log (5.2m)
1) [chromium] › tests\e2e\analysis-flow.spec.ts:321:5 › runs the full postflop analysis flow and exposes the debug log › Verify preflop LLM and postflop solver-plus-LLM content on every street
Error: [31mTimed out 30000ms waiting for [39m[2mexpect([22m[31mlocator[39m[2m).[22mtoBeVisible[2m()[22m
Locator: getByRole('button', { name: 'Preflop 1', exact: true })
Expected: visible
Received: <element(s) not found>
Call log:
[2m - expect.toBeVisible with timeout 30000ms[22m
[2m - waiting for getByRole('button', { name: 'Preflop 1', exact: true })[22m
599 | exact: true,
600 | });
> 601 | await expect(decisionButton).toBeVisible();
| ^
602 | await decisionButton.click();
603 |
604 | const notes = page.getByTestId('analysis-notes');
at visitStreet (E:\Desktop\Poker\tests\e2e\analysis-flow.spec.ts:601:38)
at E:\Desktop\Poker\tests\e2e\analysis-flow.spec.ts:620:7
at E:\Desktop\Poker\tests\e2e\analysis-flow.spec.ts:577:5
attachment #6: screenshot (image/png) ──────────────────────────────────────────────────────────
test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\test-failed-1.png
────────────────────────────────────────────────────────────────────────────────────────────────
attachment #7: video (video/webm) ──────────────────────────────────────────────────────────────
test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\video.webm
────────────────────────────────────────────────────────────────────────────────────────────────
attachment #9: trace (application/zip) ─────────────────────────────────────────────────────────
test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\trace.zip
Usage:
pnpm exec playwright show-trace test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\trace.zip
────────────────────────────────────────────────────────────────────────────────────────────────
Slow test file: [chromium] › tests\e2e\analysis-flow.spec.ts (5.2m)
Consider running tests from slow files in parallel, see https://playwright.dev/docs/test-parallel.
1 failed
[chromium] › tests\e2e\analysis-flow.spec.ts:321:5 › runs the full postflop analysis flow and exposes the debug log
To open last HTML report run:
[36m[39m
[36m pnpm exec playwright show-report[39m
[36m[39m
The first failure is specific: the analysis completed, but the hand-review page no longer exposes a Preflop 1 button that the existing test expects. I’m inspecting the hand-detail navigation logic and the failure artifacts now to determine whether this is a stale test assumption or a real UI regression in the recent hand-review changes.
rg -n "streetNavigationGroups|Preflop 1|street-btn-|decision-btn-|analysis-notes|gto-mix-grid|analyze-button" "apps/web/src/app/hands/[handId]/page.tsx"Exit code: 0
Wall time: 0.3 seconds
Output:
2825: const streetNavigationGroups = useMemo(
2842: testId: `street-btn-${street}`,
2851: testId: `decision-btn-${decision.id}`,
4400: data-testid="analyze-button"
4686: {streetNavigationGroups.map((group, groupIndex) => {
Get-Content -LiteralPath 'apps\web\src\app\hands\[handId]\page.tsx' | Select-Object -Skip 4680 -First 220Exit code: 0
Wall time: 0.4 seconds
Output:
)}
>
Overview
</button>
{streetNavigationGroups.map((group, groupIndex) => {
return (
<React.Fragment key={group.street}>
{groupIndex > 0 ? (
<div className="hidden h-px w-4 bg-white/[0.12] md:block lg:w-6" />
) : null}
{group.items.map((item, itemIndex) => {
const isActive =
selection.kind === 'decision' && selection.decisionId === item.decisionId;
return (
<React.Fragment key={item.key}>
{itemIndex > 0 ? (
<div className="hidden h-px w-2 bg-white/[0.08] md:block" />
) : null}
<button
type="button"
data-testid={item.testId}
onClick={() => setSelection({ kind: 'decision', decisionId: item.decisionId })}
className={cn(
item.useStreetButtonStyle ? bottomBarStreetButtonClass : bottomBarDecisionButtonClass,
isActive
? item.useStreetButtonStyle
? 'bg-indigo-500/20 text-white shadow-[inset_0_0_0_1px_rgba(129,140,248,0.3)]'
: 'bg-sky-500/20 text-white shadow-[inset_0_0_0_1px_rgba(125,211,252,0.3)]'
: 'bg-sky-500/[0.08] text-sky-100 shadow-[inset_0_0_0_1px_rgba(125,211,252,0.16)] hover:bg-sky-500/[0.12] hover:text-white',
)}
>
{item.label}
</button>
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</div>
</div>
</div>
);
}
Get-Content test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\browser-console.jsonExit code: 1
Wall time: 0.4 seconds
Output:
Get-Content : Cannot find path 'E:\Desktop\Poker\test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-
log-chromium\browser-console.json' because it does not exist.
At line:2 char:1
+ Get-Content test-results\analysis-flow-runs-the-ful-d324a-w-and-expos ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (E:\Desktop\Poke...er-console.json:String) [Get-Content], ItemNotFoundEx
ception
+ FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetContentCommand
Get-Content test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\network-errors.jsonExit code: 1
Wall time: 0.4 seconds
Output:
Get-Content : Cannot find path 'E:\Desktop\Poker\test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-
log-chromium\network-errors.json' because it does not exist.
At line:2 char:1
+ Get-Content test-results\analysis-flow-runs-the-ful-d324a-w-and-expos ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (E:\Desktop\Poke...ork-errors.json:String) [Get-Content], ItemNotFoundEx
ception
+ FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetContentCommand
Get-Content test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\played-actions.jsonExit code: 1
Wall time: 0.4 seconds
Output:
Get-Content : Cannot find path 'E:\Desktop\Poker\test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-
log-chromium\played-actions.json' because it does not exist.
At line:2 char:1
+ Get-Content test-results\analysis-flow-runs-the-ful-d324a-w-and-expos ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (E:\Desktop\Poke...ed-actions.json:String) [Get-Content], ItemNotFoundEx
ception
+ FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetContentCommand
Get-Content test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\analysis-status.jsonExit code: 1
Wall time: 0.4 seconds
Output:
Get-Content : Cannot find path 'E:\Desktop\Poker\test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-
log-chromium\analysis-status.json' because it does not exist.
At line:2 char:1
+ Get-Content test-results\analysis-flow-runs-the-ful-d324a-w-and-expos ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (E:\Desktop\Poke...sis-status.json:String) [Get-Content], ItemNotFoundEx
ception
+ FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetContentCommand
Get-ChildItem test-results -Recurse | Select-Object FullName, Length | Format-Table -AutoSize -WrapExit code: 0
Wall time: 0.3 seconds
Output:
FullName L
e
n
g
t
h
-------- -
E:\Desktop\Poker\test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium
E:\Desktop\Poker\test-results\.last-run.json 9
6
E:\Desktop\Poker\test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\test-failed-1.png 4
5
8
7
9
4
E:\Desktop\Poker\test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\trace.zip 3
3
8
4
3
0
1
2
E:\Desktop\Poker\test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\video.webm 7
5
2
4
3
5
8
Get-Content -LiteralPath 'apps\web\src\app\hands\[handId]\page.tsx' | Select-Object -Skip 2800 -First 80Exit code: 0
Wall time: 0.3 seconds
Output:
}
if (!selectedDecision || !selectedDecisionAnalysis?.recommendedAction) {
return null;
}
return formatRecommendedActionText(
selectedDecision,
selectedDecisionAnalysis.recommendedAction,
selectedDecisionAnalysis.meta,
);
}, [
decisionMixActions,
selectedDecisionCanonical?.recommendedActionLabel,
selectedDecision,
selectedDecisionAnalysis?.meta,
selectedDecisionAnalysis?.recommendedAction,
]);
const selectionDescriptor = useMemo(() => {
if (selection.kind === 'overview') return 'Overview';
if (selection.kind === 'street') return streetLabel(selection.street);
if (!selectedDecision) return 'Decision';
return formatDecisionTitle(selectedDecision);
}, [selectedDecision, selection]);
const mobileTableTitle = selectedDecision ? formatDecisionAction(selectedDecision) : selectionDescriptor;
const streetNavigationGroups = useMemo(
() =>
STREETS.map((street) => {
const streetDecisions = decisionsByStreet[street];
const items: Array<{
key: string;
label: string;
decisionId: string;
testId: string;
useStreetButtonStyle: boolean;
}> = [];
if (streetDecisions.length === 1) {
items.push({
key: streetDecisions[0].id,
label: streetLabel(street),
decisionId: streetDecisions[0].id,
testId: `street-btn-${street}`,
useStreetButtonStyle: true,
});
} else if (streetDecisions.length >= 2) {
streetDecisions.forEach((decision, index) => {
items.push({
key: decision.id,
label: `${streetLabel(street)} ${index + 1}`,
decisionId: decision.id,
testId: `decision-btn-${decision.id}`,
useStreetButtonStyle: false,
});
});
}
return { street, items };
}).filter((group) => group.items.length > 0),
[decisionsByStreet],
);
const aiDecisionBundles = useMemo(() => {
const progressById = new Map<string, DecisionPipelineEntry>();
for (const row of effectiveDecisionProgressEntries) {
progressById.set(row.decisionId, row);
}
return heroDecisions.map((decision) => {
const analysis = decision.analyses[0] ?? null;
const decisionStreet = normalizeStreet(decision.street);
const basePipelineEntry = progressById.get(decision.id) ?? null;
const statusSnapshot = decisionStatusSnapshotById[decision.id] ?? null;
const pipelineEntry = mergeDecisionPipelineEntryWithStatusSnapshot({
entry: basePipelineEntry,
statusSnapshot,
analysisPresent: Boolean(analysis),
});
const missingSavedPayload = hasMissingSavedPayloadIssue({
analysisPresent: Boolean(analysis),
pipelineEntry: basePipelineEntry,
rg -n "Preflop 1|street-btn-preflop|decision-btn-|streetNavigationGroups|formatDecisionTitle\(|formatDecisionAction\(" apps/web/src/app/hands/hand-detail-page.test.tsx apps/web/src/app/hands/[handId]/page.tsxExit code: 0
Wall time: 0.2 seconds
Output:
apps/web/src/app/hands/[handId]/page.tsx:1667:function formatDecisionTitle(decision: DecisionRow): string {
apps/web/src/app/hands/[handId]/page.tsx:1669: return `${street} ${formatDecisionAction(decision)}`;
apps/web/src/app/hands/[handId]/page.tsx:1672:function formatDecisionAction(decision: DecisionRow): string {
apps/web/src/app/hands/[handId]/page.tsx:2822: return formatDecisionTitle(selectedDecision);
apps/web/src/app/hands/[handId]/page.tsx:2824: const mobileTableTitle = selectedDecision ? formatDecisionAction(selectedDecision) : selectionDescriptor;
apps/web/src/app/hands/[handId]/page.tsx:2825: const streetNavigationGroups = useMemo(
apps/web/src/app/hands/[handId]/page.tsx:2851: testId: `decision-btn-${decision.id}`,
apps/web/src/app/hands/[handId]/page.tsx:2935: title: formatDecisionTitle(decision),
apps/web/src/app/hands/[handId]/page.tsx:4089: <span>{formatDecisionTitle(selectedDecision)}</span>
apps/web/src/app/hands/[handId]/page.tsx:4305: title={formatDecisionTitle(selectedDecision)}
apps/web/src/app/hands/[handId]/page.tsx:4316: title={formatDecisionTitle(selectedDecision)}
apps/web/src/app/hands/[handId]/page.tsx:4374: {formatDecisionAction(selectedDecision)}
apps/web/src/app/hands/[handId]/page.tsx:4686: {streetNavigationGroups.map((group, groupIndex) => {
apps/web/src/app/hands/hand-detail-page.test.tsx:447: expect(renderResult.container.querySelector('[data-testid="decision-btn-dec_flop_1"]')).toBeNull();
apps/web/src/app/hands/hand-detail-page.test.tsx:462: expect(renderResult.container.querySelector('[data-testid="street-btn-preflop"]')).toBeNull();
apps/web/src/app/hands/hand-detail-page.test.tsx:463: expect(renderResult.container.querySelector('[data-testid="decision-btn-dec_pre_1"]')).not.toBeNull();
apps/web/src/app/hands/hand-detail-page.test.tsx:464: expect(renderResult.container.querySelector('[data-testid="decision-btn-dec_pre_2"]')).not.toBeNull();
apps/web/src/app/hands/hand-detail-page.test.tsx:467: expect(buttonTexts).toContain('Preflop 1');
apps/web/src/app/hands/hand-detail-page.test.tsx:496: expect(renderResult.container.querySelector('[data-testid="decision-btn-dec_flop_1"]')).toBeNull();
apps/web/src/app/hands/hand-detail-page.test.tsx:507: expect(renderResult.container.querySelector('[data-testid="street-btn-preflop"]')).toBeNull();
apps/web/src/app/hands/hand-detail-page.test.tsx:508: expect(renderResult.container.querySelector('[data-testid="decision-btn-dec_pre_1"]')).not.toBeNull();
apps/web/src/app/hands/hand-detail-page.test.tsx:509: expect(renderResult.container.querySelector('[data-testid="decision-btn-dec_pre_2"]')).not.toBeNull();
apps/web/src/app/hands/hand-detail-page.test.tsx:852: { decisionId: 'dec_pre_1', street: 'preflop', label: 'Preflop 1', status: 'llm_only', stage: 'llm_only', errorMessage: null, solverAvailable: false },
apps/web/src/app/hands/hand-detail-page.test.tsx:865: { decisionId: 'dec_pre_1', street: 'preflop', label: 'Preflop 1', status: 'llm_only', stage: 'llm_only', errorMessage: null, solverAvailable: false },
apps/web/src/app/hands/hand-detail-page.test.tsx:2298: const preflopDecisionButton = renderResult.container.querySelector('[data-testid="decision-btn-dec_pre_1"]') as HTMLButtonElement | null;
Get-Content -LiteralPath 'apps\web\src\app\hands\hand-detail-page.test.tsx' | Select-Object -Skip 2440 -First 220Exit code: 0
Wall time: 0.4 seconds
Output:
return createMockResponse({ error: 'not-found' }, 404);
});
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchMock as typeof fetch;
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => {
root.render(<HandReviewPage params={Promise.resolve({ handId: 'hand_1' })} />);
});
await flushAsync();
await flushAsync();
const analyzeButton = container.querySelector('[data-testid="analyze-button"]') as HTMLButtonElement | null;
expect(analyzeButton).not.toBeNull();
await act(async () => {
analyzeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flushAsync();
const hand1FetchCountBeforeSwitch = fetchMock.mock.calls.filter(([input]) =>
getUrl(input as RequestInfo | URL).includes('/api/hands/hand_1?'),
).length;
await act(async () => {
root.render(<HandReviewPage params={Promise.resolve({ handId: 'hand_2' })} />);
});
await flushAsync();
await flushAsync();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 2200));
});
await flushAsync();
await flushAsync();
const hand1FetchCountAfterSwitch = fetchMock.mock.calls.filter(([input]) =>
getUrl(input as RequestInfo | URL).includes('/api/hands/hand_1?'),
).length;
expect(hand1FetchCountAfterSwitch).toBe(hand1FetchCountBeforeSwitch);
} finally {
globalThis.fetch = originalFetch;
act(() => {
root.unmount();
});
container.remove();
}
});
it('refreshes whole-hand analysis in place while the review page is already open', async () => {
let handFetchCount = 0;
let overviewStatusFetchCount = 0;
const renderResult = await renderPage({
sel: 'overview',
fetchImpl: async (input: RequestInfo | URL, init?: RequestInit) => {
const url = getUrl(input);
if (url.includes('/api/hands/hand_1/analysis')) {
return createMockResponse({ summary: null }, 404);
}
if (url.includes('/api/hands/hand_1?')) {
handFetchCount += 1;
const detail = buildHandDetailResponse();
detail.hand.reportsByScope =
handFetchCount >= 3
? {
WHOLE_HAND: {
status: 'complete',
contentJson: {
overallStrategyRecap: 'Auto refreshed whole hand recap.',
},
jobMeta: {
source: 'fallback',
groundedFallback: true,
},
},
}
: {};
return createMockResponse(detail);
}
if (
url.includes('/api/hand-actions/status') &&
(!init || init.method === undefined || init.method === 'GET')
) {
overviewStatusFetchCount += 1;
if (overviewStatusFetchCount === 1) {
return createMockResponse({
analyzeHand: { status: 'completed', errorMessage: null },
analysis: { status: 'running' },
pipelineStatus: 'running',
overview: { status: 'running', stage: 'calling_llm', errorMessage: null },
counts: { total: 1, complete: 0, running: 1, failed: 0, llmOnly: 0 },
});
}
return createMockResponse({
analyzeHand: { status: 'completed', errorMessage: null },
analysis: { status: 'complete' },
pipelineStatus: 'complete',
overview: { status: 'complete', stage: 'complete', errorMessage: null },
counts: { total: 1, complete: 1, running: 0, failed: 0, llmOnly: 0 },
});
}
return createMockResponse({ error: 'not-found' }, 404);
},
});
try {
expect(renderResult.container.textContent ?? '').not.toContain(
'Auto refreshed whole hand recap.',
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 2200));
});
await flushAsync();
await flushAsync();
expect(renderResult.container.textContent ?? '').toContain(
'Auto refreshed whole hand recap.',
);
} finally {
renderResult.restore();
}
});
it('renders unavailable state for postflop decision when hero-combo policy is missing', async () => {
const renderResult = await renderPage({
sel: 'decision:dec_flop_1',
fetchImpl: async (input: RequestInfo | URL) => {
const url = getUrl(input);
if (url.includes('/api/hands/hand_1/analysis')) {
return createMockResponse({ summary: 'Whole-hand summary fallback.' });
}
if (url.includes('/api/hands/hand_1?')) {
const detail = buildHandDetailResponse();
detail.hand.decisions = detail.hand.decisions.map((decision) =>
decision.id === 'dec_flop_1'
? {
...decision,
analyses: [
{
...decision.analyses[0],
explanation: 'Legacy node mix note.',
recommendedAction: 'check',
recommendationSource: 'node_mix',
gtoPolicy: {
check: 0.7,
'bet:33': 0.3,
},
},
],
}
: decision,
);
return createMockResponse(detail);
}
return createMockResponse({ error: 'not-found' }, 404);
},
});
try {
const text = renderResult.container.textContent ?? '';
expect(text).toContain('Solver failed');
expect(text).not.toContain('Flop note without distribution.');
expect(renderResult.container.querySelector('[data-testid="gto-mix-grid"]')).toBeNull();
expect(renderResult.container.querySelector('[data-testid="gto-donut-placeholder"]')).toBeNull();
} finally {
renderResult.restore();
}
});
it('renders hero-combo strategy mix and avoids node-mix wording', async () => {
const renderResult = await renderPage({
sel: 'decision:dec_flop_1',
fetchImpl: async (input: RequestInfo | URL) => {
const url = getUrl(input);
if (url.includes('/api/hands/hand_1/analysis')) {
return createMockResponse({ summary: 'Whole-hand summary fallback.' });
}
if (url.includes('/api/hands/hand_1?')) {
const detail = buildHandDetailResponse();
detail.hand.decisions = detail.hand.decisions.map((decision) =>
decision.id === 'dec_flop_1'
? {
...decision,
analyses: [
{
...decision.analyses[0],
explanation: 'Hero combo coaching line.',
recommendedAction: 'bet:100',
recommendationSource: 'hero_combo',
gtoPolicy: {
check: 0.2,
'bet:100': 0.8,
},
},
],
}
: decision,
);
return createMockResponse(detail);
}
return createMockResponse({ error: 'not-found' }, 404);
},
});
try {
const text = renderResult.container.textContent ?? '';
expect(text).not.toContain('Hero Combo Mix');
expect(text).not.toContain('Strategy for your exact hand at this node.');
expect(text).toContain('Recommended action: BET POT');
expect(text).not.toContain('Node-mix top action');
expect(text.toLowerCase()).not.toContain('node mix');
expect(renderResult.container.querySelector('[data-testid="gto-mix-grid"]')).not.toBeNull();
} finally {
renderResult.restore();
}
Get-Content -LiteralPath 'apps\web\src\app\hands\hand-detail-page.test.tsx' | Select-Object -Skip 3200 -First 200Exit code: 0
Wall time: 0.4 seconds
Output:
id: 'e4',
type: 'street',
payload: { type: 'street', street: 'preflop', board: [] },
timestamp: '2026-02-28T10:00:03.500Z',
sequence: 4,
},
{
id: 'e5',
type: 'action',
payload: { type: 'action', playerId: 'hero', action: 'call', amount: 10 },
timestamp: '2026-02-28T10:00:04.000Z',
sequence: 5,
},
{
id: 'e6',
type: 'hand_end',
payload: { type: 'hand_end', finalStacks: { hero: 995, villain: 1005 } },
timestamp: '2026-02-28T10:00:05.000Z',
sequence: 6,
},
];
detail.hand.decisions = detail.hand.decisions.map((decision) =>
decision.id === 'dec_pre_2'
? {
...decision,
amount: 5,
potBefore: 15,
toCall: 5,
committedThisStreetBefore: 5,
handEventSeq: 5,
}
: decision,
);
return createMockResponse(detail);
}
return createMockResponse({ error: 'not-found' }, 404);
},
});
try {
expect(lastTableReplayProps).not.toBeNull();
const snapshot = lastTableReplayProps?.snapshot as
| {
seq?: number;
currentPot?: number;
seats?: Array<{ playerId?: string | null; committedRound?: number }>;
}
| null
| undefined;
const heroSeat = snapshot?.seats?.find((seat) => seat.playerId === 'hero');
const villainSeat = snapshot?.seats?.find((seat) => seat.playerId === 'villain');
expect(snapshot?.seq).toBe(4);
expect(snapshot?.currentPot).toBe(15);
expect(heroSeat?.committedRound).toBe(5);
expect(villainSeat?.committedRound).toBe(10);
} finally {
renderResult.restore();
}
});
it('hydrates missing saved-result failures instead of rendering a duplicate unavailable banner', async () => {
const renderResult = await renderPage({
sel: 'decision:dec_flop_1',
fetchImpl: async (input: RequestInfo | URL, init?: RequestInit) => {
const url = getUrl(input);
if (url.includes('/api/hands/hand_1/analysis')) {
return createMockResponse({ summary: 'Whole-hand summary fallback.' });
}
if (url.includes('/api/hands/hand_1?')) {
const detail = buildHandDetailResponse();
detail.hand.decisions = detail.hand.decisions.map((decision) =>
decision.id === 'dec_flop_1'
? {
...decision,
analyses: [],
}
: decision,
);
return createMockResponse(detail);
}
if (url.includes('/api/hand-actions/status') && (!init || init.method === undefined || init.method === 'GET')) {
return createMockResponse({
analyzeHand: { status: 'completed', errorMessage: null },
analysis: { status: 'queued' },
strictness: 'warn',
pipelineStatus: 'complete',
decisions: [
{
decisionId: 'dec_flop_1',
street: 'flop',
label: 'Flop 1',
status: 'complete',
stage: 'complete',
errorMessage: null,
solverAvailable: true,
solverConfigured: true,
solverAttempted: true,
solverError: null,
solverErrorCode: null,
},
],
blockingDecisions: [],
overview: {
status: 'complete',
stage: 'complete',
errorMessage: null,
},
counts: { total: 1, complete: 1, running: 0, failed: 0, llmOnly: 0 },
});
}
if (url.includes('/api/analysis/status/dec_flop_1')) {
return createMockResponse({
status: 'failed',
stage: 'failed',
errorMessage: 'Analysis completed but result was not saved. Re-run Analyze.',
debugEvents: [
{
ts: '2026-03-19T06:00:05.000Z',
source: 'api-status',
level: 'error',
decisionId: 'dec_flop_1',
handId: 'hand_1',
message: 'Terminal analysis failure',
data: {
status: 'failed',
stage: 'failed',
error: 'Analysis completed but result was not saved. Re-run Analyze.',
},
},
],
});
}
return createMockResponse({ error: 'not-found' }, 404);
},
});
try {
await flushAsync();
await flushAsync();
const text = renderResult.container.textContent ?? '';
expect(text).not.toContain('Analysis unavailable');
expect(text).not.toContain('no recommendation payload was saved');
expect(text).toContain('Failed');
expect(text).toContain('Analysis completed but result was not saved. Re-run Analyze.');
expect(renderResult.container.querySelector('[data-testid="gto-mix-grid"]')).toBeNull();
} finally {
renderResult.restore();
}
});
it('hides coach controls when analysis text is missing', async () => {
const renderResult = await renderPage({
sel: 'overview',
fetchImpl: async (input: RequestInfo | URL) => {
const url = getUrl(input);
if (url.includes('/api/hands/hand_1/analysis')) {
return createMockResponse({ summary: null }, 404);
}
if (url.includes('/api/hands/hand_1?')) {
const detail = buildHandDetailResponse();
detail.hand.reportsByScope = {};
detail.hand.decisions = detail.hand.decisions.map((decision) => ({
...decision,
analyses: decision.analyses.map((analysis) => ({
...analysis,
explanation: null,
})),
}));
return createMockResponse(detail);
}
return createMockResponse({ error: 'not-found' }, 404);
},
});
try {
expect(renderResult.container.querySelector('[data-testid="coach-toggle"]')).toBeNull();
} finally {
renderResult.restore();
}
});
});
[Message too Large to Upload]
I’ve confirmed from the failure screenshot that the page is already on the preflop decision when there is only one hero decision on that street, and the navigation intentionally renders Preflop instead of a separate Preflop 1 button. I’m validating that against the existing unit tests and then I’ll update the e2e flow to use the stable street/decision test ids the page already exposes.
Get-Content playwright\.auth\google-user.jsonExit code: 0
Wall time: 0.3 seconds
Output:
{
"cookies": [
{
"name": "OTZ",
"value": "8507536_76_76_104100_72_446760",
"domain": "accounts.google.com",
"path": "/",
"expires": 1775348137,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "HSID",
"value": "A-B_O6VvualUdylUT",
"domain": ".google.com",
"path": "/",
"expires": 1807316143.045011,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
},
{
"name": "SSID",
"value": "AUIVDYk6GushvYqnV",
"domain": ".google.com",
"path": "/",
"expires": 1807316143.04503,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "APISID",
"value": "d4HElDAemNrAthP6/ASuBuyIJv9P70-qWW",
"domain": ".google.com",
"path": "/",
"expires": 1807316143.045047,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
},
{
"name": "SAPISID",
"value": "XTYkZdUZZfAN8_iV/A79K5H9XlJNkbhenA",
"domain": ".google.com",
"path": "/",
"expires": 1807316143.045067,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "__Secure-1PAPISID",
"value": "XTYkZdUZZfAN8_iV/A79K5H9XlJNkbhenA",
"domain": ".google.com",
"path": "/",
"expires": 1807316143.045104,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "__Secure-3PAPISID",
"value": "XTYkZdUZZfAN8_iV/A79K5H9XlJNkbhenA",
"domain": ".google.com",
"path": "/",
"expires": 1807316143.045129,
"httpOnly": false,
"secure": true,
"sameSite": "None"
},
{
"name": "ACCOUNT_CHOOSER",
"value": "AFx_qI5SVZ3xWOKxO9oUWYu4WXP-J3G6ldxsqeXazyTllBQSZ6N_M-i196tLIHPLwB6oNPB4j4RPqdis-EqeGzqmyBds1kmC-L8K9s1tVb9PwZ8DOHl3e-WMyO2_2_kmT00rMr-nRxZu",
"domain": "accounts.google.com",
"path": "/",
"expires": 1807316143.045149,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "LSID",
"value": "s.CA:g.a0007gjFdDMc1gBvZR2f18KsTg3cS7jhFELe-UslG-56AE5OnFx-_z8E50_PB6hBof_MUY9R-AACgYKAaQSARESFQHGX2MimmuC-TYwRF2iIrsA8FtzShoVAUF8yKpqyyO0ERhvOti3tEBR89ay0076",
"domain": "accounts.google.com",
"path": "/",
"expires": 1807316143.240191,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "__Host-1PLSID",
"value": "s.CA:g.a0007gjFdDMc1gBvZR2f18KsTg3cS7jhFELe-UslG-56AE5OnFx-GORqIuXslQwRptWk7UsgGAACgYKAVYSARESFQHGX2Mikp4OjGKhcIfkIMwXsqldeRoVAUF8yKpFkieKreEnrktOteTw4KpQ0076",
"domain": "accounts.google.com",
"path": "/",
"expires": 1807316143.240305,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "__Host-3PLSID",
"value": "s.CA:g.a0007gjFdDMc1gBvZR2f18KsTg3cS7jhFELe-UslG-56AE5OnFx-DGb32os0ianjyT-h3bCBZQACgYKASMSARESFQHGX2MikPN3VOj7ky87rBbp0Ce-pRoVAUF8yKpzEEbeKxYQ9H1zcaiVsrTT0076",
"domain": "accounts.google.com",
"path": "/",
"expires": 1807316143.240349,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "HSID",
"value": "Azx56u6OsTIpwX9Nl",
"domain": ".google.ca",
"path": "/",
"expires": 1807316143.479628,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
},
{
"name": "SSID",
"value": "Aa__0e1s6wi3glXlI",
"domain": ".google.ca",
"path": "/",
"expires": 1807316143.479715,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "APISID",
"value": "d4HElDAemNrAthP6/ASuBuyIJv9P70-qWW",
"domain": ".google.ca",
"path": "/",
"expires": 1807316143.479735,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
},
{
"name": "SAPISID",
"value": "XTYkZdUZZfAN8_iV/A79K5H9XlJNkbhenA",
"domain": ".google.ca",
"path": "/",
"expires": 1807316143.479754,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "__Secure-1PAPISID",
"value": "XTYkZdUZZfAN8_iV/A79K5H9XlJNkbhenA",
"domain": ".google.ca",
"path": "/",
"expires": 1807316143.479771,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "__Secure-3PAPISID",
"value": "XTYkZdUZZfAN8_iV/A79K5H9XlJNkbhenA",
"domain": ".google.ca",
"path": "/",
"expires": 1807316143.479793,
"httpOnly": false,
"secure": true,
"sameSite": "None"
},
{
"name": "NID",
"value": "529=NQcwh42mUV4gTnuN_M6loOqCyP_oKHPC_mF-D8lTcn29xs8cg977NOGJWkLE40hnXw4Nd3rz9_hWes8i6uHKGBAGIczciG8cXTphhrG3mQt4SZ2F1cWatU8NfIW_QdS5WRu074PTWRsusZanM2fWxwSXxJpiDjrcflN6IBWiYzvga4t4O6C-5bCoh6lhudSX3gjMRz0CK_0ZyZOy5uEn8kmY582dzBT-",
"domain": ".google.ca",
"path": "/",
"expires": 1788567343.479812,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "SID",
"value": "g.a0007gjFdOAHUljWd7sB-zpLV_gVXmnYMFp2F6FrViD7LRDznPqdq88nvVyupPg7Lf4shC3QlgACgYKAcwSARESFQHGX2Mi2jgEk8kBXErXSrR1qhkDLRoVAUF8yKoFzg_QZD5BxTh4pWM-5vLx0076",
"domain": ".google.ca",
"path": "/",
"expires": 1807316143.479833,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
},
{
"name": "__Secure-1PSID",
"value": "g.a0007gjFdOAHUljWd7sB-zpLV_gVXmnYMFp2F6FrViD7LRDznPqdxVKvdgg9Z0JBa9ozfRkxlwACgYKASASARESFQHGX2Mid1INjA4ah1NMwmSu1iX82RoVAUF8yKrdk4FOtpkc3dHQrMMy-CY_0076",
"domain": ".google.ca",
"path": "/",
"expires": 1807316143.479853,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "__Secure-3PSID",
"value": "g.a0007gjFdOAHUljWd7sB-zpLV_gVXmnYMFp2F6FrViD7LRDznPqdcDUlKPo58CSBRq4fdTeTKgACgYKAa0SARESFQHGX2MilQ1G_ECEVH-v60Aa59vNzxoVAUF8yKqUtZ29OLLprmgHysKl0stq0076",
"domain": ".google.ca",
"path": "/",
"expires": 1807316143.479877,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "SID",
"value": "g.a0007gjFdE4853FMEXY28w-ewQa0feYzahe_8VjiVM818GSWT32-OguS6FlQ6Sl8EGCisgooZQACgYKAegSARESFQHGX2Mijxb5TWX7Q7qbVLvx8oATxxoVAUF8yKoLmH6gFziJsTgshW3dPd350076",
"domain": ".google.com",
"path": "/",
"expires": 1807316144.311256,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
},
{
"name": "__Secure-1PSID",
"value": "g.a0007gjFdE4853FMEXY28w-ewQa0feYzahe_8VjiVM818GSWT32-e8YfbIVGiSWYL5zJKA7rKgACgYKAU8SARESFQHGX2MiZhCzTGtaO4dj2Nt4XNNiAhoVAUF8yKrYKcK4ZSvuerBmuvIv87fO0076",
"domain": ".google.com",
"path": "/",
"expires": 1807316144.311303,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "__Secure-3PSID",
"value": "g.a0007gjFdE4853FMEXY28w-ewQa0feYzahe_8VjiVM818GSWT32-YVJux19R2ChgozkX1QSINAACgYKAY8SARESFQHGX2MiFtc6S0Nj42AA-pvFDc6FFxoVAUF8yKorHZ1HHIcA1PGbdf5r5-Ee0076",
"domain": ".google.com",
"path": "/",
"expires": 1807316144.311344,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "LSOLH",
"value": "AH+1Ng1EJkdI3MWYqwHByHfjyTln8yOqHZZIwux8QsRbjGEU9hDBK+35ArcaNJMnZ6PDi9R9hZRI",
"domain": "accounts.google.com",
"path": "/",
"expires": 1804292145.802254,
"httpOnly": false,
"secure": true,
"sameSite": "None"
},
{
"name": "__Host-GAPS",
"value": "1:8MiJsJgpZSEk2qZcXKCl_vyewwBsNbN5Xva2iUVCR6JGC1qrvsxAFLBEUbXJQ4LYNha1lrJUpDt9WZcy5Cu0gRScmBaXSIodD8dK_0vOTqomwiRZ74jUkUa5QvAQvA2hX7wE_rgAfgd133EylbgQYQRj0E8sfz0b-N0qZoCLKEZKtYbi5jRFIw:22NgKuKRZx9FXNfB",
"domain": "accounts.google.com",
"path": "/",
"expires": 1808717207.369404,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "NID",
"value": "529=lQh5NlTR6RGf3OizXbvDJYJpmI994TL__0zlaiQcUUf5vz5nFhOa7kgfD_jlH3UkDKItRP8YomoqcQEC3c-5BH3-cCEWMmfBWSM_mLvejiDSC3OeX80_KwC3J20fK1WetFNFa7nh39mYLzKIh9-irVV1SJv4FEZpzEWoIk94hPK-Jz38Yr1c_vSt4HYlNnR6tBujstVIkHiE7j0pTc-0MNph1j3s3At_qXs0CgfJ06nNPP6Da8bXPCaLmpIW4DOjSwLkjTdhoTjIclcby_I7w5aJCpG4K8eAJrSjGvbXjyXAkCgMYnCthpKlz5sVYtNSBYxrcbmRFFeznCBe3Oky9-2l71bUzPorXR3X2Kmo3AbEo8wEJVf5kaFW45BNBW3pyWHvehdRXVwkF6ZYP4Xsja8jouzH-n5_vjHYWifmylX-N7vvcM7F8d9XDgzbiYYHp7GuXd3QzGuyRZ0zRUY4hx0ZYrU6VMMpCjbTtAWzED1wfeN-CyLHxAe7jZRzJwfHKZV91oLkSX-fV6IQ66DiuweiNuErXAWwxihSX20BvYQijJdpgJhtWDE86P5_lyKwMnPBWPkcAWYwB0wMkjM96n9c1QbfVkuWC4Hb3LCEJq1-kqM1aTBFPFONc71ERp-Or-yQUUX3xYB5a9jGw16_Y2ZvQKe4TQIbfIB0dHx2a4oxSEqcurOwLw",
"domain": ".google.com",
"path": "/",
"expires": 1790209918.453518,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "__Host-GAPSTS",
"value": "gapsts-CiwBeJp6FPm-x8hLYFJwbjQGXEubEYOrZnWybkuf0brFa0tIu-smwRLpR170dRAB",
"domain": "accounts.google.com",
"path": "/",
"expires": 1808958718.723444,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "next-auth.csrf-token",
"value": "9bac835aa0af9b31df9d3ee1d7822b47174609bf4dde714baafc4cbb1eabfa61%7Cc0812872a09387060631c978ddf742f79a148d2c50470046cbd6e0d5c9b72328",
"domain": "127.0.0.1",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
},
{
"name": "next-auth.callback-url",
"value": "http%3A%2F%2Flocalhost%3A3000",
"domain": "127.0.0.1",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
},
{
"name": "next-auth.csrf-token",
"value": "a45e4030a95a48e3815855779e835ae7c93ff4666d49396714e0197756f727dc%7Cd4fb253af9d28b8eed970156dc5d96c49f5c31d921d804b864ae096e2f665595",
"domain": "localhost",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
},
{
"name": "next-auth.callback-url",
"value": "http%3A%2F%2Flocalhost%3A3000",
"domain": "localhost",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
},
{
"name": "__Secure-1PSIDTS",
"value": "sidts-CjcBBj1CYri4MmjwxW8cXVxQduYj37sTracvbCLcAJppCU6msoLMnivCvlslqcHP8yd26_HZr2TuEAA",
"domain": ".google.com",
"path": "/",
"expires": 1805936114.674947,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "__Secure-1PSIDRTS",
"value": "sidts-CjcBBj1CYri4MmjwxW8cXVxQduYj37sTracvbCLcAJppCU6msoLMnivCvlslqcHP8yd26_HZr2TuEAA",
"domain": ".google.com",
"path": "/",
"expires": 1774400714.675044,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "__Secure-3PSIDTS",
"value": "sidts-CjcBBj1CYri4MmjwxW8cXVxQduYj37sTracvbCLcAJppCU6msoLMnivCvlslqcHP8yd26_HZr2TuEAA",
"domain": ".google.com",
"path": "/",
"expires": 1805936114.675083,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "__Secure-3PSIDRTS",
"value": "sidts-CjcBBj1CYri4MmjwxW8cXVxQduYj37sTracvbCLcAJppCU6msoLMnivCvlslqcHP8yd26_HZr2TuEAA",
"domain": ".google.com",
"path": "/",
"expires": 1774400714.67512,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "SIDCC",
"value": "AKEyXzUdtjDACSeEbMepQvksM9pk-QdQ6W9NQ0tiItJwLpY0Aa9gpsNWnwFlG1XS0_JiIGWD9w",
"domain": ".google.com",
"path": "/",
"expires": 1805936114.675154,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
},
{
"name": "__Secure-1PSIDCC",
"value": "AKEyXzU_qMPjX8CwFbP-XMZ7bB1zaIMMLEZMBhhyffvyLk81FukKSaZSDg7iYeSWnJuPj_a_em0",
"domain": ".google.com",
"path": "/",
"expires": 1805936114.675185,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "__Secure-3PSIDCC",
"value": "AKEyXzVdM0XmO6XARtZawYzb_fHErKpWJdMZnSBJsjS3QgaZ5zwg_mT9N6M4A6WTgdZ0ohAKhYo",
"domain": ".google.com",
"path": "/",
"expires": 1805936114.675281,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "next-auth.session-token",
"value": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..keBxin5i09HIDQ3b.sKdbjIfF3g8DemuKIJl6evJteLygu6kMbEbnVODaSIjGhNC5qp2kLF4hEd0Hku70-vTSoUF7QWBNzES9u4YSaPUYYtobjDF2d4Vma96IDjErrygNHQVUVcacFlwELTovNCg7BCAhxPgFScgj2PgUO3AffW8M86bUui-Gaa5UZCI3L8yWkoRBj8jbDi1O6n4mSDDQ8g67lx5v3Hpdjy9BgQ39vzZKyQF7yYAiN1oLKQPysFCERHwoDeSwmtPzofo_ftx_wn5siFsWJ80ALSTB0wF4kC7QLzhg4gRclhXcY0vMqh64Q0U6cnjJRvw8vj0rwzWxRRgjfH5slKtshr7jKU8_XqtgDxBrQH3WTe-if-PPGRuvvJmLKTTKUBeI5HCKTlP56WbSF9QO1C9Fl0uzFgLLsrB_9we5euk32QSahcgnkmJGeBh_0y0wXrrbejdl3IdzvzonibeBoB-2fxesmVtv39U1JZja_Tgma2Cx9MB0qNz2le-wDlq9JyQpRSfJbd5KgX2N7USO7ZfH1r5bjV9U5fPF03UaHfQsTbmB9kN3igfusLVGBV1lXFflXhSGD_8LSkZ-A2YXsWJBCPAxnRr8mbazZqYHDHt_7fBYDSXoj6hc-0L6X3jh--sN24vQem_m19tbJv8GPjD99xQ1VXB-4B7K4lDUjHfvt0zYM6jroR6RI5f-Q3sYQO-T5z_o2uEVXl_lYSGDmQMCEbammbHIOw0.L6Yr9DRaMYDoa-xN24q36A",
"domain": "localhost",
"path": "/",
"expires": 1776992469.193735,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "http://localhost:3000",
"localStorage": [
{
"name": "nextauth.message",
"value": "{\"event\":\"session\",\"data\":{\"trigger\":\"getSession\"},\"timestamp\":1774400468}"
},
{
"name": "poker_guest_id",
"value": "guest_30dc7768-fb2e-4ebb-a26a-4039f127d098"
},
{
"name": "poker_client_id",
"value": "client_951d4550-d3c2-428d-abd7-1cca52a2836b"
},
{
"name": "ally-supports-cache",
"value": "{\"userAgent\":\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36\",\"version\":\"1.4.1\",\"focusAreaImgTabindex\":false,\"focusAreaTabindex\":false,\"focusAreaWithoutHref\":false,\"focusAudioWithoutControls\":false,\"focusBrokenImageMap\":true,\"focusChildrenOfFocusableFlexbox\":false,\"focusFieldsetDisabled\":true,\"focusFieldset\":false,\"focusFlexboxContainer\":false,\"focusFormDisabled\":true,\"focusImgIsmap\":false,\"focusImgUsemapTabindex\":true,\"focusInHiddenIframe\":true,\"focusInvalidTabindex\":false,\"focusLabelTabindex\":true,\"focusObjectSvg\":true,\"focusObjectSvgHidden\":false,\"focusRedirectImgUsemap\":false,\"focusRedirectLegend\":\"\",\"focusScrollBody\":false,\"focusScrollContainerWithoutOverflow\":false,\"focusScrollContainer\":true,\"focusSummary\":true,\"focusSvgFocusableAttribute\":false,\"focusSvgTabindexAttribute\":true,\"focusSvgNegativeTabindexAttribute\":true,\"focusSvgUseTabindex\":true,\"focusSvgForeignobjectTabindex\":true,\"focusSvg\":false,\"focusTabindexTrailingCharacters\":true,\"focusTable\":false,\"focusVideoWithoutControls\":false,\"cssShadowPiercingDeepCombinator\":\"\",\"focusInZeroDimensionObject\":true,\"focusObjectSwf\":true,\"focusSvgInIframe\":false,\"tabsequenceAreaAtImgPosition\":false,\"time\":\"2026-03-14T05:08:41.613Z\"}"
},
{
"name": "sonify-debug-logs",
"value": "{\"state\":{\"logs\":[{\"id\":\"1773570813574-046zzr\",\"timestamp\":\"2026-03-15T10:33:33.574Z\",\"level\":\"info\",\"source\":\"job-store\",\"message\":\"Loaded transcription job\",\"details\":\"{\\n \\\"id\\\": \\\"2022a7eb-bfbc-4112-96ae-9b567dcda923\\\",\\n \\\"status\\\": \\\"completed\\\",\\n \\\"progress\\\": 100,\\n \\\"title\\\": \\\"canon-in-d-johann-pachelbel.mp3\\\",\\n \\\"errorMessage\\\": null,\\n \\\"resultKeys\\\": [\\n \\\"midiUrl\\\",\\n \\\"musicxmlUrl\\\",\\n \\\"scorePlaybackEventsUrl\\\"\\n ]\\n}\",\"repeatCount\":1},{\"id\":\"1773570961312-lmdg6f\",\"timestamp\":\"2026-03-15T10:36:01.312Z\",\"level\":\"info\",\"source\":\"job-store\",\"message\":\"Loaded transcription job\",\"details\":\"{\\n \\\"id\\\": \\\"2022a7eb-bfbc-4112-96ae-9b567dcda923\\\",\\n \\\"status\\\": \\\"completed\\\",\\n \\\"progress\\\": 100,\\n \\\"title\\\": \\\"canon-in-d-johann-pachelbel.mp3\\\",\\n \\\"errorMessage\\\": null,\\n \\\"resultKeys\\\": [\\n \\\"midiUrl\\\",\\n \\\"musicxmlUrl\\\",\\n \\\"scorePlaybackEventsUrl\\\"\\n ]\\n}\",\"repeatCount\":1},{\"id\":\"1773570995156-5le12y\",\"timestamp\":\"2026-03-15T10:36:35.156Z\",\"level\":\"info\",\"source\":\"job-store\",\"message\":\"Loaded transcription job\",\"details\":\"{\\n \\\"id\\\": \\\"2022a7eb-bfbc-4112-96ae-9b567dcda923\\\",\\n \\\"status\\\": \\\"completed\\\",\\n \\\"progress\\\": 100,\\n \\\"title\\\": \\\"canon-in-d-johann-pachelbel.mp3\\\",\\n \\\"errorMessage\\\": null,\\n \\\"resultKeys\\\": [\\n \\\"midiUrl\\\",\\n \\\"musicxmlUrl\\\",\\n \\\"scorePlaybackEventsUrl\\\"\\n ]\\n}\",\"repeatCount\":1},{\"id\":\"1773571175749-9kevjf\",\"timestamp\":\"2026-03-15T10:39:35.749Z\",\"level\":\"info\",\"source\":\"job-store\",\"message\":\"Loaded transcription job\",\"details\":\"{\\n \\\"id\\\": \\\"2022a7eb-bfbc-4112-96ae-9b567dcda923\\\",\\n \\\"status\\\": \\\"completed\\\",\\n \\\"progress\\\": 100,\\n \\\"title\\\": \\\"canon-in-d-johann-pachelbel.mp3\\\",\\n \\\"errorMessage\\\": null,\\n \\\"resultKeys\\\": [\\n \\\"midiUrl\\\",\\n \\\"musicxmlUrl\\\",\\n \\\"scorePlaybackEventsUrl\\\"\\n ]\\n}\",\"repeatCount\":2},{\"id\":\"1773571242466-iepi4i\",\"timestamp\":\"2026-03-15T10:40:42.466Z\",\"level\":\"info\",\"source\":\"job-store\",\"message\":\"Loaded transcription job\",\"details\":\"{\\n \\\"id\\\": \\\"2022a7eb-bfbc-4112-96ae-9b567dcda923\\\",\\n \\\"status\\\": \\\"completed\\\",\\n \\\"progress\\\": 100,\\n \\\"title\\\": \\\"canon-in-d-johann-pachelbel.mp3\\\",\\n \\\"errorMessage\\\": null,\\n \\\"resultKeys\\\": [\\n \\\"midiUrl\\\",\\n \\\"musicxmlUrl\\\",\\n \\\"scorePlaybackEventsUrl\\\"\\n ]\\n}\",\"repeatCount\":1}]},\"version\":0}"
}
]
},
{
"origin": "http://127.0.0.1:3000",
"localStorage": [
{
"name": "nextauth.message",
"value": "{\"event\":\"session\",\"data\":{\"trigger\":\"getSession\"},\"timestamp\":1774398732}"
},
{
"name": "poker_guest_id",
"value": "guest_22c90e0b-4805-46f6-930c-f6e347b36a1f"
},
{
"name": "poker_client_id",
"value": "client_d33b6918-bc89-4ac2-9331-7bf43de6b278"
}
]
}
]
}
Get-Content -LiteralPath 'apps\web\src\app\hands\hand-detail-page.test.tsx' | Select-Object -Skip 420 -First 100Exit code: 0
Wall time: 0.3 seconds
Output:
return {
container,
root,
fetchMock,
restore: () => {
globalThis.fetch = originalFetch;
act(() => {
root.unmount();
});
container.remove();
},
};
}
function getContainerTextWithoutAiDebug(container: HTMLElement): string {
const clone = container.cloneNode(true) as HTMLElement;
clone.querySelector('[data-testid="ai-debug-panel"]')?.remove();
return clone.textContent ?? '';
}
describe('HandReviewPage sel model and analyze wiring', () => {
it('shows a street-named tab when a street has exactly one hero decision and keeps Overview as the only summary tab', async () => {
const renderResult = await renderPage({ sel: 'overview' });
try {
expect(renderResult.container.querySelector('[data-testid="street-btn-flop"]')).not.toBeNull();
expect(renderResult.container.querySelector('[data-testid="decision-btn-dec_flop_1"]')).toBeNull();
const buttonTexts = Array.from(renderResult.container.querySelectorAll('button'))
.map((button) => (button.textContent ?? '').trim());
expect(buttonTexts).toContain('Overview');
expect(buttonTexts).toContain('Flop');
expect(buttonTexts).not.toContain('Flop 1');
expect(getContainerTextWithoutAiDebug(renderResult.container)).not.toContain('Select one of your actions below');
} finally {
renderResult.restore();
}
});
it('shows numbered tabs only when a street has multiple hero decisions', async () => {
const renderResult = await renderPage({ sel: 'overview' });
try {
expect(renderResult.container.querySelector('[data-testid="street-btn-preflop"]')).toBeNull();
expect(renderResult.container.querySelector('[data-testid="decision-btn-dec_pre_1"]')).not.toBeNull();
expect(renderResult.container.querySelector('[data-testid="decision-btn-dec_pre_2"]')).not.toBeNull();
const buttonTexts = Array.from(renderResult.container.querySelectorAll('button'))
.map((button) => (button.textContent ?? '').trim());
expect(buttonTexts).toContain('Preflop 1');
expect(buttonTexts).toContain('Preflop 2');
expect(buttonTexts).not.toContain('Preflop');
} finally {
renderResult.restore();
}
});
it('omits streets with zero hero decisions', async () => {
const renderResult = await renderPage({ sel: 'overview' });
try {
expect(renderResult.container.querySelector('[data-testid="street-btn-turn"]')).toBeNull();
expect(renderResult.container.querySelector('[data-testid="street-btn-river"]')).toBeNull();
const buttonTexts = Array.from(renderResult.container.querySelectorAll('button'))
.map((button) => (button.textContent ?? '').trim());
expect(buttonTexts).not.toContain('Turn');
expect(buttonTexts).not.toContain('River');
expect(buttonTexts).not.toContain('Turn 1');
expect(buttonTexts).not.toContain('River 1');
} finally {
renderResult.restore();
}
});
it('normalizes old parent-style street deep links to the single decision route', async () => {
const renderResult = await renderPage({ sel: 'street:flop' });
try {
expect(replaceMock).toHaveBeenCalledWith('/hands/hand_1?sel=decision%3Adec_flop_1');
expect(renderResult.container.querySelector('[data-testid="street-btn-flop"]')).not.toBeNull();
expect(renderResult.container.querySelector('[data-testid="decision-btn-dec_flop_1"]')).toBeNull();
expect(getContainerTextWithoutAiDebug(renderResult.container)).not.toContain('Select one of your actions below');
} finally {
renderResult.restore();
}
});
it('normalizes old parent-style street deep links to the first decision when the street has multiple decisions', async () => {
const renderResult = await renderPage({ sel: 'street:preflop' });
try {
expect(replaceMock).toHaveBeenCalledWith('/hands/hand_1?sel=decision%3Adec_pre_1');
expect(renderResult.container.querySelector('[data-testid="street-btn-preflop"]')).toBeNull();
expect(renderResult.container.querySelector('[data-testid="decision-btn-dec_pre_1"]')).not.toBeNull();
expect(renderResult.container.querySelector('[data-testid="decision-btn-dec_pre_2"]')).not.toBeNull();
expect(getContainerTextWithoutAiDebug(renderResult.container)).not.toContain('Select one of your actions below');
} finally {
renderResult.restore();
}
});
it('wires overview Analyze button to hand-actions endpoint used by table analyze hand', async () => {
const renderResult = await renderPage({ sel: 'overview' });
try {
const analyzeButton = renderResult.container.querySelector('[data-testid="analyze-button"]') as HTMLButtonElement | null;
expect(analyzeButton).not.toBeNull();
Get-Content tests\e2e\analysis-flow.spec.ts | Select-Object -Skip 560 -First 80Exit code: 0
Wall time: 0.3 seconds
Output:
}
const serializedPayload = JSON.stringify(parsedPayload);
if (!targetHand || !serializedPayload.includes(targetHand.handId)) {
throw new Error(
`The debug payload does not reference the analyzed hand ${targetHand?.handId ?? '<unknown>'}. Payload preview: ${payloadText.slice(0, 500)}`,
);
}
if (!/debugEvents|decisionLogs|handPipeline|requestHash/i.test(serializedPayload)) {
throw new Error(
`The debug payload does not include expected analysis debug data. Payload preview: ${payloadText.slice(0, 500)}`,
);
}
});
await test.step('Verify preflop LLM and postflop solver-plus-LLM content on every street', async () => {
const visitStreet = async (
street: (typeof REQUIRED_STREETS)[number],
expectsStrategy: boolean,
) => {
const streetButton = page.getByTestId(`street-btn-${street}`);
if ((await streetButton.count()) > 0) {
await expect(streetButton).toBeVisible();
await streetButton.click();
const summaryPanel = page.getByTestId('street-summary-panel');
if ((await summaryPanel.count()) > 0) {
await expect(summaryPanel).toBeVisible();
const summaryText = (await summaryPanel.textContent())?.trim() ?? '';
if (!summaryText) {
throw new Error(`The ${street} street summary panel rendered, but it is empty.`);
}
}
}
const decisionButton = page.getByRole('button', {
name: `${STREET_LABELS[street]} 1`,
exact: true,
});
await expect(decisionButton).toBeVisible();
await decisionButton.click();
const notes = page.getByTestId('analysis-notes');
await expect(notes).toBeVisible();
const notesText = (await notes.textContent())?.trim() ?? '';
if (!notesText) {
throw new Error(`The ${street} analysis notes container rendered, but no LLM coaching text was present.`);
}
if (expectsStrategy) {
await expect(page.getByTestId('gto-mix-grid')).toBeVisible();
await expect(page.getByTestId('analyze-button')).toBeVisible();
} else {
await expect(page.getByTestId('gto-mix-grid')).toHaveCount(0);
await expect(page.getByTestId('analyze-button')).toHaveCount(0);
}
};
await visitStreet('preflop', false);
await visitStreet('flop', true);
await visitStreet('turn', true);
await visitStreet('river', true);
});
} finally {
await testInfo.attach('played-actions.json', {
body: JSON.stringify(playedActions, null, 2),
contentType: 'application/json',
});
if (targetHand) {
await testInfo.attach('tracked-hand.json', {
body: JSON.stringify(targetHand, null, 2),
contentType: 'application/json',
});
}
if (finalStatus) {
await testInfo.attach('analysis-status.json', {
body: JSON.stringify(finalStatus, null, 2),
contentType: 'application/json',
});
I’ve got the failure pinned to the e2e selector logic, not the analysis pipeline. I’m patching the spec so it selects the street’s active decision the same way the product does: a street tab when there’s one hero decision, or the numbered first decision when there are multiple.
Get-Content tests\e2e\analysis-flow.spec.ts | Select-Object -Skip 570 -First 70Exit code: 0
Wall time: 0.3 seconds
Output:
throw new Error(
`The debug payload does not include expected analysis debug data. Payload preview: ${payloadText.slice(0, 500)}`,
);
}
});
await test.step('Verify preflop LLM and postflop solver-plus-LLM content on every street', async () => {
const visitStreet = async (
street: (typeof REQUIRED_STREETS)[number],
expectsStrategy: boolean,
) => {
const streetButton = page.getByTestId(`street-btn-${street}`);
if ((await streetButton.count()) > 0) {
await expect(streetButton).toBeVisible();
await streetButton.click();
const summaryPanel = page.getByTestId('street-summary-panel');
if ((await summaryPanel.count()) > 0) {
await expect(summaryPanel).toBeVisible();
const summaryText = (await summaryPanel.textContent())?.trim() ?? '';
if (!summaryText) {
throw new Error(`The ${street} street summary panel rendered, but it is empty.`);
}
}
}
const decisionButton = page.getByRole('button', {
name: `${STREET_LABELS[street]} 1`,
exact: true,
});
await expect(decisionButton).toBeVisible();
await decisionButton.click();
const notes = page.getByTestId('analysis-notes');
await expect(notes).toBeVisible();
const notesText = (await notes.textContent())?.trim() ?? '';
if (!notesText) {
throw new Error(`The ${street} analysis notes container rendered, but no LLM coaching text was present.`);
}
if (expectsStrategy) {
await expect(page.getByTestId('gto-mix-grid')).toBeVisible();
await expect(page.getByTestId('analyze-button')).toBeVisible();
} else {
await expect(page.getByTestId('gto-mix-grid')).toHaveCount(0);
await expect(page.getByTestId('analyze-button')).toHaveCount(0);
}
};
await visitStreet('preflop', false);
await visitStreet('flop', true);
await visitStreet('turn', true);
await visitStreet('river', true);
});
} finally {
await testInfo.attach('played-actions.json', {
body: JSON.stringify(playedActions, null, 2),
contentType: 'application/json',
});
if (targetHand) {
await testInfo.attach('tracked-hand.json', {
body: JSON.stringify(targetHand, null, 2),
contentType: 'application/json',
});
}
if (finalStatus) {
await testInfo.attach('analysis-status.json', {
body: JSON.stringify(finalStatus, null, 2),
contentType: 'application/json',
});
The spec is patched to follow the page’s actual navigation model instead of assuming every street has a numbered first-decision button. I’m rerunning the full Playwright flow now; if anything else fails, I’ll keep tracing until the real path passes cleanly.
pnpm exec playwright test tests/e2e/analysis-flow.spec.ts --project=chromiumExit code: 1
Wall time: 113.9 seconds
Output:
Running 1 test using 1 worker
[e2e] action Call5 Waiting Hand in progress
[e2e] action Check Flop Hand in progress
[e2e] action Call10 Turn Hand in progress
[e2e] analyze-hand River Hand in progress
[e2e] action Call13 River Report queued
x 1 [chromium] › tests\e2e\analysis-flow.spec.ts:321:5 › runs the full postflop analysis flow and exposes the debug log (1.8m)
1) [chromium] › tests\e2e\analysis-flow.spec.ts:321:5 › runs the full postflop analysis flow and exposes the debug log › Wait for whole-hand analysis to finish
Error: whole-hand analysis for cmn713vqs00dzbv5krjhjz4et failed early: {
"gameId": "cmn713uq400dxbv5k9iot5hux",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"handIndex": null,
"handComplete": true,
"strictness": "warn",
"pipelineStatus": "blocked",
"save": {
"status": "idle",
"errorMessage": null,
"stage": null,
"message": null
},
"analyzeHand": {
"status": "running",
"errorMessage": null,
"stage": "calling_llm",
"message": null
},
"analysis": {
"id": null,
"status": "failed",
"analyzed": true,
"stage": "solver_failed",
"errorMessage": "hero_combo_unavailable",
"message": null
},
"decisions": [
{
"decisionId": "cmn713vxb00e9bv5ko5yi0iqa",
"street": "preflop",
"label": "Preflop 1",
"status": "running",
"stage": "calling_llm",
"errorMessage": null,
"solverAvailable": false,
"solverConfigured": true,
"solverAttempted": false,
"solverError": "preflop_llm_only",
"solverErrorCode": null,
"debugEventsPreview": [
{
"ts": "2026-03-26T05:25:33.794Z",
"source": "api-status",
"level": "info",
"message": "Decision analysis enqueued",
"decisionId": "cmn713vxb00e9bv5ko5yi0iqa",
"handId": "cmn713vqs00dzbv5krjhjz4et"
},
{
"ts": "2026-03-26T05:27:08.380Z",
"source": "api-worker",
"level": "info",
"message": "Stage transition: started",
"decisionId": "cmn713vxb00e9bv5ko5yi0iqa",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"data": {
"status": "running",
"solverAttempted": false
}
},
{
"ts": "2026-03-26T05:27:08.396Z",
"source": "api-worker",
"level": "info",
"message": "Stage transition: calling_llm",
"decisionId": "cmn713vxb00e9bv5ko5yi0iqa",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"data": {
"status": "running",
"solverAttempted": false
}
}
]
},
{
"decisionId": "cmn713xj800enbv5kwsyzfeuq",
"street": "flop",
"label": "Flop 1",
"status": "queued",
"stage": "enqueued",
"errorMessage": null,
"solverAvailable": false,
"solverConfigured": true,
"solverAttempted": null,
"solverError": null,
"solverErrorCode": null,
"debugEventsPreview": [
{
"ts": "2026-03-26T05:25:33.793Z",
"source": "api-status",
"level": "info",
"message": "Decision analysis enqueued",
"decisionId": "cmn713xj800enbv5kwsyzfeuq",
"handId": "cmn713vqs00dzbv5krjhjz4et"
}
]
},
{
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"street": "turn",
"label": "Turn 1",
"status": "solver_failed",
"stage": "solver_failed",
"errorMessage": "hero_combo_unavailable",
"solverAvailable": false,
"solverConfigured": true,
"solverAttempted": true,
"solverError": "hero_combo_unavailable",
"solverErrorCode": "hero_combo_unavailable",
"debugEventsPreview": [
{
"ts": "2026-03-26T05:27:08.334Z",
"source": "api-worker",
"level": "info",
"message": "Stage transition: solver_done",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"data": {
"status": "running",
"solverAttempted": true
}
},
{
"ts": "2026-03-26T05:27:08.339Z",
"source": "api-worker",
"level": "warn",
"message": "Hero combo policy unavailable",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"scope": "TURN",
"data": {
"street": "turn",
"failureReason": "hero_key_not_in_combo_map"
}
},
{
"ts": "2026-03-26T05:27:08.347Z",
"source": "api-worker",
"level": "warn",
"message": "Stage transition: solver_failed",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"data": {
"status": "solver_failed",
"solverErrorCode": "hero_combo_unavailable",
"solverAttempted": true
}
}
]
},
{
"decisionId": "cmn71404600fbbv5koitdqvhb",
"street": "river",
"label": "River 1",
"status": "queued",
"stage": "enqueued",
"errorMessage": null,
"solverAvailable": false,
"solverConfigured": true,
"solverAttempted": null,
"solverError": null,
"solverErrorCode": null,
"debugEventsPreview": [
{
"ts": "2026-03-26T05:25:33.803Z",
"source": "api-status",
"level": "info",
"message": "Decision analysis enqueued",
"decisionId": "cmn71404600fbbv5koitdqvhb",
"handId": "cmn713vqs00dzbv5krjhjz4et"
}
]
}
],
"blockingDecisions": [
{
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"street": "turn",
"label": "Turn 1",
"solverError": "hero_combo_unavailable",
"solverErrorCode": "hero_combo_unavailable",
"stage": "solver_failed"
}
],
"overview": {
"status": "blocked",
"stage": "blocked:Turn 1",
"errorMessage": "Blocked: solver required for postflop decisions"
},
"counts": {
"total": 4,
"queued": 2,
"complete": 0,
"running": 1,
"failed": 1,
"llmOnly": 0
},
"debugEvents": [
{
"ts": "2026-03-26T05:25:32.613Z",
"source": "api-status",
"level": "info",
"message": "Hand action recorded (queued)",
"handId": "cmn713vqs00dzbv5krjhjz4et"
},
{
"ts": "2026-03-26T05:25:32.638Z",
"source": "api-status",
"level": "info",
"message": "Review snapshot persisted for analyze request",
"handId": "cmn713vqs00dzbv5krjhjz4et"
},
{
"ts": "2026-03-26T05:25:32.640Z",
"source": "api-status",
"level": "info",
"message": "Waiting for hand completion before execution",
"handId": "cmn713vqs00dzbv5krjhjz4et"
},
{
"ts": "2026-03-26T05:25:33.718Z",
"source": "api-status",
"level": "info",
"message": "Processing pending hand actions",
"handId": "cmn713vqs00dzbv5krjhjz4et"
},
{
"ts": "2026-03-26T05:25:33.720Z",
"source": "api-status",
"level": "info",
"message": "Executing hand action",
"handId": "cmn713vqs00dzbv5krjhjz4et"
},
{
"ts": "2026-03-26T05:25:33.732Z",
"source": "api-status",
"level": "info",
"message": "Pipeline requested",
"handId": "cmn713vqs00dzbv5krjhjz4et"
},
{
"ts": "2026-03-26T05:25:33.767Z",
"source": "api-status",
"level": "info",
"message": "Non-overview reports queued",
"handId": "cmn713vqs00dzbv5krjhjz4et"
},
{
"ts": "2026-03-26T05:25:33.793Z",
"source": "api-status",
"level": "info",
"message": "Decision analysis enqueued",
"decisionId": "cmn713xj800enbv5kwsyzfeuq",
"handId": "cmn713vqs00dzbv5krjhjz4et"
},
{
"ts": "2026-03-26T05:25:33.794Z",
"source": "api-status",
"level": "info",
"message": "Decision analysis enqueued",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et"
},
{
"ts": "2026-03-26T05:25:33.794Z",
"source": "api-status",
"level": "info",
"message": "Decision analysis enqueued",
"decisionId": "cmn713vxb00e9bv5ko5yi0iqa",
"handId": "cmn713vqs00dzbv5krjhjz4et"
},
{
"ts": "2026-03-26T05:25:33.803Z",
"source": "api-status",
"level": "info",
"message": "Decision analysis enqueued",
"decisionId": "cmn71404600fbbv5koitdqvhb",
"handId": "cmn713vqs00dzbv5krjhjz4et"
},
{
"ts": "2026-03-26T05:25:33.831Z",
"source": "api-status",
"level": "info",
"message": "Decision analysis jobs enqueued",
"handId": "cmn713vqs00dzbv5krjhjz4et"
},
{
"ts": "2026-03-26T05:26:43.880Z",
"source": "api-worker",
"level": "info",
"message": "Stage transition: started",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"data": {
"status": "running",
"solverAttempted": false
}
},
{
"ts": "2026-03-26T05:26:43.896Z",
"source": "api-worker",
"level": "info",
"message": "Hero range class injected",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"scope": "TURN",
"data": {
"street": "turn"
}
},
{
"ts": "2026-03-26T05:26:43.906Z",
"source": "api-worker",
"level": "info",
"message": "Stage transition: calling_solver",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"data": {
"status": "running",
"solverAttempted": false
}
},
{
"ts": "2026-03-26T05:26:43.909Z",
"source": "api-worker",
"level": "info",
"message": "Calling solver-service",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"scope": "TURN",
"data": {
"street": "turn",
"timeoutMs": 300000
}
},
{
"ts": "2026-03-26T05:26:43.914Z",
"source": "api-worker",
"level": "info",
"message": "Solver response headers received",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"scope": "TURN",
"data": {
"street": "turn",
"headersDurationMs": 5,
"statusCode": 200
}
},
{
"ts": "2026-03-26T05:26:43.533Z",
"source": "solver-service",
"level": "info",
"message": "request start",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"scope": "TURN",
"data": {
"street": "turn"
}
},
{
"ts": "2026-03-26T05:26:43.533Z",
"source": "solver-service",
"level": "info",
"message": "spawning solver",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"scope": "TURN",
"data": {
"street": "turn"
}
},
{
"ts": "2026-03-26T05:27:07.740Z",
"source": "solver-service",
"level": "info",
"message": "solver end",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"scope": "TURN",
"data": {
"street": "turn",
"status": "COMPLETED",
"durationMs": 24204,
"exitCode": 0
}
},
{
"ts": "2026-03-26T05:27:08.315Z",
"source": "api-worker",
"level": "info",
"message": "Solver stream parsed",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"scope": "TURN",
"data": {
"street": "turn",
"requestHash": "7e3d4f61bde7c5884beb8592f17dee3df02131a3fc1847031fdd93e2ba4707a6",
"heroComboFailureReason": "hero_key_not_in_combo_map",
"headersDurationMs": 5,
"fullDurationMs": 24406,
"statusCode": 200,
"policyKeyCount": 3,
"comboPolicyKeyCount": 229,
"heroComboPolicyPresent": false
}
},
{
"ts": "2026-03-26T05:27:08.334Z",
"source": "api-worker",
"level": "info",
"message": "Stage transition: solver_done",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"data": {
"status": "running",
"solverAttempted": true
}
},
{
"ts": "2026-03-26T05:27:08.339Z",
"source": "api-worker",
"level": "warn",
"message": "Hero combo policy unavailable",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"scope": "TURN",
"data": {
"street": "turn",
"failureReason": "hero_key_not_in_combo_map"
}
},
{
"ts": "2026-03-26T05:27:08.347Z",
"source": "api-worker",
"level": "warn",
"message": "Stage transition: solver_failed",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"data": {
"status": "solver_failed",
"solverErrorCode": "hero_combo_unavailable",
"solverAttempted": true
}
},
{
"ts": "2026-03-26T05:27:08.380Z",
"source": "api-worker",
"level": "info",
"message": "Stage transition: started",
"decisionId": "cmn713vxb00e9bv5ko5yi0iqa",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"data": {
"status": "running",
"solverAttempted": false
}
},
{
"ts": "2026-03-26T05:27:08.396Z",
"source": "api-worker",
"level": "info",
"message": "Stage transition: calling_llm",
"decisionId": "cmn713vxb00e9bv5ko5yi0iqa",
"handId": "cmn713vqs00dzbv5krjhjz4et",
"data": {
"status": "running",
"solverAttempted": false
}
}
]
}
Decision coverage: preflop:running:solver=false:stage=calling_llm:error=n/a | flop:queued:solver=false:stage=enqueued:error=n/a | turn:solver_failed:solver=false:stage=solver_failed:error=hero_combo_unavailable | river:queued:solver=false:stage=enqueued:error=n/a
301 | const failure = params.failFast?.(value);
302 | if (failure) {
> 303 | throw new Error(`${params.label} failed early: ${failure}`);
| ^
304 | }
305 |
306 | if (params.accept(value)) {
at pollUntil (E:\Desktop\Poker\tests\e2e\analysis-flow.spec.ts:303:13)
at E:\Desktop\Poker\tests\e2e\analysis-flow.spec.ts:495:21
at E:\Desktop\Poker\tests\e2e\analysis-flow.spec.ts:490:5
attachment #5: screenshot (image/png) ──────────────────────────────────────────────────────────
test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\test-failed-1.png
────────────────────────────────────────────────────────────────────────────────────────────────
attachment #6: video (video/webm) ──────────────────────────────────────────────────────────────
test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\video.webm
────────────────────────────────────────────────────────────────────────────────────────────────
attachment #8: trace (application/zip) ─────────────────────────────────────────────────────────
test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\trace.zip
Usage:
pnpm exec playwright show-trace test-results\analysis-flow-runs-the-ful-d324a-w-and-exposes-the-debug-log-chromium\trace.zip
────────────────────────────────────────────────────────────────────────────────────────────────
1 failed
[chromium] › tests\e2e\analysis-flow.spec.ts:321:5 › runs the full postflop analysis flow and exposes the debug log
To open last HTML report run:
[36m[39m
[36m pnpm exec playwright show-report[39m
[36m[39m
The next failure is a real product issue: the whole-hand pipeline is getting blocked because a postflop decision ends in hero_combo_unavailable, so later streets never finish. I’m tracing that through the analysis pipeline and the decision canonicalization code now, using the exact error path from the worker output rather than patching the test.
rg -n "hero_combo_unavailable|hero_key_not_in_combo_map|comboPolicy|Hero combo policy unavailable|solver_failed" apps/api apps/web packages testsExit code: 0
Wall time: 0.3 seconds
Output:
tests\e2e\analysis-flow.spec.ts:67: status: 'queued' | 'running' | 'llm_only' | 'complete' | 'solver_failed' | 'failed';
tests\e2e\analysis-flow.spec.ts:517: if (status.decisions.some((decision) => decision.status === 'failed' || decision.status === 'solver_failed')) {
apps/web\src\app\hands\[handId]\page.tsx:91: state: 'complete' | 'explanation_failed' | 'solver_failed' | 'llm_only';
apps/web\src\app\hands\[handId]\page.tsx:229: status: 'queued' | 'running' | 'llm_only' | 'complete' | 'solver_failed' | 'failed';
apps/web\src\app\hands\[handId]\page.tsx:804: raw === 'solver_failed' ||
apps/web\src\app\hands\[handId]\page.tsx:843: row.status === 'solver_failed' ||
apps/web\src\app\hands\[handId]\page.tsx:986: | 'solver_failed'
apps/web\src\app\hands\[handId]\page.tsx:1067: status !== 'solver_failed' &&
apps/web\src\app\hands\[handId]\page.tsx:1191: .filter((row) => row.status === 'solver_failed')
apps/web\src\app\hands\[handId]\page.tsx:1214: if (status === 'solver_failed') return 'Solver failed';
apps/web\src\app\hands\[handId]\page.tsx:1440: statusSnapshot.serverStatus === 'solver_failed' ||
apps/web\src\app\hands\[handId]\page.tsx:1442: statusSnapshot.solverErrorCode === 'hero_combo_unavailable'
apps/web\src\app\hands\[handId]\page.tsx:1443: ? 'solver_failed'
apps/web\src\app\hands\[handId]\page.tsx:1465: (nextStatus === 'solver_failed'
apps/web\src\app\hands\[handId]\page.tsx:1466: ? 'solver_failed'
apps/web\src\app\hands\[handId]\page.tsx:1476: nextStatus === 'failed' || nextStatus === 'solver_failed'
apps/web\src\app\hands\[handId]\page.tsx:1481: nextStatus === 'solver_failed'
apps/web\src\app\hands\[handId]\page.tsx:1566: if (decisions.some((row) => row.status === 'solver_failed')) {
apps/web\src\app\hands\[handId]\page.tsx:2472: (row) => row.status === 'failed' || row.status === 'solver_failed',
apps/web\src\app\hands\[handId]\page.tsx:2609: : 'solver_failed'
apps/web\src\app\hands\[handId]\page.tsx:2616: stage: status === 'solver_failed' ? 'solver_failed' : status === 'failed' ? 'failed' : status,
apps/web\src\app\hands\[handId]\page.tsx:2620: : status === 'solver_failed'
apps/web\src\app\hands\[handId]\page.tsx:2624: 'solver_failed'
apps/web\src\app\hands\[handId]\page.tsx:2657: (row) => row.status === 'failed' || row.status === 'solver_failed',
apps/web\src\app\hands\[handId]\page.tsx:2703: selectedDecisionPipelineEntry?.status === 'solver_failed')) ||
apps/web\src\app\hands\[handId]\page.tsx:2738: selectedDecisionPipelineEntry?.status === 'solver_failed';
apps/web\src\app\hands\[handId]\page.tsx:2890: pipelineEntry?.status === 'failed' || pipelineEntry?.status === 'solver_failed';
apps/web\src\app\hands\[handId]\page.tsx:3132: pipelineStatus === 'solver_failed' ||
apps/web\src\app\hands\[handId]\page.tsx:3929: .filter((row) => row.status === 'solver_failed')
apps/web\src\app\hands\[handId]\page.tsx:4004: row.status === 'failed' || row.status === 'solver_failed'
apps/web\src\app\hands\[handId]\page.tsx:4067: (selectedDecisionHeroComboUnavailable ? 'hero_combo_unavailable' : null) ??
apps/web\src\app\hands\[handId]\page.tsx:4072: ? 'solver_failed'
apps/web\src\app\hands\[handId]\page.tsx:4075: ? 'solver_failed'
apps/web\src\app\hands\[handId]\page.tsx:4078: solverFailureCode === 'hero_combo_unavailable'
apps/web\src\app\hands\[handId]\page.tsx:4094: effectiveStatus === 'solver_failed'
apps/web\src\app\hands\[handId]\page.tsx:4108: {effectiveStatus === 'solver_failed' ? (
apps/web\src\app\hands\[handId]\page.tsx:4136: selectedDecisionPipelineEntry?.status === 'solver_failed' ||
apps/web\src\app\hands\hand-detail-page.test.tsx:1017: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1018: stage: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1032: stage: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1091: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1124: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1210: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1222: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1234: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1285: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1297: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1309: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1477: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1478: stage: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1517: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1543: stage: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1927: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1928: stage: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1944: stage: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1971: it('does not render a separate failure log panel for solver_failed decisions without debug UI enabled', async () => {
apps/web\src\app\hands\hand-detail-page.test.tsx:1987: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:1988: stage: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:2023: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:2039: stage: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:2075: it('does not render a separate decision debug affordance for solver_failed decisions', async () => {
apps/web\src\app\hands\hand-detail-page.test.tsx:2091: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:2092: stage: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:2124: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:2140: stage: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:2171: it('shows explicit hero-combo unavailable banner for hero_combo_unavailable failures', async () => {
apps/web\src\app\hands\hand-detail-page.test.tsx:2184: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:2185: stage: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:2186: solverErrorCode: 'hero_combo_unavailable',
apps/web\src\app\hands\hand-detail-page.test.tsx:2200: status: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:2201: stage: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:2202: errorMessage: 'hero_combo_unavailable',
apps/web\src\app\hands\hand-detail-page.test.tsx:2206: solverError: 'hero_combo_unavailable',
apps/web\src\app\hands\hand-detail-page.test.tsx:2207: solverErrorCode: 'hero_combo_unavailable',
apps/web\src\app\hands\hand-detail-page.test.tsx:2215: solverError: 'hero_combo_unavailable',
apps/web\src\app\hands\hand-detail-page.test.tsx:2216: stage: 'solver_failed',
apps/web\src\app\hands\hand-detail-page.test.tsx:2236: expect(text).not.toContain('Solver failed: hero_combo_unavailable.');
apps/api\src\analysis-pipeline.test.ts:233: expect(status3.status).toBe('solver_failed');
apps/api\src\analysis-pipeline.test.ts:234: expect(status3.errorMessage).toBe('hero_combo_unavailable');
apps/api\src\analysis-pipeline.test.ts:240: expect(result.error).toBe('hero_combo_unavailable');
apps/api\src\analysis-pipeline.test.ts:254: it('does not overwrite solver_failed status with ready on queue completed event', async () => {
apps/api\src\analysis-pipeline.test.ts:267: status: 'solver_failed',
apps/api\src\analysis-pipeline.test.ts:269: stage: 'solver_failed',
apps/api\src\analysis-pipeline.test.ts:270: errorMessage: 'hero_combo_unavailable',
apps/api\src\analysis-pipeline.test.ts:292: expect(persisted?.status).toBe('solver_failed');
apps/api\src\analysis-pipeline.test.ts:293: expect(persisted?.stage).toBe('solver_failed');
apps/api\src\analysis-pipeline.test.ts:297: expect(failedEvent?.payload?.errorMessage).toBe('hero_combo_unavailable');
apps/api\src\analysis-pipeline.test.ts:614: it('keeps hero_combo_unavailable as terminal solver_failed when queue job completes without analysis row', async () => {
apps/api\src\analysis-pipeline.test.ts:615: const decisionId = 'decision_hero_combo_unavailable';
apps/api\src\analysis-pipeline.test.ts:619: handId: 'hand_hero_combo_unavailable',
apps/api\src\analysis-pipeline.test.ts:620: playerId: 'player_hero_combo_unavailable',
apps/api\src\analysis-pipeline.test.ts:621: hand: { roomId: 'room_hero_combo_unavailable' },
apps/api\src\analysis-pipeline.test.ts:624: id: 'status_hero_combo_unavailable',
apps/api\src\analysis-pipeline.test.ts:637: handId: 'hand_hero_combo_unavailable',
apps/api\src\analysis-pipeline.test.ts:643: stage: 'solver_failed',
apps/api\src\analysis-pipeline.test.ts:644: solverError: 'hero_combo_unavailable',
apps/api\src\analysis-pipeline.test.ts:645: solverErrorCode: 'hero_combo_unavailable',
apps/api\src\analysis-pipeline.test.ts:661: expect(statusPayload.status).toBe('solver_failed');
apps/api\src\analysis-pipeline.test.ts:662: expect(statusPayload.stage).toBe('solver_failed');
apps/api\src\analysis-pipeline.test.ts:663: expect(statusPayload.errorMessage).toBe('hero_combo_unavailable');
apps/api\src\analysis-pipeline.test.ts:666: expect(persisted?.status).toBe('solver_failed');
apps/api\src\analysis-pipeline.test.ts:667: expect(persisted?.stage).toBe('solver_failed');
apps/api\src\analysis-pipeline.test.ts:668: expect(persisted?.errorMessage).toBe('hero_combo_unavailable');
apps/api\src\analysis-pipeline.test.ts:675: error: 'hero_combo_unavailable',
apps/api\src\analysis-pipeline.test.ts:681: it('returns solver_failed instead of ready for legacy postflop analyses without hero-combo policy', async () => {
apps/api\src\analysis-pipeline.test.ts:723: expect(submitPayload.status).toBe('solver_failed');
apps/api\src\analysis-pipeline.test.ts:728: expect(statusPayload.status).toBe('solver_failed');
apps/api\src\analysis-pipeline.test.ts:729: expect(statusPayload.errorMessage).toBe('hero_combo_unavailable');
apps/api\src\analysis-pipeline.test.ts:877: status: 'solver_failed',
apps/api\src\analysis-pipeline.test.ts:879: stage: 'solver_failed',
apps/api\src\analysis-pipeline.test.ts:984: status: 'solver_failed',
apps/api\src\analysis-pipeline.test.ts:986: stage: 'solver_failed',
apps/api\src\analysis-pipeline.test.ts:1051: status: 'solver_failed',
apps/api\src\analysis-pipeline.test.ts:1053: stage: 'solver_failed',
apps/api\src\analysis-pipeline.test.ts:1080: status: 'solver_failed',
apps/api\src\analysis-pipeline.test.ts:1081: stage: 'solver_failed',
apps/api\src\analysis-queue-events.ts:71: if (normalizedStage === 'solver_failed' || normalizedStage === 'solver_required') {
apps/api\src\analysis-queue-events.ts:76: status.status === 'solver_failed' ||
apps/web\src\components\table\AnalysisDrawer.tsx:64: state: 'complete' | 'explanation_failed' | 'solver_failed' | 'llm_only';
apps/api\src\services\analysis-debug-events.ts:282: copyInteger('comboPolicyKeyCount');
apps/api\src\services\analysis-debug-events.ts:328: status: 'failed' | 'solver_failed' | 'cancelled';
apps/api\src\routes\analysis-rest.ts:272: (status === 'failed' || status === 'solver_failed' || status === 'cancelled') &&
apps/api\src\routes\analysis-rest.ts:552:const HERO_COMBO_UNAVAILABLE_REASON = 'hero_combo_unavailable';
apps/api\src\routes\analysis-rest.ts:634:} | null | undefined): { status: 'failed' | 'solver_failed'; error: string } | null {
apps/api\src\routes\analysis-rest.ts:642: status: 'solver_failed',
apps/api\src\routes\analysis-rest.ts:670: failure?.status === 'solver_failed'
apps/api\src\routes\analysis-rest.ts:888: if (normalizedStage === 'solver_failed' || normalizedStage === 'solver_required') {
apps/api\src\routes\analysis-rest.ts:927: status?: 'failed' | 'solver_failed' | 'cancelled';
apps/api\src\routes\analysis-rest.ts:929:}): Promise<{ status: 'failed' | 'solver_failed' | 'cancelled'; error: string }> {
apps/api\src\routes\analysis-rest.ts:967: const terminalStatus = params.status === 'solver_failed' ? 'solver_failed' : 'failed';
apps/api\src\routes\analysis-rest.ts:970: (terminalStatus === 'solver_failed' ? 'solver_failed' : 'failed');
apps/api\src\routes\analysis-rest.ts:1058: statusRecord.status === 'solver_failed'
apps/api\src\routes\analysis-rest.ts:1068: stage: analysisFailure.status === 'solver_failed' ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1077: ...(analysisFailure?.status === 'solver_failed'
apps/api\src\routes\analysis-rest.ts:1094: effectiveStatusRecord.status === 'solver_failed' ||
apps/api\src\routes\analysis-rest.ts:1101: effectiveStatusRecord.status === 'solver_failed' ||
apps/api\src\routes\analysis-rest.ts:1105: status: effectiveStatusRecord.status as 'failed' | 'solver_failed' | 'cancelled',
apps/api\src\routes\analysis-rest.ts:1200: status: solverFailed ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1201: stage: solverFailed ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1244: status: solverFailed ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1245: stage: solverFailed ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1259: stage: failure.status === 'solver_failed' ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1279: stage: failure.status === 'solver_failed' ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1353: queueStatus = solverFailed ? 'solver_failed' : 'failed';
apps/api\src\routes\analysis-rest.ts:1354: stage = solverFailed ? 'solver_failed' : 'failed';
apps/api\src\routes\analysis-rest.ts:1389: if (status?.status === 'failed' || status?.status === 'solver_failed') {
apps/api\src\routes\analysis-rest.ts:1419: status: solverFailed ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1420: stage: solverFailed ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1499: status: solverFailed ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1500: stage: solverFailed ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1595: existingStatus.status === 'solver_failed' ||
apps/api\src\routes\analysis-rest.ts:1665: status: solverFailed ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1669: stage: solverFailed ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1978: status: solverFailed ? 'solver_failed' : 'failed',
apps/api\src\routes\analysis-rest.ts:1979: stage: solverFailed ? 'solver_failed' : 'failed',
apps/web\src\components\table\HandTimeline.tsx:31: rawStatus === 'solver_failed' ||
apps/api\src\services\analysis-stage.ts:22: solver_failed: 'solver_failed',
apps/api\src\services\analysis-stage.ts:62: if (params.status === 'solver_failed') {
apps/api\src\services\analysis-stage.ts:63: return 'solver_failed';
apps/api\src\services\analysis-status.ts:9: | 'solver_failed'
apps/api\src\services\analysis-submit.ts:53: status === 'solver_failed' ||
apps/api\src\services\analysis-submit.ts:151: if (!force && existingAnalysisFailure?.status === 'solver_failed' && !isInFlightJobState(jobState)) {
apps/api\src\services\decision-analysis-canonical.test.ts:82: expect(canonical.state).toBe('solver_failed');
apps/api\src\services\decision-analysis-canonical.ts:6: | 'solver_failed'
apps/api\src\services\decision-analysis-canonical.ts:494: return 'solver_failed';
apps/api\src\services\decision-analysis-canonical.ts:506: params.status === 'solver_failed' ||
apps/api\src\services\decision-analysis-canonical.ts:513: return 'solver_failed';
apps/api\src\services\decision-analysis-canonical.ts:650: state !== 'solver_failed' &&
apps/api\src\services\decision-analysis-requirements.test.ts:57: it('classifies legacy postflop node-mix analyses as solver_failed', () => {
apps/api\src\services\decision-analysis-requirements.test.ts:70: status: 'solver_failed',
apps/api\src\services\decision-analysis-requirements.test.ts:71: error: 'hero_combo_unavailable',
apps/api\src\services\decision-analysis-requirements.ts:143: (hasPositivePolicyEntry(params.gtoPolicy) ? 'hero_combo_unavailable' : 'solver_required')
apps/api\src\services\decision-analysis-requirements.ts:148: status: 'failed' | 'solver_failed';
apps/api\src\services\decision-analysis-requirements.ts:161: status: 'solver_failed',
apps/api\src\services\hand-actions.test.ts:46: status: 'queued' | 'running' | 'ready' | 'failed' | 'solver_failed' | 'cancelled';
apps/api\src\services\hand-actions.test.ts:444: expect(byDecision.get('dec_flop_1')?.status).toBe('solver_failed');
apps/api\src\services\hand-actions.test.ts:717: expect(decision?.status).toBe('solver_failed');
apps/api\src\services\hand-actions.test.ts:773: expect(byDecision.get('dec_flop_warn_missing')?.status).toBe('solver_failed');
apps/api\src\services\hand-actions.test.ts:787: it('marks warn-mode solver-service failures as solver_failed and blocks overview', async () => {
apps/api\src\services\hand-actions.test.ts:800: status: 'solver_failed',
apps/api\src\services\hand-actions.test.ts:801: stage: 'solver_failed',
apps/api\src\services\hand-actions.test.ts:822: expect(decision?.status).toBe('solver_failed');
apps/api\src\services\hand-actions.test.ts:823: expect(decision?.stage).toBe('solver_failed');
apps/api\src\services\hand-actions.test.ts:830: it('does not keep waiting_for_decisions when remaining postflop decisions are solver_failed', async () => {
apps/api\src\services\hand-actions.test.ts:835: id: 'dec_pre_waiting_solver_failed',
apps/api\src\services\hand-actions.test.ts:841: id: 'dec_flop_waiting_solver_failed',
apps/api\src\services\hand-actions.test.ts:848: decisionId: 'dec_flop_waiting_solver_failed',
apps/api\src\services\hand-actions.test.ts:849: status: 'solver_failed',
apps/api\src\services\hand-actions.test.ts:850: stage: 'solver_failed',
apps/api\src\services\hand-actions.test.ts:871: expect(byDecision.get('dec_pre_waiting_solver_failed')?.status).toBe('queued');
apps/api\src\services\hand-actions.test.ts:872: expect(byDecision.get('dec_flop_waiting_solver_failed')?.status).toBe('solver_failed');
apps/api\src\services\hand-actions.test.ts:875: it('reconciles queued decision status to solver_failed from debug events', async () => {
apps/api\src\services\hand-actions.test.ts:898: message: 'Stage transition: solver_failed',
apps/api\src\services\hand-actions.test.ts:900: status: 'solver_failed',
apps/api\src\services\hand-actions.test.ts:932: expect(decision?.status).toBe('solver_failed');
apps/api\src\services\hand-actions.test.ts:933: expect(decision?.stage).toBe('solver_failed');
apps/api\src\services\hand-actions.test.ts:1008: it('treats ready status without analysis row as solver_failed when debug indicates hero_combo_unavailable', async () => {
apps/api\src\services\hand-actions.test.ts:1031: message: 'Stage transition: solver_failed',
apps/api\src\services\hand-actions.test.ts:1033: status: 'solver_failed',
apps/api\src\services\hand-actions.test.ts:1034: solverErrorCode: 'hero_combo_unavailable',
apps/api\src\services\hand-actions.test.ts:1035: solverError: 'hero_combo_unavailable',
apps/api\src\services\hand-actions.test.ts:1051: expect(decision?.status).toBe('solver_failed');
apps/api\src\services\hand-actions.test.ts:1052: expect(decision?.stage).toBe('solver_failed');
apps/api\src\services\hand-actions.test.ts:1053: expect(decision?.solverErrorCode).toBe('hero_combo_unavailable');
apps/api\src\services\hand-actions.test.ts:1054: expect(decision?.solverError).toBe('hero_combo_unavailable');
apps/api\src\services\hand-actions.test.ts:1103: expect(decision?.status).toBe('solver_failed');
apps/api\src\services\hand-actions.ts:49: | 'solver_failed'
apps/api\src\services\hand-actions.ts:59: | 'solver_failed'
apps/api\src\services\hand-actions.ts:416: status: 'queued' | 'running' | 'ready' | 'failed' | 'solver_failed' | 'cancelled';
apps/api\src\services\hand-actions.ts:572: stage === 'solver_failed' ||
apps/api\src\services\hand-actions.ts:581: stage === 'solver_failed' ||
apps/api\src\services\hand-actions.ts:792: return decision.status === 'solver_failed';
apps/api\src\services\hand-actions.ts:1263: decisionStatus?.status === 'solver_failed' ||
apps/api\src\services\hand-actions.ts:1303: ? 'solver_failed'
apps/api\src\services\hand-actions.ts:1307: ? 'solver_failed'
apps/api\src\services\hand-actions.ts:1325: ? 'solver_failed'
apps/api\src\services\hand-actions.ts:1327: ? 'solver_failed'
apps/api\src\services\hand-actions.ts:1405: : debugSolverSummary.stage ?? 'solver_failed';
apps/api\src\services\hand-actions.ts:1444: normalizedFailedStage === 'solver_failed' ||
apps/api\src\services\hand-actions.ts:1456: ? ('solver_failed' as const)
apps/api\src\services\hand-actions.ts:1491: const solverFailureByCode = inferredSolverErrorCode === 'hero_combo_unavailable';
apps/api\src\services\hand-actions.ts:1494: normalizedReadyStage === 'solver_failed' ||
apps/api\src\services\hand-actions.ts:1526: ? ('solver_failed' as const)
apps/api\src\services\hand-actions.ts:1528: stage: solverFailedStage ? 'solver_failed' : 'failed',
apps/api\src\services\hand-actions.ts:1564: ? 'solver_failed'
apps/api\src\services\hand-actions.ts:1577: readyStatus === 'solver_failed'
apps/api\src\services\hand-actions.ts:1578: ? 'solver_failed'
apps/api\src\services\hand-actions.ts:1582: errorMessage: readyStatus === 'solver_failed' ? postflopFailure : explanationFailure,
apps/api\src\services\hand-actions.ts:1586: solverError: readyStatus === 'solver_failed' ? postflopFailure : explanationFailure,
apps/api\src\services\hand-actions.ts:1594: decisionStatus?.status === 'solver_failed' ||
apps/api\src\services\hand-actions.ts:1603: normalizedFailedStage === 'solver_failed' ||
apps/api\src\services\hand-actions.ts:1604: decisionStatus.status === 'solver_failed' ||
apps/api\src\services\hand-actions.ts:1638: ? ('solver_failed' as const)
apps/api\src\services\hand-actions.ts:1680: status: 'solver_failed',
apps/api\src\services\hand-actions.ts:1684: : debugSolverSummary.stage ?? 'solver_failed',
apps/api\src\services\hand-actions.ts:1739: (decision) => decision.status === 'failed' || decision.status === 'solver_failed',
apps/api\src\services\hand-actions.ts:1749: decision.status === 'solver_failed',
apps/api\src\services\hand-actions.ts:1864: decision.status === 'solver_failed' ||
apps/api\src\services\hand-actions.ts:1887: (decision) => decision.status === 'failed' || decision.status === 'solver_failed',
apps/api\src\services\hand-analysis-pipeline.test.ts:24: status?: 'failed' | 'solver_failed' | 'cancelled';
apps/api\src\services\hand-analysis-pipeline.test.ts:338: it('does not queue WHOLE_HAND when warn strictness has postflop solver_failed decisions', async () => {
apps/api\src\services\hand-analysis-pipeline.test.ts:367: it('blocks WHOLE_HAND when analysis status is explicitly solver_failed', async () => {
apps/api\src\services\hand-analysis-pipeline.test.ts:376: status: 'solver_failed',
apps/api\src\services\hand-analysis-pipeline.test.ts:377: stage: 'solver_failed',
apps/api\src\services\hand-analysis-pipeline.ts:142: in: ['failed', 'solver_failed', 'cancelled'],
apps/api\src\services\hand-analysis-pipeline.ts:155: if (stage === 'solver_required' || stage === 'solver_failed') {
apps/api\src\services\hand-analysis-pipeline.ts:214: in: ['failed', 'solver_failed', 'cancelled'],
apps/api\src\workers\analysis-worker.integration.test.ts:584: it('treats postflop multi-way spots as solver_failed even if fallback coaching exists', async () => {
apps/api\src\workers\analysis-worker.integration.test.ts:619: expect(result.status).toBe('solver_failed');
apps/api\src\workers\analysis-worker.integration.test.ts:625: it('keeps solver timeout as a terminal solver_failed outcome', async () => {
apps/api\src\workers\analysis-worker.integration.test.ts:656: expect(result.status).toBe('solver_failed');
apps/api\src\workers\analysis-worker.integration.test.ts:663: it('keeps timeout aborts as a terminal solver_failed outcome', async () => {
apps/api\src\workers\analysis-worker.integration.test.ts:695: expect(result.status).toBe('solver_failed');
apps/api\src\workers\analysis-worker.integration.test.ts:1762: it('marks decision solver_failed when solver completes without heroComboPolicy', async () => {
apps/api\src\workers\analysis-worker.integration.test.ts:1768: errorCode: 'hero_combo_unavailable',
apps/api\src\workers\analysis-worker.integration.test.ts:1776: heroComboFailureReason: 'hero_combo_unavailable',
apps/api\src\workers\analysis-worker.integration.test.ts:1807: expect(result.status).toBe('solver_failed');
apps/api\src\workers\analysis-worker.integration.test.ts:1813: params?.update?.status === 'solver_failed' &&
apps/api\src\workers\analysis-worker.integration.test.ts:1814: params?.update?.stage === 'solver_failed' &&
apps/api\src\workers\analysis-worker.integration.test.ts:1815: params?.update?.errorMessage === 'hero_combo_unavailable'
apps/api\src\workers\analysis-worker.integration.test.ts:1823: it('marks decision solver_failed when comboPolicies do not contain the hero key', async () => {
apps/api\src\workers\analysis-worker.integration.test.ts:1842: heroComboFailureReason: 'hero_key_not_in_combo_map',
apps/api\src\workers\analysis-worker.integration.test.ts:1873: expect(result.status).toBe('solver_failed');
apps/api\src\workers\analysis-worker.integration.test.ts:1879: params?.update?.status === 'solver_failed' &&
apps/api\src\workers\analysis-worker.integration.test.ts:1880: params?.update?.stage === 'solver_failed' &&
apps/api\src\workers\analysis-worker.integration.test.ts:1881: params?.update?.errorMessage === 'hero_combo_unavailable'
apps/api\src\workers\analysis-worker.test.ts:440: expect(result.status).toBe('solver_failed');
apps/api\src\workers\analysis-worker.test.ts:493: expect(result.status).toBe('solver_failed');
apps/api\src\workers\analysis-worker.test.ts:549: expect(result.status).toBe('solver_failed');
apps/api\src\workers\analysis-worker.test.ts:618: expect(result.status).toBe('solver_failed');
apps/api\src\workers\analysis-worker.test.ts:628: params?.update?.status === 'solver_failed' &&
apps/api\src\workers\analysis-worker.test.ts:629: params?.update?.stage === 'solver_failed' &&
apps/api\src\workers\analysis-worker.test.ts:650: it('classifies solver-service HTTP errors as solver_failed in warn strictness', async () => {
apps/api\src\workers\analysis-worker.test.ts:691: expect(result.status).toBe('solver_failed');
apps/api\src\workers\analysis-worker.test.ts:708: params?.update?.status === 'solver_failed' &&
apps/api\src\workers\analysis-worker.test.ts:709: params?.update?.stage === 'solver_failed'
apps/api\src\workers\analysis-worker.test.ts:729: it('captures solver-service stream error details in debug events and solver_failed status', async () => {
apps/api\src\workers\analysis-worker.test.ts:784: expect(result.status).toBe('solver_failed');
apps/api\src\workers\analysis-worker.test.ts:788: params?.update?.status === 'solver_failed' &&
apps/api\src\workers\analysis-worker.test.ts:789: params?.update?.stage === 'solver_failed'
apps/api\src\workers\analysis-worker.test.ts:814: .find((event) => event.source === 'api-worker' && event.message.includes('Stage transition: solver_failed'));
apps/api\src\workers\analysis-worker.test.ts:916: it('captures solver-service result ERROR details in debug events and solver_failed status', async () => {
apps/api\src\workers\analysis-worker.test.ts:964: expect(result.status).toBe('solver_failed');
apps/api\src\workers\analysis-worker.test.ts:968: params?.update?.status === 'solver_failed' &&
apps/api\src\workers\analysis-worker.test.ts:969: params?.update?.stage === 'solver_failed'
apps/api\src\workers\analysis-worker.test.ts:988: .find((event) => event.source === 'api-worker' && event.message.includes('Stage transition: solver_failed'));
apps/api\src\workers\analysis-worker.test.ts:1069: expect(result.status).toBe('solver_failed');
apps/api\src\workers\analysis-worker.test.ts:1073: params?.update?.status === 'solver_failed' &&
apps/api\src\workers\analysis-worker.test.ts:1074: params?.update?.stage === 'solver_failed'
apps/api\src\workers\analysis-worker.test.ts:1085: .find((event) => event.source === 'api-worker' && event.message.includes('Stage transition: solver_failed'));
apps/api\src\workers\analysis-worker.logic.ts:274:const HERO_COMBO_UNAVAILABLE_ERROR_CODE = 'hero_combo_unavailable';
apps/api\src\workers\analysis-worker.logic.ts:276:const HERO_COMBO_KEY_MISSING_REASON = 'hero_key_not_in_combo_map';
apps/api\src\workers\analysis-worker.logic.ts:1853: const comboPolicyKeyCount =
apps/api\src\workers\analysis-worker.logic.ts:1875: comboPolicyKeyCount,
apps/api\src\workers\analysis-worker.logic.ts:5395: | 'solver_failed'
apps/api\src\workers\analysis-worker.logic.ts:5406: : params.stage === 'solver_failed'
apps/api\src\workers\analysis-worker.logic.ts:5407: ? 'solver_failed'
apps/api\src\workers\analysis-worker.logic.ts:5416: params.stage === 'solver_failed'
apps/api\src\workers\analysis-worker.logic.ts:5433: params.stage === 'failed' || params.stage === 'solver_failed' || params.stage === 'solver_required'
apps/api\src\workers\analysis-worker.logic.ts:5714: stage: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:5716: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:5722: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:5821: stage: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:5822: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:5829: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:5845: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:5852: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6253: stage: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6255: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6261: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6272: stage: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6274: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6280: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6312: stage: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6314: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6320: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6369: stage: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6371: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6377: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6387: stage: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6389: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6395: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6515: message: 'Hero combo policy unavailable',
apps/api\src\workers\analysis-worker.logic.ts:6537: stage: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6539: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6545: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6982: stage: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6984: status: 'solver_failed',
apps/api\src\workers\analysis-worker.logic.ts:6991: status: 'solver_failed',
Get-Content apps\api\src\workers\analysis-worker.logic.tsExit code: 0
Wall time: 1.6 seconds
Total output lines: 7185
Output:
import { Worker, Job, UnrecoverableError } from 'bullmq';
import { Prisma } from '@prisma/client';
import { extname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Agent } from 'undici';
import {
getAnalysisQueue,
} from '../queue.js';
import { prisma } from '../db.js';
import { config, resolveSolverUrlFromEnv } from '../config.js';
import { upsertAnalysisStatus, type AnalysisJobStatus } from '../services/analysis-status.js';
import { appendDecisionDebugEvent } from '../services/analysis-debug-events.js';
import {
getAnalysisExplanationLlmClient,
setAnalysisExplanationLlmClient as setSharedAnalysisExplanationLlmClient,
} from '../services/analysis-explanation-client.js';
import { isAnalysisJobId, parseDecisionIdFromJobId } from '../analysis-job-id.js';
import {
HAND_ANALYSIS_PROMPT_VERSION,
HAND_ANALYSIS_REQUEUE_DELAY_MS,
POSTFLOP_STREETS,
} from '../hand-analysis-constants.js';
import {
buildDelayedHandAnalysisJobId,
} from '../hand-analysis-job-id.js';
import { HAND_REPORT_SCOPES, type HandReportScopeValue } from '../hand-report-job-id.js';
import {
finalizeHandAnalysisRunForDecision,
markOverviewCompleted,
} from '../services/hand-analysis-pipeline.js';
import { decisionAnalysisSatisfiesRequirements } from '../services/decision-analysis-requirements.js';
import {
buildHandReportFallback,
buildHandReportPrompt,
buildHandReportPromptInput,
type ScopedHandReportContent,
} from '../services/hand-report-context.js';
import {
buildCanonicalDecisionAnalysis,
findInvalidPresetResponseDisplayKeys,
readCanonicalDecisionAnalysis,
type CanonicalDecisionAnalysis,
} from '../services/decision-analysis-canonical.js';
import { replayHand, type HandMeta, type HandEvent } from '@poker/table';
import {
computeActionSizing,
matchChildForAction,
toCanonicalCardToken,
toTexasSolverComboKey,
toTexasSolverComboKeyFromCards,
} from '@poker/shared';
import {
ExplanationGenerationError,
explainDecision,
formatExplanationText,
parseLLMExplanationJson,
structuredExplanationFromPlainText,
validateLLMExplanationOutput,
type Explanation,
type ExplanationContext,
type ExplanationLLMClient,
type SolverSummary,
} from '../explain.js';
import { buildDerivedActionHistory } from './analysis-history.js';
import { resolveDecisionPotBefore } from './analysis-pot.js';
import {
buildDisplayPolicyForSizingDecision,
formatSizingKey,
mapDisplayPolicyKey,
applyDecisionStreetSizing,
normalizeStreetSizes,
resolveSizingKeys,
SNAP_TOLERANCE,
} from './analysis-sizing.js';
import { rewriteRaisePolicyKeys } from './analysis-raise-key.js';
import {
countActivePlayersAtDecision,
filterEventsUpToStreet,
isSolverStreetSupported,
normalizeStreet,
shouldUseSolver,
toSolverStreet,
validateBoardLengthForStreet,
type SolverStreet,
} from './analysis-worker-utils.js';
interface AnalysisJobData {
handId: string;
decisionId: string;
userId?: string;
}
interface HandAnalysisJobData {
handAnalysisId: string;
}
interface HandReportJobData {
handId: string;
userId: string;
scope: HandReportScopeValue;
runoutAware: boolean;
}
interface DbHandEvent {
sequence: number;
timestamp: Date;
payload: unknown;
type?: string | null;
}
interface DecisionRecord {
playerId: string;
action: string;
amount?: number | null;
potBefore?: number | null;
toCall?: number | null;
committedThisStreetBefore?: number | null;
timestamp: Date;
street?: string | null;
}
type SizingMode = 'preset' | 'include_actual';
interface SolverServiceNormalized {
policy: Record<string, number>;
comboPolicies?: Record<string, Record<string, number>>;
actionEvs?: Record<string, number>;
nodeEv?: number;
heroComboKey?: string | null;
heroComboPolicy?: Record<string, number>;
heroComboFailureReason?: string | null;
}
type SolverSelectionMeta = {
status: 'matched' | 'unsupported' | 'approximated';
failedAt?: number;
message?: string;
path?: string[];
availableActions?: string[];
snapped?: boolean;
targetFraction?: number;
chosenFraction?: number;
matchedFraction?: number;
modeUsed?: 'total' | 'delta';
matchedKey?: string;
snappedFromKey?: string;
snappedToKey?: string;
};
type SolverActionHistoryEntry = {
action: string;
amount?: number;
potBefore: number;
potAtStreetStart: number;
toCall?: number;
lastAggressorBet?: number;
committedThisStreetBefore: number;
};
type SolverRequestMeta = {
pot: number;
realEffectiveStack: number;
cappedEffectiveStack: number;
maxSpr: number;
stackCapped: boolean;
};
interface SolverServiceRequest {
pot: number;
effectiveStack: number;
street: SolverStreet;
board: string[];
ipRange: string;
oopRange: string;
betSizes: {
flop: number[];
turn: number[];
river: number[];
};
raiseSizes?: {
flop: number[];
turn: number[];
river: number[];
};
actionHistory?: SolverActionHistoryEntry[];
accuracy?: number;
maxIteration?: number;
timeoutMs?: number;
heroCards?: [string, string];
actingSeat?: number | null;
}
interface SolverServiceResponse {
status: 'COMPLETED' | 'unsupported';
requestHash: string;
raw?: unknown;
normalized?: SolverServiceNormalized | null;
error?: string;
errorCode?: string;
meta?: {
runtimeMs?: number;
cached?: boolean;
progressPercent?: number;
selection?: SolverSelectionMeta;
};
}
type SolverDebugEvent = {
source: 'api-worker' | 'solver-service';
level: 'info' | 'warn' | 'error';
ts?: string;
message: string;
data?: Record<string, unknown>;
};
type SolverDebugSink = (event: SolverDebugEvent) => Promise<void> | void;
// Wider default ranges to avoid degenerate solver trees
// Includes pairs 22+, broadway combos, suited connectors, suited aces
const DEFAULT_IP_RANGE = [
// Pairs
'AA:1,KK:1,QQ:1,JJ:1,TT:1,99:1,88:1,77:1,66:1,55:1,44:1,33:1,22:1',
// Broadway
'AKs:1,AKo:1,AQs:1,AQo:1,AJs:1,AJo:1,ATs:1,KQs:1,KQo:1,KJs:1,KTs:1,QJs:1,QTs:1,JTs:1',
// Suited connectors and one-gappers
'T9s:1,98s:1,87s:1,76s:1,65s:1,54s:1,J9s:1,T8s:1,97s:1,86s:1,75s:1',
// Suited aces
'A9s:1,A8s:1,A7s:1,A6s:1,A5s:1,A4s:1,A3s:1,A2s:1',
].join(',');
const DEFAULT_OOP_RANGE = DEFAULT_IP_RANGE;
const DEFAULT_BET_SIZES_POT: SolverServiceRequest['betSizes'] = {
flop: [1 / 3, 2 / 3, 1],
turn: [1 / 3, 2 / 3, 1],
river: [1 / 3, 2 / 3, 1],
};
const DEFAULT_RAISE_SIZES_POT: NonNullable<SolverServiceRequest['raiseSizes']> = {
flop: [1 / 3, 2 / 3, 1],
turn: [1 / 3, 2 / 3, 1],
river: [1 / 3, 2 / 3, 1],
};
const DEFAULT_FLOP_BET_SIZES_POT = [1 / 3, 2 / 3];
const DEFAULT_FLOP_RAISE_SIZES_POT = [2 / 3];
const DEFAULT_SOLVER_TARGET_MS = 300_000;
const DEFAULT_SOLVER_TIMEOUT_MS = 600_000;
const DEFAULT_SOLVER_ACCURACY = 1;
const DEFAULT_SOLVER_MAX_ITERATION = 30;
const DEFAULT_SOLVER_FLOP_TARGET_MS = DEFAULT_SOLVER_TARGET_MS;
const DEFAULT_SOLVER_FLOP_MAX_ITERATION = 18;
const DEFAULT_SOLVER_MAX_SPR = 12;
const DEFAULT_HAND_REPORT_SOLVER_TIMEOUT_MS = 20_000;
const SOLVER_HTTP_TIMEOUT_BUFFER_MS = 30_000;
export const SOLVER_HTTP_408_RETRY_COUNT = 2;
const SOLVER_HTTP_408_BACKOFF_BASE_MS = 1_500;
const DEFAULT_SOLVER_HTTP_429_COOLDOWN_MS = 10_000;
const DEFAULT_SOLVER_MAX_INJECTION_FRACTION = 100;
const ANALYSIS_JOB_TIMEOUT_BUFFER_MS = 120_000;
const DEFAULT_ANALYSIS_WORKER_CONCURRENCY = 8;
const DEFAULT_ANALYSIS_WORKER_LIMITER_MAX = 1;
const DEFAULT_ANALYSIS_WORKER_LIMITER_DURATION_MS = 1_000;
const DEFAULT_ANALYSIS_WORKER_LOCK_BUFFER_MS = 120_000;
const DEFAULT_ANALYSIS_WORKER_LOCK_RENEW_TIME_MS = 30_000;
const DEFAULT_ANALYSIS_WORKER_STALLED_INTERVAL_MS = 30_000;
const DEFAULT_ANALYSIS_WORKER_MAX_STALLED_COUNT_PROD = 2;
const DEFAULT_ANALYSIS_WORKER_MAX_STALLED_COUNT_DEV = 3;
const DEFAULT_EVENT_LOOP_YIELD_EVERY = 500;
const STALLED_LIMIT_REASON_FRAGMENT = 'job stalled more than allowable limit';
export const ANALYSIS_WORKER_SANDBOX_CHILD_ENV = 'ANALYSIS_WORKER_SANDBOX_CHILD';
const DEFAULT_ANALYSIS_DEV_BLOCK_EVENT_LOOP_MS = 0;
const SOLVER_TIMEOUT_USER_MESSAGE =
'Solver timed out. Try again, or use smaller bet sizes / fewer iterations.';
const SOLVER_CRASH_USER_MESSAGE =
'Solver crashed while analyzing this spot. Try again, or use a smaller tree.';
const HERO_COMBO_UNAVAILABLE_ERROR_CODE = 'hero_combo_unavailable';
const HERO_COMBO_MAP_MISSING_REASON = 'missing_combo_map_in_solver_output';
const HERO_COMBO_KEY_MISSING_REASON = 'hero_key_not_in_combo_map';
const RANGE_CLASS_RANK_ORDER = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'] as const;
const RANGE_CLASS_RANK_SCORES = RANGE_CLASS_RANK_ORDER.reduce<Record<string, number>>(
(scores, rank, index) => {
scores[rank] = RANGE_CLASS_RANK_ORDER.length - index;
return scores;
},
{},
);
type AnalysisWorkerExecutionMode = 'inline' | 'process' | 'threads';
export const SOLVER_TARGET_MS =
readPositiveIntFromEnv('SOLVER_TARGET_MS') ?? DEFAULT_SOLVER_TARGET_MS;
export const SOLVER_TIMEOUT_MS =
readPositiveIntFromEnv('SOLVER_TIMEOUT_MS') ??
readPositiveIntFromEnv('TEXAS_SOLVER_MAX_MS') ??
DEFAULT_SOLVER_TIMEOUT_MS;
const SOLVER_ACCURACY =
readPositiveNumberFromEnv('SOLVER_ACCURACY') ?? DEFAULT_SOLVER_ACCURACY;
const SOLVER_MAX_ITERATION =
readPositiveIntFromEnv('SOLVER_MAX_ITERATION') ?? DEFAULT_SOLVER_MAX_ITERATION;
const SOLVER_FLOP_TARGET_MS =
readPositiveIntFromEnv('SOLVER_FLOP_TARGET_MS') ?? DEFAULT_SOLVER_FLOP_TARGET_MS;
const SOLVER_FLOP_MAX_ITERATION =
readPositiveIntFromEnv('SOLVER_FLOP_MAX_ITERATION') ?? DEFAULT_SOLVER_FLOP_MAX_ITERATION;
const SOLVER_MAX_SPR =
readPositiveNumberFromEnv('SOLVER_MAX_SPR') ?? DEFAULT_SOLVER_MAX_SPR;
const HAND_REPORT_SOLVER_TIMEOUT_MS =
readPositiveIntFromEnv('HAND_REPORT_SOLVER_TIMEOUT_MS') ??
DEFAULT_HAND_REPORT_SOLVER_TIMEOUT_MS;
const SOLVER_SIZING_MODE: SizingMode = readSizingModeFromEnv();
export const SOLVER_HTTP_TIMEOUT_MS = SOLVER_TIMEOUT_MS + SOLVER_HTTP_TIMEOUT_BUFFER_MS;
export const SOLVER_HTTP_429_COOLDOWN_MS =
readPositiveIntFromEnv('SOLVER_HTTP_429_COOLDOWN_MS') ?? DEFAULT_SOLVER_HTTP_429_COOLDOWN_MS;
export const ANALYSIS_JOB_TIMEOUT_MS =
readPositiveIntFromEnv('ANALYSIS_JOB_TIMEOUT_MS') ??
SOLVER_HTTP_TIMEOUT_MS + ANALYSIS_JOB_TIMEOUT_BUFFER_MS;
const SOLVER_HTTP_BODY_MAX_CHARS = 2_000;
export const ANALYSIS_WORKER_CONCURRENCY =
readPositiveIntFromEnv('ANALYSIS_WORKER_CONCURRENCY') ??
DEFAULT_ANALYSIS_WORKER_CONCURRENCY;
export const ANALYSIS_WORKER_LIMITER_MAX =
readPositiveIntFromEnv('ANALYSIS_WORKER_LIMITER_MAX') ??
DEFAULT_ANALYSIS_WORKER_LIMITER_MAX;
export const ANALYSIS_WORKER_LIMITER_DURATION_MS =
readPositiveIntFromEnv('ANALYSIS_WORKER_LIMITER_DURATION_MS') ??
DEFAULT_ANALYSIS_WORKER_LIMITER_DURATION_MS;
export const ANALYSIS_WORKER_EXECUTION_MODE = readAnalysisWorkerExecutionModeFromEnv();
export const IS_ANALYSIS_SANDBOX_CHILD =
process.env[ANALYSIS_WORKER_SANDBOX_CHILD_ENV] === '1';
const ANALYSIS_WORKER_LOCK_BUFFER_MS =
readPositiveIntFromEnv('ANALYSIS_WORKER_LOCK_BUFFER_MS') ??
DEFAULT_ANALYSIS_WORKER_LOCK_BUFFER_MS;
export const ANALYSIS_WORKER_LOCK_DURATION_MS =
readPositiveIntFromEnv('ANALYSIS_WORKER_LOCK_DURATION_MS') ??
Math.max(
ANALYSIS_JOB_TIMEOUT_MS + ANALYSIS_WORKER_LOCK_BUFFER_MS,
SOLVER_TIMEOUT_MS + ANALYSIS_WORKER_LOCK_BUFFER_MS
);
export const ANALYSIS_WORKER_LOCK_RENEW_TIME_MS =
readPositiveIntFromEnv('ANALYSIS_WORKER_LOCK_RENEW_TIME_MS') ??
DEFAULT_ANALYSIS_WORKER_LOCK_RENEW_TIME_MS;
export const ANALYSIS_WORKER_STALLED_INTERVAL_MS =
readPositiveIntFromEnv('ANALYSIS_WORKER_STALLED_INTERVAL_MS') ??
DEFAULT_ANALYSIS_WORKER_STALLED_INTERVAL_MS;
export const ANALYSIS_WORKER_MAX_STALLED_COUNT =
readPositiveIntFromEnv('ANALYSIS_WORKER_MAX_STALLED_COUNT') ??
(process.env.NODE_ENV === 'production'
? DEFAULT_ANALYSIS_WORKER_MAX_STALLED_COUNT_PROD
: DEFAULT_ANALYSIS_WORKER_MAX_STALLED_COUNT_DEV);
const EVENT_LOOP_YIELD_EVERY =
readPositiveIntFromEnv('ANALYSIS_EVENT_LOOP_YIELD_EVERY') ??
DEFAULT_EVENT_LOOP_YIELD_EVERY;
const ANALYSIS_DEV_BLOCK_EVENT_LOOP_MS =
readPositiveIntFromEnv('ANALYSIS_DEV_BLOCK_EVENT_LOOP_MS') ??
DEFAULT_ANALYSIS_DEV_BLOCK_EVENT_LOOP_MS;
const ANALYSIS_DEV_BLOCK_EVENT_LOOP_ONCE =
process.env.ANALYSIS_DEV_BLOCK_EVENT_LOOP_ONCE !== '0';
const ANALYSIS_DEV_BLOCK_EVENT_LOOP_DECISION_ID =
process.env.ANALYSIS_DEV_BLOCK_EVENT_LOOP_DECISION_ID?.trim() || null;
const SOLVER_MAX_INJECTION_FRACTION =
readPositiveNumberFromEnv('SOLVER_MAX_INJECTION_FRACTION') ??
DEFAULT_SOLVER_MAX_INJECTION_FRACTION;
const ANALYSIS_VERBOSE_TERMINAL_LOGS =
process.env.ANALYSIS_VERBOSE_TERMINAL_LOGS === '1';
const ANALYSIS_DEBUG_RECOMMENDATION_TRACE =
process.env.ANALYSIS_DEBUG_RECOMMENDATION_TRACE === '1';
const SOLVER_DISPATCHER = new Agent({ headersTimeout: 0, bodyTimeout: 0 });
const DEFAULT_MATCH_TOLERANCE = 0.1;
const MATCH_TOLERANCE =
readPositiveNumberFromEnv('SOLVER_ACTION_TOLERANCE') ?? DEFAULT_MATCH_TOLERANCE;
const POT_BEFORE_EPS = 1e-3;
const POSTFLOP_STREET_SET = new Set<string>(POSTFLOP_STREETS);
export const HAND_ANALYSIS_MAX_DECISION_RETRIES = 3;
const HAND_REPORT_SCOPE_SET = new Set<string>(HAND_REPORT_SCOPES);
let analysisDevEventLoopBlocked = false;
let activeAnalysisWorkerRateLimiter: Pick<Worker, 'rateLimit'> | null = null;
let solverConnectivityCheckedInDev = false;
export function setAnalysisWorkerRateLimiterForTest(
rateLimiter: Pick<Worker, 'rateLimit'> | null,
): void {
activeAnalysisWorkerRateLimiter = rateLimiter;
}
export function setAnalysisExplanationLlmClient(
client?: ExplanationLLMClient
): void {
setSharedAnalysisExplanationLlmClient(client);
}
function readPositiveIntFromEnv(name: string): number | undefined {
const value = process.env[name];
if (!value) return undefined;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) {
return undefined;
}
return parsed;
}
function readPositiveNumberFromEnv(name: string): number | undefined {
const value = process.env[name];
if (!value) return undefined;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
}
function readSizingModeFromEnv(): SizingMode {
const raw = process.env.SOLVER_SIZING_MODE;
if (!raw) return 'preset';
const normalized = raw.trim().toLowerCase();
return normalized === 'include_actual' ? 'include_actual' : 'preset';
}
function readAnalysisWorkerExecutionModeFromEnv(): AnalysisWorkerExecutionMode {
const raw = process.env.ANALYSIS_WORKER_EXECUTION_MODE;
if (!raw) {
return process.env.NODE_ENV === 'production' ? 'process' : 'inline';
}
const normalized = raw.trim().toLowerCase();
if (normalized === 'inline' || normalized === 'process' || normalized === 'threads') {
return normalized;
}
return process.env.NODE_ENV === 'production' ? 'process' : 'inline';
}
function readSolverErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== 'object') return undefined;
const payload = error as {
code?: unknown;
errorCode?: unknown;
solverErrorCode?: unknown;
};
if (typeof payload.solverErrorCode === 'string' && payload.solverErrorCode.trim()) {
return payload.solverErrorCode.trim();
}
if (typeof payload.code === 'string' && payload.code.trim()) {
return payload.code.trim();
}
if (typeof payload.errorCode === 'string' && payload.errorCode.trim()) {
return payload.errorCode.trim();
}
return undefined;
}
function normalizeSolverErrorCodeKey(code: string | undefined): string | null {
if (typeof code !== 'string') {
return null;
}
const normalized = code.trim().toLowerCase();
return normalized || null;
}
function isSolverCrashCode(code: string | undefined): boolean {
const normalized = normalizeSolverErrorCodeKey(code);
return (
normalized === 'solver_killed' ||
normalized === 'crash' ||
normalized === 'solver-service:crash' ||
normalized === 'solver-service:solver_killed'
);
}
function isSolverTimeoutCode(code: string | undefined): boolean {
const normalized = normalizeSolverErrorCodeKey(code);
return (
normalized === 'solver_timeout' ||
normalized === 'timeout' ||
normalized === 'solver-service:timeout' ||
normalized === 'solver-service:solver_timeout'
);
}
function textIndicatesSolverCrash(text: string | undefined): boolean {
const normalized = text?.toLowerCase() ?? '';
return (
normalized.includes('sigsegv') ||
normalized.includes('sigabrt') ||
normalized.includes('sigbus') ||
normalized.includes('sigill') ||
normalized.includes('segmentation fault') ||
normalized.includes('solver crash') ||
normalized.includes('solver crashed') ||
normalized.includes('crash signal') ||
normalized.includes('exited via signal') ||
normalized.includes('solver-service:crash')
);
}
function readSolverAttemptRecords(error: unknown): Array<Record<string, unknown>> {
if (!error || typeof error !== 'object') {
return [];
}
const payload = error as { solverAttempts?: unknown; attempts?: unknown };
const value = Array.isArray(payload.solverAttempts)
? payload.solverAttempts
: Array.isArray(payload.attempts)
? payload.attempts
: [];
return value.filter(
(entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === 'object'
);
}
function hasSolverCrashAttempt(error: unknown): boolean {
return readSolverAttemptRecords(error).some((attempt) => {
const attemptCode =
typeof attempt.errorCode === 'string' ? attempt.errorCode : undefined;
const attemptMessage =
typeof attempt.message === 'string' ? attempt.message : undefined;
const attemptSignal =
typeof attempt.signal === 'string' ? attempt.signal : undefined;
return (
isSolverCrashCode(attemptCode) ||
normalizeSolverErrorCodeKey(attemptCode) === 'crash' ||
Boolean(attemptSignal) ||
textIndicatesSolverCrash(attemptMessage)
);
});
}
function isSolverCrashError(error: unknown): boolean {
if (isSolverCrashCode(readSolverErrorCode(error))) {
return true;
}
if (hasSolverCrashAttempt(error)) {
return true;
}
if (!error || typeof error !== 'object') {
return false;
}
const payload = error as {
signal?: unknown;
stderrTail?: unknown;
solverStderrTail?: unknown;
};
const signal = typeof payload.signal === 'string' ? payload.signal : undefined;
const stderrTail =
typeof payload.stderrTail === 'string'
? payload.stderrTail
: typeof payload.solverStderrTail === 'string'
? payload.solverStderrTail
: undefined;
if (signal) {
return true;
}
const message = error instanceof Error ? error.message : String(error);
return textIndicatesSolverCrash(message) || textIndicatesSolverCrash(stderrTail);
}
function isSolverTimeoutError(error: unknown): boolean {
const code = readSolverErrorCode(error);
if (isSolverCrashError(error)) {
return false;
}
if (isSolverTimeoutCode(code)) {
return true;
}
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
return (
normalized.includes('timed out') ||
normalized.includes('timeout') ||
normalized.includes('http 408')
);
}
function buildSolverTimeoutMessage(error: unknow…50168 tokens truncated…
analysisMeta.snapped = displaySizingResult.snapped;
analysisMeta.snappedToKey = displaySizingResult.snapped
? displaySizingResult.actualSizingKey ?? null
: null;
const mappedRecommended = mapDisplayPolicyKey(
canonicalRecommendedAction,
displaySizingResult.keyMap
);
outputRecommendedAction =
displayPolicy[mappedRecommended] !== undefined
? mappedRecommended
: canonicalRecommendedAction;
}
// Build fixed display policy with predefined options
// Non-response: check, bet 1/3, bet 2/3, bet pot, user action
// Response: fold, call, raise pot, all-in, user action
const displayPolicyBeforeFixedMapping = displayPolicy;
try {
displayPolicy = buildFixedDisplayPolicy(
displayPolicyBeforeFixedMapping,
isResponseNode,
analysisMeta.userActionKey ?? null,
analysisMeta.actualActionFraction,
SNAP_TOLERANCE,
{ sizingMode: analysisMeta.sizingMode ?? null },
);
} catch (error) {
await pushDecisionDebug({
level: 'error',
scope: solverStreet.toUpperCase(),
message: 'Invalid display policy after sizing canonicalization',
data: {
reason: error instanceof Error ? error.message : String(error),
policyKeys: Object.keys(displayPolicyBeforeFixedMapping),
policySum: Number(sumPolicyFrequency(displayPolicyBeforeFixedMapping).toFixed(4)),
},
});
throw error;
}
const finalDisplayedActualActionKey = resolveFixedDisplayActionKey({
isResponseNode,
userActionKey: analysisMeta.userActionKey ?? null,
actualFraction: analysisMeta.actualActionFraction ?? null,
snapTolerance: SNAP_TOLERANCE,
});
if (finalDisplayedActualActionKey) {
analysisMeta.displayActionKey = finalDisplayedActualActionKey;
analysisMeta.actualActionKey = finalDisplayedActualActionKey;
}
if (Object.keys(displayPolicy).length > 0) {
outputRecommendedAction = pickDisplayedRecommendedAction(displayPolicy);
}
const canonicalPolicy = buildCanonicalDecisionAnalysis({
status,
policy: displayPolicy,
meta: analysisMeta,
combo: heroCardInfo.canonicalCards?.join('') ?? null,
board: handState.board?.map((card: any) => `${card.rank}${card.suit}`) ?? [],
rawAction: decision.action,
amount: typeof decision.amount === 'number' ? decision.amount : null,
normalizeDisplayFamilies: true,
actualActionKey:
analysisMeta.displayActionKey ??
analysisMeta.actualActionKey ??
analysisMeta.userActionKey ??
displayActionKey ??
decisionPolicyKey ??
null,
});
if (canonicalPolicy.recommendedActionKey) {
outputRecommendedAction = canonicalPolicy.recommendedActionKey;
}
const recommendationSource: NonNullable<AnalysisMeta['recommendationSource']> = 'hero_combo';
const selectedActionPercentages = canonicalPolicy.displayedStrategyActions.reduce<Record<string, number>>(
(acc, action) => {
acc[action.actionKey] = action.freqPct;
return acc;
},
{},
);
analysisMeta.recommendationSource = recommendationSource;
analysisMeta.heroComboFailureReason = null;
analysisMeta.solverNodePath = solverNodePath.length > 0 ? solverNodePath : null;
analysisMeta.heroComboLookupKey = heroComboLookupKey;
analysisMeta.solverComboKeysSample = solverComboKeysSample;
analysisMeta.lookupHit = lookupHit;
analysisMeta.playerPerspective = 'action_history_selected_node';
if (ANALYSIS_DEBUG_RECOMMENDATION_TRACE) {
await pushDecisionDebug({
level: 'info',
scope: solverStreet.toUpperCase(),
message: 'Recommendation trace',
data: {
decisionId,
scope: solverStreet.toUpperCase(),
board: solverRequest.board,
heroCardsRaw: heroCardInfo.rawCards,
heroCards: heroCardInfo.canonicalCards,
heroSeat,
actingSeat,
buttonPosition,
heroIsIp,
solverNodePath: solverNodePath.length > 0 ? solverNodePath : null,
recommendationSource,
heroComboLookupKey,
solverComboKeysSample,
lookupHit,
heroComboPolicySource,
selectedActionPercentages,
recommendationAction: outputRecommendedAction,
playerPerspective: 'action_history_selected_node',
failureReason: null,
},
});
}
throwIfAborted(jobSignal);
const explanationPolicy = canonicalPolicy.displayedStrategyActions.reduce<Record<string, number>>(
(acc, action) => {
acc[action.actionKey] = action.frequency;
return acc;
},
{},
);
const explanationActualAction =
canonicalPolicy.actualAction.actionKey ??
analysisMeta.displayActionKey ??
analysisMeta.actualActionKey ??
analysisMeta.userActionKey ??
displayActionKey ??
decisionPolicyKey ??
decision.action;
const explanationRecommendedAction = canonicalPolicy.recommendedActionKey ?? null;
const topActionFreq =
explanationRecommendedAction
? canonicalPolicy.displayedStrategyActions.find(
(action) => action.actionKey === explanationRecommendedAction,
)?.frequency
: undefined;
const solverSummary: SolverSummary | null =
explanationRecommendedAction && Number.isFinite(topActionFreq)
? {
topAction: explanationRecommendedAction,
topActionPercent:
canonicalPolicy.displayedStrategyActions.find(
(action) => action.actionKey === explanationRecommendedAction,
)?.freqPct ?? Number(((topActionFreq as number) * 100).toFixed(1)),
}
: null;
// Generate explanation using explain service
await persistDecisionStage({ pct: 95, stage: 'calling_llm', errorMessage: null });
const explanationCtx: ExplanationContext = {
pos: heroPosition,
street: decisionStreet as 'preflop' | 'flop' | 'turn' | 'river',
board: handState.board?.map((c: any) => `${c.rank}${c.suit}`).join('') || '',
heroHand: heroCardInfo.canonicalCards?.join('') ?? undefined,
solverPolicy: explanationPolicy,
actualAction: explanationActualAction,
spr,
potSize: currentPot,
heroStack,
potBefore: analysisMeta.potBefore ?? null,
toCall: analysisMeta.toCall ?? null,
committedThisStreetBefore: analysisMeta.committedThisStreetBefore ?? null,
responseNode: isResponseNode,
};
let explanationResult: StructuredExplanation | null = null;
let explanationFailureReason: string | null = null;
let explanation = '';
try {
explanationResult = await explainDecision(
explanationCtx,
getAnalysisExplanationLlmClient(),
{
solverSummaryOrNull: solverSummary,
strict: true,
}
);
explanation = formatExplanationText(explanationResult);
analysisMeta.explanationSource = 'llm';
analysisMeta.explanationError = null;
} catch (error) {
const reason =
error instanceof ExplanationGenerationError ? error.code : 'llm_request_failed';
const detail =
error instanceof Error && error.message && error.message !== reason
? error.message
: null;
analysisMeta.explanationSource = null;
analysisMeta.explanationError = reason;
explanationFailureReason = reason;
await pushDecisionDebug({
level: 'warn',
message: 'Decision explanation unavailable',
data: {
reason,
detail,
},
});
}
// Save analysis to database
applySolverStatusToMeta(analysisMeta, solverRunStatus);
const finalCanonicalPolicy = buildCanonicalDecisionAnalysis({
status,
policy: displayPolicy,
meta: analysisMeta,
combo: heroCardInfo.canonicalCards?.join('') ?? null,
board: handState.board?.map((card: any) => `${card.rank}${card.suit}`) ?? [],
rawAction: decision.action,
amount: typeof decision.amount === 'number' ? decision.amount : null,
normalizeDisplayFamilies: true,
actualActionKey:
analysisMeta.displayActionKey ??
analysisMeta.actualActionKey ??
analysisMeta.userActionKey ??
displayActionKey ??
decisionPolicyKey ??
null,
});
const analysisData: any = {
decisionId,
status,
explanation,
evDifference: null,
recommendedAction: finalCanonicalPolicy.recommendedActionKey ?? outputRecommendedAction,
gtoPolicy: displayPolicy,
requestHash: solverResponse.requestHash,
rawSolverOutput: buildRawSolverOutput(
solverResponse.raw,
analysisMeta,
explanationResult,
finalCanonicalPolicy,
),
};
throwIfAborted(jobSignal);
const analysis = await prisma.analysis.create({ data: analysisData });
await persistDecisionStage({
pct: 100,
stage: explanationFailureReason ? 'failed' : 'complete',
status: explanationFailureReason ? 'failed' : 'ready',
errorMessage: explanationFailureReason,
});
emitCompleted(decisionId, analysis, analysisMeta);
console.log(`Analysis complete for decision ${decisionId}: ${status}`);
shouldFinalizeRun = true;
return {
analysisId: analysis.id,
status: explanationFailureReason ? 'failed' : status,
};
} catch (error) {
const solverErrorCode = readSolverErrorCode(error);
const isTimeoutLikeFailure =
jobTimedOut || isSolverTimeoutCode(solverErrorCode) || isSolverTimeoutError(error);
const isAbortFailure =
!isTimeoutLikeFailure && ((jobSignal.aborted && !jobTimedOut) || isAbortError(error));
const solverFailureBeforeCompletion =
solverRequested && !solverCompletedSuccessfully && !isAbortFailure;
if (solverFailureBeforeCompletion) {
const streamFailure = isSolverServiceStreamError(error) ? error : null;
const streamFailureCode = normalizeSolverServiceErrorCode(
streamFailure?.solverErrorCode ??
(error as { solverErrorCode?: unknown; errorCode?: unknown; code?: unknown })
?.solverErrorCode ??
solverErrorCode ??
(error as { solverErrorCode?: unknown; errorCode?: unknown; code?: unknown })?.code ??
(error as { solverErrorCode?: unknown; errorCode?: unknown; code?: unknown })?.errorCode,
);
const streamFailureShortCode = toSolverServiceShortCode(streamFailureCode);
const streamFailureMessage = summarizeSolverServiceErrorMessage(
streamFailure?.message ??
normalizeFailureMessage(error, 'solver-service failure'),
'solver-service failure',
);
const streamFailureExitCode =
readSolverExitCode(
streamFailure?.exitCode ??
(error as { exitCode?: unknown; solverExitCode?: unknown })?.exitCode ??
(error as { exitCode?: unknown; solverExitCode?: unknown })?.solverExitCode,
) ?? null;
const streamFailureStderrTail = tailText(
streamFailure?.stderrTail ??
(error as { stderrTail?: unknown; solverStderrTail?: unknown })?.stderrTail ??
(error as { stderrTail?: unknown; solverStderrTail?: unknown })?.solverStderrTail,
2000,
);
const streamFailureStderrTailPreview = streamFailureStderrTail
? streamFailureStderrTail.slice(0, 200)
: null;
const solverUnreachable = isSolverConnectivityFailure(error);
const crashFailure =
isSolverCrashCode(streamFailureCode ?? solverErrorCode) || isSolverCrashError(error);
const crashDetail = buildSolverCrashMessage(streamFailure?.message ?? error);
const reason = jobTimedOut
? timeoutMessage
: crashFailure
? SOLVER_CRASH_USER_MESSAGE
: isSolverTimeoutCode(solverErrorCode) || isSolverTimeoutError(error)
? SOLVER_TIMEOUT_USER_MESSAGE
: streamFailureShortCode
? `${streamFailureShortCode}${
streamFailureMessage ? `: ${streamFailureMessage}` : ''
}`
: error instanceof SolverHttpError
? formatSolverHttpFailureReason(error)
: solverUnreachable
? buildSolverUnavailableReason(error)
: normalizeFailureMessage(error, 'Solver reference unavailable');
solverRunStatus.solverAttempted = true;
solverRunStatus.solverError = crashFailure
? `${streamFailureShortCode ?? 'solver-service:crash'}: ${crashDetail}`
: streamFailureShortCode ?? reason;
solverRunStatus.solverErrorCode = crashFailure
? streamFailureCode ?? solverErrorCode ?? 'SOLVER_KILLED'
: streamFailureCode ?? solverErrorCode ?? null;
solverRunStatus.solverExitCode = streamFailureExitCode;
solverRunStatus.solverStderrTailPreview = streamFailureStderrTailPreview;
await abortSolverService(reason);
await pushDecisionDebug({
source: 'api-worker',
level: 'error',
scope: debugStreet.toUpperCase(),
message: 'Solver terminal failure',
data: {
street: debugStreet,
error: reason,
solverErrorCode: solverRunStatus.solverErrorCode,
solverExitCode: solverRunStatus.solverExitCode,
solverStderrTailPreview: solverRunStatus.solverStderrTailPreview,
solverAttempted: solverRunStatus.solverAttempted,
solverConfigured: solverRunStatus.solverConfigured,
},
});
await persistDecisionStage({
pct: 100,
stage: 'solver_failed',
detail: reason,
status: 'solver_failed',
errorMessage: reason,
});
console.warn(`[ANALYSIS] solver failed for decision ${decisionId}: ${reason}`);
shouldFinalizeRun = true;
return {
analysisId: null,
status: 'solver_failed',
};
}
if (isAbortFailure) {
const reason = cancelReason();
if (solverRequested) {
await abortSolverService(reason);
}
const progress = progressState.lastPct >= 0 ? progressState.lastPct : 0;
await pushDecisionDebug({
level: 'warn',
message: 'Analysis cancelled',
data: {
reason,
progress,
solverRequested,
solverConfigured: solverRunStatus.solverConfigured,
solverAttempted: solverRunStatus.solverAttempted,
},
});
await upsertAnalysisStatus({
decisionId,
jobId: analysisJobId,
status: 'cancelled',
progress,
stage: 'cancelled',
errorMessage: reason,
cancelledAt: new Date(),
cancelledReason: reason,
});
if (error instanceof UnrecoverableError) {
throw error;
}
throw new UnrecoverableError(`cancelled: ${reason}`);
}
const reason = normalizeFailureMessage(error);
if (error instanceof SolverHttpError) {
console.error('[analysis-worker] solver HTTP error', {
decisionId,
statusCode: error.statusCode,
});
}
if (isSolverHttp429Error(error)) {
const progress = progressState.lastPct >= 0 ? progressState.lastPct : 0;
const rateLimited = await applyWorkerRateLimitOnSolver429({
decisionId,
analysisJobId,
progress,
reason,
});
if (rateLimited) {
const rateLimitError = createWorkerRateLimitError();
if (rateLimitError) {
throw rateLimitError;
}
}
}
const retryableSolverFailure = isRetryableSolverServiceFailure(error);
if (retryableSolverFailure && hasRetryRemainingOnCurrentAttempt(job)) {
const attempts = getConfiguredAttempts(job);
const attemptsMade = typeof job.attemptsMade === 'number' ? job.attemptsMade : 0;
const progress = progressState.lastPct >= 0 ? progressState.lastPct : 0;
await upsertAnalysisStatus({
decisionId,
jobId: analysisJobId,
status: 'queued',
progress,
stage: 'enqueued',
errorMessage: reason,
cancelledAt: null,
cancelledReason: null,
});
console.warn('[analysis-worker] transient solver failure, deferring to BullMQ retry', {
decisionId,
attemptsMade: attemptsMade + 1,
attempts,
reason,
});
throw error;
}
await markAnalysisFailedStatus({
decisionId,
jobId: analysisJobId,
handId,
progressState,
errorMessage: reason,
});
await syncProgressTelemetry('failed', reason);
if (!retryableSolverFailure) {
throw new UnrecoverableError(reason);
}
throw error;
} finally {
clearTimeout(overallTimeout);
if (parentAbortHandler && signal) {
signal.removeEventListener('abort', parentAbortHandler);
}
if (shouldFinalizeRun) {
try {
await finalizeHandAnalysisRunForDecision({
decisionId,
userId,
});
} catch (error) {
console.warn('[analysis-worker] failed to finalize hand analysis run', {
decisionId,
userId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
}
async function markDecisionJobStarted(job: Job<AnalysisJobData>): Promise<void> {
const decisionId = getDecisionIdFromAnalysisJob(job);
if (!decisionId) {
return;
}
const jobId = job.id ? String(job.id) : decisionId;
await upsertAnalysisStatus({
decisionId,
jobId,
status: 'running',
progress: Math.max(5, getProgressFromAnalysisJob(job)),
stage: 'started',
errorMessage: null,
cancelledAt: null,
cancelledReason: null,
});
}
/**
* Process analysis queue jobs:
* - `analyze-decision`: per-decision solver analysis
* - `analyze-hand`: full-hand summary built from decision analyses
* - `analyze-hand-report`: per-scope hand report status tracking
*/
export async function processAnalysisJob(
job: Job<AnalysisJobData | HandAnalysisJobData | HandReportJobData>,
token?: string,
signal?: AbortSignal,
) {
if (job.name === 'analyze-hand') {
return processHandAnalysisJob(job as Job<HandAnalysisJobData>, signal);
}
if (job.name === 'analyze-hand-report') {
return processHandReportJob(job as Job<HandReportJobData>, signal);
}
const decisionJob = job as Job<AnalysisJobData>;
await markDecisionJobStarted(decisionJob);
return processDecisionAnalysisJob(decisionJob, token, signal);
}
export default processAnalysisJob;
export function resolveAnalysisSandboxProcessorPath(): URL | null {
const modulePath = fileURLToPath(import.meta.url);
const extension = extname(modulePath).toLowerCase();
if (extension !== '.js' && extension !== '.mjs' && extension !== '.cjs') {
return null;
}
if (
process.env.NODE_ENV === 'production' &&
modulePath.replaceAll('\\', '/').includes('/src/')
) {
return null;
}
return pathToFileURL(modulePath);
}
export function resolveAnalysisWorkerProcessor():
| (typeof processAnalysisJob)
| URL {
if (ANALYSIS_WORKER_EXECUTION_MODE === 'inline') {
return processAnalysisJob;
}
const processFile = resolveAnalysisSandboxProcessorPath();
if (!processFile) {
const message =
'[analysis-worker] sandbox mode requires compiled JS processor from dist';
if (process.env.NODE_ENV === 'production') {
throw new Error(message);
}
console.warn('[analysis-worker] sandbox mode requested but unsupported in TS runtime; falling back to inline', {
mode: ANALYSIS_WORKER_EXECUTION_MODE,
});
return processAnalysisJob;
}
return processFile;
}
export type AnalysisWorkerJobData = AnalysisJobData | HandAnalysisJobData | HandReportJobData;
Get-Content apps\api\src\services\decision-analysis-canonical.tsExit code: 0
Wall time: 0.6 seconds
Output:
import { getActionDisplayLabel } from '@poker/shared';
export type CanonicalAnalysisState =
| 'complete'
| 'explanation_failed'
| 'solver_failed'
| 'llm_only';
export type CanonicalAnalysisMeta = {
potBefore?: number | null;
toCall?: number | null;
committedThisStreetBefore?: number | null;
canonicalActionKey?: string | null;
displayActionKey?: string | null;
userActionKey?: string | null;
actualActionKey?: string | null;
recommendationSource?: 'hero_combo' | 'node_mix' | 'fallback' | null;
explanationSource?: 'llm' | null;
explanationError?: string | null;
solverMissing?: boolean | null;
solverUnavailableReason?: string | null;
solverError?: string | null;
heroComboFailureReason?: string | null;
};
export type CanonicalStrategyAction = {
actionKey: string;
label: string;
frequency: number;
freqPct: number;
isPreferred: boolean;
isYou: boolean;
};
export type CanonicalActualAction = {
actionKey: string | null;
label: string | null;
rawAction: string | null;
amount: number | null;
frequency: number | null;
freqPct: number | null;
};
export type CanonicalExplanationState = {
status: 'ready' | 'failed' | 'unavailable';
error: string | null;
source: 'llm' | null;
};
export type CanonicalDecisionAnalysis = {
version: 1;
state: CanonicalAnalysisState;
combo: string | null;
board: string[];
actualAction: CanonicalActualAction;
displayedStrategyActions: CanonicalStrategyAction[];
recommendedActionKey: string | null;
recommendedActionLabel: string | null;
explanationInput: {
combo: string | null;
board: string[];
actualActionLabel: string | null;
displayedPolicy: Array<{
actionKey: string;
label: string;
freqPct: number;
isPreferred: boolean;
isYou: boolean;
}>;
recommendedActionLabel: string | null;
};
explanationState: CanonicalExplanationState;
};
type ActionLabelParts = {
primary: string;
secondary?: string;
};
type SizingContext = {
potStart: number;
potAfterCall: number;
toCall: number;
committedBefore: number;
currentBet: number;
};
const FRACTION_LABELS: Record<number, string> = {
25: '1/4',
33: '1/3',
50: '1/2',
67: '2/3',
75: '3/4',
};
const DISPLAY_POLICY_SUM_TOLERANCE = 1e-3;
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null;
}
return value as Record<string, unknown>;
}
function formatChipAmount(value: number): string {
if (!Number.isFinite(value)) return '0';
const rounded = Math.round(value * 10) / 10;
return Number.isInteger(rounded) ? rounded.toString() : rounded.toFixed(1);
}
function formatPotLabel(percent: number): string {
if (!Number.isFinite(percent) || percent <= 0) return 'POT';
const targets = Object.keys(FRACTION_LABELS).map((value) => Number(value));
let nearest = targets[0];
let bestDiff = Math.abs(percent - nearest);
for (const target of targets.slice(1)) {
const diff = Math.abs(percent - target);
if (diff < bestDiff) {
bestDiff = diff;
nearest = target;
}
}
if (bestDiff <= 2) {
const fraction = FRACTION_LABELS[nearest];
if (fraction) return `${fraction} POT`;
}
const rounded = Math.round(percent);
if (rounded === 100) return 'POT';
return `${rounded}% POT`;
}
function parseSizingKey(
actionKey: string,
): { kind: 'bet' | 'raise'; percent?: number; allIn: boolean } | null {
const normalized = actionKey.trim().toLowerCase();
const match = normalized.match(/^(bet|raise)[:\s]+(allin|\d+(?:\.\d+)?)$/);
if (!match) return null;
const kind = match[1] as 'bet' | 'raise';
const raw = match[2];
if (raw === 'allin') {
return { kind, allIn: true };
}
const percent = Number(raw);
if (!Number.isFinite(percent) || percent <= 0) return null;
return { kind, percent, allIn: false };
}
function buildSizingContext(meta?: CanonicalAnalysisMeta | null): SizingContext | null {
const potBefore = meta?.potBefore;
const toCall = meta?.toCall;
if (typeof potBefore !== 'number' || !Number.isFinite(potBefore)) return null;
if (typeof toCall !== 'number' || !Number.isFinite(toCall)) return null;
const potStart = potBefore - toCall;
if (!Number.isFinite(potStart) || potStart <= 0) return null;
const potAfterCall = potStart + 2 * toCall;
if (!Number.isFinite(potAfterCall) || potAfterCall <= 0) return null;
const committedBefore =
typeof meta?.committedThisStreetBefore === 'number' &&
Number.isFinite(meta.committedThisStreetBefore)
? Math.max(0, meta.committedThisStreetBefore)
: 0;
const currentBet = committedBefore + toCall;
return {
potStart,
potAfterCall,
toCall,
committedBefore,
currentBet,
};
}
function isResponseNodePolicy(policy: Record<string, number>): boolean {
const keys = Object.keys(policy).map((key) => key.trim().toLowerCase());
const hasCall = keys.includes('call');
const hasFold = keys.includes('fold');
const hasCheck = keys.includes('check');
return (hasCall || hasFold) && !hasCheck;
}
function inferResponseNode(
policy: Record<string, number>,
meta?: CanonicalAnalysisMeta | null,
): boolean {
if (isResponseNodePolicy(policy)) {
return true;
}
const keys = Object.keys(policy).map((key) => key.trim().toLowerCase());
if (keys.includes('check')) {
return false;
}
return typeof meta?.toCall === 'number' && Number.isFinite(meta.toCall) && meta.toCall > 0;
}
function normalizeActionKey(actionKey: string): string {
return actionKey.trim().toLowerCase();
}
function matchesNormalizedUserAction(
normalizedKey: string,
normalizedUserActionKey: string | null,
): boolean {
if (!normalizedUserActionKey) {
return false;
}
if (normalizedKey === normalizedUserActionKey) {
return true;
}
if (
(normalizedUserActionKey === 'allin' || normalizedUserActionKey === 'all_in') &&
(normalizedKey === 'bet:allin' ||
normalizedKey === 'bet:all_in' ||
normalizedKey === 'raise:allin' ||
normalizedKey === 'raise:all_in')
) {
return true;
}
return false;
}
function getCanonicalDisplayActionKeys(responseNode: boolean): string[] {
return responseNode
? ['fold', 'call', 'raise:33', 'raise:100']
: ['check', 'bet:33', 'bet:100'];
}
function getActionSortOrder(key: string): number {
const normalized = normalizeActionKey(key);
if (normalized === 'fold') return 0;
if (normalized === 'check') return 1;
if (normalized === 'call') return 2;
if (normalized.startsWith('bet:') || normalized.startsWith('raise:')) {
const sizeToken = normalized.replace(/^(bet|raise):/, '');
if (sizeToken === 'allin' || sizeToken === 'all_in') {
return 1000;
}
const size = Number(sizeToken);
if (Number.isFinite(size)) {
return 100 + size;
}
}
return 500;
}
function resolveDisplayedActualActionKey(params: {
meta?: CanonicalAnalysisMeta | null;
actualActionKey?: string | null;
}): string | null {
return (
params.actualActionKey ??
params.meta?.displayActionKey ??
params.meta?.actualActionKey ??
params.meta?.canonicalActionKey ??
params.meta?.userActionKey ??
null
);
}
export function findInvalidPresetResponseDisplayKeys(
policy: Record<string, number>,
userActionKey?: string | null,
): string[] {
if (!inferResponseNode(policy)) {
return [];
}
const normalizedUserActionKey =
typeof userActionKey === 'string' && userActionKey.trim().length > 0
? normalizeActionKey(userActionKey)
: null;
return Object.keys(policy).filter((key) => {
const normalized = normalizeActionKey(key);
if (normalized === 'fold' || normalized === 'call') {
return false;
}
if (matchesNormalizedUserAction(normalized, normalizedUserActionKey)) {
return false;
}
const match = normalized.match(/^raise:(\d+)$/);
if (!match) {
return normalized.startsWith('raise:');
}
return match[1] !== '33' && match[1] !== '100';
});
}
function formatRaiseLabelParts(
raiseExtraPercent: number,
meta?: CanonicalAnalysisMeta | null,
): ActionLabelParts {
const fallback = `RAISE ${formatPotLabel(raiseExtraPercent)}`;
const ctx = buildSizingContext(meta);
if (!ctx) return { primary: fallback };
const raiseExtraAmount = (raiseExtraPercent / 100) * ctx.potAfterCall;
if (!Number.isFinite(raiseExtraAmount) || raiseExtraAmount <= 0) {
return { primary: fallback };
}
const raiseToAmount = raiseExtraAmount + ctx.currentBet;
if (!Number.isFinite(raiseToAmount) || raiseToAmount <= 0) {
return { primary: fallback };
}
return {
primary: `RAISE ${formatPotLabel(raiseExtraPercent)}`,
secondary: `to ${formatChipAmount(raiseToAmount)}`,
};
}
function formatActionLabelParts(
actionKey: string,
responseNode: boolean,
meta?: CanonicalAnalysisMeta | null,
): ActionLabelParts {
const parsed = parseSizingKey(actionKey);
if (parsed) {
const kind = responseNode && parsed.kind === 'bet' ? 'raise' : parsed.kind;
if (parsed.allIn) {
return { primary: `${kind.toUpperCase()} ALL-IN` };
}
if (kind === 'raise' && parsed.percent !== undefined) {
return formatRaiseLabelParts(parsed.percent, meta);
}
const base = getActionDisplayLabel(actionKey);
if (!base) return { primary: actionKey.toUpperCase() };
if (!responseNode) return { primary: base };
if (base.startsWith('BET ')) {
return { primary: `RAISE ${base.slice(4)}` };
}
if (base === 'BET') return { primary: 'RAISE' };
return { primary: base };
}
const base = getActionDisplayLabel(actionKey);
if (!base) return { primary: actionKey.toUpperCase() };
if (!responseNode) return { primary: base };
if (base.startsWith('BET ')) {
return { primary: `RAISE ${base.slice(4)}` };
}
if (base === 'BET') return { primary: 'RAISE' };
return { primary: base };
}
export function formatCanonicalActionLabel(
actionKey: string,
policy: Record<string, number>,
meta?: CanonicalAnalysisMeta | null,
): string {
const parts = formatActionLabelParts(actionKey, inferResponseNode(policy, meta), meta);
return parts.secondary ? `${parts.primary} (${parts.secondary})` : parts.primary;
}
function hasDisplayPolicyMassOverflow(policy: Record<string, number>): boolean {
const visibleSum = Object.values(policy).reduce((total, frequency) => {
if (!Number.isFinite(frequency) || frequency <= 0) {
return total;
}
return total + frequency;
}, 0);
return visibleSum > 1 + DISPLAY_POLICY_SUM_TOLERANCE;
}
function buildDisplayedEntries(
policy: Record<string, number>,
actualActionKey?: string | null,
meta?: CanonicalAnalysisMeta | null,
normalizeDisplayFamilies?: boolean,
): {
entries: Array<readonly [string, number]>;
highlightKey: string | null;
actualKey: string | null;
} {
const rawEntries = Object.entries(policy).filter(([, freq]) => Number.isFinite(freq) && freq >= 0);
const responseNode = inferResponseNode(policy, meta);
const actualKey = resolveDisplayedActualActionKey({ meta, actualActionKey });
const canonicalDisplayKeys = new Set(
(normalizeDisplayFamilies ? getCanonicalDisplayActionKeys(responseNode) : []).map((key) =>
normalizeActionKey(key),
),
);
const entries = [...rawEntries];
for (const displayKey of canonicalDisplayKeys) {
if (!entries.some(([key]) => normalizeActionKey(key) === displayKey)) {
entries.push([displayKey, 0]);
}
}
if (
actualKey &&
!entries.some(([key]) => normalizeActionKey(key) === normalizeActionKey(actualKey))
) {
entries.push([actualKey, 0]);
}
const visibleEntries = entries
.filter(([key, freq]) => {
const normalizedKey = normalizeActionKey(key);
return (
freq > 0 ||
canonicalDisplayKeys.has(normalizedKey) ||
(actualKey !== null && normalizedKey === normalizeActionKey(actualKey))
);
})
.sort((left, right) => getActionSortOrder(left[0]) - getActionSortOrder(right[0]));
if (hasDisplayPolicyMassOverflow(policy)) {
return {
entries: [],
highlightKey: null,
actualKey,
};
}
const displayEntries =
visibleEntries.length === 0
? []
: visibleEntries.map(([key, freq]) => [key, freq] as const);
const policyKeys = new Set(displayEntries.map(([key]) => key));
const canonicalKey = meta?.canonicalActionKey ?? null;
const displayKey = meta?.displayActionKey ?? null;
const policyHasActual = actualKey ? policyKeys.has(actualKey) : false;
const policyHasCanonical = canonicalKey ? policyKeys.has(canonicalKey) : false;
const policyHasDisplay = displayKey ? policyKeys.has(displayKey) : false;
let highlightKey: string | null = null;
if (policyHasActual) {
highlightKey = actualKey;
} else if (policyHasDisplay) {
highlightKey = displayKey;
} else if (policyHasCanonical) {
highlightKey = canonicalKey;
}
return { entries: displayEntries, highlightKey, actualKey };
}
export function buildCanonicalDisplayedStrategyActions(params: {
policy: Record<string, number>;
actualActionKey?: string | null;
meta?: CanonicalAnalysisMeta | null;
normalizeDisplayFamilies?: boolean;
}): CanonicalStrategyAction[] {
const { policy, meta } = params;
const { entries, highlightKey } = buildDisplayedEntries(
policy,
params.actualActionKey,
meta,
params.normalizeDisplayFamilies,
);
if (entries.length === 0) {
return [];
}
const responseNode = inferResponseNode(policy, meta);
const displayActions = entries.map(([actionKey, frequency], index) => ({
actionKey,
label: formatActionLabelParts(actionKey, responseNode, meta).secondary
? `${formatActionLabelParts(actionKey, responseNode, meta).primary} (${formatActionLabelParts(actionKey, responseNode, meta).secondary})`
: formatActionLabelParts(actionKey, responseNode, meta).primary,
frequency,
freqPct: Number((frequency * 100).toFixed(1)),
index,
}));
let preferredIndex = -1;
let preferredFreqPct = -1;
for (const action of displayActions) {
if (action.freqPct > preferredFreqPct) {
preferredFreqPct = action.freqPct;
preferredIndex = action.index;
}
}
return displayActions.map((action) => ({
actionKey: action.actionKey,
label: action.label,
frequency: action.frequency,
freqPct: action.freqPct,
isPreferred: action.index === preferredIndex && action.freqPct > 0,
isYou: highlightKey === action.actionKey,
}));
}
function pickPreferredAction(
actions: CanonicalStrategyAction[],
): CanonicalStrategyAction | null {
return actions.find((action) => action.isPreferred) ?? null;
}
function inferCanonicalState(params: {
status: string | null | undefined;
displayedStrategyActions: CanonicalStrategyAction[];
explanationState: CanonicalExplanationState;
meta?: CanonicalAnalysisMeta | null;
policyMassInvalid: boolean;
}): CanonicalAnalysisState {
if (params.policyMassInvalid) {
return 'solver_failed';
}
if (params.displayedStrategyActions.length > 0) {
return params.explanationState.status === 'failed' ? 'explanation_failed' : 'complete';
}
if (params.status === 'unsupported' || params.meta?.solverMissing === true) {
return 'llm_only';
}
if (
params.status === 'solver_failed' ||
params.status === 'failed' ||
params.status === 'cancelled' ||
params.meta?.heroComboFailureReason ||
params.meta?.solverError ||
params.meta?.solverUnavailableReason
) {
return 'solver_failed';
}
return 'llm_only';
}
function normalizeBoard(board: unknown): string[] {
if (!Array.isArray(board)) {
return [];
}
return board
.map((card) => (typeof card === 'string' ? card.trim() : ''))
.filter((card) => card.length > 0);
}
function readExplanationState(meta?: CanonicalAnalysisMeta | null): CanonicalExplanationState {
if (meta?.explanationError) {
return {
status: 'failed',
error: meta.explanationError,
source: meta.explanationSource ?? null,
};
}
if (meta?.explanationSource === 'llm') {
return {
status: 'ready',
error: null,
source: 'llm',
};
}
return {
status: 'unavailable',
error: null,
source: null,
};
}
export function buildCanonicalDecisionAnalysis(params: {
status: string | null | undefined;
policy: Record<string, number>;
meta?: CanonicalAnalysisMeta | null;
combo?: string | null;
board?: string[];
rawAction?: string | null;
amount?: number | null;
actualActionKey?: string | null;
actualActionLabel?: string | null;
normalizeDisplayFamilies?: boolean;
}): CanonicalDecisionAnalysis {
const displayedStrategyActions = buildCanonicalDisplayedStrategyActions({
policy: params.policy,
actualActionKey: params.actualActionKey,
meta: params.meta,
normalizeDisplayFamilies: params.normalizeDisplayFamilies,
});
const policyMassInvalid = hasDisplayPolicyMassOverflow(params.policy);
const preferredAction = pickPreferredAction(displayedStrategyActions);
const actualActionKey =
params.actualActionKey ??
params.meta?.displayActionKey ??
params.meta?.actualActionKey ??
params.meta?.userActionKey ??
null;
const actualActionEntry =
actualActionKey !== null
? displayedStrategyActions.find((action) => action.actionKey === actualActionKey) ?? null
: null;
const actualActionLabel =
params.actualActionLabel ??
(actualActionEntry ? actualActionEntry.label : null) ??
(actualActionKey ? formatCanonicalActionLabel(actualActionKey, params.policy, params.meta) : null);
const explanationState = readExplanationState(params.meta);
const state = inferCanonicalState({
status: params.status,
displayedStrategyActions,
explanationState,
meta: params.meta,
policyMassInvalid,
});
return {
version: 1,
state,
combo: typeof params.combo === 'string' && params.combo.trim() ? params.combo.trim() : null,
board: normalizeBoard(params.board),
actualAction: {
actionKey: actualActionKey,
label: actualActionLabel,
rawAction: typeof params.rawAction === 'string' && params.rawAction.trim() ? params.rawAction.trim() : null,
amount: typeof params.amount === 'number' && Number.isFinite(params.amount) ? params.amount : null,
frequency: actualActionEntry?.frequency ?? null,
freqPct: actualActionEntry?.freqPct ?? null,
},
displayedStrategyActions,
recommendedActionKey: preferredAction?.actionKey ?? null,
recommendedActionLabel: preferredAction?.label ?? null,
explanationInput: {
combo: typeof params.combo === 'string' && params.combo.trim() ? params.combo.trim() : null,
board: normalizeBoard(params.board),
actualActionLabel,
displayedPolicy: displayedStrategyActions.map((action) => ({
actionKey: action.actionKey,
label: action.label,
freqPct: action.freqPct,
isPreferred: action.isPreferred,
isYou: action.isYou,
})),
recommendedActionLabel: preferredAction?.label ?? null,
},
explanationState,
};
}
export function readCanonicalDecisionAnalysis(rawSolverOutput: unknown): CanonicalDecisionAnalysis | null {
const payload = asRecord(rawSolverOutput);
const rawCanonical = payload ? asRecord(payload.canonical) : null;
if (!rawCanonical) {
return null;
}
const version = rawCanonical.version;
const state = rawCanonical.state;
const combo = rawCanonical.combo;
const board = rawCanonical.board;
const actualAction = asRecord(rawCanonical.actualAction);
const explanationInput = asRecord(rawCanonical.explanationInput);
const explanationState = asRecord(rawCanonical.explanationState);
const displayedStrategyActions = Array.isArray(rawCanonical.displayedStrategyActions)
? rawCanonical.displayedStrategyActions
: null;
if (version !== 1) {
return null;
}
if (
state !== 'complete' &&
state !== 'explanation_failed' &&
state !== 'solver_failed' &&
state !== 'llm_only'
) {
return null;
}
if (!actualAction || !explanationInput || !explanationState || !displayedStrategyActions) {
return null;
}
const parsedActions: CanonicalStrategyAction[] = [];
for (const rawAction of displayedStrategyActions) {
const record = asRecord(rawAction);
if (!record) {
return null;
}
if (
typeof record.actionKey !== 'string' ||
typeof record.label !== 'string' ||
typeof record.frequency !== 'number' ||
typeof record.freqPct !== 'number' ||
typeof record.isPreferred !== 'boolean' ||
typeof record.isYou !== 'boolean'
) {
return null;
}
parsedActions.push({
actionKey: record.actionKey,
label: record.label,
frequency: record.frequency,
freqPct: record.freqPct,
isPreferred: record.isPreferred,
isYou: record.isYou,
});
}
return {
version: 1,
state,
combo: typeof combo === 'string' && combo.trim() ? combo.trim() : null,
board: normalizeBoard(board),
actualAction: {
actionKey:
typeof actualAction.actionKey === 'string' && actualAction.actionKey.trim()
? actualAction.actionKey.trim()
: null,
label:
typeof actualAction.label === 'string' && actualAction.label.trim()
? actualAction.label.trim()
: null,
rawAction:
typeof actualAction.rawAction === 'string' && actualAction.rawAction.trim()
? actualAction.rawAction.trim()
: null,
amount:
typeof actualAction.amount === 'number' && Number.isFinite(actualAction.amount)
? actualAction.amount
: null,
frequency:
typeof actualAction.frequency === 'number' && Number.isFinite(actualAction.frequency)
? actualAction.frequency
: null,
freqPct:
typeof actualAction.freqPct === 'number' && Number.isFinite(actualAction.freqPct)
? actualAction.freqPct
: null,
},
displayedStrategyActions: parsedActions,
recommendedActionKey:
typeof rawCanonical.recommendedActionKey === 'string' && rawCanonical.recommendedActionKey.trim()
? rawCanonical.recommendedActionKey.trim()
: null,
recommendedActionLabel:
typeof rawCanonical.recommendedActionLabel === 'string' && rawCanonical.recommendedActionLabel.trim()
? rawCanonical.recommendedActionLabel.trim()
: null,
explanationInput: {
combo:
typeof explanationInput.combo === 'string' && explanationInput.combo.trim()
? explanationInput.combo.trim()
: null,
board: normalizeBoard(explanationInput.board),
actualActionLabel:
typeof explanationInput.actualActionLabel === 'string' && explanationInput.actualActionLabel.trim()
? explanationInput.actualActionLabel.trim()
: null,
displayedPolicy: parsedActions.map((action) => ({
actionKey: action.actionKey,
label: action.label,
freqPct: action.freqPct,
isPreferred: action.isPreferred,
isYou: action.isYou,
})),
recommendedActionLabel:
typeof explanationInput.recommendedActionLabel === 'string' &&
explanationInput.recommendedActionLabel.trim()
? explanationInput.recommendedActionLabel.trim()
: null,
},
explanationState: {
status:
explanationState.status === 'ready' ||
explanationState.status === 'failed' ||
explanationState.status === 'unavailable'
? explanationState.status
: 'unavailable',
error:
typeof explanationState.error === 'string' && explanationState.error.trim()
? explanationState.error.trim()
: null,
source: explanationState.source === 'llm' ? 'llm' : null,
},
};
}
Get-Content apps\api\src\services\decision-analysis-requirements.tsExit code: 0
Wall time: 0.4 seconds
Output:
import { findInvalidPresetResponseDisplayKeys } from './decision-analysis-canonical.js';
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null;
}
return value as Record<string, unknown>;
}
function readNullableString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function isPostflopStreet(value: string | null | undefined): boolean {
const normalized = value?.trim().toLowerCase();
return normalized === 'flop' || normalized === 'turn' || normalized === 'river';
}
export function hasPositivePolicyEntry(policy: unknown): boolean {
const record = asRecord(policy);
if (!record) {
return false;
}
return Object.values(record).some(
(value) => typeof value === 'number' && Number.isFinite(value) && value > 0,
);
}
export function parseSolverMissing(rawSolverOutput: unknown): boolean | null {
const payload = asRecord(rawSolverOutput);
const meta = asRecord(payload?.meta);
if (!meta || typeof meta.solverMissing !== 'boolean') {
return null;
}
return meta.solverMissing;
}
export function hasUsableLlmExplanation(rawSolverOutput: unknown): boolean {
const payload = asRecord(rawSolverOutput);
const meta = asRecord(payload?.meta);
const explanation = asRecord(payload?.explanation);
if (readNullableString(meta?.explanationSource) !== 'llm') {
return false;
}
if (readNullableString(meta?.explanationError)) {
return false;
}
const reasons = Array.isArray(explanation?.reasons)
? explanation.reasons.filter(
(value): value is string => typeof value === 'string' && value.trim().length > 0,
)
: [];
const rule = readNullableString(explanation?.rule);
return reasons.length > 0 && Boolean(rule);
}
export function extractExplanationFailureReason(rawSolverOutput: unknown): string | null {
const payload = asRecord(rawSolverOutput);
const meta = asRecord(payload?.meta);
return (
readNullableString(meta?.explanationError) ??
readNullableString(meta?.solverError) ??
readNullableString(meta?.solverErrorCode) ??
readNullableString(meta?.solverUnavailableReason)
);
}
export function readRecommendationSource(rawSolverOutput: unknown): string | null {
const payload = asRecord(rawSolverOutput);
const meta = asRecord(payload?.meta);
return readNullableString(meta?.recommendationSource);
}
function readSizingMode(rawSolverOutput: unknown): string | null {
const payload = asRecord(rawSolverOutput);
const meta = asRecord(payload?.meta);
return readNullableString(meta?.sizingMode);
}
function readUserActionKey(rawSolverOutput: unknown): string | null {
const payload = asRecord(rawSolverOutput);
const meta = asRecord(payload?.meta);
return readNullableString(meta?.userActionKey);
}
export function hasInvalidPresetResponseDisplayPolicy(params: {
gtoPolicy: unknown;
rawSolverOutput: unknown;
}): boolean {
if (readSizingMode(params.rawSolverOutput) !== 'preset') {
return false;
}
const record = asRecord(params.gtoPolicy);
if (!record) {
return false;
}
const policy = Object.entries(record).reduce<Record<string, number>>((acc, [key, value]) => {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
acc[key] = value;
}
return acc;
}, {});
if (Object.keys(policy).length === 0) {
return false;
}
return findInvalidPresetResponseDisplayKeys(policy, readUserActionKey(params.rawSolverOutput)).length > 0;
}
export function hasHeroComboRecommendation(params: {
gtoPolicy: unknown;
rawSolverOutput: unknown;
}): boolean {
if (parseSolverMissing(params.rawSolverOutput) === true) {
return false;
}
if (!hasPositivePolicyEntry(params.gtoPolicy)) {
return false;
}
if (hasInvalidPresetResponseDisplayPolicy(params)) {
return false;
}
return readRecommendationSource(params.rawSolverOutput) === 'hero_combo';
}
export function extractPostflopRequirementFailureReason(params: {
gtoPolicy: unknown;
rawSolverOutput: unknown;
}): string | null {
if (hasHeroComboRecommendation(params)) {
return null;
}
const payload = asRecord(params.rawSolverOutput);
const meta = asRecord(payload?.meta);
return (
readNullableString(meta?.heroComboFailureReason) ??
readNullableString(meta?.solverError) ??
readNullableString(meta?.solverErrorCode) ??
readNullableString(meta?.solverUnavailableReason) ??
(hasPositivePolicyEntry(params.gtoPolicy) ? 'hero_combo_unavailable' : 'solver_required')
);
}
export type DecisionAnalysisFailure = {
status: 'failed' | 'solver_failed';
error: string;
};
export function getDecisionAnalysisFailure(params: {
street: string | null | undefined;
gtoPolicy: unknown;
rawSolverOutput: unknown;
}): DecisionAnalysisFailure | null {
if (isPostflopStreet(params.street)) {
const postflopFailure = extractPostflopRequirementFailureReason(params);
if (postflopFailure) {
return {
status: 'solver_failed',
error: postflopFailure,
};
}
}
if (hasUsableLlmExplanation(params.rawSolverOutput)) {
return null;
}
return {
status: 'failed',
error: extractExplanationFailureReason(params.rawSolverOutput) ?? 'llm_unavailable',
};
}
export function decisionAnalysisSatisfiesRequirements(params: {
street: string | null | undefined;
gtoPolicy: unknown;
rawSolverOutput: unknown;
}): boolean {
return getDecisionAnalysisFailure(params) === null;
}
Get-Content apps\api\src\services\hand-actions.tsExit code: 0
Wall time: 0.9 seconds
Total output lines: 2203
Output:
import { HandActionStatus, HandActionType } from '@prisma/client';
import { prisma } from '../db.js';
import { getAnalysisQueue } from '../queue.js';
import { buildAnalysisJobId } from '../analysis-job-id.js';
import { buildHandReportJobId } from '../hand-report-job-id.js';
import { config, resolveSolverUrlFromEnv, type SolverStrictness } from '../config.js';
import {
appendHandDebugEvent,
getDecisionDebugEvents,
getHandDebugEvents,
sanitizeDebugEventsForClient,
type DebugEvent,
} from './analysis-debug-events.js';
import {
HandAnalysisSubmitError,
} from './hand-analysis-submit.js';
import { type HandReportStatusValue } from './hand-reports.js';
import { normalizeAnalysisStage } from './analysis-stage.js';
import { startHandAnalysisPipeline } from './hand-analysis-pipeline.js';
import {
extractExplanationFailureReason as extractSharedExplanationFailureReason,
extractPostflopRequirementFailureReason,
hasHeroComboRecommendation,
hasUsableLlmExplanation as hasSharedUsableLlmExplanation,
isPostflopStreet as isSharedPostflopStreet,
} from './decision-analysis-requirements.js';
import {
isAnalysisWorkerAvailable,
shouldStartAnalysisWorker,
} from '../workers/analysis-worker.boot.js';
import { getRoomManager } from '../game/room-manager-registry.js';
type HandActionRequestType = 'SAVE' | 'ANALYZE_HAND';
type HandActionRequestStatus = 'idle' | 'pending' | 'completed' | 'failed';
type AnalyzeHandPipelineStatus =
| 'idle'
| 'waiting'
| 'queued'
| 'running'
| 'complete'
| 'failed';
type HandAnalysisStatus = HandReportStatusValue;
type PipelineDecisionStatus =
| 'queued'
| 'running'
| 'llm_only'
| 'complete'
| 'solver_failed'
| 'failed';
type PipelineDecisionStage =
| 'not_requested'
| 'enqueued'
| 'started'
| 'calling_solver'
| 'solver_done'
| 'calling_llm'
| 'solver_required'
| 'solver_failed'
| 'complete'
| 'failed'
| 'cancelled';
type PipelineOverviewStatus = 'queued' | 'running' | 'complete' | 'blocked' | 'failed';
type PipelineStatus = 'queued' | 'running' | 'blocked' | 'complete' | 'degraded' | 'failed';
type PipelineDecisionEntry = HandActionStatusPayload['decisions'][number];
type BlockingDecisionEntry = HandActionStatusPayload['blockingDecisions'][number];
type ResolvedHand = {
id: string;
roomId: string | null;
isComplete: boolean;
};
export type HandActionStatusPayload = {
gameId: string;
handId: string;
handIndex: number | null;
handComplete: boolean;
strictness: SolverStrictness;
pipelineStatus: PipelineStatus;
save: {
status: HandActionRequestStatus;
errorMessage: string | null;
stage?: string | null;
message?: string | null;
};
analyzeHand: {
status: AnalyzeHandPipelineStatus;
errorMessage: string | null;
stage?: string | null;
message?: string | null;
};
analysis: {
id: string | null;
status: HandAnalysisStatus;
analyzed: boolean;
stage?: string | null;
errorMessage?: string | null;
message?: string | null;
};
decisions: Array<{
decisionId: string;
street: string;
label: string;
status: PipelineDecisionStatus;
stage: PipelineDecisionStage | string | null;
errorMessage: string | null;
solverAvailable: boolean;
solverConfigured: boolean | null;
solverAttempted: boolean | null;
solverError: string | null;
solverErrorCode: string | null;
debugEventsPreview: DebugEvent[];
}>;
blockingDecisions: Array<{
decisionId: string;
street: string;
label: string;
solverError: string | null;
solverErrorCode: string | null;
stage: PipelineDecisionStage | string | null;
}>;
overview: {
status: PipelineOverviewStatus;
stage: string | null;
errorMessage: string | null;
};
counts: {
total: number;
queued: number;
complete: number;
running: number;
failed: number;
llmOnly: number;
};
debugEvents: DebugEvent[];
};
export class HandActionServiceError extends Error {
readonly statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
}
}
function toRequestStatus(status: HandActionStatus | null | undefined): HandActionRequestStatus {
if (!status) return 'idle';
return status;
}
function toAnalysisStatus(status: string | null | undefined): HandAnalysisStatus {
if (status === 'queued' || status === 'running' || status === 'complete' || status === 'failed') {
return status;
}
return 'idle';
}
function toActionType(type: HandActionRequestType): HandActionType {
return type;
}
function toActionSummary(
action: { status: HandActionStatus; errorMessage: string | null } | null,
): { status: HandActionRequestStatus; errorMessage: string | null } {
return {
status: toRequestStatus(action?.status),
errorMessage: action?.errorMessage ?? null,
};
}
async function getWorkerNotRunningMessage(): Promise<string | null> {
try {
if (
typeof isAnalysisWorkerAvailable === 'function' &&
(await isAnalysisWorkerAvailable())
) {
return null;
}
} catch {
// Fall back to the startup hint when worker availability cannot be probed.
}
try {
if (!shouldStartAnalysisWorker()) {
return 'worker not running (start the dedicated worker or set START_WORKERS=1)';
}
} catch {
return 'worker not running (start the dedicated worker or set START_WORKERS=1)';
}
return 'worker not running';
}
function isAnalysisDebugHttpEnabled(): boolean {
return process.env.ANALYSIS_DEBUG_HTTP === '1';
}
const RUNNING_PIPELINE_STAGES = new Set([
'started',
'calling_solver',
'solver_done',
'calling_llm',
]);
const WORKER_HINT_RECENT_STAGE_WINDOW_MS = 30_000;
function isRunningPipelineStage(stage: string | null): boolean {
if (!stage) return false;
return RUNNING_PIPELINE_STAGES.has(stage);
}
function deriveAnalyzeHandPipelineStatus(params: {
hasAnalyzeRequest: boolean;
handComplete: boolean;
decisions: Array<{
status: PipelineDecisionStatus;
stage: string | null;
}>;
overviewStatus: PipelineOverviewStatus;
overviewStage: string | null;
}): AnalyzeHandPipelineStatus {
const { hasAnalyzeRequest, handComplete, decisions, overviewStatus, overviewStage } = params;
if (!hasAnalyzeRequest) {
return 'idle';
}
if (overviewStatus === 'failed') {
return 'failed';
}
if (!handComplete) {
return 'waiting';
}
if (decisions.some((decision) => decision.status === 'failed')) {
return 'failed';
}
const hasRunningDecision = decisions.some(
(decision) => decision.status === 'running' || isRunningPipelineStage(decision.stage),
);
const overviewIsRunning = overviewStatus === 'running' || isRunningPipelineStage(overviewStage);
if (hasRunningDecision || overviewIsRunning) {
return 'running';
}
const hasQueuedDecision = decisions.some(
(decision) => decision.status === 'queued' || decision.stage === 'enqueued',
);
const overviewIsQueued =
overviewStatus === 'queued' &&
overviewStage !== 'not_requested' &&
overviewStage !== null;
if (hasQueuedDecision || overviewIsQueued) {
return 'queued';
}
if (overviewStatus === 'complete') {
return 'complete';
}
return 'queued';
}
function deriveAnalyzeHandPipelineStage(params: {
status: AnalyzeHandPipelineStatus;
decisions: Array<{
status: PipelineDecisionStatus;
stage: string | null;
}>;
overviewStatus: PipelineOverviewStatus;
overviewStage: string | null;
}): string | null {
const { status, decisions, overviewStatus, overviewStage } = params;
if (status === 'waiting') {
return 'waiting_for_hand_completion';
}
if (status === 'running') {
if (overviewStatus === 'running' && overviewStage) {
return overviewStage;
}
const runningDecision = decisions.find(
(decision) => decision.status === 'running' || isRunningPipelineStage(decision.stage),
);
return runningDecision?.stage ?? 'started';
}
if (status === 'queued') {
if (overviewStatus === 'blocked' && overviewStage) {
return overviewStage;
}
if (overviewStatus === 'queued' && overviewStage && overviewStage !== 'not_requested') {
return overviewStage;
}
const queuedDecision = decisions.find(
(decision) => decision.status === 'queued' || decision.stage === 'enqueued',
);
return queuedDecision?.stage ?? 'enqueued';
}
if (status === 'complete') {
return 'complete';
}
if (status === 'failed') {
return 'failed';
}
return null;
}
function readStageFromJobProgress(progress: unknown): string | null {
if (!progress || typeof progress !== 'object') {
return null;
}
const stage = (progress as { stage?: unknown }).stage;
if (typeof stage !== 'string' || !stage.trim()) {
return null;
}
return normalizeAnalysisStage(stage) ?? stage.trim();
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null;
}
return value as Record<string, unknown>;
}
function hasPositivePolicyEntry(policy: unknown): boolean {
const record = asRecord(policy);
if (!record) {
return false;
}
for (const frequency of Object.values(record)) {
if (typeof frequency === 'number' && Number.isFinite(frequency) && frequency > 0) {
return true;
}
}
return false;
}
function parseSolverMissing(rawSolverOutput: unknown): boolean | null {
const payload = asRecord(rawSolverOutput);
const meta = asRecord(payload?.meta);
if (!meta || typeof meta.solverMissing !== 'boolean') {
return null;
}
return meta.solverMissing;
}
function readNullableBoolean(value: unknown): boolean | null {
if (typeof value === 'boolean') {
return value;
}
return null;
}
function readNullableString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function extractSolverErrorCodeFromText(value: string | null | undefined): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const prefixed = trimmed.match(/^solver-service:([a-z0-9_:-]+)$/i);
if (prefixed && prefixed[1]) {
return prefixed[1].toLowerCase();
}
const delimited = trimmed.match(/^solver-service:([a-z0-9_:-]+)\s*[:|-]/i);
if (delimited && delimited[1]) {
return delimited[1].toLowerCase();
}
return null;
}
function extractSolverErrorCodeFromDebugEvents(events: DebugEvent[]): string | null {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (!event) {
continue;
}
const data =
event.data && typeof event.data === 'object'
? (event.data as Record<string, unknown>)
: null;
const fromData =
readNullableString(data?.solverErrorCode) ??
readNullableString(data?.code) ??
readNullableString(data?.errorCode);
if (fromData && fromData.trim()) {
return fromData.trim().toLowerCase();
}
}
return null;
}
type DecisionSolverStatus = {
solverConfigured: boolean | null;
solverAttempted: boolean | null;
solverError: string | null;
solverErrorCode: string | null;
};
type DecisionStatusRow = {
decisionId: string;
jobId?: string | null;
status: 'queued' | 'running' | 'ready' | 'failed' | 'solver_failed' | 'cancelled';
stage: string | null;
errorMessage: string | null;
cancelledReason: string | null;
updatedAt: Date;
};
type DecisionAnalysisRow = {
decisionId: string;
gtoPolicy: unknown;
rawSolverOutput: unknown;
createdAt: Date;
};
type DecisionQueueSnapshot = {
queueState: string;
stage: string | null;
failedReason: string | null;
solverStatus: DecisionSolverStatus;
};
type DebugSolverFailureSummary = {
hasSolverFailure: boolean;
stage: string | null;
solverError: string | null;
solverErrorCode: string | null;
solverConfigured: boolean | null;
solverAttempted: boolean | null;
};
function isSolverServiceConfiguredNow(): boolean {
if (config.solverMode !== 'service') {
return false;
}
try {
return Boolean(resolveSolverUrlFromEnv(process.env, config.nodeEnv).url);
} catch {
return false;
}
}
function mergeDecisionSolverStatus(
...sources: Array<Partial<DecisionSolverStatus> | null | undefined>
): DecisionSolverStatus {
const merged: DecisionSolverStatus = {
solverConfigured: null,
solverAttempted: null,
solverError: null,
solverErrorCode: null,
};
for (const source of sources) {
if (!source) {
continue;
}
if (source.solverConfigured !== undefined) {
merged.solverConfigured = source.solverConfigured;
}
if (source.solverAttempted !== undefined) {
merged.solverAttempted = source.solverAttempted;
}
if (source.solverError !== undefined) {
merged.solverError = source.solverError;
}
if (source.solverErrorCode !== undefined) {
merged.solverErrorCode = source.solverErrorCode;
}
}
return merged;
}
function extractDecisionSolverStatus(params: {
rawSolverOutput: unknown;
fallbackErrorMessage?: string | null;
}): DecisionSolverStatus {
const payload = asRecord(params.rawSolverOutput);
const meta = asRecord(payload?.meta);
const solverConfigured = readNullableBoolean(meta?.solverConfigured);
const solverAttempted = readNullableBoolean(meta?.solverAttempted);
const solverError =
readNullableString(meta?.solverError) ??
readNullableString(meta?.solverUnavailableReason) ??
readNullableString(params.fallbackErrorMessage);
const solverErrorCode =
readNullableString(meta?.solverErrorCode) ??
extractSolverErrorCodeFromText(solverError);
return {
solverConfigured: solverConfigured ?? isSolverServiceConfiguredNow(),
solverAttempted,
solverError,
solverErrorCode,
};
}
function extractSolverFailureSummaryFromDebugEvents(events: DebugEvent[]): DebugSolverFailureSummary {
let stage: string | null = null;
let solverError: string | null = null;
let solverErrorCode: string | null = null;
let solverConfigured: boolean | null = null;
let solverAttempted: boolean | null = null;
let hasSolverServiceError = false;
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
const data =
event?.data && typeof event.data === 'object'
? (event.data as Record<string, unknown>)
: null;
const dataCode =
readNullableString(data?.solverErrorCode) ??
readNullableString(data?.errorCode) ??
readNullableString(data?.code);
if (!solverErrorCode && dataCode) {
solverErrorCode = dataCode.toLowerCase();
}
const dataError =
readNullableString(data?.solverError) ??
readNullableString(data?.error) ??
readNullableString(data?.message);
if (!solverError && dataError) {
solverError = dataError;
}
const configuredValue = readNullableBoolean(data?.solverConfigured);
if (solverConfigured === null && configuredValue !== null) {
solverConfigured = configuredValue;
}
const attemptedValue = readNullableBoolean(data?.solverAttempted);
if (solverAttempted === null && attemptedValue !== null) {
solverAttempted = attemptedValue;
}
if (!stage && typeof event.message === 'string') {
const match = event.message.match(/Stage transition:\s*([a-z_]+)/i);
if (match && match[1]) {
stage = (normalizeAnalysisStage(match[1]) ?? match[1]).toLowerCase();
}
}
if (!stage && typeof data?.stage === 'string') {
stage = (normalizeAnalysisStage(data.stage) ?? data.stage).toLowerCase();
}
if (event.source === 'solver-service' && event.level === 'error') {
hasSolverServiceError = true;
if (!solverError) {
solverError = event.message;
}
}
const fromText = extractSolverErrorCodeFromText(event.message);
if (!solverErrorCode && fromText) {
solverErrorCode = fromText;
}
if (
stage === 'solver_failed' ||
stage === 'solver_required' ||
(solverErrorCode && hasSolverServiceError)
) {
break;
}
}
const hasSolverFailure =
stage === 'solver_failed' ||
stage === 'solver_required' ||
hasSolverServiceError ||
solverErrorCode !== null;
const resolvedAttempted =
stage === 'solver_required'
? false
: hasSolverFailure
? solverAttempted ?? true
: solverAttempted;
const resolvedConfigured =
stage === 'solver_required'
? false
: hasSolverFailure
? solverConfigured ?? isSolverServiceConfiguredNow()
: solverConfigured;
return {
hasSolverFailure,
stage,
solverError,
solverErrorCode,
solverConfigured: resolvedConfigured,
solverAttempted: resolvedAttempted,
};
}
function previewDecisionDebugEvents(events: DebugEvent[], count = 3): DebugEvent[] {
const size = Math.max(0, Math.min(count, 50));
if (size === 0 || events.length === 0) {
return [];
}
if (events.length <= size) {
return events;
}
return events.slice(events.length - size);
}
function extractSolverStatusFromQueueProgress(progress: unknown): Partial<DecisionSolverStatus> {
const record = asRecord(progress);
if (!record) {
return {};
}
const solverConfigured = readNullableBoolean(record.solverConfigured);
const solverAttempted = readNullableBoolean(record.solverAttempted);
const solverError = readNullableString(record.solverError);
const solverErrorCode = readNullableString(record.solverErrorCode);
return {
solverConfigured,
solverAttempted,
solverError,
solverErrorCode: solverErrorCode ? solverErrorCode.toLowerCase() : null,
};
}
function isCancellationReason(reason?: string | null): boolean {
if (!reason) return false;
const normalized = reason.toLowerCase();
return (
normalized.includes('cancelled') ||
normalized.includes('canceled') ||
normalized.includes('abort')
);
}
function normalizeFailureReason(reason: unknown, fallback = 'Analysis failed'): string {
if (typeof reason === 'string' && reason.trim()) {
return reason.trim();
}
if (reason instanceof Error && reason.message.trim()) {
return reason.message.trim();
}
return fallback;
}
async function resolveDecisionQueueSnapshots(
decisionStatuses: DecisionStatusRow[],
): Promise<Map<string, DecisionQueueSnapshot>> {
const queueCandidateRows = decisionStatuses.filter(
(row) => row.status === 'queued' || row.status === 'running',
);
if (queueCandidateRows.length === 0) {
return new Map();
}
let queue: ReturnType<typeof getAnalysisQueue>;
try {
queue = getAnalysisQueue();
} catch {
return new Map();
}
const entries = await Promise.all(
queueCandidateRows.map(async (row) => {
const baseJobId = buildAnalysisJobId(row.decisionId, false);
try {
const queueJob =
(row.jobId ? await queue.getJob(row.jobId) : null) ??
(await queue.getJob(baseJobId)) ??
(await queue.getJob(row.decisionId));
if (!queueJob) {
return [row.decisionId, null] as con…7546 tokens truncated…rFailedStage =
normalizedFailedStage === 'solver_required' ||
normalizedFailedStage === 'solver_failed' ||
decisionStatus.status === 'solver_failed' ||
(postflopStreet && debugSolverSummary.hasSolverFailure);
const solverError =
decisionStatus.errorMessage ??
decisionStatus.cancelledReason ??
debugSolverSummary.solverError;
const solverErrorCode =
extractSolverErrorCodeFromText(solverError) ??
debugSolverSummary.solverErrorCode;
const solverStatus = mergeDecisionSolverStatus(
{ solverConfigured: solverConfiguredNow },
{
solverConfigured:
normalizedFailedStage === 'solver_required'
? false
: debugSolverSummary.solverConfigured,
solverAttempted:
normalizedFailedStage === 'solver_required'
? false
: debugSolverSummary.solverAttempted,
solverError,
solverErrorCode,
},
);
const solverAttempted =
normalizedFailedStage === 'solver_required'
? false
: solverStatus.solverAttempted ?? postflopStreet;
return {
decisionId: decision.id,
street: normalizedStreet,
label,
status:
solverFailedStage && postflopStreet
? ('solver_failed' as const)
: ('failed' as const),
stage: normalizedFailedStage,
errorMessage:
solverError ??
(solverFailedStage && postflopStreet
? 'Solver required for postflop decision'
: 'Analysis failed'),
solverAvailable: false,
solverConfigured:
normalizedFailedStage === 'solver_required'
? false
: solverStatus.solverConfigured ?? solverConfiguredNow,
solverAttempted,
solverError: solverStatus.solverError,
solverErrorCode,
debugEventsPreview,
};
}
if (hasAnalyzeRequest && postflopStreet && debugSolverSummary.hasSolverFailure) {
const solverStatus = mergeDecisionSolverStatus(
{
solverConfigured:
debugSolverSummary.stage === 'solver_required'
? false
: solverConfiguredNow,
},
{
solverConfigured: debugSolverSummary.solverConfigured,
solverAttempted:
debugSolverSummary.solverAttempted ??
(debugSolverSummary.stage === 'solver_required' ? false : true),
solverError:
debugSolverSummary.solverError ?? 'Solver required for postflop decision',
solverErrorCode: debugSolverSummary.solverErrorCode,
},
);
return {
decisionId: decision.id,
street: normalizedStreet,
label,
status: 'solver_failed',
stage:
debugSolverSummary.stage === 'solver_required'
? 'solver_required'
: debugSolverSummary.stage ?? 'solver_failed',
errorMessage: solverStatus.solverError ?? 'Solver required for postflop decision',
solverAvailable: false,
solverConfigured: solverStatus.solverConfigured,
solverAttempted: solverStatus.solverAttempted,
solverError: solverStatus.solverError,
solverErrorCode: solverStatus.solverErrorCode,
debugEventsPreview,
};
}
const defaultStatus: PipelineDecisionStatus = 'queued';
const defaultStage: PipelineDecisionStage | string =
hasAnalyzeRequest ? 'enqueued' : 'not_requested';
return {
decisionId: decision.id,
street: normalizedStreet,
label,
status: defaultStatus,
stage: defaultStage,
errorMessage: null,
solverAvailable: false,
solverConfigured: postflopStreet ? solverConfiguredNow : false,
solverAttempted: postflopStreet ? null : false,
solverError: null,
solverErrorCode: extractSolverErrorCodeFromDebugEvents(fullDebugEvents),
debugEventsPreview,
};
});
const decisionsWithDebugPreview: PipelineDecisionEntry[] = decisions.map((decision) => {
const fullDebugEvents = decisionDebugEventsMap.get(decision.decisionId) ?? [];
const debugDerivedCode = extractSolverErrorCodeFromDebugEvents(fullDebugEvents);
const debugPreview =
analysisDebugHttpEnabled && decision.debugEventsPreview.length > 0
? decision.debugEventsPreview
: analysisDebugHttpEnabled
? previewDecisionDebugEvents(fullDebugEvents, 3)
: [];
return {
...decision,
solverErrorCode: decision.solverErrorCode ?? debugDerivedCode,
debugEventsPreview: analysisDebugHttpEnabled
? sanitizeDebugEventsForClient(debugPreview)
: [],
};
});
const counts = {
total: decisionsWithDebugPreview.length,
queued: decisionsWithDebugPreview.filter((decision) => decision.status === 'queued').length,
complete: decisionsWithDebugPreview.filter(
(decision) => decision.status === 'complete' || decision.status === 'llm_only',
).length,
running: decisionsWithDebugPreview.filter((decision) => decision.status === 'running').length,
failed: decisionsWithDebugPreview.filter(
(decision) => decision.status === 'failed' || decision.status === 'solver_failed',
).length,
llmOnly: decisionsWithDebugPreview.filter((decision) => decision.status === 'llm_only').length,
};
const terminalDecisionCount = decisionsWithDebugPreview.filter(
(decision) =>
decision.status === 'complete' ||
decision.status === 'llm_only' ||
decision.status === 'failed' ||
decision.status === 'solver_failed',
).length;
const allDecisionsTerminal = terminalDecisionCount >= counts.total;
const pendingDecisionLabels = decisionsWithDebugPreview
.filter((decision) => decision.status === 'queued' || decision.status === 'running')
.map((decision) => decision.label);
const blockingDecisions: BlockingDecisionEntry[] = decisionsWithDebugPreview
.filter((decision) => hasPostflopSolverFailedDecision(decision))
.map((decision) => ({
decisionId: decision.decisionId,
street: decision.street,
label: decision.label,
solverError: decision.solverError,
solverErrorCode: decision.solverErrorCode,
stage: decision.stage,
}));
const hasBlockingDecisions = blockingDecisions.length > 0;
const overviewReport = handReports.find((report) => report.scope === 'WHOLE_HAND') ?? null;
const reportMeta =
overviewReport?.jobMeta && typeof overviewReport.jobMeta === 'object'
? (overviewReport.jobMeta as Record<string, unknown>)
: null;
let overviewStatus: PipelineOverviewStatus = 'queued';
let overviewStage: string | null = null;
let overviewErrorMessage: string | null = null;
if (hasBlockingDecisions) {
overviewStatus = 'blocked';
overviewStage = 'blocked';
overviewErrorMessage = 'Blocked: solver required for postflop decisions';
} else if (overviewReport) {
overviewStatus =
overviewReport.status === 'running'
? 'running'
: overviewReport.status === 'complete'
? 'complete'
: overviewReport.status === 'failed'
? 'failed'
: 'queued';
overviewStage =
normalizeAnalysisStage(reportMeta?.stage ?? null) ??
(overviewStatus === 'running'
? 'started'
: overviewStatus === 'complete'
? 'complete'
: overviewStatus === 'failed'
? 'failed'
: 'enqueued');
overviewErrorMessage = overviewReport.errorMessage ?? null;
} else if (analyzeAction?.status === 'failed') {
overviewStatus = 'failed';
overviewStage = 'failed';
overviewErrorMessage = analyzeAction.errorMessage ?? 'Analyze failed';
} else if (!hasAnalyzeRequest) {
overviewStatus = 'queued';
overviewStage = 'not_requested';
overviewErrorMessage = null;
} else if (!hand.isComplete) {
overviewStatus = 'queued';
overviewStage = 'waiting_for_hand_completion';
overviewErrorMessage = null;
} else if (!allDecisionsTerminal) {
overviewStatus = 'queued';
overviewStage = 'waiting_for_decisions';
overviewErrorMessage = null;
} else if (analyzeAction?.overviewQueuedAt) {
overviewStatus = 'queued';
overviewStage = 'enqueued';
overviewErrorMessage = null;
} else {
overviewStatus = 'queued';
overviewStage = 'enqueue_pending';
overviewErrorMessage = null;
}
if (
(overviewStatus === 'queued' || overviewStatus === 'running') &&
(!overviewStage || overviewStage === 'enqueued')
) {
const overviewJob = await getAnalysisQueue().getJob(
buildHandReportJobId(hand.id, 'WHOLE_HAND', true),
);
if (overviewJob) {
const queueState = await overviewJob.getState();
const queueStage =
readStageFromJobProgress(overviewJob.progress) ??
(queueState === 'active'
? 'started'
: queueState === 'waiting' || queueState === 'delayed'
? 'enqueued'
: queueState === 'failed'
? 'failed'
: null);
if (queueStage) {
overviewStage = queueStage;
}
if (queueState === 'active') {
overviewStatus = 'running';
} else if (queueState === 'failed') {
overviewStatus = 'failed';
}
if (queueState === 'failed' && typeof overviewJob.failedReason === 'string') {
const reason = overviewJob.failedReason.trim();
if (reason) {
overviewErrorMessage = reason;
}
}
}
}
const workerNotRunningMessage = await getWorkerNotRunningMessage();
const hasTerminalDecisionState = decisionsWithDebugPreview.some(
(decision) =>
decision.status === 'failed' ||
decision.status === 'solver_failed' ||
decision.status === 'complete' ||
decision.status === 'llm_only',
);
const allDecisionsQueuedOrEnqueued =
decisionsWithDebugPreview.length > 0 &&
decisionsWithDebugPreview.every(
(decision) =>
decision.status === 'queued' &&
(decision.stage === 'enqueued' ||
decision.stage === 'not_requested' ||
decision.stage === null),
);
const recentStageCutoff = Date.now() - WORKER_HINT_RECENT_STAGE_WINDOW_MS;
const hasRecentStageTransitions =
decisionStatuses.some((row) => row.updatedAt.getTime() >= recentStageCutoff) ||
handReports.some((report) => report.updatedAt.getTime() >= recentStageCutoff);
const showWorkerNotRunningMessage =
Boolean(workerNotRunningMessage) &&
allDecisionsQueuedOrEnqueued &&
!hasTerminalDecisionState &&
!hasRecentStageTransitions;
const firstDecisionFailure = decisionsWithDebugPreview.find(
(decision) => decision.status === 'failed' || decision.status === 'solver_failed',
);
const analysisStatus: HandAnalysisStatus =
hasAnalyzeRequest || overviewReport
? firstDecisionFailure
? 'failed'
: overviewStatus === 'blocked'
? 'queued'
: overviewStatus
: fallbackStatus;
const analysisStage =
hasAnalyzeRequest || overviewReport
? firstDecisionFailure?.stage ?? overviewStage
: null;
const analysisErrorMessage =
hasAnalyzeRequest || overviewReport
? firstDecisionFailure?.errorMessage ?? overviewErrorMessage
: null;
const analysisMessage =
(analysisStatus === 'queued' || analysisStatus === 'running') && showWorkerNotRunningMessage
? workerNotRunningMessage
: null;
const analyzeHandStatus = deriveAnalyzeHandPipelineStatus({
hasAnalyzeRequest,
handComplete: hand.isComplete,
decisions: decisionsWithDebugPreview,
overviewStatus,
overviewStage,
});
const analyzeHandStage = deriveAnalyzeHandPipelineStage({
status: analyzeHandStatus,
decisions: decisionsWithDebugPreview,
overviewStatus,
overviewStage,
});
const analyzeHandErrorMessage =
analyzeHandStatus === 'failed'
? overviewErrorMessage ??
firstDecisionFailure?.errorMessage ??
analyzeHandSummary.errorMessage ??
'Analyze failed'
: analyzeHandSummary.errorMessage;
const analyzeHandMessage =
(analyzeHandStatus === 'queued' || analyzeHandStatus === 'running') &&
showWorkerNotRunningMessage
? workerNotRunningMessage
: null;
const pipelineStatus = derivePipelineStatus({
hasAnalyzeRequest,
decisions: decisionsWithDebugPreview,
overviewStatus,
overviewStage,
strictness,
});
const resolvedGameId = hand.roomId ?? normalizeGameId(params.gameId);
if (!resolvedGameId) {
throw new HandActionServiceError('Unable to resolve gameId for hand', 400);
}
const pipelineDebugEvents = analysisDebugHttpEnabled ? await getHandDebugEvents(hand.id) : [];
return {
gameId: resolvedGameId,
handId: hand.id,
handIndex:
typeof params.handIndex === 'number' && Number.isInteger(params.handIndex) ? params.handIndex : null,
handComplete: hand.isComplete,
strictness,
pipelineStatus,
save: {
...toActionSummary(saveAction),
stage: null,
message: null,
},
analyzeHand: {
status: analyzeHandStatus,
errorMessage: analyzeHandErrorMessage,
stage: analyzeHandStage,
message: analyzeHandMessage,
},
analysis: {
id: latestHandAnalysis?.id ?? null,
status: analysisStatus,
analyzed: hasAnalyzeRequest || fallbackStatus !== 'idle',
stage: analysisStage,
errorMessage: analysisErrorMessage,
message: analysisMessage,
},
decisions: decisionsWithDebugPreview,
blockingDecisions,
overview: {
status: overviewStatus,
stage:
overviewStage === 'blocked' && blockingDecisions.length > 0
? `${overviewStage}:${blockingDecisions.map((decision) => decision.label).join(', ')}`
: overviewStage === 'waiting_for_decisions' && pendingDecisionLabels.length > 0
? `${overviewStage}:${pendingDecisionLabels.join(', ')}`
: overviewStage,
errorMessage: overviewErrorMessage,
},
counts,
debugEvents: analysisDebugHttpEnabled
? sanitizeDebugEventsForClient(pipelineDebugEvents)
: [],
};
}
export async function requestHandActionForUser(params: {
userId: string;
type: HandActionRequestType;
gameId?: string | null;
handId?: string | null;
handIndex?: number | null;
cancel?: boolean;
}): Promise<HandActionStatusPayload> {
const hand = await resolveTargetHand({
gameId: params.gameId,
handId: params.handId,
handIndex: params.handIndex,
});
const resolvedGameId = hand.roomId ?? normalizeGameId(params.gameId);
if (!resolvedGameId) {
throw new HandActionServiceError('Unable to resolve gameId for hand', 400);
}
const actionType = toActionType(params.type);
if (params.cancel === true) {
await prisma.handAction.deleteMany({
where: {
handId: hand.id,
userId: params.userId,
type: actionType,
},
});
await pushPipelineDebugEvent({
handId: hand.id,
message: 'Hand action cancelled',
level: 'warn',
data: {
actionType,
userId: params.userId,
},
});
return readStatusPayload({
userId: params.userId,
gameId: resolvedGameId,
handId: hand.id,
handIndex: params.handIndex,
});
}
const baseAction = await prisma.handAction.upsert({
where: {
handId_userId_type: {
handId: hand.id,
userId: params.userId,
type: actionType,
},
},
create: {
handId: hand.id,
roomId: resolvedGameId,
userId: params.userId,
type: actionType,
status: hand.isComplete ? 'completed' : 'pending',
handIndex:
typeof params.handIndex === 'number' && Number.isInteger(params.handIndex) ? params.handIndex : null,
processedAt: hand.isComplete ? new Date() : null,
},
update: {
roomId: resolvedGameId,
handIndex:
typeof params.handIndex === 'number' && Number.isInteger(params.handIndex) ? params.handIndex : null,
...(hand.isComplete
? {}
: {
status: 'pending',
errorMessage: null,
processedAt: null,
}),
},
select: {
id: true,
handId: true,
userId: true,
type: true,
},
});
await pushPipelineDebugEvent({
handId: hand.id,
message: hand.isComplete ? 'Hand action recorded (immediate execution)' : 'Hand action recorded (queued)',
data: {
actionId: baseAction.id,
actionType: actionType,
handComplete: hand.isComplete,
},
});
if (actionType === 'ANALYZE_HAND') {
const snapshotPersisted = await ensureAnalyzeReviewSnapshot({
hand,
roomId: resolvedGameId,
userId: params.userId,
});
if (!snapshotPersisted && !hand.isComplete) {
await pushPipelineDebugEvent({
handId: hand.id,
level: 'warn',
message: 'Review snapshot unavailable; relying on saved analyze intent',
data: {
actionId: baseAction.id,
userId: params.userId,
},
});
}
}
if (hand.isComplete) {
await executeActionNow(baseAction);
} else {
await pushPipelineDebugEvent({
handId: hand.id,
message: 'Waiting for hand completion before execution',
data: {
actionId: baseAction.id,
actionType: actionType,
},
});
}
return readStatusPayload({
userId: params.userId,
gameId: resolvedGameId,
handId: hand.id,
handIndex: params.handIndex,
});
}
export async function getHandActionStatusForUser(params: {
userId: string;
gameId?: string | null;
handId?: string | null;
handIndex?: number | null;
}): Promise<HandActionStatusPayload> {
return readStatusPayload({
userId: params.userId,
gameId: params.gameId,
handId: params.handId,
handIndex: params.handIndex,
});
}
export async function getPendingSaveUserIdsForHand(handId: string): Promise<string[]> {
const rows = await prisma.handAction.findMany({
where: {
handId,
type: 'SAVE',
status: 'pending',
},
select: {
userId: true,
},
});
return Array.from(new Set(rows.map((row) => row.userId)));
}
export async function getPendingRetentionUserIdsForHand(handId: string): Promise<string[]> {
const rows = await prisma.handAction.findMany({
where: {
handId,
type: {
in: ['SAVE', 'ANALYZE_HAND'],
},
status: 'pending',
},
select: {
userId: true,
},
});
return Array.from(new Set(rows.map((row) => row.userId)));
}
export async function processPendingHandActionsForCompletedHand(handId: string): Promise<void> {
const pendingActions = await prisma.handAction.findMany({
where: {
handId,
status: 'pending',
type: {
in: ['SAVE', 'ANALYZE_HAND'],
},
},
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
select: {
id: true,
handId: true,
userId: true,
type: true,
},
});
await pushPipelineDebugEvent({
handId,
message: 'Processing pending hand actions',
data: {
pendingCount: pendingActions.length,
},
});
for (const action of pendingActions) {
await executeActionNow(action);
}
}
Get-Content apps\api\src\services\hand-analysis-pipeline.tsExit code: 0
Wall time: 0.5 seconds
Output:
import type { HandActionStatus } from '@prisma/client';
import { prisma } from '../db.js';
import { config } from '../config.js';
import { submitAnalysisJob } from './analysis-submit.js';
import {
appendDecisionDebugEvent,
appendHandDebugEvent,
} from './analysis-debug-events.js';
import {
queueScopedHandReportsForHand,
resolveReportScopesForHand,
} from './hand-reports.js';
import { HandAnalysisSubmitError } from './hand-analysis-submit.js';
import {
decisionAnalysisSatisfiesRequirements,
isPostflopStreet,
} from './decision-analysis-requirements.js';
type HandAnalysisRunProgress = {
expectedDecisions: number;
completedDecisions: number;
failedDecisions: number;
overviewQueuedAt: Date | null;
overviewCompletedAt: Date | null;
};
export type StartHandAnalysisPipelineResult = {
id: string;
status: HandActionStatus;
run: HandAnalysisRunProgress;
};
const ANALYZE_HAND_TYPE = 'ANALYZE_HAND' as const;
async function pushHandPipelineEvent(params: {
handId: string;
message: string;
level?: 'info' | 'warn' | 'error';
data?: Record<string, unknown>;
decisionId?: string | null;
scope?: string | null;
}): Promise<void> {
await appendHandDebugEvent({
handId: params.handId,
decisionId: params.decisionId,
source: 'api-status',
level: params.level ?? 'info',
scope: params.scope ?? undefined,
message: params.message,
data: params.data,
});
}
async function resolveParticipantForAnalysis(params: {
handId: string;
userId: string;
}): Promise<{
playerId: string;
roomId: string;
}> {
const participant = await prisma.handParticipant.findUnique({
where: {
handId_userId: {
handId: params.handId,
userId: params.userId,
},
},
select: {
playerId: true,
hand: {
select: {
id: true,
roomId: true,
isComplete: true,
},
},
},
});
if (!participant || !participant.hand) {
throw new HandAnalysisSubmitError('HAND_NOT_FOUND', 'Hand not found', 404);
}
if (!participant.hand.isComplete) {
throw new HandAnalysisSubmitError(
'HAND_INCOMPLETE',
'Hand must be complete before analysis',
409,
);
}
if (!participant.playerId) {
throw new HandAnalysisSubmitError(
'MISSING_PLAYER_MAPPING',
'Hand participant is missing player mapping',
409,
);
}
return {
playerId: participant.playerId,
roomId: participant.hand.roomId ?? '',
};
}
async function getHeroDecisionIds(params: {
handId: string;
playerId: string;
}): Promise<Array<{ id: string; street: string }>> {
const decisions = await prisma.decision.findMany({
where: {
handId: params.handId,
playerId: params.playerId,
},
orderBy: [{ timestamp: 'asc' }, { id: 'asc' }],
select: {
id: true,
street: true,
},
});
return decisions.map((decision) => ({ id: decision.id, street: decision.street }));
}
async function hasPostflopSolverFailedDecision(params: {
decisions: Array<{ id: string; street: string }>;
}): Promise<boolean> {
const postflopDecisionIds = params.decisions
.filter((decision) => isPostflopStreet(decision.street))
.map((decision) => decision.id);
if (postflopDecisionIds.length === 0) {
return false;
}
const failedRows = await prisma.analysisStatus.findMany({
where: {
decisionId: {
in: postflopDecisionIds,
},
status: {
in: ['failed', 'solver_failed', 'cancelled'],
},
},
select: {
decisionId: true,
stage: true,
errorMessage: true,
cancelledReason: true,
},
});
return failedRows.some((row) => {
const stage = (row.stage ?? '').trim().toLowerCase();
if (stage === 'solver_required' || stage === 'solver_failed') {
return true;
}
const reason = `${row.errorMessage ?? ''} ${row.cancelledReason ?? ''}`.toLowerCase();
return reason.includes('solver_');
});
}
async function computeRunProgress(params: {
decisionIds: string[];
}): Promise<{ completedDecisions: number; failedDecisions: number }> {
if (params.decisionIds.length === 0) {
return {
completedDecisions: 0,
failedDecisions: 0,
};
}
const analyses = await prisma.analysis.findMany({
where: {
decisionId: {
in: params.decisionIds,
},
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: {
decisionId: true,
gtoPolicy: true,
rawSolverOutput: true,
decision: {
select: {
street: true,
},
},
},
});
const completedDecisionIds = new Set<string>();
for (const analysis of analyses) {
if (completedDecisionIds.has(analysis.decisionId)) {
continue;
}
if (
decisionAnalysisSatisfiesRequirements({
street: analysis.decision.street,
gtoPolicy: analysis.gtoPolicy,
rawSolverOutput: analysis.rawSolverOutput,
})
) {
completedDecisionIds.add(analysis.decisionId);
}
}
const statusRows = await prisma.analysisStatus.findMany({
where: {
decisionId: {
in: params.decisionIds,
},
status: {
in: ['failed', 'solver_failed', 'cancelled'],
},
},
select: {
decisionId: true,
},
});
const failedSet = new Set<string>();
for (const row of statusRows) {
if (completedDecisionIds.has(row.decisionId)) {
continue;
}
failedSet.add(row.decisionId);
}
return {
completedDecisions: completedDecisionIds.size,
failedDecisions: failedSet.size,
};
}
export async function finalizeHandAnalysisRun(params: {
handId: string;
userId: string;
}): Promise<HandAnalysisRunProgress | null> {
const action = await prisma.handAction.findUnique({
where: {
handId_userId_type: {
handId: params.handId,
userId: params.userId,
type: ANALYZE_HAND_TYPE,
},
},
select: {
id: true,
overviewQueuedAt: true,
overviewCompletedAt: true,
},
});
if (!action) {
return null;
}
const participant = await prisma.handParticipant.findUnique({
where: {
handId_userId: {
handId: params.handId,
userId: params.userId,
},
},
select: {
playerId: true,
},
});
if (!participant?.playerId) {
return null;
}
const heroDecisions = await getHeroDecisionIds({
handId: params.handId,
playerId: participant.playerId,
});
const postflopHeroDecisions = heroDecisions.filter((decision) =>
isPostflopStreet(decision.street),
);
const decisionIds = heroDecisions.map((decision) => decision.id);
const expectedDecisions = decisionIds.length;
const runProgress = await computeRunProgress({ decisionIds });
const terminalDecisions = runProgress.completedDecisions + runProgress.failedDecisions;
const blockedBySolverFailure = await hasPostflopSolverFailedDecision({
decisions: postflopHeroDecisions,
});
const shouldQueueOverview =
terminalDecisions >= expectedDecisions &&
!blockedBySolverFailure &&
action.overviewQueuedAt === null;
const nextOverviewQueuedAt = shouldQueueOverview ? new Date() : action.overviewQueuedAt;
if (shouldQueueOverview) {
await queueScopedHandReportsForHand({
handId: params.handId,
userId: params.userId,
runoutAware: true,
scopes: ['WHOLE_HAND'],
forceRequeueCompleted: true,
});
await pushHandPipelineEvent({
handId: params.handId,
scope: 'WHOLE_HAND',
message: 'Overview report queued',
data: {
strictness: config.solverStrictness,
expectedDecisions,
terminalDecisions,
},
});
} else if (blockedBySolverFailure && action.overviewQueuedAt === null) {
await pushHandPipelineEvent({
handId: params.handId,
level: 'warn',
scope: 'WHOLE_HAND',
message: 'Overview blocked by postflop analysis failure',
data: {
strictness: config.solverStrictness,
expectedDecisions,
completedDecisions: runProgress.completedDecisions,
failedDecisions: runProgress.failedDecisions,
},
});
}
const updated = await prisma.handAction.update({
where: { id: action.id },
data: {
expectedDecisions,
completedDecisions: runProgress.completedDecisions,
failedDecisions: runProgress.failedDecisions,
overviewQueuedAt: nextOverviewQueuedAt,
overviewCompletedAt:
shouldQueueOverview && action.overviewCompletedAt
? null
: action.overviewCompletedAt,
},
select: {
expectedDecisions: true,
completedDecisions: true,
failedDecisions: true,
overviewQueuedAt: true,
overviewCompletedAt: true,
},
});
return {
expectedDecisions: updated.expectedDecisions,
completedDecisions: updated.completedDecisions,
failedDecisions: updated.failedDecisions,
overviewQueuedAt: updated.overviewQueuedAt,
overviewCompletedAt: updated.overviewCompletedAt,
};
}
export async function finalizeHandAnalysisRunForDecision(params: {
decisionId: string;
userId?: string | null;
}): Promise<void> {
const decision = await prisma.decision.findUnique({
where: { id: params.decisionId },
select: {
handId: true,
playerId: true,
},
});
if (!decision) {
return;
}
if (params.userId) {
const participant = await prisma.handParticipant.findUnique({
where: {
handId_userId: {
handId: decision.handId,
userId: params.userId,
},
},
select: { playerId: true },
});
if (!participant || participant.playerId !== decision.playerId) {
return;
}
await finalizeHandAnalysisRun({ handId: decision.handId, userId: params.userId });
return;
}
const actions = await prisma.handAction.findMany({
where: {
handId: decision.handId,
type: ANALYZE_HAND_TYPE,
},
select: {
userId: true,
},
});
for (const action of actions) {
const participant = await prisma.handParticipant.findUnique({
where: {
handId_userId: {
handId: decision.handId,
userId: action.userId,
},
},
select: { playerId: true },
});
if (!participant || participant.playerId !== decision.playerId) {
continue;
}
await finalizeHandAnalysisRun({
handId: decision.handId,
userId: action.userId,
});
}
}
export async function markOverviewCompleted(params: {
handId: string;
userId: string;
}): Promise<void> {
await prisma.handAction.updateMany({
where: {
handId: params.handId,
userId: params.userId,
type: ANALYZE_HAND_TYPE,
},
data: {
overviewCompletedAt: new Date(),
},
});
await pushHandPipelineEvent({
handId: params.handId,
scope: 'WHOLE_HAND',
message: 'Overview report completed',
data: {
userId: params.userId,
},
});
}
export async function startHandAnalysisPipeline(params: {
handId: string;
userId: string;
}): Promise<StartHandAnalysisPipelineResult> {
const participant = await resolveParticipantForAnalysis({
handId: params.handId,
userId: params.userId,
});
const heroDecisions = await getHeroDecisionIds({
handId: params.handId,
playerId: participant.playerId,
});
const decisionIds = heroDecisions.map((decision) => decision.id);
await pushHandPipelineEvent({
handId: params.handId,
message: 'Pipeline requested',
data: {
userId: params.userId,
decisionCount: decisionIds.length,
},
});
const action = await prisma.handAction.upsert({
where: {
handId_userId_type: {
handId: params.handId,
userId: params.userId,
type: ANALYZE_HAND_TYPE,
},
},
create: {
handId: params.handId,
roomId: participant.roomId,
userId: params.userId,
type: ANALYZE_HAND_TYPE,
status: 'completed',
errorMessage: null,
expectedDecisions: decisionIds.length,
completedDecisions: 0,
failedDecisions: 0,
overviewQueuedAt: null,
overviewCompletedAt: null,
processedAt: new Date(),
},
update: {
roomId: participant.roomId,
status: 'completed',
errorMessage: null,
expectedDecisions: decisionIds.length,
completedDecisions: 0,
failedDecisions: 0,
overviewQueuedAt: null,
overviewCompletedAt: null,
processedAt: new Date(),
},
select: {
id: true,
status: true,
},
});
const availableScopes = await resolveReportScopesForHand(params.handId);
const nonOverviewScopes = availableScopes.filter((scope) => scope !== 'WHOLE_HAND');
if (nonOverviewScopes.length > 0) {
await queueScopedHandReportsForHand({
handId: params.handId,
userId: params.userId,
runoutAware: true,
scopes: nonOverviewScopes,
forceRequeueCompleted: true,
});
await pushHandPipelineEvent({
handId: params.handId,
message: 'Non-overview reports queued',
data: {
scopes: nonOverviewScopes,
},
});
}
await Promise.all(
decisionIds.map(async (decisionId) => {
await submitAnalysisJob(decisionId, { userId: params.userId });
await appendDecisionDebugEvent({
decisionId,
handId: params.handId,
source: 'api-status',
level: 'info',
message: 'Decision analysis enqueued',
data: {
userId: params.userId,
},
});
}),
);
const run = await finalizeHandAnalysisRun({
handId: params.handId,
userId: params.userId,
});
return {
id: action.id,
status: action.status,
run: run ?? {
expectedDecisions: decisionIds.length,
completedDecisions: 0,
failedDecisions: 0,
overviewQueuedAt: null,
overviewCompletedAt: null,
},
};
}
The pipeline failure is specifically hero_key_not_in_combo_map, which means the solver returned a combo policy map but the hero hand lookup key didn’t match any combo entry. I’m tracing the combo-key generation path now, including the shared TexasSolver key helpers and any recent table-state changes that might have altered the hero cards used for lookup.
rg -n "toTexasSolverComboKey|toTexasSolverComboKeyFromCards|heroComboLookupKey|heroComboPolicyPresent|comboPolicies|heroComboFailureReason|hero_key_not_in_combo_map" packages apps/api apps/solver-service apps/webExit code: 0
Wall time: 0.5 seconds
Output:
apps/solver-service\src\solverNormalization.ts:3: toTexasSolverComboKey,
apps/solver-service\src\solverNormalization.ts:4: toTexasSolverComboKeyFromCards,
apps/solver-service\src\solverNormalization.ts:11: comboPolicies?: Record<string, NormalizedPolicy>;
apps/solver-service\src\solverNormalization.ts:16: heroComboFailureReason?:
apps/solver-service\src\solverNormalization.ts:18: | 'hero_key_not_in_combo_map'
apps/solver-service\src\solverNormalization.ts:26:const HERO_COMBO_KEY_MISSING = 'hero_key_not_in_combo_map';
apps/solver-service\src\solverNormalization.ts:52: const { comboPolicies, totals, samples } = comboExtraction;
apps/solver-service\src\solverNormalization.ts:65: ...(Object.keys(comboPolicies).length > 0 ? { comboPolicies } : {}),
apps/solver-service\src\solverNormalization.ts:94: comboPolicies: Record<string, NormalizedPolicy>;
apps/solver-service\src\solverNormalization.ts:98: const comboPolicies: Record<string, NormalizedPolicy> = {};
apps/solver-service\src\solverNormalization.ts:124: const comboKey = toTexasSolverComboKey(rawComboKey);
apps/solver-service\src\solverNormalization.ts:133: comboPolicies[comboKey] = policy;
apps/solver-service\src\solverNormalization.ts:137: comboPolicies,
apps/solver-service\src\solverNormalization.ts:414: heroComboFailureReason: _previousFailureReason,
apps/solver-service\src\solverNormalization.ts:430: heroComboFailureReason: HERO_COMBO_UNAVAILABLE,
apps/solver-service\src\solverNormalization.ts:434: const heroComboKey = toTexasSolverComboKeyFromCards(first, second);
apps/solver-service\src\solverNormalization.ts:439: heroComboFailureReason: HERO_COMBO_UNAVAILABLE,
apps/solver-service\src\solverNormalization.ts:443: const comboPolicyKeyCount = normalized.comboPolicies
apps/solver-service\src\solverNormalization.ts:444: ? Object.keys(normalized.comboPolicies).length
apps/solver-service\src\solverNormalization.ts:450: heroComboFailureReason: HERO_COMBO_MAP_MISSING,
apps/solver-service\src\solverNormalization.ts:454: const heroComboPolicy = normalized.comboPolicies?.[heroComboKey];
apps/solver-service\src\solverNormalization.ts:460: heroComboFailureReason: null,
apps/solver-service\src\solverNormalization.ts:467: heroComboFailureReason: HERO_COMBO_KEY_MISSING,
apps/solver-service\src\solverNormalization.test.ts:26: expect(normalized?.comboPolicies?.JdJc).toEqual({
apps/solver-service\src\solverNormalization.test.ts:30: expect(normalized?.comboPolicies?.['3d2h']).toEqual({
apps/solver-service\src\solverNormalization.test.ts:34: expect(normalized?.comboPolicies?.QsQh).toEqual({
apps/solver-service\src\solverNormalization.test.ts:52: expect(normalized?.comboPolicies?.AhQh).toEqual({
apps/solver-service\src\solverNormalization.test.ts:56: expect(normalized?.comboPolicies?.KcQd).toEqual({
apps/solver-service\src\solverNormalization.test.ts:82: expect(normalized?.comboPolicies?.['6d5c']).toEqual({
apps/solver-service\src\solverNormalization.test.ts:87: expect(normalized?.comboPolicies?.AcKc).toEqual({
apps/solver-service\src\solverNormalization.test.ts:101: expect(withHero?.heroComboFailureReason).toBeNull();
apps/solver-service\src\solverNormalization.test.ts:135: comboPolicies: {
apps/solver-service\src\solverNormalization.test.ts:150: expect(normalized?.heroComboFailureReason).toBeNull();
apps/solver-service\src\solverNormalization.test.ts:166: expect(normalized?.heroComboFailureReason).toBe(
apps/solver-service\src\solverNormalization.test.ts:178: comboPolicies: {
apps/solver-service\src\solverNormalization.test.ts:190: expect(normalized?.heroComboFailureReason).toBe('hero_key_not_in_combo_map');
apps/solver-service\src\server.ts:1712: if (decorated?.heroComboFailureReason) {
apps/solver-service\src\server.ts:1746: const comboPolicies =
apps/solver-service\src\server.ts:1747: params.normalized && isRecord(params.normalized.comboPolicies)
apps/solver-service\src\server.ts:1748: ? (params.normalized.comboPolicies as Record<string, unknown>)
apps/solver-service\src\server.ts:1750: const comboPolicyKeys = Object.keys(comboPolicies);
apps/solver-service\src\server.ts:1777: heroComboPolicyPresent: Boolean(heroComboPolicy && Object.keys(heroComboPolicy).length > 0),
apps/solver-service\src\server.ts:1779: heroComboFailureReason: params.normalized?.heroComboFailureReason ?? null,
packages\shared\src\comboKeyTexasSolver.ts:87:export function toTexasSolverComboKey(raw: string): string | null {
packages\shared\src\comboKeyTexasSolver.ts:101:export function toTexasSolverComboKeyFromCards(first: string, second: string): string | null {
packages\shared\src\comboKeyTexasSolver.test.ts:5: toTexasSolverComboKey,
packages\shared\src\comboKeyTexasSolver.test.ts:6: toTexasSolverComboKeyFromCards,
packages\shared\src\comboKeyTexasSolver.test.ts:16:describe('toTexasSolverComboKey', () => {
packages\shared\src\comboKeyTexasSolver.test.ts:18: expect(toTexasSolverComboKey('JcJd')).toBe('JdJc');
packages\shared\src\comboKeyTexasSolver.test.ts:19: expect(toTexasSolverComboKey('JhJs')).toBe('JsJh');
packages\shared\src\comboKeyTexasSolver.test.ts:23: expect(toTexasSolverComboKey('2h3d')).toBe('3d2h');
packages\shared\src\comboKeyTexasSolver.test.ts:27: expect(toTexasSolverComboKey('7s5s')).toBe('7s5s');
packages\shared\src\comboKeyTexasSolver.test.ts:28: expect(toTexasSolverComboKey('5s7s')).toBe('7s5s');
packages\shared\src\comboKeyTexasSolver.test.ts:32:describe('toTexasSolverComboKeyFromCards', () => {
packages\shared\src\comboKeyTexasSolver.test.ts:34: expect(toTexasSolverComboKeyFromCards('Jc', 'Jd')).toBe('JdJc');
packages\shared\src\comboKeyTexasSolver.test.ts:35: expect(toTexasSolverComboKeyFromCards('2h', '3d')).toBe('3d2h');
packages\shared\src\comboKeyTexasSolver.test.ts:36: expect(toTexasSolverComboKeyFromCards('5s', '7s')).toBe('7s5s');
apps/api\src\workers\analysis-worker.test.ts:856: comboPolicies: {},
apps/api\src\workers\analysis-worker.logic.ts:49: toTexasSolverComboKey,
apps/api\src\workers\analysis-worker.logic.ts:50: toTexasSolverComboKeyFromCards,
apps/api\src\workers\analysis-worker.logic.ts:126: comboPolicies?: Record<string, Record<string, number>>;
apps/api\src\workers\analysis-worker.logic.ts:131: heroComboFailureReason?: string | null;
apps/api\src\workers\analysis-worker.logic.ts:276:const HERO_COMBO_KEY_MISSING_REASON = 'hero_key_not_in_combo_map';
apps/api\src\workers\analysis-worker.logic.ts:1854: result.normalized?.comboPolicies && typeof result.normalized.comboPolicies === 'object'
apps/api\src\workers\analysis-worker.logic.ts:1855: ? Object.keys(result.normalized.comboPolicies).length
apps/api\src\workers\analysis-worker.logic.ts:1857: const heroComboPolicyPresent =
apps/api\src\workers\analysis-worker.logic.ts:1876: heroComboPolicyPresent,
apps/api\src\workers\analysis-worker.logic.ts:1877: heroComboFailureReason:
apps/api\src\workers\analysis-worker.logic.ts:1878: result.normalized?.heroComboFailureReason ?? null,
apps/api\src\workers\analysis-worker.logic.ts:2410: heroComboFailureReason?: string | null;
apps/api\src\workers\analysis-worker.logic.ts:2414: heroComboLookupKey?: string | null;
apps/api\src\workers\analysis-worker.logic.ts:2988: const heroComboFailureReason = meta.heroComboFailureReason;
apps/api\src\workers\analysis-worker.logic.ts:2990: const heroComboLookupKey = meta.heroComboLookupKey;
apps/api\src\workers\analysis-worker.logic.ts:3091: if (typeof heroComboFailureReason === 'string' || heroComboFailureReason === null) {
apps/api\src\workers\analysis-worker.logic.ts:3092: result.heroComboFailureReason = heroComboFailureReason;
apps/api\src\workers\analysis-worker.logic.ts:3103: if (typeof heroComboLookupKey === 'string' || heroComboLookupKey === null) {
apps/api\src\workers\analysis-worker.logic.ts:3104: result.heroComboLookupKey = heroComboLookupKey;
apps/api\src\workers\analysis-worker.logic.ts:4059: comboKey: toTexasSolverComboKeyFromCards(firstCard, secondCard),
apps/api\src\workers\analysis-worker.logic.ts:4307: if (!isRecord(normalized.comboPolicies)) return {};
apps/api\src\workers\analysis-worker.logic.ts:4309: for (const [rawKey, rawPolicy] of Object.entries(normalized.comboPolicies as Record<string, unknown>)) {
apps/api\src\workers\analysis-worker.logic.ts:4311: const canonicalComboKey = toTexasSolverComboKey(rawKey);
apps/api\src\workers\analysis-worker.logic.ts:4337: ? toTexasSolverComboKey(normalized.heroComboKey)
apps/api\src\workers\analysis-worker.logic.ts:4346: typeof normalized.heroComboFailureReason === 'string' &&
apps/api\src\workers\analysis-worker.logic.ts:4347: normalized.heroComboFailureReason.trim().length > 0
apps/api\src\workers\analysis-worker.logic.ts:4348: ? normalized.heroComboFailureReason.trim()
apps/api\src\workers\analysis-worker.logic.ts:4349: : normalized.heroComboFailureReason === null
apps/api\src\workers\analysis-worker.logic.ts:4383: const comboPolicies = readNormalizedComboPolicies(normalized);
apps/api\src\workers\analysis-worker.logic.ts:4384: const solverComboKeys = Object.keys(comboPolicies);
apps/api\src\workers\analysis-worker.logic.ts:4392: const canonicalHeroCombo = toTexasSolverComboKey(heroComboKey);
apps/api\src\workers\analysis-worker.logic.ts:4400: const policy = comboPolicies[canonicalHeroCombo] ?? null;
apps/api\src\workers\analysis-worker.logic.ts:6449: const heroComboLookupKey = heroComboFromService.heroComboKey ?? heroCardInfo.comboKey;
apps/api\src\workers\analysis-worker.logic.ts:6457: heroComboLookupKey,
apps/api\src\workers\analysis-worker.logic.ts:6469: .map((key) => toTexasSolverComboKey(key))
apps/api\src\workers\analysis-worker.logic.ts:6480: (typeof heroComboLookupKey === 'string' && solverComboKeys.includes(heroComboLookupKey));
apps/api\src\workers\analysis-worker.logic.ts:6481: const heroComboFailureReason =
apps/api\src\workers\analysis-worker.logic.ts:6485: : heroComboLookupKey
apps/api\src\workers\analysis-worker.logic.ts:6493: heroComboLookupKey,
apps/api\src\workers\analysis-worker.logic.ts:6495: heroComboPolicyPresent: Boolean(heroComboPolicy),
apps/api\src\workers\analysis-worker.logic.ts:6502: analysisMeta.heroComboFailureReason = heroComboFailureReason;
apps/api\src\workers\analysis-worker.logic.ts:6504: analysisMeta.heroComboLookupKey = heroComboLookupKey;
apps/api\src\workers\analysis-worker.logic.ts:6525: heroComboLookupKey,
apps/api\src\workers\analysis-worker.logic.ts:6531: failureReason: heroComboFailureReason,
apps/api\src\workers\analysis-worker.logic.ts:6538: detail: heroComboFailureReason,
apps/api\src\workers\analysis-worker.logic.ts:6720: analysisMeta.heroComboFailureReason = null;
apps/api\src\workers\analysis-worker.logic.ts:6722: analysisMeta.heroComboLookupKey = heroComboLookupKey;
apps/api\src\workers\analysis-worker.logic.ts:6744: heroComboLookupKey,
apps/api\src\workers\analysis-worker.integration.test.ts:713: comboPolicies: {
apps/api\src\workers\analysis-worker.integration.test.ts:789: comboPolicies: {
apps/api\src\workers\analysis-worker.integration.test.ts:872: comboPolicies: {
apps/api\src\workers\analysis-worker.integration.test.ts:941: it('uses heroComboPolicy from solver-service even when comboPolicies are absent', async () => {
apps/api\src\workers\analysis-worker.integration.test.ts:958: heroComboFailureReason: null,
apps/api\src\workers\analysis-worker.integration.test.ts:1059: heroComboFailureReason: null,
apps/api\src\workers\analysis-worker.integration.test.ts:1170: heroComboFailureReason: null,
apps/api\src\workers\analysis-worker.integration.test.ts:1273: heroComboFailureReason: null,
apps/api\src\workers\analysis-worker.integration.test.ts:1382: heroComboFailureReason: null,
apps/api\src\workers\analysis-worker.integration.test.ts:1585: heroComboFailureReason: null,
apps/api\src\workers\analysis-worker.integration.test.ts:1694: heroComboFailureReason: null,
apps/api\src\workers\analysis-worker.integration.test.ts:1776: heroComboFailureReason: 'hero_combo_unavailable',
apps/api\src\workers\analysis-worker.integration.test.ts:1823: it('marks decision solver_failed when comboPolicies do not contain the hero key', async () => {
apps/api\src\workers\analysis-worker.integration.test.ts:1835: comboPolicies: {
apps/api\src\workers\analysis-worker.integration.test.ts:1842: heroComboFailureReason: 'hero_key_not_in_combo_map',
apps/api\src\workers\analysis-worker.integration.test.ts:1906: heroComboFailureReason: null,
apps/api\src\workers\analysis-worker.integration.test.ts:1982: heroComboFailureReason: null,
apps/web\src\components\table\AnalysisDrawer.tsx:1213: analysis.meta?.heroComboFailureReason ??
apps/api\src\services\analysis-debug-events.ts:265: copyText('heroComboFailureReason', 'heroComboFailureReason');
apps/api\src\services\analysis-debug-events.ts:286: copyBoolean('heroComboPolicyPresent');
apps/api\src\routes\analysis-rest.ts:102: heroComboFailureReason?: string | null;
apps/api\src\routes\analysis-rest.ts:108: heroComboLookupKey?: string | null;
apps/api\src\routes\analysis-rest.ts:486: typeof record.heroComboFailureReason === 'string' ||
apps/api\src\routes\analysis-rest.ts:487: record.heroComboFailureReason === null
apps/api\src\routes\analysis-rest.ts:489: result.heroComboFailureReason = record.heroComboFailureReason;
apps/api\src\routes\analysis-rest.ts:508: if (typeof record.heroComboLookupKey === 'string' || record.heroComboLookupKey === null) {
apps/api\src\routes\analysis-rest.ts:509: result.heroComboLookupKey = record.heroComboLookupKey;
apps/api\src\routes\analysis-rest.ts:603: heroComboFailureReason: HERO_COMBO_UNAVAILABLE_REASON,
apps/api\src\routes\analysis-rest.ts:612: heroComboFailureReason: meta.heroComboFailureReason ?? HERO_COMBO_UNAVAILABLE_REASON,
apps/api\src\routes\analysis-rest.ts:628: return meta?.heroComboFailureReason ?? HERO_COMBO_UNAVAILABLE_REASON;
apps/web\src\components\analysis\StrategyMixPanel.tsx:41: heroComboFailureReason?: string | null;
apps/api\src\services\decision-analysis-canonical.ts:23: heroComboFailureReason?: string | null;
apps/api\src\services\decision-analysis-canonical.ts:509: params.meta?.heroComboFailureReason ||
apps/api\src\services\decision-analysis-requirements.ts:139: readNullableString(meta?.heroComboFailureReason) ??
apps/api\src\routes\hands.ts:182: 'heroComboFailureReason',
apps/web\src\app\hands\[handId]\page.tsx:2621: ? asString(analysis?.meta?.heroComboFailureReason) ??
Get-Content packages\shared\src\index.tsExit code: 0 Wall time: 0.5 seconds Output: export * from './types.js'; export * from './events.js'; export * from './utils.js'; export * from './errors.js'; export * from './action-keys.js'; export * from './combo-keys.js'; export * from './comboKeyTexasSolver.js'; export * from './solver-selection.js';
Get-Content packages\shared\src\solver.tsExit code: 1
Wall time: 0.6 seconds
Output:
Get-Content : Cannot find path 'E:\Desktop\Poker\packages\shared\src\solver.ts' because it does not exist.
At line:2 char:1
+ Get-Content packages\shared\src\solver.ts
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (E:\Desktop\Poke...d\src\solver.ts:String) [Get-Content], ItemNotFoundEx
ception
+ FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetContentCommand
Get-Content packages\table\src\hand-reducer.tsExit code: 0
Wall time: 0.5 seconds
Output:
import type {
HandState,
HandEvent,
HandMeta,
Player,
PostBlindEvent,
DealEvent,
StreetEvent,
ActionEvent,
ShowdownEvent,
UncalledReturnEvent,
AwardEvent,
HandEndEvent,
} from './types.js';
import { getNextPlayer, calculateSidePots, isBettingComplete } from './betting.js';
/**
* Create initial hand state from metadata
*/
export function createInitialState(meta: HandMeta): HandState {
const players: Player[] = meta.players.map(p => ({
id: p.id,
position: p.position,
stack: p.stack,
committed: 0,
committedRound: 0, // Alias for tests
folded: false,
allIn: false,
}));
return {
meta,
street: 'preflop',
board: [],
players,
pots: [],
currentPot: 0,
currentBet: 0,
minRaise: meta.bigBlind,
lastRaiseAmount: meta.bigBlind,
actionOn: null,
dealerPosition: meta.buttonPosition,
bettingComplete: false,
};
}
/**
* Apply a single event to the hand state
*/
export function applyEvent(state: HandState, event: HandEvent): HandState {
switch (event.type) {
case 'post_blind':
return applyPostBlind(state, event);
case 'deal':
return applyDeal(state, event);
case 'street':
return applyStreet(state, event);
case 'action':
return applyAction(state, event);
case 'showdown':
return applyShowdown(state, event);
case 'uncalled_return':
return applyUncalledReturn(state, event);
case 'award':
return applyAward(state, event);
case 'hand_end':
return applyHandEnd(state, event);
default:
return state;
}
}
function applyPostBlind(state: HandState, event: PostBlindEvent): HandState {
const players = state.players.map(p => {
if (p.id === event.playerId) {
const newCommitted = p.committed + event.amount;
return {
...p,
stack: p.stack - event.amount,
committed: newCommitted,
committedRound: newCommitted, // Alias for tests
allIn: p.stack === event.amount,
};
}
return p;
});
const newCurrentBet = Math.max(state.currentBet, event.amount);
return {
...state,
players,
currentPot: state.currentPot + event.amount,
currentBet: newCurrentBet,
};
}
function applyDeal(state: HandState, event: DealEvent): HandState {
const players = state.players.map(p => {
const cards = event.playerCards[p.id];
return cards ? { ...p, cards } : p;
});
// Find first player to act (after big blind)
const bigBlindPos = (state.dealerPosition + 2) % state.players.length;
const nextPlayer = getNextPlayer(players, bigBlindPos);
return {
...state,
players,
actionOn: nextPlayer?.id || null,
};
}
function applyStreet(state: HandState, event: StreetEvent): HandState {
// Move committed chips to pot and reset for new street
const players = state.players.map(p => ({
...p,
committed: 0,
committedRound: 0, // Alias for tests
hasActed: false, // Reset for new street
}));
// Find first player to act (after button)
const nextPlayer = getNextPlayer(players, state.dealerPosition);
return {
...state,
street: event.street,
board: event.board,
players,
currentBet: 0,
minRaise: state.meta.bigBlind,
actionOn: nextPlayer?.id || null,
bettingComplete: false,
};
}
function applyAction(state: HandState, event: ActionEvent): HandState {
const players = state.players.map(p => {
if (p.id === event.playerId) {
switch (event.action) {
case 'fold':
return { ...p, folded: true };
case 'check':
return { ...p, hasActed: true };
case 'call': {
const toCall = state.currentBet - p.committed;
const pay = Math.min(toCall, p.stack);
const newStack = Math.max(0, p.stack - pay); // Never negative
const newCommitted = p.committed + pay;
return {
...p,
stack: newStack,
committed: newCommitted,
committedRound: newCommitted, // Alias for tests
allIn: newStack === 0,
hasActed: true,
};
}
case 'bet':
case 'raise':
case 'all_in': {
// event.amount is the amount to ADD (not total)
// Clamp to available stack to prevent negative
const actualAmount = Math.min(event.amount, p.stack);
const newStack = p.stack - actualAmount;
const newCommitted = p.committed + actualAmount;
return {
...p,
stack: newStack,
committed: newCommitted,
committedRound: newCommitted, // Alias for tests
allIn: newStack === 0,
hasActed: true,
};
}
default:
return p;
}
}
return p;
});
// Update betting state
let newCurrentBet = state.currentBet;
let newMinRaise = state.minRaise;
if (event.action === 'bet') {
// A bet sets the current bet level to the bettor's committed amount
const actorAfter = players.find(p => p.id === event.playerId)!;
newCurrentBet = actorAfter.committed;
newMinRaise = actorAfter.committed; // first bet defines min-raise size
} else if (event.action === 'raise') {
// For raises, event.amount includes toCall + raiseSize
// After mapping, the actor's committed is the new bet level
const actorAfter = players.find(p => p.id === event.playerId)!;
const raiseAmount = actorAfter.committed - state.currentBet;
newCurrentBet = actorAfter.committed;
newMinRaise = raiseAmount;
} else if (event.action === 'all_in') {
// All-in also raises the currentBet if it exceeds it
const actorAfter = players.find(p => p.id === event.playerId)!;
if (actorAfter.committed > state.currentBet) {
const raiseAmount = actorAfter.committed - state.currentBet;
newCurrentBet = actorAfter.committed;
// Only update minRaise if this is a full raise (>= current minRaise)
if (raiseAmount >= state.minRaise || state.currentBet === 0) {
newMinRaise = raiseAmount;
}
}
}
const newCurrentPot = state.currentPot + event.amount;
// Find next player
const currentPlayer = players.find(p => p.id === event.playerId);
const nextPlayer = currentPlayer ? getNextPlayer(players, currentPlayer.position) : null;
// Check if betting is complete
const bettingComplete = isBettingComplete(players, newCurrentBet);
return {
...state,
players,
currentPot: newCurrentPot,
currentBet: newCurrentBet,
minRaise: newMinRaise,
actionOn: bettingComplete ? null : (nextPlayer?.id || null),
bettingComplete,
};
}
function applyShowdown(state: HandState, event: ShowdownEvent): HandState {
// Reveal all cards
const players = state.players.map(p => {
const cards = event.playerHands[p.id];
return cards ? { ...p, cards } : p;
});
return {
...state,
street: 'showdown',
players,
};
}
function applyUncalledReturn(state: HandState, event: UncalledReturnEvent): HandState {
const amount = Math.max(0, event.amount);
const players = state.players.map((player) => {
if (player.id !== event.playerId) {
return player;
}
const nextCommitted = Math.max(0, player.committed - amount);
return {
...player,
stack: player.stack + amount,
committed: nextCommitted,
committedRound: nextCommitted,
allIn: player.stack + amount === 0,
};
});
return {
...state,
players,
currentPot: Math.max(0, state.currentPot - amount),
currentBet: Math.max(0, ...players.map((player) => player.committed)),
};
}
function applyAward(state: HandState, event: AwardEvent): HandState {
const players = state.players.map(p => {
const winnings = event.winners[p.id] || 0;
return {
...p,
stack: p.stack + winnings,
};
});
return {
...state,
players,
pots: event.pots,
currentPot: 0,
};
}
function applyHandEnd(state: HandState, event: HandEndEvent): HandState {
const players = state.players.map(p => ({
...p,
stack: event.finalStacks[p.id] || p.stack,
}));
return {
...state,
players,
actionOn: null,
};
}
/**
* Replay all events to reconstruct hand state
*/
export function replayHand(meta: HandMeta, events: HandEvent[]): HandState {
let state = createInitialState(meta);
for (const event of events) {
state = applyEvent(state, event);
}
return state;
}
Get-Content packages\table\src\types.tsExit code: 0
Wall time: 0.5 seconds
Output:
import type { Card } from '@poker/shared';
// Core poker types
export type Street = 'preflop' | 'flop' | 'turn' | 'river' | 'showdown';
export type ActionKind =
| 'post_blind'
| 'deal'
| 'fold'
| 'check'
| 'call'
| 'bet'
| 'raise'
| 'all_in';
// Hand metadata for reproducibility
export interface HandMeta {
handId: string;
seed: string;
timestamp: number;
players: PlayerMeta[];
smallBlind: number;
bigBlind: number;
buttonPosition: number;
}
export interface PlayerMeta {
id: string;
position: number;
stack: number;
}
// Hand events
export interface PostBlindEvent {
type: 'post_blind';
playerId: string;
amount: number;
isSmallBlind: boolean;
}
export interface DealEvent {
type: 'deal';
playerCards: Record<string, [Card, Card]>;
deckCards: Card[]; // Store full deck for determinism
}
export interface StreetEvent {
type: 'street';
street: Street;
board: Card[];
}
export interface ActionEvent {
type: 'action';
playerId: string;
action: ActionKind;
amount: number;
decisionId?: string;
handEventSeq?: number;
}
export interface ShowdownEvent {
type: 'showdown';
playerHands: Record<string, [Card, Card]>;
}
export interface UncalledReturnEvent {
type: 'uncalled_return';
playerId: string;
amount: number;
}
export interface AwardEvent {
type: 'award';
pots: Pot[];
winners: Record<string, number>; // playerId -> amount won
}
export interface HandEndEvent {
type: 'hand_end';
finalStacks: Record<string, number>;
}
export type HandEvent =
| PostBlindEvent
| DealEvent
| StreetEvent
| ActionEvent
| ShowdownEvent
| UncalledReturnEvent
| AwardEvent
| HandEndEvent;
// Hand record for storage
export interface HandRecord {
meta: HandMeta;
events: HandEvent[];
}
// Game state
export interface Player {
id: string;
position: number;
stack: number;
committed: number; // chips committed to current pot
committedRound?: number; // Alias for tests (same as committed in legacy API)
folded: boolean;
allIn: boolean;
cards?: [Card, Card];
hasActed?: boolean; // has acted this betting round
}
export interface Pot {
amount: number;
eligiblePlayers: string[];
}
export interface HandState {
meta: HandMeta;
street: Street;
board: Card[];
players: Player[];
pots: Pot[];
currentPot: number;
currentBet: number;
minRaise: number;
lastRaiseAmount: number;
actionOn: string | null;
dealerPosition: number;
bettingComplete: boolean;
}
// Betting rules
export interface BettingAction {
playerId: string;
action: ActionKind;
amount: number;
}
export interface BettingValidation {
valid: boolean;
reason?: string;
}
Get-Content packages\shared\src\comboKeyTexasSolver.tsExit code: 0
Wall time: 0.4 seconds
Output:
const TEXAS_SOLVER_RANK_SCORES: Record<string, number> = {
A: 12,
K: 11,
Q: 10,
J: 9,
T: 8,
'9': 7,
'8': 6,
'7': 5,
'6': 4,
'5': 3,
'4': 2,
'3': 1,
'2': 0,
};
const TEXAS_SOLVER_SUIT_SCORES: Record<string, number> = {
c: 0,
d: 1,
h: 2,
s: 3,
};
function normalizeTexasSolverRank(value: string): string | null {
const trimmed = value.trim().toUpperCase();
if (!trimmed) {
return null;
}
const rank = trimmed === '10' ? 'T' : trimmed;
return Object.prototype.hasOwnProperty.call(TEXAS_SOLVER_RANK_SCORES, rank)
? rank
: null;
}
function normalizeTexasSolverSuit(value: string): 'c' | 'd' | 'h' | 's' | null {
const trimmed = value.trim().toLowerCase();
if (!trimmed) {
return null;
}
return trimmed === 'c' || trimmed === 'd' || trimmed === 'h' || trimmed === 's'
? trimmed
: null;
}
function compareTexasSolverCardsDescending(left: string, right: string): number {
const leftRank = TEXAS_SOLVER_RANK_SCORES[left[0] ?? ''] ?? -1;
const rightRank = TEXAS_SOLVER_RANK_SCORES[right[0] ?? ''] ?? -1;
if (leftRank !== rightRank) {
return rightRank - leftRank;
}
const leftSuit = TEXAS_SOLVER_SUIT_SCORES[left[1] ?? ''] ?? -1;
const rightSuit = TEXAS_SOLVER_SUIT_SCORES[right[1] ?? ''] ?? -1;
if (leftSuit !== rightSuit) {
return rightSuit - leftSuit;
}
return right.localeCompare(left);
}
export function toTexasSolverCardToken(raw: string): string | null {
if (typeof raw !== 'string') {
return null;
}
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const match = trimmed.match(/^((?:10|[2-9TJQKA]))([cdhs])$/i);
if (!match) {
return null;
}
const rank = normalizeTexasSolverRank(match[1]);
const suit = normalizeTexasSolverSuit(match[2]);
if (!rank || !suit) {
return null;
}
return `${rank}${suit}`;
}
function extractTexasSolverCardTokens(raw: string): string[] {
const condensed = raw.replace(/[\s,_-]+/g, '');
const matches = condensed.match(/(?:10|[2-9TJQKA])[cdhs]/gi);
return matches ?? [];
}
export function toTexasSolverComboKey(raw: string): string | null {
if (typeof raw !== 'string') {
return null;
}
const cards = extractTexasSolverCardTokens(raw)
.map(toTexasSolverCardToken)
.filter((card): card is string => Boolean(card));
if (cards.length !== 2 || cards[0] === cards[1]) {
return null;
}
const sorted = [...cards].sort(compareTexasSolverCardsDescending);
return `${sorted[0]}${sorted[1]}`;
}
export function toTexasSolverComboKeyFromCards(first: string, second: string): string | null {
const firstCard = toTexasSolverCardToken(first);
const secondCard = toTexasSolverCardToken(second);
if (!firstCard || !secondCard || firstCard === secondCard) {
return null;
}
const sorted = [firstCard, secondCard].sort(compareTexasSolverCardsDescending);
return `${sorted[0]}${sorted[1]}`;
}
Get-Content apps\solver-service\src\solverNormalization.tsExit code: 0
Wall time: 0.5 seconds
Output:
import {
toCanonicalBetKey,
toTexasSolverComboKey,
toTexasSolverComboKeyFromCards,
} from '@poker/shared';
export type NormalizedPolicy = Record<string, number>;
export type NormalizedResult = {
policy: NormalizedPolicy;
comboPolicies?: Record<string, NormalizedPolicy>;
actionEvs?: Record<string, number>;
nodeEv?: number;
heroComboKey?: string | null;
heroComboPolicy?: NormalizedPolicy;
heroComboFailureReason?:
| 'missing_combo_map_in_solver_output'
| 'hero_key_not_in_combo_map'
| 'hero_not_in_range_template'
| 'hero_combo_unavailable'
| null;
};
const HERO_COMBO_UNAVAILABLE = 'hero_combo_unavailable';
const HERO_COMBO_MAP_MISSING = 'missing_combo_map_in_solver_output';
const HERO_COMBO_KEY_MISSING = 'hero_key_not_in_combo_map';
export type SolverNodePolicyShape = {
nodeStrategyPresent: boolean;
nodeNestedStrategyMapPresent: boolean;
comboPolicyKeyCount: number;
comboPolicyKeysSample: string[];
};
export function normalizeSolverOutput(
raw: unknown,
potChips: number,
effectiveStack: number
): NormalizedResult | null {
const root = extractRootStrategy(raw);
if (!root) return null;
if (!Number.isFinite(potChips) || potChips <= 0) return null;
const { actions, strategy } = root;
if (!actions.length) return null;
const responseNode = isResponseNode(actions);
const actionKeys = actions.map((action) =>
normalizeActionLabel(action, potChips, effectiveStack, responseNode)
);
const comboExtraction = buildComboPolicies(strategy, actions.length, actionKeys);
const { comboPolicies, totals, samples } = comboExtraction;
if (samples === 0) return null;
const policy = mapActionWeightsToPolicy(totals, samples, actionKeys);
const normalizedPolicy = normalizePolicy(policy);
if (Object.keys(normalizedPolicy).length === 0) return null;
const actionEvs = extractActionEvs(raw, actions, potChips, effectiveStack, responseNode);
const baseResult: NormalizedResult = {
policy: normalizedPolicy,
...(Object.keys(comboPolicies).length > 0 ? { comboPolicies } : {}),
};
return actionEvs ? { ...baseResult, actionEvs } : baseResult;
}
function mapActionWeightsToPolicy(
totals: number[],
sampleCount: number,
actionKeys: Array<string | null>
): NormalizedPolicy {
const policy: NormalizedPolicy = {};
if (!Number.isFinite(sampleCount) || sampleCount <= 0) {
return policy;
}
for (let i = 0; i < totals.length; i += 1) {
const key = actionKeys[i];
if (!key) continue;
const avg = totals[i] / sampleCount;
if (!Number.isFinite(avg) || avg <= 0) continue;
policy[key] = (policy[key] ?? 0) + avg;
}
return policy;
}
function buildComboPolicies(
strategy: Record<string, unknown>,
actionCount: number,
actionKeys: Array<string | null>
): {
comboPolicies: Record<string, NormalizedPolicy>;
totals: number[];
samples: number;
} {
const comboPolicies: Record<string, NormalizedPolicy> = {};
const totals = new Array(actionCount).fill(0);
let samples = 0;
for (const [rawComboKey, value] of Object.entries(strategy)) {
if (!Array.isArray(value) || value.length !== actionCount) {
continue;
}
let valid = true;
for (let i = 0; i < value.length; i += 1) {
const entry = value[i];
if (typeof entry !== 'number' || !Number.isFinite(entry)) {
valid = false;
break;
}
}
if (!valid) {
continue;
}
for (let i = 0; i < value.length; i += 1) {
totals[i] += value[i] as number;
}
samples += 1;
const comboKey = toTexasSolverComboKey(rawComboKey);
if (!comboKey) {
continue;
}
const policy = normalizePolicy(mapActionWeightsToPolicy(value as number[], 1, actionKeys));
if (Object.keys(policy).length === 0) {
continue;
}
comboPolicies[comboKey] = policy;
}
return {
comboPolicies,
totals,
samples,
};
}
function extractRootStrategy(raw: unknown): {
actions: string[];
strategy: Record<string, unknown>;
} | null {
const root = findStrategyRoot(raw);
if (!root) return null;
return readStrategyEnvelope(root);
}
function findStrategyRoot(raw: unknown): Record<string, unknown> | null {
if (!isRecord(raw)) return null;
if (readStrategyEnvelope(raw)) {
return raw;
}
const candidates = ['root', 'tree', 'result', 'solution', 'data'];
for (const key of candidates) {
const candidate = raw[key];
if (!isRecord(candidate)) continue;
if (readStrategyEnvelope(candidate)) {
return candidate as Record<string, unknown>;
}
}
return null;
}
function readStrategyEnvelope(value: Record<string, unknown>): {
actions: string[];
strategy: Record<string, unknown>;
} | null {
const actions =
readActionList(value.actions) ?? readActionListFromContainer(value.strategy);
const strategy = readStrategyMap(value.strategy);
if (!actions || !strategy) {
return null;
}
return { actions, strategy };
}
function readActionList(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
if (!value.every((entry) => typeof entry === 'string')) return null;
return value as string[];
}
function readActionListFromContainer(value: unknown): string[] | null {
if (!isRecord(value)) return null;
return readActionList(value.actions);
}
function readStrategyMap(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
if (isRecord(value.strategy)) {
return value.strategy as Record<string, unknown>;
}
if (Array.isArray(value.actions)) {
return null;
}
return value as Record<string, unknown>;
}
export function inspectSolverNodePolicyShape(raw: unknown): SolverNodePolicyShape {
const node = isRecord(raw) ? raw : null;
const strategyValue = node?.strategy;
const nodeStrategyPresent = isRecord(strategyValue);
const nodeNestedStrategyMapPresent =
nodeStrategyPresent && isRecord((strategyValue as Record<string, unknown>).strategy);
const envelope = node ? readStrategyEnvelope(node) : null;
const strategyMap =
envelope?.strategy ?? (nodeStrategyPresent ? readStrategyMap(strategyValue) : null);
const comboPolicyKeys = strategyMap ? Object.keys(strategyMap) : [];
return {
nodeStrategyPresent,
nodeNestedStrategyMapPresent,
comboPolicyKeyCount: comboPolicyKeys.length,
comboPolicyKeysSample: comboPolicyKeys.slice(0, 20),
};
}
function extractActionEvs(
raw: unknown,
actions: string[],
potChips: number,
effectiveStack: number,
responseNode: boolean
): Record<string, number> | undefined {
if (!isRecord(raw)) return undefined;
const direct =
readActionEvsFromValue(raw.actionEvs, actions, potChips, effectiveStack, responseNode) ??
readActionEvsFromValue(raw.action_evs, actions, potChips, effectiveStack, responseNode);
if (direct) return direct;
const strategy = raw.strategy;
if (!isRecord(strategy)) return undefined;
return (
readActionEvsFromValue(
(strategy as Record<string, unknown>).actionEvs,
actions,
potChips,
effectiveStack,
responseNode
) ??
readActionEvsFromValue(
(strategy as Record<string, unknown>).action_evs,
actions,
potChips,
effectiveStack,
responseNode
) ??
readActionEvsFromValue(
(strategy as Record<string, unknown>).evs,
actions,
potChips,
effectiveStack,
responseNode
)
);
}
function readActionEvsFromValue(
value: unknown,
actions: string[],
potChips: number,
effectiveStack: number,
responseNode: boolean
): Record<string, number> | undefined {
if (Array.isArray(value)) {
return mapActionEvsFromArray(value, actions, potChips, effectiveStack, responseNode);
}
if (isRecord(value)) {
return mapActionEvsFromRecord(value, potChips, effectiveStack, responseNode);
}
return undefined;
}
function mapActionEvsFromArray(
values: unknown[],
actions: string[],
potChips: number,
effectiveStack: number,
responseNode: boolean
): Record<string, number> | undefined {
if (!actions.length || values.length !== actions.length) return undefined;
const actionEvs: Record<string, number> = {};
for (let i = 0; i < actions.length; i += 1) {
const ev = values[i];
if (typeof ev !== 'number' || !Number.isFinite(ev)) continue;
const key = normalizeActionKey(actions[i], potChips, effectiveStack, responseNode);
if (!key) continue;
actionEvs[key] = ev;
}
return Object.keys(actionEvs).length ? actionEvs : undefined;
}
function mapActionEvsFromRecord(
record: Record<string, unknown>,
potChips: number,
effectiveStack: number,
responseNode: boolean
): Record<string, number> | undefined {
const actionEvs: Record<string, number> = {};
for (const [rawKey, value] of Object.entries(record)) {
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
const key = normalizeActionKey(rawKey, potChips, effectiveStack, responseNode);
if (!key) continue;
actionEvs[key] = value;
}
return Object.keys(actionEvs).length ? actionEvs : undefined;
}
function normalizeActionKey(
rawKey: string,
potChips: number,
effectiveStack: number,
responseNode: boolean
): string | null {
const trimmed = rawKey.trim();
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
if (lower === 'check' || lower === 'call' || lower === 'fold') return lower;
if (lower === 'allin' || lower === 'all_in' || lower === 'all-in') {
return responseNode ? 'raise:allin' : 'bet:allin';
}
if (lower.startsWith('bet:') || lower.startsWith('raise:')) return lower;
return normalizeActionLabel(trimmed, potChips, effectiveStack, responseNode);
}
function normalizeActionLabel(
action: string,
potChips: number,
effectiveStack: number,
responseNode: boolean
): string | null {
const trimmed = action.trim();
if (!trimmed) return null;
const upper = trimmed.toUpperCase();
if (upper === 'CHECK') return 'check';
if (upper === 'CALL') return 'call';
if (upper === 'FOLD') return 'fold';
const sizedMatch = upper.match(/^(BET|RAISE|ALLIN)\s+([0-9.]+)$/);
if (!sizedMatch) return null;
const amountChips = Number(sizedMatch[2]);
if (!Number.isFinite(amountChips)) return null;
const labelKind = sizedMatch[1];
const shouldRaise =
labelKind === 'RAISE' || (labelKind === 'BET' && responseNode);
const raisePrefix = shouldRaise ? 'raise' : 'bet';
const canonical = toCanonicalBetKey(amountChips, potChips, effectiveStack, {
tolerance: 0.05,
allInThreshold: 0.99,
});
if (canonical) return shouldRaise ? toRaiseKey(canonical) : canonical;
if (labelKind === 'ALLIN') return responseNode ? 'raise:allin' : 'bet:allin';
if (!Number.isFinite(potChips) || potChips <= 0) return null;
const pct = clampPotPercent(Math.round((amountChips * 100) / potChips));
return `${raisePrefix}:${pct}`;
}
function normalizePolicy(policy: NormalizedPolicy): NormalizedPolicy {
const entries = Object.entries(policy).filter(
([, value]) => Number.isFinite(value) && value > 0
);
const sum = entries.reduce((total, [, value]) => total + value, 0);
if (sum <= 0) return {};
return entries.reduce<NormalizedPolicy>((acc, [key, value]) => {
acc[key] = value / sum;
return acc;
}, {});
}
function clampPotPercent(value: number): number {
if (!Number.isFinite(value)) return 1;
return Math.max(1, Math.min(999, value));
}
function isResponseNode(actions: string[]): boolean {
const normalized = actions.map((action) => action.trim().toUpperCase());
const hasCall = normalized.includes('CALL');
const hasFold = normalized.includes('FOLD');
const hasCheck = normalized.includes('CHECK');
return (hasCall || hasFold) && !hasCheck;
}
function toRaiseKey(key: string): string {
return key.startsWith('bet:') ? `raise:${key.slice(4)}` : key;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function attachHeroComboPolicy(
normalized: NormalizedResult | null,
heroCards?: readonly [string, string] | readonly string[] | null
): NormalizedResult | null {
if (!normalized) return null;
const {
heroComboFailureReason: _previousFailureReason,
heroComboKey: _previousHeroComboKey,
heroComboPolicy: _previousHeroComboPolicy,
...base
} = normalized;
if (!Array.isArray(heroCards) || heroCards.length < 2) {
return base;
}
const first = heroCards[0];
const second = heroCards[1];
if (typeof first !== 'string' || typeof second !== 'string') {
return {
...base,
heroComboKey: null,
heroComboFailureReason: HERO_COMBO_UNAVAILABLE,
};
}
const heroComboKey = toTexasSolverComboKeyFromCards(first, second);
if (!heroComboKey) {
return {
...base,
heroComboKey: null,
heroComboFailureReason: HERO_COMBO_UNAVAILABLE,
};
}
const comboPolicyKeyCount = normalized.comboPolicies
? Object.keys(normalized.comboPolicies).length
: 0;
if (comboPolicyKeyCount === 0) {
return {
...base,
heroComboKey,
heroComboFailureReason: HERO_COMBO_MAP_MISSING,
};
}
const heroComboPolicy = normalized.comboPolicies?.[heroComboKey];
if (heroComboPolicy && Object.keys(heroComboPolicy).length > 0) {
return {
...base,
heroComboKey,
heroComboPolicy,
heroComboFailureReason: null,
};
}
return {
...base,
heroComboKey,
heroComboFailureReason: HERO_COMBO_KEY_MISSING,
};
}
Get-Content apps\api\src\workers\analysis-worker.logic.ts | Select-Object -Skip 4010 -First 500Exit code: 0
Wall time: 0.7 seconds
Output:
}
function normalizeCardTokenForComboLookup(value: unknown): string | null {
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed || trimmed === '??') return null;
return toCanonicalCardToken(trimmed);
}
if (!value || typeof value !== 'object') return null;
const candidate = value as { rank?: unknown; suit?: unknown };
if (typeof candidate.rank !== 'string' || typeof candidate.suit !== 'string') {
return null;
}
const compact = `${candidate.rank.trim()}${candidate.suit.trim()}`;
return toCanonicalCardToken(compact);
}
function readRawCardToken(value: unknown): string | null {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (!value || typeof value !== 'object') return null;
const candidate = value as { rank?: unknown; suit?: unknown };
if (typeof candidate.rank !== 'string' || typeof candidate.suit !== 'string') {
return null;
}
const compact = `${candidate.rank.trim()}${candidate.suit.trim()}`;
return compact.length > 0 ? compact : null;
}
type HeroCardLookupInfo = {
rawCards: [string, string] | null;
canonicalCards: [string, string] | null;
comboKey: string | null;
};
function buildHeroCardLookupInfo(first: unknown, second: unknown): HeroCardLookupInfo | null {
const firstRaw = readRawCardToken(first);
const secondRaw = readRawCardToken(second);
const firstCard = normalizeCardTokenForComboLookup(first);
const secondCard = normalizeCardTokenForComboLookup(second);
if (!firstCard || !secondCard) {
return null;
}
return {
rawCards: firstRaw && secondRaw ? [firstRaw, secondRaw] : null,
canonicalCards: [firstCard, secondCard],
comboKey: toTexasSolverComboKeyFromCards(firstCard, secondCard),
};
}
function extractHeroCardsFromEvents(
events: HandEvent[],
playerId: string,
): HeroCardLookupInfo {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (!event || event.type !== 'deal') {
continue;
}
const playerCardsValue = (event as { playerCards?: unknown }).playerCards;
if (!playerCardsValue || typeof playerCardsValue !== 'object') {
continue;
}
const playerCards = playerCardsValue as Record<string, unknown>;
const rawCards = playerCards[playerId];
if (!Array.isArray(rawCards) || rawCards.length < 2) {
continue;
}
const info = buildHeroCardLookupInfo(rawCards[0], rawCards[1]);
if (info?.comboKey) {
return info;
}
}
return {
rawCards: null,
canonicalCards: null,
comboKey: null,
};
}
function extractHeroCardsFromParticipants(
participants: unknown,
playerId: string,
): HeroCardLookupInfo {
if (!Array.isArray(participants)) {
return {
rawCards: null,
canonicalCards: null,
comboKey: null,
};
}
for (const participant of participants) {
if (!participant || typeof participant !== 'object') {
continue;
}
const typedParticipant = participant as {
playerId?: unknown;
holeCards?: unknown;
};
if (typedParticipant.playerId !== playerId || !Array.isArray(typedParticipant.holeCards)) {
continue;
}
const info = buildHeroCardLookupInfo(
typedParticipant.holeCards[0],
typedParticipant.holeCards[1],
);
if (info?.comboKey) {
return info;
}
}
return {
rawCards: null,
canonicalCards: null,
comboKey: null,
};
}
function extractHeroSeatFromParticipants(
participants: unknown,
playerId: string,
): number | null {
if (!Array.isArray(participants)) {
return null;
}
for (const participant of participants) {
if (!participant || typeof participant !== 'object') {
continue;
}
const typedParticipant = participant as {
playerId?: unknown;
seatNo?: unknown;
};
if (typedParticipant.playerId !== playerId) {
continue;
}
if (
typeof typedParticipant.seatNo === 'number' &&
Number.isFinite(typedParticipant.seatNo)
) {
return typedParticipant.seatNo;
}
}
return null;
}
function compareRangeClassCardsDescending(left: string, right: string): number {
const leftScore = RANGE_CLASS_RANK_SCORES[left[0] ?? ''] ?? -1;
const rightScore = RANGE_CLASS_RANK_SCORES[right[0] ?? ''] ?? -1;
if (leftScore !== rightScore) {
return rightScore - leftScore;
}
return right.localeCompare(left);
}
function toRangeClassToken(cards: readonly [string, string] | readonly string[] | null): string | null {
if (!Array.isArray(cards) || cards.length < 2) {
return null;
}
const first = normalizeCardTokenForComboLookup(cards[0]);
const second = normalizeCardTokenForComboLookup(cards[1]);
if (!first || !second || first === second) {
return null;
}
const sorted = [first, second].sort(compareRangeClassCardsDescending);
const firstRank = sorted[0]?.[0] ?? '';
const secondRank = sorted[1]?.[0] ?? '';
if (!firstRank || !secondRank) {
return null;
}
if (firstRank === secondRank) {
return `${firstRank}${secondRank}`;
}
const suitedSuffix = sorted[0]?.[1] === sorted[1]?.[1] ? 's' : 'o';
return `${firstRank}${secondRank}${suitedSuffix}`;
}
function rangeContainsClassToken(range: string, classToken: string): boolean {
return range
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.some((entry) => {
const [token] = entry.split(':', 1);
return token?.trim().toUpperCase() === classToken.toUpperCase();
});
}
function splitRangeEntries(range: string): string[] {
return range
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
function injectRangeClassToken(
range: string,
classToken: string,
): { range: string; beforeLen: number; afterLen: number; injected: boolean } {
const entries = splitRangeEntries(range);
const beforeLen = entries.length;
if (rangeContainsClassToken(range, classToken)) {
return {
range,
beforeLen,
afterLen: beforeLen,
injected: false,
};
}
const nextEntries = [...entries, `${classToken}:1`];
return {
range: nextEntries.join(','),
beforeLen,
afterLen: nextEntries.length,
injected: true,
};
}
function resolveHeroRangeSide(params: {
heroSeat: number | null;
buttonPosition: number | null | undefined;
}): 'ip' | 'oop' | null {
if (
typeof params.heroSeat !== 'number' ||
!Number.isFinite(params.heroSeat) ||
typeof params.buttonPosition !== 'number' ||
!Number.isFinite(params.buttonPosition)
) {
return null;
}
return params.heroSeat === params.buttonPosition ? 'ip' : 'oop';
}
function findSolverTreeRootForDebug(raw: unknown): Record<string, unknown> | null {
if (!isRecord(raw)) return null;
if (isRecord(raw.childrens) || isRecord(raw.children)) {
return raw;
}
const candidates = ['root', 'tree', 'result', 'solution', 'data'];
for (const key of candidates) {
const candidate = raw[key];
if (
isRecord(candidate) &&
(isRecord(candidate.childrens) || isRecord(candidate.children))
) {
return candidate as Record<string, unknown>;
}
}
return raw;
}
function readSolverChildrenForDebug(
node: Record<string, unknown>,
): Record<string, unknown> | null {
const children = node.childrens ?? node.children;
return isRecord(children) ? (children as Record<string, unknown>) : null;
}
function resolveSolverNodeForPath(raw: unknown, path: string[] | null | undefined): Record<string, unknown> | null {
const root = findSolverTreeRootForDebug(raw);
if (!root) return null;
if (!Array.isArray(path) || path.length === 0) {
return root;
}
let node: Record<string, unknown> = root;
for (const key of path) {
if (typeof key !== 'string' || !key.trim()) {
return null;
}
const children = readSolverChildrenForDebug(node);
if (!children || !isRecord(children[key])) {
return null;
}
node = children[key] as Record<string, unknown>;
}
return node;
}
function extractSolverComboKeys(
raw: unknown,
selectionPath: string[] | null | undefined,
): string[] {
const node = resolveSolverNodeForPath(raw, selectionPath);
if (!node || !isRecord(node.strategy)) return [];
const strategy = node.strategy as Record<string, unknown>;
if (!isRecord(strategy.strategy)) return [];
return Object.keys(strategy.strategy as Record<string, unknown>).filter(
(key) => typeof key === 'string' && key.trim().length > 0,
);
}
function readNormalizedComboPolicies(
normalized: SolverServiceResponse['normalized']
): Record<string, Record<string, number>> {
if (!normalized || !isRecord(normalized)) return {};
if (!isRecord(normalized.comboPolicies)) return {};
const result: Record<string, Record<string, number>> = {};
for (const [rawKey, rawPolicy] of Object.entries(normalized.comboPolicies as Record<string, unknown>)) {
if (!isRecord(rawPolicy)) continue;
const canonicalComboKey = toTexasSolverComboKey(rawKey);
if (!canonicalComboKey) continue;
const sanitized = sanitizePolicy(rawPolicy as Record<string, number>);
if (Object.keys(sanitized).length === 0) continue;
result[canonicalComboKey] = sanitized;
}
return result;
}
function readNormalizedHeroComboPolicy(
normalized: SolverServiceResponse['normalized'],
): {
policy: Record<string, number> | null;
heroComboKey: string | null;
failureReason: string | null;
} {
if (!normalized || !isRecord(normalized)) {
return {
policy: null,
heroComboKey: null,
failureReason: null,
};
}
const heroComboKey =
typeof normalized.heroComboKey === 'string'
? toTexasSolverComboKey(normalized.heroComboKey)
: normalized.heroComboKey === null
? null
: null;
const rawPolicy = normalized.heroComboPolicy;
const policy = isRecord(rawPolicy)
? sanitizePolicy(rawPolicy as Record<string, number>)
: null;
const failureReason =
typeof normalized.heroComboFailureReason === 'string' &&
normalized.heroComboFailureReason.trim().length > 0
? normalized.heroComboFailureReason.trim()
: normalized.heroComboFailureReason === null
? null
: null;
return {
policy: policy && Object.keys(policy).length > 0 ? policy : null,
heroComboKey,
failureReason,
};
}
function rewritePolicyForResponseNodeRaiseContext(
policy: Record<string, number> | null,
context: { potStart: number; toCall: number } | null,
): Record<string, number> | null {
if (!policy || !context) {
return policy;
}
const rewritten = rewriteRaisePolicyKeys({
policy,
potStart: context.potStart,
toCall: context.toCall,
}).policy;
return Object.keys(rewritten).length > 0 ? rewritten : null;
}
function lookupHeroComboPolicy(
normalized: SolverServiceResponse['normalized'],
heroComboKey: string | null,
): {
policy: Record<string, number> | null;
solverComboKeys: string[];
lookupHit: boolean;
} {
const comboPolicies = readNormalizedComboPolicies(normalized);
const solverComboKeys = Object.keys(comboPolicies);
if (!heroComboKey) {
return {
policy: null,
solverComboKeys,
lookupHit: false,
};
}
const canonicalHeroCombo = toTexasSolverComboKey(heroComboKey);
if (!canonicalHeroCombo) {
return {
policy: null,
solverComboKeys,
lookupHit: false,
};
}
const policy = comboPolicies[canonicalHeroCombo] ?? null;
return {
policy,
solverComboKeys,
lookupHit: Boolean(policy),
};
}
function resolveBoardForHandReportScope(
events: Array<{ type: string; payload: unknown }>,
scope: HandReportScopeValue,
): string[] | null {
const targetStreet = scope;
const expectedBoardLen = targetStreet === 'FLOP' ? 3 : targetStreet === 'TURN' ? 4 : 5;
let board: string[] = [];
for (const event of events) {
if (event.type !== 'street' || !event.payload || typeof event.payload !== 'object') {
continue;
}
const payload = event.payload as { street?: unknown; board?: unknown };
if (normalizeHandReportScopeStreet(payload.street) !== targetStreet) {
continue;
}
const normalized = normalizeHandReportBoardCards(payload.board);
if (normalized.length > 0) {
board = normalized;
}
}
if (board.length !== expectedBoardLen) {
return null;
}
return board;
}
function normalizeHandReportSolverDistribution(value: unknown): Record<string, number> | null {
if (!value || typeof value !== 'object') {
return null;
}
const entries = Object.entries(value as Record<string, unknown>).filter(
([key, freq]) =>
typeof key === 'string' &&
key.trim().length > 0 &&
typeof freq === 'number' &&
Number.isFinite(freq) &&
freq >= 0,
);
if (entries.length === 0) {
return null;
}
return (entries as Array<[string, number]>).reduce<Record<string, number>>((acc, [key, freq]) => {
acc[key] = freq;
return acc;
}, {});
}
function buildHandReportSolverReferenceRequest(params: {
scope: HandReportScopeValue;
events: Array<{ type: string; payload: unknown }>;
decisions: Array<{
id: string;
street: string;
action: string;
amount: number | null;
potBefore: number | null;
toCall: number | null;
playerId: string;
}>;
heroPlayerId: string | null;
}): { decisionId: string; request: SolverServiceRequest } | null {
const solverStreet = HAND_REPORT_SCOPE_TO_SOLVER_STREET[params.scope];
if (!solverStreet) {
return null;
}
const scopedDecisions = params.decisions.filter((decision) => {
if (normalizeHandReportScopeStreet(decision.street) !== params.scope) {
return false;
}
if (params.heroPlayerId && decision.playerId !== params.heroPlayerId) {
return false;
}
return true;
});
const candidate =
scopedDecisions[0] ??
params.decisions.find((decision) => normalizeHandReportScopeStreet(decision.street) === params.scope) ??
null;
if (!candidate) {
return null;
}
const board = resolveBoardForHandReportScope(params.events, params.scope);
if (!board) {
return null;
}
const pot = isPositiveFinite(candidate.potBefore) ? candidate.potBefore : 30;
const effectiveStack = Math.max(20, Math.round(pot * 6));
const betSizes = cloneStreetSizes(DEFAULT_BET_SIZES_POT);
const raiseSizes = cloneStreetSizes(DEFAULT_RAISE_SIZES_POT);
if (solverStreet === 'flop') {
betSizes.flop = [...DEFAULT_FLOP_BET_SIZES_POT];
raiseSizes.flop = [...DEFAULT_FLOP_RAISE_SIZES_POT];
}
const maxIteration =
solverStreet === 'flop'
? Math.max(1, Math.min(SOLVER_MAX_ITERATION, SOLVER_FLOP_MAX_ITERATION))
: SOLVER_MAX_ITERATION;
const timeoutMs =
solverStreet === 'flop'
? Math.min(HAND_REPORT_SOLVER_TIMEOUT_MS, SOLVER_FLOP_TARGET_MS)
: HAND_REPORT_SOLVER_TIMEOUT_MS;
return {
decisionId: candidate.id,
Get-Content apps\api\src\workers\analysis-worker.logic.ts | Select-Object -Skip 6420 -First 180Exit code: 0
Wall time: 0.6 seconds
Output:
}
solverCompletedSuccessfully = true;
logMemorySnapshot('after solver call', {
handId,
decisionId,
requestHash: solverResponse.requestHash,
});
const normalizedPolicyKeyCount = Object.keys(normalizedPolicy).length;
if (ANALYSIS_VERBOSE_TERMINAL_LOGS) {
console.log('[ANALYSIS] solver request summary', {
decisionId,
requestHash: solverResponse.requestHash,
pot: solverRequest.pot,
effectiveStack: solverRequest.effectiveStack,
realEffectiveStack: analysisMeta.realEffectiveStack,
stackCapped: analysisMeta.stackCapped,
raiseSizes: solverRequest.raiseSizes ?? null,
hasPolicy: Boolean(normalizedPolicy),
keyCount: normalizedPolicyKeyCount,
});
}
const solverNodePath =
Array.isArray(selectionMeta?.path)
? selectionMeta.path
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
: [];
const heroComboFromService = readNormalizedHeroComboPolicy(solverResponse.normalized);
const heroComboLookupKey = heroComboFromService.heroComboKey ?? heroCardInfo.comboKey;
let heroComboPolicy = heroComboFromService.policy;
let heroComboPolicySource: 'solver_service' | 'combo_policies_lookup' | null =
heroComboPolicy ? 'solver_service' : null;
const fallbackHeroComboLookup = heroComboPolicy
? null
: lookupHeroComboPolicy(
solverResponse.normalized,
heroComboLookupKey,
);
if (!heroComboPolicy && fallbackHeroComboLookup?.policy) {
heroComboPolicy = fallbackHeroComboLookup.policy;
heroComboPolicySource = 'combo_policies_lookup';
}
const normalizedComboPolicies = readNormalizedComboPolicies(solverResponse.normalized);
const normalizedComboKeys = Object.keys(normalizedComboPolicies);
const rawSolverComboKeys = extractSolverComboKeys(solverResponse.raw, solverNodePath);
const canonicalRawSolverComboKeys = Array.from(
new Set(
rawSolverComboKeys
.map((key) => toTexasSolverComboKey(key))
.filter((key): key is string => Boolean(key))
)
);
const solverComboKeys =
normalizedComboKeys.length > 0
? normalizedComboKeys
: canonicalRawSolverComboKeys;
const solverComboKeysSample = solverComboKeys.slice(0, 8);
const lookupHit =
Boolean(heroComboPolicy) ||
(typeof heroComboLookupKey === 'string' && solverComboKeys.includes(heroComboLookupKey));
const heroComboFailureReason =
heroComboFromService.failureReason ??
(normalizedComboKeys.length === 0
? HERO_COMBO_MAP_MISSING_REASON
: heroComboLookupKey
? HERO_COMBO_KEY_MISSING_REASON
: normalizeSolverServiceErrorCode(solverResponse.errorCode)) ??
HERO_COMBO_UNAVAILABLE_ERROR_CODE;
if (ANALYSIS_VERBOSE_TERMINAL_LOGS) {
console.log('[ANALYSIS] hero combo policy', {
decisionId,
requestHash: solverResponse.requestHash,
heroComboLookupKey,
heroComboPolicySource,
heroComboPolicyPresent: Boolean(heroComboPolicy),
solverComboKeyCount: solverComboKeys.length,
solverComboKeysSample,
});
}
if (!heroComboPolicy) {
analysisMeta.recommendationSource = null;
analysisMeta.heroComboFailureReason = heroComboFailureReason;
analysisMeta.solverNodePath = solverNodePath.length > 0 ? solverNodePath : null;
analysisMeta.heroComboLookupKey = heroComboLookupKey;
analysisMeta.solverComboKeysSample = solverComboKeysSample;
analysisMeta.lookupHit = lookupHit;
analysisMeta.playerPerspective = 'action_history_selected_node';
solverRunStatus.solverAttempted = true;
solverRunStatus.solverError = HERO_COMBO_UNAVAILABLE_ERROR_CODE;
solverRunStatus.solverErrorCode = HERO_COMBO_UNAVAILABLE_ERROR_CODE;
await pushDecisionDebug({
level: 'warn',
scope: solverStreet.toUpperCase(),
message: 'Hero combo policy unavailable',
data: {
decisionId,
scope: solverStreet.toUpperCase(),
actingSeat,
heroSeat,
buttonPosition,
heroIsIp,
heroCardsRaw: heroCardInfo.rawCards,
heroCards: heroCardInfo.canonicalCards,
heroComboLookupKey,
solverNodePath: solverNodePath.length > 0 ? solverNodePath : null,
solverComboKeysSample,
lookupHit,
heroComboPolicySource,
recommendationSource: null,
failureReason: heroComboFailureReason,
},
});
await persistDecisionStage({
pct: 100,
stage: 'solver_failed',
detail: heroComboFailureReason,
status: 'solver_failed',
errorMessage: HERO_COMBO_UNAVAILABLE_ERROR_CODE,
});
shouldFinalizeRun = true;
return {
analysisId: null,
status: 'solver_failed',
};
}
const responseNodeRaiseContext =
isPositiveFinite(decisionPotAtStreetStart) && isPositiveFinite(decisionToCall)
? {
potStart: decisionPotAtStreetStart,
toCall: decisionToCall,
}
: null;
heroComboPolicy = rewritePolicyForResponseNodeRaiseContext(
heroComboPolicy,
responseNodeRaiseContext,
);
if (!heroComboPolicy) {
throw new Error('Hero combo policy missing after response-node raise rewrite');
}
const canonicalRecommendedAction = pickRecommendedAction(heroComboPolicy);
const chosenProb = decisionPolicyKey ? heroComboPolicy[decisionPolicyKey] : undefined;
// Canonical analysis verdict is frequency-based only:
// - `optimal` when chosen action is top-frequency or >=50% mixed.
// - otherwise `suboptimal`.
// Solver EV data is not used for this verdict, and `evDifference` remains null.
const status =
decisionPolicyKey &&
(decisionPolicyKey === canonicalRecommendedAction || (chosenProb ?? 0) >= 0.5)
? 'optimal'
: 'suboptimal';
const responseNodeByToCall =
typeof decisionToCall === 'number' && Number.isFinite(decisionToCall) && decisionToCall > 0;
const responseNodeByPolicy = isResponseNodePolicy(heroComboPolicy);
const isResponseNode = responseNodeByToCall || responseNodeByPolicy;
let displayPolicy = heroComboPolicy;
let displayActionKey: string | null = null;
let outputRecommendedAction = canonicalRecommendedAction;
const displaySizingKind = isResponseNode ? 'raise' : sizingActionKind;
const displaySizingBase =
displaySizingKind === 'raise'
? DEFAULT_RAISE_SIZES_POT[solverStreet] ?? DEFAULT_BET_SIZES_POT[solverStreet] ?? []
: displaySizingKind === 'bet'
? DEFAULT_BET_SIZES_POT[solverStreet] ?? []
: [];
const shouldResolveDisplaySizing = displaySizingKind === 'bet';
const displaySizingResolution =
shouldResolveDisplaySizing && isPositiveFinite(analysisMeta.actualActionFraction)
? resolveSizingKeys(
displaySizingBase,
analysisMeta.actualActionFraction,
SNAP_TOLERANCE,
displaySizingKind
)
: null;
const displayDecisionKey =
I need to see the actual failing hand data next, not just the status summary. If the hero combo truly isn’t in the solver map, the likely causes are either a bad hero combo lookup or an impossible hand state, so I’m pulling the stored hand/decision/event records for that exact failed hand.
rg -n "model Hand\b|model HandParticipant\b|model Decision\b|model HandEvent\b|holeCards|boardSummary|rawSolverOutput" apps/api/prisma/schema.prismaExit code: 0
Wall time: 0.2 seconds
Output:
92:model Hand {
119:model HandEvent {
133:model Decision {
164: rawSolverOutput Json?
213:model HandParticipant {
220: holeCards Json?
Get-Content apps\api\prisma\schema.prisma | Select-Object -Skip 180 -First 220Exit code: 0
Wall time: 0.3 seconds
Output:
errorMessage String?
cancelledAt DateTime?
cancelledReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
decision Decision @relation(fields: [decisionId], references: [id], onDelete: Cascade)
@@index([jobId])
@@map("analysis_statuses")
}
model HandAnalysis {
id String @id @default(cuid())
handId String
userId String
status String
requestHash String
summary String? @db.Text
inputMeta Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
hand Hand @relation(fields: [handId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([handId, userId, requestHash])
@@index([userId, createdAt])
@@index([handId, createdAt])
@@map("hand_analyses")
}
model HandParticipant {
id String @id @default(cuid())
handId String
userId String
playerId String?
seatNo Int
playerName String
holeCards Json?
netResult Int
createdAt DateTime @default(now())
hand Hand @relation(fields: [handId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([handId, userId])
@@index([userId, createdAt])
@@index([handId])
@@index([playerId])
@@index([handId, playerId])
@@map("hand_participants")
}
enum HandActionType {
SAVE
ANALYZE_HAND
}
enum HandActionStatus {
pending
completed
failed
}
enum HandReportScope {
PREFLOP
WHOLE_HAND
FLOP
TURN
RIVER
}
enum HandReportStatus {
queued
running
complete
failed
}
model HandAction {
id String @id @default(cuid())
handId String
roomId String
userId String
type HandActionType
status HandActionStatus @default(pending)
errorMessage String?
handIndex Int?
expectedDecisions Int @default(0)
completedDecisions Int @default(0)
failedDecisions Int @default(0)
overviewQueuedAt DateTime?
overviewCompletedAt DateTime?
processedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
hand Hand @relation(fields: [handId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([handId, userId, type])
@@index([userId, type, status, createdAt])
@@index([roomId, userId, type, status])
@@map("hand_actions")
}
model HandReport {
id String @id @default(cuid())
handId String
userId String
scope HandReportScope
runoutAware Boolean @default(true)
status HandReportStatus @default(queued)
errorMessage String?
contentJson Json?
solverDistribution Json?
jobMeta Json?
processedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
hand Hand @relation(fields: [handId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, handId, scope, runoutAware])
@@index([userId, runoutAware, status, updatedAt])
@@index([handId, userId, runoutAware, status])
@@map("hand_reports")
}
@'
const { PrismaClient } = require('./apps/api/node_modules/@prisma/client');
const prisma = new PrismaClient();
(async () => {
const handId = 'cmn713vqs00dzbv5krjhjz4et';
const hand = await prisma.hand.findUnique({
where: { id: handId },
select: {
id: true,
roomId: true,
isComplete: true,
finalPot: true,
boardSummary: true,
heroCards: true,
participants: { select: { userId: true, playerId: true, seatNo: true, holeCards: true } },
events: { orderBy: { sequence: 'asc' }, select: { sequence: true, type: true, payload: true } },
decisions: { orderBy: [{ timestamp: 'asc' }, { id: 'asc' }], select: { id: true, playerId: true, street: true, action: true, amount: true, potBefore: true, toCall: true, committedThisStreetBefore: true, handEventSeq: true, player: { select: { name: true } }, analyses: { orderBy: { createdAt: 'desc' }, select: { id: true, status: true, recommendedAction: true, gtoPolicy: true, rawSolverOutput: true, createdAt: true } } } },
},
});
console.log(JSON.stringify(hand, null, 2));
})().catch((err) => { console.error(err); process.exit(1); }).finally(async () => { await prisma.$disconnect(); });
'@ | nodeExit code: 1
Wall time: 0.4 seconds
Output:
PrismaClientValidationError:
Invalid `prisma.hand.findUnique()` invocation:
{
where: {
id: "cmn713vqs00dzbv5krjhjz4et"
},
select: {
id: true,
roomId: true,
isComplete: true,
finalPot: true,
boardSummary: true,
~~~~~~~~~~~~
heroCards: true,
participants: {
select: {
userId: true,
playerId: true,
seatNo: true,
holeCards: true
}
},
events: {
orderBy: {
sequence: "asc"
},
select: {
sequence: true,
type: true,
payload: true
}
},
decisions: {
orderBy: [
{
timestamp: "asc"
},
{
id: "asc"
}
],
select: {
id: true,
playerId: true,
street: true,
action: true,
amount: true,
potBefore: true,
toCall: true,
committedThisStreetBefore: true,
handEventSeq: true,
player: {
select: {
name: true
}
},
analyses: {
orderBy: {
createdAt: "desc"
},
select: {
id: true,
status: true,
recommendedAction: true,
gtoPolicy: true,
rawSolverOutput: true,
createdAt: true
}
}
}
},
? allowBots?: true,
? seed?: true,
? buttonPosition?: true,
? smallBlind?: true,
? bigBlind?: true,
? startedAt?: true,
? endedAt?: true,
? room?: true,
? handAnalyses?: true,
? handActions?: true,
? handReports?: true,
? _count?: true
}
}
Unknown field `boardSummary` for select statement on model `Hand`. Available options are marked with ?.
at Nn (E:\Desktop\Poker\node_modules\.pnpm\@prisma+client@6.17.1_prism_0f3e51022084b755e6e5bbd56889ea0f\node_modules\@prisma\client\runtime\library.js:29:1363)
at ei.handleRequestError (E:\Desktop\Poker\node_modules\.pnpm\@prisma+client@6.17.1_prism_0f3e51022084b755e6e5bbd56889ea0f\node_modules\@prisma\client\runtime\library.js:121:6911)
at ei.handleAndLogRequestError (E:\Desktop\Poker\node_modules\.pnpm\@prisma+client@6.17.1_prism_0f3e51022084b755e6e5bbd56889ea0f\node_modules\@prisma\client\runtime\library.js:121:6593)
at ei.request (E:\Desktop\Poker\node_modules\.pnpm\@prisma+client@6.17.1_prism_0f3e51022084b755e6e5bbd56889ea0f\node_modules\@prisma\client\runtime\library.js:121:6300)
at async a (E:\Desktop\Poker\node_modules\.pnpm\@prisma+client@6.17.1_prism_0f3e51022084b755e6e5bbd56889ea0f\node_modules\@prisma\client\runtime\library.js:130:9551)
at async [stdin]:5:16 {
clientVersion: '6.17.1'
}
Get-Content apps\api\prisma\schema.prisma | Select-Object -Skip 88 -First 90Exit code: 0
Wall time: 0.3 seconds
Output:
@@map("rooms")
}
model Hand {
id String @id @default(cuid())
roomId String?
allowBots Boolean @default(false)
seed String
buttonPosition Int
smallBlind Int
bigBlind Int
startedAt DateTime @default(now())
endedAt DateTime?
finalPot Int?
isComplete Boolean @default(false)
room Room? @relation(fields: [roomId], references: [id], onDelete: SetNull)
events HandEvent[]
decisions Decision[]
participants HandParticipant[]
handAnalyses HandAnalysis[]
handActions HandAction[]
handReports HandReport[]
@@index([roomId])
@@index([allowBots])
@@index([finalPot])
@@map("hands")
}
model HandEvent {
id String @id @default(cuid())
handId String
type String
payload Json
timestamp DateTime @default(now())
sequence Int
hand Hand @relation(fields: [handId], references: [id], onDelete: Cascade)
@@index([handId, sequence])
@@map("hand_events")
}
model Decision {
id String @id @default(cuid())
handId String
playerId String
street String
action String
amount Int?
potBefore Int?
toCall Int?
committedThisStreetBefore Int?
handEventSeq Int?
timestamp DateTime @default(now())
hand Hand @relation(fields: [handId], references: [id], onDelete: Cascade)
analyses Analysis[]
analysisStatus AnalysisStatus?
@@index([handId])
@@index([playerId])
@@map("decisions")
}
model Analysis {
id String @id @default(cuid())
decisionId String
status String
explanation String @db.Text
evDifference Float?
recommendedAction String
gtoPolicy Json
requestHash String?
rawSolverOutput Json?
createdAt DateTime @default(now())
decision Decision @relation(fields: [decisionId], references: [id], onDelete: Cascade)
@@index([decisionId])
@@map("analyses")
}
model AnalysisStatus {
id String @id @default(cuid())
decisionId String @unique
jobId String
status String
progress Int @default(0)
@'
const { PrismaClient } = require('./apps/api/node_modules/@prisma/client');
const prisma = new PrismaClient();
(async () => {
const handId = 'cmn713vqs00dzbv5krjhjz4et';
const hand = await prisma.hand.findUnique({
where: { id: handId },
select: {
id: true,
roomId: true,
isComplete: true,
finalPot: true,
participants: { select: { userId: true, playerId: true, seatNo: true, holeCards: true, playerName: true, netResult: true } },
events: { orderBy: { sequence: 'asc' }, select: { sequence: true, type: true, payload: true } },
decisions: { orderBy: [{ timestamp: 'asc' }, { id: 'asc' }], select: { id: true, playerId: true, street: true, action: true, amount: true, potBefore: true, toCall: true, committedThisStreetBefore: true, handEventSeq: true, analyses: { orderBy: { createdAt: 'desc' }, select: { id: true, status: true, recommendedAction: true, gtoPolicy: true, rawSolverOutput: true, createdAt: true } }, analysisStatus: { select: { status: true, stage: true, errorMessage: true, updatedAt: true } } } },
},
});
console.log(JSON.stringify(hand, null, 2));
})().catch((err) => { console.error(err); process.exit(1); }).finally(async () => { await prisma.$disconnect(); });
'@ | nodeExit code: 0
Wall time: 0.4 seconds
Output:
{
"id": "cmn713vqs00dzbv5krjhjz4et",
"roomId": "cmn713uq400dxbv5k9iot5hux",
"isComplete": true,
"finalPot": 66,
"participants": [
{
"userId": "cmlehgezn0000bvcwmrggvydb",
"playerId": "player_client_951d4550-d3c2-428d-abd7-1cca52a2836b",
"seatNo": 0,
"holeCards": [
"Qs",
"2h"
],
"playerName": "Playwright Hero",
"netResult": 33
}
],
"events": [
{
"sequence": 1,
"type": "post_blind",
"payload": {
"type": "post_blind",
"amount": 5,
"playerId": "player_client_951d4550-d3c2-428d-abd7-1cca52a2836b",
"isSmallBlind": true
}
},
{
"sequence": 2,
"type": "post_blind",
"payload": {
"type": "post_blind",
"amount": 10,
"playerId": "bot_1774502727532_9fal9",
"isSmallBlind": false
}
},
{
"sequence": 3,
"type": "deal",
"payload": {
"type": "deal",
"deckCards": [],
"playerCards": {
"bot_1774502727532_9fal9": [
{
"rank": "2",
"suit": "h"
},
{
"rank": "3",
"suit": "d"
}
],
"player_client_951d4550-d3c2-428d-abd7-1cca52a2836b": [
{
"rank": "2",
"suit": "h"
},
{
"rank": "3",
"suit": "d"
}
]
}
}
},
{
"sequence": 4,
"type": "street",
"payload": {
"type": "street",
"board": [],
"street": "preflop"
}
},
{
"sequence": 5,
"type": "action",
"payload": {
"type": "action",
"action": "call",
"amount": 10,
"playerId": "player_client_951d4550-d3c2-428d-abd7-1cca52a2836b",
"decisionId": "cmn713vxb00e9bv5ko5yi0iqa",
"handEventSeq": 5
}
},
{
"sequence": 6,
"type": "action",
"payload": {
"type": "action",
"action": "check",
"amount": 0,
"playerId": "bot_1774502727532_9fal9",
"decisionId": "cmn713wgi00edbv5kzny5dqsa",
"handEventSeq": 6
}
},
{
"sequence": 7,
"type": "street",
"payload": {
"type": "street",
"board": [
{
"rank": "J",
"suit": "s"
},
{
"rank": "Q",
"suit": "h"
},
{
"rank": "T",
"suit": "d"
}
],
"street": "flop"
}
},
{
"sequence": 8,
"type": "action",
"payload": {
"type": "action",
"action": "check",
"amount": 0,
"playerId": "bot_1774502727532_9fal9",
"decisionId": "cmn713xeu00ejbv5kdhu95o69",
"handEventSeq": 8
}
},
{
"sequence": 9,
"type": "action",
"payload": {
"type": "action",
"action": "check",
"amount": 0,
"playerId": "player_client_951d4550-d3c2-428d-abd7-1cca52a2836b",
"decisionId": "cmn713xj800enbv5kwsyzfeuq",
"handEventSeq": 9
}
},
{
"sequence": 10,
"type": "street",
"payload": {
"type": "street",
"board": [
{
"rank": "J",
"suit": "s"
},
{
"rank": "Q",
"suit": "h"
},
{
"rank": "T",
"suit": "d"
},
{
"rank": "6",
"suit": "s"
}
],
"street": "turn"
}
},
{
"sequence": 11,
"type": "action",
"payload": {
"type": "action",
"action": "bet",
"amount": 10,
"playerId": "bot_1774502727532_9fal9",
"decisionId": "cmn713yhh00etbv5kexd6e9iz",
"handEventSeq": 11
}
},
{
"sequence": 12,
"type": "action",
"payload": {
"type": "action",
"action": "call",
"amount": 10,
"playerId": "player_client_951d4550-d3c2-428d-abd7-1cca52a2836b",
"decisionId": "cmn713yqd00exbv5kww0ls8fc",
"handEventSeq": 12
}
},
{
"sequence": 13,
"type": "street",
"payload": {
"type": "street",
"board": [
{
"rank": "J",
"suit": "s"
},
{
"rank": "Q",
"suit": "h"
},
{
"rank": "T",
"suit": "d"
},
{
"rank": "6",
"suit": "s"
},
{
"rank": "4",
"suit": "s"
}
],
"street": "river"
}
},
{
"sequence": 14,
"type": "action",
"payload": {
"type": "action",
"action": "bet",
"amount": 13,
"playerId": "bot_1774502727532_9fal9",
"decisionId": "cmn713zxa00f7bv5ku418gm45",
"handEventSeq": 14
}
},
{
"sequence": 15,
"type": "action",
"payload": {
"type": "action",
"action": "call",
"amount": 13,
"playerId": "player_client_951d4550-d3c2-428d-abd7-1cca52a2836b",
"decisionId": "cmn71404600fbbv5koitdqvhb",
"handEventSeq": 15
}
},
{
"sequence": 16,
"type": "showdown",
"payload": {
"type": "showdown",
"playerHands": {}
}
},
{
"sequence": 17,
"type": "award",
"payload": {
"pots": [
{
"amount": 66,
"eligiblePlayers": [
"player_client_951d4550-d3c2-428d-abd7-1cca52a2836b",
"bot_1774502727532_9fal9"
]
}
],
"type": "award",
"winners": {
"player_client_951d4550-d3c2-428d-abd7-1cca52a2836b": 66
}
}
},
{
"sequence": 18,
"type": "hand_end",
"payload": {
"type": "hand_end",
"finalStacks": {
"bot_1774502727532_9fal9": 967,
"player_client_951d4550-d3c2-428d-abd7-1cca52a2836b": 233
}
}
},
{
"sequence": 19,
"type": "final_state",
"payload": {
"seats": [
{
"stack": 233,
"seatNo": 0,
"playerId": "player_client_951d4550-d3c2-428d-abd7-1cca52a2836b",
"playerName": "Playwright Hero"
},
{
"stack": 967,
"seatNo": 1,
"playerId": "bot_1774502727532_9fal9",
"playerName": "Bot 2"
},
{
"stack": 0,
"seatNo": 2,
"playerId": null,
"playerName": null
},
{
"stack": 0,
"seatNo": 3,
"playerId": null,
"playerName": null
},
{
"stack": 0,
"seatNo": 4,
"playerId": null,
"playerName": null
},
{
"stack": 0,
"seatNo": 5,
"playerId": null,
"playerName": null
},
{
"stack": 0,
"seatNo": 6,
"playerId": null,
"playerName": null
},
{
"stack": 0,
"seatNo": 7,
"playerId": null,
"playerName": null
},
{
"stack": 0,
"seatNo": 8,
"playerId": null,
"playerName": null
}
]
}
}
],
"decisions": [
{
"id": "cmn713vxb00e9bv5ko5yi0iqa",
"playerId": "player_client_951d4550-d3c2-428d-abd7-1cca52a2836b",
"street": "preflop",
"action": "call",
"amount": 5,
"potBefore": 15,
"toCall": 5,
"committedThisStreetBefore": 5,
"handEventSeq": 5,
"analyses": [
{
"id": "cmn7165m4001zbvc4edrlcag4",
"status": "unsupported",
"recommendedAction": "",
"gtoPolicy": {},
"rawSolverOutput": {
"meta": {
"solverError": "preflop_llm_only",
"solverMissing": true,
"solverEligible": false,
"solverExitCode": null,
"solverAttempted": false,
"solverErrorCode": null,
"explanationError": null,
"solverConfigured": true,
"explanationSource": "llm",
"solverStderrTailPreview": null,
"solverUnavailableReason": "preflop_llm_only"
},
"canonical": {
"board": [],
"combo": "Qs2h",
"state": "llm_only",
"version": 1,
"actualAction": {
"label": "CALL 5",
"amount": 5,
"freqPct": null,
"actionKey": null,
"frequency": null,
"rawAction": "call"
},
"explanationInput": {
"board": [],
"combo": "Qs2h",
"displayedPolicy": [],
"actualActionLabel": "CALL 5",
"recommendedActionLabel": null
},
"explanationState": {
"error": null,
"source": "llm",
"status": "ready"
},
"recommendedActionKey": null,
"recommendedActionLabel": null,
"displayedStrategyActions": []
},
"explanation": {
"rule": "Raise to 15 to apply pressure and take control of the pot.",
"reasons": [
"Raise to 15 instead of calling 5 to take advantage of the unopened pot.",
"With Qs2h in the SB, a raise from this position can pressure the BB and utilize your stack of 995 effectively.",
"The pot before your decision is 15, and calling 5 only adds a small amount to the pot, making it 20.",
"Calling 5 does not maximize your opportunity to win the pot compared to raising to 15, which would better leverage your position."
],
"verdict": "unknown"
}
},
"createdAt": "2026-03-26T05:27:13.901Z"
}
],
"analysisStatus": {
"status": "ready",
"stage": "complete",
"errorMessage": null,
"updatedAt": "2026-03-26T05:27:13.943Z"
}
},
{
"id": "cmn713wgi00edbv5kzny5dqsa",
"playerId": "bot_1774502727532_9fal9",
"street": "preflop",
"action": "check",
"amount": null,
"potBefore": 20,
"toCall": 0,
"committedThisStreetBefore": 10,
"handEventSeq": 6,
"analyses": [],
"analysisStatus": null
},
{
"id": "cmn713xeu00ejbv5kdhu95o69",
"playerId": "bot_1774502727532_9fal9",
"street": "flop",
"action": "check",
"amount": null,
"potBefore": 20,
"toCall": 0,
"committedThisStreetBefore": 0,
"handEventSeq": 8,
"analyses": [],
"analysisStatus": null
},
{
"id": "cmn713xj800enbv5kwsyzfeuq",
"playerId": "player_client_951d4550-d3c2-428d-abd7-1cca52a2836b",
"street": "flop",
"action": "check",
"amount": null,
"potBefore": 20,
"toCall": 0,
"committedThisStreetBefore": 0,
"handEventSeq": 9,
"analyses": [],
"analysisStatus": {
"status": "running",
"stage": "solver_failed",
"errorMessage": "hero_combo_unavailable",
"updatedAt": "2026-03-26T05:28:27.108Z"
}
},
{
"id": "cmn713yhh00etbv5kexd6e9iz",
"playerId": "bot_1774502727532_9fal9",
"street": "turn",
"action": "bet",
"amount": 10,
"potBefore": 20,
"toCall": 0,
"committedThisStreetBefore": 0,
"handEventSeq": 11,
"analyses": [],
"analysisStatus": null
},
{
"id": "cmn713yqd00exbv5kww0ls8fc",
"playerId": "player_client_951d4550-d3c2-428d-abd7-1cca52a2836b",
"street": "turn",
"action": "call",
"amount": 10,
"potBefore": 30,
"toCall": 10,
"committedThisStreetBefore": 0,
"handEventSeq": 12,
"analyses": [],
"analysisStatus": {
"status": "running",
"stage": "solver_failed",
"errorMessage": "hero_combo_unavailable",
"updatedAt": "2026-03-26T05:27:08.350Z"
}
},
{
"id": "cmn713zxa00f7bv5ku418gm45",
"playerId": "bot_1774502727532_9fal9",
"street": "river",
"action": "bet",
"amount": 13,
"potBefore": 40,
"toCall": 0,
"committedThisStreetBefore": 0,
"handEventSeq": 14,
"analyses": [],
"analysisStatus": null
},
{
"id": "cmn71404600fbbv5koitdqvhb",
"playerId": "player_client_951d4550-d3c2-428d-abd7-1cca52a2836b",
"street": "river",
"action": "call",
"amount": 13,
"potBefore": 53,
"toCall": 13,
"committedThisStreetBefore": 0,
"handEventSeq": 15,
"analyses": [],
"analysisStatus": {
"status": "running",
"stage": "calling_solver",
"errorMessage": null,
"updatedAt": "2026-03-26T05:28:48.297Z"
}
}
]
}
rg -n "extractHeroCardsFromEvents|extractHeroCardsFromParticipants|heroCardInfo =|Hero range class injected|hero_not_in_range_template|injectRangeClassToken|toRangeClassToken" apps/api/src/workers/analysis-worker.logic.tsExit code: 0
Wall time: 0.2 seconds
Output:
4063:function extractHeroCardsFromEvents(
4093:function extractHeroCardsFromParticipants(
4167:function toRangeClassToken(cards: readonly [string, string] | readonly string[] | null): string | null {
4207:function injectRangeClassToken(
5570: const heroCardInfoFromParticipants = extractHeroCardsFromParticipants(
5578: const heroCardInfoFromEvents = extractHeroCardsFromEvents(allEvents, decision.playerId);
5579: const heroCardInfo = heroCardInfoFromParticipants.comboKey
6063: const heroRangeClass = toRangeClassToken(heroCardInfo.canonicalCards);
6082: const injection = injectRangeClassToken(solverRequest.ipRange, heroRangeClass);
6091: message: 'Hero range class injected',
6105: const injection = injectRangeClassToken(solverRequest.oopRange, heroRangeClass);
6114: message: 'Hero range class injected',
Get-Content apps\api\src\workers\analysis-worker.logic.ts | Select-Object -Skip 5550 -First 90Exit code: 0
Wall time: 0.3 seconds
Output:
seed: decision.hand.seed,
timestamp: decision.hand.startedAt.getTime(),
players: metaPlayers,
smallBlind: decision.hand.smallBlind,
bigBlind: decision.hand.bigBlind,
buttonPosition: decision.hand.buttonPosition,
};
const handState = replayHand(meta, events);
await reportProgress(job, progressState, 15, 'started');
const decisionStreet = normalizeStreet(decision.street);
debugStreet = decisionStreet;
validateBoardLengthForStreet(handState.board, decisionStreet, { decisionId });
const solverStreet = toSolverStreet(decisionStreet);
const activePlayerCount = countActivePlayersAtDecision(handState);
const heroPlayerForExplanation = handState.players?.find((p: any) => p.id === decision.playerId);
const heroPosition = heroPlayerForExplanation?.position || 0;
const heroStack = heroPlayerForExplanation?.stack || 0;
const heroCardInfoFromParticipants = extractHeroCardsFromParticipants(
decision.hand?.participants,
decision.playerId,
);
const heroSeatFromParticipants = extractHeroSeatFromParticipants(
decision.hand?.participants,
decision.playerId,
);
const heroCardInfoFromEvents = extractHeroCardsFromEvents(allEvents, decision.playerId);
const heroCardInfo = heroCardInfoFromParticipants.comboKey
? heroCardInfoFromParticipants
: heroCardInfoFromEvents;
const heroSeat =
heroSeatFromParticipants !== null
? heroSeatFromParticipants
: typeof heroPlayerForExplanation?.position === 'number' &&
Number.isFinite(heroPlayerForExplanation.position)
? heroPlayerForExplanation.position
: null;
const actingSeat = heroSeat;
const currentPot = handState.currentPot || handState.meta?.bigBlind * 3 || 30;
const spr = heroStack > 0 && currentPot > 0 ? heroStack / currentPot : 10;
const boardText = handState.board?.map((card: any) => `${card.rank}${card.suit}`).join('') ?? '';
const heroHandText = heroCardInfo.canonicalCards?.join('') ?? null;
const promptActionHistory = buildPromptActionHistory(events);
const actionFacedSummary = buildActionFacedSummary(events, decision.playerId);
if (!solverStreet) {
solverRunStatus.solverEligible = false;
solverRunStatus.solverAttempted = false;
solverRunStatus.solverError = 'preflop_llm_only';
await persistDecisionStage({ pct: 95, stage: 'calling_llm', errorMessage: null });
const noSolverExplanationCtx: ExplanationContext = {
pos: heroPosition,
street: decisionStreet as 'preflop' | 'flop' | 'turn' | 'river',
board: boardText,
heroHand: heroHandText ?? undefined,
actionFaced: actionFacedSummary,
solverPolicy: {},
actualAction: decision.action,
spr,
potSize: currentPot,
heroStack,
potBefore: typeof decision.potBefore === 'number' ? decision.potBefore : null,
toCall: typeof decision.toCall === 'number' ? decision.toCall : null,
committedThisStreetBefore:
typeof decision.committedThisStreetBefore === 'number'
? decision.committedThisStreetBefore
: null,
};
const explanationOutput = await generateNoSolverDecisionExplanation({
fallbackVerdict: 'unknown',
ctx: noSolverExplanationCtx,
actionTakenLabel: formatActionAndAmount(
decision.action,
typeof decision.amount === 'number' ? decision.amount : null,
),
actionFaced: actionFacedSummary,
prompt: buildNoSolverDecisionPrompt({
decisionStreet,
boardText,
heroHand: heroHandText,
actionFaced: actionFacedSummary,
action: decision.action,
amount: typeof decision.amount === 'number' ? decision.amount : null,
potBefore: typeof decision.potBefore === 'number' ? decision.potBefore : null,
toCall: typeof decision.toCall === 'number' ? decision.toCall : null,
heroPosition,
heroStack,
spr,
actionHistory: promptActionHistory,
reason: 'Preflop is LLM-only in this pipeline. Provide practical coaching and a clear recommendation.',
Get-Content apps\api\src\workers\analysis-worker.logic.ts | Select-Object -Skip 6040 -First 100Exit code: 0
Wall time: 0.3 seconds
Output:
const analysisMeta = buildAnalysisMeta(solverRequestMeta);
analysisMeta.sizingMode = SOLVER_SIZING_MODE;
analysisMeta.actualActionKind = actualActionKind;
analysisMeta.actualActionAmount = decisionAmount;
analysisMeta.actualActionFraction = decisionSizing?.fractionForDisplay ?? null;
analysisMeta.potBefore = decisionPotBefore;
analysisMeta.toCall = decisionToCall;
analysisMeta.committedThisStreetBefore = decisionCommittedBefore;
analysisMeta.solverSizingAdjusted = decisionSizingAdjusted;
analysisMeta.solverSizingAdjustedReason = decisionSizingAdjusted
? 'sizing adjusted for solver'
: null;
analysisMeta.solverSizingOriginalFraction = decisionSizingRawFraction;
analysisMeta.solverSizingInjectedFraction = decisionSizingInjectedFraction;
analysisMeta.solverSizingMaxFraction = SOLVER_MAX_INJECTION_FRACTION;
applySolverStatusToMeta(analysisMeta, solverRunStatus);
const userActionKey =
resolveActualActionKey(actualActionKind, analysisMeta.actualActionFraction) ??
(decisionActionKind === 'all_in' || decisionActionKind === 'allin' ? 'all_in' : null);
analysisMeta.userActionKey = userActionKey;
analysisMeta.actualActionKey = userActionKey;
const heroRangeClass = toRangeClassToken(heroCardInfo.canonicalCards);
const buttonPosition =
typeof decision.hand?.buttonPosition === 'number' &&
Number.isFinite(decision.hand.buttonPosition)
? decision.hand.buttonPosition
: null;
const heroRangeSide = resolveHeroRangeSide({
heroSeat,
buttonPosition,
});
const heroIsIp =
heroRangeSide === 'ip' ? true : heroRangeSide === 'oop' ? false : null;
const heroInIpRange = heroRangeClass
? rangeContainsClassToken(solverRequest.ipRange, heroRangeClass)
: false;
const heroInOopRange = heroRangeClass
? rangeContainsClassToken(solverRequest.oopRange, heroRangeClass)
: false;
if (heroRangeClass && heroRangeSide === 'ip' && !heroInIpRange) {
const injection = injectRangeClassToken(solverRequest.ipRange, heroRangeClass);
if (injection.injected) {
solverRequest = {
...solverRequest,
ipRange: injection.range,
};
await pushDecisionDebug({
level: 'info',
scope: solverStreet.toUpperCase(),
message: 'Hero range class injected',
data: {
heroRangeClass,
heroSeat,
actingSeat,
buttonPosition,
heroIsIp,
injectedInto: 'ip',
beforeLen: injection.beforeLen,
afterLen: injection.afterLen,
},
});
}
} else if (heroRangeClass && heroRangeSide === 'oop' && !heroInOopRange) {
const injection = injectRangeClassToken(solverRequest.oopRange, heroRangeClass);
if (injection.injected) {
solverRequest = {
...solverRequest,
oopRange: injection.range,
};
await pushDecisionDebug({
level: 'info',
scope: solverStreet.toUpperCase(),
message: 'Hero range class injected',
data: {
heroRangeClass,
heroSeat,
actingSeat,
buttonPosition,
heroIsIp,
injectedInto: 'oop',
beforeLen: injection.beforeLen,
afterLen: injection.afterLen,
},
});
}
}
logMemorySnapshot('before solver call', {
handId,
decisionId,
stackCapped: analysisMeta.stackCapped,
});
let solverResponse: SolverServiceResponse | null = null;
let selectionMeta: SolverSelectionMeta | undefined;
let normalizedPolicy: Record<string, number> | null = null;
let decisionPolicyKey: string | null = null;
let decisionSnapped = false;
try {
const maxSolverAttempts = SOLVER_HTTP_408_RETRY_COUNT + 1;
let solverAttempt = 0;
@'
const { PrismaClient } = require('./apps/api/node_modules/@prisma/client');
const prisma = new PrismaClient();
(async () => {
const hand = await prisma.hand.findUnique({ where: { id: 'cmn713vqs00dzbv5krjhjz4et' }, select: { buttonPosition: true, smallBlind: true, bigBlind: true } });
console.log(JSON.stringify(hand, null, 2));
})().catch((err) => { console.error(err); process.exit(1); }).finally(async () => { await prisma.$disconnect(); });
'@ | nodeExit code: 0
Wall time: 0.3 seconds
Output:
{
"buttonPosition": 0,
"smallBlind": 5,
"bigBlind": 10
}
rg -n "heroCards:" apps/api/src/workers/analysis-worker.logic.tsExit code: 0
Wall time: 0.2 seconds
Output:
6038: ...(heroCardInfo.canonicalCards ? { heroCards: heroCardInfo.canonicalCards } : {}),
6524: heroCards: heroCardInfo.canonicalCards,
6737: heroCards: heroCardInfo.canonicalCards,
rg -n "model .*Debug|analysis_debug|DebugEvent" apps/api/prisma/schema.prisma apps/api/src/services/analysis-debug-events.tsExit code: 0
Wall time: 0.2 seconds
Output:
apps/api/src/services/analysis-debug-events.ts:3:export type DebugEventSource = 'api-worker' | 'solver-service' | 'api-status';
apps/api/src/services/analysis-debug-events.ts:4:export type DebugEventLevel = 'info' | 'warn' | 'error';
apps/api/src/services/analysis-debug-events.ts:6:export type DebugEvent = {
apps/api/src/services/analysis-debug-events.ts:8: source: DebugEventSource;
apps/api/src/services/analysis-debug-events.ts:9: level: DebugEventLevel;
apps/api/src/services/analysis-debug-events.ts:45:const inMemoryStore = new Map<string, DebugEvent[]>();
apps/api/src/services/analysis-debug-events.ts:73:function normalizeLevel(value: DebugEventLevel | null | undefined): DebugEventLevel {
apps/api/src/services/analysis-debug-events.ts:78:function normalizeSource(value: DebugEventSource): DebugEventSource {
apps/api/src/services/analysis-debug-events.ts:182: event: Pick<DebugEvent, 'scope' | 'data'>,
apps/api/src/services/analysis-debug-events.ts:207:function sanitizeDebugEventDataForClient(event: DebugEvent): Record<string, unknown> | undefined {
apps/api/src/services/analysis-debug-events.ts:296:export function sanitizeDebugEventForClient(event: DebugEvent): DebugEvent {
apps/api/src/services/analysis-debug-events.ts:297: const sanitized: DebugEvent = {
apps/api/src/services/analysis-debug-events.ts:300: const data = sanitizeDebugEventDataForClient(event);
apps/api/src/services/analysis-debug-events.ts:309:export function sanitizeDebugEventsForClient(events: DebugEvent[]): DebugEvent[] {
apps/api/src/services/analysis-debug-events.ts:310: return events.map((event) => sanitizeDebugEventForClient(event));
apps/api/src/services/analysis-debug-events.ts:337:}): DebugEvent | null {
apps/api/src/services/analysis-debug-events.ts:383:}): DebugEvent | null {
apps/api/src/services/analysis-debug-events.ts:403:function normalizeStoredEvent(value: unknown): DebugEvent | null {
apps/api/src/services/analysis-debug-events.ts:425: const normalized: DebugEvent = {
apps/api/src/services/analysis-debug-events.ts:465:async function appendEventToStore(key: string, event: DebugEvent, maxEvents: number): Promise<void> {
apps/api/src/services/analysis-debug-events.ts:504:async function readEventsFromStore(key: string, maxEvents: number): Promise<DebugEvent[]> {
apps/api/src/services/analysis-debug-events.ts:517: .filter((event): event is DebugEvent => Boolean(event));
apps/api/src/services/analysis-debug-events.ts:534:type BaseAppendDebugEventParams = {
apps/api/src/services/analysis-debug-events.ts:536: source: DebugEventSource;
apps/api/src/services/analysis-debug-events.ts:537: level?: DebugEventLevel;
apps/api/src/services/analysis-debug-events.ts:544: params: BaseAppendDebugEventParams & {
apps/api/src/services/analysis-debug-events.ts:548:): DebugEvent {
apps/api/src/services/analysis-debug-events.ts:549: const event: DebugEvent = {
apps/api/src/services/analysis-debug-events.ts:568:export async function appendDecisionDebugEvent(
apps/api/src/services/analysis-debug-events.ts:569: params: BaseAppendDebugEventParams & {
apps/api/src/services/analysis-debug-events.ts:591:export async function appendHandDebugEvent(
apps/api/src/services/analysis-debug-events.ts:592: params: BaseAppendDebugEventParams & {
apps/api/src/services/analysis-debug-events.ts:611:export async function getDecisionDebugEvents(decisionId: string): Promise<DebugEvent[]> {
apps/api/src/services/analysis-debug-events.ts:619:export async function getHandDebugEvents(handId: string): Promise<DebugEvent[]> {
apps/api/src/services/analysis-debug-events.ts:627:export async function getDecisionDebugEventsPreview(
apps/api/src/services/analysis-debug-events.ts:630:): Promise<DebugEvent[]> {
apps/api/src/services/analysis-debug-events.ts:631: const events = await getDecisionDebugEvents(decisionId);
apps/api/src/services/analysis-debug-events.ts:642:export async function getDecisionDebugEventsPreviewMap(
apps/api/src/services/analysis-debug-events.ts:645:): Promise<Map<string, DebugEvent[]>> {
apps/api/src/services/analysis-debug-events.ts:656: const events = await getDecisionDebugEventsPreview(decisionId, previewCount);
apps/api/src/services/analysis-debug-events.ts:664:export async function clearDebugEventsForTests(): Promise<void> {