games: no mercy, and the multiples nobody re-measured

No Mercy UNO as a rules dial on the existing tier, not a fourth table: 168 cards,
draw-until-playable, draw-stacking, and the twenty-five card mercy kill. Six
tiers now; a normal game never runs a line of the new code.

The engine is the whole of it so far — the felt hasn't been touched, so there is
no way to play this in a browser yet.

Two things worth knowing.

The normal tiers were mispriced, and had been for a while. They were set against
a naive win rate of 43/32/27%; it now measures 40.3/29.2/23.3%. The bots got
better at some point after the multiples were written down and nobody re-ran the
measurement — which the plan explicitly warns about, because the bots and the
tiers are a pair. Table and Full House had been charging an 18–19% house edge
instead of the 8% they were meant to. All six tiers are repriced off a fresh
measurement, and TestTheMultiplesAreStillPriced now fails the build if they
drift again. It is the test the normal tiers never had, which is how they drifted.

And No Mercy is *easier* than UNO, at every table size, so it pays less. The
mercy rule does not care whose hand hits twenty-five: it kills bots too, and
every bot it buries is one fewer seat that can beat you to the last card. A deck
built to be merciless turns out to be merciless mostly to the table.

The rake test used to assert a payout of 214, which was the 2.2x duel written
down as a number. It failed on a rake that was entirely correct. It derives the
arithmetic from the tier now: the rule is that the house takes its cut of the
profit and never touches the stake, and that holds at any multiple.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
prosolis
2026-07-14 10:07:55 -07:00
parent 4bc38859d4
commit aca523e511
5 changed files with 1092 additions and 61 deletions

View File

@@ -93,6 +93,64 @@ func botRank(hand []Card, topColor Color, playable []int, minOpponent int) []int
return out
}
// botStack answers a stack, or reports -1 when the bot has nothing to answer it
// with and has to eat the lot.
//
// It plays the *smallest* draw card it holds. The bill is passed on either way —
// what it is passing on is the stack plus whatever it added — so the cheap card
// does the same job as the expensive one, and keeps the +10 in hand for a turn
// when the bot is the one choosing to hurt somebody rather than the one dodging.
//
// The slip is here too: one time in six it reaches for the second-smallest, so a
// player can't read the stack it just passed as a complete inventory of what the
// bot doesn't have.
func botStack(hand []Card, topColor Color, rng *rand.Rand) (Card, int) {
var can []int
for i, c := range hand {
if c.CanStackOn(topColor) {
can = append(can, i)
}
}
if len(can) == 0 {
return Card{}, -1
}
// Smallest draw first. A stable insertion sort: there are never many.
for i := 1; i < len(can); i++ {
for j := i; j > 0 && hand[can[j]].Value.Draw() < hand[can[j-1]].Value.Draw(); j-- {
can[j], can[j-1] = can[j-1], can[j]
}
}
pick := can[0]
if len(can) > 1 && rng.IntN(botSlip) == 0 {
pick = can[1]
}
return hand[pick], pick
}
// botRouletteColor names the colour for a roulette: whichever the bot holds
// *least* of. The victim flips until that colour turns up, so the rarer the
// colour, the longer they flip and the more they keep. Naming the colour you're
// long in is naming the one that ends the flipping soonest, which is mercy — and
// this is not that game.
func botRouletteColor(hand []Card, rng *rand.Rand) Color {
counts := [5]int{}
for _, c := range hand {
if c.Color.Playable() {
counts[c.Color]++
}
}
best, bestN := Wild, 1<<30
for col := Red; col <= Green; col++ {
if counts[col] < bestN {
best, bestN = col, counts[col]
}
}
if best == Wild {
return Red + Color(rng.IntN(4))
}
return best
}
// botColor names a colour for a wild: whichever the bot holds most of, so the
// card it plays next is one it already has. A hand of nothing but wilds picks
// at random rather than always saying red, which would be a tell.