Commit a855527a authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(backgammon): forced moves, variant rules, online sync, bot payouts

Rules: move generation is rewritten so both dice must be played when they
can be, otherwise the larger. 31 used a long-backgammon setup and allowed
pins; it now starts like mahbousa, a single checker closes a point and the
winner scores the loser's remaining checkers (lengths 1/11/21/31).
Mahbousa could freeze forever when both starting stacks were pinned; the
plakoto mother rule now settles it. Turning the cube off lasted one game,
taking the bot's double froze the game, and a player on black saw the
board upside down with a swapped score.

Online: a poll could wipe unsent moves, the cube never synced, 'opponent'
was sent as the winner id, and a refreshed tab rejoined as white. Online
is now a numbered move log plus a snapshot; the other side replays the log
with animation and checks it matches. Timers stop when the scene closes.

Bot games get a server match row (action start) and complete pays the
creator once; a row under 90 s old closes with no payout.

Look: flat teal board, red and white points, checkers on a lip, flat dice
and cube. backgammon-rules 659/659 (brute-force cross-check); browser
suite 122 + online 39/39 + layout 20/20, zero console errors.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 8a0227c2
......@@ -21,6 +21,7 @@ $action = $input['action'] ?? '';
const BG_CLAIM_PLACEHOLDER = '00000000-0000-0000-0000-000000000000';
switch ($action) {
case 'start': handleStart($userId, $input); break;
case 'queue': handleQueue($userId, $input); break;
case 'dequeue': handleBgDequeue($userId); break;
case 'status': handleStatus($userId); break;
......@@ -33,6 +34,31 @@ switch ($action) {
default: jsonError('Invalid action');
}
/**
* A game against the bot gets a real match row, players [me, 'bot_<level>'],
* so its win can be paid once by complete (as domino does).
*/
function handleStart(string $userId, array $input): void {
$level = in_array($input['bot_level'] ?? '', ['easy', 'medium', 'hard'], true) ? $input['bot_level'] : 'medium';
$variant = in_array($input['variant'] ?? '', ['sheshbesh', 'mahbousa', 'thirtyone'], true) ? $input['variant'] : 'sheshbesh';
$length = max(1, min(31, intval($input['match_length'] ?? 3)));
$match = supabaseService()->insert('backgammon_matches', [
'status' => 'in_progress',
'players' => [$userId, 'bot_' . $level],
'colors' => ['white', 'black'],
'variant' => $variant,
'match_length' => $length,
'scores' => [0, 0],
'current_turn' => 0,
'game_state' => ['mode' => 'bot', 'bot_level' => $level],
'host_id' => $userId,
'created_by' => $userId,
]);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if (!$matchId) jsonError('Could not start the match', 500);
jsonResponse(['match_id' => $matchId]);
}
/** jsonb columns arrive as arrays; rows written json_encode()d arrive as strings. */
function bgJson($value, array $default = []): array {
if (is_array($value)) return $value;
......@@ -296,8 +322,38 @@ function handleComplete(string $userId, array $input): void {
// The client names the winner as its own id or as 'opponent'. Resolve that
// against the seats, so winner_id is always a seated player's id (or null)
// and never the string 'opponent', which a uuid column refuses.
$rows = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'players', 'limit' => 1]);
$players = (is_array($rows) && !isset($rows['error']) && !empty($rows)) ? bgJson($rows[0]['players'] ?? null) : [];
$rows = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'players,status,created_by,created_at', 'limit' => 1]);
$row = (is_array($rows) && !isset($rows['error']) && !empty($rows)) ? $rows[0] : null;
$players = $row ? bgJson($row['players'] ?? null) : [];
$isBot = (bool) array_filter($players, fn($p) => is_string($p) && str_starts_with($p, 'bot_'));
if ($isBot) {
// A bot match pays its creator once, while it is still being played.
if (!$row || ($row['status'] ?? '') !== 'in_progress') jsonResponse(['success' => true, 'already_completed' => true, 'rewards' => []]);
if (($row['created_by'] ?? null) !== $userId) jsonError('Not your match', 403);
$botId = array_values(array_filter($players, fn($p) => is_string($p) && str_starts_with($p, 'bot_')))[0];
if ($winner !== $userId) $winner = $botId;
// Anti-farming: a real game against the bot takes minutes.
$tooQuick = strtotime($row['created_at'] ?? 'now') > time() - 90;
$claimed = $sdb->update('backgammon_matches', [
'status' => 'completed',
'completed_at' => date('c'),
'winner_id' => $winner === $userId ? $userId : null,
'result' => $tooQuick ? 'too_short' : $reason,
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId, 'completed_at' => 'is.null']);
if (isset($claimed['error'])) jsonError('Could not complete the match', 500);
if (empty($claimed)) jsonResponse(['success' => true, 'already_completed' => true, 'rewards' => []]);
if ($tooQuick) jsonResponse(['success' => true, 'too_short' => true, 'rewards' => []]);
// Bot ids map to null: the function skips nulls, and a 'bot_' id fails its uuid cast.
$result = supabaseRpc('complete_match', [
'p_game_key' => 'backgammon',
'p_match_id' => $matchId,
'p_winners' => [$winner === $userId ? $userId : null],
'p_reason' => $reason
]);
jsonResponse($result ?: ['success' => true, 'rewards' => []]);
}
if ($winner === 'opponent') {
$others = array_values(array_filter($players, fn($p) => $p !== $userId));
$winner = $others[0] ?? null;
......
// 3D dice renderer with physics bounce & spin animation
const DIE_SIZE = 38;
const DOT_RADIUS = 3.5;
export function drawDice(ctx, x, y, values, options = {}) {
const { used = [], rolling = false, frame = 0, totalFrames = 24 } = options;
for (let i = 0; i < values.length; i++) {
const dx = x + i * (DIE_SIZE + 12);
const val = rolling ? Math.floor(Math.random() * 6) + 1 : values[i];
const isUsed = used.includes(i);
// Stagger animation per die
const dieFrame = rolling ? Math.max(0, frame - i * 3) : 0;
drawSingleDie(ctx, dx, y, val, isUsed, dieFrame, totalFrames);
// Dice, Night Arcade: flat white cubes with ink pips, standing on a lip. A
// throw comes in from the thrower's edge of the board, tumbling (faces
// flicker, the cube spins and its lip deepens as it rolls), hops twice and
// settles on its value.
import { palette, roundRect } from './board-renderer.js';
const TAU = Math.PI * 2;
/** Both players' dice are white; kept as a hook for a themed set. */
export const skinFor = () => 'white';
function pipSpots(v) {
const a = 0.27, b = 0.5, c = 0.73;
switch (v) {
case 1: return [[b, b]];
case 2: return [[a, c], [c, a]];
case 3: return [[a, c], [b, b], [c, a]];
case 4: return [[a, a], [a, c], [c, a], [c, c]];
case 5: return [[a, a], [a, c], [b, b], [c, a], [c, c]];
default: return [[a, a], [a, b], [a, c], [c, a], [c, b], [c, c]];
}
}
function drawSingleDie(ctx, x, y, value, isUsed, frame, totalFrames) {
const t = frame > 0 ? frame / totalFrames : 0;
// Physics: bounce with damping
const bounceY = frame > 0 ? -Math.abs(Math.sin(t * Math.PI * 3)) * 12 * (1 - t) : 0;
const rotation = frame > 0 ? t * Math.PI * 6 * (1 - t * 0.5) : 0;
const scale = frame > 0 ? 1 + Math.sin(t * Math.PI) * 0.15 : 1;
/**
* One die centred at (x, y). opts: rot (radians), scale, used (dimmed),
* lift (height above the field, px), roll (0..1, mid-tumble), alpha.
*/
export function drawDie(ctx, x, y, size, value, opts = {}) {
const { rot = 0, scale = 1, used = false, lift = 0, roll = 0, alpha = 1 } = opts;
const P = palette();
const s = size * scale * (used ? 0.86 : 1);
const r = s * 0.24;
const a = alpha * (used ? 0.38 : 1);
if (lift > 0.5) {
// A short soft shadow on the field while it is in the air
ctx.save();
ctx.translate(x + DIE_SIZE / 2, y + DIE_SIZE / 2 + bounceY);
ctx.rotate(rotation);
ctx.scale(scale, scale);
ctx.translate(-DIE_SIZE / 2, -DIE_SIZE / 2);
// Shadow (moves with bounce)
if (!isUsed) {
const shadowBlur = 6 + Math.abs(bounceY) * 0.5;
ctx.shadowColor = 'rgba(0,0,0,0.4)';
ctx.shadowBlur = shadowBlur;
ctx.shadowOffsetY = 3 - bounceY * 0.3;
}
// Die body — 3D face with gradient
const bodyGrad = ctx.createLinearGradient(0, 0, DIE_SIZE, DIE_SIZE);
if (isUsed) {
bodyGrad.addColorStop(0, 'rgba(180, 180, 180, 0.35)');
bodyGrad.addColorStop(1, 'rgba(140, 140, 140, 0.25)');
} else {
bodyGrad.addColorStop(0, '#FFFEF5');
bodyGrad.addColorStop(0.4, '#FFF8E8');
bodyGrad.addColorStop(1, '#F0E0C0');
}
ctx.fillStyle = bodyGrad;
roundRect(ctx, 0, 0, DIE_SIZE, DIE_SIZE, 6);
ctx.fill();
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
// Border
ctx.strokeStyle = isUsed ? 'rgba(150,150,150,0.3)' : '#8B6914';
ctx.lineWidth = isUsed ? 1 : 1.5;
roundRect(ctx, 0, 0, DIE_SIZE, DIE_SIZE, 6);
ctx.stroke();
// Inner edge highlight (3D effect)
if (!isUsed) {
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1;
roundRect(ctx, 1.5, 1.5, DIE_SIZE - 3, DIE_SIZE - 3, 5);
ctx.stroke();
}
if (isUsed) ctx.globalAlpha = 0.35;
// Dots with slight shadow
const dotColor = isUsed ? '#666' : '#1A0800';
const positions = getDotPositions(value, DIE_SIZE);
for (const [dx, dy] of positions) {
if (!isUsed) {
ctx.globalAlpha = a;
ctx.beginPath();
ctx.arc(dx + 0.5, dy + 0.5, DOT_RADIUS, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.15)';
ctx.ellipse(x, y + s * 0.42, s * (0.5 + lift * 0.006), s * 0.18, 0, 0, TAU);
ctx.fillStyle = `rgba(0,0,0,${Math.max(0.08, 0.24 - lift * 0.006)})`;
ctx.fill();
ctx.restore();
}
ctx.save();
ctx.globalAlpha = a;
ctx.translate(x, y - lift);
ctx.rotate(rot);
const depth = s * (0.12 + 0.1 * Math.abs(Math.sin(roll * Math.PI)));
roundRect(ctx, -s / 2, -s / 2 + depth, s, s, r);
ctx.fillStyle = P.cardEdge;
ctx.fill();
roundRect(ctx, -s / 2, -s / 2, s, s, r);
ctx.fillStyle = P.card;
ctx.fill();
const pr = s * 0.09;
ctx.fillStyle = P.ink;
for (const [px, py] of pipSpots(value)) {
ctx.beginPath();
ctx.arc(dx, dy, DOT_RADIUS, 0, Math.PI * 2);
ctx.fillStyle = dotColor;
ctx.arc(-s / 2 + px * s, -s / 2 + py * s, pr, 0, TAU);
ctx.fill();
}
ctx.restore();
}
function getDotPositions(value, size) {
const c = size / 2;
const q1 = size * 0.24;
const q3 = size * 0.76;
switch (value) {
case 1: return [[c, c]];
case 2: return [[q1, q3], [q3, q1]];
case 3: return [[q1, q3], [c, c], [q3, q1]];
case 4: return [[q1, q1], [q1, q3], [q3, q1], [q3, q3]];
case 5: return [[q1, q1], [q1, q3], [c, c], [q3, q1], [q3, q3]];
case 6: return [[q1, q1], [q1, c], [q1, q3], [q3, q1], [q3, c], [q3, q3]];
default: return [[c, c]];
}
/** Where n dice sit around a centre point. */
export function diceSpots(center, n, size) {
const gap = size * 0.24;
const total = n * size + (n - 1) * gap;
const out = [];
for (let i = 0; i < n; i++) out.push({ x: center.x - total / 2 + size / 2 + i * (size + gap), y: center.y });
return out;
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
export function createRollAnimation(onFrame, onComplete) {
const totalFrames = 24;
const duration = 700;
let startTime = null;
function tick(now) {
if (!startTime) startTime = now;
const elapsed = now - startTime;
const frame = Math.min(totalFrames, Math.floor((elapsed / duration) * totalFrames));
onFrame(frame, totalFrames);
if (elapsed < duration) {
requestAnimationFrame(tick);
} else {
onComplete();
}
}
requestAnimationFrame(tick);
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
/**
* A throw of two dice: from `from` (the thrower's edge) to their resting
* spots. frame(now) returns each die's pose; done(now) once they rest.
* onBounce(i) fires when die i touches the field (for the clack).
*/
export function createThrow({ values, from, spots, duration = 760, onBounce = null, now = performance.now() }) {
const dice = values.map((v, i) => ({
value: v,
from: { x: from.x + (i ? 1 : -1) * 18, y: from.y },
to: spots[i],
spin: (i ? -1 : 1) * (2.2 + Math.random() * 1.4),
delay: i * 70,
face: 1 + Math.floor(Math.random() * 6),
lastFlip: 0,
bounced: [false, false, false],
}));
const start = now;
return {
duration: duration + 70,
frame(t) {
return dice.map((d, i) => {
const p = Math.max(0, Math.min(1, (t - start - d.delay) / duration));
const e = easeOut(p);
const x = d.from.x + (d.to.x - d.from.x) * e;
const y = d.from.y + (d.to.y - d.from.y) * e;
// Two hops, lower each time, then rest
const hop = Math.abs(Math.sin(p * Math.PI * 2.6)) * Math.pow(1 - p, 1.3);
const lift = hop * 26;
const contact = [0.385, 0.77, 0.99].findIndex((c, k) => p >= c && !d.bounced[k]);
if (contact >= 0) { d.bounced[contact] = true; onBounce?.(i, contact); }
if (p < 0.82 && t - d.lastFlip > 55) { d.face = 1 + Math.floor(Math.random() * 6); d.lastFlip = t; }
const value = p >= 0.82 ? d.value : d.face;
const rot = d.spin * (1 - e) * Math.PI * 1.5 + (p >= 1 ? 0 : 0);
const roll = p < 0.82 ? (p * 9) % 1 : 0;
const squash = lift < 1.5 && p < 0.98 && p > 0.05 ? 0.92 : 1;
return { x, y, lift, rot, value, roll, scale: (1 + lift * 0.006) * squash, p };
});
},
done(t) { return t - start >= this.duration; },
};
}
// Arc-based move animator with overshoot bounce + particle burst
import { getPointCoords } from './board-renderer.js';
// Flights for checkers: a moved checker is lifted, carried in an arc and set
// down; a hit checker is knocked off its point and tumbles to the bar; a
// borne-off checker flies into the tray and turns on edge as it drops in.
// The scene draws each pose; nothing here touches the game state.
const MOVE_DURATION = 320;
const HIT_DURATION = 420;
const BEAR_DURATION = 380;
const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
export function createMoveAnimation(from, to, layout, onFrame, onComplete) {
const startPos = getPixelPos(from, layout);
const endPos = getPixelPos(to, layout);
if (!startPos || !endPos) { onComplete(); return; }
const startTime = performance.now();
const isHit = to === 'bar';
const isBear = to === 'off';
const duration = isHit ? HIT_DURATION : isBear ? BEAR_DURATION : MOVE_DURATION;
// Arc height proportional to distance
const dist = Math.hypot(endPos.x - startPos.x, endPos.y - startPos.y);
const arcHeight = Math.min(dist * 0.4, 60);
function tick(now) {
const elapsed = now - startTime;
const raw = Math.min(1, elapsed / duration);
// Ease: overshoot for moves, bounce for hits
const t = isHit ? easeOutBounce(raw) : easeOutBack(raw);
const x = startPos.x + (endPos.x - startPos.x) * t;
const y = startPos.y + (endPos.y - startPos.y) * t;
// Parabolic arc
const arcY = -arcHeight * 4 * raw * (1 - raw);
// Scale pulse on landing
const scale = raw > 0.85 ? 1 + (1 - raw) * 2 * 0.15 : 1;
onFrame({ x, y: y + arcY, progress: raw, scale, isLanding: raw > 0.95 });
if (raw < 1) {
requestAnimationFrame(tick);
} else {
onComplete();
}
}
requestAnimationFrame(tick);
function readNumber(key, fallback) {
const v = Number(window.__EL3AB_THEME?.[key]);
return Number.isFinite(v) && v > 0 ? v : fallback;
}
export function createHitAnimation(hitPos, onFrame, onComplete) {
const startTime = performance.now();
const duration = 300;
function tick(now) {
const elapsed = now - startTime;
const t = Math.min(1, elapsed / duration);
const shake = Math.sin(t * Math.PI * 4) * (1 - t) * 6;
const scale = 1 + Math.sin(t * Math.PI) * 0.3;
onFrame({ shake, scale, progress: t });
if (t < 1) requestAnimationFrame(tick);
else onComplete();
}
requestAnimationFrame(tick);
/** How long a move takes, from the branding admin (bg_move_duration), 260..600 ms. */
export function moveDuration() { return Math.max(260, Math.min(600, readNumber('bg_move_duration', 340))); }
function arcRatio() { return Math.max(0, Math.min(1, readNumber('bg_arc_height', 0.4))); }
/**
* from/to: {x, y} canvas points. kind: 'move' | 'hit' | 'bear'.
* speed divides the duration (tests run fast).
*/
export function createFlight({ from, to, kind = 'move', speed = 1, now = performance.now() }) {
const dist = Math.hypot(to.x - from.x, to.y - from.y);
const base = kind === 'hit' ? 380 : kind === 'bear' ? moveDuration() + 60 : moveDuration();
const duration = (base + Math.min(160, dist * 0.35)) / speed;
const arcH = kind === 'hit' ? Math.min(46, 18 + dist * 0.18) : Math.min(64, 10 + dist * arcRatio() * 0.5);
const spin = kind === 'hit' ? (to.x > from.x ? 1 : -1) * Math.PI * 3 : 0;
return {
kind, duration, from, to,
pose(t) {
const p = Math.max(0, Math.min(1, (t - now) / duration));
const e = kind === 'hit' ? easeOut(p) : easeInOut(p);
const x = from.x + (to.x - from.x) * e;
const y = from.y + (to.y - from.y) * e;
const lift = arcH * 4 * p * (1 - p);
const scale = kind === 'bear' ? 1 - 0.25 * easeOut(p) : 1 + lift * 0.004;
return { x, y, lift, scale, rot: spin * (1 - e), p };
},
done(t) { return t - now >= duration; },
};
}
export function createBearOffAnimation(pos, onFrame, onComplete) {
const startTime = performance.now();
const duration = 400;
function tick(now) {
const elapsed = now - startTime;
const t = Math.min(1, elapsed / duration);
const ease = easeOutCubic(t);
const scale = 1 - ease * 0.6;
const alpha = 1 - ease;
const y = pos.y + ease * 30;
onFrame({ x: pos.x, y, scale, alpha, progress: t });
if (t < 1) requestAnimationFrame(tick);
else onComplete();
}
/** Back-compat wrapper: animate from one point to another with callbacks. */
export function createMoveAnimation(fromPos, toPos, _layout, onFrame, onComplete) {
if (!fromPos || !toPos) { onComplete(); return; }
const f = createFlight({ from: fromPos, to: toPos });
const tick = (t) => {
const ps = f.pose(t);
onFrame({ x: ps.x, y: ps.y - ps.lift, scale: ps.scale, progress: ps.p });
if (f.done(t)) onComplete(); else requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
function getPixelPos(location, layout) {
if (!layout) return null;
const { margin, pointW, pointH, barWidth, boardH, bearW, innerW, checkerR } = layout;
const barX = margin + 6 * pointW;
const bearX = margin + innerW;
if (location === 'bar') return { x: barX + barWidth / 2, y: margin + boardH / 2 };
if (location === 'off') return { x: bearX + bearW / 2, y: margin + boardH / 2 };
if (typeof location === 'number' && location >= 0 && location <= 23) {
const { x, y, isTop } = getPointCoords(location, margin, pointW, pointH, barWidth, boardH);
return { x: x + pointW / 2, y: isTop ? y + checkerR * 2 : y - checkerR * 2 };
}
return null;
}
function easeOutBack(t) {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}
function easeOutBounce(t) {
if (t < 1 / 2.75) return 7.5625 * t * t;
if (t < 2 / 2.75) { t -= 1.5 / 2.75; return 7.5625 * t * t + 0.75; }
if (t < 2.5 / 2.75) { t -= 2.25 / 2.75; return 7.5625 * t * t + 0.9375; }
t -= 2.625 / 2.75;
return 7.5625 * t * t + 0.984375;
}
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
// Bot AI for Backgammon — 3 difficulty levels
import { getValidMoves, applyMove, cloneState, getPipCount, WHITE, BLACK } from './rules.js';
export function getBotMove(game, difficulty = 'medium') {
const moves = getValidMoves(game);
if (moves.length === 0) return null;
if (difficulty === 'easy') return getEasyMove(moves);
if (difficulty === 'hard') return getHardMove(game, moves);
return getMediumMove(game, moves);
}
function getEasyMove(moves) {
return moves[Math.floor(Math.random() * moves.length)];
}
function getMediumMove(game, moves) {
let best = moves[0];
let bestScore = -Infinity;
for (const move of moves) {
const score = scoreMoveSimple(game, move);
if (score > bestScore) {
bestScore = score;
best = move;
}
}
return best;
}
function getHardMove(game, moves) {
let best = moves[0];
let bestScore = -Infinity;
for (const move of moves) {
const score = scoreMoveAdvanced(game, move);
if (score > bestScore) {
bestScore = score;
best = move;
}
// Tawla bot. It looks at every distinct complete play for its roll and scores
// the position each one leaves: the race, made points and primes, blots the
// opponent can reach, pins (mahbousa) and blocks (31). Easy plays any legal
// play, medium scores with some noise, hard plays the best it sees.
import { getTurnPlays, getValidMoves, variantInfo, WHITE, BLACK } from './rules.js';
const norm = (pos, type) => (type === WHITE ? pos : 23 - pos);
/** The whole turn the bot will play: { moves: [{from, steps, to, kind}] } or null. */
export function getBotPlay(game, difficulty = 'medium') {
const plays = getTurnPlays(game);
if (!plays.length) return null;
if (difficulty === 'easy') return plays[Math.floor(Math.random() * plays.length)];
const capture = variantInfo(game.variant).capture;
let best = plays[0], bestScore = -Infinity;
for (const p of plays) {
let s = evaluate(p.board, game.turn, capture);
if (difficulty === 'medium') s += (Math.random() - 0.5) * 14;
if (s > bestScore) { bestScore = s; best = p; }
}
return best;
}
function scoreMoveSimple(game, move) {
let score = 0;
for (const action of move.actions) {
if (action.type === 'hit') score += 30;
if (action.type === 'bear') score += 50;
}
// Prefer moving from bar
if (move.from === 'bar') score += 20;
// Prefer making points (landing where we already have pieces)
if (move.to !== 'off' && move.to >= 0 && move.to <= 23) {
const destCount = game.state.points[move.to].filter(p => p.type === game.turn).length;
if (destCount === 1) score += 15; // making a point
}
// Avoid leaving blots
if (move.from >= 0 && move.from <= 23) {
const remaining = game.state.points[move.from].filter(p => p.type === game.turn).length - 1;
if (remaining === 1) score -= 10;
}
return score;
/** One move, for callers that still play move by move. */
export function getBotMove(game, difficulty = 'medium') {
const play = getBotPlay(game, difficulty);
if (!play?.moves.length) return null;
const first = play.moves[0];
return getValidMoves(game).find(m => m.from === first.from && m.steps === first.steps) || null;
}
function scoreMoveAdvanced(game, move) {
let score = 0;
const type = game.turn;
const opp = type === WHITE ? BLACK : WHITE;
// Hit value — sending opponent back is huge
for (const action of move.actions) {
if (action.type === 'hit') score += 40;
if (action.type === 'bear') score += 60;
}
// Blot safety — penalize leaving blots
if (move.from >= 0 && move.from <= 23) {
const remaining = game.state.points[move.from].filter(p => p.type === type).length - 1;
if (remaining === 1) {
// How exposed is this blot?
const exposure = calculateBlotExposure(game.state, move.from, opp);
score -= exposure * 8;
}
}
// Point-making — landing to create a 2+ stack
if (move.to !== 'off' && move.to >= 0 && move.to <= 23) {
const friendsAtDest = game.state.points[move.to].filter(p => p.type === type).length;
if (friendsAtDest === 1) score += 20; // making a new point
if (friendsAtDest >= 2) score += 5; // adding to existing point
}
// Prime building — consecutive blocked points
if (move.to !== 'off' && move.to >= 0 && move.to <= 23) {
score += primeBonus(game.state, move.to, type) * 8;
}
// Anchor value — holding points deep in opponent's board
if (move.to !== 'off' && move.to >= 0 && move.to <= 23) {
const norm = type === WHITE ? move.to : 23 - move.to;
if (norm >= 18) score += 12; // anchor in opponent's home
}
// Race — prefer bearing off and advancing when ahead
const myPips = getPipCount(game.state, type, game.variant);
const oppPips = getPipCount(game.state, opp, game.variant);
if (myPips < oppPips) {
score += move.steps * 2; // racing advantage, advance further
}
// Advancing runners from deep position
if (move.from >= 0 && move.from <= 23) {
const normFrom = type === WHITE ? move.from : 23 - move.from;
if (normFrom >= 20) score += 8; // escape back runners
}
return score;
function pips(b, type) {
let n = 0;
for (let i = 0; i < 24; i++) for (const t of b.pts[i]) if (t === type) n += norm(i, type) + 1;
return n + b.bar[type] * 25;
}
function calculateBlotExposure(state, pos, opponentType) {
let threats = 0;
for (let dist = 1; dist <= 6; dist++) {
const fromPos = opponentType === WHITE ? pos + dist : pos - dist;
if (fromPos < 0 || fromPos > 23) continue;
if (state.points[fromPos].some(p => p.type === opponentType)) {
threats++;
}
}
// Also count bar pieces as threats
if (state.bar[opponentType].length > 0) {
const barEntry = opponentType === WHITE ? (23 - pos) : pos;
if (barEntry >= 1 && barEntry <= 6) threats += state.bar[opponentType].length;
}
return threats;
/** Rough count of rolls that bring an opposing checker onto point i. */
function shotsAt(b, i, opp) {
let shots = 0;
for (let d = 1; d <= 12; d++) {
const j = opp === WHITE ? i + d : i - d; // where an opposing checker would come from
let src = false;
if (j >= 0 && j <= 23) {
const pt = b.pts[j];
src = pt.length && pt[pt.length - 1] === opp;
} else if ((opp === WHITE && j === 24) || (opp === BLACK && j === -1)) {
src = b.bar[opp] > 0;
}
if (src) shots += d <= 6 ? 1 : 0.35;
}
return shots;
}
function primeBonus(state, point, type) {
let consecutive = 0;
for (let i = point - 3; i <= point + 3; i++) {
if (i < 0 || i > 23) continue;
if (state.points[i].filter(p => p.type === type).length >= 2) {
consecutive++;
} else {
if (i > point) break;
consecutive = 0;
}
}
return Math.max(0, consecutive - 1);
function evaluate(b, me, capture) {
const opp = me === WHITE ? BLACK : WHITE;
const myPips = pips(b, me), oppPips = pips(b, opp);
let s = (oppPips - myPips) * 1.0 + (b.off[me] - b.off[opp]) * 4;
// The opponent's rearmost checker: points beyond it no longer block anything.
let oppBack = -1;
for (let i = 0; i < 24; i++) if (b.pts[i].includes(opp)) oppBack = Math.max(oppBack, norm(i, opp));
if (b.bar[opp]) oppBack = 24;
let myBack = -1;
for (let i = 0; i < 24; i++) if (b.pts[i].includes(me)) myBack = Math.max(myBack, norm(i, me));
if (b.bar[me]) myBack = 24;
const contact = myBack + oppBack >= 23;
let run = 0, bestRun = 0;
for (let n = 23; n >= 0; n--) {
const i = me === WHITE ? n : 23 - n;
const pt = b.pts[i];
const top = pt.length ? pt[pt.length - 1] : null;
const mine = pt.filter(t => t === me).length;
// In front of the opponent's checkers (from its side), a point blocks it.
const blocks = 23 - n <= oppBack;
const held = top === me && (capture === 'block' ? mine >= 1 : mine >= 2);
if (held && blocks && contact) {
run++; bestRun = Math.max(bestRun, run);
s += n <= 5 ? 5 + (n >= 3 ? 2 : 0) : n >= 18 ? 3 : 3;
} else if (!(top === me && mine >= 1 && capture === 'block')) {
run = 0;
}
if (contact && capture !== 'block' && top === me && pt.length === 1) {
const shots = shotsAt(b, i, opp);
if (shots) s -= capture === 'hit' ? shots * (2.2 + (23 - n) * 0.18) : shots * (3 + (23 - n) * 0.22);
}
if (capture === 'pin' && pt.length > 1 && pt[0] !== top) {
const depth = norm(i, pt[0]) + 1; // how far the pinned checker still has to go
const v = 12 + depth * 0.5;
s += top === me ? v : -v;
}
}
if (contact) s += bestRun * bestRun * 1.6;
s += (b.bar[opp] - b.bar[me]) * 10;
return s;
}
// Doubling Cube logic for Backgammon match play
// The doubling cube. Centred, either player may double before rolling; once
// taken, only its owner may redouble. A dropped double loses the game at the
// cube's current value.
import { WHITE, BLACK } from './rules.js';
export function createCube() {
return {
value: 1,
owner: null, // null = center (either can double), WHITE or BLACK = only that player can
owner: null, // null = centre; WHITE or BLACK = only that player may double
offered: false,
offeredBy: null
offeredBy: null,
};
}
export function canDouble(cube, playerType) {
if (cube.offered) return false;
if (cube.owner === null) return true;
return cube.owner === playerType;
if (!cube || cube.offered) return false;
if (cube.value >= 64) return false;
return cube.owner === null || cube.owner === playerType;
}
export function offerDouble(cube, playerType) {
......@@ -24,7 +26,7 @@ export function offerDouble(cube, playerType) {
}
export function acceptDouble(cube) {
if (!cube.offered) return false;
if (!cube?.offered) return false;
cube.value *= 2;
cube.owner = cube.offeredBy === WHITE ? BLACK : WHITE;
cube.offered = false;
......@@ -32,33 +34,35 @@ export function acceptDouble(cube) {
return true;
}
/** Returns the side that declined (and so loses the game). */
export function declineDouble(cube) {
if (!cube.offered) return false;
cube.offered = false;
if (!cube?.offered) return null;
const decliner = cube.offeredBy === WHITE ? BLACK : WHITE;
cube.offered = false;
cube.offeredBy = null;
return decliner; // returns who declined (and thus loses)
return decliner;
}
export function getStake(cube, gameScore) {
return cube.value * gameScore;
return (cube ? cube.value : 1) * gameScore;
}
// Bot doubling decision based on pip count advantage
export function shouldBotDouble(cube, myPips, oppPips, difficulty) {
if (!canDouble(cube, BLACK)) return false;
const advantage = (oppPips - myPips) / oppPips;
/**
* The bot doubles when it leads the race clearly. myPips/oppPips are from
* the bot's side. Easy never doubles.
*/
export function shouldBotDouble(cube, myPips, oppPips, difficulty, botType = BLACK) {
if (!canDouble(cube, botType)) return false;
if (difficulty === 'easy') return false;
const advantage = (oppPips - myPips) / Math.max(1, oppPips);
if (difficulty === 'medium') return advantage > 0.25;
return advantage > 0.15; // hard bot doubles more aggressively
return advantage > 0.15;
}
/** Take or drop: myPips/oppPips are from the bot's side. Easy always takes. */
export function shouldBotAccept(cube, myPips, oppPips, difficulty) {
const disadvantage = (myPips - oppPips) / myPips;
if (difficulty === 'easy') return true; // easy bot always accepts
if (difficulty === 'easy') return true;
const disadvantage = (myPips - oppPips) / Math.max(1, myPips);
if (difficulty === 'medium') return disadvantage < 0.35;
return disadvantage < 0.25; // hard bot drops when too far behind
return disadvantage < 0.25;
}
// Match system — first-to-N points, Crawford rule
import { WHITE, BLACK, createGame, getGameScore } from './rules.js';
// A match: first to N points, the doubling cube, and the Crawford rule (no
// cube in the game right after someone reaches match point).
import { WHITE, BLACK, createGame } from './rules.js';
import { createCube } from './doubling.js';
export function createMatch(matchLength = 5, variant = 'sheshbesh') {
export function createMatch(matchLength = 3, variant = 'sheshbesh', useCube = true) {
return {
length: matchLength,
length: Math.max(1, Number(matchLength) || 1),
variant,
useCube: !!useCube,
scores: [0, 0], // [WHITE, BLACK]
gameNumber: 0,
currentGame: null,
cube: null,
crawford: false,
crawfordUsed: false,
postCrawford: false,
isOver: false,
winner: null,
postCrawford: false
lastResult: null,
};
}
......@@ -22,55 +25,60 @@ export function startNewGame(match) {
match.gameNumber++;
match.currentGame = createGame(match.variant);
// Crawford rule: no doubling in the game immediately after someone reaches match-point - 1
const atMatchPoint = match.scores[WHITE] === match.length - 1 || match.scores[BLACK] === match.length - 1;
if (atMatchPoint && !match.crawfordUsed) {
match.crawford = true;
match.crawfordUsed = true;
match.cube = null;
} else {
match.crawford = false;
if (match.crawfordUsed) match.postCrawford = true;
match.cube = createCube();
}
match.cube = match.useCube && !match.crawford ? createCube() : null;
// Alternate who goes first each game (simplified — real backgammon uses highest die)
if (match.gameNumber > 1) {
// White opens the first game; after that the games alternate.
match.currentGame.turn = match.gameNumber % 2 === 0 ? BLACK : WHITE;
}
return match.currentGame;
}
/** Score a finished game. gameScore is the game's own value, before the cube. */
export function endGame(match, winner, gameScore) {
const stake = match.cube ? match.cube.value * gameScore : gameScore;
const stake = (match.cube ? match.cube.value : 1) * gameScore;
match.scores[winner] += stake;
if (match.scores[winner] >= match.length) {
match.isOver = true;
match.winner = winner;
}
return {
winner,
gameScore,
stake,
scores: [...match.scores],
matchOver: match.isOver
};
match.lastResult = { winner, gameScore, stake, scores: [...match.scores], matchOver: match.isOver };
return match.lastResult;
}
export function canUseCube(match) {
return !match.crawford && match.cube !== null;
return !match.crawford && match.cube !== null && !match.isOver;
}
export function getMatchState(match) {
return {
length: match.length,
variant: match.variant,
useCube: match.useCube,
scores: [...match.scores],
gameNumber: match.gameNumber,
crawford: match.crawford,
crawfordUsed: match.crawfordUsed,
postCrawford: match.postCrawford,
isOver: match.isOver,
winner: match.winner
winner: match.winner,
cube: match.cube ? { ...match.cube } : null,
};
}
/** Restore a match from getMatchState() output (multiplayer sync). */
export function applyMatchState(match, ms) {
if (!ms) return match;
for (const k of ['length', 'variant', 'useCube', 'gameNumber', 'crawford', 'crawfordUsed', 'postCrawford', 'isOver', 'winner']) {
if (ms[k] !== undefined) match[k] = ms[k];
}
if (Array.isArray(ms.scores)) match.scores = [...ms.scores];
match.cube = ms.cube ? { ...ms.cube } : null;
return match;
}
This diff is collapsed.
This diff is collapsed.
......@@ -5,6 +5,12 @@ import * as store from '../../../core/store.js';
import { t } from '../../../core/i18n.js';
import { icon, GAME_MARK } from '../../../core/icons.js';
import { screenHead, modeButton, roomHero } from '../../../core/ui.js';
import { variantInfo } from '../logic/rules.js';
// The match lengths offered per variant (31 is played to 31), and whether the
// cube starts switched on: backgammon uses it, mahbousa and 31 traditionally do not.
const LENGTH_KEYS = ['short_match', 'medium_match', 'normal_match', 'long_match'];
const CUBE_DEFAULT = { sheshbesh: true, mahbousa: false, thirtyone: false };
export function mountRoom(el, params) {
const { mode = 'menu' } = params || {};
......@@ -98,15 +104,13 @@ function renderSetup(el, params) {
${chip('variant', 'mahbousa', false, `<span class="bg-chip-label">${t('backgammon.mahbousa')}</span>`)}
${chip('variant', 'thirtyone', false, `<span class="bg-chip-label">${t('backgammon.thirtyone')}</span>`)}
</div>
<p class="bg-rule" id="variant-rule">${t('backgammon.sheshbesh_desc')}</p>
</section>
<!-- Match Length -->
<section class="bg-section">
<h2 class="section-title bg-section-title">${t('backgammon.match_length')}</h2>
<div class="chips bg-grid" id="length-grid">
${[[1, 'short_match'], [3, 'medium_match'], [5, 'normal_match'], [7, 'long_match']].map(([n, key]) =>
chip('len', n, n === 3, `<span class="num bg-chip-num">${n}</span><span class="bg-chip-label">${t('backgammon.' + key)}</span>`)).join('')}
</div>
<div class="chips bg-grid" id="length-grid">${lengthChips('sheshbesh')}</div>
</section>
${!isOnline ? `
......@@ -143,8 +147,23 @@ function renderSetup(el, params) {
let difficulty = 'medium';
let useCube = true;
setupChipGroup(el, '#variant-grid', val => { variant = val; }, 'variant');
setupChipGroup(el, '#length-grid', val => { matchLength = parseInt(val); }, 'len');
const pickVariant = (val) => {
variant = val;
el.querySelector('#variant-rule').textContent = t('backgammon.' + val + '_desc');
// The lengths and the cube default follow the variant.
const grid = el.querySelector('#length-grid');
grid.innerHTML = lengthChips(val);
matchLength = variantInfo(val).defaultLength;
setupChipGroup(el, '#length-grid', v => { matchLength = parseInt(v, 10); }, 'len');
useCube = CUBE_DEFAULT[val];
el.querySelectorAll('#cube-grid .bg-chip').forEach(b => {
const on = (b.dataset.cube === 'on') === useCube;
b.classList.toggle('bg-chip-active', on);
b.setAttribute('aria-pressed', String(on));
});
};
setupChipGroup(el, '#variant-grid', pickVariant, 'variant');
setupChipGroup(el, '#length-grid', val => { matchLength = parseInt(val, 10); }, 'len');
if (!isOnline) setupChipGroup(el, '#difficulty-grid', val => { difficulty = val; }, 'diff');
setupChipGroup(el, '#cube-grid', val => { useCube = val === 'on'; }, 'cube');
......@@ -187,6 +206,12 @@ function renderSearching(el) {
el.querySelector('#btn-cancel').onclick = () => { audio.play('click'); scene.pop(); };
}
function lengthChips(variant) {
const info = variantInfo(variant);
return info.lengths.map((n, i) =>
chip('len', n, n === info.defaultLength, `<span class="num bg-chip-num">${n}</span><span class="bg-chip-label">${t('backgammon.' + LENGTH_KEYS[i])}</span>`)).join('');
}
function setupChipGroup(el, selector, onChange, dataKey) {
el.querySelectorAll(`${selector} .bg-chip`).forEach(btn => {
btn.addEventListener('click', () => {
......@@ -239,6 +264,7 @@ function getStyles() {
.bg-chip-active .bgr-pips { opacity: 1; }
.bgr-start { margin-top: var(--s-8); }
.bg-rule { margin-top: var(--s-2); font-size: var(--fs-sm); font-weight: 600; line-height: 1.5; color: var(--text-secondary); text-shadow: var(--text-shadow); }
/* Searching: a short stack of cards shuffling. */
.bgr-searching { min-height: 100%; justify-content: center; text-align: center; gap: var(--s-6); }
......
// Doubling cube 3D display widget
export function renderCubeDisplay(container, cubeValue, cubeOwner, myColor) {
let existing = container.querySelector('.bgg-cube-display');
if (!existing) {
existing = document.createElement('div');
existing.className = 'bgg-cube-display';
container.appendChild(existing);
}
// The doubling cube, drawn on the bar: centred while nobody owns it, at the
// owner's end of the bar once taken. When the stakes double it flips over
// and lands showing the new value.
if (!cubeValue || cubeValue === 1) {
existing.style.display = 'none';
return;
}
import { palette, roundRect, FONT } from '../canvas/board-renderer.js';
existing.style.display = '';
const position = cubeOwner === null ? 'center' : cubeOwner === myColor ? 'bottom' : 'top';
const TAU = Math.PI * 2;
existing.innerHTML = `
<div class="bgg-cube bgg-cube-${position}">
<div class="bgg-cube-face">
<span class="bgg-cube-val">${cubeValue}</span>
</div>
</div>
`;
/** Where the cube rests: 'center', 'me' or 'opp' end of the bar. */
export function cubeSpot(L, where) {
const size = Math.min(L.B - 2, 28);
const x = L.barX + L.B / 2;
const y = where === 'center' ? L.midY : where === 'me' ? L.fieldBottom - size * 0.9 : L.fieldTop + size * 0.9;
return { x, y, size };
}
export function createCubeStyles() {
return `
.bgg-cube-display { position:absolute;z-index:4;pointer-events:none; }
.bgg-cube-center { top:50%;right:8px;transform:translateY(-50%); }
.bgg-cube-top { top:8px;right:8px; }
.bgg-cube-bottom { bottom:8px;right:8px; }
.bgg-cube-face {
width:32px;height:32px;border-radius:6px;
background:linear-gradient(135deg,rgba(139,92,246,0.2),rgba(139,92,246,0.05));
border:1.5px solid rgba(139,92,246,0.4);
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 8px rgba(139,92,246,0.2);
/**
* Draw the cube: a flat white cube on its lip, the stake in red ink. flip:
* 0..1 progress of a flip (the face squeezes to an edge and opens on the new
* value), lift: px above the bar.
*/
export function drawCube(ctx, x, y, size, value, { flip = 1, lift = 0, prev = null } = {}) {
const P = palette();
const k = flip < 1 ? Math.abs(Math.cos(flip * Math.PI)) : 1;
const shown = flip < 0.5 && prev !== null ? prev : value;
const s = size;
ctx.save();
if (lift > 0.5) {
ctx.beginPath();
ctx.ellipse(x, y + s * 0.45, s * 0.5, s * 0.16, 0, 0, TAU);
ctx.fillStyle = 'rgba(0,0,0,0.22)';
ctx.fill();
}
.bgg-cube-val { font-size:14px;font-weight:900;color:var(--purple-light); }
`;
ctx.translate(x, y - lift);
ctx.scale(1, Math.max(0.06, k));
roundRect(ctx, -s / 2, -s / 2 + s * 0.12, s, s, s * 0.24);
ctx.fillStyle = P.cardEdge;
ctx.fill();
roundRect(ctx, -s / 2, -s / 2, s, s, s * 0.24);
ctx.fillStyle = P.card;
ctx.fill();
ctx.font = `800 ${Math.max(12, Math.round(s * (shown >= 10 ? 0.46 : 0.58)))}px ${FONT}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = P.redInk;
ctx.fillText(String(shown === 1 ? 64 : shown), 0, s * 0.04);
ctx.restore();
}
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment