From 2c80bac66a6ae9da58bee5e67a6ada0f07ea57d7 Mon Sep 17 00:00:00 2001 From: "pip-cto[bot]" Date: Tue, 25 Aug 2026 15:31:57 +0000 Subject: [PATCH] feat(drills): pot odds, the membership's second kind #minor The same turn spot as "count your outs" with a price on it: they have bet, both hands are face up, one card is to come, and the only question is whether the pot is laying you enough. Graded by enumeration rather than by simulation. The spec had this kind grading against `estimateEquity`, which would have made the first equity-graded thing in the app the thing people pay for. One card to come against a hand you can see is 44 showdowns, so it does not have to be: the equity is counted and the price is a fraction, and nothing here can mark a correct call wrong. The `ambiguous` reject reason the contract has been describing since part A arrives with it; the two rules attached to it about seeding `estimateEquity` do not, because there is no rng in this kind. The price comes from `requiredEquity` in config/potOdds, the same function /learn/pot-odds prints its table from, so a member is taught and graded by one definition of a price. Half the spots are calls and half are folds, by construction: a spot is only accepted if some bet this pot could carry would make it a call and some other bet would make it a fold, and then a coin picks which. Priced at random instead, four accepted spots in five are folds, and the rating would be reading who had noticed that rather than who can count. `membersOnly` is on the kind in the commit that registers it, and a test asserts it by name: a paid kind that ships without the flag is free forever under rule #8 (technology#55). --- src/components/drills/DrillRunner.tsx | 52 +++- src/config/drills.ts | 11 + src/lib/drills/countYourOuts.ts | 58 +---- src/lib/drills/index.ts | 2 + src/lib/drills/potOdds.ts | 266 ++++++++++++++++++++ src/lib/drills/rating.ts | 61 ++++- src/lib/drills/standing.ts | 23 ++ src/lib/drills/turnSpot.ts | 64 +++++ src/lib/drills/types.ts | 70 +++++- tests/drills.test.ts | 9 +- tests/drillsOuts.test.ts | 2 + tests/drillsPotOdds.test.ts | 348 ++++++++++++++++++++++++++ 12 files changed, 895 insertions(+), 71 deletions(-) create mode 100644 src/lib/drills/potOdds.ts create mode 100644 src/lib/drills/turnSpot.ts create mode 100644 tests/drillsPotOdds.test.ts diff --git a/src/components/drills/DrillRunner.tsx b/src/components/drills/DrillRunner.tsx index 4ea598c..0da6e31 100644 --- a/src/components/drills/DrillRunner.tsx +++ b/src/components/drills/DrillRunner.tsx @@ -8,8 +8,9 @@ import { PageShell } from '@/components/PageShell' import { PlayingCard } from '@/components/PlayingCard' import { type DrillKind, canPlayDrill } from '@/config/drills' import { gradeDrill, nextDrill, randomSeed } from '@/lib/drills' -import type { Drill, DrillChoice, DrillHand } from '@/lib/drills/types' +import type { Drill, DrillChoice, DrillHand, DrillStakes } from '@/lib/drills/types' import { type Card, cardName } from '@/lib/poker/cards' +import { formatChips } from '@/lib/useMoney' import { haptics } from '@/lib/haptics' import { sound } from '@/lib/sound' import { useHydrated } from '@/lib/useHydrated' @@ -218,6 +219,14 @@ function Dealing({ kind }: { kind: DrillKind }) { ) } +/** + * Keys bound to the choice they name, for the kinds that have one: a and b are + * the two hands, s is the split, c and f are the call and the fold. A key whose + * choice this kind does not deal does nothing, which is why they are ids rather + * than positions — the digits already cover positions. + */ +const LETTERS: Record = { a: 'a', b: 'b', s: 'split', c: 'call', f: 'fold' } + /** * The run itself. Mounted only on the client, so the state initialiser below is * the first spot of this visit and not a spot from build time. @@ -292,11 +301,10 @@ function Run({ kind }: { kind: DrillKind }) { return } // A digit picks the nth choice, which is what the badge on the button - // says. The letters stay bound to the ids they have always meant on the - // free kind rather than to positions, so they do nothing on a kind that - // has no Hand A. + // says. The letters stay bound to choice ids rather than to positions, so + // each one does nothing at all on a kind that has no choice by that name. const digit = Number(key) - const byLetter = key === 'a' ? 'a' : key === 'b' ? 'b' : key === 's' ? 'split' : null + const byLetter = LETTERS[key] ?? null const choice = Number.isInteger(digit) && digit >= 1 && digit <= drill.choices.length ? drill.choices[digit - 1] @@ -331,6 +339,7 @@ function Run({ kind }: { kind: DrillKind }) { transition={{ duration: 0.25, ease: 'easeOut' }} >

{kind.question}

+ {drill.stakes && }
{drill.board.map((card) => ( @@ -367,7 +376,11 @@ function Run({ kind }: { kind: DrillKind }) { an answer set in their own right, and stacking four full-width bars would bury the cards they are about. */} {outcomes.length > 1 ? ( -
+ // Four counts want a row of four on a wide screen; two words want two + // buttons you can hit rather than two quarters of a row. +
2 && 'sm:grid-cols-4')} + > {outcomes.map((choice) => ( + {`Pot ${pot} chips, ${toCall} to call.`} + + Pot {pot} + {' · '} + {toCall} to call + +

+ ) +} + /** * A holding the spot shows and does not ask about. * @@ -500,7 +536,9 @@ function CountChoice({ onClick={onPick} disabled={revealed} aria-pressed={chosen} - aria-label={`${choice.label} cards`} + // "6" on its own is read out as a bare six, so a kind whose labels need a + // unit says what it is (`spoken`). "Call" needs nothing adding to it. + aria-label={choice.spoken ?? choice.label} className={cn( 'relative rounded-2xl border py-4 text-center text-lg font-semibold tabular-nums transition', revealed && choice.winning diff --git a/src/config/drills.ts b/src/config/drills.ts index e14977f..aa48376 100644 --- a/src/config/drills.ts +++ b/src/config/drills.ts @@ -89,6 +89,17 @@ export const DRILL_KINDS: DrillKind[] = [ boardCards: 4, membersOnly: true, }, + { + id: 'pot-odds', + title: 'Pot odds', + blurb: + 'They have bet the turn. Both hands are face up, one card is to come: is the price right?', + question: 'Call or fold?', + gradedBy: + 'Settled by dealing all 44 cards you cannot see and holding what gets there against what the pot is charging.', + boardCards: 4, + membersOnly: true, + }, ] /** diff --git a/src/lib/drills/countYourOuts.ts b/src/lib/drills/countYourOuts.ts index 5bb7b6b..76dfa00 100644 --- a/src/lib/drills/countYourOuts.ts +++ b/src/lib/drills/countYourOuts.ts @@ -1,7 +1,8 @@ -import { type Card, type Rng, mulberry32, shuffledDeck } from '@/lib/poker/cards' +import { type Card, type Rng, mulberry32 } from '@/lib/poker/cards' import { determineWinners, evaluateHand, handPhrase } from '@/lib/poker/handEval' import { type OutsShape, outsDifficulty } from './rating' -import { type DrillChoice, type DrillHand, type Generated, accept, reject } from './types' +import { HERO, UNSEEN, VILLAIN, dealTurn, faceUpHands } from './turnSpot' +import { type DrillChoice, type Generated, accept, reject } from './types' // The membership's first kind: two hands face up on the turn, one card to come, // how many of the cards left win it for you. @@ -23,28 +24,6 @@ import { type DrillChoice, type DrillHand, type Generated, accept, reject } from // it is free forever by rule #8, and the box the membership is priced from // empties itself on the way to being sold. -const HERO = 'hero' -const VILLAIN = 'villain' - -/** - * Cards nobody has seen: 52, less two hole cards each and the four on the turn. - * - * A constant rather than `rest.length` in the sentence, so that a change to the - * deal has to come past this line. The tests assert the deck arithmetic holds. - */ -const UNSEEN = 44 - -/** Two hands and a turn board, dealt from one seeded deck. */ -function deal(seed: number): { hero: Card[]; villain: Card[]; board: Card[]; rest: Card[] } { - const deck = shuffledDeck(mulberry32(seed)) - return { - hero: deck.slice(0, 2), - villain: deck.slice(2, 4), - board: deck.slice(4, 8), - rest: deck.slice(8), - } -} - /** What one river card does for the hero. */ type RiverOutcome = 'wins' | 'chops' | 'loses' @@ -179,7 +158,7 @@ function pick(pool: number[], count: number, rng: Rng): number[] { * that trips it is not worth asking. */ export function generateCountYourOuts(seed: number): Generated { - const { hero, villain, board, rest } = deal(seed) + const { hero, villain, board, rest } = dealTurn(seed) const contenders = [ { id: HERO, hole: hero }, { id: VILLAIN, hole: villain }, @@ -209,6 +188,10 @@ export function generateCountYourOuts(seed: number): Generated { label: String(n), cards: [], winning: n === count, + // The unit belongs to the kind that asks for it. A bare "6" on a button is + // read out as a bare six, and the question above the board is not read out + // again with it. + spoken: `${n} cards`, })) return accept({ @@ -216,7 +199,7 @@ export function generateCountYourOuts(seed: number): Generated { seed, board, choices, - hands: showHands(hero, villain, board), + hands: faceUpHands(hero, villain, board), answer: String(count), settledBy: shape, difficulty: outsDifficulty(shape, trap), @@ -224,29 +207,6 @@ export function generateCountYourOuts(seed: number): Generated { }) } -/** - * Both holdings, with what each one is right now. - * - * The villain's made hand is named from the start rather than at the reveal, - * and that is the drill: you cannot count what beats you without being told - * what you are up against. Hiding it would make this a guessing game about the - * opponent rather than an exercise in counting. - */ -function showHands(hero: Card[], villain: Card[], board: Card[]): DrillHand[] { - const name = (hole: Card[]) => { - const phrase = handPhrase(evaluateHand(hole, board)) - return phrase ? capitalise(phrase) : undefined - } - return [ - { label: 'You', cards: hero, ...withDetail(name(hero)) }, - { label: 'They have', cards: villain, ...withDetail(name(villain)) }, - ] -} - -const withDetail = (detail?: string) => (detail ? { detail } : {}) - -const capitalise = (phrase: string): string => phrase[0].toUpperCase() + phrase.slice(1) - /** * The one sentence, out of the same enumeration that set the answer. * diff --git a/src/lib/drills/index.ts b/src/lib/drills/index.ts index 4926d06..ef7c764 100644 --- a/src/lib/drills/index.ts +++ b/src/lib/drills/index.ts @@ -1,4 +1,5 @@ import { generateCountYourOuts } from './countYourOuts' +import { generatePotOdds } from './potOdds' import type { Drill, DrillKindId, Generated, Grade } from './types' import { generateWhichHandWins } from './whichHandWins' @@ -16,6 +17,7 @@ export * from './rating' const GENERATORS: Record Generated> = { 'which-hand-wins': generateWhichHandWins, 'count-your-outs': generateCountYourOuts, + 'pot-odds': generatePotOdds, } /** diff --git a/src/lib/drills/potOdds.ts b/src/lib/drills/potOdds.ts new file mode 100644 index 0000000..1ac62a1 --- /dev/null +++ b/src/lib/drills/potOdds.ts @@ -0,0 +1,266 @@ +import { BET_SIZES, pct, requiredEquity } from '@/config/potOdds' +import { type Card, mulberry32 } from '@/lib/poker/cards' +import { determineWinners } from '@/lib/poker/handEval' +import { formatChips } from '@/lib/useMoney' +import { type PriceShape, priceDifficulty } from './rating' +import { HERO, UNSEEN, VILLAIN, dealTurn, faceUpHands } from './turnSpot' +import { type DrillChoice, type Generated, accept, reject } from './types' + +// The membership's second kind: the same turn spot as counting outs with a +// price on it. They have bet, both hands are face up, one card is to come, and +// the only question is whether the pot is laying you enough. +// +// **Exact, like both kinds before it.** The spec had this graded by +// `estimateEquity` (technology#55), and the first equity-graded thing in the app +// should not be the thing people pay for. It does not have to be: one card to +// come against a hand you can see is 44 showdowns, so the equity here is +// counted rather than sampled. Same answer as a million simulations would +// eventually give, no tolerance, and nothing that can mark a correct call +// wrong. The price is a fraction and was always exact. +// +// **The two numbers come from one place each.** What the hand gets there is the +// enumeration below; what the pot is charging is `requiredEquity` from +// config/potOdds, which is the same function /learn/pot-odds prints its table +// from. A player who reads the guide and then plays the drill is being taught +// and graded by one definition of a price, and the guide's tests pin it. +// +// **`membersOnly` lives in config/drills.ts and had to be there in the commit +// that registered this kind** (technology#55). A paid kind that ships without +// it is free forever by rule #8. + +/** + * How far apart the two numbers must be, in points, for the spot to be asked at + * all. + * + * Under this the two answers are worth the same to within a rounding, and a + * player who folds a call that was right by three points has not misread + * anything. The spec's number, and it is a judgement that wants play-testing + * rather than theory: the equity is exact, so this is not a tolerance on a + * noisy estimate, it is a statement about which questions are fair. + */ +const MARGIN = 4 + +/** + * And how far apart they may be before the spot stops being a question. + * + * A call that is right by thirty points is right whether or not you counted + * anything, so it teaches nothing and rates nobody. Same instinct as the + * ranking kind's `one-sided`: a spot you can answer by looking is a look. + */ +const MAX_GAP = 20 + +/** + * The pots a spot can be played for. Multiples of 60, so that every bet size + * below lands on a whole chip and the price the player is shown is exactly the + * price they are graded against. + */ +const POTS = [120, 180, 240, 360, 480, 600, 900, 1200, 1800] as const + +/** + * The bets a spot can be facing, as a fraction of the pot before it. + * + * The seven from `config/potOdds` — the sizes /learn/bet-sizing teaches — plus + * two small stabs and one between half and two-thirds. **The extra three are + * not a second opinion about bet sizing**, which is why the prices they set are + * still `requiredEquity`'s and not this file's: they exist because the guide's + * ladder starts at a quarter pot, and a hand with eight outs cannot be priced + * as a call by any bet on it. Without them this kind would only ever deal the + * big draws, and the eight-out straight draw is the spot the whole lesson is + * usually taught with. + */ +const FRACTIONS = [1 / 6, 1 / 5, 2 / 5, ...BET_SIZES.map((size) => size.fraction)].sort( + (a, b) => a - b, +) + +/** One card to come, and what it does for the hero. */ +interface Enumerated { + /** Rivers the hero wins outright. */ + wins: number + /** Rivers that split the pot. Any at all and the spot is thrown away. */ + chops: number +} + +/** + * Deal every card that is left, one at a time, and read the showdown. + * + * One pass, and both the grade and the sentence read it. Identical in shape to + * the counting kind's enumeration and deliberately not shared with it: that one + * also reads what each winning card *makes*, because its sentence names the + * draws, and this one has no use for the phrases. Sharing would mean doing the + * expensive half of that work on every spot here to throw it away. + */ +function enumerateRivers(hero: Card[], villain: Card[], board: Card[], rest: Card[]): Enumerated { + const contenders = [ + { id: HERO, hole: hero }, + { id: VILLAIN, hole: villain }, + ] + const enumerated: Enumerated = { wins: 0, chops: 0 } + + for (const card of rest) { + const { winners } = determineWinners(contenders, [...board, card]) + if (winners.length > 1) enumerated.chops++ + else if (winners[0] === HERO) enumerated.wins++ + } + + return enumerated +} + +/** A price this pot could be charging, and what it would take to call it. */ +interface Price { + /** + * The bet, in chips. A whole number of them, and every pot above is a + * multiple of 60 so that rounding it never has anything to do: a bet of + * 199.99999999 chips would be shown as 200 and graded as neither. + */ + toCall: number + /** The pot as the table shows it: what was there, plus their bet. */ + pot: number + /** The share of the pot you have to win for calling to break even. */ + required: number +} + +const pricesFor = (potBefore: number): Price[] => + FRACTIONS.map((fraction) => { + const toCall = Math.round(potBefore * fraction) + return { + toCall, + pot: potBefore + toCall, + // From the guide's own function rather than from the two numbers above. + // `toCall / (pot + toCall)` is the same fraction and a test holds the two + // together to the chip; taking it from there means the drill and + // /learn/pot-odds cannot come to different answers about the same bet. + required: requiredEquity(fraction), + } + }) + +/** + * Generate the spot at `seed`, or say why it was thrown away. + * + * **A spot survives only if the price could have made it either answer**, and + * that is the load-bearing rule in this file. The cards come out of the shuffle + * and are never chosen; the size of the bet is, which in a real hand is chosen + * by the opponent anyway. So the filter keeps the hands that some bet this pot + * could carry would make a call and some other bet would make a fold, and then + * a coin decides which of the two this spot is. + * + * Two things fall out of that, and both are the point: + * + * 1. **The answers are half calls and half folds**, so answering "fold" to + * everything scores what a coin scores. Priced at random instead, four + * accepted spots in five are folds — honest about the population of poker + * hands, and useless as a drill, because the rating would be reading who had + * noticed the habit rather than who can count. + * 2. **Every spot is one the price decides.** A hand with three outs is a fold + * against any bet anybody would make, so it is not asked here. That is the + * same ruling as the ranking kind's `one-sided`: a spot you can answer + * without reading it is a look, not a question. + */ +export function generatePotOdds(seed: number): Generated { + const { hero, villain, board, rest } = dealTurn(seed) + + // You are the one drawing, on every spot this kind deals. A hand that is + // already in front is not a price to read: it wins unless it is caught, so no + // bet anybody would make could turn it into a fold, and it would be thrown + // away below anyway. Rejecting it here rather than there is worth the line — + // it is half of all deals, and it costs one showdown to see instead of 44. + const turn = determineWinners( + [ + { id: HERO, hole: hero }, + { id: VILLAIN, hole: villain }, + ], + board, + ) + if (turn.winners.includes(HERO)) return reject('already-ahead') + + const { wins, chops } = enumerateRivers(hero, villain, board, rest) + // A river that splits the pot is half an out, and whether half an out is an + // out is a real disagreement between reasonable players. Same ruling as the + // counting kind: the spot goes in the bin rather than the player being marked + // wrong for the other reading. + if (chops > 0) return reject('chop-possible') + // Nothing wins it, so there is nothing to price. True and useful about a hand + // and a bad question: "fold" is right without reading anything. + if (wins === 0) return reject('drawing-dead') + + const equity = wins / UNSEEN + + // A second stream, seeded from the spot's own seed, so the pot and the bet + // are as reproducible as the cards and still independent of the shuffle that + // dealt them. + const rng = mulberry32((seed ^ 0x5f37_59df) >>> 0) + const potBefore = POTS[Math.floor(rng() * POTS.length)] + + const gapOf = (price: Price) => Math.abs(equity - price.required) * 100 + const prices = pricesFor(potBefore) + const askable = prices.filter((price) => { + const gap = gapOf(price) + return gap >= MARGIN && gap <= MAX_GAP + }) + const calls = askable.filter((price) => equity > price.required) + const folds = askable.filter((price) => price.required > equity) + + if (calls.length === 0 || folds.length === 0) { + // The hand cannot be asked both ways at this pot. Either the bets that + // would ask the missing half are too close to the hand to be fair, or there + // are none: nothing anybody would bet turns this hand into that answer. + const missing = + calls.length === 0 + ? prices.filter((price) => equity > price.required) + : prices.filter((price) => price.required > equity) + return reject(missing.some((price) => gapOf(price) < MARGIN) ? 'ambiguous' : 'one-sided') + } + + const wantsCall = rng() < 0.5 + const side = wantsCall ? calls : folds + const price = side[Math.floor(rng() * side.length)] + const answer = wantsCall ? 'call' : 'fold' + const choices: DrillChoice[] = [ + { id: 'call', label: 'Call', cards: [], winning: answer === 'call' }, + { id: 'fold', label: 'Fold', cards: [], winning: answer === 'fold' }, + ] + + return accept({ + kind: 'pot-odds', + seed, + board, + choices, + hands: faceUpHands(hero, villain, board), + stakes: { pot: price.pot, toCall: price.toCall }, + answer, + settledBy: shapeOf(gapOf(price)), + difficulty: priceDifficulty(shapeOf(gapOf(price))), + explanation: explain(wins, equity, price), + }) +} + +/** How much room there was between the two numbers. See {@link PriceShape}. */ +function shapeOf(gap: number): PriceShape { + return gap < 7 ? 'thin-price' : gap < 11 ? 'close-price' : 'clear-price' +} + +/** + * The one sentence, out of the same enumeration and the same fraction that set + * the answer. + * + * Three numbers in the order they are useful: what gets there, what that is as + * a percentage, and what the pot is charging. Then the comparison, said as a + * comparison. It never says "you should have" and it never says "wrong": the + * player is told the two numbers and can see for themselves which is bigger, + * which is Build 2's register and the reason drills grade with arithmetic + * instead of an opinion. + * + * No "about" anywhere, unlike the coach's version of this sentence. That one + * says "about" because it is reading fifteen hundred simulations; this one + * counted all 44 cards, and hedging an exact number would be the first + * dishonest thing in the feature in the other direction. + */ +function explain(wins: number, equity: number, price: Price): string { + const head = + wins === 1 + ? `One of the ${UNSEEN} cards left wins it for you` + : `${wins} of the ${UNSEEN} cards left win it for you` + const call = `calling ${formatChips(price.toCall)} to win ${formatChips(price.pot)} needs ${pct(price.required)}%` + const verdict = + equity > price.required ? 'Enough, so it is a call.' : 'Not enough, so it is a fold.' + return `${head}, which is ${pct(equity)}%, and ${call}. ${verdict}` +} diff --git a/src/lib/drills/rating.ts b/src/lib/drills/rating.ts index 9e5992b..2202ec3 100644 --- a/src/lib/drills/rating.ts +++ b/src/lib/drills/rating.ts @@ -72,6 +72,30 @@ export type SettledBy = 'category' | 'rank' | 'kicker' | 'split' */ export type OutsShape = 'one-draw' | 'two-draws' | 'many-draws' +/** + * How a "pot odds" spot is shaped, easiest first: how far apart what the hand + * gets there and what the pot is charging turned out to be. Read off the same + * enumeration and the same fraction that set the answer, so, as above, the + * difficulty cannot disagree with the grade. + * + * What makes a price hard is not the size of the bet, it is how little room + * there is between the two numbers. A quarter-pot bet with a flush draw is a + * call you can make without arithmetic; the same draw against a pot-sized bet + * is four points from being a fold and you have to actually count. + * + * - `clear-price`: 11 points or more between them. + * - `close-price`: 7 to 11 points. Counting badly gets it wrong. + * - `thin-price`: under 7, and never under the margin at which the spot is + * thrown away instead (see `RejectReason` in ./types). + * + * **The two boundaries were measured before they were chosen.** Over 6,000 + * seeds the gap has a median of 8.3 points and never exceeds 20, so 7 and 11 + * cut the spots roughly 27 / 43 / 30. Boundaries picked as round numbers + * instead put four spots in five in one band, and a shape that nearly every + * spot has is not a difficulty, it is a constant. + */ +export type PriceShape = 'clear-price' | 'close-price' | 'thin-price' + /** * Any spot's shape, whichever kind dealt it. * @@ -80,7 +104,7 @@ export type OutsShape = 'one-draw' | 'two-draws' | 'many-draws' * only that a spot has a shape and a number, never which kind's vocabulary the * shape is drawn from. */ -export type SpotKind = SettledBy | OutsShape +export type SpotKind = SettledBy | OutsShape | PriceShape /** * What each shape is rated. @@ -155,6 +179,36 @@ export const EASIEST_OUTS = OUTS_BASE['one-draw'] /** The most an outs spot can be rated. */ export const HARDEST_OUTS = OUTS_BASE['many-draws'] + TRAP +/** + * What each shape of a pricing spot is rated. + * + * Same judgement, same caveat: the ordering is the defensible part and the + * numbers are not measured, because nobody has answered one of these either. + * + * Pitched above both other kinds on purpose, and the reason is arithmetic + * rather than taste. Counting the outs is the *first* half of one of these + * spots: you then have to turn the count into a percentage and hold it against + * a fraction of a pot. A player who can do the counting kind perfectly still + * has a step left here, so the floor sits above that kind's floor. + * + * **No adjustment on top, and that is deliberate.** The other two kinds each + * carry one (a decoy, a trap) because there was something computable about the + * cards that made a spot harder than its shape. Here the gap between the two + * numbers *is* that thing, and it is already the shape. A second adjustment + * would be asserting something about the spot rather than reading it. + */ +const PRICE_BASE: Record = { + 'clear-price': 1060, + 'close-price': 1280, + 'thin-price': 1460, +} + +/** The least a pricing spot can be rated. Both numbers a long way apart. */ +export const EASIEST_PRICE = PRICE_BASE['clear-price'] + +/** The most a pricing spot can be rated. */ +export const HARDEST_PRICE = PRICE_BASE['thin-price'] + /** * What this spot is worth. Splits take no decoy adjustment: with two winners * there is no losing hand to be misled by. @@ -168,6 +222,11 @@ export function outsDifficulty(shape: OutsShape, trap: boolean): number { return OUTS_BASE[shape] + (trap ? TRAP : 0) } +/** What a pricing spot is worth. Its shape, and nothing else — see {@link PRICE_BASE}. */ +export function priceDifficulty(shape: PriceShape): number { + return PRICE_BASE[shape] +} + /** Elo's expectation: the share of spots at this difficulty you should get right. */ export function expectedScore(player: number, difficulty: number): number { return 1 / (1 + 10 ** ((difficulty - player) / 400)) diff --git a/src/lib/drills/standing.ts b/src/lib/drills/standing.ts index 4295209..edf0579 100644 --- a/src/lib/drills/standing.ts +++ b/src/lib/drills/standing.ts @@ -1,9 +1,11 @@ import type { DrillKindId } from './types' import { type OutsShape, + type PriceShape, type SettledBy, type SpotKind, outsDifficulty, + priceDifficulty, spotDifficulty, } from './rating' @@ -94,6 +96,26 @@ const COUNT_YOUR_OUTS: SpotShape[] = [ outsShape('many-draws', 'spots with three or more draws at once'), ] +const priceShape = (settledBy: PriceShape, label: string): SpotShape => ({ + settledBy, + label, + rating: priceDifficulty(settledBy), +}) + +/** + * The shapes "pot odds" deals, easiest first. + * + * The ladder here is how much room there was between what the hand gets there + * and what the pot was charging, for the reason set out on {@link PriceShape}: + * a call that is right by twenty points is right whether or not you counted, + * and one that is right by five is only right if you did. + */ +const POT_ODDS: SpotShape[] = [ + priceShape('clear-price', 'clear prices'), + priceShape('close-price', 'close prices'), + priceShape('thin-price', 'the closest prices'), +] + /** * Every kind's ladder, or an explicit `null` for a kind that has none. * @@ -106,6 +128,7 @@ const COUNT_YOUR_OUTS: SpotShape[] = [ const LADDERS: Record = { 'which-hand-wins': WHICH_HAND_WINS, 'count-your-outs': COUNT_YOUR_OUTS, + 'pot-odds': POT_ODDS, } /** The shapes this kind deals, easiest first, or null if it has no ladder. */ diff --git a/src/lib/drills/turnSpot.ts b/src/lib/drills/turnSpot.ts new file mode 100644 index 0000000..db29c1f --- /dev/null +++ b/src/lib/drills/turnSpot.ts @@ -0,0 +1,64 @@ +import { type Card, mulberry32, shuffledDeck } from '@/lib/poker/cards' +import { evaluateHand, handPhrase } from '@/lib/poker/handEval' +import type { DrillHand } from './types' + +// What the two turn kinds share: one deal, and one way of putting both +// holdings on the screen. +// +// Extracted when the second kind wanted them rather than in advance. The point +// is not the saved lines, it is that "count your outs" and "pot odds" deal the +// same spot and differ only in what they ask about it, so a change to the deal +// has to be a change to both or to neither. Two copies of this would drift the +// day one of them started dealing the flop. + +/** The ids the two contenders are known by inside a turn spot. */ +export const HERO = 'hero' +export const VILLAIN = 'villain' + +/** + * Cards nobody has seen: 52, less two hole cards each and the four on the turn. + * + * A constant rather than `rest.length` in the sentence a player reads, so that + * a change to the deal has to come past this line. The tests assert the deck + * arithmetic holds. + */ +export const UNSEEN = 44 + +/** Two hands and a turn board, dealt from one seeded deck. */ +export function dealTurn(seed: number): { + hero: Card[] + villain: Card[] + board: Card[] + rest: Card[] +} { + const deck = shuffledDeck(mulberry32(seed)) + return { + hero: deck.slice(0, 2), + villain: deck.slice(2, 4), + board: deck.slice(4, 8), + rest: deck.slice(8), + } +} + +/** + * Both holdings, with what each one is right now. + * + * The villain's made hand is named from the start rather than at the reveal, + * and that is the drill on both kinds: you cannot count what beats you, or + * price a call against it, without being told what you are up against. Hiding + * it would make either kind a guessing game about the opponent. + */ +export function faceUpHands(hero: Card[], villain: Card[], board: Card[]): DrillHand[] { + const name = (hole: Card[]) => { + const phrase = handPhrase(evaluateHand(hole, board)) + return phrase ? capitalise(phrase) : undefined + } + return [ + { label: 'You', cards: hero, ...withDetail(name(hero)) }, + { label: 'They have', cards: villain, ...withDetail(name(villain)) }, + ] +} + +const withDetail = (detail?: string) => (detail ? { detail } : {}) + +const capitalise = (phrase: string): string => phrase[0].toUpperCase() + phrase.slice(1) diff --git a/src/lib/drills/types.ts b/src/lib/drills/types.ts index 4dd2633..5d63efd 100644 --- a/src/lib/drills/types.ts +++ b/src/lib/drills/types.ts @@ -26,8 +26,18 @@ import type { SpotKind } from './rating' * back). It is graded by dealing all 44 remaining cards one at a time and * reading the showdown, so it is exact in the same way the free kind is: no * simulation, nothing to sample, nothing that can mark a correct count wrong. + * + * **`pot-odds` comes with the membership too**, and carries `membersOnly` from + * the commit that registered it for the same reason. It is the same turn spot + * as counting outs with a price on it, and it is exact for the same reason: the + * cards that win it are counted rather than simulated, and the price the pot is + * laying is a fraction. **The spec had this kind graded by `estimateEquity`** + * (technology#55, part B), which would have made the first equity-graded thing + * in the app the thing people pay for. One card to come against a hand you can + * see is 44 showdowns, so it is enumerable, and enumerating it is strictly + * better than sampling it: same answer, no tolerance, nothing to drift. */ -export type DrillKindId = 'which-hand-wins' | 'count-your-outs' +export type DrillKindId = 'which-hand-wins' | 'count-your-outs' | 'pot-odds' /** One of the answers on offer. */ export interface DrillChoice { @@ -43,6 +53,12 @@ export interface DrillChoice { * correct button, and the runner should show all three as right. */ winning: boolean + /** + * What a screen reader says in place of the label, where the label on its own + * does not stand up as a sentence: "6" is read as a bare six, and "6 cards" + * is the button. Absent where the label already says what it means ("Call"). + */ + spoken?: string /** The made hand in words once the answer is out, e.g. "Two pair". */ detail?: string /** The five cards this hand actually plays, shown with the answer. */ @@ -67,6 +83,21 @@ export interface DrillHand { detail?: string } +/** + * The money on the table, for a kind whose question is about a price rather + * than only about cards. + * + * Both numbers are chips as the table shows them: `pot` is what is in the + * middle *including* the bet being faced, and `toCall` is what it costs to see + * the next card. So the price the pot is laying is `toCall / (pot + toCall)`, + * which is the same reading `lib/coach.ts` takes of a real hand — one + * definition of a price in the app, not two. + */ +export interface DrillStakes { + pot: number + toCall: number +} + /** A generated spot: everything the runner draws and the grader needs. */ export interface Drill { kind: DrillKindId @@ -85,6 +116,11 @@ export interface Drill { * Absent for a kind whose choices are the hands (see {@link DrillHand}). */ hands?: DrillHand[] + /** + * The pot and the price, for a kind that asks about one. Absent everywhere + * else, and the runner draws nothing rather than drawing a zero. + */ + stakes?: DrillStakes /** The id of the correct choice. */ answer: string /** @@ -121,8 +157,11 @@ export interface Grade { * Why a generated spot was thrown away instead of shown. Generation is a * filtered stream, not a raw one, and this is the filter's vocabulary. * - * - `one-sided`: the two hands are more than one category apart, so the spot - * is a look rather than a question. + * - `one-sided`: the spot is a look rather than a question. On the ranking kind + * that means the two hands are more than one category apart. On the pricing + * kind it means no bet this pot could carry would make the answer the one the + * spot set out to ask for, which is the same defect wearing the other kind's + * clothes: nothing on the screen is deciding anything. * - `unexplainable`: the winner cannot be explained from the same evaluation * that graded it. Silence over noise, at generation time. * @@ -137,14 +176,24 @@ export interface Grade { * bin rather than the player being marked wrong for the other reading. * - `drawing-dead`: nothing wins it. A true and useful fact about a hand, and a * bad multiple-choice question: it makes "the lowest number" a free guess. + * The pricing kind rejects it too, where it means there is no draw left to + * put a price on. + * + * The last one belongs to "pot odds": + * + * - `ambiguous`: what the hand gets there and what the pot is charging sit + * inside {@link https://github.com/playpip/technology/issues/55 the margin}, + * 4 points, where calling and folding are worth the same to within a + * rounding. A player who reads it the other way is not wrong, so the spot is + * not asked. The number is a judgement and wants play-testing, not theory. * - * **An equity-graded kind adds `ambiguous` here**, and rejects any spot where - * required and actual equity sit inside the margin (4 points is a guess and - * wants play-testing, not theory). Two rules come with it, and they are why - * this vocabulary exists before there is a kind that needs it: the rng handed - * to `estimateEquity` is `mulberry32(drill.seed)`, so the grade and the - * sentence under it cannot drift; and iterations go **up** there rather than - * down, because generation happens once per spot and not once per render. + * **This vocabulary was written before there was a kind that needed the last + * one**, with two rules attached: that the rng handed to `estimateEquity` be + * `mulberry32(drill.seed)`, and that iterations go up rather than down. Neither + * applies, because the kind that arrived grades by enumeration instead — 44 + * showdowns, no rng at all. The margin survived the change and the rest did + * not, which is the right way round: it is about the question being fair, not + * about the estimate being steady. */ export type RejectReason = | 'one-sided' @@ -152,6 +201,7 @@ export type RejectReason = | 'already-ahead' | 'chop-possible' | 'drawing-dead' + | 'ambiguous' /** The result of generating at one seed: a spot, or the reason there isn't one. */ export interface Generated { diff --git a/tests/drills.test.ts b/tests/drills.test.ts index 4414463..b9f7021 100644 --- a/tests/drills.test.ts +++ b/tests/drills.test.ts @@ -181,6 +181,7 @@ test('the filter throws away one-sided spots, and only those', (t) => { 'already-ahead': 0, 'chop-possible': 0, 'drawing-dead': 0, + ambiguous: 0, } let kept = 0 for (let seed = 1; seed <= 5_000; seed++) { @@ -190,10 +191,10 @@ test('the filter throws away one-sided spots, and only those', (t) => { } t.true(counts['one-sided'] > 100, `the filter is not biting: ${counts['one-sided']} rejected`) t.true(kept > counts['one-sided'], 'more spots are thrown away than kept') - // The three reasons that belong to "count your outs". This kind has no turn, - // no chop rule and no draw to be dead on, so seeing one here would mean the - // generators had got crossed. - for (const reason of ['already-ahead', 'chop-possible', 'drawing-dead'] as const) { + // The reasons that belong to the two turn kinds. This kind has no turn, no + // chop rule, no draw to be dead on and no price to be close to, so seeing one + // here would mean the generators had got crossed. + for (const reason of ['already-ahead', 'chop-possible', 'drawing-dead', 'ambiguous'] as const) { t.is(counts[reason], 0, `${reason} is not this kind's vocabulary`) } // Not a tuning knob. This fires when the sentence and the grade came from diff --git a/tests/drillsOuts.test.ts b/tests/drillsOuts.test.ts index 332fc21..65c095e 100644 --- a/tests/drillsOuts.test.ts +++ b/tests/drillsOuts.test.ts @@ -163,6 +163,7 @@ test('the filter rejects for the reasons this kind has, and no others', (t) => { 'already-ahead': 0, 'chop-possible': 0, 'drawing-dead': 0, + ambiguous: 0, } let kept = 0 for (let seed = 1; seed <= 4_000; seed++) { @@ -179,6 +180,7 @@ test('the filter rejects for the reasons this kind has, and no others', (t) => { // knob: it fires when a winning river makes a hand we cannot name, which // would mean the sentence and the count came from different readings. t.is(counts['one-sided'], 0, "one-sided is not this kind's vocabulary") + t.is(counts.ambiguous, 0, 'the count is exact, so no spot here is ever too close to ask') t.is(counts.unexplainable, 0, 'a spot could not be explained from its own enumeration') }) diff --git a/tests/drillsPotOdds.test.ts b/tests/drillsPotOdds.test.ts new file mode 100644 index 0000000..ea8d4fa --- /dev/null +++ b/tests/drillsPotOdds.test.ts @@ -0,0 +1,348 @@ +import test from 'ava' +import { DRILL_KINDS, canPlayDrill, drillKind } from '@/config/drills' +import { requiredEquity } from '@/config/potOdds' +import { + EASIEST_PRICE, + HARDEST_PRICE, + MAX_ATTEMPTS, + type PriceShape, + drillAt, + gradeDrill, + nextDrill, + priceDifficulty, +} from '@/lib/drills' +import { spotLadder, standingLine } from '@/lib/drills/standing' +import type { Drill, RejectReason } from '@/lib/drills/types' +import { createDeck, cardToString } from '@/lib/poker/cards' +import { determineWinners } from '@/lib/poker/handEval' + +// "Pot odds" is the second kind that comes with the membership, and the first +// one whose question is about money. So the tests are the same two halves as +// the counting kind's — the answer is right, and the gate is real — plus a +// third that is new here: the two numbers the grade compares have to be the +// numbers the player was shown. A drill that grades a call against a price it +// did not print is worse than one that grades it wrong, because nothing on the +// screen would say so. +// +// Everything is exact. The equity is 44 showdowns and the price is a fraction, +// so there is no sampling anywhere in this kind and a flaky test here is a +// wrong test. + +const KIND = 'pot-odds' + +/** Every accepted spot in a range of seeds. */ +function accepted(from: number, count: number): Drill[] { + const drills: Drill[] = [] + for (let seed = from; seed < from + count; seed++) { + const { drill } = drillAt(KIND, seed) + if (drill) drills.push(drill) + } + return drills +} + +const hand = (drill: Drill, label: string) => + drill.hands?.find((h) => h.label === label)?.cards ?? [] + +const hero = (drill: Drill) => hand(drill, 'You') +const villain = (drill: Drill) => hand(drill, 'They have') + +/** + * What the hand is worth, worked out again from the cards the spot dealt. + * + * Deliberately not the generator's own enumeration: it rebuilds the deck, takes + * out the eight cards on the screen, deals each of the rest and reads the + * showdown. If this ever disagrees with the drill, somebody paying for this is + * being marked wrong for being right. + */ +function equityOf(drill: Drill): { wins: number; chops: number; equity: number } { + const seen = new Set([...drill.board, ...hero(drill), ...villain(drill)].map(cardToString)) + const rest = createDeck().filter((card) => !seen.has(cardToString(card))) + const contenders = [ + { id: 'hero', hole: hero(drill) }, + { id: 'villain', hole: villain(drill) }, + ] + let wins = 0 + let chops = 0 + for (const card of rest) { + const { winners } = determineWinners(contenders, [...drill.board, card]) + if (winners.length > 1) chops++ + else if (winners[0] === 'hero') wins++ + } + return { wins, chops, equity: wins / rest.length } +} + +/** The price the pot laid, read off the two numbers the player was shown. */ +const priceOf = (drill: Drill): number => { + const { pot, toCall } = drill.stakes ?? { pot: 0, toCall: 0 } + return toCall / (pot + toCall) +} + +test('the same seed is the same spot, forever', (t) => { + for (const seed of [1, 13, 1_000, 4_294_967_295]) { + t.deepEqual(drillAt(KIND, seed), drillAt(KIND, seed), `seed ${seed}`) + } +}) + +// The pinned spots: both answers, all three shapes, and the two halves of the +// sentence. If the arithmetic or the wording moves, it says so here rather than +// on a paying player's screen. +const PINNED: { + seed: number + answer: string + settledBy: PriceShape + stakes: { pot: number; toCall: number } + explanation: string +}[] = [ + { + seed: 7, + answer: 'fold', + settledBy: 'close-price', + stakes: { pot: 200, toCall: 80 }, + explanation: + '9 of the 44 cards left win it for you, which is 20.5%, and calling 80 to win 200 needs 28.6%. Not enough, so it is a fold.', + }, + { + seed: 13, + answer: 'call', + settledBy: 'clear-price', + stakes: { pot: 700, toCall: 100 }, + explanation: + '11 of the 44 cards left win it for you, which is 25%, and calling 100 to win 700 needs 12.5%. Enough, so it is a call.', + }, + { + seed: 28, + answer: 'call', + settledBy: 'thin-price', + stakes: { pot: 504, toCall: 144 }, + explanation: + '12 of the 44 cards left win it for you, which is 27.3%, and calling 144 to win 504 needs 22.2%. Enough, so it is a call.', + }, + { + seed: 375, + answer: 'fold', + settledBy: 'thin-price', + stakes: { pot: 3_600, toCall: 2_400 }, + explanation: + '15 of the 44 cards left win it for you, which is 34.1%, and calling 2,400 to win 3,600 needs 40%. Not enough, so it is a fold.', + }, +] + +test('fixed seeds price and grade the same way every run', (t) => { + for (const pin of PINNED) { + const { drill } = drillAt(KIND, pin.seed) + if (!drill) { + t.fail(`seed ${pin.seed} no longer generates a spot`) + continue + } + t.is(drill.answer, pin.answer, `seed ${pin.seed}: answer`) + t.is(drill.settledBy, pin.settledBy, `seed ${pin.seed}: shape`) + t.deepEqual(drill.stakes, pin.stakes, `seed ${pin.seed}: stakes`) + t.is(drill.explanation, pin.explanation, `seed ${pin.seed}: explanation`) + t.true(gradeDrill(drill, pin.answer).correct, `seed ${pin.seed}: grade`) + } +}) + +// The one that matters most, and the reason this kind is enumerated rather than +// simulated: the answer is re-derived from the cards on the screen and the +// numbers under them, by code that shares nothing with the generator. +test('every answer agrees with the cards and the price it was shown at', (t) => { + const drills = accepted(1, 2_000) + t.true(drills.length > 150, `sample too small to mean anything: ${drills.length}`) + for (const drill of drills) { + const { wins, chops, equity } = equityOf(drill) + t.is(chops, 0, `seed ${drill.seed}: a spot that can chop was asked anyway`) + t.is(drill.answer, equity > priceOf(drill) ? 'call' : 'fold', `seed ${drill.seed}`) + // The count in the sentence is the count that settled it, not a second one. + t.is(Number(drill.explanation.split(' ')[0]), wins, `seed ${drill.seed}: ${drill.explanation}`) + } +}) + +// The price is arithmetic on two whole numbers of chips, and it is the same +// arithmetic /learn/pot-odds prints its table from. If those two ever come +// apart, a member is being taught one thing and graded by another. +test('the price the spot charges is the price the guide teaches', (t) => { + for (const drill of accepted(1, 1_500)) { + const stakes = drill.stakes + if (!stakes) { + t.fail(`seed ${drill.seed}: a pricing spot with no price on it`) + continue + } + t.is(stakes.toCall, Math.round(stakes.toCall), `seed ${drill.seed}: a fraction of a chip`) + t.true(stakes.toCall > 0 && stakes.pot > stakes.toCall, `seed ${drill.seed}: ${stakes.pot}`) + // The bet as a fraction of the pot before it, put back through the guide's + // own function. Exact, because every pot is a multiple of 60. + const potBefore = stakes.pot - stakes.toCall + t.true( + Math.abs(priceOf(drill) - requiredEquity(stakes.toCall / potBefore)) < 1e-9, + `seed ${drill.seed}: the drill and the guide price ${stakes.toCall} into ${potBefore} differently`, + ) + } +}) + +// Half the spots are calls and half are folds, by construction rather than by +// luck (see the note on generatePotOdds). This is the test that stops the +// rating quietly becoming a reading of who noticed that folding everything +// works, which is what a natural sample of turn spots would reward. +test('answering the same thing every time scores what a coin scores', (t) => { + const drills = accepted(1, 6_000) + const calls = drills.filter((drill) => drill.answer === 'call').length + const share = calls / drills.length + t.true(share > 0.45 && share < 0.55, `${(share * 100).toFixed(1)}% of spots are calls`) +}) + +// Every accepted spot is far enough from the price to be fair and near enough +// to be a question. Both halves are load-bearing: under the margin the player +// is right either way, over the gap they never had to count. +test('no spot is too close to ask, and none is too far to be worth asking', (t) => { + for (const drill of accepted(1, 2_000)) { + const gap = Math.abs(equityOf(drill).equity - priceOf(drill)) * 100 + t.true(gap >= 4, `seed ${drill.seed}: ${gap.toFixed(1)} points apart, which is a coin flip`) + t.true(gap <= 20, `seed ${drill.seed}: ${gap.toFixed(1)} points apart, which needs no counting`) + // The shape carried on the spot is that same gap, banded. One reading. + const expected: PriceShape = gap < 7 ? 'thin-price' : gap < 11 ? 'close-price' : 'clear-price' + t.is(drill.settledBy, expected, `seed ${drill.seed}: ${gap.toFixed(1)} points`) + } +}) + +test('the filter throws away the spots that are not questions', (t) => { + // Exhaustive, so a new reason anywhere in the vocabulary stops this file + // compiling and somebody has to decide whether this kind can emit it. + const counts: Record = { + 'one-sided': 0, + unexplainable: 0, + 'already-ahead': 0, + 'chop-possible': 0, + 'drawing-dead': 0, + ambiguous: 0, + } + let kept = 0 + for (let seed = 1; seed <= 4_000; seed++) { + const { drill, rejected } = drillAt(KIND, seed) + if (drill) kept++ + else if (rejected) counts[rejected]++ + } + + t.true(kept > 250, `the filter is throwing away too much: ${kept} kept of 4,000`) + t.true(counts['already-ahead'] > 100, 'the hero is never already ahead, which cannot be right') + t.true(counts['chop-possible'] > 10, 'no spot has ever been rejected for chopping') + t.true(counts['drawing-dead'] > 10, 'no spot has ever been rejected for being drawing dead') + t.true(counts.ambiguous > 10, 'no spot has ever been too close to the price to be fair') + t.true(counts['one-sided'] > 10, 'no spot has ever been rejected for being unaskable') + // Not a tuning knob here either: this kind's sentence is a count and two + // percentages, so there is no hand it can fail to explain. + t.is(counts.unexplainable, 0, "unexplainable is not this kind's vocabulary") +}) + +test('the grader accepts the answer and nothing else', (t) => { + for (const drill of accepted(30_000, 1_000)) { + t.deepEqual( + drill.choices.map((choice) => choice.id), + ['call', 'fold'], + `seed ${drill.seed}: the two answers, in the order they are drawn`, + ) + for (const choice of drill.choices) { + const grade = gradeDrill(drill, choice.id) + t.is(grade.correct, choice.id === drill.answer, `seed ${drill.seed}: ${choice.id}`) + t.is(choice.winning, choice.id === drill.answer, `seed ${drill.seed}: ${choice.id} marked`) + t.is(choice.cards.length, 0, `seed ${drill.seed}: an answer that is not a hand has cards`) + t.is(grade.explanation, drill.explanation) + t.is(grade.difficulty, drill.difficulty) + } + } +}) + +test('every spot deals eight distinct cards, both hands face up on the turn', (t) => { + for (const drill of accepted(1, 1_500)) { + t.is(drill.board.length, 4, `seed ${drill.seed}: board`) + t.is(hero(drill).length, 2, `seed ${drill.seed}: your hand`) + t.is(villain(drill).length, 2, `seed ${drill.seed}: their hand`) + const all = [...drill.board, ...hero(drill), ...villain(drill)].map(cardToString) + t.is(new Set(all).size, 8, `seed ${drill.seed}: a card is dealt twice`) + // What each hand is right now is named from the start: you cannot price a + // call against a hand you have not been told about. + for (const shown of drill.hands ?? []) { + t.true((shown.detail ?? '').length > 0, `seed ${drill.seed}: ${shown.label} unnamed`) + } + } +}) + +test('every spot carries a difficulty, and it is the one its shape is worth', (t) => { + const seen = new Set() + for (const drill of accepted(1, 3_000)) { + seen.add(drill.settledBy) + t.is(drill.difficulty, priceDifficulty(drill.settledBy as PriceShape), `seed ${drill.seed}`) + t.true( + drill.difficulty >= EASIEST_PRICE && drill.difficulty <= HARDEST_PRICE, + `seed ${drill.seed}: ${drill.difficulty}`, + ) + } + // All three shapes turn up in a normal sample. If one stopped, the ladder the + // rating is read against would be describing spots that no longer exist. + t.deepEqual([...seen].sort(), ['clear-price', 'close-price', 'thin-price']) +}) + +test('the stream always finds a spot, and quickly enough to deal on mount', (t) => { + let worst = 0 + for (let seed = 1; seed <= 1_000; seed++) { + let attempts = 1 + while (!drillAt(KIND, seed + attempts - 1).drill) attempts++ + worst = Math.max(worst, attempts) + t.is(nextDrill(KIND, seed).seed, seed + attempts - 1) + } + // This kind rejects far more than the other two — most turn spots are not a + // question about a price — so the number is worth stating rather than + // assuming. Each attempt is 44 showdowns, and the screen deals one of these + // on mount and after every answer. + t.true(worst < 100, `worst run of rejections was ${worst}`) + t.true(MAX_ATTEMPTS > worst * 4) +}) + +test('the ladder reads up, and says something honest at both ends', (t) => { + const ladder = spotLadder(KIND) + if (!ladder) { + t.fail('a kind with three shapes has no ladder') + return + } + t.is(ladder.length, 3) + for (let i = 1; i < ladder.length; i++) { + t.true(ladder[i].rating > ladder[i - 1].rating, `${ladder[i].settledBy} is not harder`) + t.true(ladder[i].label.length > 0) + } + // Below the whole ladder there is nothing to claim, and above it there is + // nothing left to be behind on. Neither sentence is a target. + t.is(standingLine(KIND, 800), `Next up: ${ladder[0].label}.`) + t.regex(standingLine(KIND, 2_000) ?? '', /every shape these spots come in/) + t.regex(standingLine(KIND, 1_200) ?? '', /^Better than even on clear prices\. Next up:/) +}) + +// --------------------------------------------------------------------------- +// The gate. The half that is about money rather than poker. +// --------------------------------------------------------------------------- + +// technology#55, and rule #8 under it: we never charge later for something that +// shipped free. The flag has to be on a paid kind in the commit that registers +// it, because no later commit can take back a kind that has been given away. By +// name rather than by iterating the registry, for the same reason as the +// counting kind's version of this test. +test("pot odds is registered as the membership's, not as free", (t) => { + const kind = drillKind(KIND) + t.true(kind.membersOnly, 'the second paid kind shipped without membersOnly') + t.false(canPlayDrill(kind, false), 'a non-member can open a kind that comes with the membership') + t.true(canPlayDrill(kind, true), 'a member cannot open the kind they paid for') +}) + +test('the membership has more than one kind in it, and the free one is still free', (t) => { + const paid = DRILL_KINDS.filter((kind) => kind.membersOnly).map((kind) => kind.id) + t.deepEqual(paid.sort(), ['count-your-outs', 'pot-odds']) + t.true(canPlayDrill(drillKind('which-hand-wins'), false), 'the free kind is no longer free') +}) + +// Only the kind that asks about money carries any. A stray price on another +// kind would draw a line of numbers over a spot that is not about them. +test('the pot and the price are on the pricing kind and nowhere else', (t) => { + for (const kind of DRILL_KINDS) { + const drill = nextDrill(kind.id, 1) + if (kind.id === KIND) t.truthy(drill.stakes, `${kind.id}: no price on a pricing spot`) + else t.is(drill.stakes, undefined, `${kind.id}: carries a price it never asks about`) + } +})