From 32cc7095311035e7a138e97795068dce539f18df Mon Sep 17 00:00:00 2001 From: Hao Li Date: Mon, 26 Jan 2026 22:31:35 -0800 Subject: [PATCH 1/2] Further optimize prod image --- Dockerfile | 9 ++++++- server/session_store.py | 54 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 00f02fc..4cdc7bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,14 @@ FROM python:3.11-slim AS runtime WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + APP_ENV=production \ + SESSION_TTL_SECONDS=3600 \ + OMP_NUM_THREADS=1 \ + MKL_NUM_THREADS=1 \ + OPENBLAS_NUM_THREADS=1 \ + NUMEXPR_NUM_THREADS=1 \ + MALLOC_ARENA_MAX=2 COPY requirements.prod.txt . RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu torch==2.10.0 diff --git a/server/session_store.py b/server/session_store.py index 8d33d0b..84cb0be 100644 --- a/server/session_store.py +++ b/server/session_store.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio +import os +from datetime import timedelta from dataclasses import dataclass from datetime import datetime from typing import Callable, Dict, Optional, Protocol @@ -29,6 +31,17 @@ def choose_two_cards_from_hand(self, hand: list) -> list: ... except ImportError: # pragma: no cover - defensive for limited environments RLPlayer = None # type: ignore[assignment] +_rl_player_singleton: Optional[AIPlayerProtocol] = None + + +def _get_rl_player() -> AIPlayerProtocol: + global _rl_player_singleton + if RLPlayer is None: + raise ValueError("RL AI is not available") + if _rl_player_singleton is None: + _rl_player_singleton = RLPlayer() + return _rl_player_singleton + @dataclass class GameSession: @@ -49,6 +62,34 @@ class SessionStore: def __init__(self) -> None: self._sessions: Dict[str, GameSession] = {} self._lock: Optional[asyncio.Lock] = None + self._session_ttl = self._load_session_ttl() + + def _load_session_ttl(self) -> Optional[timedelta]: + env = os.getenv("APP_ENV", "").lower() + if env not in {"production", "prod"}: + return None + ttl_value = os.getenv("SESSION_TTL_SECONDS") + if not ttl_value: + return None + try: + seconds = int(ttl_value) + except ValueError: + return None + if seconds <= 0: + return None + return timedelta(seconds=seconds) + + async def _cleanup_expired_sessions(self) -> None: + if self._session_ttl is None: + return + cutoff = datetime.utcnow() - self._session_ttl + expired_ids = [ + session_id + for session_id, session in self._sessions.items() + if session.updated_at < cutoff + ] + for session_id in expired_ids: + self._sessions.pop(session_id, None) async def _get_lock(self) -> asyncio.Lock: if self._lock is None: @@ -66,6 +107,7 @@ async def create_session( """Create and store a new session.""" lock = await self._get_lock() async with lock: + await self._cleanup_expired_sessions() session_id = uuid4().hex ai_player = None if use_ai: @@ -76,9 +118,7 @@ async def create_session( raise ValueError("LLM AI is not available") ai_player = LLMPlayer() elif ai_type == "rl": - if RLPlayer is None: - raise ValueError("RL AI is not available") - ai_player = RLPlayer() + ai_player = _get_rl_player() else: raise ValueError(f"Unknown ai_type: {ai_type}") game = Game( @@ -103,16 +143,22 @@ async def get_session(self, session_id: str) -> Optional[GameSession]: """Fetch a session by id.""" lock = await self._get_lock() async with lock: - return self._sessions.get(session_id) + await self._cleanup_expired_sessions() + session = self._sessions.get(session_id) + if session is not None: + session.updated_at = datetime.utcnow() + return session async def delete_session(self, session_id: str) -> bool: """Delete a session by id.""" lock = await self._get_lock() async with lock: + await self._cleanup_expired_sessions() return self._sessions.pop(session_id, None) is not None async def session_count(self) -> int: """Return number of active sessions.""" lock = await self._get_lock() async with lock: + await self._cleanup_expired_sessions() return len(self._sessions) From 7d45fd0ee1f156b88799124c343fe136291154d9 Mon Sep 17 00:00:00 2001 From: Hao Li Date: Wed, 28 Jan 2026 22:46:55 -0800 Subject: [PATCH 2/2] Fix resolving four one off card selection dialog not showing --- server/app.py | 19 +-- web/src/App.tsx | 8 +- web/tests/e2e/app.spec.ts | 290 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+), 12 deletions(-) diff --git a/server/app.py b/server/app.py index 7656b89..dc96688 100644 --- a/server/app.py +++ b/server/app.py @@ -37,15 +37,16 @@ def _update_game_state(game: Game, turn_finished: bool) -> None: def _is_ai_turn(game: Game) -> bool: state = game.game_state - return ( - state.use_ai - and ( - (state.resolving_one_off and state.current_action_player == 1) - or (state.resolving_four and state.current_action_player == 1) - or (state.resolving_seven and state.current_action_player == 1) - or (not state.resolving_one_off and state.turn == 1) - ) - ) + if not state.use_ai: + return False + if ( + state.resolving_one_off + or state.resolving_three + or state.resolving_four + or state.resolving_seven + ): + return state.current_action_player == 1 + return state.turn == 1 async def _apply_action(session: GameSession, action: Action) -> None: diff --git a/web/src/App.tsx b/web/src/App.tsx index 7f142fc..f71cafe 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -41,6 +41,8 @@ function App() { () => legalActions.filter((action) => action.type === 'Discard From Hand'), [legalActions], ) + const hasHumanActions = (actions: ActionView[]) => + actions.some((action) => action.played_by === 0) const sevenActions = useMemo( () => legalActions.filter((action) => @@ -62,11 +64,11 @@ function App() { !state?.resolving_four && !state?.resolving_seven const discardModalActive = - Boolean(state?.resolving_three) && state?.current_action_player === 0 + Boolean(state?.resolving_three) && hasHumanActions(discardActions) const fourDiscardModalActive = - Boolean(state?.resolving_four) && state?.current_action_player === 0 + Boolean(state?.resolving_four) && hasHumanActions(fourDiscardActions) const sevenModalActive = - Boolean(state?.resolving_seven) && state?.current_action_player === 0 + Boolean(state?.resolving_seven) && state?.pending_seven_player === 0 const sevenCards = state?.pending_seven_cards ?? [] const sevenActionsByCard = useMemo(() => { const grouped = new Map() diff --git a/web/tests/e2e/app.spec.ts b/web/tests/e2e/app.spec.ts index afe56c8..de44774 100644 --- a/web/tests/e2e/app.spec.ts +++ b/web/tests/e2e/app.spec.ts @@ -722,6 +722,296 @@ test('discard selection flow for three', async ({ page }) => { await responsePromise }) +test('discard selection flow for four', async ({ page }) => { + await page.route('**/api/sessions', async (route) => { + const payload = { + session_id: 'four-discard-session', + state: { + hands: [ + [ + { + id: 'hand-1', + suit: 'DIAMONDS', + rank: 'TEN', + display: 'Ten of Diamonds', + played_by: 0, + purpose: null, + point_value: 10, + is_stolen: false, + attachments: [], + }, + { + id: 'hand-2', + suit: 'SPADES', + rank: 'NINE', + display: 'Nine of Spades', + played_by: 0, + purpose: null, + point_value: 9, + is_stolen: false, + attachments: [], + }, + ], + [], + ], + hand_counts: [2, 0], + fields: [[], []], + effective_fields: [[], []], + deck_count: 18, + discard_pile: [], + discard_count: 0, + scores: [0, 0], + targets: [21, 21], + turn: 1, + current_action_player: 1, + status: null, + resolving_two: false, + resolving_one_off: false, + resolving_three: false, + resolving_four: true, + pending_four_count: 2, + overall_turn: 3, + use_ai: true, + one_off_card_to_counter: null, + }, + legal_actions: [ + { + id: 0, + label: 'Discard Ten of Diamonds from hand', + type: 'Discard From Hand', + played_by: 0, + source: 'Hand', + requires_additional_input: false, + card: { + id: 'hand-1', + suit: 'DIAMONDS', + rank: 'TEN', + display: 'Ten of Diamonds', + played_by: 0, + purpose: null, + point_value: 10, + is_stolen: false, + attachments: [], + }, + target: null, + }, + { + id: 1, + label: 'Discard Nine of Spades from hand', + type: 'Discard From Hand', + played_by: 0, + source: 'Hand', + requires_additional_input: false, + card: { + id: 'hand-2', + suit: 'SPADES', + rank: 'NINE', + display: 'Nine of Spades', + played_by: 0, + purpose: null, + point_value: 9, + is_stolen: false, + attachments: [], + }, + target: null, + }, + ], + state_version: 0, + ai_thinking: false, + } + + await route.fulfill({ json: payload }) + }) + + await page.route('**/api/sessions/four-discard-session/history', async (route) => { + await route.fulfill({ json: { entries: [], turn_counter: 3 } }) + }) + + await page.route('**/api/sessions/four-discard-session', async (route) => { + const payload = { + session_id: 'four-discard-session', + state: { + hands: [ + [ + { + id: 'hand-1', + suit: 'DIAMONDS', + rank: 'TEN', + display: 'Ten of Diamonds', + played_by: 0, + purpose: null, + point_value: 10, + is_stolen: false, + attachments: [], + }, + { + id: 'hand-2', + suit: 'SPADES', + rank: 'NINE', + display: 'Nine of Spades', + played_by: 0, + purpose: null, + point_value: 9, + is_stolen: false, + attachments: [], + }, + ], + [], + ], + hand_counts: [2, 0], + fields: [[], []], + effective_fields: [[], []], + deck_count: 18, + discard_pile: [], + discard_count: 0, + scores: [0, 0], + targets: [21, 21], + turn: 1, + current_action_player: 1, + status: null, + resolving_two: false, + resolving_one_off: false, + resolving_three: false, + resolving_four: true, + pending_four_count: 2, + overall_turn: 3, + use_ai: true, + one_off_card_to_counter: null, + }, + legal_actions: [ + { + id: 0, + label: 'Discard Ten of Diamonds from hand', + type: 'Discard From Hand', + played_by: 0, + source: 'Hand', + requires_additional_input: false, + card: { + id: 'hand-1', + suit: 'DIAMONDS', + rank: 'TEN', + display: 'Ten of Diamonds', + played_by: 0, + purpose: null, + point_value: 10, + is_stolen: false, + attachments: [], + }, + target: null, + }, + { + id: 1, + label: 'Discard Nine of Spades from hand', + type: 'Discard From Hand', + played_by: 0, + source: 'Hand', + requires_additional_input: false, + card: { + id: 'hand-2', + suit: 'SPADES', + rank: 'NINE', + display: 'Nine of Spades', + played_by: 0, + purpose: null, + point_value: 9, + is_stolen: false, + attachments: [], + }, + target: null, + }, + ], + state_version: 0, + ai_thinking: false, + } + + await route.fulfill({ json: payload }) + }) + + await page.route('**/api/sessions/four-discard-session/actions', async (route) => { + const payload = { + state: { + hands: [ + [ + { + id: 'hand-2', + suit: 'SPADES', + rank: 'NINE', + display: 'Nine of Spades', + played_by: 0, + purpose: null, + point_value: 9, + is_stolen: false, + attachments: [], + }, + ], + [], + ], + hand_counts: [1, 0], + fields: [[], []], + effective_fields: [[], []], + deck_count: 18, + discard_pile: [ + { + id: 'hand-1', + suit: 'DIAMONDS', + rank: 'TEN', + display: 'Ten of Diamonds', + played_by: null, + purpose: null, + point_value: 10, + is_stolen: false, + attachments: [], + }, + ], + discard_count: 1, + scores: [0, 0], + targets: [21, 21], + turn: 1, + current_action_player: 1, + status: null, + resolving_two: false, + resolving_one_off: false, + resolving_three: false, + resolving_four: false, + pending_four_count: 0, + overall_turn: 3, + use_ai: true, + one_off_card_to_counter: null, + }, + legal_actions: [], + state_version: 1, + last_actions: [ + { + id: -1, + label: 'Discard Ten of Diamonds from hand', + type: 'Discard From Hand', + played_by: 0, + source: 'Hand', + requires_additional_input: false, + card: null, + target: null, + }, + ], + } + + await route.fulfill({ json: payload }) + }) + + await page.goto('/') + + await expect(page.getByText('Discard Cards')).toBeVisible() + await expect( + page.getByRole('button', { name: 'Ten of Diamonds' }), + ).toBeVisible() + + const responsePromise = page.waitForResponse( + '**/api/sessions/four-discard-session/actions', + ) + await page.getByRole('button', { name: 'Ten of Diamonds' }).click() + await page.getByRole('button', { name: 'Discard' }).click() + await responsePromise +}) + test('seven reveal modal flow', async ({ page }) => { await page.route('**/api/sessions', async (route) => { const payload = {