Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86f3d2dc0a | |||
| abac42c344 | |||
| 44bb8687f7 | |||
| cb4ed10c1a | |||
| ba00530caf |
@@ -53,9 +53,6 @@ DB, no shared UI components. If RTO is down, Lyra skips analysis and nothing bre
|
||||
- **Positions:** `UTG UTG1 UTG2 MP LJ HJ CO BTN SB BB`.
|
||||
- **Actions:** `post fold check call bet raise allin`. `amount` is a plain number (no `$`),
|
||||
null for non-sized actions (fold/check). Street boards appear as `{street, board}` entries.
|
||||
A **straddle** is recorded as a preflop `post` at a non-blind position (typically 2×BB,
|
||||
voluntary); preflop action starts left of it and it acts last, but that's reflected by
|
||||
action *order*, not a distinct verb.
|
||||
- **Streets:** `preflop flop turn river`.
|
||||
|
||||
`lyra/poker.py:normalize_structured()` is the single function that guarantees this shape.
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
# Hand recorder — design note
|
||||
|
||||
A tap-to-build hand recorder. The point isn't "nicer input" — it's **correctness by
|
||||
construction**: every tap writes a known action into a known slot, so there's no parse
|
||||
step that can be wrong. It sidesteps the whole class of LLM-parse replay bugs. The text
|
||||
parser stays for importing the backlog (old notes, Trilium, ChatGPT history); the recorder
|
||||
is clean capture going forward.
|
||||
|
||||
Output is the canonical structured shape in [HAND_HISTORY.md](HAND_HISTORY.md) — so it
|
||||
drops straight into the DB and the existing replay viewer, and flows to RTO unchanged.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Correctness by construction** — the UI only lets you build valid hands; the emitter
|
||||
produces the contract shape; the server `normalize_structured()` is the final guarantee.
|
||||
2. **Reusable module, mount-agnostic.** Decision: overlay first, swap to standalone if the
|
||||
overlay fights the chat page — *reusing the code either way*. So the recorder is a
|
||||
self-contained module mounted into a container element, with the **emit logic kept pure
|
||||
(no DOM)**. Moving overlay → standalone is a re-mount, not a rewrite.
|
||||
3. **Don't reinvent what Lyra knows.** Pre-fill from live session state.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
lyra/web/static/recorder.js # the module: state machine + DOM shell + buildStructured()
|
||||
lyra/web/static/recorder.css # scoped styles (full-screen table + keypad)
|
||||
```
|
||||
|
||||
- `Recorder.mount(containerEl, { sessionId, onSave, onClose })` — instantiates into any
|
||||
container. In V1 the container is a full-screen overlay `<div>` inside `index.html`
|
||||
(chat/session page stays mounted underneath — a flip-over, not a route change). If that
|
||||
proves janky, the *same module* mounts into `recorder.html` with zero logic changes.
|
||||
- **Pure core, separable from DOM:** `buildStructured(state) -> structuredDict`. No element
|
||||
access — takes the in-memory state, returns the contract object. This is the testable,
|
||||
reusable heart; the DOM shell only reads/writes `state` and calls `buildStructured` on save.
|
||||
|
||||
## Pre-fill from live state (the "it already knows" feel)
|
||||
|
||||
Source: `GET /session/data` (→ `poker.hud`). Today it gives us:
|
||||
- `session`: `venue`, `stakes`, `game`, `format`, `is_live`
|
||||
- `stack.current` → hero's starting stack for the hand
|
||||
- `villains[]`: `name`, `category`, `tendencies`, `last_note` (the players read this session)
|
||||
|
||||
Derived in the recorder:
|
||||
- **Blinds** parsed from `stakes` ("1/3" → SB 1, BB 3) → auto-seed the `post` actions.
|
||||
- **Hero stack** from `stack.current`.
|
||||
|
||||
**Two gaps to close as part of the build** (flagged, not yet done):
|
||||
1. **Seats aren't in the HUD bundle.** `player_reads` *has* a `seat` column, but
|
||||
`_session_villains()` doesn't select it — so we can name the villains but not place them.
|
||||
Fix: add `seat` (latest read per player) to the villains payload, then auto-seat them.
|
||||
Until then, V1 seats known villains in read-order and you assign positions by tapping.
|
||||
2. **Hero position isn't tracked live** (button/seat moves every hand) — so `hero_pos` is a
|
||||
per-hand tap, seeded to last-used. That's correct, not a gap to "fix", just noting it.
|
||||
|
||||
## In-memory state model
|
||||
|
||||
```js
|
||||
state = {
|
||||
meta: { game, stakes, venue, sessionId }, // from /session/data
|
||||
blinds: { sb, bb }, // parsed from stakes
|
||||
heroPos: "BTN", // tapped per hand
|
||||
seats: [ { pos, name, stack, cards: null, in: true } ], // incl. hero seat
|
||||
street: "preflop", // street currently being entered
|
||||
board: { flop: [], turn: [], river: [] },
|
||||
actions:[ { street, pos, action, amount } ], // appended as you tap
|
||||
result: { pot: null, heroNet: null, summary: "" }
|
||||
}
|
||||
```
|
||||
|
||||
`buildStructured(state)` →
|
||||
|
||||
| contract field | from |
|
||||
|---|---|
|
||||
| `hero_pos` | `state.heroPos` |
|
||||
| `hero_cards` | the hero seat's `cards` |
|
||||
| `players[]` | `state.seats` (`{pos,stack,name,cards}`; emitter doesn't set `hero`/version — server normalize does) |
|
||||
| `actions[]` | `state.actions`, with a `{street, board}` reveal entry spliced in at each street boundary from `state.board` |
|
||||
| `board` | `flop + turn + river` concatenated |
|
||||
| `result` | `state.result` |
|
||||
|
||||
Client builds best-effort; **`store_hand_history()` → `normalize_structured()` is the
|
||||
authority** (canonical cards, hero sync, `schema_version`, `completeness`). Keeps the
|
||||
client dumb and the contract enforced in one place.
|
||||
|
||||
## Persistence
|
||||
|
||||
New endpoint (small, part of the build):
|
||||
```
|
||||
POST /hands body: { structured, session_id?, tag?, lesson? }
|
||||
-> store_hand_history(structured, ...) -> { id }
|
||||
```
|
||||
On save: POST, then hand off to the existing viewer `/hand/{id}` to replay — which doubles
|
||||
as the correctness check (what you tapped is exactly what replays).
|
||||
|
||||
## Scope
|
||||
|
||||
**V1 — core loop (chosen).** Seats + cards + per-street actions emitting valid structured
|
||||
JSON. Manual street advance (a "next street" button + board entry), free bet-size entry
|
||||
(type the number). Proves capture → store → replay end to end on the locked schema.
|
||||
|
||||
### Card entry (V1)
|
||||
|
||||
Contextual: tap a card slot (hero card, board square, "they showed") → a compact picker
|
||||
pops at that slot. One picker holds **4 color-coded suits + 13 ranks + `x` + unknown-card**.
|
||||
Whichever you tap first sets the flow for that card — no mode switch:
|
||||
|
||||
- **Suit first → it locks** (stays lit). Each subsequent rank tap places `rank+lockedSuit`
|
||||
and auto-advances. Flush flop = `♥ T 8 5` (4 taps); suited hole = `♥ A K` (3 taps).
|
||||
- **Rank first → card is pending a suit**; the next tap must be a suit (or `x`). Best for
|
||||
rainbow/mixed. Locked suit stays in effect until a different suit is tapped.
|
||||
- **`x`** = unknown suit → stores e.g. `Ax`; flips `completeness.cards` false so RTO skips
|
||||
suit-dependent math. **Unknown-card** button = a villain card never shown (`x`).
|
||||
|
||||
No typing — lowercase tokens are the internal/contract format only; the player only ever
|
||||
taps symbols. State: `lockedSuit` (nullable) + the active slot; auto-advance on complete.
|
||||
|
||||
**V2 — the smart keypad.** The contextual state machine layered on top: tracks whose turn
|
||||
it is and the current bet, offers only legal actions (check vs call; bet/raise reveal size
|
||||
presets ½/¾/pot/+1bb), auto-advances the street when action closes, tap-a-seat to set the
|
||||
actor, one-tap "they showed [cards]" at showdown. ~6–10 taps, no typing. Built on the same
|
||||
`state` + `buildStructured`, so V1's emitter doesn't change — V2 just drives `state` smarter.
|
||||
|
||||
## Layout sketch (full-screen overlay)
|
||||
|
||||
```
|
||||
┌───────────────────────────── Record hand ─────────────── ✕ ┐
|
||||
│ (CO) (BTN) │
|
||||
│ (HJ) ◯ oval table ◯ (SB) │
|
||||
│ (MP) (UTG) (BB·hero) │
|
||||
│ board: [ 7d ][ 2c ][ 5h ] pot: 40 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ acting: BB [ fold ][ check ][ call ][ bet ][ raise ] │
|
||||
│ amount: [ 15 ] [ ½ ][ ¾ ][ pot ][ +1bb ] (V2) │
|
||||
│ [ ◀ prev street ] [ next street ▶ ] [ they showed… ] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ preflop: BTN raise 15 · BB call [ save & replay ] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Build order
|
||||
|
||||
1. `POST /hands` endpoint + add `seat` to the villains payload (server, small).
|
||||
2. `recorder.js` skeleton: `mount()`, `state`, `buildStructured()` (pure).
|
||||
3. Overlay shell in `index.html` (open button in session/cash mode) + `recorder.css`.
|
||||
4. V1 capture flow → save → replay. Validate a real hand round-trips identically.
|
||||
5. V2 smart keypad on top.
|
||||
```
|
||||
@@ -0,0 +1,115 @@
|
||||
# Poker mode: message-type-specific prompts + dumb capture
|
||||
|
||||
- **Date:** 2026-06-28
|
||||
- **Status:** Draft for review
|
||||
- **Scope:** Poker (`poker_cash`) mode only. This is the template; the same pattern will later extend to Build/Explore/Study/Decide in a separate pass.
|
||||
|
||||
## Problem
|
||||
|
||||
In poker mode Lyra routes correctly but her replies are generic — "stringing poker words together." Evidence from the 2026-06-27 (Meadows) and 2026-06-28 (Wheeling) sessions in chat `sess-dff2s91c`:
|
||||
|
||||
- **Every turn is a coaching essay, including pure data.** A stack update (`Stack=$685`) draws 4–6 sentences of momentum filler ("keep that momentum rolling," "you've got this"). Bare facts don't need a brain.
|
||||
- **She projects tilt/fatigue onto neutral facts.** "Table broke, it's 11:50pm" → three replies about "late-night fatigue-driven decisions… mental reset." Brian had to say *"I'm not upset… you seem to be reading me as tilted."* Cause: the `_route` mood nudge (`lyra/mind.py:328`) firing on non-mood messages.
|
||||
- **She doesn't reason about bet intent.** On a hand where Brian flopped bottom set, turned a full house, value bet $40 and got 88 to fold, she said *"his fold… shows the power of representing something stronger… well executed."* That was a **value bet that got no value** — money left on the table — not a successful rep. She pattern-matched "bet → fold → good."
|
||||
- **She never calls `analyze_spot`; she eyeballs.** The 77 multiway spot got *"a disciplined fold might have been the better path"* — pure hedge, no math, violating the persona's "never eyeball poker math" rule.
|
||||
- **Even her best reply leaks bad poker logic.** The sharp Connie read included "limp-checking in position" — contradictory nonsense — proving "sounds sharp" ≠ "is correct."
|
||||
|
||||
Root cause: one large per-turn card (`_CASH_CARD`, `lyra/modes.py:66`) describes **traits** ("be decisive," "be present"). The model satisfies trait language with safe, flattering abstraction. Pure-data turns shouldn't reach the model at all; judgment turns need concrete response contracts, not adjectives.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Pure data entry (stack, buy-in, cash-out) never touches the LLM.
|
||||
2. Conversational turns get a **message-type-specific** prompt instead of one broad card.
|
||||
3. Kill the false tilt/fatigue reads.
|
||||
4. On hands, reason about **bet intent** and lean on `analyze_spot` — flag missed value, value-owning, and sizing rather than reflexively praising.
|
||||
5. Fix the iOS-PWA bottom safe-area gap surfaced in testing.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Rewriting the other five modes (separate pass).
|
||||
- Changing the tool handlers themselves (`poker.log_stack`, etc. already exist and are LLM-independent).
|
||||
- An LLM-based classifier in v1 (the design leaves the seam for it; v1 ships heuristics).
|
||||
- Itemized buy-in history (buy-ins remain a single `buy_in_total`).
|
||||
|
||||
## Architecture
|
||||
|
||||
Split poker input by whether it needs judgment.
|
||||
|
||||
- **Track 1 — dumb capture:** pure data → direct HTTP endpoint → tool handler. No prompt, no reply.
|
||||
- **Track 2 — conversational:** anything needing a brain → a classifier tags the message type → a type-specific prompt fragment is injected in place of the giant card.
|
||||
- **PWA fix:** correct the bottom safe-area inset.
|
||||
|
||||
### Deliverable 1 — Track 1: dumb capture (no LLM)
|
||||
|
||||
**Backend (new endpoints in `lyra/web/server.py`).** Today the *only* write path for a stack/buy-in is chat → tool-calling; no direct endpoint exists. Add:
|
||||
|
||||
- `POST /session/stack` → `poker.log_stack(amount)` → returns updated `stack_state()`. Server stamps the time.
|
||||
- `POST /session/buyin` → `poker.add_buyin(amount)` → returns new `buy_in_total`.
|
||||
- Cash-out reuses the existing `PATCH /session/{id}` with `{cash_out}` (already recomputes `net`).
|
||||
|
||||
Both resolve the live session via `poker._resolve(None)` and return `{"ok": ..., "stack": ...}`. On "no live session" return a clear error the UI can show.
|
||||
|
||||
**Frontend — the 2nd input box (`lyra/web/static/index.html`).** A slim quick-capture row **below the message input, above the bottom nav icons**. Behavior:
|
||||
|
||||
- Type a number (e.g. `685`) → `POST /session/stack` → on success, append a one-line confirmation to the **Live Log** (`$685 logged · 11:34pm`) and update the HUD. **No chat message, no LLM call, main message box untouched.**
|
||||
- Scope: **stack only** in v1. Buy-in and cash-out live on the Session HUD widget below.
|
||||
- Empty/non-numeric input is ignored (or shows inline hint). Decimal and `$`-prefixed input tolerated (`$685`, `685`).
|
||||
|
||||
**Frontend — HUD widget (`lyra/web/static/session.html`).** In the Stack card (`session.html:280`), mirroring the existing `saveEdit()` PATCH pattern (`session.html:192`): a stack field (`POST /session/stack`), a buy-in field (`POST /session/buyin`), and a cash-out field (existing PATCH). Each updates the sparkline on success.
|
||||
|
||||
### Deliverable 2 — Track 2: classifier + type fragments
|
||||
|
||||
**Classifier (`lyra/poker_classify.py`, new).** A function `classify(message: str) -> str` returning one of `HAND | STATUS | MENTAL | LOG | CHAT`. v1 is heuristic; the signature is the swappable seam — an LLM/MI50 implementation can replace the body later with no other changes.
|
||||
|
||||
Heuristic signals (first match wins, in priority order):
|
||||
|
||||
1. **HAND** — card-token regex (`\b[2-9TJQKA][shdc]\b` appearing ≥2×), or position tokens (UTG/MP/HJ/CO/BTN/SB/BB/"button"/"hijack"), or street words (flop/turn/river) combined with betting verbs (bet/raise/call/fold/check/shove/limp).
|
||||
2. **MENTAL** — first-person feeling: "I feel", "I'm tilted/steaming/fried/tired/frustrated/confident", "in my head", "mental", "leak", "on tilt".
|
||||
3. **STATUS** — time/venue/logistics with no cards: "table broke", "new table", "waiting for a seat", "seat opened", clock times, venue names.
|
||||
4. **LOG** — prose money/result that slipped past the 2nd box: "I'm at 350", "stack is", "out for", "cashed", "rebought" with a number.
|
||||
5. **CHAT** — default fallback (questions, open talk).
|
||||
|
||||
Ambiguous → `CHAT` (safe full-voice default).
|
||||
|
||||
**Pipeline integration (`lyra/mind.py`).** Add a `_classify` step to `PIPELINE` (after `_route`, before `_compose`) that sets `ctx.msg_type` when `ctx.mode.key == "poker_cash"`. In `build_messages` (`mind.py:137`), when in poker mode, inject the **type fragment** in the seat currently held by `mode.card` (`mind.py:152`) instead of the whole `_CASH_CARD`. Non-poker modes are unchanged.
|
||||
|
||||
**Fragment registry (new `lyra/poker_prompts.py`).** `POKER_FRAGMENTS: dict[str, str]` keyed by type. Each fragment is short and concrete (rules + response shape), not trait language. Persona guardrails ("never eyeball math," "don't invent reads") stay sourced from `personas/lyra.md` via `_persona_block`.
|
||||
|
||||
Response contracts:
|
||||
|
||||
- **HAND.** Steps, in order: (1) log it (`record_hand` preferred, or `log_hand`); (2) if the decision is close or equity matters, call `analyze_spot` — do **not** eyeball; (3) reason about **bet intent** — for each meaningful bet, name what it was for (value / bluff / protection) and whether it achieved that (a fold to a value bet = value lost — flag it; a call of a bluff = bluff failed); (4) name leaks plainly (value-owning, missed value, bad sizing); (5) one clear opinion. **No reflexive praise** — do not open with "nice hand." If a named villain is referenced, call `player_profile`/`get_villain_file` first; never invent a read. Talk in prose, not a listicle. Seed with 2 exemplars (below).
|
||||
- **STATUS.** 1–2 sentences. Acknowledge, log a stack only if a bare number is present, then stop. **No coaching. No tilt/fatigue reads.**
|
||||
- **MENTAL.** Full presence, real voice, an actual point of view. (This register already works — preserve it.)
|
||||
- **LOG.** One line. `$317 logged.` Nothing else.
|
||||
- **CHAT.** Full voice, a real opinion, no filler sign-offs.
|
||||
|
||||
**HAND exemplars** (embedded in the fragment):
|
||||
|
||||
> *Brian:* "Flopped bottom set with 22, turned a full house, bet $40 on the river, he folded 88."
|
||||
> *Lyra:* "That's a value bet that got no value — 88 was never folding to a smaller stab. $40 into that pot was too much; ~$20–25 gets called by the over-pair and the worse two-pairs. The hand's a monster, the leak is the sizing."
|
||||
|
||||
> *Brian:* "77 in a multiway pot, flop 4h5c3c, HJ bets 75, BTN shoves 74, I call. Turn/river check through, HJ wins with 64."
|
||||
> *Lyra:* "Against a bet and a shove on 4-5-3 you're drawing thin — sets, two pair, and the made wheel are all ahead, and you block almost none of it. The stack-depth logic (he only had ~150 behind) is real, but that's a reason to fold and wait, not to call off light. This is the value-owning spot you flagged yourself."
|
||||
|
||||
**Pipeline fixes:**
|
||||
|
||||
- Remove/gate the `_route` mood nudge (`mind.py:328`) so a steady→"steady" / up→"hype" note is **not** appended on non-MENTAL messages. Mental-game register comes only from the MENTAL fragment.
|
||||
- Trim the always-on mode-menu note (`mind.py:77`) inside poker mode — it dilutes every turn.
|
||||
|
||||
### Deliverable 3 — PWA bottom safe-area fix
|
||||
|
||||
The band below the nav icons is an iOS-PWA safe-area issue (likely `100vh` not accounting for `env(safe-area-inset-bottom)` / the home indicator). Fix the layout container and bottom nav in the web CSS so the app fills the viewport with the icons seated above the home indicator. Use the `building-ios-pwas` skill at implementation time.
|
||||
|
||||
## Testing / verification
|
||||
|
||||
- **Classifier unit tests:** feed the real user turns from both transcripts and assert the expected type (e.g. "Stack is at $373" → LOG/handled by box, "Button straddle… I limp UTG with 22…" → HAND, "table broke, it's 11:50pm" → STATUS, "I feel like I'm being mean when I raise" → MENTAL).
|
||||
- **Endpoint tests:** `POST /session/stack` / `/session/buyin` against a live test session; assert the row is written, time stamped, and `stack_state()` updates; assert a clean error when no live session.
|
||||
- **Transcript replay (eyeball eval):** run yesterday's + today's user turns through the new classifier + fragments and compare replies to the logged mush — confirm stack updates produce no essay, the $40 hand is flagged as missed value, and STATUS turns carry no tilt-reading.
|
||||
- **PWA:** verify on the iOS PWA that the gap is gone and the new input box sits above the nav with the keyboard open.
|
||||
|
||||
## Out of scope / future
|
||||
|
||||
- LLM classifier behind the same interface (if heuristics prove brittle).
|
||||
- Extending message-type fragments to the other five modes.
|
||||
- Itemized buy-in history / per-table stack series.
|
||||
- Buy-in/cash-out in the chat-page 2nd box (HUD only for v1).
|
||||
+21
-2
@@ -9,20 +9,39 @@ a long silence *means* to her is left to her own reflection, not prescribed here
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lyra import config
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _local_tz() -> ZoneInfo | timezone:
|
||||
"""Brian's configured local zone (falls back to UTC if it can't be loaded)."""
|
||||
try:
|
||||
return ZoneInfo(config.load().timezone)
|
||||
except Exception:
|
||||
return timezone.utc
|
||||
|
||||
|
||||
def _parse(iso: str) -> datetime:
|
||||
dt = datetime.fromisoformat(iso)
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def short(iso_or_dt: str | datetime | None = None) -> str:
|
||||
"""Local time-of-day like '10:45pm', for timeline rows."""
|
||||
dt = _parse(iso_or_dt) if isinstance(iso_or_dt, str) else (iso_or_dt or now())
|
||||
return dt.astimezone(_local_tz()).strftime("%-I:%M%p").lower()
|
||||
|
||||
|
||||
def stamp(dt: datetime | None = None) -> str:
|
||||
"""Wall-clock stamp, e.g. 'Wednesday, 17 Jun 2026, 01:50 UTC'."""
|
||||
return (dt or now()).strftime("%A, %d %b %Y, %H:%M UTC")
|
||||
"""Wall-clock stamp in Brian's local timezone, e.g.
|
||||
'Friday, 27 Jun 2026, 01:50 EDT'. Times are stored UTC; this is what she *reads*,
|
||||
so 'what time is it' answers in his time, not UTC."""
|
||||
return (dt or now()).astimezone(_local_tz()).strftime("%A, %d %b %Y, %H:%M %Z")
|
||||
|
||||
|
||||
def gap_seconds(since_iso: str | None, ref: datetime | None = None) -> float | None:
|
||||
|
||||
+49
-13
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Iterator, Literal, TypedDict
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
from lyra import logbus
|
||||
from lyra.config import load
|
||||
|
||||
|
||||
@@ -18,30 +20,54 @@ class Message(TypedDict):
|
||||
Backend = Literal["local", "cloud", "mi50"]
|
||||
|
||||
|
||||
def _approx_tok(messages: list) -> int:
|
||||
"""Rough prompt size (chars/4) — enough to see what's loading a backend."""
|
||||
total = 0
|
||||
for m in messages or []:
|
||||
if isinstance(m, dict) and isinstance(m.get("content"), str):
|
||||
total += len(m["content"])
|
||||
return total // 4
|
||||
|
||||
|
||||
def _resolved_model(cfg, backend: Backend, model: str | None) -> str:
|
||||
if backend == "cloud":
|
||||
return model or cfg.cloud_model
|
||||
if backend == "mi50":
|
||||
return model or cfg.mi50_model
|
||||
return model or cfg.local_model
|
||||
|
||||
|
||||
def complete(messages: list[Message], backend: Backend = "local", model: str | None = None) -> str:
|
||||
"""Generate a completion. `model` overrides the backend's default model
|
||||
(used so live chat can run a stronger cloud model than bulk consolidation)."""
|
||||
cfg = load()
|
||||
mdl = _resolved_model(cfg, backend, model)
|
||||
logbus.log("info", "llm call", kind="complete", backend=backend, model=mdl, tok=_approx_tok(messages))
|
||||
t0 = time.monotonic()
|
||||
|
||||
if backend == "cloud":
|
||||
if not cfg.openai_api_key:
|
||||
raise RuntimeError("OPENAI_API_KEY is not set")
|
||||
client = OpenAI(api_key=cfg.openai_api_key)
|
||||
resp = client.chat.completions.create(model=model or cfg.cloud_model, messages=messages)
|
||||
return resp.choices[0].message.content or ""
|
||||
|
||||
if backend == "mi50":
|
||||
resp = client.chat.completions.create(model=mdl, messages=messages)
|
||||
out = resp.choices[0].message.content or ""
|
||||
elif backend == "mi50":
|
||||
# MI50 box runs an OpenAI-compatible llama.cpp server; key is unused.
|
||||
client = OpenAI(api_key="not-needed", base_url=cfg.mi50_base_url)
|
||||
resp = client.chat.completions.create(model=model or cfg.mi50_model, messages=messages)
|
||||
return resp.choices[0].message.content or ""
|
||||
resp = client.chat.completions.create(model=mdl, messages=messages)
|
||||
out = resp.choices[0].message.content or ""
|
||||
else:
|
||||
resp = httpx.post(
|
||||
f"{cfg.local_base_url}/api/chat",
|
||||
json={"model": mdl, "messages": messages, "stream": False},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
out = resp.json()["message"]["content"]
|
||||
|
||||
resp = httpx.post(
|
||||
f"{cfg.local_base_url}/api/chat",
|
||||
json={"model": model or cfg.local_model, "messages": messages, "stream": False},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["message"]["content"]
|
||||
logbus.log("info", "llm done", kind="complete", backend=backend,
|
||||
ms=int((time.monotonic() - t0) * 1000), out=len(out))
|
||||
return out
|
||||
|
||||
|
||||
def chat_call(
|
||||
@@ -68,6 +94,8 @@ def chat_call(
|
||||
kwargs: dict = {"model": mdl, "messages": messages}
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
logbus.log("info", "llm call", kind="chat", backend=backend, model=mdl, tok=_approx_tok(messages))
|
||||
t0 = time.monotonic()
|
||||
msg = client.chat.completions.create(**kwargs).choices[0].message
|
||||
tcs = None
|
||||
if getattr(msg, "tool_calls", None):
|
||||
@@ -75,6 +103,9 @@ def chat_call(
|
||||
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
logbus.log("info", "llm done", kind="chat", backend=backend,
|
||||
ms=int((time.monotonic() - t0) * 1000), out=len(msg.content or ""),
|
||||
tools=[t["name"] for t in tcs] if tcs else None)
|
||||
return msg.model_dump(), tcs
|
||||
|
||||
# local (Ollama): no tool-calling here — return plain content.
|
||||
@@ -105,6 +136,8 @@ def chat_call_stream(
|
||||
kwargs: dict = {"model": mdl, "messages": messages, "stream": True}
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
logbus.log("info", "llm call", kind="chat-stream", backend=backend, model=mdl, tok=_approx_tok(messages))
|
||||
t0 = time.monotonic()
|
||||
parts: list[str] = []
|
||||
frags: dict[int, dict] = {} # tool-call fragments accumulated by index
|
||||
for chunk in client.chat.completions.create(**kwargs):
|
||||
@@ -123,6 +156,9 @@ def chat_call_stream(
|
||||
if tc.function and tc.function.arguments:
|
||||
slot["arguments"] += tc.function.arguments
|
||||
content = "".join(parts)
|
||||
logbus.log("info", "llm done", kind="chat-stream", backend=backend,
|
||||
ms=int((time.monotonic() - t0) * 1000), out=len(content),
|
||||
tools=[frags[i]["name"] for i in sorted(frags)] if frags else None)
|
||||
if frags:
|
||||
calls = [frags[i] for i in sorted(frags)]
|
||||
assistant = {
|
||||
|
||||
+11
-5
@@ -67,11 +67,17 @@ _CASH_CARD = """You are copiloting Brian's LIVE cash game right now — you're a
|
||||
a session is (or should be) open. You move between two registers depending on what he's doing:
|
||||
|
||||
• HE HANDS YOU FACTS TO TRACK — his stack, a hand, a read on someone, a rebuy, a result. \
|
||||
Log it with the right tool and confirm in ONE short line ("$350 stack logged."). Don't \
|
||||
narrate, don't explain what logging is, don't ask permission — just do it. He says his \
|
||||
current stack → log_stack. He describes a hand → log_hand (terse) or record_hand (a full \
|
||||
hand he wants saved/replayable). A read on a player → add_read. A rebuy → add_buyin. This is \
|
||||
the quiet, fast half of the job; he shouldn't feel you working.
|
||||
LOGGING IS THE JOB: if his message contains anything trackable, you MUST call the tool \
|
||||
FIRST, before you reply — every single time. Logging and talking are not either/or; do \
|
||||
BOTH. Never let a conversational reply take the place of the log. A described hand ALWAYS \
|
||||
gets logged, even mid-banter, even if he's just telling a story about it — don't skip the \
|
||||
hand because you're busy reacting to it. Then confirm in ONE short line ("$350 stack \
|
||||
logged."). Don't narrate, don't explain logging, don't ask permission — just do it. \
|
||||
Routing: current stack → log_stack (and pass `note` with the why if he gives one — "card \
|
||||
dead", "doubled up vs the LAG"). A hand he describes → record_hand (a real, replayable \
|
||||
hand) — prefer this over log_hand so it lands on his timeline with a link. A read on a \
|
||||
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.
|
||||
|
||||
• HE ASKS FOR ADVICE, OR TELLS YOU HOW HE'S FEELING — tilted, steaming, card-dead, bored, \
|
||||
stuck, "should I have folded the river?" THIS is when he needs you most. Drop the shorthand \
|
||||
|
||||
+58
-12
@@ -16,7 +16,7 @@ import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from lyra import llm, memory
|
||||
from lyra import clock, llm, memory
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS poker_sessions (
|
||||
@@ -138,7 +138,8 @@ def _c():
|
||||
conn.executescript(_SCHEMA)
|
||||
# Add columns introduced after a DB already had the tables (no-op if present).
|
||||
for ddl in ("ALTER TABLE poker_hands ADD COLUMN structured TEXT",
|
||||
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT"):
|
||||
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT",
|
||||
"ALTER TABLE poker_stack_log ADD COLUMN note TEXT"):
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
@@ -403,17 +404,18 @@ def add_buyin(amount: float, session_id: int | None = None) -> float:
|
||||
|
||||
# --- stack tracking ---
|
||||
|
||||
def log_stack(amount: float, session_id: int | None = None) -> dict:
|
||||
"""Record Brian's current chip stack. Returns {current, buy_in, net} where net
|
||||
is his live net while sitting (current stack − total bought in)."""
|
||||
def log_stack(amount: float, note: str | None = None, session_id: int | None = None) -> dict:
|
||||
"""Record Brian's current chip stack, optionally with the why ("card dead",
|
||||
"doubled up vs Sal") — that context becomes the session-timeline line. Returns
|
||||
{current, buy_in, net} where net is his live net while sitting."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
raise ValueError("no live session")
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO poker_stack_log (session_id, amount, created_at) VALUES (?, ?, ?)",
|
||||
(sid, float(amount), _now()),
|
||||
"INSERT INTO poker_stack_log (session_id, amount, note, created_at) VALUES (?, ?, ?, ?)",
|
||||
(sid, float(amount), (note or "").strip() or None, _now()),
|
||||
)
|
||||
return stack_state(sid)
|
||||
|
||||
@@ -436,7 +438,7 @@ def stack_log(session_id: int | None = None) -> list[dict]:
|
||||
if sid is None:
|
||||
return []
|
||||
return [dict(r) for r in _c().execute(
|
||||
"SELECT id, amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id",
|
||||
"SELECT id, amount, note, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id",
|
||||
(sid,),
|
||||
).fetchall()]
|
||||
|
||||
@@ -1179,20 +1181,63 @@ def running_stats(stakes: str | None = None, venue: str | None = None,
|
||||
|
||||
# --- live session HUD (everything tracked in the current session, for the UI) ---
|
||||
|
||||
def timeline(session_id: int | None = None) -> list[dict]:
|
||||
"""The session's running log: start, stack updates (+context), hands (linkable),
|
||||
reads, and rituals, interleaved chronologically with local time-of-day stamps.
|
||||
This is what Brian sees as the night's story — '10:45 start … 12:00a doubled up,
|
||||
$750 (hand)'. Each entry: {time, at, kind, text, hand_id?, amount?, result?}."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return []
|
||||
s = get_session(sid) or {}
|
||||
events: list[dict] = []
|
||||
|
||||
if s.get("started_at"):
|
||||
bits = [s.get("stakes"), s.get("game"), f"at {s['venue']}" if s.get("venue") else None]
|
||||
label = " ".join(b for b in bits if b)
|
||||
events.append({"at": s["started_at"], "kind": "start",
|
||||
"text": ("Session start — " + label) if label else "Session start"})
|
||||
|
||||
for r in stack_log(sid):
|
||||
events.append({"at": r["created_at"], "kind": "stack",
|
||||
"amount": r.get("amount"), "text": r.get("note") or "stack update"})
|
||||
|
||||
for h in list_hands(sid):
|
||||
desc = " ".join(b for b in (h.get("position"), h.get("hole_cards")) if b)
|
||||
events.append({"at": h["at"], "kind": "hand", "hand_id": h["id"],
|
||||
"result": h.get("result"), "text": desc or "hand"})
|
||||
|
||||
for r in _c().execute(
|
||||
"SELECT pr.created_at AS at, pr.seat AS seat, pr.note AS note, p.name AS name "
|
||||
"FROM player_reads pr LEFT JOIN poker_players p ON p.id = pr.player_id "
|
||||
"WHERE pr.session_id = ?", (sid,),
|
||||
).fetchall():
|
||||
who = r["name"] or (f"seat {r['seat']}" if r["seat"] else "villain")
|
||||
events.append({"at": r["at"], "kind": "read", "text": f"Read — {who}: {r['note']}"})
|
||||
|
||||
for r in list_rituals(sid):
|
||||
tag = f"[{r['classification']}] " if r.get("classification") else ""
|
||||
events.append({"at": r["created_at"], "kind": r["kind"],
|
||||
"text": tag + (r.get("content") or r["kind"]), "hand_id": r.get("hand_id")})
|
||||
|
||||
events.sort(key=lambda e: e["at"] or "")
|
||||
for e in events:
|
||||
e["time"] = clock.short(e["at"])
|
||||
return events
|
||||
|
||||
|
||||
def _session_villains(sid: int) -> list[dict]:
|
||||
"""Players read this session, with their standing dossier fields."""
|
||||
rows = _c().execute(
|
||||
"SELECT p.name AS name, p.category AS category, p.tendencies AS tendencies, "
|
||||
"p.adjustment AS adjustment, "
|
||||
"(SELECT note FROM player_reads r2 WHERE r2.player_id = p.id "
|
||||
" AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note, "
|
||||
"(SELECT seat FROM player_reads r3 WHERE r3.player_id = p.id "
|
||||
" AND r3.session_id = ? ORDER BY r3.id DESC LIMIT 1) AS seat "
|
||||
" AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note "
|
||||
"FROM poker_players p "
|
||||
"WHERE p.id IN (SELECT DISTINCT player_id FROM player_reads "
|
||||
" WHERE session_id = ? AND player_id IS NOT NULL) "
|
||||
"ORDER BY p.updated_at DESC",
|
||||
(sid, sid, sid),
|
||||
(sid, sid),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@@ -1253,6 +1298,7 @@ def hud(session_id: int | None = None) -> dict | None:
|
||||
},
|
||||
"hands": hands,
|
||||
"villains": _session_villains(sid),
|
||||
"timeline": timeline(sid),
|
||||
"notes": notes,
|
||||
"rituals": {
|
||||
"alligator": alligator_active(sid),
|
||||
|
||||
+9
-5
@@ -129,16 +129,20 @@ def maybe_summarize_async(session_id: str, backend: Backend | None = None) -> No
|
||||
|
||||
|
||||
def summarize_all(
|
||||
backend: Backend | None = None, limit: int | None = None, workers: int = 8
|
||||
backend: Backend | None = None, limit: int | None = None, workers: int | None = None
|
||||
) -> dict:
|
||||
"""Summarize every session that needs it. Idempotent and resumable.
|
||||
|
||||
LLM summarization runs concurrently across `workers` threads (great for a
|
||||
cloud backend). DB reads (loading transcripts) and writes (store_summary,
|
||||
which also embeds) happen on the main thread, so the single SQLite
|
||||
connection is never touched from multiple threads.
|
||||
Concurrency is backend-aware: the cloud API parallelizes happily, but the
|
||||
local/MI50 GPU servers run a single slot (llama.cpp --parallel 1) — firing N
|
||||
requests at them just queues, blows the client timeout, and thrashes the KV
|
||||
cache (wasted compute + heat). So GPU backends run serially unless overridden.
|
||||
DB reads/writes (store_summary embeds) stay on the main thread, so the single
|
||||
SQLite connection is never touched from multiple threads.
|
||||
"""
|
||||
backend = backend or config.load().summary_backend
|
||||
if workers is None:
|
||||
workers = 8 if backend == "cloud" else 1
|
||||
|
||||
# Main thread: collect the work (transcripts) for sessions needing a summary.
|
||||
todo: list[tuple[str, str, int]] = []
|
||||
|
||||
+7
-3
@@ -184,8 +184,9 @@ def _log_stack(args: dict, ctx: dict) -> str:
|
||||
amount = float(args.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
return "Give me a number for the stack."
|
||||
note = (args.get("note") or "").strip() or None
|
||||
try:
|
||||
st = poker.log_stack(amount)
|
||||
st = poker.log_stack(amount, note=note)
|
||||
except ValueError:
|
||||
return "No live session — start one first, then I'll track your stack."
|
||||
net = st.get("net")
|
||||
@@ -519,8 +520,11 @@ TOOLS.update({
|
||||
"log_stack",
|
||||
"Record Brian's CURRENT total chip stack in the live session. Call whenever "
|
||||
"he states his stack ('I'm at 350', 'down to 220', 'stacked off to 900'). "
|
||||
"Tracks his stack over time and his live net while he's still sitting.",
|
||||
{"amount": {**_N, "description": "Current total chip stack, in dollars"}},
|
||||
"Tracks his stack over time and his live net while he's still sitting. Pass "
|
||||
"`note` with the WHY when he gives it ('card dead', 'doubled up vs the LAG') — "
|
||||
"it becomes the line in his session timeline.",
|
||||
{"amount": {**_N, "description": "Current total chip stack, in dollars"},
|
||||
"note": {**_S, "description": "Optional context for the change, e.g. 'card dead', 'doubled up'"}},
|
||||
["amount"])},
|
||||
"scar_note": {"handler": _scar_note, "spec": _f(
|
||||
"scar_note",
|
||||
|
||||
@@ -339,24 +339,6 @@ def create_app() -> FastAPI:
|
||||
async def hands_data(limit: int = 60) -> dict:
|
||||
return {"hands": poker.list_recent_hands(limit=limit)}
|
||||
|
||||
@app.post("/hands")
|
||||
async def hands_create(request: Request) -> dict:
|
||||
"""Store a structured hand built by the recorder. Body:
|
||||
{structured, session_id?, tag?, lesson?}. normalize_structured() (in
|
||||
store_hand_history) is the authority on shape, so the client can be best-effort."""
|
||||
body = await request.json()
|
||||
structured = body.get("structured")
|
||||
if not isinstance(structured, dict):
|
||||
return {"ok": False, "error": "missing structured hand body"}
|
||||
hid = await asyncio.to_thread(
|
||||
poker.store_hand_history, structured,
|
||||
session_id=body.get("session_id"), tag=body.get("tag"), lesson=body.get("lesson"),
|
||||
)
|
||||
# Enrich villain dossiers from the recorded players, same as the parser path.
|
||||
await asyncio.to_thread(poker.link_hand_players, hid, structured, body.get("session_id"))
|
||||
logbus.log("info", "hand recorded", id=hid, session=body.get("session_id"))
|
||||
return {"ok": True, "id": hid}
|
||||
|
||||
@app.get("/recap/{session_id}")
|
||||
async def recap_page() -> FileResponse:
|
||||
return FileResponse(str(_STATIC / "recap.html"))
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<title>Lyra Core Chat</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="/recorder.css" />
|
||||
<!-- PWA -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
@@ -130,8 +129,7 @@
|
||||
<a class="tab active" href="/" aria-current="page"><span class="ti">💬</span><span class="tl">Chat</span></a>
|
||||
<a class="tab" href="/session"><span class="ti">🎬</span><span class="tl">Session</span></a>
|
||||
<a class="tab" href="/hands"><span class="ti">🃏</span><span class="tl">Hands</span></a>
|
||||
<a class="tab tab-mind" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a>
|
||||
<button class="tab tab-rec" id="recordTab" type="button"><span class="ti">➕</span><span class="tl">Record</span></button>
|
||||
<a class="tab" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a>
|
||||
<button class="tab" id="moreTab" type="button"><span class="ti">⋯</span><span class="tl">More</span></button>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -1247,23 +1245,5 @@
|
||||
});
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
<!-- Hand recorder (overlay; chat/session stays mounted underneath) -->
|
||||
<div id="recorderOverlay" class="rec-overlay"></div>
|
||||
<script src="/recorder.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var overlay = document.getElementById("recorderOverlay");
|
||||
var recordTab = document.getElementById("recordTab");
|
||||
function close() { overlay.classList.remove("open"); overlay.innerHTML = ""; }
|
||||
if (recordTab) recordTab.addEventListener("click", function () {
|
||||
overlay.innerHTML = "";
|
||||
overlay.classList.add("open");
|
||||
window.Recorder.mount(overlay, {
|
||||
onClose: close,
|
||||
onSave: function (id) { close(); window.open("/hand/" + id, "_blank"); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/* Hand recorder overlay. Uses the app theme tokens (--accent, --bg-* etc.) from
|
||||
style.css when mounted in index.html. For a standalone recorder.html, import those
|
||||
tokens too (see :root in style.css). */
|
||||
|
||||
.rec-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-dark, #070707);
|
||||
flex-direction: column;
|
||||
}
|
||||
/* :not(.open) outranks .rec-root's display:flex (added on mount), so closing the
|
||||
overlay actually hides it instead of leaving an empty black screen. */
|
||||
.rec-overlay:not(.open) { display: none; }
|
||||
.rec-overlay.open { display: flex; }
|
||||
|
||||
.rec-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
color: var(--text-main, #e8e8e8);
|
||||
font-family: var(--font-console, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.rec-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
padding-top: calc(12px + env(safe-area-inset-top)); /* clear the notch/status bar */
|
||||
border-bottom: 1px solid var(--border, #2a1d12);
|
||||
}
|
||||
.rec-title { font-weight: 700; color: var(--accent, #ff7a00); }
|
||||
.rec-meta { color: var(--text-fade, #8a8a8a); font-size: .82rem; flex: 1; }
|
||||
.rec-x {
|
||||
background: none; border: 1px solid var(--border, #2a1d12); color: var(--text-fade, #8a8a8a);
|
||||
border-radius: 8px; width: 34px; height: 34px; font-size: 1rem;
|
||||
}
|
||||
|
||||
.rec-body { flex: 1; overflow-y: auto; padding: 12px 14px 20px; -webkit-overflow-scrolling: touch; }
|
||||
.rec-sec { margin-bottom: 18px; }
|
||||
.rec-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .6px; color: var(--text-fade, #8a8a8a); margin-bottom: 6px; }
|
||||
.rec-dim { color: var(--text-fade, #8a8a8a); font-size: .85rem; }
|
||||
|
||||
.rec-pos-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.rec-pos {
|
||||
min-width: 44px; padding: 9px 10px; border-radius: 9px;
|
||||
background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8);
|
||||
border: 1px solid var(--border, #2a1d12); font-size: .82rem; font-weight: 600;
|
||||
}
|
||||
.rec-pos.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); }
|
||||
.rec-pos.add { color: var(--text-fade, #8a8a8a); border-style: dashed; }
|
||||
|
||||
.rec-hero-cards { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
||||
|
||||
.rec-seats { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
|
||||
.rec-seat { display: flex; align-items: center; gap: 8px; }
|
||||
.rec-seat-pos { min-width: 42px; font-weight: 700; color: var(--gold, #ffb347); }
|
||||
.rec-name {
|
||||
flex: 1; min-width: 0; padding: 8px 9px; border-radius: 8px;
|
||||
background: var(--bg-elev, #0e0e0e); border: 1px solid var(--border, #2a1d12);
|
||||
color: var(--text-main, #e8e8e8); font-family: inherit; font-size: .85rem;
|
||||
}
|
||||
.rec-rm { background: none; border: none; color: var(--text-fade, #8a8a8a); font-size: .9rem; padding: 4px; }
|
||||
|
||||
/* typed card entry */
|
||||
.rec-field { display: block; margin-top: 10px; }
|
||||
.rec-cards {
|
||||
width: 100%; padding: 10px 11px; border-radius: 8px;
|
||||
background: var(--bg-elev, #0e0e0e); border: 1px solid var(--border, #2a1d12);
|
||||
color: var(--text-main, #e8e8e8); font-family: inherit; font-size: .95rem;
|
||||
letter-spacing: 1px; box-sizing: border-box;
|
||||
}
|
||||
.rec-cards.sm { width: 88px; flex: none; padding: 8px 9px; font-size: .85rem; }
|
||||
|
||||
.rec-street-tabs { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.rec-tab {
|
||||
flex: 1; padding: 9px 6px; border-radius: 9px; font-size: .78rem; font-weight: 600;
|
||||
background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8); border: 1px solid var(--border, #2a1d12);
|
||||
}
|
||||
.rec-tab.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); }
|
||||
|
||||
.rec-act-add { display: flex; gap: 6px; margin: 8px 0; }
|
||||
.rec-sel, .rec-num {
|
||||
padding: 9px 8px; border-radius: 8px; background: var(--bg-elev, #0e0e0e);
|
||||
border: 1px solid var(--border, #2a1d12); color: var(--text-main, #e8e8e8);
|
||||
font-family: inherit; font-size: .85rem; min-width: 0;
|
||||
}
|
||||
.rec-sel { flex: 1; }
|
||||
.rec-num { width: 70px; }
|
||||
.rec-add-act { padding: 9px 12px; border-radius: 8px; background: var(--border-bright, #4a2f15); color: #fff; border: none; font-weight: 600; }
|
||||
.rec-result { display: flex; gap: 12px; }
|
||||
.rec-result label { display: flex; flex-direction: column; gap: 4px; font-size: .72rem; color: var(--text-fade, #8a8a8a); }
|
||||
|
||||
.rec-log { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; }
|
||||
.rec-ln { font-size: .82rem; color: var(--text-main, #e8e8e8); }
|
||||
.rec-ln.brd { display: flex; gap: 4px; align-items: center; color: var(--text-fade, #8a8a8a); }
|
||||
.rec-ln b { color: var(--accent, #ff7a00); font-weight: 700; }
|
||||
|
||||
.rec-foot {
|
||||
display: flex; gap: 10px;
|
||||
padding: 12px 14px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--border, #2a1d12);
|
||||
}
|
||||
.rec-save { flex: 1; padding: 14px; border-radius: 10px; background: var(--accent, #ff7a00); color: #111; border: none; font-weight: 700; font-size: 1rem; }
|
||||
.rec-save:disabled { opacity: .6; }
|
||||
.rec-cancel { padding: 14px 18px; border-radius: 10px; background: var(--bg-elev, #0e0e0e); color: var(--text-fade, #8a8a8a); border: 1px solid var(--border, #2a1d12); font-weight: 600; font-size: 1rem; }
|
||||
|
||||
.rec-undo { background: none; border: none; color: var(--text-fade, #8a8a8a); font-size: .75rem; padding: 0 4px; }
|
||||
|
||||
/* The Record tab swaps in for Mind in the bottom bar, but only in poker (cash) mode.
|
||||
body.cash-mode is toggled on mode change in index.html. */
|
||||
#tabbar .tab-rec { display: none; }
|
||||
body.cash-mode #tabbar .tab-rec { display: flex; }
|
||||
body.cash-mode #tabbar .tab-mind { display: none; }
|
||||
#tabbar .tab-rec .ti { color: var(--accent, #ff7a00); filter: none; }
|
||||
@@ -1,425 +0,0 @@
|
||||
/* Hand recorder — tap-to-build poker hands. See docs/RECORDER.md.
|
||||
*
|
||||
* Correctness by construction: each field writes a known value into a known slot,
|
||||
* so there's no LLM parse step that can be wrong. Output is the canonical structured
|
||||
* contract (docs/HAND_HISTORY.md); the server's normalize_structured() is the final
|
||||
* authority on shape (case, suits, 10->T, completeness), so this stays best-effort.
|
||||
*
|
||||
* Mount-agnostic: Recorder.mount(container, opts) renders into ANY element — a
|
||||
* full-screen overlay in index.html today, a standalone recorder.html later, with
|
||||
* zero logic changes. buildStructured(state) is pure (no DOM) — the reusable core.
|
||||
*
|
||||
* Card entry: plain typed text for now ("ah kh", "AhKh", "7d 2c 5h"). The tap picker
|
||||
* is shelved (docs/RECORDER.md V2) — parseCards() + server normalize handle the rest.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const SUITS = { s: "♠", h: "♥", d: "♦", c: "♣" };
|
||||
const POSITIONS = ["UTG", "UTG1", "UTG2", "MP", "LJ", "HJ", "CO", "BTN", "SB", "BB"];
|
||||
const STREETS = ["preflop", "flop", "turn", "river"];
|
||||
const STREET_BOARD = { flop: 3, turn: 1, river: 1 };
|
||||
const ACTIONS = ["fold", "check", "call", "bet", "raise", "allin"];
|
||||
const SIZED = { bet: true, raise: true, allin: true };
|
||||
|
||||
// --- card text -> tokens (server normalizes case/suit/10) ------------------
|
||||
function parseCards(str) {
|
||||
if (!str) return [];
|
||||
const s = String(str).trim().replace(/10/g, "T");
|
||||
if (!s) return [];
|
||||
const parts = /\s/.test(s) ? s.split(/\s+/) : s.match(/.{1,2}/g) || [];
|
||||
return parts.map((p) => p.trim()).filter(Boolean);
|
||||
}
|
||||
function cardsText(arr) {
|
||||
return arr && arr.length ? arr.join(" ") : "";
|
||||
}
|
||||
|
||||
// --- pure core: state -> contract dict (testable, no DOM) ------------------
|
||||
function buildStructured(state) {
|
||||
const players = state.seats
|
||||
.filter((s) => s.pos)
|
||||
.map((s) => {
|
||||
const p = { pos: s.pos };
|
||||
if (s.stack != null) p.stack = s.stack;
|
||||
if (s.name) p.name = s.name;
|
||||
p.cards = s.cards && s.cards.length ? s.cards.slice() : null;
|
||||
return p;
|
||||
});
|
||||
|
||||
const actions = [];
|
||||
for (const st of STREETS) {
|
||||
const reveal = state.board[st];
|
||||
if (st !== "preflop" && reveal && reveal.length) {
|
||||
actions.push({ street: st, board: reveal.slice() });
|
||||
}
|
||||
for (const a of state.actions.filter((x) => x.street === st)) {
|
||||
actions.push({
|
||||
street: st,
|
||||
pos: a.pos,
|
||||
action: a.action,
|
||||
amount: a.amount != null ? a.amount : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hero = state.seats.find((s) => s.pos === state.heroPos);
|
||||
const board = [].concat(state.board.flop, state.board.turn, state.board.river);
|
||||
return {
|
||||
game: state.meta.game || "NLH",
|
||||
stakes: state.meta.stakes || null,
|
||||
hero_pos: state.heroPos || null,
|
||||
hero_cards: hero && hero.cards ? hero.cards.slice() : [],
|
||||
players,
|
||||
actions,
|
||||
board,
|
||||
result: {
|
||||
pot: state.result.pot,
|
||||
hero_net: state.result.heroNet,
|
||||
summary: state.result.summary || "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseBlinds(stakes) {
|
||||
const m = (stakes || "").match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
|
||||
return m ? { sb: parseFloat(m[1]), bb: parseFloat(m[2]) } : { sb: null, bb: null };
|
||||
}
|
||||
|
||||
function initialState(hud) {
|
||||
const sess = (hud && hud.session) || {};
|
||||
const stack = (hud && hud.stack) || {};
|
||||
const blinds = parseBlinds(sess.stakes);
|
||||
|
||||
const seats = [];
|
||||
for (const v of (hud && hud.villains) || []) {
|
||||
if (v.seat && POSITIONS.includes(v.seat)) {
|
||||
seats.push({ pos: v.seat, name: v.name || null, stack: null, cards: null });
|
||||
}
|
||||
}
|
||||
|
||||
const actions = [];
|
||||
if (blinds.sb != null) actions.push({ street: "preflop", pos: "SB", action: "post", amount: blinds.sb });
|
||||
if (blinds.bb != null) actions.push({ street: "preflop", pos: "BB", action: "post", amount: blinds.bb });
|
||||
|
||||
return {
|
||||
meta: {
|
||||
game: sess.game || "NLH",
|
||||
stakes: sess.stakes || null,
|
||||
venue: sess.venue || null,
|
||||
sessionId: sess.id != null ? sess.id : null,
|
||||
},
|
||||
blinds,
|
||||
heroStack: stack.current != null ? stack.current : null,
|
||||
heroPos: null,
|
||||
seats,
|
||||
street: "preflop",
|
||||
board: { flop: [], turn: [], river: [] },
|
||||
actions,
|
||||
result: { pot: null, heroNet: null, summary: "" },
|
||||
};
|
||||
}
|
||||
|
||||
function ensureHero(state) {
|
||||
let hero = state.seats.find((s) => s.pos === state.heroPos);
|
||||
if (!hero && state.heroPos) {
|
||||
hero = { pos: state.heroPos, name: "Hero", stack: state.heroStack, cards: [] };
|
||||
state.seats.push(hero);
|
||||
}
|
||||
return hero || {};
|
||||
}
|
||||
|
||||
window.Recorder = {
|
||||
buildStructured,
|
||||
parseCards,
|
||||
parseBlinds,
|
||||
initialState,
|
||||
_internals: { POSITIONS, STREETS },
|
||||
mount,
|
||||
};
|
||||
|
||||
// --- mount / render -------------------------------------------------------
|
||||
async function mount(container, opts) {
|
||||
opts = opts || {};
|
||||
let hud = opts.hud;
|
||||
if (!hud) {
|
||||
try {
|
||||
const url = opts.sessionId != null ? `/session/data?id=${opts.sessionId}` : "/session/data";
|
||||
hud = await fetch(url).then((r) => r.json());
|
||||
} catch (e) {
|
||||
hud = { session: null };
|
||||
}
|
||||
}
|
||||
const state = initialState(hud);
|
||||
const ctx = { container, state, opts };
|
||||
container.classList.add("rec-root");
|
||||
container.addEventListener("click", (e) => handleClick(ctx, e));
|
||||
container.addEventListener("input", (e) => handleInput(ctx, e));
|
||||
render(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function render(ctx) {
|
||||
const s = ctx.state;
|
||||
const hero = s.seats.find((x) => x.pos === s.heroPos) || {};
|
||||
ctx.container.innerHTML = `
|
||||
<div class="rec-head">
|
||||
<div class="rec-title">Record hand</div>
|
||||
<div class="rec-meta">${esc(s.meta.venue || "")}${s.meta.stakes ? " · " + esc(s.meta.stakes) : ""}</div>
|
||||
<button class="rec-x" data-act="close">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="rec-body">
|
||||
<section class="rec-sec">
|
||||
<div class="rec-label">Your seat</div>
|
||||
<div class="rec-pos-row">
|
||||
${POSITIONS.map((p) => `<button class="rec-pos${s.heroPos === p ? " on" : ""}" data-act="hero-pos" data-pos="${p}">${p}</button>`).join("")}
|
||||
</div>
|
||||
<label class="rec-field">
|
||||
<span class="rec-label">your cards</span>
|
||||
<input class="rec-cards" data-act="hero-cards" autocapitalize="off" autocomplete="off" spellcheck="false"
|
||||
placeholder="e.g. ah kh" value="${esc(cardsText(hero.cards))}">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="rec-sec">
|
||||
<div class="rec-label">Players in the hand</div>
|
||||
<div class="rec-seats">
|
||||
${s.seats.filter((x) => x.pos !== s.heroPos).map((seat) => renderSeat(seat)).join("") || '<div class="rec-dim">none yet</div>'}
|
||||
</div>
|
||||
<div class="rec-pos-row">
|
||||
${POSITIONS.filter((p) => p !== s.heroPos && !s.seats.some((x) => x.pos === p)).map((p) => `<button class="rec-pos add" data-act="add-seat" data-pos="${p}">+ ${p}</button>`).join("")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rec-sec">
|
||||
<div class="rec-label">Streets</div>
|
||||
<div class="rec-street-tabs">
|
||||
${STREETS.map((st) => `<button class="rec-tab${s.street === st ? " on" : ""}" data-act="street" data-street="${st}">${st}${boardCount(s, st)}</button>`).join("")}
|
||||
</div>
|
||||
${renderStreet(ctx)}
|
||||
</section>
|
||||
|
||||
<section class="rec-sec">
|
||||
<div class="rec-label">Result</div>
|
||||
<div class="rec-result">
|
||||
<label>pot <input class="rec-num" data-act="result" data-k="pot" inputmode="decimal" value="${s.result.pot != null ? s.result.pot : ""}"></label>
|
||||
<label>your net <input class="rec-num" data-act="result" data-k="heroNet" inputmode="decimal" value="${s.result.heroNet != null ? s.result.heroNet : ""}"></label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="rec-foot">
|
||||
<button class="rec-cancel" data-act="close">Cancel</button>
|
||||
<button class="rec-save" data-act="save">Save & replay</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSeat(seat) {
|
||||
return `
|
||||
<div class="rec-seat">
|
||||
<span class="rec-seat-pos">${seat.pos}</span>
|
||||
<input class="rec-name" data-act="seat-name" data-pos="${seat.pos}" autocapitalize="off" autocomplete="off"
|
||||
placeholder="name" value="${esc(seat.name || "")}">
|
||||
<input class="rec-cards sm" data-act="seat-cards" data-pos="${seat.pos}" autocapitalize="off" autocomplete="off" spellcheck="false"
|
||||
placeholder="shown?" value="${esc(cardsText(seat.cards))}">
|
||||
<button class="rec-rm" data-act="rm-seat" data-pos="${seat.pos}">✕</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderStreet(ctx) {
|
||||
const s = ctx.state;
|
||||
const st = s.street;
|
||||
const players = s.seats.map((x) => x.pos);
|
||||
const boardInput =
|
||||
st === "preflop"
|
||||
? ""
|
||||
: `<label class="rec-field">
|
||||
<span class="rec-label">${st} board (${STREET_BOARD[st]})</span>
|
||||
<input class="rec-cards" data-act="board-cards" data-street="${st}" autocapitalize="off" autocomplete="off" spellcheck="false"
|
||||
placeholder="${st === "flop" ? "e.g. 7d 2c 5h" : "e.g. 5h"}" value="${esc(cardsText(s.board[st]))}">
|
||||
</label>`;
|
||||
|
||||
// Straddle: a voluntary preflop blind from any non-blind seat, default 2×BB.
|
||||
// Action starts left of it and it acts last preflop — order is whatever you enter.
|
||||
const stradAmt = s.blinds.bb != null ? 2 * s.blinds.bb : null;
|
||||
const stradElig = players.filter((p) => p !== "SB" && p !== "BB" && !s.actions.some((a) => a.straddle && a.pos === p));
|
||||
const straddle =
|
||||
st === "preflop" && stradElig.length
|
||||
? `<div class="rec-act-add">
|
||||
<select class="rec-sel" data-act="str-pos">
|
||||
<option value="">+ straddle${stradAmt != null ? " (" + stradAmt + ")" : ""}…</option>
|
||||
${stradElig.map((p) => `<option value="${p}">${p}</option>`).join("")}
|
||||
</select>
|
||||
<button class="rec-add-act" data-act="add-straddle">add</button>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
${boardInput}
|
||||
${straddle}
|
||||
<div class="rec-act-add">
|
||||
<select class="rec-sel" data-act="na-pos">
|
||||
<option value="">who</option>
|
||||
${players.map((p) => `<option value="${p}">${p}${p === s.heroPos ? " (you)" : ""}</option>`).join("")}
|
||||
</select>
|
||||
<select class="rec-sel" data-act="na-action">
|
||||
<option value="">action</option>
|
||||
${ACTIONS.map((a) => `<option value="${a}">${a}</option>`).join("")}
|
||||
</select>
|
||||
<input class="rec-num" data-act="na-amount" inputmode="decimal" placeholder="$">
|
||||
<button class="rec-add-act" data-act="add-action">add</button>
|
||||
</div>
|
||||
<div class="rec-log">
|
||||
${s.board[st] && s.board[st].length && st !== "preflop" ? `<div class="rec-ln brd">${st}: ${cardsText(s.board[st])}</div>` : ""}
|
||||
${s.actions
|
||||
.filter((a) => a.street === st)
|
||||
.map((a, i) => {
|
||||
const label = a.straddle ? "straddle" : a.action;
|
||||
const amt = a.amount != null ? " " + a.amount : "";
|
||||
const fixed = a.action === "post" && !a.straddle; // blinds aren't removable
|
||||
const rm = fixed ? "" : ` <button class="rec-undo" data-act="rm-action" data-street="${st}" data-i="${i}">✕</button>`;
|
||||
return `<div class="rec-ln">${a.pos} <b>${label}</b>${amt}${rm}</div>`;
|
||||
})
|
||||
.join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function boardCount(s, st) {
|
||||
const n = (s.board[st] || []).length;
|
||||
return n ? ` ${n}` : "";
|
||||
}
|
||||
|
||||
// --- events ---------------------------------------------------------------
|
||||
function handleClick(ctx, e) {
|
||||
const s = ctx.state;
|
||||
const t = e.target.closest("[data-act]");
|
||||
if (!t) return;
|
||||
const act = t.getAttribute("data-act");
|
||||
|
||||
switch (act) {
|
||||
case "close":
|
||||
if (ctx.opts.onClose) ctx.opts.onClose();
|
||||
return;
|
||||
case "hero-pos": {
|
||||
const pos = t.getAttribute("data-pos");
|
||||
const old = s.seats.find((x) => x.pos === s.heroPos);
|
||||
if (old && old.name === "Hero" && !(old.cards || []).length) {
|
||||
s.seats = s.seats.filter((x) => x !== old);
|
||||
}
|
||||
s.heroPos = s.heroPos === pos ? null : pos;
|
||||
if (s.heroPos) ensureHero(s);
|
||||
break;
|
||||
}
|
||||
case "add-seat":
|
||||
s.seats.push({ pos: t.getAttribute("data-pos"), name: null, stack: null, cards: null });
|
||||
break;
|
||||
case "rm-seat":
|
||||
s.seats = s.seats.filter((x) => x.pos !== t.getAttribute("data-pos"));
|
||||
break;
|
||||
case "street":
|
||||
s.street = t.getAttribute("data-street");
|
||||
break;
|
||||
case "add-action":
|
||||
addActionFromControls(ctx);
|
||||
break;
|
||||
case "add-straddle": {
|
||||
const sel = ctx.container.querySelector('[data-act="str-pos"]');
|
||||
const pos = sel && sel.value;
|
||||
if (pos) {
|
||||
const amt = s.blinds.bb != null ? 2 * s.blinds.bb : null;
|
||||
s.actions.push({ street: "preflop", pos, action: "post", amount: amt, straddle: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "rm-action":
|
||||
removeAction(s, t.getAttribute("data-street"), parseInt(t.getAttribute("data-i"), 10));
|
||||
break;
|
||||
case "save":
|
||||
return doSave(ctx);
|
||||
default:
|
||||
return; // inputs handled in handleInput
|
||||
}
|
||||
render(ctx);
|
||||
}
|
||||
|
||||
function handleInput(ctx, e) {
|
||||
const s = ctx.state;
|
||||
const t = e.target.closest("[data-act]");
|
||||
if (!t) return;
|
||||
const act = t.getAttribute("data-act");
|
||||
if (act === "hero-cards") {
|
||||
const hero = ensureHero(s);
|
||||
hero.cards = parseCards(t.value);
|
||||
} else if (act === "seat-cards") {
|
||||
const seat = s.seats.find((x) => x.pos === t.getAttribute("data-pos"));
|
||||
if (seat) seat.cards = parseCards(t.value);
|
||||
} else if (act === "board-cards") {
|
||||
s.board[t.getAttribute("data-street")] = parseCards(t.value);
|
||||
} else if (act === "seat-name") {
|
||||
const seat = s.seats.find((x) => x.pos === t.getAttribute("data-pos"));
|
||||
if (seat) seat.name = t.value.trim() || null;
|
||||
} else if (act === "result") {
|
||||
const k = t.getAttribute("data-k");
|
||||
s.result[k] = t.value === "" ? null : parseFloat(t.value);
|
||||
}
|
||||
// no re-render mid-typing (keeps input focus)
|
||||
}
|
||||
|
||||
function addActionFromControls(ctx) {
|
||||
const root = ctx.container;
|
||||
const pos = root.querySelector('[data-act="na-pos"]').value;
|
||||
const action = root.querySelector('[data-act="na-action"]').value;
|
||||
const amt = root.querySelector('[data-act="na-amount"]').value;
|
||||
if (!pos || !action) return;
|
||||
const entry = { street: ctx.state.street, pos, action };
|
||||
entry.amount = SIZED[action] && amt !== "" ? parseFloat(amt) : null;
|
||||
ctx.state.actions.push(entry);
|
||||
}
|
||||
|
||||
function removeAction(state, street, idxWithinStreet) {
|
||||
let seen = -1;
|
||||
for (let i = 0; i < state.actions.length; i++) {
|
||||
if (state.actions[i].street === street) {
|
||||
seen++;
|
||||
if (seen === idxWithinStreet) {
|
||||
state.actions.splice(i, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function doSave(ctx) {
|
||||
const structured = buildStructured(ctx.state);
|
||||
const btn = ctx.container.querySelector(".rec-save");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Saving…";
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/hands", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ structured, session_id: ctx.state.meta.sessionId }),
|
||||
}).then((r) => r.json());
|
||||
if (res && res.ok) {
|
||||
if (ctx.opts.onSave) ctx.opts.onSave(res.id);
|
||||
else window.location.href = `/hand/${res.id}`;
|
||||
} else {
|
||||
throw new Error((res && res.error) || "save failed");
|
||||
}
|
||||
} catch (err) {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Save failed — retry";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function esc(x) {
|
||||
return String(x == null ? "" : x).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
}
|
||||
|
||||
void SUITS;
|
||||
})();
|
||||
@@ -104,6 +104,16 @@
|
||||
.big-empty { text-align: center; padding: 50px 20px; color: var(--fade); }
|
||||
.big-empty .ico { font-size: 2.4rem; }
|
||||
.big-empty a { color: var(--accent); text-decoration: none; }
|
||||
/* running timeline */
|
||||
ul.tl { list-style: none; margin: 0; padding: 0; }
|
||||
ul.tl li { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--bg-line); align-items: baseline; font-size: .92rem; line-height: 1.4; }
|
||||
ul.tl li:last-child { border-bottom: none; }
|
||||
.tl-time { color: var(--fade); font-variant-numeric: tabular-nums; font-size: .78rem; min-width: 60px; flex: none; }
|
||||
.tl-body { flex: 1; }
|
||||
.tl-amt { margin-left: 6px; font-variant-numeric: tabular-nums; }
|
||||
li.start .tl-body { color: var(--accent); font-weight: 600; }
|
||||
li.scar .tl-body, li.confidence .tl-body { font-style: italic; }
|
||||
.tl-body a.hand { color: var(--accent); text-decoration: none; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -219,6 +229,7 @@
|
||||
}
|
||||
curSession = s;
|
||||
const stack = data.stack || {};
|
||||
const timeline = data.timeline || [];
|
||||
const hands = data.hands || [];
|
||||
const villains = data.villains || [];
|
||||
const notes = data.notes || [];
|
||||
@@ -277,6 +288,16 @@
|
||||
${stack.current == null ? '<p class="empty" style="margin:12px 0 0">No stack logged yet — tell Lyra your stack ("I\'m at 350").</p>' : ''}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">📜 Timeline</p>
|
||||
${timeline.length ? `<ul class="tl">${timeline.map(e => `
|
||||
<li class="${esc(e.kind)}">
|
||||
<span class="tl-time">${esc(e.time)}</span>
|
||||
<span class="tl-body">${esc(e.text)}${e.amount != null ? ` <b class="tl-amt">${money(e.amount)}</b>` : ''}${e.result != null ? ` <span class="res ${e.result>=0?'up':'down'}">${signed(e.result)}</span>` : ''}${e.hand_id ? ` <a class="hand" href="/hand/${e.hand_id}">hand ›</a>` : ''}</span>
|
||||
</li>`).join('')}</ul>`
|
||||
: '<p class="empty">Nothing yet tonight — the running log fills in as you play.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">Hands this session</p>
|
||||
${hands.length ? `<ul class="rows">${hands.slice().reverse().map(h => `
|
||||
|
||||
@@ -101,11 +101,3 @@ def test_list_recent_hands_flags_structured(poker):
|
||||
rows = {r["id"]: r for r in poker.list_recent_hands()}
|
||||
assert rows[structured_id]["has_structured"] is True
|
||||
assert rows[flat_id]["has_structured"] is False
|
||||
|
||||
|
||||
def test_hud_villains_carry_seat(poker):
|
||||
"""The recorder auto-places known players, so the HUD bundle must expose their seat."""
|
||||
poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
||||
poker.add_read("3-bets light", seat="BTN", name="Sal", category="risky")
|
||||
villains = {v["name"]: v for v in poker.hud()["villains"]}
|
||||
assert villains["Sal"]["seat"] == "BTN"
|
||||
|
||||
Reference in New Issue
Block a user