← poker-tutor
import PokerCore
import PokerAI
import TestKit
func preflopTableTests(_ t: TestRun) {
t.suite("preflop hand strength") { t in
t.test("the table covers all 169 starting hands") {
t.equal(HandClass.all.count, 169)
t.equal(Set(HandClass.all).count, 169, "no duplicates")
t.equal(HandClass.all.reduce(0) { $0 + $1.combos }, 1326, "every two-card deal is accounted for")
for hand in HandClass.all {
t.expect(PreflopTable.percentile[hand] != nil, "\(hand) is missing from the table")
}
}
t.test("hand shorthand round-trips") {
t.equal(HandClass("AKs")?.description, "AKs")
t.equal(HandClass("77")?.description, "77")
t.equal(HandClass("T9o")?.description, "T9o")
t.equal(HandClass([Card].parse("Kd Ad")).description, "AKs")
t.equal(HandClass([Card].parse("2c 2h")).description, "22")
t.expect(HandClass("AAs") == nil, "a pair cannot be suited")
}
t.test("combinations match the counts") {
t.equal(HandClass("AA")!.combinations().count, 6)
t.equal(HandClass("AKs")!.combinations().count, 4)
t.equal(HandClass("AKo")!.combinations().count, 12)
for hand in HandClass.all {
t.equal(hand.combinations().count, hand.combos, "\(hand)")
for combo in hand.combinations() { t.equal(HandClass(combo), hand, "\(hand) round-trip") }
}
}
t.test("equity matches known values") {
t.close(PreflopTable.equity(HandClass("AA")!, opponents: 1), 0.85, 0.02)
t.close(PreflopTable.equity(HandClass("72o")!, opponents: 1), 0.35, 0.03)
t.close(PreflopTable.equity(HandClass("AA")!, opponents: 5), 0.49, 0.03, "aces against a full field")
t.lessThan(PreflopTable.equity(HandClass("AA")!, opponents: 5),
PreflopTable.equity(HandClass("AA")!, opponents: 1))
}
t.test("the ordering looks like a real range chart") {
let pct = { (s: String) in PreflopTable.percentile(HandClass(s)!) }
t.lessThan(pct("AA"), 0.01, "aces are the very top")
t.greaterThan(pct("32o"), 0.95, "three-deuce offsuit is right at the bottom")
t.greaterThan(pct("72o"), 0.90, "seven-deuce is down there too")
t.lessThan(pct("AKs"), pct("AKo"), "suited beats offsuit")
t.lessThan(pct("QQ"), pct("AKo"), "queens beat ace-king")
t.lessThan(pct("76s"), pct("K3o"), "a suited connector plays better than a weak king")
t.lessThan(pct("JTs"), pct("J4o"), "connected beats a dangler")
t.lessThan(pct("55"), pct("A9o"))
for (a, b) in [("AA", "KK"), ("KK", "QQ"), ("TT", "88"), ("AKs", "AQs"), ("AQs", "AQo")] {
t.lessThan(pct(a), pct(b), "\(a) should rank above \(b)")
}
}
t.test("ranges are cut by combinations, not by hand names") {
// The 13 pairs are 8% of the 169 names but only 6% of actual deals. Counting names
// would quietly make every range in the app too tight.
let tight = PreflopTable.range(top: 0.10)
let combos = tight.reduce(0) { $0 + $1.combos }
t.close(Double(combos) / 1326.0, 0.10, 0.02, "a 10% range really is a tenth of deals")
t.expect(tight.contains(HandClass("AA")!))
t.expect(!tight.contains(HandClass("72o")!))
for fraction in [0.05, 0.15, 0.25, 0.40, 0.70] {
let range = PreflopTable.range(top: fraction)
let share = Double(range.reduce(0) { $0 + $1.combos }) / 1326.0
t.close(share, fraction, 0.03, "a \(Int(fraction * 100))% range")
}
}
t.test("wider ranges contain narrower ones") {
let narrow = PreflopTable.range(top: 0.10)
let wide = PreflopTable.range(top: 0.30)
t.expect(narrow.isSubset(of: wide), "a 30% range must contain the whole 10% range")
}
}
}
func handReaderTests(_ t: TestRun) {
t.suite("reading a hand") { t in
func read(_ hole: String, _ board: String, opponents: Int = 1) -> HandAssessment {
HandReader.assess(hole: [Card].parse(hole), board: [Card].parse(board), opponents: opponents)
}
t.test("made hands are named relative to the board") {
t.equal(read("Ac Ad", "Kh 8s 3c").category, .overPair)
t.equal(read("8c 8d", "Kh 9s 3c").category, .underPair)
t.equal(read("Ac Kd", "Kh 8s 3c").category, .topPair)
t.equal(read("Kc 4d", "Kh 8s 3c").category, .topPairWeakKicker)
t.equal(read("8c 4d", "Kh 8s 3c").category, .middlePair)
t.equal(read("3c 4d", "Kh 8s 3c").category, .bottomPair)
t.equal(read("Kc 8d", "Kh 8s 3c").category, .twoPair)
t.equal(read("3c 3d", "Kh 8s 3s").category, .set)
t.equal(read("Kc 4d", "Kh Ks 3c").category, .trips)
t.equal(read("Jc Td", "Kh Qs 3c 9d").category, .straight)
t.equal(read("Ac 4c", "Kc 8c 3c").category, .flush)
t.equal(read("Kc Kd", "Kh 8s 8c").category, .fullHouse)
t.equal(read("Kc Kd", "Kh Ks 8c").category, .monster)
t.equal(read("Ac 4d", "Kh 8s 3c").category, .aceHigh)
t.equal(read("7c 4d", "Kh 8s 3c").category, .nothing)
}
t.test("draws are recognised") {
t.equal(read("Ac 4c", "Kc 8c 3d").draw, .flushDraw)
t.equal(read("Jc Td", "9h 8s 3c").draw, .openEnded)
t.equal(read("Jc Td", "9h 7s 3c").draw, .gutshot)
t.equal(read("Ac Kd", "9h 7s 3c").draw, .overcards)
let combo = read("Jc Tc", "9c 8s 3c")
t.equal(combo.draw, .combo, "a flush draw plus a straight draw")
t.equal(combo.outs, 15, "nine clubs plus the queens and sevens that are not clubs")
}
t.test("out counts are right") {
t.equal(read("Ac 4c", "Kc 8c 3d").outs, 9, "nine cards complete a flush")
t.equal(read("Jc Td", "9h 8s 3c").outs, 8, "eight cards complete an open-ender")
t.equal(read("Jc Td", "9h 7s 3c").outs, 4, "four cards complete a gutshot")
t.equal(read("Ac Kd", "9h 7s 3c").outs, 6, "six cards pair an overcard")
}
t.test("cards that pair the board are not outs") {
// The obvious shortcut — count anything that improves your hand category — counts
// every card that pairs the board, which improves everybody's hand equally. A coach
// that tells the player they had fifteen outs when they had nine is worse than silent.
let flushDraw = read("Ac 4c", "Kc 8c 3d")
t.equal(flushDraw.outs, 9, "not the kings, eights and threes that pair the board")
// A straight the board would make on its own belongs to everyone, not to this hand.
let boardStraight = read("Ac 2d", "9h 8s 7c")
t.equal(boardStraight.draw, .none, "a six or ten makes the board's straight, not ours")
}
t.test("nothing is drawing on the river") {
let river = read("Ac 4c", "Kc 8c 3d 2h 9s")
t.equal(river.draw, .none)
t.equal(river.outs, 0)
t.equal(river.drawEquity, 0.0)
}
t.test("a dangerous board devalues a good hand") {
let safe = read("Ac Kd", "Kh 8s 3c") // top pair, rainbow rags
let scary = read("Ac Kd", "Kh 9h 8h") // top pair, flush out there
t.lessThan(scary.showdownValue, safe.showdownValue, "top pair is worth less on a flush board")
t.expect(scary.texture.flushPossible)
t.expect(!safe.texture.flushPossible)
}
t.test("more opponents means less is good enough") {
let heads = read("Ac Kd", "Kh 8s 3c", opponents: 1)
let field = read("Ac Kd", "Kh 8s 3c", opponents: 5)
t.lessThan(field.showdownValue, heads.showdownValue)
t.greaterThan(heads.showdownValue, 0.5, "top pair is usually good heads-up")
t.lessThan(field.showdownValue, 0.35, "top pair is rarely good against five")
}
t.test("stronger hands score higher, always") {
let ladder = [
read("7c 4d", "Kh 8s 3c"), // nothing
read("3c 4d", "Kh 8s 3c"), // bottom pair
read("8c 4d", "Kh 8s 3c"), // middle pair
read("Kc 4d", "Kh 8s 3c"), // top pair, weak kicker
read("Ac Kd", "Kh 8s 3c"), // top pair
read("Kc 8d", "Kh 8s 3c"), // two pair
read("3c 3d", "Kh 8s 3c"), // a set
]
for (a, b) in zip(ladder, ladder.dropFirst()) {
t.lessThan(a.showdownValue, b.showdownValue, "\(a.summary) should be worth less than \(b.summary)")
}
}
t.test("a draw adds value without pretending to be a made hand") {
let draw = read("Ac 4c", "Kc 8c 3d")
t.greaterThan(draw.total, draw.showdownValue, "the draw is worth something")
t.greaterThan(draw.drawEquity, 0.25, "nine outs twice is a real chunk of equity")
t.lessThan(draw.showdownValue, 0.30, "but ace high is not currently a hand")
}
}
}
func personalityTests(_ t: TestRun) {
t.suite("personalities") { t in
t.test("styles differ in the ways their names claim") {
let nit = Personality.nit.parameters(skill: 30)
let lag = Personality.looseAggressive.parameters(skill: 60)
let station = Personality.callingStation.parameters(skill: 20)
let maniac = Personality.maniac.parameters(skill: 25)
t.lessThan(nit.openRange, lag.openRange, "a nit plays fewer hands than a loose aggressive player")
t.greaterThan(station.callSlack, nit.callSlack, "a station calls with far less than it should")
t.lessThan(station.aggression, lag.aggression, "a station does not raise")
t.greaterThan(maniac.bluffFrequency, nit.bluffFrequency, "a maniac bluffs, a nit does not")
t.greaterThan(station.limpTendency, lag.limpTendency, "passive players limp")
}
t.test("losing styles only exist at low skill") {
t.lessThan(Personality.callingStation.skillRange.upperBound, 50)
t.lessThan(Personality.loosePassive.skillRange.upperBound, 50)
t.greaterThan(Personality.trickyRegular.skillRange.lowerBound, 50)
t.greaterThan(Personality.solidRegular.skillRange.upperBound, 90)
}
t.test("skill closes the leaks without erasing the style") {
for personality in Personality.allCases {
let low = personality.parameters(skill: personality.skillRange.lowerBound)
let high = personality.parameters(skill: personality.skillRange.upperBound)
t.expect(high.sizingNoise <= low.sizingNoise + 0.0001, "\(personality.name) should size better with skill")
t.expect(abs(high.callSlack) <= abs(low.callSlack) + 0.0001, "\(personality.name) should call closer to correct")
t.expect(high.adaptation >= low.adaptation - 0.0001, "\(personality.name) should notice more with skill")
}
// A station at the top of its band still calls too much — it is a station.
t.greaterThan(Personality.callingStation.parameters(skill: 35).callSlack, 0.1)
}
t.test("skill outside a style's band is clamped, not extrapolated") {
let a = Personality.nit.parameters(skill: 200)
let b = Personality.nit.parameters(skill: Personality.nit.skillRange.upperBound)
t.equal(a.openRange, b.openRange)
}
t.test("position widens ranges the way it should") {
let m = PositionAdjustment.openMultiplier
t.lessThan(m(.underTheGun), m(.middle))
t.lessThan(m(.middle), m(.cutoff))
t.lessThan(m(.cutoff), m(.button))
t.greaterThan(m(.button), 1.5, "the button plays far more hands than anyone")
}
}
t.suite("table composition") { t in
t.test("easy tables are full of recreational players, hard tables are not") {
func mix(_ difficulty: Int) -> [Personality: Int] {
var rng = SeededRandom(seed: 99)
var counts: [Personality: Int] = [:]
for _ in 0..<40 {
for seat in TableComposition.lineup(difficulty: difficulty, opponents: 8, rng: &rng) {
counts[seat.personality, default: 0] += 1
}
}
return counts
}
let easy = mix(15), hard = mix(85)
let recreational: [Personality] = [.callingStation, .loosePassive, .maniac]
let regulars: [Personality] = [.solidRegular, .trickyRegular, .looseAggressive]
let easyRec = recreational.reduce(0) { $0 + (easy[$1] ?? 0) }
let hardRec = recreational.reduce(0) { $0 + (hard[$1] ?? 0) }
let easyReg = regulars.reduce(0) { $0 + (easy[$1] ?? 0) }
let hardReg = regulars.reduce(0) { $0 + (hard[$1] ?? 0) }
t.greaterThan(easyRec, hardRec, "an easy table has more recreational players")
t.greaterThan(hardReg, easyReg, "a hard table has more regulars")
t.equal(hardRec, 0, "calling stations do not sit at the top tables")
}
t.test("average skill rises with difficulty") {
func averageSkill(_ difficulty: Int) -> Double {
var rng = SeededRandom(seed: 7)
var total = 0, count = 0
for _ in 0..<40 {
for seat in TableComposition.lineup(difficulty: difficulty, opponents: 8, rng: &rng) {
total += seat.skill; count += 1
}
}
return Double(total) / Double(count)
}
var previous = -1.0
for difficulty in [10, 30, 50, 70, 90] {
let average = averageSkill(difficulty)
t.greaterThan(average, previous, "difficulty \(difficulty) should be tougher than the level below")
previous = average
}
}
t.test("every seat gets a valid opponent") {
var rng = SeededRandom(seed: 3)
for difficulty in stride(from: 0, through: 100, by: 5) {
let lineup = TableComposition.lineup(difficulty: difficulty, opponents: 8, rng: &rng)
t.equal(lineup.count, 8, "difficulty \(difficulty)")
for seat in lineup {
t.expect(seat.personality.skillRange.contains(seat.skill),
"\(seat.personality.name) at skill \(seat.skill) is outside its band")
}
}
}
}
}