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 3406a6be799b87209c0b4adb2da04db5faa052cc Mon Sep 17 00:00:00 2001 From: Hao Li Date: Sat, 31 Jan 2026 20:11:45 -0800 Subject: [PATCH 2/2] Add moves description --- web/src/App.css | 29 +++++++++ web/src/App.tsx | 153 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 158 insertions(+), 24 deletions(-) diff --git a/web/src/App.css b/web/src/App.css index cdf4fb1..e1007ac 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -380,6 +380,12 @@ color: var(--ink-60); } +.action-desc-line { + font-size: 12px; + color: var(--ink-70); + line-height: 1.3; +} + .action-buttons { display: flex; gap: 10px; @@ -391,6 +397,29 @@ flex-wrap: wrap; } +.action-button { + display: grid; + gap: 4px; + text-align: left; + align-items: start; + padding: 10px 14px; + white-space: normal; + max-width: 260px; + border-radius: 16px; +} + +.action-button .action-label { + display: block; +} + +.action-button .action-desc { + font-size: 11px; + color: var(--ink-60); + text-transform: none; + letter-spacing: 0.2px; + line-height: 1.3; +} + .action-choice-row .selected-action { border-color: rgba(224, 164, 99, 0.9); background: rgba(224, 164, 99, 0.2); diff --git a/web/src/App.tsx b/web/src/App.tsx index 7f142fc..31348ce 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -334,6 +334,11 @@ function App() {
{selectedAction?.label ?? 'Select an action'}
+ {selectedAction && actionDescription(selectedAction) ? ( +
+ {actionDescription(selectedAction)} +
+ ) : null}
{state?.resolving_one_off ? 'Resolve or counter the one-off.' @@ -364,16 +369,14 @@ function App() {
{actionChoices.map((action) => ( - + onClick={() => handleActionSelect(action)} + /> ))}
@@ -522,17 +525,13 @@ function App() {
{(sevenActionsByCard.get(card.id) ?? []).map((action) => ( - + /> ))}
@@ -578,15 +577,13 @@ function App() {
{modalActions.map((action) => ( - + /> ))}
@@ -619,8 +616,33 @@ type CardTileProps = { onClick?: () => void } +type ActionButtonProps = { + action: ActionView + context: string + selected?: boolean + disabled?: boolean + onClick: () => void +} + const oneOffRanks = new Set(['ACE', 'THREE', 'FOUR', 'FIVE', 'SIX']) const faceRanks = new Set(['JACK', 'QUEEN', 'KING', 'EIGHT']) +const oneOffDescriptions: Record = { + ACE: 'Destroy all point cards on the field.', + TWO: 'Scrap a target royal or Glasses Eight.', + THREE: 'Take one card from the scrap pile.', + FOUR: 'Opponent discards two cards of their choice.', + FIVE: 'Discard one card, then draw up to three (max 8 in hand).', + SIX: 'Destroy all face cards (royals) on the field.', + SEVEN: 'Reveal the top two cards of the deck; choose one to play.', + NINE: "Return an opponent's field card to their hand (cannot play it next turn).", + TEN: 'No effect.', +} +const faceCardDescriptions: Record = { + KING: 'Reduce points needed to win while in play.', + QUEEN: 'Protect your other cards from targeted effects.', + JACK: 'Steal a target point card while in play.', + EIGHT: "Reveal your opponent's hand while in play.", +} function cardTag(card: CardView) { if (card.purpose === 'POINTS') return 'points' @@ -650,6 +672,61 @@ function rankShort(rank: string) { return map[rank] ?? rank } +function actionDescription(action: ActionView): string | null { + if (action.type === 'Draw') { + return 'Draw one card (max 8 in hand).' + } + if (action.type === 'Points') { + return 'Play for points equal to its rank.' + } + if (action.type === 'Scuttle') { + return 'Scrap an opponent point card with a higher card (suit breaks ties).' + } + if (action.type === 'Face Card') { + if (action.card?.rank && faceCardDescriptions[action.card.rank]) { + return faceCardDescriptions[action.card.rank] + } + return 'Play a royal for an ongoing effect.' + } + if (action.type === 'Jack') { + return 'Steal a target point card while in play.' + } + if (action.type === 'One-Off') { + if (action.card?.rank && oneOffDescriptions[action.card.rank]) { + return oneOffDescriptions[action.card.rank] + } + return "Trigger this card's one-off effect." + } + if (action.type === 'Counter') { + return 'Counter a one-off with a Two.' + } + if (action.type === 'Resolve') { + return 'Let the pending one-off resolve.' + } + if (action.type === 'Take From Discard') { + return 'Take one card from the scrap pile.' + } + if (action.type === 'Discard From Hand') { + return 'Discard a card from your hand.' + } + if (action.type === 'Discard Revealed') { + return 'Discard a revealed card.' + } + if (action.type === 'Request Stalemate') { + return 'Ask to end the game in a stalemate.' + } + if (action.type === 'Accept Stalemate') { + return 'Accept the stalemate request.' + } + if (action.type === 'Reject Stalemate') { + return 'Reject the stalemate request.' + } + if (action.type === 'Concede') { + return 'Concede the game.' + } + return null +} + function SuitIcon({ suit, size = 20 }: { suit: string; size?: number }) { const suitProps = { size, strokeWidth: 2.5 } @@ -667,6 +744,34 @@ function SuitIcon({ suit, size = 20 }: { suit: string; size?: number }) { } } +function ActionButton({ + action, + context, + selected, + disabled, + onClick, +}: ActionButtonProps) { + const description = actionDescription(action) + const descId = description ? `action-desc-${context}-${action.id}` : undefined + + return ( + + ) +} + function CardTile({ card, selected, isHand, onClick }: CardTileProps) { const tag = cardTag(card) const rank = rankShort(card.rank)