From f2944ed402e6b8ae4e25d08f4a2e9fabea771198 Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 3 Jul 2026 22:52:20 +0000 Subject: [PATCH] feat: confirm-loop tools + descriptor reads for nameless villains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3. She can now log and resolve identity at the table: - add_read gains a `descriptor` param — a read on an unnamed player resolves to an existing descriptor villain (confident match) or opens a new one, so reads on "neck tattoo guy" accumulate and reuse across the night. - name_villain(descriptor, name): attach a real name once caught (history carries). - link_villains(a, b, same): merge on confirmed same-person, or mark distinct so she stops asking. Refuses to act on a vague reference — never merges on a guess. - Cash card PLAYERS guidance: log nameless villains by distinctive descriptor, cite the SCOUTING DESK note, ask before assuming a callback, confirm before merge. 4 tests. Full suite 150 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/modes.py | 19 +++++++-- lyra/poker.py | 16 ++++++- lyra/tools.py | 71 ++++++++++++++++++++++++++++++- tests/test_villain_tools.py | 85 +++++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 tests/test_villain_tools.py diff --git a/lyra/modes.py b/lyra/modes.py index 1743f6b..513db9a 100644 --- a/lyra/modes.py +++ b/lyra/modes.py @@ -47,9 +47,9 @@ _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", "analyze_spot", "session_stats", "session_state", "end_session", - "generate_recap", "scar_note", "confidence_bank", "alligator_blood", "reset_ritual", - "undo_last", "update_session", + "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", ) # Talk mode also gets start_session as the *entry point*: opening a session from a @@ -99,6 +99,19 @@ thing that shows in the session's "notes" panel. This is NOT the place for how y existential musing, or reflection on yourself — that's your journal (journal_write), and it \ stays off the table. At the table you're logging the session, not processing your night. +PLAYERS — names AND nameless. Most villains don't come with a name; Brian knows them by a \ +look ("neck tattoo guy", "the bald reg two to my left"). Log reads on them anyway: give \ +`add_read` a `descriptor` instead of a name and it attaches to that unnamed player, reused \ +whenever he describes the guy again. Prefer DISTINCTIVE features (tattoos, build, a hat) over \ +generic ones — "mid-aged white guy in glasses" identifies no one. When you already have \ +history on someone he names or describes, a SCOUTING DESK note will appear with it — cite it, \ +don't invent. If you're not sure the guy he's describing is one you know, ASK ("same neck-\ +tattoo reg from last week?") rather than assume — a wrong callback is worse than none. On his \ +YES that two are the same person, call link_villains(same=true) to merge them; on "nah, \ +different guy," link_villains(same=false) so you stop asking. When he finally catches a name \ +for a described player, name_villain carries the whole history over. Never merge on a guess — \ +only when he's confirmed it. + Everything you log appears on Brian's live HUD (the Session view) — stack, live net, \ hands, villains, the confidence bank, the scar notes, and whether Alligator Blood is on. \ That HUD and you read the SAME data. So when he asks where he's at — his stack, his live \ diff --git a/lyra/poker.py b/lyra/poker.py index bed2df8..dd345a8 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -1393,14 +1393,26 @@ def scan_merge_candidates(sim_threshold: float = _SIM_HIGH) -> int: def add_read(note: str, seat: str | None = None, name: str | None = None, - session_id: int | None = None, **player_fields) -> int: - """Log a live read. If `name` is given, upsert the player and link the read.""" + descriptor: str | None = None, session_id: int | None = None, + **player_fields) -> int: + """Log a live read. `name` upserts a named player; `descriptor` (a nameless + villain's physical description) resolves to an existing descriptor villain when + confident, else opens a new one — so reads on unnamed players still accumulate.""" sid = _resolve(session_id) + venue = player_fields.get("venue") pid = None if name: pid = upsert_player(name, **{k: v for k, v in player_fields.items() if k in ("venue", "description", "tendencies", "adjustment", "category")}) + elif descriptor: + res = resolve_villain(descriptor, venue=venue, session_id=sid) + if res["band"] in ("name", "high") and res["match_id"]: + pid = res["match_id"] + add_descriptor(pid, descriptor) # sharpen the key with this phrasing + else: + pid = create_descriptor_villain(descriptor, venue=venue, + category=player_fields.get("category")) conn = _c() with conn: cur = conn.execute( diff --git a/lyra/tools.py b/lyra/tools.py index ecb04a2..32742e6 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -290,14 +290,60 @@ def _log_hand(args: dict, ctx: dict) -> str: def _add_read(args: dict, ctx: dict) -> str: poker.add_read( note=args.get("note") or "", seat=args.get("seat"), name=args.get("name"), + descriptor=args.get("descriptor"), tendencies=args.get("tendencies"), adjustment=args.get("adjustment"), description=args.get("description"), category=args.get("category"), venue=args.get("venue"), ) - who = f" on {args['name']}" if args.get("name") else "" + who = f" on {args['name']}" if args.get("name") else ( + f" on “{args['descriptor']}”" if args.get("descriptor") else "") return f"Read logged{who}." +def _resolve_villain_ref(ref: str) -> tuple[int | None, str]: + """Resolve a name-or-descriptor to a single player id for a confirm-loop action. + Returns (id, band); acts only on a deterministic name or a confident descriptor.""" + live = poker.live_session() + res = poker.resolve_villain(ref, venue=(live or {}).get("venue"), + session_id=(live or {}).get("id")) + if res["band"] in ("name", "high") and res["match_id"]: + return res["match_id"], res["band"] + return None, res["band"] + + +def _name_villain(args: dict, ctx: dict) -> str: + ref = (args.get("descriptor") or "").strip() + name = (args.get("name") or "").strip() + if not ref or not name: + return "Need both the description of the player and the name to attach." + pid, band = _resolve_villain_ref(ref) + if pid is None: + return (f"Couldn't confidently find “{ref}” to name — too vague or no match. " + "Add a read with the descriptor first, or be more specific.") + poker.name_villain(pid, name) + return f"Got it — “{ref}” is {name} now; their history carries over." + + +def _link_villains(args: dict, ctx: dict) -> str: + a = (args.get("player_a") or "").strip() + b = (args.get("player_b") or "").strip() + same = bool(args.get("same")) + if not a or not b: + return "Need two players to link (by name or description)." + ida, _ = _resolve_villain_ref(a) + idb, _ = _resolve_villain_ref(b) + if ida is None or idb is None: + return ("Couldn't confidently pin down both players, so I didn't merge anything — " + "safer to leave it. You can sort it on the Players page.") + if ida == idb: + return "Those resolve to the same profile already — nothing to do." + if same: + poker.merge_players(ida, idb) + return "Merged — same guy. Their histories are one file now." + poker.mark_distinct(ida, idb, note=args.get("note")) + return "Noted they're different people — I won't suggest merging them again." + + def _end_session(args: dict, ctx: dict) -> str: s = poker.end_session(cash_out=float(args.get("cash_out") or 0), mood=args.get("mood")) hourly = f", {s['net'] / s['hours']:+.0f}/hr" if s.get("hours") else "" @@ -585,9 +631,13 @@ TOOLS.update({ [])}, "add_read": {"handler": _add_read, "spec": _f( "add_read", - "Log a read on an opponent. If you give a name, it's saved to the persistent villain file.", + "Log a read on an opponent. Give a `name` if known; if not, give a `descriptor` " + "(a distinctive physical description like 'neck tattoo, backwards cap') and the read " + "attaches to that nameless player — reused automatically next time you describe him.", {"note": {**_S, "description": "The observation / what they showed down"}, "name": {**_S, "description": "Player name/handle if known (creates/updates their dossier)"}, + "descriptor": {**_S, "description": "Physical description when there's no name, e.g. " + "'neck tattoo, heavyset'. Prefer distinctive features over generic ones."}, "seat": {**_S, "description": "Seat or relative position"}, "tendencies": {**_S, "description": "Standing read on how they play"}, "adjustment": {**_S, "description": "How Brian should exploit them"}, @@ -595,6 +645,23 @@ TOOLS.update({ "category": {**_S, "description": "feeder | risky | reg | unknown"}, "venue": {**_S, "description": "Where they play"}}, ["note"])}, + "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 " + "off the Bravo screen). Their whole history carries over to the name.", + {"descriptor": {**_S, "description": "How you'd been referring to him, e.g. 'neck tattoo guy'"}, + "name": {**_S, "description": "His real name/handle"}}, + ["descriptor", "name"])}, + "link_villains": {"handler": _link_villains, "spec": _f( + "link_villains", + "Resolve a same-person question when Brian confirms it. same=true MERGES two profiles " + "into one (their histories join); same=false records they're DIFFERENT people so you " + "stop asking. Only call after he's confirmed — never merge on a guess.", + {"player_a": {**_S, "description": "First player, by name or description"}, + "player_b": {**_S, "description": "Second player, by name or description"}, + "same": {"type": "boolean", "description": "true = same person (merge); false = different"}, + "note": {**_S, "description": "For different people: the tell that distinguishes them"}}, + ["player_a", "player_b", "same"])}, "end_session": {"handler": _end_session, "spec": _f( "end_session", "Close the live session: record cashout, compute net + hours.", {"cash_out": {**_N, "description": "Final cashout amount"}, diff --git a/tests/test_villain_tools.py b/tests/test_villain_tools.py new file mode 100644 index 0000000..95856e2 --- /dev/null +++ b/tests/test_villain_tools.py @@ -0,0 +1,85 @@ +"""Confirm-loop tools: descriptor reads, name attach, merge/mark-distinct.""" +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_descriptor_read_creates_then_reuses_nameless_villain(mods): + poker, tools = mods + poker.start_session(venue="Meadows", stakes="1/3", buy_in=300) + tools.dispatch("add_read", {"note": "opened UTG light", + "descriptor": "neck tattoo sleeve arm"}, {}) + tools.dispatch("add_read", {"note": "showed a bluff", + "descriptor": "neck tattoo sleeve"}, {}) # rephrase → same guy + players = [p for p in poker.get_villain_file() if not p["named"]] + assert len(players) == 1 # one nameless villain, not two + reads = poker._c().execute( + "SELECT COUNT(*) n FROM player_reads WHERE player_id = ?", (players[0]["id"],) + ).fetchone()["n"] + assert reads == 2 + + +def test_name_villain_tool_attaches_name(mods): + poker, tools = mods + poker.start_session(venue="Meadows", buy_in=300) + poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows") + out = tools.dispatch("name_villain", {"descriptor": "neck tattoo sleeve arm", + "name": "Danny"}, {}) + assert "Danny" in out + assert poker.resolve_villain("Danny")["band"] == "name" + + +def test_link_villains_merge_and_distinct(mods): + poker, tools = mods + poker.start_session(venue="Meadows", buy_in=300) + poker.upsert_player("Danny", venue="Meadows") + poker.upsert_player("Donny", venue="Meadows") + # same=false → recorded distinct + tools.dispatch("link_villains", {"player_a": "Danny", "player_b": "Donny", + "same": False, "note": "different builds"}, {}) + a = poker.resolve_villain("Danny")["match_id"] + b = poker.resolve_villain("Donny")["match_id"] + assert poker.are_distinct(a, b) + # same=true on a fresh pair → merged + poker.upsert_player("Mike", venue="Meadows") + poker.upsert_player("Michael", venue="Meadows") + tools.dispatch("link_villains", {"player_a": "Mike", "player_b": "Michael", + "same": True}, {}) + names = [p["name"] for p in poker.get_villain_file()] + assert ("Mike" in names) ^ ("Michael" in names) # one absorbed the other + + +def test_link_villains_refuses_when_reference_is_vague(mods): + poker, tools = mods + poker.start_session(venue="Meadows", buy_in=300) + poker.upsert_player("Danny", venue="Meadows") + out = tools.dispatch("link_villains", {"player_a": "Danny", + "player_b": "some guy", "same": True}, {}) + assert "didn't merge" in out.lower() or "couldn't" in out.lower()