381 lines
13 KiB
JavaScript
381 lines
13 KiB
JavaScript
// 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';
|
||
}
|