Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/components/drills/DrillRunner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
29 changes: 25 additions & 4 deletions src/lib/poker/ai/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
79 changes: 79 additions & 0 deletions tests/ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, { n: number; led: number }> {
const by = new Map<number, { n: number; led: number }>()
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')
})