"""
ai-casino.online keno and Plinko simulations, October 2026.
Reproducible: run `python3 keno_plinko_sims.py` (needs numpy). Fixed seed 20261014.
Writes keno-plinko-summary.json next to where it is run.

Pay tables
- Keno: Wizard of Odds "spot keno" Pay Tables 1, 4 and 7
  (https://wizardofodds.com/games/keno/spot-keno/). Pays are "for one":
  the amount returned per 1 unit staked, stake included.
- Plinko: BGaming Plinko, 8 to 16 rows, low / medium / high risk, as published
  by Wizard of Odds (https://wizardofodds.com/games/plinko/).

Exact figures
1) Keno: probability of each catch for 1 to 10 spots (hypergeometric, 20 of 80
   drawn), return of each pay table, checked against Wizard of Odds' figures.
2) Plinko: return, chance of getting at least the stake back, and chance of the
   top multiplier for every row count and risk level.

Simulations
3) Keno: 1,000,000 draws checking a 10-spot ticket against the exact odds.
4) Keno hot and cold numbers: 200,000 draws. Before each draw, a "hot" ticket
   (the 10 numbers drawn most in the previous 100 draws), a "cold" ticket
   (the 10 drawn least) and a random ticket are compared.
5) Keno sessions: 100,000 sessions of 100 draws at GBP 1, 4-spot and 10-spot,
   on Pay Table 1 and Pay Table 7.
6) Plinko: 1,000,000 drops on a 16-row board, paid on all three risk levels
   using the same ball paths.
7) Plinko sessions: 100,000 sessions of 200 drops at GBP 1 per risk level,
   16 rows, same ball paths for every risk level.
"""
import json
from math import comb
import numpy as np

rng = np.random.default_rng(20261014)
out = {"seed": 20261014}

# ---------------------------------------------------------------- keno tables
KENO = {
    "table1": {
        2: [1, 0, 5], 3: [1, 0, 2, 11], 4: [1, 0, 1, 5, 40],
        5: [1, 0, 0, 3, 10, 400], 6: [1, 0, 0, 2, 5, 49, 1000],
        7: [1, 0, 0, 1, 2, 22, 275, 2500], 8: [1, 0, 0, 1, 2, 10, 40, 500, 5000],
        9: [1, 0, 0, 0, 1, 5, 40, 400, 1000, 5000],
        10: [1, 0, 0, 0, 0, 5, 25, 120, 500, 2000, 10000],
    },
    "table4": {
        2: [0, 0, 14], 3: [0, 0, 2, 45], 4: [0, 0, 2, 5, 85],
        5: [0, 0, 0, 3, 11, 804], 6: [0, 0, 0, 3, 4, 68, 1500],
        7: [0, 0, 0, 1, 2, 20, 390, 7000], 8: [0, 0, 0, 0, 2, 12, 98, 1550, 10000],
        9: [0, 0, 0, 0, 1, 6, 44, 300, 4700, 10000],
        10: [0, 0, 0, 0, 0, 5, 23, 132, 1000, 4500, 10000],
    },
    "table7": {
        1: [0, 3], 2: [0, 0, 15], 3: [0, 0, 2, 48], 4: [0, 0, 2, 5, 100],
        5: [0, 0, 0, 3, 13, 838], 6: [0, 0, 0, 3, 4, 75, 1660],
        7: [0, 0, 0, 1, 2, 22, 422, 7000], 8: [0, 0, 0, 0, 2, 13, 100, 1670, 10000],
        9: [0, 0, 0, 0, 1, 6, 44, 362, 4700, 10000],
        10: [0, 0, 0, 0, 0, 5, 24, 146, 1000, 4500, 10000],
    },
}
# Returns as printed by Wizard of Odds, used to check the transcription above.
WOO_RETURNS = {
    "table1": {2: 86.08, 3: 84.66, 4: 85.97, 5: 85.79, 6: 84.96, 7: 85.33, 8: 84.57, 9: 84.25, 10: 86.43},
    "table4": {2: 84.18, 3: 90.19, 4: 90.19, 5: 90.33, 6: 90.76, 7: 90.85, 8: 90.67, 9: 89.93, 10: 89.79},
    "table7": {1: 75.00, 2: 90.19, 3: 94.35, 4: 94.78, 5: 94.95, 6: 94.99, 7: 94.92, 8: 94.90, 9: 93.60, 10: 93.20},
}


def keno_p(spots, catch):
    return comb(spots, catch) * comb(80 - spots, 20 - catch) / comb(80, 20)


exact = {}
for s in range(1, 11):
    probs = [keno_p(s, c) for c in range(s + 1)]
    exact[s] = probs
out["keno_exact"] = {
    str(s): {
        "catch_all_pct": round(exact[s][s] * 100, 8),
        "catch_all_one_in": round(1 / exact[s][s]),
        "probs_pct": [round(p * 100, 6) for p in exact[s]],
    }
    for s in range(1, 11)
}

returns = {}
for t, tab in KENO.items():
    returns[t] = {}
    for s, pays in tab.items():
        r = sum(p * x for p, x in zip(exact[s], pays)) * 100
        win_back = sum(p for p, x in zip(exact[s], pays) if x >= 1) * 100
        profit = sum(p for p, x in zip(exact[s], pays) if x > 1) * 100
        assert abs(r - WOO_RETURNS[t][s]) < 0.011, (t, s, r, WOO_RETURNS[t][s])
        returns[t][str(s)] = {"return_pct": round(r, 2), "house_edge_pct": round(100 - r, 2),
                              "stake_back_or_more_pct": round(win_back, 2),
                              "profit_pct": round(profit, 2)}
out["keno_pay_table_returns"] = returns

# ---------------------------------------------------------- 3) 10-spot check
N = 1_000_000
ticket = np.zeros(80, bool)
ticket[:10] = True  # numbers 1-10; any fixed choice is equivalent
draws = np.argsort(rng.random((N, 80)), axis=1)[:, :20]
catches = ticket[draws].sum(axis=1)
counts = np.bincount(catches, minlength=11)
out["keno_10spot_1m"] = {
    "draws": N,
    "observed_pct": [round(c / N * 100, 4) for c in counts],
    "exact_pct": [round(p * 100, 4) for p in exact[10]],
    "five_plus_observed_pct": round(counts[5:].sum() / N * 100, 3),
    "five_plus_exact_pct": round(sum(exact[10][5:]) * 100, 3),
    "best_catch": int(catches.max()),
    "times_8_plus": int(counts[8:].sum()),
}

# ---------------------------------------------------------- 4) hot and cold
N, WINDOW = 200_000, 100
order = np.argsort(rng.random((N, 80)), axis=1)[:, :20]
hits = np.zeros((N, 80), bool)
np.put_along_axis(hits, order, True, axis=1)
cum = np.vstack([np.zeros((1, 80), int), np.cumsum(hits, axis=0)])
res = {"hot": [], "cold": [], "random": []}
for i in range(WINDOW, N):
    freq = cum[i] - cum[i - WINDOW] + rng.random(80) * 0.01  # tiny jitter breaks ties fairly
    o = np.argsort(freq)
    res["hot"].append(hits[i, o[-10:]].sum())
    res["cold"].append(hits[i, o[:10]].sum())
    res["random"].append(hits[i, rng.choice(80, 10, replace=False)].sum())
out["keno_hot_cold"] = {
    "draws_tested": N - WINDOW, "window": WINDOW, "spots": 10,
    "expected_avg_catch": 2.5,
    **{f"{k}_avg_catch": round(float(np.mean(v)), 4) for k, v in res.items()},
    **{f"{k}_five_plus_pct": round(float(np.mean(np.array(v) >= 5)) * 100, 3) for k, v in res.items()},
}

# ---------------------------------------------------------- 5) keno sessions
SESS, DRAWS = 100_000, 100
ks = {}
for s in (4, 10):
    c = rng.hypergeometric(s, 80 - s, 20, size=(SESS, DRAWS))
    for t in ("table1", "table7"):
        pay = np.array(KENO[t][s])
        result = pay[c].sum(axis=1) - DRAWS
        ks[f"{s}spot_{t}"] = {
            "avg": round(float(result.mean()), 2),
            "median": float(np.median(result)),
            "ahead_pct": round(float((result > 0).mean()) * 100, 2),
            "p10": float(np.percentile(result, 10)),
            "p90": float(np.percentile(result, 90)),
            "best": float(result.max()),
            "worst": float(result.min()),
        }
out["keno_sessions"] = {"sessions": SESS, "draws": DRAWS, "stake": 1, **ks}

# ---------------------------------------------------------------- plinko
P_HALF = {  # pays from the left edge to the middle; boards are symmetric
    8: {"low": [5.6, 2.1, 1.1, 1, 0.5], "medium": [13, 3, 1.3, 0.7, 0.4], "high": [29, 4, 1.5, 0.3, 0.2]},
    9: {"low": [5.6, 2, 1.6, 1, 0.7], "medium": [18, 4, 1.7, 0.9, 0.5], "high": [43, 7, 2, 0.6, 0.2]},
    10: {"low": [8.9, 3, 1.4, 1.1, 1, 0.5], "medium": [22, 5, 2, 1.4, 0.6, 0.4], "high": [76, 10, 3, 0.9, 0.3, 0.2]},
    11: {"low": [8.4, 3, 1.9, 1.3, 1, 0.7], "medium": [24, 6, 3, 1.8, 0.7, 0.5], "high": [120, 14, 5.2, 1.4, 0.4, 0.2]},
    12: {"low": [10, 3, 1.6, 1.4, 1.1, 1, 0.5], "medium": [33, 11, 4, 2, 1.1, 0.6, 0.3], "high": [170, 24, 8.1, 2, 0.7, 0.2, 0.2]},
    13: {"low": [8.1, 4, 3, 1.9, 1.2, 0.9, 0.7], "medium": [43, 13, 6, 3, 1.3, 0.7, 0.4], "high": [260, 37, 11, 4, 1, 0.2, 0.2]},
    14: {"low": [7.1, 4, 1.9, 1.4, 1.3, 1.1, 1, 0.5], "medium": [58, 15, 7, 4, 1.9, 1, 0.5, 0.2], "high": [420, 56, 18, 5, 1.9, 0.3, 0.2, 0.2]},
    15: {"low": [15, 8, 3, 2, 1.5, 1.1, 1, 0.7], "medium": [88, 18, 11, 5, 3, 1.3, 0.5, 0.3], "high": [620, 83, 27, 8, 3, 0.5, 0.2, 0.2]},
    16: {"low": [16, 9, 2, 1.4, 1.4, 1.2, 1.1, 1, 0.5], "medium": [110, 41, 10, 5, 3, 1.5, 1, 0.5, 0.3], "high": [1000, 130, 26, 9, 4, 2, 0.2, 0.2, 0.2]},
}
# Wizard of Odds' summary returns for medium and high risk (its low-risk return
# column repeats the medium figures, so low risk is checked by our own maths only).
WOO_PLINKO = {8: (98.91, 99.06), 9: (99.14, 99.06), 10: (98.91, 99.06), 11: (99.02, 99.16), 12: (98.99, 99.12),
              13: (98.99, 99.09), 14: (98.99, 98.98), 15: (99.00, 99.03), 16: (98.99, 98.98)}


def full(rows, risk):
    half = P_HALF[rows][risk]
    n = rows + 1
    return [half[min(i, n - 1 - i)] for i in range(n)]


pexact = {}
for rows in range(8, 17):
    probs = [comb(rows, k) / 2 ** rows for k in range(rows + 1)]
    pexact[str(rows)] = {"slot_probs_pct": [round(p * 100, 4) for p in probs]}
    for risk in ("low", "medium", "high"):
        pays = full(rows, risk)
        assert len(P_HALF[rows][risk]) == (rows + 2) // 2, (rows, risk)
        rtp = sum(p * x for p, x in zip(probs, pays)) * 100
        if risk != "low":
            woo = WOO_PLINKO[rows][0 if risk == "medium" else 1]
            assert abs(rtp - woo) < 0.011, (rows, risk, rtp, woo)
        pexact[str(rows)][risk] = {
            "pays": pays,
            "rtp_pct": round(rtp, 2),
            "stake_back_or_more_pct": round(sum(p for p, x in zip(probs, pays) if x >= 1) * 100, 2),
            "profit_pct": round(sum(p for p, x in zip(probs, pays) if x > 1) * 100, 2),
            "top_multiplier": max(pays),
            "top_one_in": 2 ** rows // 2,
            "min_multiplier": min(pays),
            "min_pay_pct": round(sum(p for p, x in zip(probs, pays) if x == min(pays)) * 100, 2),
        }
out["plinko_exact"] = pexact

# ---------------------------------------------------------- 6) 1M drops
N = 1_000_000
slots = rng.binomial(16, 0.5, size=N)
drops = {"drops": N, "rows": 16,
         "slot_counts": np.bincount(slots, minlength=17).tolist()}
for risk in ("low", "medium", "high"):
    pay = np.array(full(16, risk))[slots]
    drops[risk] = {
        "observed_rtp_pct": round(float(pay.mean()) * 100, 2),
        "stake_back_or_more_pct": round(float((pay >= 1).mean()) * 100, 2),
        "median_pay": float(np.median(pay)),
        "top_hits": int((pay == pay.max()).sum()),
    }
drops["middle_five_slots_pct"] = round(float(((slots >= 6) & (slots <= 10)).mean()) * 100, 2)
drops["edge_slots_pct"] = round(float(((slots == 0) | (slots == 16)).mean()) * 100, 4)
out["plinko_1m_drops"] = drops

# ---------------------------------------------------------- 7) sessions
SESS, DROPS = 100_000, 200
slots = rng.binomial(16, 0.5, size=(SESS, DROPS))
ps = {"sessions": SESS, "drops": DROPS, "rows": 16, "stake": 1}
for risk in ("low", "medium", "high"):
    result = np.array(full(16, risk))[slots].sum(axis=1) - DROPS
    ps[risk] = {
        "avg": round(float(result.mean()), 2),
        "median": round(float(np.median(result)), 2),
        "ahead_pct": round(float((result > 0).mean()) * 100, 2),
        "p10": round(float(np.percentile(result, 10)), 2),
        "p90": round(float(np.percentile(result, 90)), 2),
        "best": round(float(result.max()), 2),
        "worst": round(float(result.min()), 2),
    }
out["plinko_sessions"] = ps

with open("keno-plinko-summary.json", "w") as f:
    json.dump(out, f, indent=1)
print(json.dumps(out, indent=1))
