Polish table and clarify showdown results

This commit is contained in:
Jay
2026-07-26 21:46:28 -04:00
parent b8087a9872
commit df4d90f40b
9 changed files with 1625 additions and 277 deletions
@@ -115,15 +115,57 @@ fun interface PlayerAgent {
data class Pot(val amount: Int, val eligible: List<Int>)
/** One public hand turned face-up at showdown, with its authoritative engine score. */
data class ShowdownHand(
val seat: Int,
val hole: List<Int>,
val score: Int,
)
data class PotPayout(
val seat: Int,
val amount: Int,
)
/**
* One settled pot in main-pot then side-pot order.
*
* A flat winner list cannot explain a side-pot hand: two players may both be
* winners without having tied or winning the same chips. Keeping each award
* and its exact payouts makes the completed-hand UI able to say exactly who won
* what, including the odd chip in a split.
*/
data class PotAward(
val amount: Int,
val payouts: List<PotPayout>,
) {
init {
require(amount > 0)
require(payouts.isNotEmpty())
require(payouts.all { it.amount > 0 })
require(payouts.map { it.seat }.distinct().size == payouts.size)
require(payouts.sumOf { it.amount } == amount)
}
val winnerSeats: List<Int> get() = payouts.map(PotPayout::seat)
}
data class HandResult(
val board: IntArray,
/** Net chip change per seat for this hand. */
val net: IntArray,
val winners: List<Int>,
val wentToShowdown: Boolean,
val potSize: Int,
val events: List<HandEvent>,
)
/** Empty for an uncontested hand; contains only cards publicly revealed at showdown. */
val showdownHands: List<ShowdownHand>,
/** Main pot first, then side pots from the shortest contribution upward. */
val potAwards: List<PotAward>,
) {
/** Unique winning seats in main/side-pot award order. */
val winners: List<Int>
get() = potAwards.flatMap(PotAward::winnerSeats).distinct()
}
/**
* A no-limit Texas Hold'em table.
@@ -565,7 +607,8 @@ class Table(
}
val contenders = seats.filter { it.contesting }
val winners = ArrayList<Int>()
val showdownHands = ArrayList<ShowdownHand>()
val potAwards = ArrayList<PotAward>()
var wentToShowdown = false
val potTotal = pot()
@@ -573,7 +616,7 @@ class Table(
val w = contenders.first()
w.stack += potTotal
for (s in seats) s.committedThisHand = 0
winners.add(w.index)
potAwards.add(PotAward(potTotal, listOf(PotPayout(w.index, potTotal))))
} else {
wentToShowdown = true
// Cards are face up from here, so snapshots may show them.
@@ -583,7 +626,15 @@ class Table(
val seven = IntArray(7)
seven[0] = c.hole[0]; seven[1] = c.hole[1]
for (k in board.indices) seven[2 + k] = board[k]
scores[c.index] = HandEvaluator.evaluate(seven, 2 + board.size)
val score = HandEvaluator.evaluate(seven, 2 + board.size)
scores[c.index] = score
showdownHands.add(
ShowdownHand(
seat = c.index,
hole = c.hole.toList(),
score = score,
),
)
}
val levels = contenders.map { it.committedThisHand }.distinct().sorted()
@@ -610,11 +661,16 @@ class Table(
.filter { scores.getValue(it) == best }
.sortedBy { (it - button - 1 + seats.size) % seats.size }
val share = p.amount / potWinners.size
var remainder = p.amount - share * potWinners.size
for (w in potWinners) {
seats[w].stack += share
if (remainder > 0) { seats[w].stack += 1; remainder-- }
if (w !in winners) winners.add(w)
val remainder = p.amount - share * potWinners.size
val payouts = potWinners.mapIndexed { index, winner ->
PotPayout(
seat = winner,
amount = share + if (index < remainder) 1 else 0,
)
}
potAwards.add(PotAward(p.amount, payouts))
for (payout in payouts) {
seats[payout.seat].stack += payout.amount
}
}
for (s in seats) s.committedThisHand = 0
@@ -624,10 +680,11 @@ class Table(
return HandResult(
board = board.toIntArray(),
net = net,
winners = winners,
wentToShowdown = wentToShowdown,
potSize = potTotal,
events = ArrayList(events),
showdownHands = showdownHands,
potAwards = potAwards,
)
}
@@ -182,6 +182,19 @@ class TableRulesTest {
// Kings beat queens for the side pot.
assertTrue(result.net[1] > 0, "kings should win the side pot")
assertTrue(result.net[2] < 0, "queens should lose")
assertEquals(
listOf(
PotAward(150, listOf(PotPayout(0, 150))),
PotAward(300, listOf(PotPayout(1, 300))),
),
result.potAwards,
"presentation must retain who won the main and side pots separately",
)
assertEquals(
listOf(0, 1, 2),
result.showdownHands.map { it.seat },
"only publicly revealed contenders belong in showdown results",
)
}
@Test
@@ -239,6 +252,16 @@ class TableRulesTest {
assertEquals(3, result.net[2], "seat left of the button gets the odd chip")
assertEquals(2, result.net[0], "the other winner gets the smaller share")
assertEquals(-5, result.net[1], "the folded small blind loses its post")
assertEquals(
listOf(PotAward(25, listOf(PotPayout(2, 13), PotPayout(0, 12)))),
result.potAwards,
"award must preserve the exact odd-chip payout, not only the tied seats",
)
assertEquals(
listOf(0, 2),
result.showdownHands.map { it.seat },
"a folded player's private cards must not enter the public showdown result",
)
}
// ---------- uncalled bets ----------