Advertisement

← poker-tutor

Sources/PokerAI/TableComposition.swift

7.3 KB Download file
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)
    }
}