Commit 4ce3d90f authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(ludo): online boards stay in sync; rules, bots, rewards; flat board

Online: at speed a client's writes trail its own board, and a stale-row
check read that lag as a stale row, reset the client and let it play again
from the older board: two writers with one sequence number. The check now
fires only with nothing in flight, every write is dropped while a resync
is pending, and a resync takes the row's sequence. The quiet-player notice
shows for each new silence, not once per match.

Rules: in 3-4 player games the first finisher kept a turn nobody could
take and the game stalled; three sixes is enforced and no longer carries
into the next turn. Seats come from the match, not the room. Exactly one
client settles a match, with real ids. Bots score their moves by
difficulty. A leaver is announced and in a duel the one who stays wins.
Names in the roll button are escaped.

Look: flat quadrants on white, pawns on a lip, a flat tumbling die.
tests/play/ludo.mjs 73/73 (local 4, duel, online), ludo-rules 60/60.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 95acecec
This diff is collapsed.
// Ludo bot: scores every legal move the way a decent player thinks about it
// (get home, hit, come out, reach safety, don't stop in front of an enemy) and
// plays the best one. The level decides how carefully it looks.
import { getValidMoves, globalSquare, isSafeSquare, pieceIndexOf, HOME_ENTRY_POS, TRACK_LEN, FINISH_POS } from './rules.js';
/**
* How many opponent tokens could land on shared square `g` with one roll.
* A token that would turn into its own home column before reaching g is no threat.
*/
function threatsOn(game, playerIdx, g) {
if (g == null || isSafeSquare(g)) return 0;
let n = 0;
for (let i = 0; i < game.numPlayers; i++) {
if (i === playerIdx || game.players[i].finished) continue;
for (const pc of game.players[i].pieces) {
if (pc.finished || pc.pos < 0 || pc.pos > HOME_ENTRY_POS) continue;
const og = globalSquare(game, i, pc.pos);
const d = (g - og + TRACK_LEN) % TRACK_LEN;
if (d >= 1 && d <= 6 && pc.pos + d <= HOME_ENTRY_POS) n++;
}
}
return n;
}
/** Score one move. Higher is better. */
export function scoreMove(game, playerIdx, move) {
const pieces = game.players[playerIdx].pieces;
const piece = pieces[pieceIndexOf(move)];
let s = move.to; // progress
if (move.type === 'finish') s += 1000;
if (move.type === 'capture') {
const g = globalSquare(game, playerIdx, move.to);
let victims = 0;
for (let i = 0; i < game.numPlayers; i++) {
if (i === playerIdx) continue;
for (const pc of game.players[i].pieces) {
if (!pc.finished && pc.pos >= 0 && globalSquare(game, i, pc.pos) === g) victims += 20 + pc.pos * 3;
}
}
s += 500 + victims;
}
if (move.type === 'enter') s += 360;
if (move.type === 'home') s += 260; // into the home column: out of reach for good
const fromG = piece.pos >= 0 ? globalSquare(game, playerIdx, piece.pos) : null;
const toG = move.to <= HOME_ENTRY_POS ? globalSquare(game, playerIdx, move.to) : null;
const fromThreat = fromG == null ? 0 : threatsOn(game, playerIdx, fromG);
const toThreat = toG == null ? 0 : threatsOn(game, playerIdx, toG);
s += fromThreat * 90; // escaping danger
s -= toThreat * 130; // stopping in front of an enemy
if (toG != null && isSafeSquare(toG)) s += 110;
// A token far along is worth more to protect.
if (fromThreat) s += piece.pos * 1.5;
// Leave the last stretch for exact rolls: prefer not to park just short of home.
if (move.to > HOME_ENTRY_POS && move.to < FINISH_POS) s += 10;
return s;
}
/**
* The bot's move for this roll, or null when nothing can move.
* level: 'easy' plays loosely, 'medium' sensibly with a little noise, 'hard' carefully.
*/
export function chooseMove(game, playerIdx, dice, level = 'medium') {
const moves = getValidMoves(game, playerIdx, dice);
if (!moves.length) return null;
if (moves.length === 1) return moves[0];
if (level === 'easy' && Math.random() < 0.45) return moves[Math.floor(Math.random() * moves.length)];
const noise = level === 'hard' ? 0 : level === 'easy' ? 220 : 70;
let best = null, bestScore = -Infinity;
for (const m of moves) {
const sc = scoreMove(game, playerIdx, m) + (noise ? (Math.random() - 0.5) * noise : 0);
if (sc > bestScore) { bestScore = sc; best = m; }
}
return best;
}
This diff is collapsed.
This source diff could not be displayed because it is too large. You can view the blob instead.
This diff is collapsed.
......@@ -5,6 +5,8 @@ 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 { getColor } from '../../../core/theme.js';
import { pawnSVG } from '../canvas/board.js';
export function mountRoom(el, params) {
const { mode = 'menu' } = params || {};
......@@ -229,6 +231,7 @@ function renderSetup(el, params) {
const seats = getSeatPositions(playerCount);
// The four player inks, in seat order: red, green, yellow, blue.
const inks = ['var(--ludo-red)', 'var(--ludo-green)', 'var(--ludo-yellow)', 'var(--ludo-blue)'];
const pawnInk = [getColor('ludo_red', '#FF4D5E'), getColor('ludo_green', '#2BCF76'), getColor('ludo_yellow', '#FFC531'), getColor('ludo_blue', '#3B86FF')];
const labels = [t('ludo.red'), t('ludo.green'), t('ludo.yellow'), t('ludo.blue')];
const positions = ['bottom-left', 'top-left', 'top-right', 'bottom-right'];
......@@ -242,7 +245,7 @@ function renderSetup(el, params) {
return `
<div class="lr-seat lr-seat-${positions[i]} ${active ? 'lr-seat-active' : 'lr-seat-empty'}" style="--seat-color:${inks[i]};">
${active ? `
<span class="lr-seat-token">${icon('game-ludo', 26)}</span>
<span class="lr-seat-token">${pawnSVG(pawnInk[i], 26, 'seat' + i)}</span>
<span class="lr-seat-label">${labels[i]}</span>
<span class="lr-seat-dot">${icon(isHuman ? 'user' : 'bot', 14)}<span>${who}</span></span>
` : `<span class="lr-seat-label">${who}</span>`}
......@@ -351,8 +354,8 @@ function getStyles() {
.lr-pips { display: inline-flex; gap: 2px; color: currentColor; opacity: 0.8; }
.lr-chip-active .lr-pips { opacity: 1; }
/* Seats: four card-stock squares placed as on the board (which does not
mirror), each with its player's token printed in that player's ink. */
/* Seats: four tiles placed as on the board (which does not mirror): a
white card with the player's pawn where someone sits, ink where nobody does. */
.lr-seat-preview { display: flex; justify-content: center; }
.lr-board-mini {
position: relative;
......@@ -372,34 +375,31 @@ function getStyles() {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
min-height: 92px;
padding: var(--s-2);
border-radius: var(--r-lg);
border-radius: 20px;
text-align: center;
transition: background var(--dur-normal) var(--ease-out);
}
.lr-seat-active {
background: var(--card);
color: var(--ink);
box-shadow: var(--shadow-sm);
outline: 1px solid var(--card-line);
outline-offset: -5px;
box-shadow: 0 4px 0 var(--card-edge);
}
.lr-seat-empty {
background-color: var(--back-700);
background-image: var(--back-pattern-dense);
background-size: 14px 14px;
background: var(--back-800);
box-shadow: 0 4px 0 var(--back-950);
color: var(--text-muted);
}
.lr-seat-token { display: flex; color: var(--seat-color); }
.lr-seat-token { display: flex; }
.lr-seat-label { font-size: var(--fs-sm); font-weight: 800; color: inherit; }
.lr-seat-dot { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-xs); font-weight: 500; color: var(--ink-3); }
.lr-board-center {
position: absolute; inset: 0; margin: auto;
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
border-radius: 9px;
background: var(--back-950);
border-radius: 14px;
background: var(--back-700);
color: var(--text-primary);
box-shadow: 0 0 0 4px var(--back-900);
box-shadow: 0 4px 0 var(--back-950), 0 0 0 4px var(--stage);
}
.lr-start { margin-top: var(--s-8); }
......@@ -409,7 +409,7 @@ function getStyles() {
.lr-searching .lr-hero { flex-direction: column; align-items: center; margin: 0; }
.lr-pulse-ring { display: grid; place-items: center; height: 120px; margin-bottom: var(--s-5); }
.lr-pulse-inner {
grid-area: 1 / 1; width: 64px; height: 90px; border-radius: 8px; box-shadow: var(--shadow-md);
grid-area: 1 / 1; width: 64px; height: 90px; border-radius: 12px; box-shadow: var(--shadow-md);
transform: rotate(calc((var(--q) - 1) * 6deg));
animation: lrShuffle 2.8s var(--ease-in-out) infinite;
animation-delay: calc(var(--q) * 90ms);
......
// The die: a flat white die on a lip, ink pips. A roll throws it up, it spins
// with its faces flicking past, and it lands on the rolled face with a squash.
// An admin can replace any face drawing by uploading "emoji_dice_<n>".
import { getAsset } from '../../../core/theme.js';
import { esc } from '../../../core/ui.js';
// Which of the nine pip places are printed for each face.
const PIPS = {
1: [0, 0, 0, 0, 1, 0, 0, 0, 0],
2: [0, 0, 1, 0, 0, 0, 1, 0, 0],
3: [0, 0, 1, 0, 1, 0, 1, 0, 0],
4: [1, 0, 1, 0, 0, 0, 1, 0, 1],
5: [1, 0, 1, 0, 1, 0, 1, 0, 1],
6: [1, 0, 1, 1, 0, 1, 1, 0, 1],
};
export function pipsMarkup(value) {
return (PIPS[value] || PIPS[1]).map(d => `<span class="ld-pip${d ? ' on' : ''}"></span>`).join('');
}
function faceInner(n) {
const url = getAsset('emoji_dice_' + n);
return url
? `<img src="${esc(url)}" alt="" width="44" height="44" style="width:44px;height:44px;object-fit:contain;" draggable="false">`
: pipsMarkup(n);
}
/** Build the die inside `host`; returns its controller. */
export function createDie(host) {
host.innerHTML = `<span class="ld-die-face" data-face="1">${faceInner(1)}</span><span class="ld-die-shadow"></span>`;
const face = host.querySelector('.ld-die-face');
const shadow = host.querySelector('.ld-die-shadow');
let value = 1;
const show = n => { face.dataset.face = String(n); face.innerHTML = faceInner(n); };
const reduced = () => !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
return {
get value() { return value; },
set(n) { value = n; face.getAnimations().forEach(a => a.cancel()); show(n); },
/** Throw the die and land on `n`; onLand fires as it hits the table. */
async roll(n, ms = 760, onLand) {
value = n;
if (reduced()) { show(n); onLand?.(); return; }
face.getAnimations().forEach(a => a.cancel());
const turns = (2 + Math.floor(Math.random() * 2)) * 360 * (Math.random() < 0.5 ? -1 : 1);
// Faces flick past while it is in the air.
let last = n;
const flick = setInterval(() => {
let f = 1 + Math.floor(Math.random() * 6);
if (f === last) f = (f % 6) + 1;
last = f;
show(f);
}, Math.max(40, ms / 10));
const anim = face.animate([
{ transform: 'translateY(0) rotate(0deg) scale(1)' },
{ transform: `translateY(-58%) rotate(${turns * 0.45}deg) scale(1.14)`, offset: 0.35 },
{ transform: `translateY(0) rotate(${turns}deg) scale(1)`, offset: 0.68 },
{ transform: `translateY(0) rotate(${turns}deg) scale(1.14, 0.84)`, offset: 0.78 },
{ transform: `translateY(-8%) rotate(${turns}deg) scale(0.96, 1.05)`, offset: 0.88 },
{ transform: `translateY(0) rotate(${turns}deg) scale(1)` },
], { duration: ms, easing: 'ease-in-out' });
shadow.animate([
{ transform: 'scale(1)', opacity: 0.5 },
{ transform: 'scale(0.55)', opacity: 0.2, offset: 0.35 },
{ transform: 'scale(1.1)', opacity: 0.55, offset: 0.7 },
{ transform: 'scale(1)', opacity: 0.5 },
], { duration: ms, easing: 'ease-in-out' });
setTimeout(() => { clearInterval(flick); show(n); onLand?.(); }, ms * 0.68);
await anim.finished.catch(() => {});
clearInterval(flick);
show(n);
},
/** A short shiver: the roll could not be played. */
shiver() {
if (reduced()) return;
face.animate([
{ transform: 'rotate(0)' }, { transform: 'rotate(-10deg)' }, { transform: 'rotate(9deg)' },
{ transform: 'rotate(-5deg)' }, { transform: 'rotate(0)' },
], { duration: 420, easing: 'ease-out' });
},
};
}
export const DIE_STYLE = `
.ld-die { position: relative; width: 58px; height: 58px; flex-shrink: 0; display: grid; place-items: center; }
.ld-die-face {
position: relative; z-index: 1;
width: 46px; height: 46px;
display: grid; grid-template: repeat(3, 1fr) / repeat(3, 1fr);
padding: 6px;
border-radius: 13px;
background: var(--card);
box-shadow: 0 4px 0 var(--card-edge);
}
.ld-die-face:has(img) { padding: 1px; display: block; }
.ld-pip { width: 8px; height: 8px; border-radius: 50%; margin: auto; }
.ld-pip.on { background: var(--ink); }
.ld-die-face[data-face="1"] .ld-pip.on { width: 12px; height: 12px; background: var(--red-ink); }
.ld-die-shadow {
position: absolute; inset-inline: 10px; bottom: 0; height: 8px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.35);
filter: blur(3px);
opacity: 0.5;
}
`;
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