Skip to content

feat: lobby player search, practice mode vs bots, mobile action bar, and live hand timeline (#173, #174, #175, #176) - #487

Merged
Marvy247 merged 5 commits into
HitEmPoka:mainfrom
jayteemoney:feat/lobby-search-practice-mode-mobile-actions-timeline
Aug 31, 2026
Merged

feat: lobby player search, practice mode vs bots, mobile action bar, and live hand timeline (#173, #174, #175, #176)#487
Marvy247 merged 5 commits into
HitEmPoka:mainfrom
jayteemoney:feat/lobby-search-practice-mode-mobile-actions-timeline

Conversation

@jayteemoney

Copy link
Copy Markdown
Contributor

Four lobby/table UX features, one per commit, plus a fix for a build that was
already broken on main.

Closes #173
Closes #174
Closes #175
Closes #176


#173 — Find a table by player alias or public-key prefix

The lobby could already filter open tables, but only by matching the query as
a substring anywhere in a seat's raw address, which doesn't answer "which
table is my friend at?" — you know a friend by their alias or the first few
characters of their key, not a fragment from the middle of one.

Matching moved into lib/table-search.ts so the rules are testable on their
own. Aliases match as a case-insensitive substring; keys match as a prefix
rather than a substring; table numbers still match, with an optional leading
#. Both the wallet address and the contract-side chain address are searched,
so seats the lobby hasn't mapped to a wallet stay findable.

Each surviving row now shows which seat matched ("seat 2: Alice") — knowing
only that the table matched isn't much use. Search runs on a 250 ms debounce
(the input stays controlled, so typing is unaffected), and the field is
labelled with a role="status" live region announcing the result count.

Aliases remain per-browser, as they already were; key-prefix search works
regardless.

#174 — Practice mode against bots, no wallet needed

The existing "SOLO VS AI" path still creates a table through the coordinator,
signs with a wallet, and leans on the chain for street progression — so a
visitor without a wallet can't use it. /practice is self-contained: the deck
is shuffled in the browser, the bots decide in the browser, nothing is signed
or submitted. It's offered from the connect screen too, where a wallet-less
visitor would otherwise be stuck.

lib/practice-engine.ts is a pure state machine with the RNG threaded through
the state, so a session replays identically from its seed and the rules are
testable without a renderer. It covers blinds (including the heads-up
button-posts-the-small-blind rule), betting rounds with correct re-opening — a
short all-in raises the price without reopening action, a full raise does —
street progression, running the board out when everyone is all in, showdown by
best five-card hand, and side pots split by contribution level.

lib/practice-bot.ts holds the opponents, on the two axes players actually use
to describe each other:

style behaviour
easy loose-passive calling station; calls down light, never bluffs
medium tight-passive rock; folds junk, calls with the rest
hard tight-aggressive bets and raises for value, bluffs ~20%

All three share one decision shape — strength in, action out — with the profile
supplying thresholds, each weighing its strength against the pot odds offered.
Made-hand categories are scored on a deliberately non-linear scale: the step
from high card to a pair is the biggest jump in playability, while the top
categories are all "just bet it", and that spacing is what lets one set of
thresholds separate a bot that calls with any pair from one that needs two pair
to raise. Bots also tighten as more opponents stay in.

The table reuses Board, Card and the new mobile bar, and carries the same
F/C/B/R/A shortcuts, so what a new player learns here transfers to a real table.

#175 — Mobile bottom action bar

MobileActionBar collapses fold / check-call / raise / all-in into a bar pinned
to the bottom edge. Raise expands upward into a sheet with a horizontally
scrolling row of pot-relative presets (MIN, ½, ⅔, POT, 2×, MAX), a slider, and
an explicit confirm; presets that clamp to the same number on a short stack
collapse to one entry.

  • padding-bottom: max(8px, env(safe-area-inset-bottom)) clears the iOS home
    indicator, and the body reserves the bar's height so the felt and chat toggle
    are never underneath it.
  • 48px buttons, above the 44px WCAG 2.5.5 target.
  • Always rendered, hidden by media query above 640px — no viewport-width state
    in React to get wrong during hydration, no flash on first paint. Below the
    breakpoint the desktop panel's interactive clusters are hidden, so the two
    never present duplicate controls.
  • The sheet closes on Escape and when the turn passes, so it can't sit over the
    felt showing a stale price.

#176 — Hand timeline scrubber

A strip under the felt records each moment of the hand in progress, stamped
with how long into the hand it happened. Selecting one shows the table as it
stood then; a LIVE control returns to the present.

Separate from hand-history.ts, which archives finished hands — this is the
in-flight view. It resets each hand and persists to localStorage, so a reload
mid-hand keeps the record, which is the disconnect case the issue describes.

Events are derived from state the table already polls, so no new endpoint is
needed. Each event's id is built from what the moment is rather than when it
was seen, making recording idempotent — necessary because the table re-syncs
from a chain subscription, a WebSocket push, and an interval poll at once, and
would otherwise record every moment three times.

The strip follows the APG slider pattern rather than a row of loose buttons:
one focusable control carrying aria-valuenow/aria-valuetext, driven with
arrow keys plus Home/End/PageUp/PageDown. Markers stay clickable but are out of
the tab order. A role="status" region announces whether the felt is showing
now or then, and the board carries a dashed outline while reviewing. Scrubbing
is read-only — every control stays bound to the live state.


Build fix (first commit)

next build type-checks the app, and three pre-existing errors were failing it
on main before any of this work:

  • Table.tsx referenced replayEntry / setReplayEntry, but the state was
    never declared — the hand replayer (Create a hand replayer with step-forward/back controls #62) and the history panel's onReplay
    callback were both already wired to it.
  • reconnect-state.ts passed a bare Uint8Array where Uint8Array<ArrayBuffer>
    is required; since TypeScript 5.7 the type is generic over its buffer and the
    bare alias admits SharedArrayBuffer.
  • token-selector.test.tsx widened { type: "XLM" } to { type: string },
    which no longer satisfies TokenChoice.

They're separated into their own commit so they're easy to review or take
independently.


Verification

  • npx tsc --noEmit — clean; each of the five commits type-checks on its own.
  • npm run build — succeeds; /practice prerenders as static.
  • npm test241 passing, 135 of them new. The 4 failures
    (audio-controls, reconnect-state) are pre-existing on main and
    unrelated; they fail identically before this branch.
  • Driven in a real browser (Playwright, dev server):

Rebased on upstream/main (530d9a9) — fast-forward, no conflicts.

`next build` type-checks the app, and three pre-existing errors were failing
it on main:

- `Table.tsx` referenced `replayEntry` / `setReplayEntry`, but the state was
  never declared. The hand replayer (HitEmPoka#62) and the history panel's `onReplay`
  callback were both already wired to it.
- `reconnect-state.ts` passed a bare `Uint8Array` where a
  `Uint8Array<ArrayBuffer>` is required. Since TypeScript 5.7 the type is
  generic over its buffer and the bare alias admits `SharedArrayBuffer`,
  which neither `crypto.getRandomValues` nor `BufferSource` accepts.
- `token-selector.test.tsx` widened `{ type: "XLM" }` to `{ type: string }`,
  which no longer satisfies `TokenChoice`.

`npx tsc --noEmit` is clean and `npm run build` succeeds.
The lobby could already filter open tables, but only by matching the query
as a substring anywhere in a seat's raw address. That does not answer the
question the issue poses — "which table is my friend at?" — because a player
knows a friend by the alias they set at the table or by the first few
characters of their key, not by a fragment from the middle of one.

Matching moves into `lib/table-search.ts` so the rules are testable on their
own:

- Aliases match as a case-insensitive substring, read from the existing
  per-browser alias store. "bo" finds "Bobby".
- Keys match as a **prefix**, not a substring: typing `GABC` finds the player
  whose key starts that way, and no longer matches every key containing those
  characters somewhere inside. Case-insensitive, since Stellar keys are
  upper-case base32.
- Table numbers still match, with an optional leading `#`.
- Both the wallet address and the contract-side chain address are searched,
  so seats the lobby has not mapped to a wallet are still findable.

Each surviving row now shows *which* seat matched ("seat 2: Alice"), since
knowing the table matched is not much use on its own.

Search runs on a 250 ms debounce — the raw input stays controlled, so typing
is unaffected, but the filter no longer re-reads an alias per seat on every
keystroke. The field is labelled and paired with a `role="status"` live
region announcing the result count, so the outcome of a search is available
without sight of the list.

Aliases are per-browser, as they already were; key-prefix search works
regardless.

23 tests cover prefix detection, alias and address matching, seat ordering,
and the "lobby detail failed to load" path.
On a narrow screen the action panel pushed the felt off the top of the
viewport, and its buttons landed wherever the wrapping happened to put them.
`MobileActionBar` collapses the four decisions a player actually makes —
fold, check/call, raise, all-in — into a bar pinned to the bottom edge, in
thumb reach and in the same place every hand.

Raise is the only one needing more than a tap, so it expands upward into a
sheet rather than permanently occupying bar space. The sheet carries a
horizontally scrolling row of pot-relative presets (MIN, ½, ⅔, POT, 2×, MAX)
plus a slider and an explicit confirm, so a mis-tap cannot commit chips.
Presets that clamp to the same number on a short stack collapse to one entry
instead of repeating.

Layout details:

- `padding-bottom: max(8px, env(safe-area-inset-bottom))` keeps the row clear
  of the iOS home indicator, and the body reserves the bar's height so the
  felt and the chat toggle are never underneath it.
- Buttons are 48px tall, above the 44px WCAG 2.5.5 touch target.
- The bar is always rendered and hidden by media query above 640px, so there
  is no viewport-width state in React to get wrong during hydration and no
  flash on first paint. Below that breakpoint the desktop panel's own
  interactive clusters are hidden, so the two never present duplicate
  controls.
- The sheet closes on Escape and when the turn passes, so it can never sit
  over the felt showing a stale price.

Verified on an iPhone 13 viewport: the bar sits flush with the bottom of the
viewport, the desktop cluster is hidden, and a raise submits and closes. 16
tests cover the action mapping, preset sizing and de-duplication, the
expand/collapse contract, and the disabled states.
A player who drops for thirty seconds — a tunnel, a locked screen, a
suspended tab — comes back to a board that has moved on with no record of how
it got there. A strip under the felt now records each moment of the hand in
progress, stamped with how long into the hand it happened; selecting one
shows the table as it stood then, and a LIVE control returns to the present.

This is separate from `hand-history.ts`, which archives hands once they are
over. This is the in-flight view: it resets each hand and persists to
localStorage, so a reload mid-hand keeps the record — exactly the disconnect
case the issue describes.

Events are derived from the state the table already polls, so no new endpoint
is needed. Each event's id is built from what the moment *is* rather than
when it was seen, which makes recording idempotent — necessary because the
table re-syncs from a chain subscription, a WebSocket push, and an interval
poll at once, and would otherwise record every moment three times.

The strip follows the APG slider pattern rather than being a row of loose
buttons: one focusable control carrying `aria-valuenow`/`aria-valuetext`,
driven with arrow keys plus Home/End/PageUp/PageDown, so a keyboard user
scrubs the way they would any range. Markers stay clickable for pointer users
but are kept out of the tab order. A `role="status"` region announces whether
the felt is showing now or then, and the board carries a dashed outline while
reviewing, so the live state and a past one can never be confused. Every
control stays bound to the live state — scrubbing is read-only.

Verified against a stubbed coordinator stepping a hand through its streets:
six markers recorded, Home rewinds the felt from a 300 pot to 20, and the
LIVE control restores it. 25 unit tests plus 12 component tests cover event
derivation, idempotency, snapshot clamping, elapsed-time formatting,
per-hand/per-table persistence including corrupt data, and the keyboard
contract.
…ka#174)

The existing "SOLO VS AI" path still creates a table through the coordinator,
signs with a wallet, and leans on the chain for street progression — so a
visitor with no wallet cannot use it, and it is not really practice. `/practice`
is a self-contained table: the deck is shuffled in the browser, the bots
decide in the browser, and nothing is signed or submitted. A visitor with no
wallet is offered it directly from the connect screen, where they would
otherwise be stuck.

`lib/practice-engine.ts` is a pure state machine — every transition takes a
state and returns a new one, with the RNG threaded through the state rather
than kept on the side, so a whole session replays identically from its seed
and the rules are testable without a renderer. It covers blinds (including
the heads-up button-posts-the-small-blind rule), betting rounds with proper
re-opening — a short all-in raises the price without reopening action, a full
raise does — street progression, running out the board when everyone is all
in, showdown by best five-card hand, and side pots split by contribution
level so a short stack can only win the layer it paid into.

`lib/practice-bot.ts` holds the opponents. Difficulty is expressed on the two
axes players actually use to describe each other:

  easy   — loose-passive calling station: calls down light, never bluffs
  medium — tight-passive rock: folds junk, calls with the rest
  hard   — tight-aggressive: bets and raises for value, bluffs ~20%

All three share one decision shape — hand strength in, action out — with the
profile supplying thresholds, and each compares its strength against the pot
odds it is being offered scaled by how loose the profile is. Made-hand
categories are scored on a deliberately non-linear scale: the step from high
card to a pair is the biggest jump in playability, while the top categories
are all "just bet it", and that spacing is what lets one set of thresholds
separate a bot that calls with any pair from one that needs two pair to
raise. Bots also tighten as more opponents stay in the hand.

The table reuses Board, Card and the mobile action bar from HitEmPoka#175, so what a
new player learns here transfers to a real table, and it carries the same
F/C/B/R/A shortcuts.

Verified in a browser: 1–5 bots, eight hands played end to end with no
console errors, and the page prerenders as static since it has no backend.
46 tests cover engine and bots, including chip conservation across 125 hands
over 25 seeds, that no seed leaves a hand unable to progress, and that a
contested showdown always names the winning hand.
@drips-wave

drips-wave Bot commented Aug 31, 2026

Copy link
Copy Markdown

@jayteemoney Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Marvy247
Marvy247 merged commit 8144390 into HitEmPoka:main Aug 31, 2026
7 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants