fertig-classic-games/public/src/games/craps/CrapsLogic.js

381 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Pure Craps rules. No Phaser dependency.
//
// Scope: Pass Line, Don't Pass (bar 12), Field, Place (4·5·6·8·9·10),
// Come / Don't Come, and free Odds behind Pass/Don't and Come/Don't Come.
//
// A "round" is one shooter sequence. The shooter rolls a come-out; if a point
// is set the game enters the point phase and the shooter keeps rolling until
// the point repeats (pass wins) or a 7 shows (seven-out → shooter rotates).
//
// State is treated as immutable: every mutating helper returns a fresh object.
export const POINT_NUMBERS = [4, 5, 6, 8, 9, 10];
// Bet types and where they live on the layout.
export const BET = {
PASS: 'pass',
DONT_PASS: 'dontpass',
FIELD: 'field',
PLACE: 'place',
COME: 'come',
DONT_COME: 'dontcome',
};
// Chip denominations offered in the UI.
export const CHIP_AMOUNTS = [5, 25, 50, 100];
// ─── Setup ───────────────────────────────────────────────────────────────────
export function createInitialState(opponents = [], humanChips = 2000, humanName = 'You') {
const players = [
{ name: humanName, isAI: false, avatar: null, chips: humanChips, bets: [], lastDelta: 0 },
...opponents.map((o) => ({
name: o.name ?? o.id ?? 'Bot',
isAI: true,
avatar: o,
chips: 2000,
bets: [],
lastDelta: 0,
})),
];
return {
players,
shooterIndex: 0,
phase: 'betting', // betting → comeout → point → (betting | gameover)
point: null,
dice: [1, 1],
rollCount: 0,
_nextBetId: 1,
};
}
export function cloneState(state) {
return JSON.parse(JSON.stringify(state));
}
// ─── Bet placement ─────────────────────────────────────────────────────────--
// Which base bet types may be opened right now.
export function legalBetTypes(state) {
const out = new Set([BET.FIELD, BET.PLACE]);
if (state.point === null) {
out.add(BET.PASS);
out.add(BET.DONT_PASS);
} else {
out.add(BET.COME);
out.add(BET.DONT_COME);
}
return out;
}
// A line/come bet that can still receive odds (point/come-point established).
export function oddsEligibleBets(player, state) {
return player.bets.filter((b) => {
if (b.type === BET.PASS || b.type === BET.DONT_PASS) return state.point !== null;
if (b.type === BET.COME || b.type === BET.DONT_COME) return b.comePoint != null;
return false;
});
}
export function maxOddsFor(bet) {
// Keep it simple: allow up to 3× the flat bet behind the line.
return bet.amount * 3;
}
// Place a flat bet. Stake is removed from the player's bankroll immediately.
export function placeBet(state, playerIndex, { type, number = null, amount }) {
const player = state.players[playerIndex];
if (amount <= 0 || amount > player.chips) return state;
if (!legalBetTypes(state).has(type)) return state;
if (type === BET.PLACE && !POINT_NUMBERS.includes(number)) return state;
const s = cloneState(state);
const p = s.players[playerIndex];
// Stack onto an existing matching flat bet rather than duplicating it.
const existing = p.bets.find((b) =>
b.type === type &&
(type === BET.PLACE ? b.number === number : b.comePoint == null && b.number == null));
if (existing && type !== BET.COME && type !== BET.DONT_COME) {
existing.amount += amount;
} else {
p.bets.push({
id: s._nextBetId++,
type,
number: type === BET.PLACE ? number : null,
comePoint: null,
amount,
oddsAmount: 0,
// Place bets sit "off" during a come-out; everything else is working.
working: type === BET.PLACE ? state.point !== null : true,
});
}
p.chips -= amount;
return s;
}
// Add odds behind an eligible line/come bet.
export function addOdds(state, playerIndex, betId, amount) {
const s = cloneState(state);
const p = s.players[playerIndex];
const bet = p.bets.find((b) => b.id === betId);
if (!bet) return state;
const cap = maxOddsFor(bet) - bet.oddsAmount;
const add = Math.min(amount, cap, p.chips);
if (add <= 0) return state;
bet.oddsAmount += add;
p.chips -= add;
return s;
}
// Return un-resolved stakes to the bankroll (only sensible during a betting
// window). Used by "Clear" and when leaving the table.
export function refundBets(state, playerIndex, predicate = () => true) {
const s = cloneState(state);
const p = s.players[playerIndex];
const kept = [];
for (const b of p.bets) {
if (predicate(b)) p.chips += b.amount + b.oddsAmount;
else kept.push(b);
}
p.bets = kept;
return s;
}
// ─── Dice ────────────────────────────────────────────────────────────────────
export function rollDice(state) {
const s = cloneState(state);
s.dice = [1 + Math.floor(Math.random() * 6), 1 + Math.floor(Math.random() * 6)];
s.rollCount += 1;
return s;
}
// Force specific dice (testing / scripted demos).
export function setDice(state, dice) {
const s = cloneState(state);
s.dice = [...dice];
return s;
}
// ─── Payout tables (return winnings only, not the returned stake) ─────────────
function floor(n) { return Math.floor(n); }
function passOddsWin(amount, number) {
if (number === 4 || number === 10) return amount * 2; // 2:1
if (number === 5 || number === 9) return floor(amount * 3 / 2); // 3:2
if (number === 6 || number === 8) return floor(amount * 6 / 5); // 6:5
return 0;
}
function dontOddsWin(amount, number) {
if (number === 4 || number === 10) return floor(amount / 2); // 1:2
if (number === 5 || number === 9) return floor(amount * 2 / 3); // 2:3
if (number === 6 || number === 8) return floor(amount * 5 / 6); // 5:6
return 0;
}
function placeWin(amount, number) {
if (number === 4 || number === 10) return floor(amount * 9 / 5);
if (number === 5 || number === 9) return floor(amount * 7 / 5);
if (number === 6 || number === 8) return floor(amount * 7 / 6);
return 0;
}
function fieldWin(amount, total) {
if (total === 2) return amount * 2; // 2 pays double
if (total === 12) return amount * 3; // 12 pays triple
return amount; // 3,4,9,10,11 pay 1:1
}
// ─── Resolution ────────────────────────────────────────────────────────────--
// Resolve every bet against the dice already set on `state`.
// Returns { state, total, payouts, pointEstablished, pointMade, sevenOut, newComeOut }.
//
// payouts entry: { playerIndex, betType, number, result, amount, delta }
// result ∈ 'win' | 'lose' | 'push'; delta is the net bankroll change for the bet.
export function resolveRoll(state) {
const s = cloneState(state);
const total = s.dice[0] + s.dice[1];
const point = s.point;
const isComeOut = point === null;
const payouts = [];
let pointEstablished = null;
let pointMade = false;
let sevenOut = false;
for (let pi = 0; pi < s.players.length; pi++) {
const p = s.players[pi];
const kept = [];
for (const bet of p.bets) {
const resolved = resolveBet(bet, { total, point, isComeOut });
if (resolved.result === 'open') {
// Bet survives (possibly mutated, e.g. a come bet that travelled).
kept.push(resolved.bet ?? bet);
continue;
}
// Credit returned stake + winnings; delta is net of the staked amount.
const stake = bet.amount + bet.oddsAmount;
let credited = 0;
if (resolved.result === 'win') credited = stake + resolved.winnings;
else if (resolved.result === 'push') credited = stake;
p.chips += credited;
const delta = credited - stake;
p.lastDelta += delta;
payouts.push({
playerIndex: pi,
betType: bet.type,
number: bet.number ?? bet.comePoint ?? null,
result: resolved.result,
amount: stake,
delta,
});
// A winning Place bet stays up and working; everything else clears.
if (resolved.keep) kept.push(resolved.bet ?? bet);
}
p.bets = kept;
}
// Phase / point transitions driven by the line outcome.
if (isComeOut) {
if (POINT_NUMBERS.includes(total)) {
pointEstablished = total;
s.point = total;
s.phase = 'point';
// Place bets switch on once a point exists.
for (const p of s.players) for (const b of p.bets) {
if (b.type === BET.PLACE) b.working = true;
}
} else {
s.phase = 'comeout';
}
} else if (total === point) {
pointMade = true;
s.point = null;
s.phase = 'comeout';
for (const p of s.players) for (const b of p.bets) {
if (b.type === BET.PLACE) b.working = false;
}
} else if (total === 7) {
sevenOut = true;
s.point = null;
s.phase = 'comeout';
for (const p of s.players) for (const b of p.bets) {
if (b.type === BET.PLACE) b.working = false;
}
}
const newComeOut = pointMade || sevenOut;
if (sevenOut) {
s.shooterIndex = (s.shooterIndex + 1) % s.players.length;
}
return { state: s, total, payouts, pointEstablished, pointMade, sevenOut, newComeOut };
}
// Resolve a single bet. Returns one of:
// { result:'open', bet } — unresolved (bet may have mutated)
// { result:'win', winnings, keep } — paid; keep=true leaves it on the felt
// { result:'lose' } | { result:'push' }
function resolveBet(bet, { total, point, isComeOut }) {
switch (bet.type) {
case BET.FIELD: {
if ([2, 3, 4, 9, 10, 11, 12].includes(total)) {
return { result: 'win', winnings: fieldWin(bet.amount, total) };
}
return { result: 'lose' };
}
case BET.PASS: {
if (isComeOut) {
if (total === 7 || total === 11) return { result: 'win', winnings: bet.amount };
if (total === 2 || total === 3 || total === 12) return { result: 'lose' };
return { result: 'open' }; // point established, ride it
}
if (total === point) {
const winnings = bet.amount + passOddsWin(bet.oddsAmount, point);
return { result: 'win', winnings };
}
if (total === 7) return { result: 'lose' };
return { result: 'open' };
}
case BET.DONT_PASS: {
if (isComeOut) {
if (total === 2 || total === 3) return { result: 'win', winnings: bet.amount };
if (total === 12) return { result: 'push' }; // bar 12
if (total === 7 || total === 11) return { result: 'lose' };
return { result: 'open' };
}
if (total === 7) {
const winnings = bet.amount + dontOddsWin(bet.oddsAmount, point);
return { result: 'win', winnings };
}
if (total === point) return { result: 'lose' };
return { result: 'open' };
}
case BET.PLACE: {
if (!bet.working) return { result: 'open' };
if (total === bet.number) {
return { result: 'win', winnings: placeWin(bet.amount, bet.number), keep: true };
}
if (total === 7) return { result: 'lose' };
return { result: 'open' };
}
case BET.COME:
case BET.DONT_COME: {
const isDont = bet.type === BET.DONT_COME;
if (bet.comePoint == null) {
// This roll is the come bet's own come-out.
if (total === 7 || total === 11) return isDont ? { result: 'lose' } : { result: 'win', winnings: bet.amount };
if (total === 2 || total === 3) return isDont ? { result: 'win', winnings: bet.amount } : { result: 'lose' };
if (total === 12) return isDont ? { result: 'push' } : { result: 'lose' };
// Travels to its number.
return { result: 'open', bet: { ...bet, comePoint: total } };
}
// Come point established.
if (total === bet.comePoint) {
if (isDont) return { result: 'lose' };
return { result: 'win', winnings: bet.amount + passOddsWin(bet.oddsAmount, bet.comePoint) };
}
if (total === 7) {
if (isDont) return { result: 'win', winnings: bet.amount + dontOddsWin(bet.oddsAmount, bet.comePoint) };
return { result: 'lose' };
}
return { result: 'open' };
}
default:
return { result: 'open' };
}
}
// ─── Round / table helpers ─────────────────────────────────────────────────--
export function clearLastDeltas(state) {
const s = cloneState(state);
for (const p of s.players) p.lastDelta = 0;
return s;
}
export function totalAtRisk(player) {
return player.bets.reduce((sum, b) => sum + b.amount + b.oddsAmount, 0);
}
export function hasActiveBets(player) {
return player.bets.length > 0;
}
// Human net result vs. the chips they walked in with, for match history.
export function getNetResult(player, startingChips) {
if (player.chips > startingChips) return 'win';
if (player.chips < startingChips) return 'loss';
return 'draw';
}