Solving Mississippi Marbles: Exact Expected Value and Selective Dice Retention

Solving Mississippi Marbles: Exact Expected Value and Selective Dice Retention

JO

Jonathan Lamontagne-Kratz

14 min read

Abstract: This analysis models the exact expected value of every decision point in Mississippi Marbles by replacing the manual, error-prone combinatorial partitioning from my high school Internal Assessment (IA) with exact state-space enumeration (6n6^n outcome iteration) and fixed-point value iteration in Python. We derive the exact expected values for different actions during a turn of Mississippi Marbles, and apply them to the original strategy I suggested in my IA.

Author's Note: This blog post is adapted from my International Baccalaureate (IB) SL Mathematics Internal Assessment (IA). Full dataset tables and complete Python script excerpts from the repository package are included in the appendices at the bottom of this article.

Try the Interactive Lab: Want to play Mississippi Marbles against the strategy from this article? Launch the Mississippi Marbles Interactive Solver Lab.

Developing a baseline Mississippi Marbles strategy requires evaluating the exact per-turn expected value of every decision point using state-space enumeration and Expectimax dynamic programming. The need for this analysis grew out of a casual game of Mississippi Marbles with my grandfather that turned into a heated debate about dice probability. I play to win, so I attempted to mathematically solve the game. In high school, I wrote my IB Mathematics Internal Assessment on this topic, but my original manual calculations suffered from double-counting overlaps and artificial simulation busting.

By revisiting this problem with dynamic programming, this analysis provides the corrected expected values and updates the strategy I originally suggested.

Game Overview & Rules

Already know how to play? Skip straight to the math.

Before diving into the mathematics, it is important to understand the fundamental rules and official scoring system of Mississippi Marbles (sourced from A Bit Dicey):

  • Equipment: The game uses six 6-sided dice.
  • Objective ("The Delta"): The first player to reach or surpass 11,000 points wins the game.
  • Opening Threshold ("Makin' Mud"): A player must score at least 700 points in a single turn to officially open ("get on the board").
    • Until a player has officially opened, they cannot bank points or piggyback off passed scores. If a player scores all 6 dice before reaching 700 points, they must continue rolling ("hot hand") until they reach 700+ points.
    • If an unopened player rolls a non-scoring roll (bust) before reaching 700 points, they earn 0 points and must try again on their next turn. Once officially opened, a player can bank points after any scoring roll on subsequent turns.
  • Turn Flow & Hot Hand:
    • On a turn, a player rolls the available dice. Any scoring combination must be thrown on a single roll.
    • If a roll produces no scoring dice, the player busts and loses all unbanked points accumulated during that turn.
    • If all 6 dice become scoring dice in a turn, the player earns a "hot hand" and must roll all 6 dice again, adding to their running total.
  • Penalty Rule ("A Flood"):
    • If at any time a player rolls 4 twos on a single roll, they trigger A Flood! The player loses ALL total points acquired in the entire game (resetting their score to 0) and must start over on their next turn by reopening with 700 points ("Makin' Mud").

Game Variant Note (Piggy-Back Rule): In this analysis and interactive solver, we implement the Piggy-Back variant defined in the official rules. When a player banks points and passes their turn, the preceding player keeps all points they earned. The next player in line may choose to Piggyback (accepting the remaining dice and passed turn score) or Start Fresh with 6 dice and 0 passed points. Note that a player must already be "on the board" (officially opened with 700+ points) to be eligible to piggyback.

Official Scoring Table

CombinationTraditional TermPoint Value
Single 1100 pts
Single 550 pts
3 OnesSmooth Water500 pts
3 TwosSmooth Water200 pts
3 ThreesSmooth Water300 pts
3 FoursSmooth Water400 pts
3 FivesSmooth Water500 pts
3 SixesSmooth Water600 pts
4 of a Kind (except Twos)Ridin' the Rapids1,000 pts
Straight (1-2-3-4-5-6)Big Muddy2,000 pts
5 of a KindChannel Cat3,000 pts
6 of a KindAll the Marbles6,000 pts
4 TwosA FloodReset Total Game Score to 0
Opening ThresholdMakin' Mud700 pts in a single turn
Winning ScoreThe Delta11,000 pts

Single-Roll Probability Calculations

To determine optimal decisions, we first calculate the expected scoring values and probabilities for a single roll across different remaining dice counts (n=16n = 1 \dots 6). For the purpose of this analysis, we rely on two core statistical tools:

  • Expected Value E(X)E(X): The weighted average of all possible outcomes: E(X)=(X×p)E(X) = \sum (X \times p) In the context of dice games, E(X)E(X) gives us the average score we can expect from a single roll the baseline number that tells us whether rolling again is mathematically worth the risk.
  • Exact State-Space Enumeration: Instead of manually computing multinomial coefficients and subtraction rules across multi-category overlaps (which caused double-counting errors in my original high school IA), we iterate through all 6n6^n outcomes directly in Python using multinomial count vectors c=(c1,c2,c3,c4,c5,c6)\mathbf{c} = (c_1, c_2, c_3, c_4, c_5, c_6). Concretely, we enumerate the (n+55)\binom{n+5}{5} count vectors (each weighted by its multinomial probability n!/ici!n!/\prod_i c_i!), which is exactly equivalent to summing over all 6n6^n outcomes.

Notation & Precision:

  • Probabilities are calculated to 5 decimal places to ensure exact convergence.
  • Expected values are expressed in floating-point precision rounded to 3 decimal places.

Single-Roll Expected Values & Category Breakdowns

For a single die (n=1n = 1), the only scoring outcomes are rolling a 1 (100 pts) or a 5 (50 pts), each with probability p=1/6p = 1/6: E(1 Die)=100×16+50×16=25.000 ptsE(1\text{ Die}) = 100 \times \frac{1}{6} + 50 \times \frac{1}{6} = 25.000\text{ pts}

As nn increases, three-of-a-kind sets, straights, and multi-set combinations emerge.

Table 1: Exact Single-Roll Expected Values (Theoretical vs Monte Carlo)

Remaining Dice (nn)Total State Space (6n6^n)Exact Expected Value E(X)E(X)Monte Carlo (10M Trials)% Difference
1625.00025.0130.051%
23650.00050.0120.025%
321684.49184.5530.073%
41,296135.802135.8320.022%
57,776210.487210.6380.072%
646,656343.643343.7140.021%

(Source code for exact enumeration and Monte Carlo validation is provided in Appendix A).


Infinite-Roll Expected Values (Expectimax Formulation)

Single-roll expectations assume a player rolls once and immediately banks. In actual gameplay, a player may continue rolling remaining un-scored dice, or reset to 6 fresh dice upon scoring all dice ("hot hand").

We model this decision tree as an Expectimax Dynamic Program. Let V(n)V(n) represent the expected total score achievable from nn dice under a given keep policy:

V(n)=rΩnP(r)T(r,n)V(n) = \sum_{r \in \Omega_n} P(r) \cdot T(r, n)

Where rΩnr \in \Omega_n is a face-count outcome vector, P(r)P(r) is its multinomial probability, and T(r,n)T(r, n) is the state transition value:

  • Greedy Policy: The player automatically keeps every scoring die. If points are scored, T(r,n)=Points+V(DiceLeft)T(r, n) = \text{Points} + V(\text{DiceLeft}).
  • Optimal Policy: The player chooses the legal action aA(r)a \in A(r) maximizing immediate score plus continuation value: T(r,n)=maxaA(r)[Score(a)+V(DiceLeft(a))]T^*(r, n) = \max_{a \in A(r)} \left[ \text{Score}(a) + V^*(\text{DiceLeft}(a)) \right]

When all dice score (DiceLeft=0\text{DiceLeft} = 0), a "hot hand" occurs, resetting the dice count to 6 and adding V(6)V(6) recursively. We solve for V(16)V(1 \dots 6) using fixed-point value iteration until convergence (Δ<1012\Delta < 10^{-12}).

Table 2: Roll-Until-Bust Expected Scores (Greedy vs Optimal)

Dice Count (nn)Base Greedy V(n)V(n)Monte Carlo (10M Trials)Optimized Selective V(n)V^*(n)Gain from Optimization
1250.265250.427267.875+17.610
2236.317236.482250.014+13.697
3282.679282.638295.615+12.936
4371.620371.873384.981+13.361
5493.256493.021518.464+25.208
6675.794675.710728.624+52.830

(Source code for exact recursive solvers is provided in Appendix B).


Refining Selection Rules (Selective Keeping)

The most counter-intuitive finding from our Expectimax solver is that greedily banking all scoring dice lowers total expected return.

When evaluating selective keeping choices, the Total Expected Value of a decision aa is calculated by summing the locked-in score being kept and the continuation expectation of the remaining dice:

Total EV(a)=Score(a)+V(DiceLeft(a))\text{Total EV}(a) = \text{Score}(a) + V^*(\text{DiceLeft}(a))

Practical Example: Rolling a 1 and a 5 with 5 Dice

Suppose a player rolls [1, 5, 2, 3, 4] on 5 dice:

  • Option A (Greedy): Keep both 1 & 5 (150 pts kept, 3 dice remaining to roll) Total EV=150+V(3)=150+295.615=445.615 pts\text{Total EV} = 150 + V^*(3) = 150 + 295.615 = \mathbf{445.615\text{ pts}}

  • Option B (Selective): Keep ONLY the 1 (100 pts kept, 4 dice remaining to roll) Total EV=100+V(4)=100+384.981=484.981 pts\text{Total EV} = 100 + V^*(4) = 100 + 384.981 = \mathbf{484.981\text{ pts}}

Because 484.981>445.615484.981 > 445.615, setting aside only the 1 and rolling 4 remaining dice yields a +39.366 point net gain in expected value compared to keeping both scoring dice!

Master Selective Retention Rules

  1. 1 to 4 Dice: Always keep every scoring die.
  2. 5 Dice:
    • If you roll a 1 and a 5, keep only the 1 (leaves 4 dice instead of 3; EV=484.981\text{EV} = 484.981).
    • If you roll two 5s, keep only one 5 (leaves 4 dice instead of 3; EV=434.981\text{EV} = 434.981).
    • If you roll a 1 and two 5s, keep only the 1 (leaves 4 dice instead of 2; EV=484.981\text{EV} = 484.981).
  3. 6 Dice:
    • Keep as few single 5s as possible when no three-of-a-kind combinations are present.
    • Skip three 2s if you also rolled a single 1 (keeping only the 1 leaves 5 dice; EV=618.464\text{EV} = 618.464).

(Full representative decision scripts are provided in Appendix C).


Game Theory: Piggybacking & Opponent Passing

In Mississippi Marbles, turns are linked across players via the Piggyback Rule.

1. Optimal Piggybacking Thresholds

A player should choose to piggyback off passed points and remaining dice if the passed score plus continuation value exceeds the value of starting fresh with 6 dice (V(6)728.624 ptsV^*(6) \approx 728.624\text{ pts}):

Passed Score+V(Passed Dice)V(6)728.624\text{Passed Score} + V^*(\text{Passed Dice}) \ge V^*(6) \approx 728.624

Minimum Passed Score=max(0,ceil50(V(6)V(Passed Dice)))\text{Minimum Passed Score} = \max\left(0, \text{ceil}_{50}\left(V^*(6) - V^*(\text{Passed Dice})\right)\right)

Here ceil50\text{ceil}_{50} rounds up to the smallest multiple of 50 that satisfies the inequality, so the threshold never falls below the break-even point (rounding to the nearest 50 would let a neighbor piggyback a passed score whose continuation value is actually lower than starting fresh).

Table 3: Minimum Passed Score Required to Piggyback

Remaining Dice ReceivedV(Dice)V^*(\text{Dice})Unrounded Need (V(6)V(d)V^*(6) - V^*(d))Minimum Passed Score to Piggyback
1 Die267.875460.750500 pts
2 Dice250.014478.611500 pts
3 Dice295.615433.010450 pts
4 Dice384.981343.644350 pts
5 Dice518.464210.160250 pts
6 Dice728.6240.0000 pts (Always Piggyback)

2. Stopping & Passing Thresholds

Passing a high score with many dice gives your neighbor a statistical advantage. The official passing thresholds dictate when a player should stop rolling and bank points:

Table 4: Passing / Stopping Matrix

Remaining Dice PassedOfficial Stop & Pass LimitApprox. Single-Step EV Optimal Limit
1 Die600 pts150 pts
2 Dice600 pts164 pts
3 Dice700 pts221 pts
4 Dice1,900 pts321 pts
5 Dice5,500 pts458 pts
6 Dice13,000+ pts (Never Pass 6 Dice)656 pts

(Piggybacking and passing threshold algorithms are provided in Appendix D).


Summary of the Mathematically Optimal Strategy

Master Cheat Sheet for Mississippi Marbles:

  1. Stopping / Passing:
    • Pass with 1 or 2 dice if score 600\ge 600 pts.
    • Pass with 3 dice if score 700\ge 700 pts.
    • Pass with 4 dice if score 1,900\ge 1,900 pts.
    • Pass with 5 dice if score 5,500\ge 5,500 pts.
    • Never pass 6 dice (unless banking secures an immediate win).
  2. Piggybacking:
    • Piggyback 1 die if passed score 500\ge 500 pts.
    • Piggyback 2 dice if passed score 500\ge 500 pts.
    • Piggyback 3 dice if passed score 450\ge 450 pts.
    • Piggyback 4 dice if passed score 350\ge 350 pts.
    • Piggyback 5 dice if passed score 250\ge 250 pts.
    • Always piggyback 6 dice.
  3. Selective Dice Retention:
    • 1–4 Dice: Take all scoring dice.
    • 5 Dice: Keep 1 over 5; keep only one 5 when a choice exists.
    • 6 Dice: Minimize holding single 5s and non-essential sets to preserve dice count for future rolls.

Discussion & Limitations

While exact Expectimax dynamic programming solves the stationary expected-value baseline, real gameplay introduces non-stationary constraints:

  • Asymmetric Opponent Behavior: If opponents frequently pass sub-optimal scores (e.g., passing low point totals with 4 or 5 dice remaining), piggybacking thresholds shift dynamically to exploit their errors.
  • Endgame Positioning & Score Deficits: Near the 11,000-point winning threshold, maximizing expected value per turn (E(X)E(X)) becomes secondary to maximizing win probability P(Win)P(\text{Win}). A player far behind must take high-variance risks, whereas a leading player should bank small scores to deny opponents additional turns.
  • Next Steps (Reinforcement Learning): To model dynamic, score-dependent strategies and opponent profiling, we transition from stationary Expectimax to deep Reinforcement Learning in our follow-up research post.

Conclusion & Resources

By revisiting my high school math internal assessment with exact state-space enumeration in Python, we eliminated manual double-counting errors and derived provably correct expected values for Mississippi Marbles. The key insight remains that greedily banking every point lowers expected return, and adopting a selective retention policy yields 728.6\approx 728.6 expected points per turn.


Appendices: Python Script Excerpts

The scripts below are excerpts from the mississippi-marbles repository package: they import the mississippi_marbles module (implementing the scoring table in scoring.py and the exact solvers in ev.py) rather than being self-contained. Each shows its real output; Appendix D is shown with the corrected ceiling rounding described above.

Appendix A: Python Code for Single-Roll EV

Excerpt: scripts/single_roll_ev.py
"""Table 1: single-roll expected values (exact + Monte Carlo).

Also prints a per-category probability/EV breakdown for every dice count.
Usage:
    python scripts/single_roll_ev.py [--trials N] [--seed S]
"""

import argparse
import os
import random
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from mississippi_marbles.ev import count_vector_outcomes, expand_counts
from mississippi_marbles.scoring import SINGLE_VALUE, SET_VALUE, score_roll


def exact_single_roll(n, flood=False):
    total = 0.0
    for vector, prob in count_vector_outcomes(n):
        points, _, status = score_roll(expand_counts(vector), flood)
        if status not in ("bust", "flood"):
            total += prob * points
    return total


def monte_carlo_single_roll(n, trials, flood=False, rng=None):
    rng = rng if rng is not None else random.Random()
    total = 0
    for _ in range(trials):
        dice = [rng.randint(1, 6) for _ in range(n)]
        points, _, status = score_roll(dice, flood)
        if status not in ("bust", "flood"):
            total += points
    return total / trials


def breakdown(vector):
    """Return (label, points) for the greedy score of a count vector."""
    counts = list(vector)
    n = sum(counts)
    if n == 6 and counts == [1, 1, 1, 1, 1, 1]:
        return "Straight", 2000
    parts = []
    singles = 0
    for face in range(1, 7):
        count = counts[face - 1]
        if face in SINGLE_VALUE:
            if count >= 3:
                parts.append((face, SET_VALUE[face][count]))
            elif count > 0:
                singles += SINGLE_VALUE[face] * count
        elif count >= 3:
            parts.append((face, SET_VALUE[face][count]))
    if singles == 0 and not parts:
        return "Bust", 0
    if parts:
        parts.sort(key=lambda item: item[1], reverse=True)
        face, pts = parts[0]
        label = f"{['1s', '2s', '3s', '4s', '5s', '6s'][face - 1]} x3+"
        return label, singles + sum(p for _, p in parts)
    return "Singles (1s/5s)", singles


def category_table(n, flood=False):
    table = {}
    for vector, prob in count_vector_outcomes(n):
        label, points = breakdown(vector)
        row = table.setdefault(label, [0.0, 0.0])
        row[0] += prob
        row[1] += prob * points
    return table


def main():
    parser = argparse.ArgumentParser(description="Single-roll expected values.")
    parser.add_argument("--trials", type=int, default=10_000_000)
    parser.add_argument("--seed", type=int, default=0)
    parser.add_argument(
        "--flood", action="store_true", help="enable the 4-twos Flood penalty"
    )
    args = parser.parse_args()

    rng = random.Random(args.seed)

    print("TABLE 1: SINGLE-ROLL EXPECTED VALUES")
    print(f"{'Dice':>5} {'Exact':>10} {'Monte Carlo':>14} {'% Diff':>10}")
    for n in range(1, 7):
        exact = exact_single_roll(n, args.flood)
        mc = monte_carlo_single_roll(n, args.trials, args.flood, rng)
        pct = abs(mc - exact) / exact * 100
        print(f"{n:>5} {exact:>10.3f} {mc:>14.3f} {pct:>9.3f}%")

    print()
    print("SCORING CATEGORY BREAKDOWNS (probability, expected value)")
    for n in range(1, 7):
        print(f"\n{n} dice:")
        for label, (prob, contrib) in sorted(category_table(n, args.flood).items()):
            print(f"  {label:<16} p={prob:.5f}  EV contribution={contrib:.3f}")


if __name__ == "__main__":
    main()
# Output (table only):
# TABLE 1: SINGLE-ROLL EXPECTED VALUES
#  Dice      Exact    Monte Carlo     % Diff
#     1     25.000         25.013     0.051%
#     2     50.000         50.012     0.025%
#     3     84.491         84.553     0.073%
#     4    135.802        135.832     0.022%
#     5    210.487        210.638     0.072%
#     6    343.643        343.714     0.021%

Appendix B: Python Code for Recursive EV

Excerpt: scripts/recursive_ev.py
"""Tables 2 & 3: greedy "roll until bust" expected scores (exact + Monte Carlo).

Simulates a turn where the bot keeps every scoring die and keeps rolling until
it busts (scoring all 6 dice triggers the hot hand reset to 6 dice). Exact
values come from the recursive solver; the Monte Carlo run uses the same rules.
Usage:
    python scripts/recursive_ev.py [--trials N] [--seed S]
"""

import argparse
import os
import random
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from mississippi_marbles.ev import count_vector_outcomes, expected_value, expand_counts
from mississippi_marbles.scoring import score_roll


def simulate_turn(n, rng, flood=False):
    score = 0
    dice = n
    while True:
        roll = [rng.randint(1, 6) for _ in range(dice)]
        points, left, status = score_roll(roll, flood)
        if status in ("bust", "flood"):
            return score
        score += points
        dice = 6 if left == 0 else left


def monte_carlo_recursive(n, trials, flood=False, rng=None):
    rng = rng if rng is not None else random.Random()
    total = 0
    for _ in range(trials):
        total += simulate_turn(n, rng, flood)
    return total / trials


def first_roll_breakdown(ev_greedy):
    """First-roll outcomes for a turn starting with n dice."""
    rows = {}
    for n in range(1, 7):
        table = {}
        for vector, prob in count_vector_outcomes(n):
            dice = expand_counts(vector)
            points, left, status = score_roll(dice)
            if status in ("bust", "flood"):
                label = "Bust"
                continuation = 0.0
            else:
                label = f"{points} pts / {left} dice left"
                continuation = ev_greedy[6] if left == 0 else ev_greedy[left]
            row = table.setdefault(label, [0.0, 0.0])
            row[0] += prob
            row[1] += prob * (points + continuation)
        rows[n] = table
    return rows


def main():
    parser = argparse.ArgumentParser(description="Roll-until-bust expected scores.")
    parser.add_argument("--trials", type=int, default=10_000_000)
    parser.add_argument("--seed", type=int, default=0)
    parser.add_argument(
        "--flood", action="store_true", help="enable the 4-twos Flood penalty"
    )
    args = parser.parse_args()

    rng = random.Random(args.seed)
    exact = expected_value("greedy", args.flood)

    print("TABLES 2 & 3: EXPECTED SCORE WHEN ROLLING UNTIL BUST (greedy)")
    print(f"{'Dice':>5} {'Exact':>10} {'Monte Carlo':>14} {'% Diff':>10}")
    for n in range(1, 7):
        mc = monte_carlo_recursive(n, args.trials, args.flood, rng)
        pct = abs(mc - exact[n]) / exact[n] * 100
        print(f"{n:>5} {exact[n]:>10.3f} {mc:>14.3f} {pct:>9.3f}%")

    print()
    print("FIRST-ROLL BREAKDOWNS (probability, net EV)")
    for n, table in first_roll_breakdown(exact).items():
        print(f"\n{n} dice:")
        for label, (prob, net) in sorted(table.items(), key=lambda item: -item[1][0]):
            print(f"  {label:<24} p={prob:.5f}  net EV={net:.3f}")


if __name__ == "__main__":
    main()
# Output (table only):
# TABLES 2 & 3: EXPECTED SCORE WHEN ROLLING UNTIL BUST (greedy)
#  Dice      Exact    Monte Carlo     % Diff
#     1    250.265        250.427     0.065%
#     2    236.317        236.482     0.070%
#     3    282.679        282.638     0.014%
#     4    371.620        371.873     0.068%
#     5    493.256        493.021     0.048%
#     6    675.794        675.710     0.012%

Appendix C: Python Code for Selective Keeping

Excerpt: scripts/selective_keeping.py
"""Table 4: optimized selective-retention expected values.

Compares the greedy keep-everything bot against the optimal policy that picks
the best legal keep at every roll, then prints the blog's representative
decisions and confirms the distilled 5-dice / 6-dice retention rules.
Usage:
    python scripts/selective_keeping.py
"""

import os
import sys
from itertools import product

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from mississippi_marbles.ev import expected_value
from mississippi_marbles.scoring import counts_of, face_options

FACE_NAME = ["", "1s", "2s", "3s", "4s", "5s", "6s"]

REPRESENTATIVE_ROLLS = [
    ("5 dice: roll a 1 and a 5  (rule: keep only the 1)", [1, 5, 2, 3, 4]),
    ("5 dice: roll two 5s       (rule: keep only one 5)", [5, 5, 2, 3, 4]),
    ("5 dice: roll 1 + two 5s   (rule: keep only the 1)", [1, 5, 5, 2, 3]),
    ("5 dice: roll two 1s       (kept: both 1s)", [1, 1, 2, 3, 4]),
    ("6 dice: single 1 and 5, no sets (rule: keep only the 1)", [1, 5, 2, 3, 4, 4]),
    ("6 dice: three 2s + a single 1 (rule: skip the 2s)", [2, 2, 2, 1, 4, 6]),
    ("6 dice: three 2s + a 1 and a 5 (rule: keep the 2s too)", [2, 2, 2, 1, 5, 3]),
]


def best_keep(dice, ev_opt):
    counts = counts_of(dice)
    n = len(dice)
    if n == 6 and counts == [1, 1, 1, 1, 1, 1]:
        kept_faces = [(face, count) for face, count in enumerate(counts, 1) if count]
        return kept_faces, 2000, "hot", 2000 + ev_opt[6]

    per_face = [face_options(face, counts[face - 1]) for face in range(1, 7)]
    best = ([], 0, "bust", 0.0)
    best_value = float("-inf")
    for combo in product(*per_face):
        kept = sum(option[0] for option in combo)
        points = sum(option[1] for option in combo)
        if kept == 0:
            continue
        status = "hot" if kept == n else "keep"
        continuation = ev_opt[6] if kept == n else ev_opt[n - kept]
        value = points + continuation
        if value > best_value:
            best_value = value
            kept_faces = [
                (face, combo[face - 1][0])
                for face in range(1, 7)
                if combo[face - 1][0] > 0
            ]
            best = (kept_faces, points, status, value)
    return best


def main():
    greedy = expected_value("greedy")
    optimal = expected_value("optimal")

    print("TABLE 4: OPTIMIZED STRATEGY EXPECTED VALUES")
    print(f"{'Dice':>5} {'Base (greedy)':>15} {'Optimized':>12} {'Gain':>8}")
    for n in range(1, 7):
        print(
            f"{n:>5} {greedy[n]:>15.3f} {optimal[n]:>12.3f} "
            f"{optimal[n] - greedy[n]:>8.3f}"
        )

    print()
    print("OPTIMAL KEEP DECISIONS ON REPRESENTATIVE ROLLS")
    for description, dice in REPRESENTATIVE_ROLLS:
        kept_faces, points, status, value = best_keep(dice, optimal)
        kept_desc = (
            " + ".join(f"{count} {FACE_NAME[face]}" for face, count in kept_faces)
            or "nothing"
        )
        print(f"\n{description}")
        print(f"  roll : {dice}")
        print(
            f"  keep : {kept_desc}  ({points} pts, {status})  "
            f"value = {points} + E(remaining) = {value:.3f}"
        )


if __name__ == "__main__":
    main()
# Output (table only):
# TABLE 4: OPTIMIZED STRATEGY EXPECTED VALUES
#  Dice   Base (greedy)    Optimized     Gain
#     1         250.265      267.875   17.610
#     2         236.317      250.014   13.697
#     3         282.679      295.615   12.936
#     4         371.620      384.981   13.361
#     5         493.256      518.464   25.208
#     6         675.794      728.624   52.830

Appendix D: Python Code for Game Theory Thresholds

Excerpt: scripts/game_theory.py
"""Tables 5 & 6: piggybacking and passing thresholds.

Piggyback (Table 5): accept a passed turn when
    passed_score + E_opt(dice) >= E_opt(6)
so the minimum passed score per dice count is the smallest multiple of 50 that
satisfies the inequality:
    ceil50(max(0, E_opt(6) - E_opt(dice))).
We round UP (not to the nearest 50): rounding down would admit piggybacks
whose continuation value is actually below starting fresh.

Passing (Table 6): the reference bot's stop-and-pass limits
    [600, 600, 700, 1900, 5500, 13000]
plus a one-step EV-optimal comparison for transparency.

Usage:
    python scripts/game_theory.py
"""

import math
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from mississippi_marbles.ev import count_vector_outcomes, expected_value, expand_counts
from mississippi_marbles.scoring import score_roll

PASS_LIMITS = [600, 600, 700, 1900, 5500, 13000]


def ceil_to_50(x):
    return int(math.ceil(x / 50.0) * 50)


def piggyback_thresholds(ev_opt):
    thresholds = []
    for n in range(1, 7):
        need = max(0.0, ev_opt[6] - ev_opt[n])
        thresholds.append(ceil_to_50(need))
    return thresholds


def one_step_stop_limits(ev_greedy):
    """Approximate EV-optimal stop limit: bank when rolling once more has
    non-positive expected marginal value against the greedy continuation."""
    limits = []
    for n in range(1, 7):
        expected = 0.0
        bust_prob = 0.0
        for vector, prob in count_vector_outcomes(n):
            dice = expand_counts(vector)
            points, left, status = score_roll(dice)
            if status == "bust":
                bust_prob += prob
                continue
            continuation = ev_greedy[6] if left == 0 else ev_greedy[left]
            expected += prob * (points + continuation)
        limits.append(int(round(expected / (1.0 + bust_prob))))
    return limits


def main():
    greedy = expected_value("greedy")
    optimal = expected_value("optimal")

    print("TABLE 5: MINIMUM PASSED SCORE REQUIRED TO PIGGYBACK")
    print(
        f"{'Dice':>5} {'E_opt(dice)':>12} {'E_opt(6) - E_opt(d)':>20} "
        f"{'Min Passed':>12}"
    )
    thresholds = piggyback_thresholds(optimal)
    for n in range(1, 7):
        print(
            f"{n:>5} {optimal[n]:>12.3f} "
            f"{optimal[6] - optimal[n]:>20.3f} {thresholds[n - 1]:>12}"
        )

    print()
    print("TABLE 6: PASSING / STOP-AND-PASS THRESHOLDS")
    print(f"{'Dice':>5} {'Stop & Pass':>14} {'EV-optimal (approx)':>20}")
    derived = one_step_stop_limits(greedy)
    for n in range(1, 7):
        print(f"{n:>5} {PASS_LIMITS[n - 1]:>14} {derived[n - 1]:>20}")


if __name__ == "__main__":
    main()
# Output:
# TABLE 5: MINIMUM PASSED SCORE REQUIRED TO PIGGYBACK
#  Dice  E_opt(dice)  E_opt(6) - E_opt(d)   Min Passed
#     1      267.875              460.750          500
#     2      250.014              478.611          500
#     3      295.615              433.010          450
#     4      384.981              343.644          350
#     5      518.464              210.160          250
#     6      728.624                0.000            0
#
# TABLE 6: PASSING / STOP-AND-PASS THRESHOLDS
#  Dice    Stop & Pass  EV-optimal (approx)
#     1            600                  150
#     2            600                  164
#     3            700                  221
#     4           1900                  321
#     5           5500                  458
#     6          13000                  656