; ============================================================================ ; poker.hex ; Poker Night on LOAD { 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. set %me $age.me 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 %me $age.me 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 names %name 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 = " " setat %myhole me $data 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