From f745ef43a1ca6dd4b1bca74ca0b299b220611904 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sat, 27 Jun 2026 04:11:48 +0000 Subject: [PATCH] feat: POST /hands endpoint + seat in HUD villains (recorder step 1) - POST /hands: store a structured hand from the recorder; normalize_structured() (via store_hand_history) is the shape authority, so the client stays best-effort. Enriches villain dossiers from the recorded players, same as the parser path. Verified live: best-effort body -> normalized (version, completeness, hero sync). - _session_villains: surface each player's latest seat so the recorder can auto-place known players on the table. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/poker.py | 6 ++++-- lyra/web/server.py | 18 ++++++++++++++++++ tests/test_hand_schema.py | 8 ++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lyra/poker.py b/lyra/poker.py index e215295..d6a6d36 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -1185,12 +1185,14 @@ def _session_villains(sid: int) -> list[dict]: "SELECT p.name AS name, p.category AS category, p.tendencies AS tendencies, " "p.adjustment AS adjustment, " "(SELECT note FROM player_reads r2 WHERE r2.player_id = p.id " - " AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note " + " AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note, " + "(SELECT seat FROM player_reads r3 WHERE r3.player_id = p.id " + " AND r3.session_id = ? ORDER BY r3.id DESC LIMIT 1) AS seat " "FROM poker_players p " "WHERE p.id IN (SELECT DISTINCT player_id FROM player_reads " " WHERE session_id = ? AND player_id IS NOT NULL) " "ORDER BY p.updated_at DESC", - (sid, sid), + (sid, sid, sid), ).fetchall() return [dict(r) for r in rows] diff --git a/lyra/web/server.py b/lyra/web/server.py index 2759781..f9e385f 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -339,6 +339,24 @@ def create_app() -> FastAPI: async def hands_data(limit: int = 60) -> dict: return {"hands": poker.list_recent_hands(limit=limit)} + @app.post("/hands") + async def hands_create(request: Request) -> dict: + """Store a structured hand built by the recorder. Body: + {structured, session_id?, tag?, lesson?}. normalize_structured() (in + store_hand_history) is the authority on shape, so the client can be best-effort.""" + body = await request.json() + structured = body.get("structured") + if not isinstance(structured, dict): + return {"ok": False, "error": "missing structured hand body"} + hid = await asyncio.to_thread( + poker.store_hand_history, structured, + session_id=body.get("session_id"), tag=body.get("tag"), lesson=body.get("lesson"), + ) + # Enrich villain dossiers from the recorded players, same as the parser path. + await asyncio.to_thread(poker.link_hand_players, hid, structured, body.get("session_id")) + logbus.log("info", "hand recorded", id=hid, session=body.get("session_id")) + return {"ok": True, "id": hid} + @app.get("/recap/{session_id}") async def recap_page() -> FileResponse: return FileResponse(str(_STATIC / "recap.html")) diff --git a/tests/test_hand_schema.py b/tests/test_hand_schema.py index 99a5039..076c065 100644 --- a/tests/test_hand_schema.py +++ b/tests/test_hand_schema.py @@ -101,3 +101,11 @@ def test_list_recent_hands_flags_structured(poker): rows = {r["id"]: r for r in poker.list_recent_hands()} assert rows[structured_id]["has_structured"] is True assert rows[flat_id]["has_structured"] is False + + +def test_hud_villains_carry_seat(poker): + """The recorder auto-places known players, so the HUD bundle must expose their seat.""" + poker.start_session(venue="Meadows", stakes="1/3", buy_in=400) + poker.add_read("3-bets light", seat="BTN", name="Sal", category="risky") + villains = {v["name"]: v for v in poker.hud()["villains"]} + assert villains["Sal"]["seat"] == "BTN"