Files
project-lyra/lyra/web/static/hand.html
T
serversdown 44a559c5f9 fix: render flat-logged hands + on-demand "build replay"
Quick-logged hands (log_hand) store flat fields with no structured JSON, so the
hand viewer dead-ended with "no structured data to replay" — even when the full
street-by-street action was captured (e.g. the KhQh-vs-Louis hand). Now:

- hand.html renders a readable STATIC view of any flat hand (hero cards, board,
  street narratives, result, lesson) instead of erroring; also handles empty/garbage
  structured rows by falling back to the flat view.
- "▶ Build replay" button + poker.reconstruct_hand + POST /hand/{id}/reconstruct:
  parse a flat hand's narrative into structured form on demand, making any
  quick-logged hand replayable without an LLM call per log during live play.
- test_modes.py +1 (reconstruct wiring).

(Also reconstructed the two live Meadows hands and removed one empty hand in the DB.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:02:10 +00:00

292 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="#070707" />
<title>Lyra — Hand</title>
<style>
:root {
--bg:#070707; --bg-elev:#0e0e0e; --border:#2a1d12; --text:#e8e8e8;
--fade:#8a8a8a; --accent:#ff7a00; --felt:#16322a; --feltline:#0f5132;
--chip:#ffb347; --hero:#ff7a00;
}
*{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;}
.card.unknown{background:#2a3550;color:#7c879e;font-size:1.2rem;}
.card .nosuit{color:#9aa3b5;}
.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(255,122,0,.4);}
.seat.acting{border-color:var(--chip);box-shadow:0 0 12px rgba(255,179,71,.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:#241400;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:#241400;}
.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:#8fd694;} .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 c = String(code).trim();
if(c.toLowerCase()==='x') return `<span class="card${sm?' sm':''} unknown">?</span>`;
const m = c.match(/^(10|[2-9TJQKA])\s*([shdcx])$/i);
if(!m) return `<span class="card${sm?' sm':''}">${esc(c)}</span>`;
const r = m[1].toUpperCase().replace('10','T'); const s = m[2].toLowerCase();
if(s==='x') return `<span class="card${sm?' sm':''}"><span class="r">${r}</span><span class="nosuit">·</span></span>`;
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('');
// Split a loose card string ("KhQh", "Qh Qc", "Tc 8s Js 6d", "Ax") into codes.
const parseCards = s => (String(s||'').match(/(10|[2-9TJQKA])[shdcx]/gi) || []);
// Flat (quick-logged) hands have no structured replay — show a readable static
// view of everything that WAS captured, plus an on-demand "build replay".
function renderFlat(h){
document.getElementById('sub').textContent = h.position || '';
const hole = parseCards(h.hole_cards), board = parseCards(h.board);
const streets = [['Preflop',h.preflop],['Flop',h.flop],['Turn',h.turn],['River',h.river],['Showdown',h.showdown]]
.filter(x=>x[1]);
const canBuild = streets.length > 0;
document.getElementById('root').innerHTML = `
<div class="summary" style="text-align:center">
<div class="lbl">Hero ${esc(h.position||'')}${h.tag?' · '+esc(h.tag):''}</div>
<div style="display:flex;gap:5px;justify-content:center;margin:10px 0">
${hole.length?cards(hole):'<span class="card unknown">?</span>'}</div>
${board.length?`<div class="lbl" style="margin-top:6px">Board</div>
<div style="display:flex;gap:5px;justify-content:center;margin-top:6px">${cards(board)}</div>`:''}
</div>
${streets.length?`<div class="log">${streets.map(s=>`<div class="ln"><span class="st">${s[0]}</span>${esc(s[1])}</div>`).join('')}</div>`:''}
${h.result!=null?`<div class="summary"><div class="lbl">Result</div>
<div class="${h.result>=0?'net-pos':'net-neg'}">Hero net: ${h.result>=0?'+':''}${esc(h.result)}</div></div>`:''}
${h.lesson?`<div class="summary"><div class="lbl">Lesson</div><div>${esc(h.lesson)}</div></div>`:''}
<div class="controls">
${canBuild?'<button id="build">▶ Build replay</button>':''}
</div>
<p style="color:var(--fade);text-align:center;font-size:.78rem;margin-top:10px">
${canBuild?'Quick-logged hand (static). Build replay to reconstruct a step-through.':'Quick-logged hand — limited detail captured.'}</p>`;
const b = document.getElementById('build');
if(b) b.onclick = async () => {
b.disabled = true; b.textContent = '… building';
try{
const r = await fetch(`/hand/${h.id}/reconstruct`,{method:'POST'});
const d = await r.json();
if(d.ok) location.reload(); else { b.disabled=false; b.textContent='▶ Build replay'; alert("Couldn't reconstruct this one."); }
}catch(e){ b.disabled=false; b.textContent='▶ Build replay'; alert('Failed: '+e.message); }
};
}
function render(h){
const sub = document.getElementById('sub');
const data = h.structured;
const hasReplay = data && (((data.players||[]).length) || ((data.actions||[]).length));
if(!hasReplay){ renderFlat(h); 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');
const starts = {};
ordered.forEach((p,i)=>{
starts[p.pos] = (p.stack!=null ? Number(p.stack) : null);
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>`
+ `<div class="stack" data-stack>${p.stack!=null?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('');
const cap = s => s ? s[0].toUpperCase()+s.slice(1) : s;
const fmt = n => Number.isInteger(n) ? n : Math.round(n*100)/100;
function draw(){
let board = [], street = 'Preflop';
const lastAct = {}, folded = {};
// street-aware chip accounting: amounts are "to" totals for the street
const contrib = {}; // committed in prior (flushed) streets
let streetCommit = {}, streetBet = 0, curStreet = 'preflop';
const flushStreet = () => { for(const p in streetCommit){ contrib[p]=(contrib[p]||0)+streetCommit[p]; } streetCommit={}; streetBet=0; };
for(let i=0;i<step;i++){
const a = acts[i];
if(a.board){ flushStreet(); curStreet=a.street; board=a.board; street=cap(a.street); continue; }
if(a.street && a.street!==curStreet){ flushStreet(); curStreet=a.street; }
if(a.street) street = cap(a.street);
const pos=a.pos, amt=(a.amount!=null?Number(a.amount):null);
if(pos){
switch(a.action){
case 'post': case 'bet': streetCommit[pos]=amt||0; streetBet=Math.max(streetBet, amt||0); break;
case 'raise': case 'allin': streetCommit[pos]=(amt!=null?amt:streetBet); streetBet=Math.max(streetBet, streetCommit[pos]); break;
case 'call': streetCommit[pos]=(amt!=null?amt:streetBet); break;
case 'fold': folded[pos]=true; break;
}
lastAct[pos]=(a.action||'')+(amt!=null?' '+amt:'');
}
}
// committed total per player (flushed streets + current street), pot = sum
const committed={}, allPos=new Set([...Object.keys(contrib),...Object.keys(streetCommit)]);
let pot=0;
allPos.forEach(p=>{ committed[p]=(contrib[p]||0)+(streetCommit[p]||0); pot+=committed[p]; });
boardEl.innerHTML = cards(board);
potEl.textContent = pot ? ('Pot '+fmt(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 stEl=s.querySelector('[data-stack]'), start=starts[pos], c=committed[pos]||0;
if(start!=null){ const rem=start-c; stEl.textContent = rem<=0 ? 'all in' : fmt(rem); }
else { stEl.textContent = c ? ''+fmt(c) : ''; }
});
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>
<script src="/nav.js"></script>
</body>
</html>