Big poker mode changes and hotfixes. #5

Merged
serversdown merged 39 commits from fix/mi50-summary-cap-fallback into dev 2026-07-04 15:09:33 -04:00
4 changed files with 219 additions and 0 deletions
Showing only changes of commit 3b3878ada1 - Show all commits
+13
View File
@@ -1523,6 +1523,19 @@ def get_villain_file(name: str | None = None, venue: str | None = None) -> list[
return [dict(r) for r in _c().execute(sql, params).fetchall()]
def players_overview() -> list[dict]:
"""All villains for the browser: identity + observation count + last seen,
named players first, then most-recently-updated."""
rows = _c().execute(
"SELECT p.id, p.name, p.named, p.venue, p.category, p.descriptors, "
"p.tendencies, p.adjustment, p.updated_at, "
"(SELECT COUNT(*) FROM player_observations o WHERE o.player_id = p.id) AS obs, "
"(SELECT COUNT(*) FROM player_reads r WHERE r.player_id = p.id) AS reads "
"FROM poker_players p ORDER BY p.named DESC, p.updated_at DESC"
).fetchall()
return [dict(r) for r in rows]
def villain_recall(player_id: int) -> dict | None:
"""Episodic recall for one villain: who, how often/where seen, last seen, the
notable hands against him (with ids to link), reads, and stats. This is the
+39
View File
@@ -436,6 +436,45 @@ def create_app() -> FastAPI:
async def hands_data(limit: int = 60) -> dict:
return {"hands": poker.list_recent_hands(limit=limit)}
@app.get("/players")
async def players_page() -> FileResponse:
"""Villain file browser + the identity-resolution review queue."""
return FileResponse(str(_STATIC / "players.html"))
@app.get("/players/data")
async def players_data() -> dict:
return {"players": poker.players_overview(),
"queue": poker.list_identity_queue()}
@app.get("/player/{player_id}/data")
async def player_data(player_id: int) -> dict:
return poker.villain_recall(player_id) or {}
@app.post("/player/{player_id}")
async def player_update(player_id: int, request: Request) -> dict:
body = await request.json()
fields = {k: body[k] for k in ("name", "venue", "category", "tendencies",
"adjustment", "description") if body.get(k) is not None}
if body.get("name"): # naming via the browser flips it to a real handle
poker.name_villain(player_id, body["name"])
fields.pop("name", None)
row = poker.update_player(player_id, **fields) if fields else None
return {"ok": True, "player": row}
@app.post("/identity/{task_id}/resolve")
async def identity_resolve(task_id: int, request: Request) -> dict:
body = await request.json()
action = body.get("action") or "dismiss"
kw = {k: v for k, v in body.items() if k != "action"}
ok = await asyncio.to_thread(poker.resolve_identity_task, task_id, action, **kw)
logbus.log("info", "identity task resolved", id=task_id, action=action)
return {"ok": ok}
@app.post("/players/scan")
async def players_scan() -> dict:
filed = await asyncio.to_thread(poker.scan_merge_candidates)
return {"ok": True, "filed": filed}
@app.get("/recap/{session_id}")
async def recap_page() -> FileResponse:
return FileResponse(str(_STATIC / "recap.html"))
+1
View File
@@ -8,6 +8,7 @@
{ href: "/session", icon: "♠", label: "Session" },
{ href: "/history", icon: "📚", label: "History" },
{ href: "/hands", icon: "🃏", label: "Hands" },
{ href: "/players", icon: "👤", label: "Players" },
{ href: "/self", icon: "🧠", label: "Mind" },
{ href: "/thoughts", icon: "💭", label: "Thoughts" },
{ href: "/journal", icon: "📔", label: "Journal" },
+166
View File
@@ -0,0 +1,166 @@
<!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 — Players</title>
<style>
:root{--bg:#070707;--bg-elev:#0e0e0e;--bg-line:#141414;--border:#2a1d12;--text:#e8e8e8;--fade:#8a8a8a;--accent:#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:center;gap:10px;padding:13px 0;}
.topbar h1{font-size:1.05rem;margin:0;font-weight:600;}
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
.count{margin-left:auto;color:var(--fade);font-size:.8rem;}
main{max-width:640px;margin:0 auto;padding:12px 12px 44px;}
h2.sec{font-size:.74rem;text-transform:uppercase;letter-spacing:.6px;color:var(--fade);margin:20px 2px 8px;}
.queue{background:#160d05;border:1px solid var(--accent);border-radius:10px;padding:11px 12px;margin-bottom:9px;}
.queue .k{font-size:.62rem;text-transform:uppercase;letter-spacing:.5px;color:var(--accent);}
.queue .q-body{font-size:.9rem;margin:5px 0 9px;}
.queue .who{font-weight:600;}
.btns{display:flex;flex-wrap:wrap;gap:7px;}
button{font:inherit;font-size:.82rem;padding:6px 11px;border-radius:7px;border:1px solid var(--border);
background:var(--bg-line);color:var(--text);cursor:pointer;}
button.pri{border-color:var(--accent);color:var(--accent);}
button:active{background:#241400;}
.card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;}
.card .row{display:flex;align-items:center;gap:9px;cursor:pointer;}
.nm{font-size:.96rem;font-weight:600;}
.nm.desc{font-weight:500;font-style:italic;color:#e8d3bf;}
.meta{font-size:.74rem;color:var(--fade);}
.pill{font-size:.6rem;text-transform:uppercase;letter-spacing:.4px;border:1px solid var(--border);
border-radius:20px;padding:1px 7px;color:var(--fade);}
.pill.desc{border-color:#5a3c1e;color:#d0a56e;}
.spacer{margin-left:auto;}
.detail{margin-top:9px;padding-top:9px;border-top:1px solid var(--bg-line);font-size:.86rem;display:none;}
.detail.open{display:block;}
.detail .lbl{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.4px;margin:8px 0 3px;}
.detail ul{margin:3px 0;padding-left:18px;} .detail li{margin:2px 0;}
.detail a{color:var(--accent);text-decoration:none;}
.edit{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px;}
.edit input,.edit select{font:inherit;font-size:.82rem;padding:5px 8px;border-radius:6px;
border:1px solid var(--border);background:var(--bg);color:var(--text);}
.empty{color:var(--fade);text-align:center;padding:34px 16px;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>👤 Players</h1>
<a class="back" href="/">← Chat</a>
<span class="count" id="count"></span>
</div>
</header>
<main id="root"><p class="empty">Loading…</p></main>
<script>
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
let DATA={players:[],queue:[]};
async function load(){
try{ DATA=await (await fetch('/players/data',{cache:'no-store'})).json(); }
catch(e){ document.getElementById('root').innerHTML='<p class="empty">Couldn\'t load players.</p>'; return; }
render();
}
function render(){
const {players,queue}=DATA;
const named=players.filter(p=>p.named), nameless=players.filter(p=>!p.named);
document.getElementById('count').textContent=`${players.length} player${players.length===1?'':'s'}`;
let html='';
if(queue.length){
html+=`<h2 class="sec">⚠ Needs your call — ${queue.length}</h2>`;
html+=queue.map(qCard).join('');
}
html+=`<h2 class="sec">Named — ${named.length} <button class="pri" style="float:right;padding:3px 9px" onclick="scan()">Scan for dupes</button></h2>`;
html+= named.length ? named.map(pCard).join('') : '<p class="empty">No named players yet.</p>';
html+=`<h2 class="sec">By description — ${nameless.length}</h2>`;
html+= nameless.length ? nameless.map(pCard).join('') : '<p class="empty">No nameless villains yet — they show up here as you describe players at the table.</p>';
document.getElementById('root').innerHTML=html;
}
function qCard(q){
const ps=q.players||[];
if(q.kind==='merge_candidate' && ps.length===2){
const a=ps[0], b=ps[1];
return `<div class="queue"><div class="k">Possible merge${q.confidence?` · ${Math.round(q.confidence*100)}%`:''}</div>
<div class="q-body">Same person? <span class="who">${label(a)}</span> &nbsp;vs&nbsp; <span class="who">${label(b)}</span></div>
<div class="btns">
<button class="pri" onclick="resolveTask(${q.id},'merge',{keep_id:${keepId(a,b)},dup_id:${dupId(a,b)}})">✓ Same — merge</button>
<button onclick="resolveTask(${q.id},'distinct',{a_id:${a.id},b_id:${b.id}})">✕ Different</button>
<button onclick="resolveTask(${q.id},'dismiss',{})">Dismiss</button>
</div></div>`;
}
const who=ps[0]?label(ps[0]):'?';
return `<div class="queue"><div class="k">Needs clarification</div>
<div class="q-body">You referred to <span class="who">“${esc(q.descriptor||'')}”</span>${ps[0]?` — is that ${who}?`:''}</div>
<div class="btns"><button onclick="resolveTask(${q.id},'dismiss',{})">Got it</button></div></div>`;
}
const label=p=>`${esc(p.name)}${p.named?'':' <span class="pill desc">desc</span>'}${p.venue?` · ${esc(p.venue)}`:''}${p.obs?` · ${p.obs}h`:''}`;
const keepId=(a,b)=>a.named?a.id:(b.named?b.id:a.id);
const dupId=(a,b)=>a.named?b.id:(b.named?a.id:b.id);
function pCard(p){
const pills=[p.named?'':'<span class="pill desc">desc</span>',p.category?`<span class="pill">${esc(p.category)}</span>`:''].join('');
const meta=[p.venue,p.obs?`${p.obs} hands`:'',p.reads?`${p.reads} reads`:''].filter(Boolean).join(' · ');
return `<div class="card" id="p${p.id}">
<div class="row" onclick="toggle(${p.id})">
<span class="nm ${p.named?'':'desc'}">${p.named?esc(p.name):'“'+esc(p.name)+'”'}</span>
${pills}<span class="spacer"></span><span class="meta">${esc(meta)}</span>
</div>
<div class="detail" id="d${p.id}"></div></div>`;
}
async function toggle(id){
const el=document.getElementById('d'+id);
if(el.classList.contains('open')){el.classList.remove('open');return;}
el.classList.add('open'); el.innerHTML='<span class="meta">Loading…</span>';
const r=await (await fetch(`/player/${id}/data`,{cache:'no-store'})).json();
el.innerHTML=detailHtml(id,r);
}
function detailHtml(id,r){
const p=r.player||{}; let h='';
const seen=[r.times_seen?`seen ${r.times_seen}×`:'', r.last_seen?`last ${String(r.last_seen).slice(0,10)}`:''].filter(Boolean).join(' · ');
if(seen) h+=`<div class="meta">${esc(seen)}</div>`;
if(r.stats) h+=`<div class="lbl">Stats</div><div>VPIP ${r.stats.vpip_pct} · PFR ${r.stats.pfr_pct} · WTSD ${r.stats.wtsd_pct} <span class="meta">(${r.stats.hands} hands)</span></div>`;
if(r.descriptors) h+=`<div class="lbl">Descriptors</div><div>${esc(r.descriptors)}</div>`;
if(p.tendencies) h+=`<div class="lbl">Tendencies</div><div>${esc(p.tendencies)}</div>`;
if(p.adjustment) h+=`<div class="lbl">Exploit</div><div>${esc(p.adjustment)}</div>`;
if((r.reads||[]).length){h+='<div class="lbl">Reads</div><ul>'+r.reads.slice(0,8).map(x=>`<li>${esc(x)}</li>`).join('')+'</ul>';}
if((r.notable_hands||[]).length){h+='<div class="lbl">Notable hands</div><ul>'+r.notable_hands.map(x=>`<li><a href="/hand/${x.hand_id}">hand #${x.hand_id}</a>${x.cards?' — '+esc(x.cards):''}${x.summary?' <span class="meta">'+esc(x.summary)+'</span>':''}</li>`).join('')+'</ul>';}
h+=`<div class="edit">
${p.named?'':`<input id="nm${id}" placeholder="give a name…" size="12"><button onclick="rename(${id})">Name</button>`}
<select id="cat${id}" onchange="setCat(${id})">
${['','feeder','risky','reg','unknown'].map(c=>`<option value="${c}" ${p.category===c?'selected':''}>${c||'category…'}</option>`).join('')}
</select></div>`;
return h;
}
async function rename(id){
const v=document.getElementById('nm'+id).value.trim(); if(!v)return;
await fetch(`/player/${id}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:v})});
load();
}
async function setCat(id){
const v=document.getElementById('cat'+id).value;
await fetch(`/player/${id}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({category:v})});
}
async function resolveTask(id,action,kw){
await fetch(`/identity/${id}/resolve`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,...kw})});
load();
}
async function scan(){
const r=await (await fetch('/players/scan',{method:'POST'})).json();
load();
}
load();
</script>
<script src="/nav.js"></script>
</body>
</html>