update to 0.2.0 stable #2
+125
-2
@@ -12,9 +12,11 @@ needs to pass an id around.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from lyra import memory
|
from lyra import llm, memory
|
||||||
|
|
||||||
_SCHEMA = """
|
_SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS poker_sessions (
|
CREATE TABLE IF NOT EXISTS poker_sessions (
|
||||||
@@ -51,7 +53,8 @@ CREATE TABLE IF NOT EXISTS poker_hands (
|
|||||||
result REAL,
|
result REAL,
|
||||||
stack_after REAL,
|
stack_after REAL,
|
||||||
tag TEXT, -- well_played | leak | cooler | confidence | notable
|
tag TEXT, -- well_played | leak | cooler | confidence | notable
|
||||||
lesson TEXT
|
lesson TEXT,
|
||||||
|
structured TEXT -- full parsed hand-history JSON (for the viewer)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_hands_session ON poker_hands(session_id);
|
CREATE INDEX IF NOT EXISTS idx_hands_session ON poker_hands(session_id);
|
||||||
|
|
||||||
@@ -87,6 +90,11 @@ def _c():
|
|||||||
conn = memory._connection()
|
conn = memory._connection()
|
||||||
if _ensured_for is not conn:
|
if _ensured_for is not conn:
|
||||||
conn.executescript(_SCHEMA)
|
conn.executescript(_SCHEMA)
|
||||||
|
# Add columns introduced after a DB already had the tables (no-op if present).
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE poker_hands ADD COLUMN structured TEXT")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
_ensured_for = conn
|
_ensured_for = conn
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
@@ -198,6 +206,121 @@ def list_hands(session_id: int | None = None) -> list[dict]:
|
|||||||
).fetchall()]
|
).fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
# --- hand-history parsing (rough shorthand -> structured JSON) ---
|
||||||
|
|
||||||
|
_HAND_PARSE_PROMPT = """You convert a player's rough shorthand description of a poker hand \
|
||||||
|
into a structured JSON hand history. Output ONLY valid JSON — no prose, no code fences.
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
{
|
||||||
|
"game": "NLH" | "PLO" | ...,
|
||||||
|
"stakes": "<e.g. 1/3, or null>",
|
||||||
|
"hero_pos": "<UTG|UTG1|MP|LJ|HJ|CO|BTN|SB|BB, hero's position>",
|
||||||
|
"hero_cards": ["Rs", ...], // rank+suit; suit one of s h d c; null if unknown
|
||||||
|
"players": [ // every player mentioned, incl. hero
|
||||||
|
{"pos": "<position>", "stack": <number|null>, "name": <string|null>, "cards": [".."]|null}
|
||||||
|
],
|
||||||
|
"actions": [ // chronological, across all streets
|
||||||
|
// when a street begins, FIRST emit its board reveal:
|
||||||
|
{"street": "flop", "board": ["7d","2c","5h"]}, // turn/river: one card in the array
|
||||||
|
{"street": "preflop|flop|turn|river", "pos": "<pos>", "action": "post|fold|check|call|bet|raise|allin", "amount": <number|null>}
|
||||||
|
],
|
||||||
|
"board": ["..."], // full final board, 0-5 cards
|
||||||
|
"result": {"pot": <number|null>, "hero_net": <number|null>, "summary": "<one line>"}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules: infer positions and street order sensibly. Amounts are plain numbers (no $). \
|
||||||
|
Normalize cards like "Ah","Td","9s". Use null/omit for anything not stated. Stay faithful \
|
||||||
|
to what's described — do not invent action that isn't implied."""
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json(s: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
return json.loads(s)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
m = re.search(r"\{.*\}", s or "", re.S)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return json.loads(m.group())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_hand(shorthand: str, stakes: str | None = None,
|
||||||
|
backend: str | None = None) -> dict | None:
|
||||||
|
"""Turn rough shorthand into a structured hand-history dict via an LLM pass."""
|
||||||
|
backend = backend or "cloud"
|
||||||
|
ctx = f"Stakes: {stakes}\n\n" if stakes else ""
|
||||||
|
parsed = _safe_json(llm.complete(
|
||||||
|
[{"role": "system", "content": _HAND_PARSE_PROMPT},
|
||||||
|
{"role": "user", "content": ctx + shorthand}],
|
||||||
|
backend=backend,
|
||||||
|
))
|
||||||
|
if parsed and stakes and not parsed.get("stakes"):
|
||||||
|
parsed["stakes"] = stakes
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _review_session_id() -> int:
|
||||||
|
"""A standing 'Hand Reviews' session to attach standalone parsed hands to."""
|
||||||
|
conn = _c()
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT id FROM poker_sessions WHERE venue = 'Hand Reviews' AND status = 'review'"
|
||||||
|
).fetchone()
|
||||||
|
if r:
|
||||||
|
return int(r["id"])
|
||||||
|
with conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO poker_sessions (started_at, venue, status, buy_in_total) "
|
||||||
|
"VALUES (?, 'Hand Reviews', 'review', 0)",
|
||||||
|
(_now(),),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def store_hand_history(parsed: dict, session_id: int | None = None,
|
||||||
|
tag: str | None = None, lesson: str | None = None) -> int:
|
||||||
|
"""Store a parsed hand: full JSON + extracted flat fields for stats/listing."""
|
||||||
|
sid = _resolve(session_id) or _review_session_id()
|
||||||
|
hero_cards = parsed.get("hero_cards") or []
|
||||||
|
board = parsed.get("board") or []
|
||||||
|
result = (parsed.get("result") or {})
|
||||||
|
conn = _c()
|
||||||
|
with conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO poker_hands (session_id, at, position, hole_cards, board, "
|
||||||
|
"pot, result, tag, lesson, structured) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(sid, _now(), parsed.get("hero_pos"),
|
||||||
|
" ".join(hero_cards) if hero_cards else None,
|
||||||
|
" ".join(board) if board else None,
|
||||||
|
result.get("pot"), result.get("hero_net"), tag, lesson,
|
||||||
|
json.dumps(parsed)),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def record_hand(shorthand: str, session_id: int | None = None, stakes: str | None = None,
|
||||||
|
tag: str | None = None, lesson: str | None = None,
|
||||||
|
backend: str | None = None) -> dict:
|
||||||
|
"""Parse shorthand -> structured hand -> store. Returns {id, parsed} (id None on parse fail)."""
|
||||||
|
parsed = parse_hand(shorthand, stakes=stakes, backend=backend)
|
||||||
|
if not parsed:
|
||||||
|
return {"id": None, "parsed": None}
|
||||||
|
hid = store_hand_history(parsed, session_id=session_id, tag=tag, lesson=lesson)
|
||||||
|
return {"id": hid, "parsed": parsed}
|
||||||
|
|
||||||
|
|
||||||
|
def get_hand(hand_id: int) -> dict | None:
|
||||||
|
"""A stored hand with its structured JSON parsed back into a dict."""
|
||||||
|
r = _c().execute("SELECT * FROM poker_hands WHERE id = ?", (hand_id,)).fetchone()
|
||||||
|
if not r:
|
||||||
|
return None
|
||||||
|
d = dict(r)
|
||||||
|
d["structured"] = json.loads(d["structured"]) if d.get("structured") else None
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
# --- villain file ---
|
# --- villain file ---
|
||||||
|
|
||||||
def upsert_player(name: str, venue: str | None = None, description: str | None = None,
|
def upsert_player(name: str, venue: str | None = None, description: str | None = None,
|
||||||
|
|||||||
@@ -149,6 +149,20 @@ def _running_stats(args: dict, ctx: dict) -> str:
|
|||||||
return f"{rs['sessions']} sessions, {rs['hours']:g}h, net {rs['net']:+.0f}{hourly}. By stake: {by}"
|
return f"{rs['sessions']} sessions, {rs['hours']:g}h, net {rs['net']:+.0f}{hourly}. By stake: {by}"
|
||||||
|
|
||||||
|
|
||||||
|
def _record_hand(args: dict, ctx: dict) -> str:
|
||||||
|
out = poker.record_hand(
|
||||||
|
args.get("shorthand") or "", stakes=args.get("stakes"),
|
||||||
|
tag=args.get("tag"), lesson=args.get("lesson"),
|
||||||
|
)
|
||||||
|
if not out["id"]:
|
||||||
|
return "I couldn't parse that hand — give it to me again with a little more detail?"
|
||||||
|
p = out["parsed"]
|
||||||
|
cards = " ".join(p.get("hero_cards") or [])
|
||||||
|
logbus.log("info", "hand reconstructed", id=out["id"], hero=p.get("hero_pos"))
|
||||||
|
return (f"Hand #{out['id']} reconstructed — {p.get('hero_pos') or '?'} "
|
||||||
|
f"{cards}. View/replay it at /hand/{out['id']}")
|
||||||
|
|
||||||
|
|
||||||
def _villain_file(args: dict, ctx: dict) -> str:
|
def _villain_file(args: dict, ctx: dict) -> str:
|
||||||
vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue"))
|
vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue"))
|
||||||
if not vs:
|
if not vs:
|
||||||
@@ -231,6 +245,16 @@ TOOLS.update({
|
|||||||
"game": {**_S, "description": "Filter by game type"},
|
"game": {**_S, "description": "Filter by game type"},
|
||||||
"since": {**_S, "description": "ISO date lower bound, e.g. '2026-06-01'"}},
|
"since": {**_S, "description": "ISO date lower bound, e.g. '2026-06-01'"}},
|
||||||
[])},
|
[])},
|
||||||
|
"record_hand": {"handler": _record_hand, "spec": _f(
|
||||||
|
"record_hand",
|
||||||
|
"Reconstruct a hand from Brian's rough shorthand into a structured, "
|
||||||
|
"replayable hand history. Use when he describes/vomits a hand he wants "
|
||||||
|
"saved or to review. Pass his description verbatim as 'shorthand'.",
|
||||||
|
{"shorthand": {**_S, "description": "Brian's rough description of the hand, verbatim"},
|
||||||
|
"stakes": {**_S, "description": "Stakes if known, e.g. '1/3'"},
|
||||||
|
"tag": {**_S, "description": "well_played | leak | cooler | confidence | notable"},
|
||||||
|
"lesson": {**_S, "description": "Takeaway, if he stated one"}},
|
||||||
|
["shorthand"])},
|
||||||
"get_villain_file": {"handler": _villain_file, "spec": _f(
|
"get_villain_file": {"handler": _villain_file, "spec": _f(
|
||||||
"get_villain_file",
|
"get_villain_file",
|
||||||
"Pull saved opponent dossiers (the villain file). Filter by name or venue, e.g. before sitting down.",
|
"Pull saved opponent dossiers (the villain file). Filter by name or venue, e.g. before sitting down.",
|
||||||
|
|||||||
+10
-1
@@ -18,7 +18,7 @@ from fastapi import FastAPI, Request
|
|||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from lyra import chat, logbus, memory, self_state, summary
|
from lyra import chat, logbus, memory, poker, self_state, summary
|
||||||
from lyra.llm import Backend
|
from lyra.llm import Backend
|
||||||
|
|
||||||
|
|
||||||
@@ -141,6 +141,15 @@ def create_app() -> FastAPI:
|
|||||||
async def journal_data(limit: int = 300) -> dict:
|
async def journal_data(limit: int = 300) -> dict:
|
||||||
return {"entries": memory.list_journal(limit=limit)}
|
return {"entries": memory.list_journal(limit=limit)}
|
||||||
|
|
||||||
|
@app.get("/hand/{hand_id}")
|
||||||
|
async def hand_page(hand_id: int) -> FileResponse:
|
||||||
|
"""Replayable hand-history viewer."""
|
||||||
|
return FileResponse(str(_STATIC / "hand.html"))
|
||||||
|
|
||||||
|
@app.get("/hand/{hand_id}/data")
|
||||||
|
async def hand_data(hand_id: int) -> dict:
|
||||||
|
return poker.get_hand(hand_id) or {}
|
||||||
|
|
||||||
@app.get("/stream/logs")
|
@app.get("/stream/logs")
|
||||||
async def stream_logs(request: Request) -> StreamingResponse:
|
async def stream_logs(request: Request) -> StreamingResponse:
|
||||||
"""Live activity feed: replay the recent buffer, then stream new events."""
|
"""Live activity feed: replay the recent buffer, then stream new events."""
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0b0d12" />
|
||||||
|
<title>Lyra — Hand</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg:#0b0d12; --bg-elev:#141821; --border:#232936; --text:#e6e9ef;
|
||||||
|
--fade:#8b93a7; --accent:#7aa2ff; --felt:#16322a; --feltline:#0f5132;
|
||||||
|
--chip:#ffcf6b; --hero:#7aa2ff;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%;}
|
||||||
|
header{position:sticky;top:0;z-index:10;background:var(--bg-elev);border-bottom:1px solid var(--border);
|
||||||
|
padding:env(safe-area-inset-top) 14px 0;}
|
||||||
|
.topbar{display:flex;align-items:baseline;gap:10px;padding:12px 0;flex-wrap:wrap;}
|
||||||
|
.topbar h1{font-size:1.02rem;margin:0;font-weight:600;}
|
||||||
|
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
|
||||||
|
.sub{color:var(--fade);font-size:.85rem;margin-left:auto;}
|
||||||
|
main{max-width:760px;margin:0 auto;padding:14px;}
|
||||||
|
|
||||||
|
.table-wrap{position:relative;width:100%;max-width:560px;margin:8px auto;aspect-ratio:1.45/1;}
|
||||||
|
.felt{position:absolute;inset:8%;background:radial-gradient(ellipse at center,#1c4a3c,var(--felt));
|
||||||
|
border:6px solid #25201a;border-radius:50%/50%;box-shadow:inset 0 0 40px rgba(0,0,0,.5);}
|
||||||
|
.center{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;width:80%;}
|
||||||
|
.board{display:flex;gap:5px;justify-content:center;min-height:46px;align-items:center;flex-wrap:wrap;}
|
||||||
|
.pot{margin-top:8px;color:var(--chip);font-size:.85rem;font-variant-numeric:tabular-nums;}
|
||||||
|
.street{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.6px;margin-bottom:4px;}
|
||||||
|
|
||||||
|
.card{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;
|
||||||
|
width:32px;height:44px;background:#f4f4f0;color:#111;border-radius:5px;font-weight:700;
|
||||||
|
box-shadow:0 1px 3px rgba(0,0,0,.4);line-height:1;}
|
||||||
|
.card.sm{width:26px;height:36px;font-size:.8rem;}
|
||||||
|
.card .r{font-size:1rem;}
|
||||||
|
.card.red{color:#c8102e;}
|
||||||
|
.card.back{background:#2a3550;color:#2a3550;}
|
||||||
|
|
||||||
|
.seat{position:absolute;transform:translate(-50%,-50%);width:96px;text-align:center;
|
||||||
|
background:rgba(13,16,22,.85);border:1px solid var(--border);border-radius:10px;padding:5px 4px;}
|
||||||
|
.seat.hero{border-color:var(--hero);box-shadow:0 0 10px rgba(122,162,255,.4);}
|
||||||
|
.seat.acting{border-color:var(--chip);box-shadow:0 0 12px rgba(255,207,107,.6);}
|
||||||
|
.seat .pos{font-size:.66rem;color:var(--accent);font-weight:700;letter-spacing:.4px;}
|
||||||
|
.seat .nm{font-size:.66rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
|
.seat .cards{display:flex;gap:3px;justify-content:center;margin:3px 0;}
|
||||||
|
.seat .stack{font-size:.66rem;color:var(--text);font-variant-numeric:tabular-nums;}
|
||||||
|
.seat .act{font-size:.62rem;color:var(--chip);min-height:.8em;}
|
||||||
|
.seat.folded{opacity:.4;}
|
||||||
|
|
||||||
|
.controls{display:flex;gap:8px;align-items:center;justify-content:center;margin:14px 0 6px;}
|
||||||
|
.controls button{background:#1b2333;border:1px solid var(--border);color:var(--text);
|
||||||
|
border-radius:8px;padding:8px 14px;font-size:.95rem;cursor:pointer;-webkit-tap-highlight-color:transparent;}
|
||||||
|
.controls button:disabled{opacity:.4;}
|
||||||
|
.step-label{color:var(--fade);font-size:.8rem;min-width:80px;text-align:center;}
|
||||||
|
.now{text-align:center;color:var(--text);font-size:.95rem;min-height:1.3em;margin-bottom:6px;}
|
||||||
|
|
||||||
|
.log{margin-top:14px;border-top:1px solid var(--border);padding-top:10px;}
|
||||||
|
.log .ln{padding:5px 8px;border-radius:6px;font-size:.9rem;display:flex;gap:8px;}
|
||||||
|
.log .ln.cur{background:#1b2333;}
|
||||||
|
.log .ln.brd{color:var(--fade);font-style:italic;}
|
||||||
|
.log .st{color:var(--fade);font-size:.72rem;width:54px;flex:none;text-transform:uppercase;}
|
||||||
|
.summary{margin-top:14px;background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:12px;}
|
||||||
|
.summary .lbl{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.5px;}
|
||||||
|
.err{color:#ff6b6b;text-align:center;padding:40px;}
|
||||||
|
.net-pos{color:#5ad1a0;} .net-neg{color:#ff6b6b;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="topbar">
|
||||||
|
<h1>🃏 Hand</h1>
|
||||||
|
<a class="back" href="/">← Chat</a>
|
||||||
|
<span class="sub" id="sub"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="root"><p class="err" id="boot">Loading hand…</p></main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const SUIT = {s:"♠", h:"♥", d:"♦", c:"♣"};
|
||||||
|
const RED = new Set(["h", "d"]);
|
||||||
|
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
|
||||||
|
|
||||||
|
function cardEl(code, sm){
|
||||||
|
if(!code) return '';
|
||||||
|
const m = String(code).trim().match(/^(10|[2-9TJQKA])\s*([shdc])$/i);
|
||||||
|
if(!m) return `<span class="card${sm?' sm':''}">${esc(code)}</span>`;
|
||||||
|
const r = m[1].toUpperCase().replace('10','T'); const s = m[2].toLowerCase();
|
||||||
|
return `<span class="card${sm?' sm':''}${RED.has(s)?' red':''}"><span class="r">${r}</span><span>${SUIT[s]}</span></span>`;
|
||||||
|
}
|
||||||
|
const cards = (arr, sm) => (arr||[]).map(c=>cardEl(c,sm)).join('');
|
||||||
|
|
||||||
|
function render(h){
|
||||||
|
const sub = document.getElementById('sub');
|
||||||
|
const data = h.structured;
|
||||||
|
if(!data){ document.getElementById('root').innerHTML = '<p class="err">This hand has no structured data to replay.</p>'; return; }
|
||||||
|
|
||||||
|
const players = (data.players||[]).slice();
|
||||||
|
// order so hero sits at the bottom
|
||||||
|
let heroIdx = players.findIndex(p => p.pos === data.hero_pos);
|
||||||
|
if(heroIdx < 0) heroIdx = 0;
|
||||||
|
const ordered = players.slice(heroIdx).concat(players.slice(0, heroIdx));
|
||||||
|
const n = Math.max(ordered.length, 1);
|
||||||
|
|
||||||
|
const acts = data.actions || [];
|
||||||
|
let step = 0; // number of actions applied
|
||||||
|
|
||||||
|
sub.textContent = [data.stakes, data.game].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="table-wrap" id="tw">
|
||||||
|
<div class="felt"></div>
|
||||||
|
<div class="center">
|
||||||
|
<div class="street" id="street"></div>
|
||||||
|
<div class="board" id="board"></div>
|
||||||
|
<div class="pot" id="pot"></div>
|
||||||
|
</div>
|
||||||
|
<div id="seats"></div>
|
||||||
|
</div>
|
||||||
|
<div class="now" id="now"></div>
|
||||||
|
<div class="controls">
|
||||||
|
<button id="prev">◀ Prev</button>
|
||||||
|
<span class="step-label" id="steplab"></span>
|
||||||
|
<button id="next">Next ▶</button>
|
||||||
|
<button id="all">End</button>
|
||||||
|
</div>
|
||||||
|
<div class="log" id="log"></div>
|
||||||
|
${data.result ? `<div class="summary"><div class="lbl">Result</div>
|
||||||
|
<div>${esc(data.result.summary||'')}</div>
|
||||||
|
${data.result.hero_net!=null ? `<div class="${data.result.hero_net>=0?'net-pos':'net-neg'}">Hero net: ${data.result.hero_net>=0?'+':''}${esc(data.result.hero_net)}</div>`:''}
|
||||||
|
</div>`:''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// place seats around the oval
|
||||||
|
const seatsEl = document.getElementById('seats');
|
||||||
|
ordered.forEach((p,i)=>{
|
||||||
|
const ang = (90 + i*(360/n)) * Math.PI/180; // bottom = 90deg
|
||||||
|
const x = 50 + 46*Math.cos(ang), y = 50 + 44*Math.sin(ang);
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'seat' + (p.pos===data.hero_pos?' hero':'');
|
||||||
|
el.style.left = x+'%'; el.style.top = y+'%';
|
||||||
|
el.dataset.pos = p.pos;
|
||||||
|
const hcards = (p.pos===data.hero_pos ? (p.cards||data.hero_cards) : p.cards);
|
||||||
|
el.innerHTML = `<div class="pos">${esc(p.pos||'')}</div>`
|
||||||
|
+ (p.name?`<div class="nm">${esc(p.name)}</div>`:'')
|
||||||
|
+ `<div class="cards">${hcards?cards(hcards,true):'<span class="card sm back">x</span><span class="card sm back">x</span>'}</div>`
|
||||||
|
+ (p.stack!=null?`<div class="stack">${esc(p.stack)}</div>`:'')
|
||||||
|
+ `<div class="act" data-act></div>`;
|
||||||
|
seatsEl.appendChild(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
const boardEl=document.getElementById('board'), potEl=document.getElementById('pot'),
|
||||||
|
streetEl=document.getElementById('street'), nowEl=document.getElementById('now'),
|
||||||
|
logEl=document.getElementById('log'), steplab=document.getElementById('steplab');
|
||||||
|
|
||||||
|
// build the log
|
||||||
|
logEl.innerHTML = acts.map((a,idx)=>{
|
||||||
|
if(a.board) return `<div class="ln brd" data-i="${idx}"><span class="st">${esc(a.street)}</span>${cards(a.board,true)}</div>`;
|
||||||
|
const amt = a.amount!=null ? ' '+a.amount : '';
|
||||||
|
return `<div class="ln" data-i="${idx}"><span class="st">${esc(a.street||'')}</span>${esc(a.pos||'')} ${esc(a.action||'')}${amt}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
function draw(){
|
||||||
|
let pot = 0, board = [], street = 'Preflop';
|
||||||
|
const lastAct = {}, folded = {};
|
||||||
|
for(let i=0;i<step;i++){
|
||||||
|
const a = acts[i];
|
||||||
|
if(a.board){ board = a.board; street = a.street; continue; }
|
||||||
|
if(a.street) street = a.street;
|
||||||
|
if(a.amount!=null && ['call','bet','raise','allin','post'].includes(a.action)) pot += Number(a.amount)||0;
|
||||||
|
if(a.pos){ lastAct[a.pos] = (a.action||'') + (a.amount!=null?' '+a.amount:''); }
|
||||||
|
if(a.action==='fold' && a.pos) folded[a.pos]=true;
|
||||||
|
}
|
||||||
|
boardEl.innerHTML = cards(board);
|
||||||
|
potEl.textContent = pot ? ('Pot ~'+pot) : '';
|
||||||
|
streetEl.textContent = street;
|
||||||
|
document.querySelectorAll('.seat').forEach(s=>{
|
||||||
|
const pos=s.dataset.pos;
|
||||||
|
s.querySelector('[data-act]').textContent = lastAct[pos]||'';
|
||||||
|
s.classList.toggle('folded', !!folded[pos]);
|
||||||
|
s.classList.remove('acting');
|
||||||
|
});
|
||||||
|
const cur = acts[step-1];
|
||||||
|
if(cur && cur.pos){
|
||||||
|
const s = [...document.querySelectorAll('.seat')].find(x=>x.dataset.pos===cur.pos);
|
||||||
|
if(s) s.classList.add('acting');
|
||||||
|
}
|
||||||
|
nowEl.innerHTML = step===0 ? 'Cards dealt — preflop.'
|
||||||
|
: (cur.board ? `${cur.street[0].toUpperCase()+cur.street.slice(1)}: ${cards(cur.board,true)}`
|
||||||
|
: `${esc(cur.pos||'')} ${esc(cur.action||'')}${cur.amount!=null?' '+cur.amount:''}`);
|
||||||
|
steplab.textContent = `${step} / ${acts.length}`;
|
||||||
|
document.getElementById('prev').disabled = step===0;
|
||||||
|
document.getElementById('next').disabled = step>=acts.length;
|
||||||
|
logEl.querySelectorAll('.ln').forEach(l=>l.classList.toggle('cur', Number(l.dataset.i)===step-1));
|
||||||
|
const curln = logEl.querySelector('.ln.cur'); if(curln) curln.scrollIntoView({block:'nearest'});
|
||||||
|
}
|
||||||
|
document.getElementById('prev').onclick=()=>{if(step>0){step--;draw();}};
|
||||||
|
document.getElementById('next').onclick=()=>{if(step<acts.length){step++;draw();}};
|
||||||
|
document.getElementById('all').onclick=()=>{step=acts.length;draw();};
|
||||||
|
document.addEventListener('keydown',e=>{
|
||||||
|
if(e.key==='ArrowRight'){if(step<acts.length){step++;draw();}}
|
||||||
|
if(e.key==='ArrowLeft'){if(step>0){step--;draw();}}
|
||||||
|
});
|
||||||
|
logEl.querySelectorAll('.ln').forEach(l=>l.onclick=()=>{step=Number(l.dataset.i)+1;draw();});
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(){
|
||||||
|
const id = location.pathname.split('/')[2];
|
||||||
|
try{
|
||||||
|
const r = await fetch(`/hand/${id}/data`,{cache:'no-store'});
|
||||||
|
const h = await r.json();
|
||||||
|
if(!h || !h.id){ document.getElementById('root').innerHTML='<p class="err">Hand not found.</p>'; return; }
|
||||||
|
render(h);
|
||||||
|
}catch(e){ document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the hand.</p>'; }
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -64,6 +64,37 @@ def test_running_stats(lyra):
|
|||||||
assert "1/3" in rs["by_stake"]
|
assert "1/3" in rs["by_stake"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_hand_history_store_and_get(lyra):
|
||||||
|
poker = lyra
|
||||||
|
parsed = {"game": "NLH", "stakes": "1/3", "hero_pos": "BTN", "hero_cards": ["As", "Ks"],
|
||||||
|
"players": [{"pos": "BTN", "cards": ["As", "Ks"]}, {"pos": "BB"}],
|
||||||
|
"actions": [{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 12},
|
||||||
|
{"street": "flop", "board": ["As", "7d", "2s"]}],
|
||||||
|
"board": ["As", "7d", "2s"], "result": {"pot": 80, "hero_net": 330, "summary": "won"}}
|
||||||
|
hid = poker.store_hand_history(parsed) # no live session -> attaches to a review session
|
||||||
|
h = poker.get_hand(hid)
|
||||||
|
assert h["position"] == "BTN" and h["hole_cards"] == "As Ks"
|
||||||
|
assert h["result"] == 330
|
||||||
|
assert h["structured"]["actions"][0]["amount"] == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_hand_tool_parses_and_stores(lyra, monkeypatch):
|
||||||
|
import re
|
||||||
|
|
||||||
|
from lyra import llm, tools
|
||||||
|
hand_json = ('{"hero_pos":"CO","hero_cards":["Js","Jd"],'
|
||||||
|
'"players":[{"pos":"CO","cards":["Js","Jd"]},{"pos":"BB","name":"drunk"}],'
|
||||||
|
'"actions":[{"street":"preflop","pos":"CO","action":"raise","amount":45}],'
|
||||||
|
'"board":[],"result":{"hero_net":-300,"summary":"lost to a straight"}}')
|
||||||
|
monkeypatch.setattr(llm, "complete", lambda messages, backend=None, model=None: hand_json)
|
||||||
|
out = tools.dispatch("record_hand", {"shorthand": "JJ in CO, lost to a straight", "stakes": "1/3"})
|
||||||
|
assert "/hand/" in out
|
||||||
|
hid = int(re.search(r"/hand/(\d+)", out).group(1))
|
||||||
|
h = lyra.get_hand(hid)
|
||||||
|
assert h["structured"]["hero_pos"] == "CO"
|
||||||
|
assert h["result"] == -300
|
||||||
|
|
||||||
|
|
||||||
def test_poker_tools_dispatch(lyra):
|
def test_poker_tools_dispatch(lyra):
|
||||||
from lyra import tools
|
from lyra import tools
|
||||||
assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})
|
assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})
|
||||||
|
|||||||
Reference in New Issue
Block a user