Commit 929a1434 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: rework Ludo dice for fun tension — human luck + rubber-banding

The dice system now creates satisfying back-and-forth gameplay:

- Human players get a subtle persistent luck edge over bots (slight
  boost to 5s and 6s) — feels lucky without being obvious
- Rubber-band: whoever is behind gets help catching up; humans get
  an extra comeback boost when far behind the leader
- Momentum damper: bots that pull too far ahead get cooled down
- Stuck rescue: all-at-home gets aggressive six boost (stronger for
  humans)
- Finishing push after 8 min ensures games wrap under 15 min
- Removed all capture-biased dice logic — captures now happen purely
  by board position, not rigged dice
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ee53cfbf
...@@ -47,6 +47,7 @@ export function rollDice(game, playerIdx) { ...@@ -47,6 +47,7 @@ export function rollDice(game, playerIdx) {
function getDiceContext(game, playerIdx) { function getDiceContext(game, playerIdx) {
const player = game.players[playerIdx]; const player = game.players[playerIdx];
const piecesHome = player.pieces.filter(p => p.pos === -1 && !p.finished).length; const piecesHome = player.pieces.filter(p => p.pos === -1 && !p.finished).length;
const piecesFinished = player.pieces.filter(p => p.finished).length;
const progress = player.pieces.reduce((sum, p) => { const progress = player.pieces.reduce((sum, p) => {
if (p.finished) return sum + FINISH_POS; if (p.finished) return sum + FINISH_POS;
...@@ -58,79 +59,64 @@ function getDiceContext(game, playerIdx) { ...@@ -58,79 +59,64 @@ function getDiceContext(game, playerIdx) {
.filter((_, i) => i !== playerIdx && !game.players[i].finished) .filter((_, i) => i !== playerIdx && !game.players[i].finished)
.map(op => op.pieces.reduce((s, p) => s + (p.finished ? FINISH_POS : p.pos === -1 ? 0 : p.pos), 0) / (FINISH_POS * 4) * 100); .map(op => op.pieces.reduce((s, p) => s + (p.finished ? FINISH_POS : p.pos === -1 ? 0 : p.pos), 0) / (FINISH_POS * 4) * 100);
const avgOthersProgress = othersProgress.length ? othersProgress.reduce((a, b) => a + b, 0) / othersProgress.length : 0; const avgOthersProgress = othersProgress.length ? othersProgress.reduce((a, b) => a + b, 0) / othersProgress.length : 0;
const maxOthersProgress = othersProgress.length ? Math.max(...othersProgress) : 0;
let killValues = 0;
const capturePerDie = [false, false, false, false, false, false];
for (let d = 1; d <= 6; d++) {
const moves = getValidMoves(game, playerIdx, d);
if (moves.some(m => m.type === 'capture')) {
killValues++;
capturePerDie[d - 1] = true;
}
}
const nearCapture = detectNearCapture(game, playerIdx);
const elapsed = game.startTime ? (Date.now() - game.startTime) / 60000 : 0; const elapsed = game.startTime ? (Date.now() - game.startTime) / 60000 : 0;
const isHuman = game.humanPlayers && game.humanPlayers.includes(playerIdx);
const humanCount = game.humanPlayers ? game.humanPlayers.length : 1;
return { piecesHome, progress, avgOthersProgress, killValues, capturePerDie, nearCapture, elapsed }; return { playerIdx, piecesHome, piecesFinished, progress, avgOthersProgress, maxOthersProgress, elapsed, isHuman, humanCount };
}
function detectNearCapture(game, playerIdx) {
const player = game.players[playerIdx];
let bestDist = Infinity;
for (const piece of player.pieces) {
if (piece.pos === -1 || piece.finished || piece.pos >= HOME_ENTRY_POS) continue;
const myGlobal = (piece.pos + START_POSITIONS[playerIdx]) % SHARED_PATH_LENGTH;
for (let opp = 0; opp < game.numPlayers; opp++) {
if (opp === playerIdx) continue;
for (const op of game.players[opp].pieces) {
if (op.pos === -1 || op.finished || op.pos >= HOME_ENTRY_POS) continue;
const theirGlobal = (op.pos + START_POSITIONS[opp]) % SHARED_PATH_LENGTH;
if (SAFE_SQUARES.includes(theirGlobal)) continue;
const stepsNeeded = (theirGlobal - myGlobal + SHARED_PATH_LENGTH) % SHARED_PATH_LENGTH;
if (stepsNeeded >= 1 && stepsNeeded <= 6 && stepsNeeded < bestDist) {
bestDist = stepsNeeded;
}
}
}
}
return bestDist <= 6 ? bestDist : 0;
} }
function computeDiceWeights(ctx) { function computeDiceWeights(ctx) {
const w = [1, 1, 1, 1, 1, 1]; const w = [1, 1, 1, 1, 1, 1];
// Time factor: 0 at start, 1 at 15 min — used to decay capture bias and boost finishing
const timeFactor = Math.min(1, ctx.elapsed / 15); const timeFactor = Math.min(1, ctx.elapsed / 15);
if (ctx.piecesHome === 4) { // --- HUMAN LUCK EDGE ---
w[5] += 0.8; // Humans get a subtle persistent advantage over bots (feels lucky without being obvious)
} else if (ctx.piecesHome >= 3) { if (ctx.isHuman) {
w[5] += 0.4; w[4] += 0.12; // slight 5 boost (good moves)
w[5] += 0.18; // slight 6 boost (enter + extra turn)
} }
// --- RUBBER BAND: whoever is behind gets help ---
const deficit = ctx.avgOthersProgress - ctx.progress; const deficit = ctx.avgOthersProgress - ctx.progress;
if (deficit > 15) { if (deficit > 10) {
w[5] += Math.min(0.8, deficit / 40); const rubberBand = Math.min(0.6, deficit / 50);
w[5] += rubberBand;
w[4] += rubberBand * 0.5;
}
// If human is far behind the leader, stronger comeback boost
if (ctx.isHuman && ctx.maxOthersProgress - ctx.progress > 20) {
w[5] += 0.25;
} }
if (ctx.progress - ctx.avgOthersProgress > 20 && ctx.piecesHome === 0) { // --- MOMENTUM DAMPER: leader gets slightly cooled ---
w[5] = Math.max(0.5, w[5] - 0.5); if (ctx.progress - ctx.avgOthersProgress > 18 && !ctx.isHuman) {
w[5] = Math.max(0.7, w[5] - 0.3);
w[4] = Math.max(0.8, w[4] - 0.15);
} }
// As game progresses, boost sixes more aggressively to ensure finish within 15 min // --- STUCK RESCUE: all pieces at home need a six ---
if (ctx.elapsed > 8) { if (ctx.piecesHome === 4) {
w[5] += 0.3 + timeFactor * 0.5; w[5] += ctx.isHuman ? 0.9 : 0.6;
} else if (ctx.piecesHome >= 3) {
w[5] += ctx.isHuman ? 0.5 : 0.3;
} }
// Capture bias decays with time — early game has mild luck, late game is fair/random // --- FINISHING PUSH: accelerate endgame so it stays under 15 min ---
const captureMul = Math.max(0, 1 - timeFactor * 1.2); if (ctx.elapsed > 8) {
if (ctx.nearCapture > 0) { const push = 0.2 + timeFactor * 0.6;
const idx = ctx.nearCapture - 1; w[5] += push;
w[idx] += 0.15 * captureMul; // Everyone gets higher rolls late game to move pieces faster
w[3] += timeFactor * 0.2;
w[4] += timeFactor * 0.2;
} }
for (let i = 0; i < 6; i++) {
if (ctx.capturePerDie[i]) w[i] += 0.08 * captureMul; // --- CLOSE-TO-FINISH excitement: if player has 2+ pieces finished, help them wrap up ---
if (ctx.piecesFinished >= 2) {
w[5] += 0.15;
w[4] += 0.1;
} }
return w; return w;
......
...@@ -73,6 +73,7 @@ export function mountGame(el, params) { ...@@ -73,6 +73,7 @@ export function mountGame(el, params) {
game.startTime = Date.now(); game.startTime = Date.now();
game.turboMode = false; game.turboMode = false;
game.turnCount = 0; game.turnCount = 0;
game.humanPlayers = PLAYER_NAMES.map((n, i) => n.startsWith('Bot') ? -1 : i).filter(i => i >= 0);
validMoves = []; validMoves = [];
diceAnimating = false; diceAnimating = false;
lastSyncTurnCount = 0; lastSyncTurnCount = 0;
......
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