Big poker mode changes and hotfixes. #5
+16
-3
@@ -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 \
|
||||
|
||||
+14
-2
@@ -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(
|
||||
|
||||
+69
-2
@@ -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"},
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user