diff --git a/lyra/modes.py b/lyra/modes.py index 9dddbdd..af85d52 100644 --- a/lyra/modes.py +++ b/lyra/modes.py @@ -47,9 +47,10 @@ _BASE = ("journal_write", "note", "think_about", "thought_response", "set_mode") # The full live cash-game toolset (incl. Brian's mental-game rituals). _CASH_TOOLS = _BASE + _LOOKUPS + ( "start_session", "add_buyin", "log_stack", "log_hand", "record_hand", - "add_read", "name_villain", "link_villains", "analyze_spot", "session_stats", - "session_state", "end_session", "generate_recap", "scar_note", "confidence_bank", - "alligator_blood", "reset_ritual", "undo_last", "update_session", + "add_read", "seat_players", "unseat_player", "name_villain", "link_villains", + "analyze_spot", "session_stats", "session_state", "end_session", "generate_recap", + "scar_note", "confidence_bank", "alligator_blood", "reset_ritual", "undo_last", + "update_session", ) # Talk mode also gets start_session as the *entry point*: opening a session from a @@ -79,6 +80,13 @@ hand) — prefer this over log_hand so it lands on his timeline with a link. A r player → add_read. A rebuy → add_buyin. A result/pot → it rides with the hand. This is the \ quiet, fast half of the job; he shouldn't feel you working, but it must always happen. +THE TABLE ROSTER. When Brian names who's at the table — usually at the start, reading handles \ +off the Bravo screen ("we've got TAG, JD, Wheelz, and a new guy in seat 3") — call seat_players \ +to register them as seated this session. That roster is who his reads/TAGs attach to by name, \ +and it's shown on his HUD. When someone busts or leaves, unseat_player; when a new player sits, \ +seat_players again. Keep it current as the table changes. A handle like "TAG" (all caps, off \ +Bravo) is a PERSON'S NAME — seat it as a player, never read it as the tight-aggressive style. + LOGGING PLAYER ACTIONS IS A CORE JOB YOU KEEP MISSING. Whenever he tells you what another \ player did — "Tag limped A4o in the SB (UTG straddled pot)", "Jonathan called the 3bet", "the \ straddler shoved" — that is a READ on that player: call add_read(name=, note= 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, diff --git a/lyra/tools.py b/lyra/tools.py index 3b94fd4..14f743d 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -311,6 +311,26 @@ def _resolve_villain_ref(ref: str) -> tuple[int | None, str]: return None, res["band"] +def _seat_players(args: dict, ctx: dict) -> str: + players = args.get("players") or [] + # Accept a plain list of names too, for convenience. + if isinstance(players, str): + players = [p.strip() for p in re.split(r"[,\n]", players) if p.strip()] + try: + n = poker.seat_players(players) + except ValueError: + return "No live session — start one first, then I'll seat the table." + roster = poker.session_roster() + names = ", ".join(r["name"] for r in roster) or "—" + return f"Seated {n}. Table now: {names}" + + +def _unseat_player(args: dict, ctx: dict) -> str: + ok = poker.unseat_player(name=args.get("name"), descriptor=args.get("descriptor")) + who = args.get("name") or args.get("descriptor") or "player" + return f"{who} is off the table." if ok else f"Couldn't find {who} on the roster." + + def _name_villain(args: dict, ctx: dict) -> str: ref = (args.get("descriptor") or "").strip() name = (args.get("name") or "").strip() @@ -653,6 +673,26 @@ TOOLS.update({ "category": {**_S, "description": "feeder | risky | reg | unknown"}, "venue": {**_S, "description": "Where they play"}}, ["note"])}, + "seat_players": {"handler": _seat_players, "spec": _f( + "seat_players", + "Register who's at the table this session — the roster Brian reads off the Bravo " + "screen (handles like TAG, JD). Call this when he names the table (usually at the " + "start) or when a new player sits. Each player is a real handle in `name`, or a " + "`descriptor` if he only describes them. These become the roster his reads/TAGs " + "attach to by name.", + {"players": {"type": "array", "description": "Players to seat", + "items": {"type": "object", "properties": { + "name": {**_S, "description": "Handle as it appears on Bravo, e.g. 'TAG'"}, + "descriptor": {**_S, "description": "Physical description if no name"}, + "seat": {**_S, "description": "Seat number/label if known"}, + "category": {**_S, "description": "feeder | risky | reg | unknown"}}}}}, + ["players"])}, + "unseat_player": {"handler": _unseat_player, "spec": _f( + "unseat_player", + "Remove a player from the table roster when they bust or leave. Keeps their history.", + {"name": {**_S, "description": "Their handle"}, + "descriptor": {**_S, "description": "Or a description if unnamed"}}, + [])}, "name_villain": {"handler": _name_villain, "spec": _f( "name_villain", "Attach a real name to a player you'd only known by description (e.g. you caught it " diff --git a/lyra/web/static/session.html b/lyra/web/static/session.html index 1cc5a1b..f342823 100644 --- a/lyra/web/static/session.html +++ b/lyra/web/static/session.html @@ -265,6 +265,7 @@ const stack = data.stack || {}; const timeline = data.timeline || []; const hands = data.hands || []; + const roster = data.roster || []; const villains = data.villains || []; const notes = data.notes || []; const stats = data.stats || {}; @@ -369,6 +370,19 @@ : '

No scars logged — mistakes to study land here.

'} +
+

🪑 Table (${roster.length})

+ ${roster.length ? `
    ${roster.map(v => ` +
  • + ${v.seat ? `${esc(v.seat)} ` : ''}${esc(v.name)} + ${v.category ? `[${esc(v.category)}]` : ''} + ${v.reads ? `· ${v.reads} read${v.reads===1?'':'s'}` : ''} + + ${v.last_note ? `
    “${esc(v.last_note)}”
    ` : ''} +
  • `).join('')}
` + : '

No roster yet — tell Lyra who\\'s at the table.

'} +
+

Villains seen

${villains.length ? `
    ${villains.map(v => ` diff --git a/tests/test_roster.py b/tests/test_roster.py new file mode 100644 index 0000000..4a8409d --- /dev/null +++ b/tests/test_roster.py @@ -0,0 +1,79 @@ +"""Live table roster: seat players, attach reads by handle, roster on the HUD.""" +from __future__ import annotations + +import importlib + +import numpy as np +import pytest + + +def _fake_embed(texts): + out = [] + for t in texts: + v = np.zeros(64, dtype=np.float32) + for w in t.lower().split(): + v[hash(w) % 64] += 1.0 + out.append((v if v.any() else np.full(64, 1e-6, dtype=np.float32)).tolist()) + return out + + +@pytest.fixture +def mods(tmp_path, monkeypatch): + monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db")) + from lyra import llm + monkeypatch.setattr(llm, "embed", _fake_embed) + import lyra.memory as memory + importlib.reload(memory) + import lyra.poker as poker + importlib.reload(poker) + import lyra.tools as tools + importlib.reload(tools) + return poker, tools + + +def test_seat_players_builds_roster(mods): + poker, _ = mods + poker.start_session(venue="Meadows", buy_in=300) + n = poker.seat_players(["TAG", "Jonathan", {"name": "Wheelz", "seat": "3"}]) + assert n == 3 + roster = poker.session_roster() + names = {r["name"] for r in roster} + assert names == {"TAG", "Jonathan", "Wheelz"} + assert next(r for r in roster if r["name"] == "Wheelz")["seat"] == "3" + + +def test_read_attaches_to_seated_player_by_handle(mods): + poker, _ = mods + poker.start_session(venue="Meadows", buy_in=300) + poker.seat_players(["TAG"]) + poker.add_read(note="limped A4o from the SB, UTG straddle", name="TAG") + roster = poker.session_roster() + tag = next(r for r in roster if r["name"] == "TAG") + assert tag["reads"] == 1 and "A4o" in tag["last_note"] + # No duplicate TAG spawned — the read landed on the seated player. + assert sum(p["name"] == "TAG" for p in poker.get_villain_file()) == 1 + + +def test_seat_players_tool_and_roster_in_hud(mods): + poker, tools = mods + poker.start_session(venue="Meadows", buy_in=300) + out = tools.dispatch("seat_players", {"players": [{"name": "TAG"}, {"name": "JD"}]}, {}) + assert "TAG" in out and "JD" in out + assert len(poker.hud()["roster"]) == 2 + + +def test_unseat_player_removes_from_roster_keeps_history(mods): + poker, _ = mods + poker.start_session(venue="Meadows", buy_in=300) + poker.seat_players(["TAG"]) + poker.add_read(note="showed a bluff", name="TAG") + assert poker.unseat_player(name="TAG") is True + assert poker.session_roster() == [] # off the table + assert poker.player_profile("TAG")["reads"] # history intact + + +def test_seat_players_accepts_plain_name_list_via_tool(mods): + poker, tools = mods + poker.start_session(venue="Meadows", buy_in=300) + tools.dispatch("seat_players", {"players": "TAG, JD, Wheelz"}, {}) + assert {r["name"] for r in poker.session_roster()} == {"TAG", "JD", "Wheelz"}