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.
......
This diff is collapsed.
...@@ -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