← poker-tutor
import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerTests
import PokerSession
/// Runs a player past the coach and prints what it concluded, so the coaching can be read and
/// judged rather than taken on trust.
func runCoach(hands: Int, seed: UInt64, playerName: String, samples: Int, showProfile: Bool,
format: AppVariant.Format = .tournament, setup: CashSetup = .standard, rung: Int = 0,
keepEveryReview: Bool = false, breakdown: Bool = false) {
let player = plantedPlayer(named: playerName)
// A cash run goes through the app's own cash table, at the structure asked for; the
// tournament run is the six-handed rig every tournament bar was set on.
let coach = format == .cash
? runPlantedCash(player, hands: hands, seed: seed, setup: setup, rung: rung, keepEveryReview: keepEveryReview)
: runPlanted(player, hands: hands, seed: seed)
print(format == .cash
? "Player: \(player.name). \(hands) hands at the app's cash table, \(setup.stakesLabel) \(setup.structure.rawValue), rung \(rung)."
: "Player: \(player.name). \(hands) hands, six-handed, 100 big blinds deep.")
print("")
let spoken = coach.reviews.flatMap { review in review.worthMentioning.map { (review, $0) } }
let opened = coach.reviews.filter { !$0.playedWell }.count
print(String(format: "Reviews opened on %.1f%% of the last %d hands (%d), %d decisions spoken.",
Double(opened) / Double(max(1, coach.reviews.count)) * 100, coach.reviews.count, opened, spoken.count))
print("")
if samples > 0 {
print("What the coach said, worst first")
print("")
for (review, verdict) in spoken.sorted(by: { $0.1.costInBB > $1.1.costInBB }).prefix(samples) {
let cards = verdict.holeCards.map(\.pretty).joined(separator: " ")
let board = verdict.board.isEmpty ? "—" : verdict.board.map(\.pretty).joined(separator: " ")
print(" Hand \(review.handNumber), \(verdict.street.name), \(verdict.spot.name)")
print(" you had \(cards) board \(board)")
print(" you \(verdict.taken); better: \(verdict.best)")
print(" \(verdict.headline)")
print(" \(wrap(verdict.detail, indent: 4))")
print(String(format: " cost about %.1f big blinds (confidence %.0f%%)", verdict.costInBB, verdict.confidence * 100))
print("")
}
}
// `--breakdown`: what the coach charged, grouped by situation, mistake and what was done
// instead of what — the view that says whether a sound player is being stopped for one
// habit or for a hundred small disagreements.
if breakdown {
var groups: [String: (count: Int, cost: Double)] = [:]
for (_, verdict) in spoken {
func shape(_ action: PlayerAction) -> String {
switch action {
case .bet: return "bet"
case .raise: return "raise"
default: return action.description
}
}
let key = String(format: "%-28@ %-24@ %-6@ -> %@", verdict.spot.name as NSString,
(verdict.error?.name ?? "close call") as NSString,
shape(verdict.taken) as NSString, shape(verdict.best) as NSString)
groups[key, default: (0, 0)].count += 1
groups[key, default: (0, 0)].cost += verdict.costInBB
}
print("Spoken decisions by kind (count, big blinds)")
for (key, value) in groups.sorted(by: { $0.value.count > $1.value.count }) {
print(String(format: " %3d %7.1f %@", value.count, value.cost, key))
}
print("")
}
let report = coach.sessionReport()
print("Session report")
print(" \(report.headline)")
print(" Rating \(report.rating.value) — \(report.rating.label)")
print("")
if report.confirmed.isEmpty {
print(" No leaks confirmed.")
} else {
print(" Leaks found:")
for (leak, record) in report.confirmed {
print(String(format: " %-38@ %3d of %3d chances (%.0f%%), costing %.0f big blinds",
leak.name as NSString, record.mistakes, record.opportunities,
record.rate * 100, record.costInBB))
}
}
if !report.watching.isEmpty {
print("")
print(" Watching (not enough hands yet):")
for (leak, record) in report.watching {
print(" \(leak.name) — \(record.mistakes) of \(record.opportunities)")
}
}
if showProfile {
print("")
print(" Every spot the coach measured:")
for leak in LeakCatalogue.all {
guard let record = coach.profile.records[leak.id], record.opportunities > 0 else { continue }
let flag = record.opportunities >= leak.minimumSample ? " " : "*"
print(String(format: " %@ %-38@ %3d/%3d = %4.0f%% (allowed %.0f%%, needs %d)",
flag, leak.name as NSString, record.mistakes, record.opportunities,
record.rate * 100, leak.acceptableRate * 100, leak.minimumSample))
}
print(" * not yet enough chances to judge")
}
}
/// A bot of a chosen strength, sat in the hero's seat.
///
/// The rating scale has to be anchored on something, and the only players this repo can measure are
/// its own. `control` is one point on that curve — a solid regular at skill 88 — and one point is
/// not a calibration.
func botPlayer(skill: Int, personality: Personality = .solidRegular) -> PlantedPlayer {
PlantedPlayer(name: "\(personality.rawValue) at skill \(skill)", leakID: "") { view, rng in
var bot = Bot(id: "calibration", personality: personality, skill: skill, seed: rng.next())
return bot.decide(view)
}
}
func plantedPlayer(named name: String) -> PlantedPlayer {
if name.lowercased().hasPrefix("bot") {
let skill = Int(name.dropFirst(3)) ?? 88
return botPlayer(skill: skill)
}
switch name.lowercased() {
case "foldsbb", "bb": return .foldsBigBlind
case "limps": return .limpsEverything
case "noriverbet", "river": return .neverValueBetsRiver
case "noturnbet", "turn": return .neverValueBetsTurn
case "foldsflop", "flop": return .foldsToEveryFlopBet
case "callsriver", "hero": return .callsEveryRiverBet
case "minbet": return .alwaysMinBetsFlop
case "overbet", "shove": return .alwaysOverbetsFlop
case "loose", "toomany": return .playsEverything
case "stackoff": return .stacksOffOnePair
case "setminer": return .setMinesEverything
case "foldsimplied": return .foldsSpeculativeHands
case "limitfolder": return .foldsDrawsForOneBet
case "noraise": return .neverRaisesPostflop
case "coldcaller": return .coldCallsRaises
case "riverfolder": return .foldsRiverToOneBet
case "calldown": return .callsRiverWithNothing
case "nofreecard": return .neverRaisesDraws
case "capper": return .capsEverything
default:
// "badseat4" — a sound player who opens far too many hands from seat 4 of 9 only.
if name.lowercased().hasPrefix("badseat"),
let number = Int(name.dropFirst(7)),
let seat = Position.names(forPlayerCount: 9).indices.contains(number - 1)
? Position.names(forPlayerCount: 9)[number - 1] : nil {
return .badInSeat(seat)
}
return .control
}
}
private func wrap(_ text: String, indent: Int, width: Int = 84) -> String {
var lines: [String] = []
var current = ""
for word in text.split(separator: " ") {
if current.count + word.count + 1 > width {
lines.append(current)
current = String(word)
} else {
current += current.isEmpty ? String(word) : " \(word)"
}
}
if !current.isEmpty { lines.append(current) }
return lines.joined(separator: "\n" + String(repeating: " ", count: indent))
}