2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発でパチスロ期待値計算Webアプリを作った話 — Next.js × Supabase × ベイズ推論

2
Last updated at Posted at 2026-03-10

はじめに

ラクパチは、パチンコ・パチスロの期待値計算、収支管理、設定判別、貯玉管理をスマホのブラウザで使える実戦支援Webアプリです。インストール不要で、ホールで即座に使えます。

この記事では、なぜ作ったのか、どんな技術で実装しているのか、そしてベイズ推論やモンテカルロといった数学的アルゴリズムをどうWebアプリに落とし込んだかを紹介します。

特に、確率計算を「金銭の判断」に使うときに踏んだ落とし穴を具体的に書きます。ここが一番の学びでした。

なぜ作ったのか

パチスロの「期待値稼働」という世界

パチスロには「天井」という仕組みがあります。一定ゲーム数ハマると必ずボーナスやAT(連チャンゾーン)が発動する救済機能です。

天井までの残りゲーム数と、天井到達時の恩恵(平均獲得枚数)から「この台を今から打ったらいくら儲かる/損するか」を計算できます。これが天井期待値です。期待値がプラスの台だけを選んで打ち続ければ長期的にはプラスに寄る——これが期待値稼働の考え方です。

既存ツールの課題

期待値計算ツールはいくつか存在しますが、それぞれ課題がありました。

ツール 強み 課題
期待値見える化 天井期待値の計算精度が高い 収支管理機能がない。計算結果が蓄積されない
SloSight 天井期待値の早見表が見やすい 非等価交換に非対応。計算のカスタマイズ不可
pRecord 収支管理の定番 期待値計算機能がない。ネイティブアプリのみ

「期待値を計算して → その結果をもとに打って → 収支を記録して → 振り返る」。このサイクルがひとつのアプリで完結せず、毎回3つのサービスを行き来する煩わしさがありました。

コンセプト

期待値計算と収支管理を一体化したWebアプリ

  • 天井期待値をリアルタイムで計算
  • 計算結果をそのまま収支記録に紐づけ
  • ブラウザで動く → インストール不要 → ホールで数秒で起動

技術スタック

レイヤー 技術 選定理由
フロントエンド Next.js 16.1.1 (App Router) RSC対応、SSRでSEO
言語 TypeScript (strict) 型安全性は譲れない
スタイリング Tailwind CSS 4 ユーティリティファーストで高速開発
UI shadcn/ui + Radix UI アクセシブルなヘッドレスUI
アニメーション Framer Motion 12 滑らかなトランジション
チャート Recharts 2 収支分布・推移グラフ
状態管理 Zustand 5 + SWR 2 グローバル状態とサーバー状態を分離
認証 Supabase Auth (Google OAuth) ソーシャルログイン
DB Supabase PostgreSQL (RLS有効) Row Level Securityで安全
ORM Drizzle ORM 型安全SQL、マイグレーション管理
AI OpenAI (gpt-4.1-mini / gpt-5.6-luna / gpt-4o vision) 会話・データランプ読み取り
ホスティング Vercel Next.js最適化
PWA Serwist 9 (Workbox後継) オフライン対応、ホーム画面追加
モバイル Capacitor 8 WebViewラッパーでストア配信

なぜWebアプリなのか

  1. インストール障壁ゼロ: URL共有だけで届く
  2. ホールで即使える: ブラウザを開くだけ
  3. 常に最新版: デプロイ = 全ユーザーに即反映
  4. クロスプラットフォーム: iOS / Android / PC 同一コード

PWAとして実装しているので、ホーム画面に追加すればネイティブアプリと同じ操作感で使えます。

コアアルゴリズム

1. ベイズ推論による設定判別

パチスロの設定判別(6段階のうちどの設定で動いているか)にベイズ推論を使っています。

P(設定s | データD) ∝ P(データD | 設定s) × P(設定s)

尤度は二項分布です。総回転数 N のうち BIG が b 回出た、という観測を Binomial(b; N, p_s) で評価します。

実装で効いた3つの判断

① 尤度は log で持ち、log-sum-exp で正規化する。 生の確率を掛け合わせると、総回転数が数千を超えた時点でアンダーフローして全部 0 になります。

// 各設定の対数尤度を出し、log-sum-exp で安全に正規化する
for (const s of settings) {
    logLikelihoods[s] = calculateLikelihood(input, machine.specs[s]);
}
// log_posterior[s] = logLikelihood[s] + log(prior[s])
// → maxLogP を引いてから exp することで桁あふれを防ぐ

② 入力とスペックの健全性を先に弾く。 ここが一番の学びでした。チェックを入れる前は、こんなバグが出ました。

if (!Number.isFinite(totalSpins) || totalSpins < 0) return -Infinity;
if (bigCount + regCount > totalSpins) return -Infinity;
if (!Number.isFinite(spec.bb) || spec.bb <= 1 ||
    !Number.isFinite(spec.rb) || spec.rb <= 1) {
    return -Infinity;
}
  • 入力が矛盾していると結論が反転する: 二項尤度の指数 (n − k) が負になると log(1−p) との積の符号がひっくり返り、「当たりやすい設定ほど尤度が高い」という逆転が起きます。総回転50でBIG30という(ありえない)入力を投げると、設定6が98.9%と表示されました。
  • スペックが壊れると「均等」として表示される: NaN が混ざると後段の正規化が無言で一律 1/6 を返します。これは「判別不能」ではなく「全設定が等確率」という別の意味の主張になってしまう。

どちらも、ユーザーが金を賭ける判断を誤らせます。確率計算を金銭判断に使うなら、「計算できない」と「結果が均等」を絶対に混同させてはいけない。

③ 入力していない小役は尤度に含めない。

// spec.grape が falsy(0 や未設定)なら、ぶどうの項はスキップする

ぶどうをカウントしていないユーザーの推測精度を、勝手に下げないための配慮です。「データが無い」を「データが0だった」として扱うと、観測していない事実で結論が動きます。

2. 天井期待値の計算エンジン

天井期待値は、1ゲームずつ走査して当選確率と恩恵を積算します。

// 天井期待値 = Σ(各G数での当選確率 × 恩恵期待枚数 × 換金価値) − 期待投資額
let expectedReturn = 0;
let expectedInvestment = 0;
let cumulativeNoHitProb = 1.0; // まだ当選していない確率

for (let g = currentGame + 1; g <= ceilingGame; g++) {
    // ゾーン内ならゾーンの当選率に差し替わる
    const hitInfo = getHitInfoAtGame(g, spec, baseHitRate, baseHitBenefitCoins);

    // このG数で「初めて」当選する確率
    const hitProbAtG = cumulativeNoHitProb * hitInfo.hitRate;

    expectedReturn += hitProbAtG * hitInfo.benefitCoins * valuePerCoin;
    expectedInvestment += cumulativeNoHitProb * costPerCoin * coinsPerGame;

    cumulativeNoHitProb *= (1 - hitInfo.hitRate);
}
// 天井到達(100%当選)
expectedReturn += cumulativeNoHitProb * ceilingBenefitCoins * valuePerCoin;

cumulativeNoHitProb を持ち回るのがポイントです。「G数gで当たる確率」ではなく「g まで当たらずに来て、g で初めて当たる確率」を積むので、二重計上が起きません。

ゾーン対応

最近の機種は「ゾーン」(特定G数帯で当選率が上がる)が複雑なので、ゾーン内は確率と恩恵を差し替えます。

function getHitInfoAtGame(game, spec, baseHitRate, baseHitBenefitCoins) {
    for (const zone of spec.zones) {
        if (game >= zone.startGame && game <= zone.endGame) {
            return { hitRate: zone.hitRate, benefitCoins: zone.benefitCoins, isZone: true };
        }
    }
    return { hitRate: baseHitRate, benefitCoins: baseHitBenefitCoins, isZone: false };
}

3. モンテカルロは「用途で2層」に分けた

「期待値がプラスなのは分かった。でも実際どれくらい振れるの?」——この疑問に答えるためモンテカルロで収支分布を出します。

ここは用途が違う2つのエンジンを持っています。この分離が実務的に効きました。

クライアント側(判定画面) 掲載用(記事の実測値)
目的 その場で振れ幅を見せる 記事に載せる数値を作る
試行数 1,000回 1条件あたり1億回(グリッドは1000万回)
乱数 Math.random() シード付き(mulberry32)
実行場所 ブラウザ ビルド前のスクリプトで事前計算しJSONに焼き込み
// クライアント側: その場で3ストーリー(P20 / P50 / P80)を出す
for (let sim = 0; sim < numSimulations; sim++) {
    let profit = 0, currentG = input.currentGame;
    while (currentG < spec.ceilingGame) {
        currentG++;
        profit -= costPerGame;
        const hitInfo = getHitInfoAtGame(currentG, spec, baseHitRate, baseBenefit);
        if (Math.random() < hitInfo.hitRate) {
            // 恩恵枚数も分散させる(一様 ±variance)
            const factor = 1 + (Math.random() * 2 - 1) * variance;
            profit += hitInfo.benefitCoins * factor * valuePerCoin;
            break;
        }
    }
    results.push(profit);
}
results.sort((a, b) => a - b);
// P20(下振れ)/ P50(中央値)/ P80(上振れ)

掲載用のほうはMath.randomを使いません。理由は単純で、再現できない結果を「実測値」として記事に載せられないからです。シード付き乱数なら、同じスペック・同じシードで誰が回しても同じ数字になります。

// 掲載用エンジンの設計メモ
// per-Gループを「当選G数の累積分布を前計算 → 逆関数サンプリング」に置き換え、
// O(log 天井G)/試行 にして億回スケールを可能にしている。
// 平均が解析計算(calculateCeilingExpectedValue)と一致することはテストで固定する。

現在焼き込み済みの総試行数は 104.5億回です。そして「モンテカルロの平均が、解析的に計算した期待値と一致すること」をテストで固定してあります。2つの独立した方法で同じ答えが出ることを、CIで担保する。 数値計算のバグは沈黙するので、これが唯一の防波堤でした。

4. 損益分岐点の二分探索

「何ゲームから打てば期待値プラスになるの?」に答えるため、二分探索で損益分岐G数を出します。期待値はG数に対して単調なので、線形走査する必要がありません。

let lo = 0, hi = spec.ceilingGame;
while (lo < hi) {
    const mid = Math.floor((lo + hi) / 2);
    const ev = calculateCeilingEV({ currentGame: mid, /* ... */ }, spec);
    if (ev.ev >= 0) hi = mid;      // もっと早いG数でもプラスかも
    else lo = mid + 1;             // まだマイナス
}

DB設計

Supabase + Drizzle ORM + RLS

machines (機種マスタ)
├── machine_probabilities   (設定別スペック)
├── machine_ceiling_specs   (天井・ゾーン情報)
runs (稼働・収支記録)          ← user_id で RLS
stores (店舗)                  ← user_id で RLS
user_tickets / ticket_transactions (チケット残高と台帳) ← user_id で RLS

RLS(Row Level Security) がポイントです。ユーザーAのデータはユーザーBからは絶対に見えない。アプリ層ではなくSQLレベルでセキュリティを担保しています。

CREATE POLICY "Users can only see their own runs"
ON runs FOR SELECT
USING (auth.uid() = user_id);

「1000枚勝った」がいくらなのか一意に決まらない問題

パチスロ特有の面倒さがここです。同じ1000枚でも、現金で借りたメダルか貯メダルかで価値が変わります。借り入れ20円/枚・交換18円/枚の店では、貯メダルの実質価値は借り入れの90%しかありません。しかもそのレートは店ごとに違う。

そこで収支レコードには計算時のレートをスナップショットとして焼き込みます。

type Run = {
  investmentCash: number;      // 現金投資(円)
  investmentSavings: number;   // 貯メダル投資(枚)
  recoveryCash: number;        // 現金回収(円)
  recoverySavings: number;     // 貯メダル回収(枚)
  balance: number;             // 最終収支(円)
  appliedRates: {              // ← ここが肝
    rentalRate: number;
    exchangeRate: number;
    valuePerUnit: number;
  };
}

後から店舗のレート設定を変更しても、過去の収支が遡って書き換わらない。「設定を変えたら去年の収支が変わった」は、家計簿系アプリで最もやってはいけない事故です。

PWA対応 — ホールで使うための設計

// next.config.ts
import withSerwistInit from "@serwist/next";
const withSerwist = withSerwistInit({ swSrc: "src/sw.ts", swDest: "public/sw.js" });

キャッシュ戦略は、機種データ = CacheFirst、API応答 = NetworkFirst(オフラインフォールバック)、静的アセット = CacheFirst。加えてアプリ側でも SWR in-memory + localStorage の2層キャッシュを持たせ、ホールの不安定なWi-Fiでも最後に取得した状態は表示できるようにしています。

課金は「実装したが止めている」

Stripe Checkout + Webhook でチケット制課金を実装しました。が、現在は停止しています。

パチンコ・パチスロ領域は Stripe でも AdSense でも「ギャンブル該当」と判定されて審査を通りません。実装は完了しているものの、フラグ1つで購入導線を消したまま運用しています。

BILLING_ENABLED=false → 購入導線が消える。上限判定・チケット消費フローは生きたまま

重要なのは、再開できるようになった瞬間にコード変更ゼロで動く状態にしておくことです。プラン判定・日次上限・消費フローは全部生かしてあり、消えているのは購入ボタンだけ。

個人開発の教訓としては、market fit より先に payment fit を確認したほうがいい。決済が通らない市場は、プロダクトが良くても収益化の設計を最初からやり直すことになります。

なお課金の原則は「勝手に減らない」。チケットはユーザーの明示的な操作にのみ紐づいて消費され、サブスクのように放置で減ることはありません。

SEO / LLMO — AIに見つけてもらう

ChatGPTやGeminiに「パチスロの期待値計算ができるアプリは?」と聞かれた時に候補に挙がることを目指しています。

  • JSON-LD構造化データ: SoftwareApplication / WebApplication / FAQPage / Organization / BreadcrumbList / HowTo / DefinedTermSet(用語集)を用途別に実装
  • /llms.txt: LLM向けの構造化テキストを配信するRoute Handler
  • FAQ形式のコンテンツ: 「Q: ○○ A: ○○」形式は引用されやすい
  • 比較表はHTMLテーブルで書く: 画像で作った表はLLMに読まれない
export async function GET() {
    return new NextResponse(LLMS_TXT, {
        headers: {
            "Content-Type": "text/plain; charset=utf-8",
            "Cache-Control": "public, max-age=86400",
        },
    });
}

ちなみに llms.txt については「効果があった」という実証を見つけられていません。既に置いてあるので維持していますが、新規に工数を割く価値があるかは懐疑的です。効いているのはむしろ、SSRで全文がHTMLに出ていること、構造化データ、そして一次データを持っていることのほうです。

苦労したポイント

1. モンテカルロのパフォーマンス

1,000回 × 数百ゲーム走査をクライアントで実行すると、低スペック機で数秒かかることがありました。

解決策: 生データ(枚ベース)を1回だけ計算し、レート変更時は円換算のみ再計算する2段階方式に分離。ランダム性のある重い計算と決定的な軽い変換を分けると、レートを変えるたびに乱数を回し直す必要がなくなります。

2. 機種データの規模

天井・ゾーン・設定別スペックを維持する対象は、いま297機種(スロット163・パチンコ134)まで増えました。

解決策: マスタはDBに置き、管理画面から更新。さらに解析情報の収集・記事化を毎晩の自動ルーチンに任せています。ここで一番大事にしているのが捏造を構造で止めることです。

  • 複数媒体で一致した数値だけを「確定」として扱う
  • 単独ソースの数値は「要確証」を必ず明記する
  • 裏が取れない項目は、空欄ではなく「【解析待ち】」と書く

これを規約ではなくスクリプトの出力仕様にしてあります。LLMに「正直に書いて」とお願いするのではなく、正直にしか書けない形にする。数字が金銭判断に直結するドメインでは、ここを人間の善意に頼ってはいけません。

3. 非等価交換の計算

等価(20円/枚)と非等価(5.6枚交換 ≒ 17.86円/枚)、さらに貯メダル再プレイなら実質等価。この「お金の多層構造」を期待値計算に正確に反映するのが想像以上に複雑でした。レート計算は1ファイルに集約し、ロジックの分散を防いでいます。

まとめ

技術 用途
ベイズ推論(二項尤度 + log-sum-exp) 設定判別(事後確率の算出)
モンテカルロ法(2層: 1,000回 / 104.5億回) 収支分布のシミュレーション
二分探索 損益分岐ゲーム数の算出
Next.js 16 + Supabase フルスタックWebアプリ基盤
Drizzle ORM + RLS 型安全DB + 行レベルセキュリティ
PWA (Serwist) オフライン対応・ネイティブ風UX
JSON-LD + llms.txt SEO / LLMO

「数字で殴れ。直感は捨てろ。」

パチスロという一見カジュアルな領域ですが、やっていることは確率統計と数値計算です。そして確率を金銭判断に使うという性質上、普通のWebアプリより一段厳しい規律が要りました。

  • 計算できないことと、結果が均等であることを混同させない
  • 再現できない乱数の結果を「実測値」と呼ばない
  • 独立した2つの方法で同じ答えが出ることをCIで担保する
  • 裏が取れない数値は、空欄ではなく「未確定」と書く

この4つは、扱う数字が誰かのお金に変わるプロダクトなら、ドメインを問わず効くと思います。

リンク


この記事が参考になったら、ぜひLGTMお願いします 🙏
ラクパチに関するフィードバック・質問もお気軽にどうぞ!

2
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?