← poker-tutor
import Foundation
import PokerCore
/// Builds the table you sit down at.
///
/// This is where "the game keeps pace with you" actually happens, and it moves two dials rather
/// than one. As difficulty rises the individual opponents play better, *and* the loose
/// recreational players are replaced by competent ones. Turning up only the skill number would
/// give you eight identical robots; changing who is at the table is what makes a promotion feel
/// like walking into a different room.
public enum TableComposition {
public struct Assignment: Sendable {
public let name: String
public let personality: Personality
public let skill: Int
public init(name: String, personality: Personality, skill: Int) {
self.name = name
self.personality = personality
self.skill = skill
}
}
/// How likely each style is to appear, by difficulty.
static func weight(_ personality: Personality, difficulty: Int) -> Double {
let d = Double(difficulty.clamped(to: 0...100))
switch personality {
case .callingStation: return falloff(from: 30, at: d, width: 28)
case .loosePassive: return falloff(from: 32, at: d, width: 30)
case .maniac: return falloff(from: 28, at: d, width: 26) * 0.55
case .nit: return bump(centre: 35, at: d, width: 30)
case .tightAggressive: return bump(centre: 55, at: d, width: 30)
case .looseAggressive: return bump(centre: 68, at: d, width: 28) * 0.8
case .solidRegular: return rampUp(from: 45, at: d, width: 35)
case .trickyRegular: return rampUp(from: 70, at: d, width: 30)
}
}
private static func falloff(from peak: Double, at d: Double, width: Double) -> Double {
d <= peak ? 1.0 : max(0, 1 - (d - peak) / width)
}
private static func rampUp(from start: Double, at d: Double, width: Double) -> Double {
d <= start ? 0 : min(1, (d - start) / width)
}
private static func bump(centre: Double, at d: Double, width: Double) -> Double {
max(0, 1 - abs(d - centre) / width)
}
/// Picks the opponents for a table at the given difficulty.
public static func lineup(difficulty: Int, opponents: Int, rng: inout SeededRandom) -> [Assignment] {
let target = difficulty.clamped(to: 0...100)
var out: [Assignment] = []
for index in 0..<opponents {
let choices = Personality.allCases.map { ($0, weight($0, difficulty: target)) }.filter { $0.1 > 0.001 }
let total = choices.reduce(0) { $0 + $1.1 }
var pick = Double(rng.next() % 100_000) / 100_000.0 * total
var personality = choices.last?.0 ?? .solidRegular
for (candidate, weight) in choices {
if pick < weight { personality = candidate; break }
pick -= weight
}
// Spread skill around the target so a table has a texture rather than a single level.
let spread = Int(rng.next() % 21) - 10
let skill = (target + spread).clamped(to: personality.skillRange)
out.append(Assignment(name: name(for: personality, index: index), personality: personality, skill: skill))
}
return out
}
/// The opponents for a table whose cast has already been decided — a rung on the ladder.
///
/// `lineup(difficulty:)` above draws the styles from a number. A rung does the opposite: it
/// names the eight people, because that is what a rung *is*. The shootouts measured style as
/// worth roughly ten times what the skill dial is worth, so a promotion has to change who is
/// sitting there; turning up a number would give the player eight slightly better versions of
/// the same room.
///
/// The cast is fixed and the seating is not. The same eight kinds of player are shuffled into
/// different chairs every sitting and their skill is jittered a little around the rung's dial,
/// so two evenings at the same table are not the same evening twice.
public static func lineup(roster: [Personality], skill: Int, rng: inout SeededRandom) -> [Assignment] {
var styles = roster
for index in stride(from: styles.count - 1, to: 0, by: -1) {
let other = Int(rng.next() % UInt64(index + 1))
styles.swapAt(index, other)
}
let target = skill.clamped(to: 0...100)
return styles.enumerated().map { index, personality in
let spread = Int(rng.next() % 11) - 5
return Assignment(name: name(for: personality, index: index),
personality: personality,
skill: (target + spread).clamped(to: personality.skillRange))
}
}
/// A table with one of every kind of player at it, in a fixed order every time.
///
/// `lineup` draws the opponents at random around a difficulty, which is right for a game: two
/// evenings in a row should not feel identical. An assessment wants the opposite. It has to be
/// able to sit a player who raises constantly on your right when it needs to see you facing a
/// raise, and somebody who never does when it needs to see you opening a pot, so every style
/// has to actually be present — and the same eight people have to come back if the sitting is
/// picked up again tomorrow, which rules out drawing them.
///
/// Skill still follows the difficulty, pulled into whatever band each style can exist in: a
/// calling station is a losing style by definition and cannot be an expert one.
public static func spread(difficulty: Int, opponents: Int) -> [Assignment] {
let order: [Personality] = [
.maniac, .looseAggressive, .trickyRegular, .solidRegular,
.tightAggressive, .nit, .loosePassive, .callingStation,
]
let target = difficulty.clamped(to: 0...100)
return (0..<opponents).map { index in
let personality = order[index % order.count]
return Assignment(name: name(for: personality, index: index),
personality: personality,
skill: target.clamped(to: personality.skillRange))
}
}
private static let firstNames = [
"Ray", "Dana", "Marcus", "Jo", "Eddie", "Priya", "Sal", "Nina",
"Walt", "Kim", "Otto", "Reba", "Vic", "Lena", "Hal", "Mo",
]
/// A first name nobody at the table is using, for a player who sits down mid-session.
public static func spareName(notIn taken: Set<String>, rng: inout SeededRandom) -> String {
let free = firstNames.filter { !taken.contains($0) }
if !free.isEmpty { return free[Int(rng.next() % UInt64(free.count))] }
var n = 2
while taken.contains("\(firstNames[0]) \(n)") { n += 1 }
return "\(firstNames[0]) \(n)"
}
static func name(for personality: Personality, index: Int) -> String {
firstNames[index % firstNames.count]
}
/// The difficulty a table should be set to for a player of this rating, in the same 0-100
/// units. Slightly above the player, which is where improvement happens.
public static func difficulty(forRating rating: Int) -> Int {
(rating + 6).clamped(to: 0...100)
}
}