feat: live table roster — seat_players / unseat_player + HUD card
The missing backbone for read tracking: a place for "who's at the table" to live. When Brian reads the table off Bravo (handles like TAG), Lyra registers them as seated this session; reads/TAGs then attach to those players by handle instead of spawning duplicates or getting missed. - session_players table; seat_player/seat_players/unseat_player/session_roster; _resolve_or_create_player (shared name/descriptor resolution, dedupe guard). - tools seat_players (accepts objects or a plain name list) + unseat_player. - HUD gains `roster`; Session page shows a 🪑 Table card (seat, handle, category, read count, last read). - Cash card: capture the roster when he names the table; a Bravo handle like TAG is a PERSON, seated as a player — never the tight-aggressive style. 5 tests. Full suite 162 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+107
-1
@@ -150,6 +150,17 @@ CREATE TABLE IF NOT EXISTS identity_queue (
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_idq_status ON identity_queue(status);
|
||||
|
||||
-- Who is seated at the table THIS session — the live roster Brian reads off Bravo
|
||||
-- at the start. Reads/TAGs attach to these players by handle; active=0 when they leave.
|
||||
CREATE TABLE IF NOT EXISTS session_players (
|
||||
session_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
seat TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (session_id, player_id)
|
||||
);
|
||||
"""
|
||||
|
||||
# Below this many observed hands, don't surface % stats (too small a sample).
|
||||
@@ -267,7 +278,7 @@ def delete_session(session_id: int) -> dict:
|
||||
counts: dict[str, int] = {}
|
||||
with conn:
|
||||
for t in ("poker_hands", "player_observations", "player_reads",
|
||||
"poker_stack_log", "poker_rituals"):
|
||||
"poker_stack_log", "poker_rituals", "session_players"):
|
||||
counts[t] = conn.execute(
|
||||
f"SELECT COUNT(*) n FROM {t} WHERE session_id = ?", (session_id,)
|
||||
).fetchone()["n"]
|
||||
@@ -1803,6 +1814,100 @@ def timeline(session_id: int | None = None) -> list[dict]:
|
||||
return events
|
||||
|
||||
|
||||
def _resolve_or_create_player(name: str | None = None, descriptor: str | None = None,
|
||||
venue: str | None = None, category: str | None = None) -> int | None:
|
||||
"""Turn a name-or-descriptor into a player id, matching an existing villain when
|
||||
confident. A description mistakenly given as a name is routed to the descriptor
|
||||
path so it dedupes (same guard add_read uses)."""
|
||||
if name and not descriptor and _looks_like_description(name):
|
||||
descriptor, name = name, None
|
||||
if name:
|
||||
return upsert_player(name, venue=venue, category=category)
|
||||
if descriptor:
|
||||
res = resolve_villain(descriptor, venue=venue)
|
||||
if res["band"] in ("name", "high") and res["match_id"]:
|
||||
add_descriptor(res["match_id"], descriptor)
|
||||
return res["match_id"]
|
||||
return create_descriptor_villain(descriptor, venue=venue, category=category)
|
||||
return None
|
||||
|
||||
|
||||
def seat_player(name: str | None = None, descriptor: str | None = None, seat: str | None = None,
|
||||
category: str | None = None, session_id: int | None = None) -> int | None:
|
||||
"""Seat one player at the live table (add to the roster). Idempotent per session."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
raise ValueError("no live session")
|
||||
venue = (get_session(sid) or {}).get("venue")
|
||||
pid = _resolve_or_create_player(name=name, descriptor=descriptor, venue=venue, category=category)
|
||||
if pid is None:
|
||||
return None
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO session_players (session_id, player_id, seat, active, created_at) "
|
||||
"VALUES (?, ?, ?, 1, ?) ON CONFLICT(session_id, player_id) DO UPDATE SET "
|
||||
"active = 1, seat = COALESCE(excluded.seat, session_players.seat)",
|
||||
(sid, pid, seat, _now()),
|
||||
)
|
||||
return pid
|
||||
|
||||
|
||||
def seat_players(players: list, session_id: int | None = None) -> int:
|
||||
"""Seat a whole table at once. Each item is a name string or a dict with
|
||||
name/descriptor/seat/category. Returns how many were seated."""
|
||||
n = 0
|
||||
for p in players or []:
|
||||
if isinstance(p, str):
|
||||
ok = seat_player(name=p, session_id=session_id)
|
||||
elif isinstance(p, dict):
|
||||
ok = seat_player(name=p.get("name"), descriptor=p.get("descriptor"),
|
||||
seat=p.get("seat"), category=p.get("category"), session_id=session_id)
|
||||
else:
|
||||
ok = None
|
||||
if ok:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def unseat_player(name: str | None = None, descriptor: str | None = None,
|
||||
session_id: int | None = None) -> bool:
|
||||
"""Mark a seated player as gone (busted/left). Keeps their reads/history."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return False
|
||||
ref = name or descriptor or ""
|
||||
res = resolve_villain(ref, venue=(get_session(sid) or {}).get("venue"), session_id=sid)
|
||||
pid = res.get("match_id")
|
||||
if pid is None:
|
||||
return False
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute("UPDATE session_players SET active = 0 WHERE session_id = ? AND player_id = ?",
|
||||
(sid, pid))
|
||||
return True
|
||||
|
||||
|
||||
def session_roster(session_id: int | None = None) -> list[dict]:
|
||||
"""The live table roster: seated players with seat, dossier, and their latest
|
||||
read this session. This is 'who's at the table right now'."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return []
|
||||
rows = _c().execute(
|
||||
"SELECT sp.seat AS seat, p.id AS id, p.name AS name, p.named AS named, "
|
||||
"p.category AS category, p.tendencies AS tendencies, "
|
||||
"(SELECT note FROM player_reads r WHERE r.player_id = p.id AND r.session_id = ? "
|
||||
" ORDER BY r.id DESC LIMIT 1) AS last_note, "
|
||||
"(SELECT COUNT(*) FROM player_reads r2 WHERE r2.player_id = p.id AND r2.session_id = ?) AS reads "
|
||||
"FROM session_players sp JOIN poker_players p ON p.id = sp.player_id "
|
||||
"WHERE sp.session_id = ? AND sp.active = 1 "
|
||||
"ORDER BY CASE WHEN sp.seat IS NULL THEN 1 ELSE 0 END, sp.seat, p.name",
|
||||
(sid, sid, sid),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _session_villains(sid: int) -> list[dict]:
|
||||
"""Players read this session, with their standing dossier fields."""
|
||||
rows = _c().execute(
|
||||
@@ -1878,6 +1983,7 @@ def hud(session_id: int | None = None) -> dict | None:
|
||||
"log": log,
|
||||
},
|
||||
"hands": hands,
|
||||
"roster": session_roster(sid),
|
||||
"villains": _session_villains(sid),
|
||||
"timeline": timeline(sid),
|
||||
"notes": notes,
|
||||
|
||||
Reference in New Issue
Block a user