9491951da0
Brian's idea: vomit rough shorthand, Lyra rebuilds it into a structured,
replayable hand history.
- poker.parse_hand(): focused LLM pass turning shorthand into a canonical hand
JSON (positions, stacks, hero cards, chronological actions w/ board reveals,
result); store_hand_history() persists JSON + extracted flat fields;
record_hand() = parse+store; standalone hands attach to a 'Hand Reviews' session
- poker_hands gains a `structured` JSON column (ALTER-migrated for existing DBs)
- record_hand tool wired into chat: "log this hand: ..." -> reconstructed + a
/hand/{id} link
- web: GET /hand/{id} viewer + /hand/{id}/data — a felt table with seats placed
around the oval (hero at bottom), hole cards, progressive board reveal, and
prev/next/end step-through of the action with running pot
- tests: store/get roundtrip, record_hand tool (stubbed parse)
Verified live: parsed a real AKs hand (BTN, 14 actions, full board) end to end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
223 lines
12 KiB
HTML
223 lines
12 KiB
HTML
<!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>
|