update to 0.2.0 stable #2
+125
-2
@@ -12,9 +12,11 @@ needs to pass an id around.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from lyra import memory
|
||||
from lyra import llm, memory
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS poker_sessions (
|
||||
@@ -51,7 +53,8 @@ CREATE TABLE IF NOT EXISTS poker_hands (
|
||||
result REAL,
|
||||
stack_after REAL,
|
||||
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);
|
||||
|
||||
@@ -87,6 +90,11 @@ def _c():
|
||||
conn = memory._connection()
|
||||
if _ensured_for is not conn:
|
||||
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
|
||||
return conn
|
||||
|
||||
@@ -198,6 +206,121 @@ def list_hands(session_id: int | None = None) -> list[dict]:
|
||||
).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 ---
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
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:
|
||||
vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue"))
|
||||
if not vs:
|
||||
@@ -231,6 +245,16 @@ TOOLS.update({
|
||||
"game": {**_S, "description": "Filter by game type"},
|
||||
"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",
|
||||
"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.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
|
||||
|
||||
|
||||
@@ -141,6 +141,15 @@ def create_app() -> FastAPI:
|
||||
async def journal_data(limit: int = 300) -> dict:
|
||||
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")
|
||||
async def stream_logs(request: Request) -> StreamingResponse:
|
||||
"""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"]
|
||||
|
||||
|
||||
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):
|
||||
from lyra import tools
|
||||
assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})
|
||||
|
||||
Reference in New Issue
Block a user