← poker-tutor
import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import TestKit
/// The ladder's job is to move somebody only when the hands say so. Almost everything below is
/// therefore about what it refuses to do: it will not move anyone on a tournament, it will not
/// bounce a player who is sitting on a bar up and down for ever, and it will not carry a rung
/// forward in memory that the saved hands cannot account for.
// MARK: - Building hands that rate exactly what we want
/// One decision in a spot with a single leak in it, so the arithmetic below is not competing with
/// any other row of the scorecard. `bet-sizing` is the only leak living in the flop-decision spot.
private func decision(mistake: Bool, cost: Double) -> VerdictRecord {
VerdictRecord(street: "flop",
spot: SpotKind.flopDecision.name,
leak: mistake ? ErrorKind.wrongSize.name : "",
severity: mistake ? Severity.moderate.rawValue : 0,
costInBB: mistake ? cost : 0,
headline: "h", detail: "d", taken: "bet", best: "bet",
spoke: mistake, confidence: 0.9)
}
/// `count` hands at a rung, built to rate exactly `rating` once there are enough of them.
///
/// The rating is `100 - costPer100 * 5.5` with no habit penalty, so the cost is worked backwards
/// from the number wanted. A fifth of the hands carry the mistake, which is under the quarter this
/// leak allows — deliberately, because a confirmed habit would add a penalty on top and the point
/// of these hands is that the rating is exactly what the test asked for.
private func hands(at tier: String, rating: Int, count: Int = LadderStanding.sampleSize,
from: Int = 1, session: String = "s") -> [HandRecord] {
let mistakes = max(1, count / 5)
// The inverse of SkillRating's own arithmetic, so a fixture asking for a rating of 60 gets
// hands that actually rate 60. It was written out by hand with the old constants and quietly
// stopped agreeing the day the scale was re-anchored on tournament play — which broke a dozen
// ladder tests that were all correct.
let costPer100 = SkillRating.costFor(rating: rating)
let each = costPer100 * Double(count) / 100 / Double(mistakes)
return (0..<count).map { index in
HandRecord(sessionID: session,
sessionStarted: Date(timeIntervalSince1970: 1_780_000_000),
playedAt: Date(timeIntervalSince1970: 1_780_000_000 + Double(from + index) * 60),
handNumber: from + index,
mode: "Tournament",
bigBlind: 100,
position: "BB",
holeCards: "Ah Kd",
board: "2c 7h Js",
netChips: 0,
wentToShowdown: false,
sawFlop: true,
putMoneyInPreflop: true,
raisedPreflop: false,
verdicts: [decision(mistake: index < mistakes, cost: each)],
tableSize: 9,
tier: tier)
}
}
public func ladderTests(_ t: TestRun) {
cashLadderTests(t)
t.suite("the rungs of the ladder") { t in
t.test("every rung seats a full table") {
for tier in LadderTier.all {
t.equal(tier.roster.count, TableSession.seatsAtTable - 1,
"\(tier.name) seats \(tier.roster.count) opponents, not eight")
}
t.equal(LadderTier.all.map(\.rung), Array(0..<LadderTier.all.count))
t.equal(Set(LadderTier.all.map(\.id)).count, LadderTier.all.count, "two rungs share an id")
}
t.test("a rung is defined by who is at it, not by the dial") {
// Style beat skill by an order of magnitude in the shootouts, so the thing that has to
// change on the way up is the cast. Counted here: the players who give money away
// leave, and the ones who take it arrive.
func recreational(_ tier: LadderTier) -> Int {
tier.roster.filter { [.callingStation, .loosePassive, .maniac].contains($0) }.count
}
func regulars(_ tier: LadderTier) -> Int {
tier.roster.filter {
[.tightAggressive, .looseAggressive, .solidRegular, .trickyRegular].contains($0)
}.count
}
for (lower, upper) in zip(LadderTier.all, LadderTier.all.dropFirst()) {
t.expect(recreational(upper) <= recreational(lower),
"\(upper.name) has more loose players than \(lower.name)")
t.expect(regulars(upper) > regulars(lower),
"\(upper.name) has no more competent players than \(lower.name)")
t.expect(upper.skill > lower.skill, "\(upper.name) is not a harder dial")
}
t.equal(recreational(LadderTier.all[0]), 7, "the bottom rung should be nearly all of them")
t.equal(recreational(LadderTier.all.last!), 0, "the top rung should have none of them")
}
t.test("the bars never touch") {
// The whole anti-yo-yo guarantee lives in this gap. If the rating that promotes you off
// a rung were close to the one that sends you back down to it, a player sitting on the
// bar would be carried up and down for ever.
for tier in LadderTier.all.dropFirst() {
guard let down = tier.demotionRating,
let up = tier.previous?.promotionRating else {
t.expect(false, "\(tier.name) has no bars"); continue
}
t.expect(up - down >= 20,
"\(tier.name) drops at \(down) and \(tier.previous!.name) leaves at \(up)")
}
t.equal(LadderTier.all.first?.demotionRating, nil, "nobody falls off the bottom")
t.equal(LadderTier.all.last?.promotionRating, nil, "there is nothing above the top")
}
t.test("everybody starts at the bottom until the hands say otherwise") {
t.equal(LadderStanding(of: []).tier.id, LadderTier.bottom.id)
// Forty good hands is an evening, not a placement — the rating off that few is held
// near the middle on purpose and reading a rung off it would be reading a number the
// hands do not support.
let evening = hands(at: "kitchen", rating: 90, count: 40).map { record -> HandRecord in
var copy = record; copy.tier = nil; return copy
}
t.equal(LadderStanding(of: evening).tier.id, LadderTier.bottom.id)
}
t.test("a long history places you where it says you are") {
let history = hands(at: "kitchen", rating: 70, count: 220).map { record -> HandRecord in
var copy = record; copy.tier = nil; return copy
}
let placed = LadderStanding(of: history)
// Compared against the rating those hands actually produce rather than a number typed
// in here. The literal used to be 70 and "regulars", which stopped being true the day
// the rating was re-anchored on tournament play — the test was pinning the old scale
// rather than the behaviour it was written for.
let measured = LeakScorecard(of: history).rating.value
t.equal(placed.tier.id, LadderTier.entry(forRating: measured).id,
"\(measured) placed at \(placed.tier.id)")
t.equal(placed.handsAtTier, 0, "none of those hands were played on the ladder")
}
// The ladder has to be climbable, and every rung has to be somewhere somebody can arrive.
// A rung nobody can reach is a rung that should not exist, and re-anchoring the rating is
// exactly the change that would quietly create one.
t.test("every rung can be arrived at, and the bars only go one way") {
var last = LadderTier.entry(forRating: 0).rung
for rating in 0...100 {
let rung = LadderTier.entry(forRating: rating).rung
t.expect(rung >= last, "rating \(rating) placed lower than \(rating - 1) did")
last = rung
}
let reached = Set((0...100).map { LadderTier.entry(forRating: $0).id })
t.equal(reached.count, LadderTier.all.count,
"only \(reached.count) of \(LadderTier.all.count) rungs can be arrived at")
// And the top of the ladder has to be beyond the best player this repo can measure —
// 62, a scripted player with no deliberate flaw — or there is nothing left to climb.
t.equal(LadderTier.entry(forRating: 62).id, LadderTier.all.last?.id,
"a player with no deliberate flaw should be at the top table")
t.equal(LadderTier.entry(forRating: 9).id, LadderTier.bottom.id,
"the worst scripted player belongs at the kitchen table")
}
t.test("arranged hands cannot place anybody, however many of them there are") {
// The seat and the stacks were chosen in an assessment, and a drill chose the whole
// situation, so neither is a result. Counting them towards the two hundred while the
// rating refuses to count them is the worst of both: an evening of drilling opened the
// placement door on a number built from almost no real hands.
func arranged(_ kind: String, drill: String?) -> [HandRecord] {
hands(at: "kitchen", rating: 90, count: 220).map { record -> HandRecord in
var copy = record
copy.tier = nil
copy.kind = kind
copy.drill = drill
return copy
}
}
let drilled = arranged(HandRecord.drillKind, drill: "bet-sizing")
t.equal(LadderStanding(of: drilled).tier.id, LadderTier.bottom.id,
"220 hands of drilling placed somebody up the ladder")
let assessed = arranged(HandRecord.assessmentKind, drill: nil)
t.equal(LadderStanding(of: assessed).tier.id, LadderTier.bottom.id,
"an assessment placed somebody up the ladder")
// The same hands played for real do place them, which is what makes the two lines above
// a statement about arranged hands rather than about the fixture.
let real = hands(at: "kitchen", rating: 90, count: 220).map { record -> HandRecord in
var copy = record; copy.tier = nil; return copy
}
t.equal(LadderStanding(of: real).tier.id, LadderTier.entry(forRating: 90).id)
t.expect(LadderTier.entry(forRating: 90).id != LadderTier.bottom.id)
// And a real history is not held down by arranged hands sitting alongside it.
t.equal(LadderStanding(of: drilled + real).tier.id, LadderTier.entry(forRating: 90).id)
}
}
t.suite("moving up and down the ladder") { t in
t.test("a tournament cannot move anybody") {
// Forty hands played brilliantly. This is the case the whole sample size exists for.
let standing = LadderStanding(of: hands(at: "kitchen", rating: 95, count: 40))
t.equal(standing.move, .held)
t.equal(standing.tier.id, "kitchen")
t.equal(standing.handsToGo, 160)
t.expect(!standing.enoughHands)
t.expect(standing.resultsLine.contains("160 to go"), standing.resultsLine)
}
// These read the bars off the ladder rather than naming them. They used to name them, and
// when the rating was re-anchored on tournament play every one of them failed while testing
// exactly the right thing — the numbers had moved underneath, not the behaviour.
t.test("promotion fires once the sample is there") {
let bar = LadderTier.bottom.promotionRating!
let short = LadderStanding(of: hands(at: "kitchen", rating: bar, count: 199))
t.equal(short.move, .held, "199 hands is not 200")
let full = LadderStanding(of: hands(at: "kitchen", rating: bar, count: 200))
t.equal(full.rating?.value, bar, "the test hands did not rate what they were built to")
t.equal(full.move, .promoted)
t.equal(full.settled.id, "kitchen")
t.equal(full.tier.rung, 1)
}
t.test("just under the bar is not a promotion") {
let bar = LadderTier.bottom.promotionRating!
let under = LadderStanding(of: hands(at: "kitchen", rating: bar - 1, count: 240))
t.equal(under.rating?.value, bar - 1)
t.equal(under.move, .held)
t.equal(under.tier.id, "kitchen")
let over = LadderStanding(of: hands(at: "kitchen", rating: bar, count: 240))
t.equal(over.move, .promoted, "the bar is meant to be inclusive")
}
t.test("demotion fires when results fall away") {
let casino = LadderTier.all.first { $0.id == "casino-low" }!
let standing = LadderStanding(of: hands(at: "casino-low",
rating: casino.demotionRating! - 1, count: 260))
t.equal(standing.move, .demoted)
t.equal(standing.settled.id, "casino-low")
t.equal(standing.tier.rung, casino.rung - 1)
t.expect(standing.resultsLine.contains("go back a rung"), standing.resultsLine)
}
t.test("nobody falls off the bottom or climbs past the top") {
let floor = LadderStanding(of: hands(at: "kitchen", rating: 2, count: 300))
t.equal(floor.move, .held)
t.equal(floor.tier.id, "kitchen")
let ceiling = LadderStanding(of: hands(at: "tough", rating: 99, count: 300))
t.equal(ceiling.move, .held)
t.equal(ceiling.tier.id, "tough")
t.expect(ceiling.requirementLine.contains("top of the ladder"), ceiling.requirementLine)
}
t.test("a move resets the sample") {
// Promoted off two hundred good hands, then one bad tournament at the new table. The
// bad tournament must not send them straight back.
var history = hands(at: "kitchen", rating: 70, count: 200, from: 1)
history += hands(at: "home-game", rating: 5, count: 40, from: 300)
let standing = LadderStanding(of: history)
t.equal(standing.settled.id, "home-game", "the rung follows the hands most recently played")
t.equal(standing.handsAtTier, 40, "the hands at the old table are behind them")
t.equal(standing.move, .held)
t.equal(standing.tier.id, "home-game")
}
t.test("a player wobbling on noise is never bounced") {
// Sessions that alternate between a rating well past the bar this rung leaves at and
// one under the bar it drops at. Judged one session at a time the player would be
// promoted and demoted on alternate evenings for ever. Judged on two hundred hands they
// are what they actually are: somewhere in the middle, and staying.
//
// Both ends are read off the rung rather than named, and the wobble is centred on the
// midpoint between its two bars, so this keeps testing the hysteresis rather than
// whatever the numbers happened to be the day it was written.
let rung = LadderTier.all.first { $0.id == "home-game" }!
let up = rung.promotionRating!, down = rung.demotionRating!
let middle = (up + down) / 2
let good = middle + (up - down), bad = middle - (up - down)
var history: [HandRecord] = []
var seen: Set<String> = []
for session in 0..<12 {
history += hands(at: "home-game", rating: session.isMultiple(of: 2) ? good : bad,
count: 40, from: 1 + session * 40, session: "s\(session)")
let standing = LadderStanding(of: history)
seen.insert(standing.tier.id)
t.equal(standing.move, .held,
"session \(session) moved them: \(standing.resultsLine)")
}
t.equal(seen, ["home-game"], "the player was carried off the rung by noise")
// And the pooled rating really is between the two bars, which is why it held.
let settled = LadderStanding(of: history)
t.expect(((down + 1)...(up - 1)).contains(settled.rating?.value ?? 0),
"these hands rate \(settled.rating?.value ?? -1), which is not between the bars")
}
t.test("a demotion is not undone by the sample that caused it") {
// Dropped from the casino table for a rating just under its bar. The same rating at the
// rung below is nowhere near what it takes to come back up, which is the twenty points
// between the two bars doing exactly the job they are there for.
let casino = LadderTier.all.first { $0.id == "casino-low" }!
let below = LadderTier.all.first { $0.rung == casino.rung - 1 }!
let dropping = casino.demotionRating! - 1
t.lessThan(dropping, below.promotionRating!,
"the bars overlap, so a demotion would promote straight back")
let dropped = LadderStanding(of: hands(at: casino.id, rating: dropping, count: 200))
t.equal(dropped.tier.id, below.id)
let after = LadderStanding(of: hands(at: below.id, rating: dropping, count: 200))
t.equal(after.move, .held, "the same results promoted them straight back up")
t.equal(after.tier.id, below.id)
}
t.test("only the hands since the last move are counted") {
var history = hands(at: "kitchen", rating: 95, count: 200, from: 1)
history += hands(at: "home-game", rating: 30, count: 200, from: 300)
let standing = LadderStanding(of: history)
t.equal(standing.handsAtTier, 200)
t.equal(standing.sample, 200)
t.equal(standing.rating?.value, 30, "the old rung's hands leaked into the new one")
}
t.test("the sample looks back a set distance and no further") {
// A terrible first four hundred hands followed by two hundred good ones. Beyond the
// window the old ones are let go, so a fixed habit stops being held against them.
var history = hands(at: "home-game", rating: 5, count: 400, from: 1)
history += hands(at: "home-game", rating: 80, count: 400, from: 500)
let standing = LadderStanding(of: history)
t.equal(standing.handsAtTier, 800)
t.equal(standing.sample, LadderStanding.window)
t.equal(standing.rating?.value, 80)
t.equal(standing.move, .promoted)
}
t.test("hands recorded at a rung this build no longer has are not guessed at") {
let history = hands(at: "a-rung-that-was-deleted", rating: 90, count: 300)
let standing = LadderStanding(of: history)
// Those hands are no longer evidence of anything on the ladder, so the player is
// placed off what they are worth rather than left holding a rung that does not exist.
t.equal(standing.tier.id, LadderTier.entry(forRating: 90).id,
"an unknown rung should fall back to a placement, not crash or promote")
t.equal(standing.handsAtTier, 0)
t.equal(standing.move, .held)
t.expect(standing.rating == nil, "there is no sample at a rung this build does not have")
}
t.test("assessment hands are not results") {
// The assessment arranges the seat and the stacks; what it wins or loses is not a
// result and it is not played at a rung, so it can never move anybody.
let arranged = hands(at: "kitchen", rating: 95, count: 300).map { record -> HandRecord in
var copy = record
copy.tier = nil
copy.kind = HandRecord.assessmentKind
return copy
}
let standing = LadderStanding(of: arranged)
t.equal(standing.handsAtTier, 0)
t.equal(standing.move, .held)
}
}
t.suite("the ladder at the table") { t in
t.test("a sitting is played at a rung and says so in the file") {
let session = TableSession(seed: 31, history: .inMemory())
session.start(.tournament)
t.equal(session.tier?.id, "kitchen", "a player with no history starts at the bottom")
t.equal(session.tableDifficulty, LadderTier.bottom.skill)
var guardRail = 0
while session.isPlaying, session.history.hands.count < 6 {
guardRail += 1
precondition(guardRail < 20_000, "session failed to progress")
if let legal = session.legal {
session.act(legal.canCheck ? .check : .fold)
} else if session.review != nil, session.engine?.isComplete == true {
session.continueAfterReview()
} else {
break
}
}
t.expect(session.history.hands.count >= 1, "no hands were written down")
for hand in session.history.hands {
t.equal(hand.tier, "kitchen", "hand \(hand.handNumber) does not say where it was played")
}
}
t.test("the people at the table are the rung's people") {
let session = TableSession(seed: 77, history: .inMemory())
session.start(.tournament)
let seated = session.opponents.values.map(\.personality).sorted { $0.rawValue < $1.rawValue }
let expected = LadderTier.bottom.roster.sorted { $0.rawValue < $1.rawValue }
t.equal(seated, expected, "the table is not the room the rung describes")
t.greaterThan(session.opponents.count, 0, "nobody sat down, so nothing below was checked")
for opponent in session.opponents.values {
t.expect(opponent.personality.skillRange.contains(opponent.skill),
"\(opponent.id) is playing outside the band their style exists in")
}
}
t.test("an assessment is not played on the ladder") {
let session = TableSession(seed: 44, history: .inMemory())
session.startAssessment(hands: 4)
t.equal(session.tier, nil)
}
t.test("the ladder is where it was left after a restart") {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("poker-ladder-\(UUID().uuidString).jsonl")
defer { try? FileManager.default.removeItem(at: url) }
let writing = HistoryStore(url: url)
for record in hands(at: "casino-low", rating: 40, count: 210) { writing.append(record) }
let before = LadderStanding(of: writing.hands(in: .allTime))
// A second store opening the same file is what the next launch does.
let reading = HistoryStore(url: url)
let after = LadderStanding(of: reading.hands(in: .allTime))
t.equal(after.settled.id, before.settled.id)
t.equal(after.tier.id, "casino-low", "the rung did not survive the restart")
t.equal(after.handsAtTier, before.handsAtTier)
t.equal(after.rating?.value, before.rating?.value)
t.equal(after.move, .held)
}
}
t.suite("what the ladder says out loud") { t in
t.test("it never claims more than the hands support") {
let fresh = LadderStanding(of: [])
t.expect(fresh.resultsLine.contains("not played a hand"), fresh.resultsLine)
t.expect(fresh.requirementLine.contains("200"), fresh.requirementLine)
let part = LadderStanding(of: hands(at: "kitchen", rating: 80, count: 120))
t.expect(part.resultsLine.contains("120 hands"), part.resultsLine)
t.expect(part.resultsLine.contains("80 to go"), part.resultsLine)
}
t.test("a rung lists who is in the room") {
let kitchen = LadderTier.bottom
t.equal(kitchen.cast.reduce(0) { $0 + $1.count }, 8, "the cast does not add up to the table")
t.expect(kitchen.castLine.contains("3 calling stations"), kitchen.castLine)
t.expect(kitchen.castLine.contains("1 nit"), kitchen.castLine)
t.expect(kitchen.castLine.hasSuffix("."), kitchen.castLine)
}
t.test("the demotion is said out loud rather than sprung on anybody") {
let casino = LadderTier.all.first { $0.id == "casino-low" }!
let safe = (casino.promotionRating! + casino.demotionRating!) / 2
let standing = LadderStanding(of: hands(at: "casino-low", rating: safe, count: 210))
t.equal(standing.move, .held)
let warning = standing.demotionLine ?? ""
t.expect(warning.contains("\(casino.demotionRating!)"), warning)
t.expect(warning.contains("Friday night home game"), warning)
}
}
}
/// The cash ladder is a ladder of stakes. Same machinery, different rungs.
func cashLadderTests(_ t: TestRun) {
t.suite("the ladder of stakes") { t in
t.test("four stakes, climbing, each with its own room") {
let rungs = LadderTier.cashRungs
t.equal(rungs.count, 4)
t.equal(rungs.map(\.rung), [0, 1, 2, 3])
for (lower, upper) in zip(rungs, rungs.dropFirst()) {
t.lessThan(lower.stakes!.bigBlind, upper.stakes!.bigBlind, "stakes should climb")
t.lessThan(lower.skill, upper.skill + 1, "the rooms should not get softer going up")
}
t.equal(rungs.map { $0.stakes!.bigBlind }, CashSetup.stakesOnOffer.map(\.bigBlind),
"every stake on offer is a rung, and every rung is on offer")
for rung in rungs { t.expect(rung.stakes != nil) }
for rung in LadderTier.tournamentRungs { t.expect(rung.stakes == nil) }
}
t.test("no rung on either ladder shares an id with any other") {
let ids = (LadderTier.tournamentRungs + LadderTier.cashRungs).map(\.id)
t.equal(Set(ids).count, ids.count)
for rung in LadderTier.cashRungs {
t.equal(LadderTier.named(rung.id)?.id, rung.id, "a cash rung resolves from the tournament product too")
t.equal(LadderTier.cash(forStakes: rung.stakes!)?.id, rung.id)
}
}
t.test("the product picks the ladder") {
t.equal(LadderTier.all.map(\.id), LadderTier.tournamentRungs.map(\.id))
withCashProduct {
t.equal(LadderTier.all.map(\.id), LadderTier.cashRungs.map(\.id))
t.equal(LadderTier.bottom.id, "cash-25-50")
let reached = Set((0...100).map { LadderTier.entry(forRating: $0).id })
t.equal(reached.count, 4, "every stake can be arrived at by some rating")
t.equal(LadderTier.entry(forRating: 0).id, "cash-25-50")
t.equal(LadderTier.entry(forRating: 100).id, "cash-500-1000")
}
}
t.test("a cash session sits at the stakes the player chose, and the hand says so") {
withCashProduct {
let session = TableSession(seed: 4, history: .inMemory(), variant: .pokerTutorCash)
session.cashSetup = CashSetup(stakes: BlindLevel(smallBlind: 100, bigBlind: 200), structure: .noLimit)
session.start(.cash)
t.equal(session.tier?.id, "cash-100-200")
t.equal(session.tableDifficulty, LadderTier.cashRungs[2].skill)
var steps = 0
while session.history.hands.isEmpty, steps < 5_000 {
steps += 1
if session.heroToAct { session.act(session.legal!.canCheck ? .check : .fold) }
else if session.engine?.isComplete == true { session.continueAfterReview() }
else { session.stepOpponent() }
}
t.equal(session.history.hands.first?.tier, "cash-100-200")
}
}
t.test("the standing counts only hands on its own ladder") {
withCashProduct {
// A hand written at a tournament rung is not a hand at any stake.
let tournamentHands = (0..<250).map { i in
HandRecord(sessionID: "t", sessionStarted: Date(), playedAt: Date(), handNumber: i,
mode: "Tournament", bigBlind: 100, position: "BTN", holeCards: "Ah Kd",
board: "", netChips: 0, wentToShowdown: false, sawFlop: false,
putMoneyInPreflop: false, raisedPreflop: false, verdicts: [], tier: "kitchen")
}
let standing = LadderStanding(of: tournamentHands)
t.equal(standing.tier.id, "cash-25-50", "tournament hands should not place anyone on the stakes ladder")
t.equal(standing.handsAtTier, 0)
t.equal(standing.placeLine, "You play 25/50.")
}
}
t.test("a cash player is told their win rate beside the rating that moves them") {
withCashProduct {
let hands = (0..<60).map { i in
HandRecord(sessionID: "c", sessionStarted: Date(), playedAt: Date(), handNumber: i,
mode: "Cash game", bigBlind: 100, position: "BTN", holeCards: "Ah Kd",
board: "", netChips: i % 2 == 0 ? 300 : -100, wentToShowdown: false,
sawFlop: false, putMoneyInPreflop: false, raisedPreflop: false,
verdicts: [], tier: "cash-50-100")
}
let standing = LadderStanding(of: hands)
t.equal(standing.settled.id, "cash-50-100")
t.expect(standing.isCash)
t.close(standing.winRatePer100, 100, 1, "sixty hands, +1 big blind a hand on average")
t.equal(standing.winRateLine, "Win rate there: +100 big blinds for every 100 hands, over 60 hands.")
t.expect(standing.requirementLine.contains("50/100") && standing.requirementLine.contains("100/200"),
"the requirement should name the stakes: \(standing.requirementLine)")
t.expect(!standing.resultsLine.contains("tournament"),
"a cash player should not be told about tournaments: \(standing.resultsLine)")
}
}
}
}