Commit f56437fc authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(ludo): bot games pay; one roll key; start key always on screen

A bot game gets its own ludo_matches row (action start, mode bot), and
complete pays its creator once through complete_match with the bot seats
as null: nothing under 90 s, nothing for a walked-away game, 403 for
anyone else. The result card shows the real coins and XP.

From the design review: the die is the roll key (#roll-btn), an idle full
face on a lip in the roller's colour, instead of a die beside a button
with a red pip in a glowing ring; plates use the shared avatar (purple
rounded square for people, ink with the robot for bots); the room's start
key is pinned above the dock with compact seat cards, checked uncovered at
360x640, 390x844 and 430x932. tests/play/ludo.mjs 83/84 then 37/37 for
the local games after fixing the opening roll in the one lucky check.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 41dbece4
...@@ -20,7 +20,11 @@ $action = $input['action'] ?? ''; ...@@ -20,7 +20,11 @@ $action = $input['action'] ?? '';
*/ */
const LUDO_CLAIM_PLACEHOLDER = '00000000-0000-0000-0000-000000000000'; const LUDO_CLAIM_PLACEHOLDER = '00000000-0000-0000-0000-000000000000';
/** A bot game pays nothing when it is completed sooner than this after it started. */
const LUDO_BOT_MIN_SECONDS = 90;
switch ($action) { switch ($action) {
case 'start': handleLudoStart($userId, $input); break;
case 'queue': handleLudoQueue($userId, $input); break; case 'queue': handleLudoQueue($userId, $input); break;
case 'dequeue': handleLudoDequeue($userId); break; case 'dequeue': handleLudoDequeue($userId); break;
case 'status': handleLudoStatus($userId); break; case 'status': handleLudoStatus($userId); break;
...@@ -361,6 +365,72 @@ function handleLudoGet(string $userId, array $input): void { ...@@ -361,6 +365,72 @@ function handleLudoGet(string $userId, array $input): void {
jsonResponse($match); jsonResponse($match);
} }
/**
* A game against bots gets a match row of its own, so finishing it can pay
* like any other game: the player, then a bot in every other seat.
*/
function handleLudoStart(string $userId, array $input): void {
$count = intval($input['player_count'] ?? 4);
if ($count < 2) $count = 2;
if ($count > 4) $count = 4;
$level = in_array($input['difficulty'] ?? '', ['easy', 'medium', 'hard'], true) ? $input['difficulty'] : 'medium';
$players = [$userId];
for ($i = 1; $i < $count; $i++) $players[] = 'bot_' . $i;
$positions = [];
for ($i = 0; $i < $count; $i++) $positions[] = ['pos' => [-1, -1, -1, -1]];
$sdb = supabaseService();
$match = $sdb->insert('ludo_matches', [
'status' => 'in_progress',
'player_count' => $count,
'players' => $players,
'current_turn' => 0,
'positions' => $positions,
'winners' => [],
'game_state' => ['mode' => 'bot', 'difficulty' => $level, 'turn_count' => 0],
'host_id' => $userId,
]);
$id = $match[0]['id'] ?? $match['id'] ?? null;
if (!$id) jsonError('Could not start the game', 500);
jsonResponse(['match_id' => $id, 'players' => $players, 'player_count' => $count]);
}
/**
* Completing a bot game: only its creator may, only once, and it pays only
* when the game ran long enough to have been played (instant completes would
* otherwise farm coins). A game the player walked away from closes unpaid.
*/
function handleLudoBotComplete($sdb, string $userId, array $match, array $winners, string $reason): void {
if (($match['host_id'] ?? null) !== $userId) jsonError('Not your game', 403);
$age = time() - strtotime($match['created_at'] ?? 'now');
$quit = in_array($reason, ['abandon', 'resign', 'leave'], true);
$paid = !$quit && $age >= LUDO_BOT_MIN_SECONDS;
$result = $quit ? 'abandoned' : ($paid ? 'normal' : 'too_short');
$updated = $sdb->update('ludo_matches', [
'status' => 'completed',
'completed_at' => date('c'),
'result' => $result,
'winners' => $winners,
], ['id' => 'eq.' . $match['id'], 'status' => 'eq.in_progress']);
if (isset($updated['error'])) jsonError('Could not complete the match', 500);
if (empty($updated)) jsonResponse(['success' => true, 'already_completed' => true]);
if (!$paid) jsonResponse(['success' => true, 'paid' => false, 'reason' => $result, 'rewards' => []]);
// Only the player is paid; every bot seat goes as null and keeps its place.
$winners = array_map(fn($w) => $w === $userId ? $w : null, array_values($winners));
if (!in_array($userId, $winners, true)) jsonResponse(['success' => true, 'paid' => false, 'reason' => 'no_player', 'rewards' => []]);
$rewards = supabaseRpc('complete_match', [
'p_game_key' => 'ludo',
'p_match_id' => $match['id'],
'p_winners' => $winners,
'p_reason' => 'normal',
]);
jsonResponse($rewards ?: ['success' => true]);
}
function handleLudoComplete(string $userId, array $input): void { function handleLudoComplete(string $userId, array $input): void {
$matchId = $input['match_id'] ?? ''; $matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required'); if (!$matchId) jsonError('match_id required');
...@@ -369,6 +439,16 @@ function handleLudoComplete(string $userId, array $input): void { ...@@ -369,6 +439,16 @@ function handleLudoComplete(string $userId, array $input): void {
$sdb = supabaseService(); $sdb = supabaseService();
// A game against bots has its own rules for paying.
$rows = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => 'id,host_id,created_at,game_state,status', 'limit' => 1]);
if (is_array($rows) && !isset($rows['error']) && !empty($rows)) {
$gs = ludoJson($rows[0]['game_state'] ?? null);
if (($gs['mode'] ?? '') === 'bot') {
handleLudoBotComplete($sdb, $userId, $rows[0], is_array($winners) ? $winners : [], (string) $reason);
return;
}
}
// Mark match as completed, once. Every client at the table reports the end // Mark match as completed, once. Every client at the table reports the end
// of the game, and each call used to pay the rewards again. The update only // of the game, and each call used to pay the rewards again. The update only
// matches a row that is not completed yet, so exactly one call wins it. // matches a row that is not completed yet, so exactly one call wins it.
......
...@@ -168,7 +168,6 @@ function setup(params) { ...@@ -168,7 +168,6 @@ function setup(params) {
S.die = createDie(el.querySelector('#dice-box')); S.die = createDie(el.querySelector('#dice-box'));
el.querySelector('#roll-btn').addEventListener('click', onRollTap); el.querySelector('#roll-btn').addEventListener('click', onRollTap);
el.querySelector('#dice-box').addEventListener('click', onRollTap);
el.querySelector('#exit-btn').addEventListener('click', handleExit); el.querySelector('#exit-btn').addEventListener('click', handleExit);
S.canvas.addEventListener('click', onBoardTap); S.canvas.addEventListener('click', onBoardTap);
...@@ -184,6 +183,14 @@ function setup(params) { ...@@ -184,6 +183,14 @@ function setup(params) {
} }
reactions.mountPanel(document.body, el.querySelector('#emote-btn')); reactions.mountPanel(document.body, el.querySelector('#emote-btn'));
// A game against bots gets its own match row on the server, so that
// finishing it pays coins and XP like any other game.
if (!live && mode === 'bot') {
const sess = S;
S.botMatch = net.post('ludo-match.php', { action: 'start', player_count: numPlayers, difficulty })
.then(r => { if (r?.match_id) sess.botMatchId = r.match_id; return r?.match_id || null; })
.catch(() => null);
}
if (live) { if (live) {
try { localStorage.setItem('el3ab_active_match', JSON.stringify({ matchId: S.matchId, gameType: 'ludo', timestamp: Date.now() })); } catch (e) {} try { localStorage.setItem('el3ab_active_match', JSON.stringify({ matchId: S.matchId, gameType: 'ludo', timestamp: Date.now() })); } catch (e) {}
loadProfiles(); loadProfiles();
...@@ -240,8 +247,11 @@ function renderShell() { ...@@ -240,8 +247,11 @@ function renderShell() {
const slot = seats[i]; const slot = seats[i];
const isMe = S.live ? i === myIndex : i === 0; const isMe = S.live ? i === myIndex : i === 0;
const bot = isBotName(i); const bot = isBotName(i);
// One avatar system for every game: a person is a purple rounded square,
// a bot an ink one with the bot sticker.
const face = isMe ? avatar(player, 40) const face = isMe ? avatar(player, 40)
: `<span class="avatar ld-avatar-plain">${bot ? gicon('bot', 30) : gicon('user', 28)}</span>`; : bot ? `<span class="avatar ld-bot">${gicon('bot', 30)}</span>`
: `<span class="avatar">${icon('user', 20)}</span>`;
const name = isMe ? (player.display_name || player.username || t('common.you')) : displayName(names[i]); const name = isMe ? (player.display_name || player.username || t('common.you')) : displayName(names[i]);
if (isMe) S.names[i] = name; if (isMe) S.names[i] = name;
plates.push({ i, slot, corner: cornerOf(slot), face, name, local: isLocalHuman(i) }); plates.push({ i, slot, corner: cornerOf(slot), face, name, local: isLocalHuman(i) });
...@@ -253,9 +263,9 @@ function renderShell() { ...@@ -253,9 +263,9 @@ function renderShell() {
return ` return `
<div class="pp${end ? ' pp-end' : ''}${p.local ? ' pp-local' : ''}" id="pp-${p.i}" style="--pc:${SEAT_INK[p.slot]};"> <div class="pp${end ? ' pp-end' : ''}${p.local ? ' pp-local' : ''}" id="pp-${p.i}" style="--pc:${SEAT_INK[p.slot]};">
<span class="pp-seat"> <span class="pp-seat">
<svg class="pp-ring" viewBox="0 0 60 60" aria-hidden="true"> <svg class="pp-ring" viewBox="0 0 56 56" aria-hidden="true">
<circle class="pp-ring-track" cx="30" cy="30" r="27"/> <rect class="pp-ring-track" x="2" y="2" width="52" height="52" rx="19"/>
<circle class="pp-ring-fill" id="ring-${p.i}" cx="30" cy="30" r="27" pathLength="100"/> <rect class="pp-ring-fill" id="ring-${p.i}" x="2" y="2" width="52" height="52" rx="19" pathLength="100"/>
</svg> </svg>
<span class="pp-avatar">${p.face}</span> <span class="pp-avatar">${p.face}</span>
<span class="pp-pawn">${pawnSVG(S.colors[p.slot], 15, 'pp' + p.i)}</span> <span class="pp-pawn">${pawnSVG(S.colors[p.slot], 15, 'pp' + p.i)}</span>
...@@ -283,11 +293,10 @@ function renderShell() { ...@@ -283,11 +293,10 @@ function renderShell() {
</div> </div>
<div id="dice-area" class="ld-controls"> <div id="dice-area" class="ld-controls">
<button class="icon-btn ld-quit" id="exit-btn" aria-label="${t('game.leave')}">${icon('logout', 20)}</button> <button class="icon-btn ld-quit" id="exit-btn" aria-label="${t('game.leave')}">${icon('logout', 20)}</button>
<div class="ld-status" id="ld-status" aria-hidden="true"></div>
<!-- The die is the roll key. -->
<button class="ld-die-key" id="roll-btn" aria-label="${t('game.roll_dice')}" disabled><span id="dice-box" class="ld-die"></span></button>
<button class="icon-btn" id="emote-btn" aria-label="${t('reaction.emotes')}">${icon('smile', 22)}</button> <button class="icon-btn" id="emote-btn" aria-label="${t('reaction.emotes')}">${icon('smile', 22)}</button>
<div class="ld-dock" id="ld-dock">
<div id="dice-box" class="ld-die" role="button" aria-label="${t('game.roll_dice')}"></div>
</div>
<button class="btn btn-primary btn-lg ld-roll" id="roll-btn" disabled>${gicon('dice', 30)}<span>${t('game.roll_dice')}</span></button>
<div id="turn-status" class="sr-only" role="status" aria-live="polite"></div> <div id="turn-status" class="sr-only" role="status" aria-live="polite"></div>
</div> </div>
</div> </div>
...@@ -495,31 +504,29 @@ function placeLabel(n) { ...@@ -495,31 +504,29 @@ function placeLabel(n) {
function updateControls() { function updateControls() {
const { el, game } = S; const { el, game } = S;
const p = game.currentPlayer; const p = game.currentPlayer;
const btn = el.querySelector('#roll-btn'); const key = el.querySelector('#roll-btn');
const dock = el.querySelector('#ld-dock'); const label = el.querySelector('#ld-status');
const status = el.querySelector('#turn-status'); const status = el.querySelector('#turn-status');
const mine = !game.gameOver && isLocalHuman(p) && iActFor(p) && !game.players[p].finished; const mine = !game.gameOver && isLocalHuman(p) && iActFor(p) && !game.players[p].finished;
const canRoll = mine && !game.rolled && !S.busy && !S.choices; const canRoll = mine && !game.rolled && !S.busy && !S.choices;
dock.style.setProperty('--pc', SEAT_INK[game.players[p].boardSlot]); // The die stands on a lip in the colour of whoever is to roll.
dock.classList.toggle('mine', mine); key.style.setProperty('--pc', SEAT_INK[game.players[p].boardSlot]);
el.querySelector('#dice-box').classList.toggle('can-roll', canRoll); key.classList.toggle('ready', canRoll);
key.disabled = !canRoll;
const name = displayName(S.names[p]); const name = displayName(S.names[p]);
let label, cls; let html, tone;
if (canRoll) { label = `${gicon('dice', 30)}<span>${t('game.roll_dice')}</span>`; cls = 'btn-primary'; } if (canRoll) { html = `${gicon('dice', 26)}<span>${t('game.roll_dice')}</span>`; tone = 'go'; }
else if (mine && S.choices) { label = `${gicon('hand', 28)}<span>${t('game.choose_piece')}</span>`; cls = 'btn-gold'; } else if (mine && S.choices) { html = `${gicon('hand', 26)}<span>${t('game.choose_piece')}</span>`; tone = 'pick'; }
else if (mine) { label = `${gicon('dice', 30)}<span>${t('game.roll_dice')}</span>`; cls = 'btn-primary'; } else if (mine) { html = `<span>${t('game.your_turn')}</span>`; tone = 'go'; }
else { else {
// A player's name is theirs to choose: escape it, and isolate its direction. // A player's name is theirs to choose: escape it, and isolate its direction.
const who = `<bdi>${esc(name)}</bdi>`; const who = `<bdi>${esc(name)}</bdi>`;
label = `<span>${playedByBot(p) ? t('game.bot_thinking', { name: who }) : t('game.turn_of', { name: who })}</span>`; html = `<span>${playedByBot(p) ? t('game.bot_thinking', { name: who }) : t('game.turn_of', { name: who })}</span>`;
cls = 'btn-secondary'; tone = '';
} }
if (btn.dataset.label !== label) { btn.innerHTML = label; btn.dataset.label = label; } if (label.dataset.html !== html) { label.innerHTML = html; label.dataset.html = html; }
btn.classList.toggle('btn-primary', cls === 'btn-primary'); label.classList.toggle('go', tone === 'go');
btn.classList.toggle('btn-gold', cls === 'btn-gold'); label.classList.toggle('pick', tone === 'pick');
btn.classList.toggle('btn-secondary', cls === 'btn-secondary');
btn.classList.toggle('ld-roll-ready', canRoll);
btn.disabled = !canRoll;
const text = game.gameOver ? '' : mine ? (S.mode === 'local-multi' ? t('game.turn_of', { name }) : t('game.your_turn')) : t('game.turn_of', { name }); const text = game.gameOver ? '' : mine ? (S.mode === 'local-multi' ? t('game.turn_of', { name }) : t('game.your_turn')) : t('game.turn_of', { name });
if (status && status.textContent !== text) status.textContent = text; if (status && status.textContent !== text) status.textContent = text;
} }
...@@ -610,7 +617,8 @@ function startTurn() { ...@@ -610,7 +617,8 @@ function startTurn() {
// moving): play it for them. // moving): play it for them.
if (game.rolled) { resolvePendingRoll(p); return; } if (game.rolled) { resolvePendingRoll(p); return; }
if (playedByBot(p)) { botTurn(p); return; } if (playedByBot(p)) { botTurn(p); return; }
// A person on this device. // A person on this device. The idle die shows a full face, ready to throw.
if (!S.busy && S.die?.value !== 6) S.die.set(6);
if (prev !== p) { if (prev !== p) {
audio.play('sfx_turn_start', 'ui'); audio.play('sfx_turn_start', 'ui');
haptic('light'); haptic('light');
...@@ -1361,6 +1369,8 @@ async function handleExit() { ...@@ -1361,6 +1369,8 @@ async function handleExit() {
(sess.pub || Promise.resolve()).then(() => net.post('ludo-match.php', { action: 'leave', match_id: sess.matchId, player_index: sess.myIndex })).catch(() => {}); (sess.pub || Promise.resolve()).then(() => net.post('ludo-match.php', { action: 'leave', match_id: sess.matchId, player_index: sess.myIndex })).catch(() => {});
try { localStorage.removeItem('el3ab_active_match'); } catch (e) {} try { localStorage.removeItem('el3ab_active_match'); } catch (e) {}
} }
// Walking away from a bot game closes its row unpaid.
if (!S.live && S.botMatchId) net.post('ludo-match.php', { action: 'complete', match_id: S.botMatchId, winners: [], reason: 'abandon' }).catch(() => {});
audio.play('lose', 'game'); audio.play('lose', 'game');
scene.exitGameMode(); scene.exitGameMode();
// Whoever walks away is placed last; the others keep their order. // Whoever walks away is placed last; the others keep their order.
...@@ -1427,7 +1437,18 @@ async function endGame({ completer = false, reason = 'normal' } = {}) { ...@@ -1427,7 +1437,18 @@ async function endGame({ completer = false, reason = 'normal' } = {}) {
let rewards = null; let rewards = null;
const startedAt = performance.now(); const startedAt = performance.now();
if (S.live && S.matchId) { if (!S.live && S.botMatch) {
// The server pays the player for a bot game it saw start (and long enough ago).
try {
const id = await Promise.race([S.botMatch, new Promise(r => setTimeout(() => r(null), 5000))]);
if (id) {
const uid = store.get('auth.userId');
const winners = order.map(i => (i === me ? uid : `bot_${i}`));
const res = await net.post('ludo-match.php', { action: 'complete', match_id: id, winners, reason: 'normal' });
rewards = res?.rewards || null;
}
} catch (e) {}
} else if (S.live && S.matchId) {
const winners = order.map(i => (isPersonSeat(i) ? S.ids[i] : `bot_${i}`)); const winners = order.map(i => (isPersonSeat(i) ? S.ids[i] : `bot_${i}`));
if (completer) { if (completer) {
try { try {
...@@ -1513,7 +1534,7 @@ function installTestHook() { ...@@ -1513,7 +1534,7 @@ function installTestHook() {
if (!S || !S.game) return null; if (!S || !S.game) return null;
const g = S.game; const g = S.game;
return { return {
mode: S.mode, live: S.live, me: S.myIndex, rot: S.rot, seq: S.seq, mode: S.mode, live: S.live, me: S.myIndex, rot: S.rot, seq: S.seq, botMatchId: S.botMatchId || null,
current: g.currentPlayer, rolled: g.rolled, dice: g.diceValue, busy: S.busy, ended: S.ended, current: g.currentPlayer, rolled: g.rolled, dice: g.diceValue, busy: S.busy, ended: S.ended,
gameOver: g.gameOver, winners: [...g.winners], sixes: g.sixesInRow, left: [...S.left], gameOver: g.gameOver, winners: [...g.winners], sixes: g.sixesInRow, left: [...S.left],
seats: [...S.seats], names: [...S.names], seats: [...S.seats], names: [...S.names],
...@@ -1619,7 +1640,7 @@ const LUDO_STYLE = ` ...@@ -1619,7 +1640,7 @@ const LUDO_STYLE = `
} }
.pp.done, .pp.gone { opacity: 0.55; } .pp.done, .pp.gone { opacity: 0.55; }
.pp-seat { position: relative; flex-shrink: 0; width: 48px; height: 48px; } .pp-seat { position: relative; flex-shrink: 0; width: 48px; height: 48px; }
.pp-ring { position: absolute; inset: -4px; width: 56px; height: 56px; transform: rotate(-90deg); overflow: visible; } .pp-ring { position: absolute; inset: -4px; width: 56px; height: 56px; overflow: visible; }
.pp-ring-track { fill: none; stroke: var(--back-600); stroke-width: 4; } .pp-ring-track { fill: none; stroke: var(--back-600); stroke-width: 4; }
.pp.active .pp-ring-track { stroke: var(--pc); animation: ppGlow 1.4s ease-in-out infinite; } .pp.active .pp-ring-track { stroke: var(--pc); animation: ppGlow 1.4s ease-in-out infinite; }
@keyframes ppGlow { 50% { opacity: 0.5; } } @keyframes ppGlow { 50% { opacity: 0.5; } }
...@@ -1632,19 +1653,9 @@ const LUDO_STYLE = ` ...@@ -1632,19 +1653,9 @@ const LUDO_STYLE = `
stroke-dashoffset: 100; stroke-dashoffset: 100;
} }
.pp-ring-fill.low { stroke: var(--red); } .pp-ring-fill.low { stroke: var(--red); }
.pp-avatar { .pp-avatar { position: absolute; inset: 4px 4px 7px; display: block; }
position: absolute; .pp-avatar .avatar { width: 40px !important; height: 40px !important; border-radius: 14px; box-shadow: 0 3px 0 var(--btn-purple-edge); }
inset: 4px; .pp-avatar .avatar.ld-bot { background: var(--back-600); box-shadow: 0 3px 0 var(--back-950); }
display: block;
border-radius: 50%;
overflow: hidden;
}
.pp-avatar .avatar, .pp-avatar img { width: 40px !important; height: 40px !important; border-radius: 50%; object-fit: cover; }
.ld-avatar-plain {
display: flex; align-items: center; justify-content: center;
width: 40px; height: 40px;
background: var(--back-600);
}
.pp-pawn { position: absolute; bottom: -7px; inset-inline-end: -6px; display: flex; } .pp-pawn { position: absolute; bottom: -7px; inset-inline-end: -6px; display: flex; }
.pp-end .pp-pawn { inset-inline-end: auto; inset-inline-start: -6px; } .pp-end .pp-pawn { inset-inline-end: auto; inset-inline-start: -6px; }
.pp-text { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; min-width: 0; line-height: 1.2; } .pp-text { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; min-width: 0; line-height: 1.2; }
...@@ -1681,7 +1692,7 @@ const LUDO_STYLE = ` ...@@ -1681,7 +1692,7 @@ const LUDO_STYLE = `
.pp-dice .ld-pip { width: 4.5px; height: 4.5px; } .pp-dice .ld-pip { width: 4.5px; height: 4.5px; }
.pp-dice.is-six .ld-pip.on { background: var(--red-ink); } .pp-dice.is-six .ld-pip.on { background: var(--red-ink); }
/* Controls: leave, reactions, the die on its dock, the roll button. */ /* Controls: leave, what is happening, the die (the roll key), reactions. */
.ld-controls { .ld-controls {
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -1689,30 +1700,40 @@ const LUDO_STYLE = ` ...@@ -1689,30 +1700,40 @@ const LUDO_STYLE = `
padding: 8px var(--gutter) calc(12px + var(--safe-bottom)); padding: 8px var(--gutter) calc(12px + var(--safe-bottom));
} }
.ld-quit { color: var(--text-secondary); } .ld-quit { color: var(--text-secondary); }
/* The die stands on a disc in the colour of whoever is rolling. */ .ld-status {
.ld-dock { flex: 1;
min-width: 0;
min-height: 54px;
display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 6px 4px;
font-family: var(--font-display);
font-size: var(--fs-md);
color: var(--text-secondary);
text-align: center;
}
.ld-status span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ld-status .gi { flex-shrink: 0; }
.ld-status.go { color: var(--text-primary); }
.ld-status.pick { color: var(--gold); }
.ld-die-key {
--pc: var(--ludo-red); --pc: var(--ludo-red);
flex-shrink: 0; flex-shrink: 0;
width: 72px; height: 72px; width: 80px; height: 80px;
display: grid; place-items: center; display: grid; place-items: center;
border-radius: 50%; padding: 0;
background: var(--pc); border: none;
box-shadow: 0 5px 0 color-mix(in srgb, var(--pc) 62%, #000); background: none;
transition: background var(--dur-normal), box-shadow var(--dur-normal); cursor: pointer;
} }
.ld-dock.mine { animation: dockGlow 1.6s ease-in-out infinite; } .ld-die-key .ld-die { width: 80px; height: 80px; }
@keyframes dockGlow { 50% { box-shadow: 0 5px 0 color-mix(in srgb, var(--pc) 62%, #000), 0 0 26px 6px color-mix(in srgb, var(--pc) 55%, transparent); } } .ld-die-key .ld-die-face { width: 64px; height: 64px; padding: 8px; border-radius: 18px; box-shadow: 0 6px 0 var(--pc); transition: box-shadow var(--dur-normal); }
.ld-die { cursor: pointer; } .ld-die-key .ld-pip { width: 11px; height: 11px; }
.ld-die.can-roll .ld-die-face { animation: dieNudge 1.8s ease-in-out infinite; } .ld-die-key:disabled { cursor: default; }
.ld-die-key:disabled:not(:has(.rolling)) .ld-die-face { opacity: 0.72; }
.ld-die-key.ready .ld-die-face { animation: dieNudge 1.8s ease-in-out infinite; }
.ld-die-key.is-pressed .ld-die-face { translate: 0 4px; box-shadow: 0 2px 0 var(--pc); }
@keyframes dieNudge { 0%, 70%, 100% { rotate: 0deg; } 76% { rotate: -10deg; } 84% { rotate: 9deg; } 92% { rotate: -4deg; } } @keyframes dieNudge { 0%, 70%, 100% { rotate: 0deg; } 76% { rotate: -10deg; } 84% { rotate: 9deg; } 92% { rotate: -4deg; } }
.ld-die.is-six .ld-die-face { box-shadow: 0 4px 0 var(--gold-dark), 0 0 0 3px var(--gold), 0 0 18px var(--gold); } .ld-die.is-six .ld-die-face { box-shadow: 0 6px 0 var(--pc), 0 0 0 3px var(--gold); }
.ld-roll { flex: 1; min-width: 0; padding-inline: 14px; }
.ld-roll span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ld-roll .gi { flex-shrink: 0; }
.ld-roll.btn-secondary:disabled { filter: none; opacity: 0.95; font-size: var(--fs-md); }
.ld-roll.btn-gold:disabled { filter: none; }
.ld-roll-ready { animation: rollBreathe 1.6s ease-in-out infinite; }
@keyframes rollBreathe { 50% { scale: 1.035; } }
/* An opponent's player card. */ /* An opponent's player card. */
.ld-opp-card { .ld-opp-card {
......
...@@ -3,7 +3,7 @@ import * as audio from '../../../core/audio.js'; ...@@ -3,7 +3,7 @@ import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js'; import * as store from '../../../core/store.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { icon, GAME_MARK } from '../../../core/icons.js'; import { icon, gicon, GAME_MARK } from '../../../core/icons.js';
import { screenHead, modeButton, roomHero } from '../../../core/ui.js'; import { screenHead, modeButton, roomHero } from '../../../core/ui.js';
import { getColor } from '../../../core/theme.js'; import { getColor } from '../../../core/theme.js';
import { pawnSVG } from '../canvas/board.js'; import { pawnSVG } from '../canvas/board.js';
...@@ -145,10 +145,12 @@ function renderSetup(el, params) { ...@@ -145,10 +145,12 @@ function renderSetup(el, params) {
<div id="seat-preview" class="lr-seat-preview"></div> <div id="seat-preview" class="lr-seat-preview"></div>
</section> </section>
<!-- Start Button --> <!-- Start: pinned at the foot of the screen so it is always in reach. -->
<button class="btn btn-primary btn-lg btn-block lr-start" id="btn-start"> <div class="lr-startbar">
${icon(isOnline ? 'search' : 'play', 20)}${isOnline ? t('ludo.search_match') : t('ludo.start_play')} <button class="btn btn-primary btn-lg btn-block lr-start" id="btn-start">
</button> ${gicon(isOnline ? 'search' : 'dice', 32)}<span>${isOnline ? t('ludo.search_match') : t('ludo.start_play')}</span>
</button>
</div>
</div> </div>
${getStyles()} ${getStyles()}
`; `;
...@@ -372,9 +374,9 @@ function getStyles() { ...@@ -372,9 +374,9 @@ function getStyles() {
.lr-seat-bottom-left { grid-area: bl; } .lr-seat-bottom-left { grid-area: bl; }
.lr-seat-bottom-right { grid-area: br; } .lr-seat-bottom-right { grid-area: br; }
.lr-seat { .lr-seat {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px;
min-height: 92px; min-height: 70px;
padding: var(--s-2); padding: 6px var(--s-2);
border-radius: 20px; border-radius: 20px;
text-align: center; text-align: center;
transition: background var(--dur-normal) var(--ease-out); transition: background var(--dur-normal) var(--ease-out);
...@@ -402,7 +404,16 @@ function getStyles() { ...@@ -402,7 +404,16 @@ function getStyles() {
box-shadow: 0 4px 0 var(--back-950), 0 0 0 4px var(--stage); box-shadow: 0 4px 0 var(--back-950), 0 0 0 4px var(--stage);
} }
.lr-start { margin-top: var(--s-8); } /* The start key stays pinned above the tab bar while the options scroll. */
.lr-startbar {
position: sticky;
bottom: 0;
z-index: 3;
margin-top: var(--s-5);
padding: 14px 0 18px;
background: linear-gradient(to bottom, transparent 0, var(--stage) 12px);
}
.lr-wrap:has(.lr-startbar) { padding-bottom: 0; }
/* Searching: a short stack of cards shuffling. */ /* Searching: a short stack of cards shuffling. */
.lr-searching { min-height: 100%; justify-content: center; text-align: center; gap: var(--s-6); } .lr-searching { min-height: 100%; justify-content: center; text-align: center; gap: var(--s-6); }
......
// The die: a flat white die on a lip, ink pips. A roll throws it up, it spins // The die: a flat white die on a lip, ink pips (all of them ink: a lone red pip
// reads as a record button). A roll throws it up, it spins
// with its faces flicking past, and it lands on the rolled face with a squash. // 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>". // An admin can replace any face drawing by uploading "emoji_dice_<n>".
import { getAsset } from '../../../core/theme.js'; import { getAsset } from '../../../core/theme.js';
...@@ -27,10 +28,11 @@ function faceInner(n) { ...@@ -27,10 +28,11 @@ function faceInner(n) {
/** Build the die inside `host`; returns its controller. */ /** Build the die inside `host`; returns its controller. */
export function createDie(host) { export function createDie(host) {
host.innerHTML = `<span class="ld-die-face" data-face="1">${faceInner(1)}</span><span class="ld-die-shadow"></span>`; // Idle, the die shows a full face.
host.innerHTML = `<span class="ld-die-face" data-face="6">${faceInner(6)}</span><span class="ld-die-shadow"></span>`;
const face = host.querySelector('.ld-die-face'); const face = host.querySelector('.ld-die-face');
const shadow = host.querySelector('.ld-die-shadow'); const shadow = host.querySelector('.ld-die-shadow');
let value = 1; let value = 6;
const show = n => { face.dataset.face = String(n); face.innerHTML = faceInner(n); }; const show = n => { face.dataset.face = String(n); face.innerHTML = faceInner(n); };
const reduced = () => !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; const reduced = () => !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
...@@ -95,7 +97,7 @@ export const DIE_STYLE = ` ...@@ -95,7 +97,7 @@ export const DIE_STYLE = `
.ld-die-face:has(img) { padding: 1px; display: block; } .ld-die-face:has(img) { padding: 1px; display: block; }
.ld-pip { width: 8px; height: 8px; border-radius: 50%; margin: auto; } .ld-pip { width: 8px; height: 8px; border-radius: 50%; margin: auto; }
.ld-pip.on { background: var(--ink); } .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-face[data-face="1"] .ld-pip.on { transform: scale(1.35); }
.ld-die-shadow { .ld-die-shadow {
position: absolute; inset-inline: 10px; bottom: 0; height: 8px; position: absolute; inset-inline: 10px; bottom: 0; height: 8px;
border-radius: 50%; border-radius: 50%;
......
...@@ -71,6 +71,15 @@ async function sb(p, { method = 'GET', body } = {}) { ...@@ -71,6 +71,15 @@ async function sb(p, { method = 'GET', body } = {}) {
try { return JSON.parse(text); } catch (e) { return text; } try { return JSON.parse(text); } catch (e) { return text; }
} }
/** Call the ludo API as one of the test accounts. */
async function apiAs(a, body) {
const r = await fetch(`${BASE}/api/ludo-match.php`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${a.token}` }, body: JSON.stringify(body) });
let j = null; try { j = await r.json(); } catch (e) {}
return { status: r.status, data: j };
}
// Coins paid for ludo games (not, say, an achievement unlocked on the way).
const coinRows = async a => { const r = await sb(`economy_transactions?player_id=eq.${a.id}&reason=like.ludo*&select=amount,reason`); return Array.isArray(r) ? r : []; };
const accounts = []; const accounts = [];
const matchIds = new Set(); const matchIds = new Set();
async function createAccount(name) { async function createAccount(name) {
...@@ -170,7 +179,22 @@ async function tapSel(page, sel) { ...@@ -170,7 +179,22 @@ async function tapSel(page, sel) {
/** The play table -> ludo -> the room for local or online play. */ /** The play table -> ludo -> the room for local or online play. */
async function openLudoRoom(page, online) { async function openLudoRoom(page, online) {
await tapSel(page, '.hand-card[data-game="ludo"]'); await page.waitForSelector('.hand-card[data-game="ludo"]', { visible: true, timeout: 15000 });
// Touching the card that is already raised plays it, so only raise it.
const raised = await page.$eval('.hand-card[data-game="ludo"]', c => c.getAttribute('aria-checked') === 'true');
if (!raised) {
// The hand is fanned and cards overlap: tap where the ludo card is on top.
await sleep(900);
const pt = await page.$eval('.hand-card[data-game="ludo"]', c => {
const b = c.getBoundingClientRect();
for (let fy = 0.5; fy < 0.95; fy += 0.1) for (let fx = 0.15; fx < 0.9; fx += 0.1) {
const x = b.left + b.width * fx, y = b.top + b.height * fy;
if (document.elementFromPoint(x, y)?.closest('.hand-card') === c) return { x, y };
}
return { x: b.left + b.width / 2, y: b.top + b.height / 2 };
});
await page.touchscreen.tap(pt.x, pt.y);
}
await sleep(500); await sleep(500);
await tapSel(page, online ? '#btn-multi' : '#btn-single'); await tapSel(page, online ? '#btn-multi' : '#btn-single');
await page.waitForSelector('#btn-start', { visible: true, timeout: 10000 }); await page.waitForSelector('#btn-start', { visible: true, timeout: 10000 });
...@@ -274,9 +298,11 @@ class Referee { ...@@ -274,9 +298,11 @@ class Referee {
* Play a local game on `page` to its end. `humans` are the seats tapped by * Play a local game on `page` to its end. `humans` are the seats tapped by
* the test. Returns the referee. `scenario` may fix dice once. * the test. Returns the referee. `scenario` may fix dice once.
*/ */
async function playLocal(page, { humans, label, maxMs = 15 * 60 * 1000, threeSixes = false }) { async function playLocal(page, { humans, label, maxMs = 15 * 60 * 1000, threeSixes = false, firstRolls = [3] }) {
const st0 = await waitFor(page, () => window.__ludoTest?.active && window.__ludoTest.state(), { label: 'the game to start' }); const st0 = await waitFor(page, () => window.__ludoTest?.active && window.__ludoTest.state(), { label: 'the game to start' });
const ref = new Referee(st0.positions.length, st0.seats); const ref = new Referee(st0.positions.length, st0.seats);
// The opening roll is fixed to a non-six, so a yard that stays shut is always seen.
if (firstRolls?.length) await page.evaluate(v => window.__ludoTest.forceDice(v), firstRolls);
const t0 = Date.now(); const t0 = Date.now();
let taps = 0, rolls = 0, forced = false, sixWatch = null, lastSeen = Date.now(), lastKey = ''; let taps = 0, rolls = 0, forced = false, sixWatch = null, lastSeen = Date.now(), lastKey = '';
while (Date.now() - t0 < maxMs) { while (Date.now() - t0 < maxMs) {
...@@ -381,6 +407,9 @@ async function partLocal4() { ...@@ -381,6 +407,9 @@ async function partLocal4() {
const st0 = await waitFor(page, () => window.__ludoTest?.active && window.__ludoTest.state(), { label: 'the table' }); const st0 = await waitFor(page, () => window.__ludoTest?.active && window.__ludoTest.state(), { label: 'the table' });
ok('the table seats four: red, green, yellow, blue', st0.seats.join() === '0,1,2,3' && st0.positions.length === 4); ok('the table seats four: red, green, yellow, blue', st0.seats.join() === '0,1,2,3' && st0.positions.length === 4);
ok('every token starts in its yard', st0.positions.flat().every(p => p === -1)); ok('every token starts in its yard', st0.positions.flat().every(p => p === -1));
const botRow = await waitFor(page, () => window.__ludoTest.state()?.botMatchId, { label: 'the bot game row' }).catch(() => null);
ok('a bot game opens its own match row on the server', !!botRow);
if (botRow) matchIds.add(botRow);
await sleep(700); await sleep(700);
await page.screenshot({ path: path.join(OUT, 'local4-start.png') }); await page.screenshot({ path: path.join(OUT, 'local4-start.png') });
...@@ -409,9 +438,16 @@ async function partLocal4() { ...@@ -409,9 +438,16 @@ async function partLocal4() {
ok('result: the finishing order lists all four in order', res.rows.length === 4 && res.rows[st.winners.indexOf(0)] !== undefined, JSON.stringify(res.rows)); ok('result: the finishing order lists all four in order', res.rows.length === 4 && res.rows[st.winners.indexOf(0)] !== undefined, JSON.stringify(res.rows));
ok('result: a sticker, green play again, neutral back, the dimmed table', res.sticker && res.again && res.back && res.shot); ok('result: a sticker, green play again, neutral back, the dimmed table', res.sticker && res.again && res.back && res.shot);
// The bot game paid: the card counted it up and the server recorded it once.
const coinsShown = await page.$eval('#lr-coins', e => parseInt(e.textContent.replace(/\D/g, ''), 10)).catch(() => 0);
const row = botRow ? (await sb(`ludo_matches?id=eq.${botRow}&select=status,result,winners`))?.[0] : null;
const paid = await coinRows(me);
ok('bot win pays: the card shows the coins the server paid', coinsShown > 0 && paid.length === 1 && paid[0].amount === coinsShown, `card ${coinsShown}, server ${JSON.stringify(paid)}`);
ok('the bot game row is settled once, as a normal finish', row && row.status === 'completed' && row.result === 'normal', JSON.stringify(row));
// Play again: a fresh table with the same seats. // Play again: a fresh table with the same seats.
await tapSel(page, '#btn-again'); await tapSel(page, '#btn-again');
const again = await waitFor(page, () => { const s = window.__ludoTest?.active && window.__ludoTest.state(); return s && s.positions.flat().every(p => p === -1) && !s.gameOver ? s : null; }, { label: 'a new game' }); const again = await waitFor(page, () => { const s = window.__ludoTest?.active && window.__ludoTest.state(); return s && s.positions.flat().every(p => p === -1) && !s.gameOver && s.botMatchId ? s : null; }, { label: 'a new game' });
ok('play again starts a fresh game with the same table', again.positions.length === 4 && again.mode === 'bot'); ok('play again starts a fresh game with the same table', again.positions.length === 4 && again.mode === 'bot');
// Leave it through the real exit, then back to the table from the result. // Leave it through the real exit, then back to the table from the result.
await sleep(600); await sleep(600);
...@@ -420,6 +456,10 @@ async function partLocal4() { ...@@ -420,6 +456,10 @@ async function partLocal4() {
await page.waitForSelector('.lr-card', { visible: true, timeout: 10000 }); await page.waitForSelector('.lr-card', { visible: true, timeout: 10000 });
const withdrew = await page.$eval('.lr-ribbon', r => r.textContent.trim()); const withdrew = await page.$eval('.lr-ribbon', r => r.textContent.trim());
ok('leaving a game shows "you withdrew"', withdrew === 'انسحبت', withdrew); ok('leaving a game shows "you withdrew"', withdrew === 'انسحبت', withdrew);
await sleep(1200);
const quitRow = again.botMatchId ? (await sb(`ludo_matches?id=eq.${again.botMatchId}&select=status,result`))?.[0] : null;
if (again.botMatchId) matchIds.add(again.botMatchId);
ok('a bot game the player walks away from closes unpaid', quitRow && quitRow.result === 'abandoned' && (await coinRows(me)).length === 1, JSON.stringify(quitRow));
await sleep(1400); await sleep(1400);
await tapSel(page, '#btn-back'); await tapSel(page, '#btn-back');
await page.waitForSelector('.hand-card[data-game="ludo"]', { visible: true, timeout: 10000 }); await page.waitForSelector('.hand-card[data-game="ludo"]', { visible: true, timeout: 10000 });
...@@ -593,6 +633,20 @@ async function partTimeout() { ...@@ -593,6 +633,20 @@ async function partTimeout() {
ok('and a token is moved for them', log.some(e => e.t === 'move' && e.p === 0 && e.move.type === 'enter'), JSON.stringify(log.slice(0, 4))); ok('and a token is moved for them', log.some(e => e.t === 'move' && e.p === 0 && e.move.type === 'enter'), JSON.stringify(log.slice(0, 4)));
await page.evaluate(() => localStorage.removeItem('el3ab_test_timer')); await page.evaluate(() => localStorage.removeItem('el3ab_test_timer'));
await page.close(); await page.close();
console.log('\n=== bot game pay: guarded against farming ===');
const other = await createAccount(`${TAG} ه`);
const st = await apiAs(me, { action: 'start', player_count: 4, difficulty: 'hard' });
const id = st.data?.match_id;
if (id) matchIds.add(id);
ok('starting a bot game returns its row', !!id && st.data.players?.[0] === me.id && st.data.players.slice(1).every(p => /^bot_/.test(p)), JSON.stringify(st.data));
const foreign = await apiAs(other, { action: 'complete', match_id: id, winners: [other.id], reason: 'normal' });
ok('only the player who started a bot game can complete it', foreign.status === 403, `${foreign.status}`);
const before = (await coinRows(me)).length;
const fast = await apiAs(me, { action: 'complete', match_id: id, winners: [me.id, 'bot_1', 'bot_2', 'bot_3'], reason: 'normal' });
ok('a bot game completed within 90 s pays nothing', fast.data?.paid === false && fast.data?.reason === 'too_short' && (await coinRows(me)).length === before, JSON.stringify(fast.data));
const twice = await apiAs(me, { action: 'complete', match_id: id, winners: [me.id], reason: 'normal' });
ok('and it cannot be completed again', twice.data?.already_completed === true && (await coinRows(me)).length === before, JSON.stringify(twice.data));
} }
async function partLayout() { async function partLayout() {
...@@ -601,6 +655,14 @@ async function partLayout() { ...@@ -601,6 +655,14 @@ async function partLayout() {
for (const [w, h] of [[360, 640], [390, 844], [430, 932]]) { for (const [w, h] of [[360, 640], [390, 844], [430, 932]]) {
const page = await openPage(me, { w, h, label: `layout-${w}` }); const page = await openPage(me, { w, h, label: `layout-${w}` });
await openLudoRoom(page, false); await openLudoRoom(page, false);
// The start key is in reach without scrolling, above the tab bar.
const startFree = await page.$eval('#btn-start', e => {
const b = e.getBoundingClientRect();
const hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2);
return b.top >= 0 && b.bottom <= innerHeight && !!hit && e.contains(hit);
});
ok(`${w}x${h}: the room's start key is on screen and uncovered without scrolling`, startFree);
await page.screenshot({ path: path.join(OUT, `room-${w}x${h}.png`) });
await tapSel(page, '#btn-start'); await tapSel(page, '#btn-start');
await waitFor(page, () => window.__ludoTest?.active && window.__ludoTest.state(), { label: 'the table' }); await waitFor(page, () => window.__ludoTest?.active && window.__ludoTest.state(), { label: 'the table' });
await sleep(1200); await sleep(1200);
......
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