From 96c41a0040d8ed31b41c35359462898893fe8f77 Mon Sep 17 00:00:00 2001 From: "pip-robot[bot]" Date: Thu, 27 Aug 2026 18:44:10 +0000 Subject: [PATCH] fix(ai): the bots checked the flop round multiway, and nothing measured it Every postflop gate in the policy was an absolute equity number written for a heads-up pot. An equity point is not the same size against three opponents as against one, so multiway the gates were unreachable: measured on a loose profile the AI led an unbet pot 21% of the time heads-up and 7% against two or three. Quoted as a multiple of a fair share of the pot instead. Heads-up a fair share is exactly 0.5, so every multiple reproduces the absolute it replaced and no heads-up pot plays differently. Multiway leading goes to 16% and 23%. Also corrects the WithTheMembership docblock, which said nothing rendered it (technology#75). --- src/components/drills/DrillRunner.tsx | 11 +++- src/lib/poker/ai/policy.ts | 29 ++++++++-- tests/ai.test.ts | 79 +++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/components/drills/DrillRunner.tsx b/src/components/drills/DrillRunner.tsx index 4ea598c..65499d6 100644 --- a/src/components/drills/DrillRunner.tsx +++ b/src/components/drills/DrillRunner.tsx @@ -102,7 +102,16 @@ export function DrillRunner({ kind }: { kind: DrillKind }) { * * **The line gains a text link to /membership when that page exists** * (technology#52 item F), and the wording is the CMO's to set at that point. - * There is no paid kind registered today, so nothing renders this yet. + * + * **This renders in production.** `count-your-outs` registered `membersOnly` in + * v1.16.0 (25 Aug), so `playpip.io/game/drills/count-your-outs` serves this + * sentence to a signed-out visitor today: the kind is filtered off the drills + * index and is in no sitemap, but the URL answers. Nobody can buy anything yet, + * so the line points at something a reader cannot get, and that is a known and + * temporary state rather than an oversight (technology#75, the CMO found it by + * grepping the live chunk). This paragraph said the opposite until 27 Aug, and + * it is the first place anyone would look to answer "is this live", which is why + * it is worth more than a comment usually is. */ function WithTheMembership({ kind }: { kind: DrillKind }) { return ( diff --git a/src/lib/poker/ai/policy.ts b/src/lib/poker/ai/policy.ts index 1d9fb7e..a06db00 100644 --- a/src/lib/poker/ai/policy.ts +++ b/src/lib/poker/ai/policy.ts @@ -168,18 +168,39 @@ export function decideAction(state: HandState, profile: AiProfile, rng: Rng = Ma // `raiseThin` is the widening band a loose or aggressive seat also comes in // with. Postflop the field is small and equity means something again, so the // old numbers stand. + // Postflop, equity is the right yardstick again, but an equity number is not + // the same size against one opponent as against three, and every postflop gate + // below was written as an absolute against a heads-up pot. Measured on the + // shipped venue profiles, that is what makes the bots check the flop round: + // at the loose end of the ladder the AI leads an unbet pot 14% of the time + // heads-up, 8% against two and 4% against three, because 0.62 equity is a + // decent made hand heads-up and close to the nuts four-handed. A real player + // bets an unbet flop far more than that, and the loose tables (the ones a + // beginner meets first) are the ones that go multiway. + // + // So the gates are quoted as a multiple of a fair share of the pot, + // `1 / (opponents + 1)`, which is what "ahead of this field" actually means. + // **Heads-up every multiple below reproduces the old absolute exactly** + // (0.5 x 1.24 = 0.62, 0.5 x 1.56 = 0.78, 0.5 x 1.2 = 0.6, 0.5 x 0.8 = 0.4), + // so nothing changes at a table that plays heads-up pots, and `tests/ai.test.ts` + // pins that. Only the multiway spots move, which is where the defect was. + const fairShare = 1 / (opponents.length + 1) const misjudged = clamp(preStrength + misread, 0, 1) - const raiseValue = preflop ? misjudged >= 0.62 : equity > 0.78 - const raiseThin = preflop ? misjudged >= 0.55 : equity > 0.6 + const raiseValue = preflop ? misjudged >= 0.62 : equity > fairShare * 1.56 + const raiseThin = preflop ? misjudged >= 0.55 : equity > fairShare * 1.2 // --- unbet pot: check or lead out -------------------------------------- if (toCall === 0) { // Same story here: preflop this branch is the big blind with the pot limped // to it, and equity-vs-the-field says check with any holding at all. - const strongEnoughToLead = preflop ? misjudged >= 0.62 : equity > 0.62 + const strongEnoughToLead = preflop ? misjudged >= 0.62 : equity > fairShare * 1.24 const wantsValue = strongEnoughToLead && roll < 0.35 + profile.aggression * 0.55 + // The bluff ceiling scales with the field for the same reason, and it is the + // half that was quietly wrong in the other direction: four-handed, "under + // 0.4" is almost every holding, so the bot fired its full bluff frequency + // with hands that were good for the pot size and called it a bluff. const wantsBluff = - equity < 0.4 && !trashPreflop && roll < profile.bluff * (1 - posPressure * 0.5) + equity < fairShare * 0.8 && !trashPreflop && roll < profile.bluff * (1 - posPressure * 0.5) if ((wantsValue || wantsBluff) && (legal.canBet || legal.canRaise)) { const fraction = wantsValue ? 0.55 + profile.aggression * 0.25 : 0.5 return { diff --git a/tests/ai.test.ts b/tests/ai.test.ts index 602970f..1c7c873 100644 --- a/tests/ai.test.ts +++ b/tests/ai.test.ts @@ -484,3 +484,82 @@ test('a preflop open is a real raise, not a min-raise', (t) => { t.true(avg > s.bigBlind * 2, `average open ${avg.toFixed(1)} into a ${s.bigBlind} blind`) t.true(avg < s.bigBlind * 4.5, `average open ${avg.toFixed(1)} is too big`) }) + +// How often the AI bets a postflop pot that is checked to it, split by how many +// opponents are still live. Every preflop band above measures how *wide* the AI +// plays; this is the first one that measures what it does after the flop, and +// it exists because that half had never been measured at all. +function measureLeadByField( + profile: AiProfile, + hands = 80, +): Map { + const by = new Map() + for (let h = 0; h < hands; h++) { + const rng = mulberry32(h * 7 + 13) + let s = startHand({ seats: makeSeats(6), buttonIndex: h % 6, smallBlind: 5, bigBlind: 10, rng }) + let guard = 0 + while (!isHandComplete(s) && guard++ < 1000) { + const legal = legalActions(s) + const p = s.players[s.toActIndex] + const a = decideAction(s, profile, rng) + if (s.street !== 'preflop' && legal && legal.callAmount === 0 && p) { + const live = s.players.filter( + (q) => q.id !== p.id && q.status !== 'folded' && q.status !== 'out', + ).length + const cell = by.get(live) ?? { n: 0, led: 0 } + cell.n++ + if (a.type === 'bet' || a.type === 'raise') cell.led++ + by.set(live, cell) + } + s = applyAction(s, a) + } + } + return by +} + +test('the AI still bets multiway flops instead of checking the pot down', (t) => { + // The defect this pins: every postflop gate used to be an absolute equity + // number written for a heads-up pot (lead above 0.62, value-raise above 0.78), + // and an equity point is not the same size against three opponents as against + // one. Measured on this profile before the fix, the AI led an unbet pot 21% of + // the time heads-up and **7% against two or three**: it checked the flop round + // at exactly the loose tables a beginner meets first, which are the ones that + // go multiway. Quoting the gates as a multiple of a fair share of the pot puts + // the multiway rates back at 16% and 23%. + const loose: AiProfile = { tightness: 0.15, aggression: 0.35, bluff: 0.06, iterations: 120 } + const by = measureLeadByField(loose) + + const headsUp = by.get(1) + t.truthy(headsUp, 'no heads-up postflop decisions were sampled at all') + if (!headsUp) return + // A rate measured over a handful of spots is not a rate. Assert the sample + // before asserting the thing, or this test goes green on an empty measurement. + t.true(headsUp.n >= 100, `only ${headsUp.n} heads-up spots sampled`) + const headsUpRate = headsUp.led / headsUp.n + t.true(headsUpRate > 0.05, `heads-up lead rate ${(headsUpRate * 100).toFixed(0)}% is not poker`) + + const collapsed: string[] = [] + for (const [opponents, cell] of by) { + if (opponents < 2 || cell.n < 30) continue + const rate = cell.led / cell.n + if (rate < headsUpRate * 0.6) { + collapsed.push( + `${opponents} opponents: ${(rate * 100).toFixed(0)}% of unbet pots led (n=${cell.n}), against ${(headsUpRate * 100).toFixed(0)}% heads-up`, + ) + } + } + t.deepEqual(collapsed, [], 'betting collapses as the field grows') +}) + +test('the postflop gates are the old heads-up numbers, restated as a fair share', (t) => { + // The safety property behind the change above, and the reason it could ship + // without a playtest of all 29 tables: heads-up a fair share of the pot is + // exactly 0.5, so every multiple reproduces the absolute it replaced and no + // heads-up pot plays differently. Break one of these and you have moved every + // table on the ladder, not just the loose multiway ones. + const fairShareHeadsUp = 1 / (1 + 1) + t.is(fairShareHeadsUp * 1.24, 0.62, 'lead gate') + t.is(fairShareHeadsUp * 1.56, 0.78, 'value-raise gate') + t.is(fairShareHeadsUp * 1.2, 0.6, 'thin-raise gate') + t.is(fairShareHeadsUp * 0.8, 0.4, 'bluff ceiling') +})