games: UNO becomes a table you sit at, and the pot that pays whoever goes out first
Phase D backend: UNO is now a session like hold'em, not a single stake. You sit
with a buy-in stack, ante into a pot each hand, and leave with what's in front of
you. The engine lost its `You` constant and its measured multiples: ApplyMove
takes the acting seat, New takes a seat list, a Tier carries an ante instead of a
Base, and a hand settles by moving the pot to the winner (less rake, and never
when a bot takes it) rather than paying a multiple. A mercy kill puts a seat out
of the hand, not out of the game — the last one standing takes the pot.
The redaction moved to the web layer, where hold'em's already lives: the engine
now stamps every seat's hand onto its events, and viewUno/viewUnoEvents strip
everything that isn't the viewer's own. TestUnoViewNeverLeaksAnotherSeatsCards is
the wall. unoTable implements tableGame; /uno/{sit,move,leave,tables,stream,chat,
say} mirror hold'em, with stream/chat/say now shared game-agnostic handlers.
The frontend is not done: uno.js still calls the retired solo endpoint, so the
page renders but is not yet playable. All engine and web tests are green.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
@@ -33,32 +33,23 @@ func TestNoMercyDeckIsADeck(t *testing.T) {
|
||||
t.Errorf("%v %v: got %d, want %d", c.Color, c.Value, m[c], n)
|
||||
}
|
||||
}
|
||||
// The normal deck's wilds are not in this one, and its coloured +4 is not in
|
||||
// the normal one. They are different cards that print the same thing.
|
||||
if m[Card{Wild, WildCard}] != 0 || m[Card{Wild, WildDrawFour}] != 0 {
|
||||
t.Error("the No Mercy deck should print none of the normal wilds")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoMercyCensus is the load-bearing one, and the same one the normal game
|
||||
// has: 168 cards, each in exactly one place, checked after every move of a
|
||||
// hundred games played to the end.
|
||||
//
|
||||
// It is what would catch the two new ways this deck can lose a card. Discard All
|
||||
// buries a whole colour under the pile, and a mercy kill shovels a
|
||||
// twenty-five-card hand back into the deck — either of those dropping a card on
|
||||
// the floor is a deck that quietly shrinks until the table can't be dealt.
|
||||
// TestNoMercyCensus is the load-bearing one: 168 cards, each in exactly one place,
|
||||
// checked after every move of a hundred hands played to the end.
|
||||
func TestNoMercyCensus(t *testing.T) {
|
||||
for _, tier := range []Tier{nmDuel(), nmTable(), nmFull()} {
|
||||
for seed := uint64(0); seed < 100; seed++ {
|
||||
s := deal(t, tier, 100, seed)
|
||||
start := census(s)
|
||||
if got := total(start); got != 168 {
|
||||
s := deal(t, tier, seed)
|
||||
if got := total(census(s)); got != 168 {
|
||||
t.Fatalf("%s seed %d: dealt %d cards, want 168", tier.Slug, seed, got)
|
||||
}
|
||||
rng := rand.New(rand.NewPCG(seed, 99))
|
||||
for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ {
|
||||
next, _, err := ApplyMove(s, naive(s, rng))
|
||||
for moves := 0; s.playing() && moves < 800; moves++ {
|
||||
next, _, err := ApplyMove(s, You, naive(s, rng))
|
||||
if err != nil {
|
||||
t.Fatalf("%s seed %d: %v (phase %s)", tier.Slug, seed, err, s.Phase)
|
||||
}
|
||||
@@ -68,38 +59,31 @@ func TestNoMercyCensus(t *testing.T) {
|
||||
tier.Slug, seed, total(got))
|
||||
}
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("%s seed %d: game never ended", tier.Slug, seed)
|
||||
if s.playing() {
|
||||
t.Fatalf("%s seed %d: hand never ended", tier.Slug, seed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// naive is the strategy the multiples are priced against: play the first legal
|
||||
// card you hold, take a stack you can't answer, and draw when you have nothing.
|
||||
// It is a real way to play and a bad one, which is exactly what a house edge is
|
||||
// measured against.
|
||||
// naive is a bad-but-real strategy: play the first legal card, take a stack you
|
||||
// can't answer, and draw when you have nothing. Always played from the human seat.
|
||||
func naive(s State, rng *rand.Rand) Move {
|
||||
if s.Phase == PhaseStack {
|
||||
if p := s.Playable(); len(p) > 0 {
|
||||
if p := s.Playable(You); len(p) > 0 {
|
||||
return playMove(s, p[0], rng)
|
||||
}
|
||||
return Move{Kind: MoveTake}
|
||||
}
|
||||
if p := s.Playable(); len(p) > 0 {
|
||||
if p := s.Playable(You); len(p) > 0 {
|
||||
return playMove(s, p[0], rng)
|
||||
}
|
||||
return Move{Kind: MoveDraw}
|
||||
}
|
||||
|
||||
// stack loads a seat's hand up to n cards by taking them off the deck, so the
|
||||
// table still holds 168 of them. Every card it moves is one that can't be played
|
||||
// on the pile, which is what a hand on its way to the mercy limit looks like.
|
||||
// table still holds 168 of them.
|
||||
func stack(s *State, seat, n int) {
|
||||
// Every card the seat was holding goes back in the deck first, so the table is
|
||||
// whole before we take n out of it again. The pile keeps whatever the deal
|
||||
// turned over — replacing it with a card of our choosing would quietly destroy
|
||||
// one, and the census below would blame the engine for it.
|
||||
s.Deck = append(s.Deck, s.Hands[seat]...)
|
||||
s.Hands[seat] = nil
|
||||
s.Color = s.top().Color
|
||||
@@ -120,15 +104,7 @@ func playMove(s State, idx int, rng *rand.Rand) Move {
|
||||
if s.Hands[You][idx].IsWild() {
|
||||
m.Color = Red + Color(rng.IntN(4))
|
||||
}
|
||||
// It calls UNO. That is not a strategy, it is a button — the table puts it in
|
||||
// front of you and waits — and what these multiples price is *bad card play*,
|
||||
// not a player who ignores the felt shouting at them.
|
||||
//
|
||||
// A player who genuinely never calls loses far more than this one does, and
|
||||
// nothing here is priced for them. That is the rule having teeth, which is the
|
||||
// point of it. Naive is the floor for someone playing the game badly, not for
|
||||
// someone not playing it.
|
||||
for _, at := range s.UnoAt() {
|
||||
for _, at := range s.UnoAt(You) {
|
||||
if at == idx {
|
||||
m.Uno = true
|
||||
}
|
||||
@@ -136,13 +112,10 @@ func playMove(s State, idx int, rng *rand.Rand) Move {
|
||||
return m
|
||||
}
|
||||
|
||||
// TestAStackIsPassedOnAndPaidOnce walks the one rule the whole mode turns on: a
|
||||
// draw card doesn't land on you, it *opens a bill*, and the seat that can't
|
||||
// answer pays the whole thing.
|
||||
// TestAStackIsPassedOnAndPaid walks the rule the whole mode turns on: a draw card
|
||||
// opens a bill, and the seat that can't answer pays the whole thing.
|
||||
func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 100, 7)
|
||||
// Rig it: you hold a +2 on a red pile, the bot holds one card that can answer
|
||||
// and one that can't.
|
||||
s := deal(t, nmDuel(), 7)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
|
||||
@@ -150,9 +123,7 @@ func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
// You play the +2. The bot answers with its own, so the bill comes back to you
|
||||
// at four — and you have nothing to answer with, so you pay it.
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play +2: %v", err)
|
||||
}
|
||||
@@ -168,22 +139,15 @@ func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
||||
if !hasKind(evs, EvStack) {
|
||||
t.Error("no stack event: the felt has nothing to show the player")
|
||||
}
|
||||
// You cannot draw your way out of it, and you cannot play a card that isn't a
|
||||
// draw card.
|
||||
if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustStack {
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustStack {
|
||||
t.Errorf("drawing out of a stack: %v, want ErrMustStack", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack {
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack {
|
||||
t.Errorf("playing a plain card under a stack: %v, want ErrMustStack", err)
|
||||
}
|
||||
|
||||
// Pay it. The bot is left holding one card it cannot play, and — because No
|
||||
// Mercy makes it draw until it can — it will draw into a fresh hand and may
|
||||
// well open a *new* stack on the way. That's the game working, not a leak, so
|
||||
// what's asserted here is the bill this seat paid, not the state of the table
|
||||
// afterwards: four cards into the hand, and the bill discharged.
|
||||
before := len(next.Hands[You])
|
||||
paid, evs, err := ApplyMove(next, Move{Kind: MoveTake})
|
||||
paid, evs, err := ApplyMove(next, You, Move{Kind: MoveTake})
|
||||
if err != nil {
|
||||
t.Fatalf("take: %v", err)
|
||||
}
|
||||
@@ -199,54 +163,49 @@ func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
||||
if len(paid.Hands[You]) < before+4 {
|
||||
t.Errorf("hand went %d → %d, want at least four more", before, len(paid.Hands[You]))
|
||||
}
|
||||
// The bill you paid is gone. Anything pending now is a new stack the bot
|
||||
// opened after yours was settled, and it is never the one you just paid.
|
||||
if paid.Pending == 4 && paid.Phase == PhaseStack {
|
||||
t.Error("the bill you just paid is still standing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwentyFiveCardsKillsYou is the mercy rule, from the player's side: the
|
||||
// stake is gone the moment the hand hits the limit, whoever else is still playing.
|
||||
func TestTwentyFiveCardsKillsYou(t *testing.T) {
|
||||
s := deal(t, nmFull(), 100, 3)
|
||||
// Twenty-four cards in your hand, and a stack of ten pointed at you.
|
||||
//
|
||||
// The cards are *moved* from the deck, not invented: a fixture that conjures
|
||||
// a hand out of nothing breaks the census before the engine gets a chance to,
|
||||
// and then the census assertion below is testing the fixture instead of the
|
||||
// mercy rule.
|
||||
// TestMercyKillsThePlayerButNotTheSession: the mercy limit takes the human out of
|
||||
// the *hand* — the pot goes to whoever is left, and the table plays on. It is no
|
||||
// longer game over.
|
||||
func TestMercyKillsThePlayerButNotTheSession(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 3)
|
||||
stack(&s, You, 24)
|
||||
s.Turn = You
|
||||
s.Phase = PhaseStack
|
||||
s.Pending = 10
|
||||
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MoveTake})
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveTake})
|
||||
if err != nil {
|
||||
t.Fatalf("take: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvMercy) {
|
||||
t.Fatal("no mercy event: twenty-five cards should have killed the seat")
|
||||
}
|
||||
if next.Phase != PhaseDone || next.Outcome != OutcomeLost {
|
||||
t.Fatalf("phase %s outcome %q, want done/lost", next.Phase, next.Outcome)
|
||||
if next.playing() {
|
||||
t.Fatalf("the hand should be over once one seat is left: phase %s", next.Phase)
|
||||
}
|
||||
if next.Payout != 0 {
|
||||
t.Errorf("a mercy kill paid out %d, want nothing", next.Payout)
|
||||
if next.live(You) || len(next.Hands[You]) != 0 {
|
||||
t.Error("a dead seat should hold no cards and be out of the hand")
|
||||
}
|
||||
if len(next.Hands[You]) != 0 || next.live(You) {
|
||||
t.Error("a dead seat should hold no cards and be out of the game")
|
||||
// Heads-up, killing the human leaves the bot alone: it takes the pot.
|
||||
if next.Winner != 1 {
|
||||
t.Errorf("winner is seat %d, want the surviving bot", next.Winner)
|
||||
}
|
||||
if got := total(census(next)); got != 168 {
|
||||
t.Errorf("%d cards after a mercy kill, want 168 — the hand goes back in the deck", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutlivingTheTableWins is the other side of the mercy rule, and the one
|
||||
// that makes No Mercy pay less than it looks like it should: the deck buries bots
|
||||
// too, and a table with every bot dead is a table you have won.
|
||||
// TestOutlivingTheTableWins: the deck buries bots too, and a table with every bot
|
||||
// dead is a pot you have taken.
|
||||
func TestOutlivingTheTableWins(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 100, 11)
|
||||
s := deal(t, nmDuel(), 11)
|
||||
pot := s.Pot
|
||||
before := s.Seats[You].Stack
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
|
||||
@@ -257,63 +216,56 @@ func TestOutlivingTheTableWins(t *testing.T) {
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play +2: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvMercy) {
|
||||
t.Fatal("the bot should have died taking the stack")
|
||||
}
|
||||
if next.Phase != PhaseDone || next.Outcome != OutcomeWon {
|
||||
t.Fatalf("phase %s outcome %q, want done/won: the last seat standing wins",
|
||||
next.Phase, next.Outcome)
|
||||
if next.playing() || next.Winner != You {
|
||||
t.Fatalf("phase %s winner %d, want a finished hand won by you", next.Phase, next.Winner)
|
||||
}
|
||||
if next.Payout != next.Pays() {
|
||||
t.Errorf("paid %d, quoted %d — settle and the felt must agree", next.Payout, next.Pays())
|
||||
profit := pot - s.Tier.Ante
|
||||
wantWon := pot - int64(float64(profit)*rake)
|
||||
if next.Seats[You].Stack != before+wantWon {
|
||||
t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon)
|
||||
}
|
||||
}
|
||||
|
||||
// TestYouDrawUntilYouCanPlay: no drawing one card and shrugging. The turn only
|
||||
// moves on when the deck itself has nothing left.
|
||||
func TestYouDrawUntilYouCanPlay(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 100, 5)
|
||||
s := deal(t, nmDuel(), 5)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Blue, One}} // nothing playable
|
||||
// A deck whose first two cards are dead and whose third plays.
|
||||
s.Deck = []Card{{Green, Two}, {Yellow, Three}, {Red, Nine}, {Blue, Four}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if len(next.Hands[You]) != 4 {
|
||||
t.Fatalf("hand is %d, want 4: you draw until something plays",
|
||||
len(next.Hands[You]))
|
||||
t.Fatalf("hand is %d, want 4: you draw until something plays", len(next.Hands[You]))
|
||||
}
|
||||
if next.Phase != PhaseDrawn {
|
||||
t.Fatalf("phase %s, want drawn: the card you stopped on is one you must play",
|
||||
next.Phase)
|
||||
t.Fatalf("phase %s, want drawn: the card you stopped on is one you must play", next.Phase)
|
||||
}
|
||||
// And you may not pass on it: you drew for it, you play it.
|
||||
if _, _, err := ApplyMove(next, Move{Kind: MovePass}); err != ErrMustPlayNow {
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MovePass}); err != ErrMustPlayNow {
|
||||
t.Errorf("passing in No Mercy: %v, want ErrMustPlayNow", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkipAllComesBackToYou — everyone else loses their turn, so the turn never
|
||||
// actually leaves the seat that played it.
|
||||
func TestSkipAllComesBackToYou(t *testing.T) {
|
||||
s := deal(t, nmFull(), 100, 13)
|
||||
s := deal(t, nmFull(), 13)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, SkipAll}, {Blue, One}}
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play skip-all: %v", err)
|
||||
}
|
||||
@@ -325,10 +277,8 @@ func TestSkipAllComesBackToYou(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscardAllTakesTheColourWithIt, and the cards it takes are still in the
|
||||
// game — buried under the pile, not deleted.
|
||||
func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 100, 17)
|
||||
s := deal(t, nmDuel(), 17)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Blue, Two}}
|
||||
@@ -336,22 +286,26 @@ func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
|
||||
s.Phase = PhasePlay
|
||||
before := total(census(s))
|
||||
|
||||
// With the call, because this play takes three reds out of the hand at once and
|
||||
// lands on exactly one card — which is precisely the case a browser counting
|
||||
// "hand length minus one" would miss, and the reason UnoAt is the engine's job.
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Uno: true})
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Uno: true})
|
||||
if err != nil {
|
||||
t.Fatalf("play discard-all: %v", err)
|
||||
}
|
||||
if len(next.Hands[You]) != 1 {
|
||||
t.Fatalf("hand is %d, want 1: every red should have gone with it",
|
||||
len(next.Hands[You]))
|
||||
t.Fatalf("hand is %d, want 1: every red should have gone with it", len(next.Hands[You]))
|
||||
}
|
||||
if next.Hands[You][0] != (Card{Blue, Two}) {
|
||||
t.Errorf("kept %v, want the blue two", next.Hands[You][0])
|
||||
}
|
||||
if top := next.Top(); top.Value != DiscardAll {
|
||||
t.Errorf("the card in play is %v, want the discard-all that was played", top.Value)
|
||||
// The discard-all was played, so it is on the pile — the bot may have played over
|
||||
// it since, which is why this looks for it rather than at the very top.
|
||||
found := false
|
||||
for _, c := range next.Discard {
|
||||
if c.Value == DiscardAll {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the discard-all isn't on the pile")
|
||||
}
|
||||
if !hasKind(evs, EvDiscardAll) {
|
||||
t.Error("no discard event")
|
||||
@@ -361,9 +315,8 @@ func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouletteFlipsUntilTheColour — and the victim keeps every card it turned.
|
||||
func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 100, 19)
|
||||
s := deal(t, nmDuel(), 19)
|
||||
s.Color = Blue
|
||||
s.Discard = []Card{{Blue, Five}}
|
||||
s.Hands[You] = []Card{{Wild, WildRoulette}, {Blue, One}}
|
||||
@@ -372,8 +325,7 @@ func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
||||
s.Turn = You
|
||||
s.Phase = PhasePlay
|
||||
|
||||
// Name red: the bot flips blue, green, yellow, red — four cards — and keeps them.
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Red})
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Red})
|
||||
if err != nil {
|
||||
t.Fatalf("play roulette: %v", err)
|
||||
}
|
||||
@@ -386,8 +338,6 @@ func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
||||
if got != 4 {
|
||||
t.Errorf("flipped %d, want 4 — up to and including the first red", got)
|
||||
}
|
||||
// One card it started with, plus the four it turned. (The bot is then skipped,
|
||||
// so the turn is back with you and it never played any of them.)
|
||||
if n := len(next.Hands[1]); n != 5 {
|
||||
t.Errorf("the bot holds %d, want 5", n)
|
||||
}
|
||||
@@ -395,43 +345,3 @@ func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
||||
t.Error("the roulette lost a card")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheMultiplesAreStillPriced measures the naive strategy against the bots and
|
||||
// checks each tier still charges roughly the house's edge for it.
|
||||
//
|
||||
// This is the test that fails when somebody changes the bots, the deck, or a
|
||||
// rule, and it is *supposed* to: the tier and the game it prices are a pair. If
|
||||
// this goes red, re-measure and move the number, don't loosen the bound.
|
||||
func TestTheMultiplesAreStillPriced(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("slow: plays thousands of games")
|
||||
}
|
||||
for _, tier := range AllTiers() {
|
||||
wins, games := 0, 3000
|
||||
for seed := 0; seed < games; seed++ {
|
||||
s := deal(t, tier, 100, uint64(seed)+7777)
|
||||
rng := rand.New(rand.NewPCG(uint64(seed), 4242))
|
||||
for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ {
|
||||
next, _, err := ApplyMove(s, naive(s, rng))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tier.Slug, err)
|
||||
}
|
||||
s = next
|
||||
}
|
||||
if s.Outcome.Won() {
|
||||
wins++
|
||||
}
|
||||
}
|
||||
p := float64(wins) / float64(games)
|
||||
// What a staked chip comes back as, playing badly: you win p of the time and
|
||||
// keep the multiple less the rake on the profit, and lose the stake the rest.
|
||||
ev := p*(1+(tier.Base-1)*(1-rake)) - 1
|
||||
t.Logf("%-8s bots=%d base=%.2f naive win rate %.1f%% house edge %.1f%%",
|
||||
tier.Slug, tier.Bots, tier.Base, p*100, -ev*100)
|
||||
if ev < -0.14 || ev > -0.02 {
|
||||
t.Errorf("%s: the house edge on naive play is %.1f%%, which is outside the 2–14%% "+
|
||||
"band the tiers are priced to. Re-measure Base: %.2f would put it near 8%%.",
|
||||
tier.Slug, -ev*100, (0.92/p-1)/(1-rake)+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user