Update main. #6
+11
-3
@@ -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).
|
# The full live cash-game toolset (incl. Brian's mental-game rituals).
|
||||||
_CASH_TOOLS = _BASE + _LOOKUPS + (
|
_CASH_TOOLS = _BASE + _LOOKUPS + (
|
||||||
"start_session", "add_buyin", "log_stack", "log_hand", "record_hand",
|
"start_session", "add_buyin", "log_stack", "log_hand", "record_hand",
|
||||||
"add_read", "name_villain", "link_villains", "analyze_spot", "session_stats",
|
"add_read", "seat_players", "unseat_player", "name_villain", "link_villains",
|
||||||
"session_state", "end_session", "generate_recap", "scar_note", "confidence_bank",
|
"analyze_spot", "session_stats", "session_state", "end_session", "generate_recap",
|
||||||
"alligator_blood", "reset_ritual", "undo_last", "update_session",
|
"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
|
# 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 \
|
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.
|
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 \
|
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 \
|
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=<player>, note=<what \
|
straddler shoved" — that is a READ on that player: call add_read(name=<player>, note=<what \
|
||||||
|
|||||||
+107
-1
@@ -150,6 +150,17 @@ CREATE TABLE IF NOT EXISTS identity_queue (
|
|||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_idq_status ON identity_queue(status);
|
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).
|
# 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] = {}
|
counts: dict[str, int] = {}
|
||||||
with conn:
|
with conn:
|
||||||
for t in ("poker_hands", "player_observations", "player_reads",
|
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(
|
counts[t] = conn.execute(
|
||||||
f"SELECT COUNT(*) n FROM {t} WHERE session_id = ?", (session_id,)
|
f"SELECT COUNT(*) n FROM {t} WHERE session_id = ?", (session_id,)
|
||||||
).fetchone()["n"]
|
).fetchone()["n"]
|
||||||
@@ -1803,6 +1814,100 @@ def timeline(session_id: int | None = None) -> list[dict]:
|
|||||||
return events
|
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]:
|
def _session_villains(sid: int) -> list[dict]:
|
||||||
"""Players read this session, with their standing dossier fields."""
|
"""Players read this session, with their standing dossier fields."""
|
||||||
rows = _c().execute(
|
rows = _c().execute(
|
||||||
@@ -1878,6 +1983,7 @@ def hud(session_id: int | None = None) -> dict | None:
|
|||||||
"log": log,
|
"log": log,
|
||||||
},
|
},
|
||||||
"hands": hands,
|
"hands": hands,
|
||||||
|
"roster": session_roster(sid),
|
||||||
"villains": _session_villains(sid),
|
"villains": _session_villains(sid),
|
||||||
"timeline": timeline(sid),
|
"timeline": timeline(sid),
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
|
|||||||
@@ -311,6 +311,26 @@ def _resolve_villain_ref(ref: str) -> tuple[int | None, str]:
|
|||||||
return None, res["band"]
|
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:
|
def _name_villain(args: dict, ctx: dict) -> str:
|
||||||
ref = (args.get("descriptor") or "").strip()
|
ref = (args.get("descriptor") or "").strip()
|
||||||
name = (args.get("name") or "").strip()
|
name = (args.get("name") or "").strip()
|
||||||
@@ -653,6 +673,26 @@ TOOLS.update({
|
|||||||
"category": {**_S, "description": "feeder | risky | reg | unknown"},
|
"category": {**_S, "description": "feeder | risky | reg | unknown"},
|
||||||
"venue": {**_S, "description": "Where they play"}},
|
"venue": {**_S, "description": "Where they play"}},
|
||||||
["note"])},
|
["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": {"handler": _name_villain, "spec": _f(
|
||||||
"name_villain",
|
"name_villain",
|
||||||
"Attach a real name to a player you'd only known by description (e.g. you caught it "
|
"Attach a real name to a player you'd only known by description (e.g. you caught it "
|
||||||
|
|||||||
@@ -265,6 +265,7 @@
|
|||||||
const stack = data.stack || {};
|
const stack = data.stack || {};
|
||||||
const timeline = data.timeline || [];
|
const timeline = data.timeline || [];
|
||||||
const hands = data.hands || [];
|
const hands = data.hands || [];
|
||||||
|
const roster = data.roster || [];
|
||||||
const villains = data.villains || [];
|
const villains = data.villains || [];
|
||||||
const notes = data.notes || [];
|
const notes = data.notes || [];
|
||||||
const stats = data.stats || {};
|
const stats = data.stats || {};
|
||||||
@@ -369,6 +370,19 @@
|
|||||||
: '<p class="empty">No scars logged — mistakes to study land here.</p>'}
|
: '<p class="empty">No scars logged — mistakes to study land here.</p>'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p class="label">🪑 Table (${roster.length})</p>
|
||||||
|
${roster.length ? `<ul class="rows">${roster.map(v => `
|
||||||
|
<li class="villain">
|
||||||
|
${v.seat ? `<span class="cat">${esc(v.seat)}</span> ` : ''}<b>${esc(v.name)}</b>
|
||||||
|
${v.category ? `<span class="cat">[${esc(v.category)}]</span>` : ''}
|
||||||
|
${v.reads ? `<span class="cat">· ${v.reads} read${v.reads===1?'':'s'}</span>` : ''}
|
||||||
|
<button class="mini" title="Rename / fix" onclick="renamePlayer(${v.id}, '${esc(v.name||'').replace(/'/g,"\\'")}')">✎</button>
|
||||||
|
${v.last_note ? `<div class="note-meta">“${esc(v.last_note)}”</div>` : ''}
|
||||||
|
</li>`).join('')}</ul>`
|
||||||
|
: '<p class="empty">No roster yet — tell Lyra who\\'s at the table.</p>'}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="label">Villains seen</p>
|
<p class="label">Villains seen</p>
|
||||||
${villains.length ? `<ul class="rows">${villains.map(v => `
|
${villains.length ? `<ul class="rows">${villains.map(v => `
|
||||||
|
|||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user