/* ============================================================
tournament.jsx — main menu + 9-max single-table tournament (SNG).
Uses window.Table (engine + controller), window.Charts (multiway GTO
grading), window.Engine (equity), and reuses window.UI feedback widgets.
Exposes window.TournamentUI = { Menu, TournamentApp }.
============================================================ */
const { useState: useT, useRef: useR, useEffect: useE, useCallback: useC } = React;
const Tbl = window.Table, Ch = window.Charts, Eg = window.Engine, GT = window.GTO;
const { FeedbackOverlay: FBOverlay, Toast: TToast } = window.UI;
const Sfx = window.Sfx || { forAction() {}, deal() {}, win() {}, lose() {}, click() {}, isEnabled: () => false, setEnabled() {} };
const chips = (n) => Math.round(n).toLocaleString();
// 9 seat anchor points around the oval rim (% of table box). id 0 = hero, bottom-center.
const POS9 = [
{ x: 50, y: 93 }, { x: 18, y: 85 }, { x: 6, y: 57 }, { x: 11, y: 27 }, { x: 33, y: 10 },
{ x: 67, y: 10 }, { x: 89, y: 27 }, { x: 94, y: 57 }, { x: 82, y: 85 },
];
const TCENTER = { x: 50, y: 50 };
// point between a seat and the pot (for bet chips / dealer button)
const lerp = (p, t) => ({ x: TCENTER.x + (p.x - TCENTER.x) * t, y: TCENTER.y + (p.y - TCENTER.y) * t });
/* ---------- bot policy (multiway) ---------- */
function botMove(H, id) {
const s = H.seats[id], bb = H.blinds.bb;
const toCall = Math.max(0, H.currentBet - s.street);
const legal = Tbl.legalActions(H).map((a) => a.type);
const has = (t) => legal.includes(t);
const sizeTo = (mult) => Math.min(Tbl.maxRaiseTo(H), Math.max(Tbl.minRaiseTo(H), Math.round(mult)));
if (H.street === "preflop") {
const code = GT.codeOfHole(s.hole);
let behind = 0; for (const oid of H.ring) { const x = H.seats[oid]; if (oid !== id && !x.folded && !x.acted) behind++; }
const raised = H.currentBet > bb + 1e-9;
if (!raised && toCall === 0) return GT.pctlOf(s.hole) > 0.9 && has("raise") ? { type: "raise", to: sizeTo(2.3 * bb) } : { type: "check" };
const facing = !raised ? "first-in" : (toCall >= s.stack * 0.6 ? "shove" : "raise");
const adv = Ch.advise({ code, effStackBB: s.stack / bb, playersBehind: behind, facing, toCallBB: toCall / bb, potBB: Tbl.potTotal(H) / bb, posName: "" });
if (adv.action === "shove" && has("raise")) return { type: "raise", to: s.street + s.stack };
if (adv.action === "raise" && has("raise")) return { type: "raise", to: sizeTo(raised ? H.currentBet * 3 : 2.3 * bb) };
if (adv.action === "call" && has("call")) return { type: "call", to: H.currentBet };
if (has("check")) return { type: "check" };
return { type: "fold" };
}
const numOpp = H.ring.filter((o) => !H.seats[o].folded).length - 1;
const eq = Eg.equityMulti(s.hole, H.board, Math.max(1, numOpp), 200);
const pot = Tbl.potTotal(H), potOdds = toCall > 0 ? toCall / (pot + toCall) : 0;
if (toCall > 0) {
if (eq > 0.66 && has("raise") && Math.random() < 0.5) return { type: "raise", to: sizeTo(H.currentBet + pot * 0.6) };
if (eq >= potOdds + 0.03 && has("call")) return { type: "call", to: H.currentBet };
return has("check") ? { type: "check" } : { type: "fold" };
}
if (eq > 0.55 && has("bet")) return { type: "bet", to: s.street + Math.round(pot * 0.6) };
return { type: "check" };
}
/* ---------- main menu ---------- */
function Menu({ onPick }) {
return (
GTO POKER
A No-Limit Hold'em trainer that grades every decision against a solver — with a detailed reason why.
♥ 7-card evaluator◆ Monte-Carlo equity♣ Mixed-strategy GTO★ Why on every play
Heads-up preflop is a real live solve. Multiway & postflop use honest published charts + equity heuristics — not a real-time solve.
);
}
/* ---------- tournament table ---------- */
function TournamentApp({ onMenu }) {
const [, setTick] = useT(0);
const rerender = useC(() => setTick((t) => t + 1), []);
const tourRef = useR(null);
const handRef = useR(null);
const botTimer = useR(null);
const keyRef = useR(null);
const sizeRef = useR(null); // current raise sizing exposed by the action bar (for the R key)
const heroSeat = 0;
const [busy, setBusy] = useT(false);
const [waitNext, setWaitNext] = useT(false);
const [pending, setPending] = useT(null);
const [toast, setToast] = useT(null);
const [study, setStudy] = useT(true);
const [msg, setMsg] = useT("");
const [muted, setMuted] = useT(!Sfx.isEnabled());
const toggleSound = () => { const on = !Sfx.isEnabled(); Sfx.setEnabled(on); setMuted(!on); if (on) Sfx.click(); };
useE(() => { newTourney(); return () => clearTimeout(botTimer.current); /* eslint-disable-next-line */ }, []);
// keyboard shortcuts: F fold, C call/check, A all-in, N next/continue
useE(() => {
const onKey = (e) => {
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const k = e.key === " " ? "n" : e.key.toLowerCase();
if (["f", "c", "a", "r", "n", "enter"].includes(k)) { e.preventDefault(); keyRef.current && keyRef.current(k === "enter" ? "n" : k); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
function newTourney() {
clearTimeout(botTimer.current);
setWaitNext(false); setPending(null); setToast(null); setMsg("");
tourRef.current = Tbl.createTournament({ players: 9, startStack: 1500, heroSeat });
nextHand();
}
function nextHand() {
const T = tourRef.current;
setWaitNext(false); setPending(null); setToast(null); setMsg("");
const H = Tbl.startHand(T);
handRef.current = H;
Sfx.deal();
rerender();
step();
}
function canHeroAct(H) { return H && !H.finished && H.toAct === heroSeat && !H.seats[heroSeat].folded && !H.seats[heroSeat].allIn && H.seats[heroSeat].stack > 0; }
function step() {
const H = handRef.current;
if (!H) return;
if (H.finished) { onHandEnd(); return; }
if (canHeroAct(H)) { setBusy(false); rerender(); return; }
setBusy(true); rerender();
botTimer.current = setTimeout(() => {
const h = handRef.current;
if (!h || h.finished) { onHandEnd(); return; }
if (canHeroAct(h)) { setBusy(false); rerender(); return; }
const id = h.toAct;
const mv = botMove(h, id);
const allin = (mv.type === "raise" || mv.type === "bet") && mv.to >= h.seats[id].street + h.seats[id].stack - 0.5;
Tbl.applyAction(h, mv);
Sfx.forAction(mv.type, allin);
rerender();
step();
}, 480 + Math.random() * 360);
}
function onHandEnd() {
const T = tourRef.current, H = handRef.current;
setBusy(false);
const heroWon = H && H.result && H.result.pots && H.result.pots.some((p) => p.winners.includes(heroSeat));
Tbl.finishHand(T);
rerender();
if (T.finished) { const champ = T.winner === heroSeat; champ ? Sfx.win() : Sfx.lose(); setMsg(champ ? "🏆 You win the tournament!" : `Tournament over — ${T.players[T.winner].name} wins. You placed ${T.players[heroSeat].place}.`); setWaitNext("done"); return; }
if (T.players[heroSeat].eliminated) { Sfx.lose(); setMsg(`You busted — ${ordinal(T.players[heroSeat].place)} place of ${T.n}.`); setWaitNext("done"); return; }
if (heroWon) Sfx.win();
setWaitNext(true);
}
function heroAct(action) {
const H = handRef.current;
if (!canHeroAct(H)) return;
const fb = Ch.grade(H, heroSeat, action);
if (study) { setPending({ fb, action }); rerender(); }
else { setToast(fb); setTimeout(() => setToast(null), 1600); applyHero(action); }
}
function applyHero(action) {
setPending(null);
const h = handRef.current, s = h.seats[heroSeat];
const allin = (action.type === "raise" || action.type === "bet") && action.to >= s.street + s.stack - 0.5;
Tbl.applyAction(h, action);
Sfx.forAction(action.type, allin);
rerender(); step();
}
const T = tourRef.current, H = handRef.current;
if (!T) return null;
const bl = Tbl.blinds(T);
const heroH = H && H.seats[heroSeat];
const reveal = H && H.finished && H.result && H.result.type === "showdown";
const winnerIds = H && H.finished && H.result && H.result.pots ? new Set(H.result.pots.flatMap((p) => p.winners)) : new Set();
keyRef.current = (k) => {
if (pending) { if (k === "n" || k === "c") applyHero(pending.action); return; }
if (waitNext) { if (k === "n" || k === "c") (waitNext === "done" ? newTourney() : nextHand()); return; }
const h = handRef.current;
if (!canHeroAct(h)) return;
const legal = Tbl.legalActions(h).map((a) => a.type);
const s = h.seats[heroSeat];
if (k === "f") heroAct(legal.includes("fold") ? { type: "fold" } : { type: "check" });
else if (k === "c") heroAct(legal.includes("call") ? { type: "call", to: h.currentBet } : { type: "check" });
else if (k === "r" && (legal.includes("raise") || legal.includes("bet")) && sizeRef.current != null) heroAct({ type: legal.includes("raise") ? "raise" : "bet", to: sizeRef.current });
else if (k === "a" && (legal.includes("raise") || legal.includes("bet"))) heroAct({ type: legal.includes("raise") ? "raise" : "bet", to: s.street + s.stack });
};
return (
GTO POKER · Tournament
9-max sit & go
{bl.sb}/{bl.bb}blinds · L{T.level + 1}
{Tbl.aliveSeats(T).length}left
{chips(T.players[heroSeat].stack)}your stack
{H &&
POT {chips(Tbl.potTotal(H) - (H.finished ? potJustWon(H) : 0))}
}
{H &&
{H.board.map((c, i) =>
)}{Array.from({ length: 5 - H.board.length }).map((_, i) =>
)}
}
{H && msg &&
{msg}
}
{H && (() => { const b = lerp(POS9[H.btnId], 0.34); return
D
; })()}
{T.players.map((p) => {
const inHand = H && H.seats[p.id] && !p.eliminated;
const s = inHand ? H.seats[p.id] : null;
const pos = POS9[p.id];
const role = H ? (p.id === H.sbId ? "SB" : p.id === H.bbId ? "BB" : "") : "";
return (
{p.name}{role && {role}}
{p.eliminated ? "OUT" : chips(p.stack)}
{s && !p.eliminated && !s.folded &&
{(p.id === heroSeat || reveal || winnerIds.has(p.id)) ? s.hole.map((c, i) => ) : [0, 1].map((i) => )}
}
{s && s.allIn && !s.folded &&
ALL-IN
}
);
})}
{H && T.players.map((p) => {
const s = H.seats[p.id];
if (!s || p.eliminated || s.street <= 0) return null;
const bp = lerp(POS9[p.id], 0.45);
return
{chips(s.street)}
;
})}
{/* action area */}
{waitNext === "done" ? (
) : waitNext ? (
{handResultText(H, heroSeat)}
) : (
)}
{pending &&
applyHero(pending.action)} />}
{toast && }
);
}
function potJustWon(H) { if (!H.result || !H.result.pots) return 0; return H.result.pots.reduce((a, p) => a + p.amount, 0); }
function ordinal(n) { const s = ["th", "st", "nd", "rd"], v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); }
function handResultText(H, hero) {
if (!H || !H.result || !H.result.pots || !H.result.pots.length) return "Hand complete";
const nm = (id) => (id === hero ? "You" : (H.seats[id] && H.seats[id].name) || "Player");
const w = H.result.pots[0].winners;
const names = w.map(nm).join(" & ");
const v = (w.length === 1 && w[0] === hero) ? "win" : "wins";
if (w.length > 1) return `Split pot — ${names}.`;
if (H.result.type === "fold") return `${names} ${v} — everyone folded.`;
const hand = H.result.hands && H.result.hands[w[0]];
return `${names} ${v}${hand ? " with " + hand : ""}.`;
}
/* ---------- tournament action bar (chips) ---------- */
function TActionBar({ H, heroSeat, canAct, busy, onAction, sizeRef }) {
const s = H && H.seats[heroSeat];
const toCall = s ? Math.max(0, H.currentBet - s.street) : 0;
const pot = H ? Tbl.potTotal(H) : 0;
const minTo = H ? Tbl.minRaiseTo(H) : 0, maxTo = s ? s.street + s.stack : 0;
const canAggro = s && s.stack > toCall;
const [to, setTo] = useT(minTo);
useE(() => {
let def = H && H.street === "preflop" ? Math.min(maxTo, Math.max(minTo, Math.round((H.currentBet > H.blinds.bb ? H.currentBet * 3 : H.blinds.bb * 2.3))))
: Math.min(maxTo, Math.max(minTo, Math.round((s ? s.street : 0) + pot * 0.6)));
setTo(def || minTo);
// eslint-disable-next-line
}, [H && H.toAct, H && H.street, H && H.history.length]);
// publish current raise sizing for the R shortcut
if (sizeRef) sizeRef.current = (H && !H.finished && canAct && canAggro) ? to : null;
if (!H || H.finished || !canAct) {
if (sizeRef) sizeRef.current = null;
const actor = H && !H.finished && H.toAct != null && H.seats[H.toAct] ? H.seats[H.toAct].name : null;
return {actor ? `${actor} is acting…` : "Waiting…"}
;
}
const raiseVerb = toCall > 0 ? "Raise" : "Bet";
const raiseType = toCall > 0 ? "raise" : "bet";
const presets = [
["½", { frac: 0.5 }], ["¾", { frac: 0.75 }], ["Pot", { frac: 1 }],
["2bb", { bb: 2 }], ["3bb", { bb: 3 }], ["4bb", { bb: 4 }], ["5bb", { bb: 5 }],
["All-in", { allin: true }],
];
const applyPreset = (p) => {
const d = p[1]; let v;
if (d.allin) v = maxTo;
else if (d.bb != null) v = d.bb * H.blinds.bb; // total bet of N big blinds
else v = s.street + d.frac * (pot + toCall); // pot-fraction raise/bet
setTo(Math.max(minTo, Math.min(maxTo, Math.round(v))));
};
return (
{canAggro && (
{presets.map((p) =>
)}
setTo(parseInt(e.target.value))} />
{chips(to)}
)}
{toCall > 0 ?
: }
{toCall > 0 && }
{canAggro && }
F {toCall > 0 ? "fold" : "check"} · C {toCall > 0 ? "call" : "check"}{canAggro && <>{" · "}R raise{" · "}A all-in>} · N next
);
}
window.TournamentUI = { Menu, TournamentApp };