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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions src/components/drills/DrillRunner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, string> = { 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.
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -331,6 +339,7 @@ function Run({ kind }: { kind: DrillKind }) {
transition={{ duration: 0.25, ease: 'easeOut' }}
>
<p className="text-center text-sm text-muted-foreground">{kind.question}</p>
{drill.stakes && <Stakes stakes={drill.stakes} />}
<div className="mt-3 flex items-center justify-center gap-1 sm:gap-2">
{drill.board.map((card) => (
<PlayingCard key={cardKey(card)} card={card} size="drill" />
Expand Down Expand Up @@ -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 ? (
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
// 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.
<div
className={cn('mt-6 grid grid-cols-2 gap-3', outcomes.length > 2 && 'sm:grid-cols-4')}
>
{outcomes.map((choice) => (
<CountChoice
key={choice.id}
Expand Down Expand Up @@ -435,6 +448,29 @@ function Run({ kind }: { kind: DrillKind }) {
)
}

/**
* The money, on a kind whose question is about a price.
*
* Two numbers, in the order the decision needs them, and in the same words the
* table uses. Nothing here is a hint: what the pot is charging as a percentage
* is the thing being asked for, so it appears in the sentence after the answer
* and never before it.
*/
function Stakes({ stakes }: { stakes: DrillStakes }) {
const pot = formatChips(stakes.pot)
const toCall = formatChips(stakes.toCall)
return (
<p className="mt-1.5 text-center text-sm tabular-nums text-muted-foreground">
<span className="sr-only">{`Pot ${pot} chips, ${toCall} to call.`}</span>
<span aria-hidden>
Pot <span className="font-semibold text-foreground">{pot}</span>
{' · '}
<span className="font-semibold text-foreground">{toCall}</span> to call
</span>
</p>
)
}

/**
* A holding the spot shows and does not ask about.
*
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/config/drills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
]

/**
Expand Down
58 changes: 9 additions & 49 deletions src/lib/drills/countYourOuts.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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'

Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -209,44 +188,25 @@ 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({
kind: 'count-your-outs',
seed,
board,
choices,
hands: showHands(hero, villain, board),
hands: faceUpHands(hero, villain, board),
answer: String(count),
settledBy: shape,
difficulty: outsDifficulty(shape, trap),
explanation: explain(outs, traps, trap),
})
}

/**
* 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.
*
Expand Down
2 changes: 2 additions & 0 deletions src/lib/drills/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { generateCountYourOuts } from './countYourOuts'
import { generatePotOdds } from './potOdds'
import type { Drill, DrillKindId, Generated, Grade } from './types'
import { generateWhichHandWins } from './whichHandWins'

Expand All @@ -16,6 +17,7 @@ export * from './rating'
const GENERATORS: Record<DrillKindId, (seed: number) => Generated> = {
'which-hand-wins': generateWhichHandWins,
'count-your-outs': generateCountYourOuts,
'pot-odds': generatePotOdds,
}

/**
Expand Down
Loading