feat: confirm-loop tools + descriptor reads for nameless villains

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 22:52:20 +00:00
parent 9cd962625d
commit f2944ed402
4 changed files with 184 additions and 7 deletions
+69 -2
View File
@@ -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"},