; ============================================================================
; poker.hex
; Poker Night
set %sb 5
set %bb 10
set %rankmap $map(A,14,K,13,Q,12,J,11,T,10,9,9,8,8,7,7,6,6,5,5,4,4,3,3,2,2)
poker_reset
sidebar add poker poker_open ♠
Play Poker
}
; Full reset to a clean lobby. Used on load AND when the table is closed, so closing a game
; terminates it completely, re-opening (even on a different network) shows a fresh lobby
alias poker_reset {
set %practice 0
set %chan #game
set %hosted 0
set %ishost 0
set %bots $map()
set %seats $list()
set %name $map()
set %stack $map()
set %bet $map()
set %folded $map()
set %board $list()
set %myhole $map()
set %hole $map()
set %started 0
set %street lobby
set %pot 0
set %tocall 0
set %turn 0
set %betamount 0
set %handid 0
set %button 0
set %hostfp
set %gameover 0
set %champion
set %startstack 1000
set %winners $list()
set %winnertext
set %allin $map()
set %turnid 0 ; bumped every time the turn moves; a stale clock tick is ignored
set %actclockms 45000 ; per-turn shot-clock (ms); the host auto-checks/folds an AWOL player
set %pending $map() ; fp->name of players who joined mid-hand; seated at the next deal
}
; ---- open / join -----------------------------------------------------------
; The sidebar entry point: just (re)draw the table. Render copes with every state
; (empty lobby, seated-and-waiting, mid-hand, showdown).
alias poker_open { poker_render }
; The user tapped Close on the table → halt the bot loop so a pending timer tick can't
; re-render and pop the table straight back open.
on SIGNAL:view_closed { poker_reset }
alias poker {
; Practice runs entirely on the local loopback (#practice is never an IRC channel).
; A real game must actually be in the shared IRC channel so peers can exchange +AGE lines.
if (%practice == 0
) { join %chan }
age.
join %chan ; announce our +AGE identity (real: broadcast; practice: skipped)
age.
send %chan join $age.
me $me 1000
; register me (local always; to peers once the host has keyed)
if (%practice == 0) { toast Joined %chan, waiting for the host to start the hand }
else { toast Joined the table - waiting for players }
}
; lobby buttons
on SIGNAL:poker_join { poker }
on SIGNAL:poker_start {
if (%gameover == 1) { return }
if (%practice == 1) { poker_deal | return }
; Real game: the host (seat 0) administers. First tap mints the shared key and invites everyone
; who announced an identity; each peer keys itself on accept (age_ready) and re-registers over the
; encrypted channel, so they appear seated. The host taps again to deal once players are in, which
; avoids dealing into a channel whose peers aren't keyed yet.
poker_sethost
if (%ishost == 0) { toast Waiting for the host to start the hand | return }
if (%hosted == 0) { age.host %chan | set %hosted 1 | poker_render | toast Table keyed, players are joining. Tap Deal when ready | return }
poker_deal
}
on SIGNAL:age_ready {
; the shared table key just arrived; register at the table over the now-encrypted channel so the
; host (and everyone) sees us seated.
if (%practice == 0
) { age.
send %chan join $age.
me $me 1000
}
}
on SIGNAL:age_msg {
; $from = sender fp ; $1 = move type ; $2- = payload
; Host-authoritative: the dealer runs all game logic and broadcasts state; clients render it.
if ($1 ==
join) { poker_onjoin
$1 $2 $3 $4 }
elseif ($1 == act) { if (%ishost == 1) { poker_onact $1 $2 $3 } }
elseif ($1 == state
) { if ($from != %
me) { poker_applystate
$2-
} }
}
; ---- host-authoritative state sync -----------------------------------------
alias poker_sethost {
set %hostfp $get(%seats, 0)
set %ishost 0
if (%practice == 1) { set %ishost 1 }
elseif (%hostfp == %
me) { set
%ishost 1
}
}
; ---- seat helpers: find the next seat still in the hand / able to act ----------
; A busted or folded seat is skipped. These use locals (set -l) so they never clobber
; a caller's %n/%fp/%idx while it is mid-loop.
alias poker_nextin {
; $1 = start index; returns the next index (wrapping) whose seat isn't folded/busted
set -l %n $len(%seats)
set -l %k 0
while (%k < %n) {
set -l %idx $mod($calc($1 + %k), %n)
if ($get(%folded, $get(%seats, %idx)) != 1) { return %idx }
set -l %k $calc(%k + 1)
}
return $mod($1, %n)
}
alias poker_nextactor {
; $1 = start index; returns the next index (wrapping) that can still act (not folded, not all-in)
set -l %n $len(%seats)
set -l %k 0
while (%k < %n) {
set -l %idx $mod($calc($1 + %k), %n)
set -l %fp $get(%seats, %idx)
if ($get(%folded, %fp) != 1) { if ($get(%allin, %fp) != 1) { return %idx } }
set -l %k $calc(%k + 1)
}
return $mod($1, %n)
}
alias poker_broadcast {
if (%ishost == 0) { return }
; NOTE: the state is shipped as space-separated tokens, so age_msg reassembles it with
; $2- (single spaces). $tojson emits no spaces except inside string values (names, the
; winner line), which are single-spaced and survive the round-trip. Keep player names
; free of runs of spaces and this stays lossless.
set %st $map()
setat %st seats %seats
setat %st stacks %stack
setat %st bets %bet
setat %st folded %folded
setat %st allin %allin
setat %st board %board
setat %st pot %pot
setat %st tocall %tocall
setat %st turn %turn
setat %st street %street
setat %st started %started
setat %st button %button
setat %st winner %winnertext
age.send %chan state $tojson(%st)
}
alias poker_applystate {
set %j $1-
set %seats $list()
set %i 0
set %go 1
while (%go == 1) {
set %fp $json(%j, seats.%i)
if ($len(%fp) == 0) { set %go 0 } else { push %seats %fp | set %i $calc(%i + 1) }
}
set %board $list()
set %i 0
set %go 1
while (%go == 1) {
set %cd $json(%j, board.%i)
if ($len(%cd) == 0) { set %go 0 } else { push %board %cd | set %i $calc(%i + 1) }
}
set %name $map()
set %stack $map()
set %bet $map()
set %folded $map()
set %allin $map()
foreach %fp %seats {
setat
%name %fp $json(%j,
names.
%fp)
setat %stack %fp $json(%j, stacks.%fp)
setat %bet %fp $json(%j, bets.%fp)
set %fd $json(%j, folded.%fp)
if (%fd == 1) { setat %folded %fp 1 }
set %ai $json(%j, allin.%fp)
if (%ai == 1) { setat %allin %fp 1 }
}
set %pot $json(%j, pot)
set %tocall $json(%j, tocall)
set %turn $json(%j, turn)
set %street $json(%j, street)
set %started $json(%j, started)
set %button $json(%j, button)
set %winnertext $json(%j, winner)
poker_sethost
poker_render
}
alias poker_onjoin {
; $2 = fp, $3 = name, $4 = stack
; track roster as a map fp->name and an ordered list (idempotent on rejoin). A brand-new player
; who arrives mid-hand is parked in %pending and seated at the next deal, so a join never
; renumbers %seats or shifts the turn pointer inside a live hand.
set %newp 0
if ($has(%name,$2) == false) { set %newp 1 }
setat %name $2 $3
setat %stack $2 $4
if (%newp == 1) {
if (%started == 1) { setat %pending $2 1 }
else { push %seats $2 }
}
poker_sethost
if (%ishost == 1) { poker_broadcast }
poker_render
}
; ---- practice mode: play a full hand solo against calling-station bots --------
alias poker_practice {
set %practice 1
set %bots $map()
set %chan #practice
poker
age.
send %chan join bot1 Botcall 1000
setat %bots bot1 1
age.
send %chan join bot2 Robofish 1000
setat %bots bot2 1
poker_deal
}
; bot turn-driver - runs on a scheduled tick (NOT synchronously) so the main thread
; is never blocked by a long cascade of bot actions; each tick acts once, the UI
; repaints, then the next action is re-scheduled by poker_onact / poker_deal below.
on SIGNAL:poker_botstep {
if (%practice == 1) {
if (%started == 1) {
set %cur $get(%seats,%turn)
if ($has(%bots,%cur) == true) { poker_botbrain %cur }
}
}
}
; Arm the per-turn shot clock. Bumps %turnid so a tick scheduled for an earlier turn is ignored
; once the turn moves on. In real games the host schedules itself to auto-act if the seat to move
; goes quiet (disconnected or just away), so one absent player can never freeze the whole table.
; Bots run on their own fast driver (poker_botstep), so this only ever resolves a human who left.
alias poker_armclock {
set %turnid $calc(%turnid + 1)
if (%practice == 0) { if (%ishost == 1) { timer %actclockms poker_turntimeout %turnid } }
}
on SIGNAL:poker_turntimeout {
if (%ishost == 0) { return }
if (%started == 0) { return }
if ($1 != %turnid) { return } ; the turn already moved on; stale tick
set %cur $get(%seats,%turn)
if ($has(%bots,%cur) == true) { return } ; bots resolve themselves
; auto-act for the away seat: check when it costs nothing, otherwise fold. age.local injects the
; move locally as that seat (exactly like a bot move) and the host re-broadcasts the result.
if ($calc(%tocall - $get(%bet,%cur)) <= 0) { age.local %cur act check }
else { age.local %cur act fold }
}
; A simple but believable bot: folds trash, calls/raises by hand strength, bluffs occasionally.
; Pre-flop it scores the two hole cards; post-flop it uses the made-hand category (0..7). Pot odds
; make cheap calls more tempting, and a random roll keeps it from being predictable.
alias poker_botbrain {
set %bot $1
set %mybet $get(%bet,%bot)
set %need $calc(%tocall - %mybet)
if (%need < 0) { set %need 0 }
set %stk $get(%stack,%bot)
set %roll $rand(0, 99)
; raise target = current call level + 3 big blinds, capped at all-in
set %raiseto $calc(%tocall + $calc(%bb * 3))
set %maxto $calc(%mybet + %stk)
if (%raiseto > %maxto) { set %raiseto %maxto }
; a call is "cheap" when it costs a quarter of the pot or less
set %cheapthresh $calc(%pot / 4)
set %cheap 0
if (%need <= %cheapthresh) { set %cheap 1 }
if ($len(%board) == 0) {
; ---- pre-flop: score the two hole cards (high cards + pair/suited bonuses) ----
; NB: split on the default (space) delimiter. A quoted " " is a literal, not a separator,
; so $split(x, " ") would return the whole string as one element and hide the second card.
set %hc $split($get(%hole,%bot))
set %c1 $get(%hc, 0)
set %c2 $get(%hc, 1)
set %r1 $get(%rankmap, $left(%c1, 1))
set %r2 $get(%rankmap, $left(%c2, 1))
set %pf $calc(%r1 + %r2)
if (%r1 == %r2) { set %pf $calc(%pf + 12) }
if ($right(%c1, 1) == $right(%c2, 1)) { set %pf $calc(%pf + 4) }
if (%need == 0) {
if (%pf >= 24) { age.local %bot act raise %raiseto }
elseif (%pf >= 18 && %roll < 50) { age.local %bot act raise %raiseto }
elseif (%roll < 6) { age.local %bot act raise %raiseto }
else { age.local %bot act check }
} else {
if (%pf >= 26) { age.local %bot act raise %raiseto }
elseif (%pf >= 19) { age.local %bot act call }
elseif (%pf >= 14 && %cheap == 1) { age.local %bot act call }
elseif (%cheap == 1 && %roll < 30) { age.local %bot act call }
else { age.local %bot act fold }
}
} else {
; ---- post-flop: made-hand category 0=high .. 7=quads ----
set %sc $handscore($get(%hole,%bot) $join(%board))
set %cat $int($calc(%sc / 759375))
if (%need == 0) {
if (%cat >= 3) { age.local %bot act raise %raiseto }
elseif (%cat == 2 && %roll < 75) { age.local %bot act raise %raiseto }
elseif (%cat == 1 && %roll < 35) { age.local %bot act raise %raiseto }
elseif (%roll < 8) { age.local %bot act raise %raiseto }
else { age.local %bot act check }
} else {
if (%cat >= 4) { age.local %bot act raise %raiseto }
elseif (%cat >= 2) { if (%roll < 45) { age.local %bot act raise %raiseto } else { age.local %bot act call } }
elseif (%cat == 1) { if (%cheap == 1 || %roll < 55) { age.local %bot act call } else { age.local %bot act fold } }
else {
if (%cheap == 1 && %roll < 55) { age.local %bot act call }
elseif (%roll < 10) { age.local %bot act call }
else { age.local %bot act fold }
}
}
}
}
; ---- start a hand (dealer button rotates each hand) ------------------------
alias poker_deal {
poker_sethost
; seat anyone who joined during the previous hand (parked in %pending), now that seat order and
; the turn pointer can safely change. Guard against double-seating on a rejoin.
foreach %fp $keys(%pending) { if ($has(%seats, %fp) == false) { push %seats %fp } }
set %pending $map()
set %started 1
set %winnertext
set %board $list()
set %folded $map()
set %bet $map()
set %hole $map()
set %myhole $map()
set %street preflop
set %pot 0
set %tocall %bb
; players with no chips are out for the hand: mark them folded so every downstream loop
; (turn advance, showdown, live-count) skips them with no special-casing.
foreach %fp %seats { if ($get(%stack,%fp) <= 0) { setat %folded %fp 1 } }
; move the dealer button to the next player who still has chips (first hand keeps seat 0)
if (%handid > 0) { set %button $poker_nextin($calc(%button + 1)) }
set %handid $calc(%handid + 1)
set %dealerfp $get(%seats, %button)
; secret deck (dealer only). seed kept private; revealed at showdown to verify.
set %seed $age.rand(32)
poker_builddeck
; deal 2 hole cards per seat; only seal to players actually in the hand
set %i 0
foreach %fp %seats {
set %c1 $get(%deck,$calc(%i * 2))
set %c2 $get(%deck,$calc(%i * 2 + 1))
if ($get(%folded,%fp) != 1) { age.seal %fp %c1 %c2 }
if (%ishost == 1) { setat %hole %fp %c1 %c2 }
set %i $calc(%i + 1)
}
; community sits after all hole cards
set %base $calc($len(%seats) * 2)
; post blinds relative to the button and open betting
poker_postblinds
poker_render
poker_broadcast
timer 350 poker_botstep
poker_armclock
}
alias poker_builddeck {
set %deck $list()
set %keyed $list()
foreach %r $list(2,3,4,5,6,7,8,9,T,J,Q,K,A) {
foreach %s $list(c,d,h,s) {
; keyed shuffle: sort cards by a PRF of (seed, card) - a verifiable permutation
push %keyed $age.sha(%seed%r%s)~%r%s
}
}
set %keyed $sort(%keyed)
foreach %k %keyed {
set %card $get($split(%k,~),1)
push %deck %card
}
}
alias poker_postblinds {
set %contrib $map()
set %acted $map()
set %allin $map()
; how many players are actually in this hand (chips at the start)
set %inhand 0
foreach %fp %seats { if ($get(%folded,%fp) != 1) { set %inhand $calc(%inhand + 1) } }
; blind seats relative to the button, skipping busted players
if (%inhand < 3) {
; heads-up: the button posts the small blind and acts first pre-flop
set %sbi %button
set %bbi $poker_nextin($calc(%button + 1))
} else {
set %sbi $poker_nextin($calc(%button + 1))
set %bbi $poker_nextin($calc(%sbi + 1))
}
set %sbfp $get(%seats, %sbi)
set %bbfp $get(%seats, %bbi)
; post blinds, clamped to stack (a short stack goes all-in on its blind)
set %sbamt %sb
if (%sbamt > $get(%stack,%sbfp)) { set %sbamt $get(%stack,%sbfp) }
set %bbamt %bb
if (%bbamt > $get(%stack,%bbfp)) { set %bbamt $get(%stack,%bbfp) }
setat %bet %sbfp %sbamt
setat %bet %bbfp %bbamt
setat %contrib %sbfp %sbamt
setat %contrib %bbfp %bbamt
setat %stack %sbfp $calc($get(%stack,%sbfp) - %sbamt)
setat %stack %bbfp $calc($get(%stack,%bbfp) - %bbamt)
if ($get(%stack,%sbfp) <= 0) { setat %allin %sbfp 1 }
if ($get(%stack,%bbfp) <= 0) { setat %allin %bbfp 1 }
set %pot $calc(%sbamt + %bbamt)
set %tocall %bb
; first to act pre-flop = next player who can act after the big blind (heads-up: the SB/button)
if (%inhand < 3) { set %turn %sbi }
else { set %turn $poker_nextactor($calc(%bbi + 1)) }
}
; our sealed hole cards arrive here
on SIGNAL:age_deal {
; $data = "<c1> <c2>"
poker_render
}
; ---- betting ---------------------------------------------------------------
on SIGNAL:poker_fold { age.send %chan act fold }
on SIGNAL:poker_call { age.send %chan act call }
on SIGNAL:poker_raise { age.send %chan act raise %betamount }
on SIGNAL:poker_min { set %betamount %bb | poker_render }
on SIGNAL:poker_q { set %betamount $int($calc(%pot / 4)) | poker_render }
on SIGNAL:poker_h { set %betamount $int($calc(%pot / 2)) | poker_render }
on SIGNAL:poker_pot { set %betamount %pot | poker_render }
on SIGNAL:poker_t { set %betamount $int($calc(%pot * 3 / 4)) | poker_render }
on SIGNAL:poker_allin { age.send %chan act allin }
alias poker_onact {
; Drop stale / double-tapped / out-of-turn actions. The view only shows action buttons on
; your turn, but taps can still race the async re-render; without this guard a second tap
; re-applies a move for a seat that has already acted, corrupting stacks/pot and the turn
; pointer (the "mash the buttons" crash). Only the seat whose turn it is may act.
if (%started == 0) { halt }
if ($from != $get(%seats,%turn)) { halt }
; $from acted: $2 = fold|check|call|raise ; $3 = raise-to amount
set %fp $from
if ($2 == fold) {
setat %folded %fp 1
setat %acted %fp 1
}
elseif ($2 == raise) {
set %need $calc($3 - $get(%bet,%fp))
if (%need < 0) { set %need 0 } ; a raise-to below our current bet isn't a raise; never move chips backwards
set %put %need
if (%put > $get(%stack,%fp)) { set %put $get(%stack,%fp) } ; clamp to stack (short all-in)
setat %bet %fp $calc($get(%bet,%fp) + %put)
setat %stack %fp $calc($get(%stack,%fp) - %put)
setat %contrib %fp $calc($get(%contrib,%fp) + %put)
set %pot $calc(%pot + %put)
set %tocall $get(%bet,%fp)
set %acted $map() ; a genuine raise re-opens the action for everyone
setat %acted %fp 1
if ($get(%stack,%fp) == 0) { setat %allin %fp 1 }
}
elseif ($2 == allin) {
; host-authoritative all-in: commit the seat's entire remaining stack, clamped by the host so
; it works even if the client's local %bet/%stack are stale. A raise (new bet above %tocall)
; re-opens the action for everyone; an all-in that only calls/undercalls does not.
set %put $get(%stack,%fp)
setat %bet %fp $calc($get(%bet,%fp) + %put)
setat %stack %fp 0
setat %contrib %fp $calc($get(%contrib,%fp) + %put)
set %pot $calc(%pot + %put)
if ($get(%bet,%fp) > %tocall) { set %tocall $get(%bet,%fp) | set %acted $map() }
setat %acted %fp 1
setat %allin %fp 1
}
else {
; call or check (call short-stack = all-in for the rest)
set %need $calc(%tocall - $get(%bet,%fp))
if (%need < 0) { set %need 0 }
set %put %need
if (%put > $get(%stack,%fp)) { set %put $get(%stack,%fp) }
setat %bet %fp $calc($get(%bet,%fp) + %put)
setat %stack %fp $calc($get(%stack,%fp) - %put)
setat %contrib %fp $calc($get(%contrib,%fp) + %put)
set %pot $calc(%pot + %put)
setat %acted %fp 1
if ($get(%stack,%fp) == 0) { setat %allin %fp 1 }
}
poker_advance
poker_render
poker_broadcast
timer 350 poker_botstep
poker_ar