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`) + } +})