Commit 09afc0f8 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: one reactions system for every game, on its own channel

Reactions did not sync, and when something appeared it floated in the middle of
the screen with no sign of who sent it. Four games had four implementations,
three broken in a different way:

- chess kept ONE shared `emote` slot in the match's game_state, so two players
  reacting in the same second overwrote each other, and it carried no sender;
- ludo posted reactions through the MOVE endpoint, which replaces game_state
  wholesale — sending an emote wiped turn_count and broke the sync loop. Its
  receive side registered with mp.onEmoteReceived, which only stores a callback
  that nothing ever invokes, so ludo players never saw a reaction at all;
- domino had a third variant, again a single shared slot;
- backgammon wrote a `last_emote` column no client ever read, so the opponent
  saw nothing. (That table does not exist in the database either — see below.)

Reactions are chat, not game state. They now live in `chat_messages` on a
`match` channel via the new api/match-chat.php, so nothing about a reaction can
reach a board, a clock or a turn — a social feature must not be able to corrupt
a game in progress. The multiplayer sync path is untouched.

core/reactions.js is the single client implementation. Its central idea is
SEATS: a game registers which element belongs to which player, and every bubble
is then drawn attached to that player, carrying their name and avatar. That is
the actual fix for "you can't tell who is talking". Seat resolution falls back
to "the seat that isn't mine" in a two-player game, so a game can register its
opponent seat before it knows the opponent's id.

Only preset emotes and preset phrases are accepted, validated server-side —
free text between strangers in a live match is a moderation surface nobody is
staffing. The cooldown is enforced on the server, not just in the UI.

Also fixed, found on the way:
- ChessBoard has draw(), not render(). board.js called render() whenever a
  themed piece image finished loading, so it threw every time and the board
  never repainted with custom piece art.
- chess/scenes/game.js built an inline onerror="" whose body embedded the result
  of emoji() — which returns <img src="..."> when a themed asset exists. Its
  double quotes closed the attribute, breaking the markup and throwing a
  SyntaxError on every bot game; the stray '"> rendered as text inside the
  avatar. The fallback is wired in JS now.

Adds tools/ui-audit.mjs (drives the real app at three phone widths and reports
measurable layout defects) and tools/test-reactions.mjs (end-to-end reaction
check against a live deployment, with teardown).

Noted, not fixed here: backgammon_matches and backgammon_queue do not exist in
the database, so backgammon multiplayer has never worked. Out of scope for this
change and untouched.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 28e6b267
<?php
/**
* In-game reactions and quick chat, for every game.
*
* Each game used to carry its own emote implementation, and each stored the
* reaction inside the match's own `game_state`:
*
* - chess kept ONE shared `emote` slot, so two players reacting in the same
* second overwrote each other, and neither carried who sent it;
* - ludo posted the reaction through the move endpoint, which REPLACES
* game_state wholesale — sending an emote wiped `turn_count` and broke the
* game's sync loop outright;
* - domino had a third variant, again a single shared slot;
* - backgammon wrote to a `last_emote` column that no client ever read, so
* the opponent never saw anything at all.
*
* Reactions are chat, not game state. They now live in `chat_messages` on a
* `match` channel, addressed by match id — a table built for exactly this, with
* a sender, a timestamp and an incremental read. Nothing here can touch a
* board, a clock or a turn, which is the property that matters: a social
* feature must never be able to corrupt a game in progress.
*
* Only preset emotes and preset phrases are accepted. Free text in a live match
* between strangers is a moderation surface nobody is staffing.
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? ($_GET['action'] ?? '');
/** Emote keys the client may send. Anything else is rejected. */
const REACTION_EMOTES = [
'gg' => '🤝',
'good_move' => '👏',
'think' => '🤔',
'hurry' => '⏱️',
'wow' => '😮',
'laugh' => '😂',
'sad' => '😢',
'angry' => '😤',
'hello' => '👋',
'fire' => '🔥',
'love' => '❤️',
'salute' => '🫡',
];
/** Preset phrase ids. The client renders the localised text for each. */
const REACTION_PHRASES = [
'good_game', 'good_luck', 'well_played', 'oops', 'thanks',
'rematch', 'hurry_please', 'sorry', 'nice_try', 'close_one',
];
/** Minimum gap between one player's reactions in a single match. */
const REACTION_COOLDOWN_SECONDS = 2;
switch ($action) {
case 'send': handleSend($userId, $input); break;
case 'poll': handlePoll($userId, $input); break;
default: jsonError('Invalid action');
}
// ---------------------------------------------------------------------------
function handleSend(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
$gameKey = $input['game_key'] ?? 'chess';
$kind = $input['kind'] ?? 'emote';
$key = (string)($input['key'] ?? '');
if (!$matchId) jsonError('match_id required');
if (!isValidUuid($matchId)) jsonError('match_id must be a uuid');
if ($kind === 'emote') {
if (!isset(REACTION_EMOTES[$key])) jsonError('Unknown emote');
} elseif ($kind === 'phrase') {
if (!in_array($key, REACTION_PHRASES, true)) jsonError('Unknown phrase');
} else {
jsonError('kind must be emote or phrase');
}
$sdb = supabaseService();
if (!isMatchParticipant($sdb, $gameKey, $matchId, $userId)) {
jsonError('You are not in this match', 403);
}
// Server-side cooldown. A client-side one is a courtesy; this is the rule.
$recent = $sdb->get('chat_messages', [
'channel_type' => 'eq.match',
'channel_id' => 'eq.' . $matchId,
'sender_id' => 'eq.' . $userId,
'select' => 'created_at',
'order' => 'created_at.desc',
'limit' => 1,
]);
if (is_array($recent) && !empty($recent) && !isset($recent['error'])) {
$last = strtotime($recent[0]['created_at'] ?? '');
if ($last && (time() - $last) < REACTION_COOLDOWN_SECONDS) {
jsonResponse(['ok' => false, 'cooldown' => true]);
}
}
$row = $sdb->insert('chat_messages', [
'channel_type' => 'match',
'channel_id' => $matchId,
'sender_id' => $userId,
'content' => $key,
'message_type' => $kind,
'metadata' => ['game_key' => $gameKey],
]);
if (isset($row['error'])) jsonError($row['error']);
jsonResponse(['ok' => true, 'message' => $row[0] ?? $row]);
}
/**
* Everything said in this match since `after`, newest last, with the sender's
* name and avatar attached so the client can show *who* reacted without a
* second request.
*/
function handlePoll(string $userId, array $input): void {
$matchId = $input['match_id'] ?? ($_GET['match_id'] ?? '');
$gameKey = $input['game_key'] ?? ($_GET['game_key'] ?? 'chess');
$after = $input['after'] ?? ($_GET['after'] ?? '');
if (!$matchId) jsonError('match_id required');
if (!isValidUuid($matchId)) jsonError('match_id must be a uuid');
$sdb = supabaseService();
if (!isMatchParticipant($sdb, $gameKey, $matchId, $userId)) {
jsonError('You are not in this match', 403);
}
$params = [
'channel_type' => 'eq.match',
'channel_id' => 'eq.' . $matchId,
'select' => 'id,sender_id,content,message_type,created_at',
'order' => 'created_at.asc',
'limit' => 30,
];
// Only ever ask for what we have not seen. A fresh client starts from the
// last few seconds rather than replaying the whole match.
$params['created_at'] = $after
? 'gt.' . $after
: 'gte.' . gmdate('c', time() - 10);
$rows = $sdb->get('chat_messages', $params);
if (!is_array($rows) || isset($rows['error'])) {
jsonResponse(['messages' => [], 'now' => gmdate('c')]);
}
$senders = array_values(array_unique(array_filter(array_column($rows, 'sender_id'))));
$profiles = [];
if ($senders) {
$p = $sdb->get('profiles', [
'id' => 'in.(' . implode(',', $senders) . ')',
'select' => 'id,display_name,username,avatar_url',
]);
if (is_array($p) && !isset($p['error'])) {
foreach ($p as $row) $profiles[$row['id']] = $row;
}
}
$out = [];
foreach ($rows as $r) {
$prof = $profiles[$r['sender_id']] ?? null;
$out[] = [
'id' => $r['id'],
'sender_id' => $r['sender_id'],
'kind' => $r['message_type'],
'key' => $r['content'],
'emoji' => REACTION_EMOTES[$r['content']] ?? null,
'name' => $prof['display_name'] ?? $prof['username'] ?? null,
'avatar_url' => $prof['avatar_url'] ?? null,
'at' => $r['created_at'],
'mine' => $r['sender_id'] === $userId,
];
}
jsonResponse(['messages' => $out, 'now' => gmdate('c')]);
}
// ---------------------------------------------------------------------------
function isValidUuid(string $v): bool {
return (bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $v);
}
/**
* Is this player actually in this match?
*
* Chess keeps two explicit colour columns; ludo and domino keep a `players`
* json array. A game whose table does not exist simply returns false, which
* denies rather than errors.
*/
function isMatchParticipant($sdb, string $gameKey, string $matchId, string $userId): bool {
if ($gameKey === 'chess') {
$rows = $sdb->get('matches', [
'id' => 'eq.' . $matchId,
'select' => 'white_player_id,black_player_id',
'limit' => 1,
]);
if (!is_array($rows) || empty($rows) || isset($rows['error'])) return false;
return $rows[0]['white_player_id'] === $userId || $rows[0]['black_player_id'] === $userId;
}
$table = [
'ludo' => 'ludo_matches',
'domino' => 'domino_matches',
][$gameKey] ?? null;
if (!$table) return false;
$rows = $sdb->get($table, ['id' => 'eq.' . $matchId, 'select' => 'players', 'limit' => 1]);
if (!is_array($rows) || empty($rows) || isset($rows['error'])) return false;
$players = $rows[0]['players'] ?? [];
if (is_string($players)) $players = json_decode($players, true) ?: [];
if (!is_array($players)) return false;
foreach ($players as $p) {
$id = is_array($p) ? ($p['id'] ?? $p['player_id'] ?? null) : $p;
if ($id === $userId) return true;
}
return false;
}
{
"name": "el3ab-player",
"name": "El3ab",
"lockfileVersion": 3,
"requires": true,
"packages": {
......
/* In-game reactions and quick chat.
Every value comes from tokens.css — no hardcoded colours or radii. */
/* ── The bubble ──────────────────────────────────────────────────────────
Drawn attached to its sender's seat, carrying their avatar and name, so a
reaction always says who sent it. */
.rx-bubble {
position: fixed;
z-index: 900;
display: flex;
align-items: center;
gap: var(--s-2);
max-width: min(78vw, 320px);
padding: var(--s-2) var(--s-3);
border-radius: var(--r-full);
background: var(--bg-elevated);
border: 1px solid var(--border);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
pointer-events: none;
opacity: 0;
transform: translateY(6px) scale(0.94);
transition: opacity var(--dur-fast) ease, transform var(--dur-normal) cubic-bezier(0.16, 1, 0.3, 1);
}
/* Your own reactions are tinted, so the two sides never read the same. */
.rx-bubble.rx-mine {
background: var(--bg-card);
border-color: var(--gold);
}
.rx-bubble.rx-in { opacity: 1; transform: translateY(0) scale(1); }
.rx-bubble.rx-out { opacity: 0; transform: translateY(-8px) scale(0.96); }
/* Seats at the top of the screen open downward; seats at the bottom open up. */
.rx-bubble.rx-top { transform-origin: top center; }
.rx-bubble.rx-bottom { transform-origin: bottom center; }
.rx-avatar {
inline-size: 22px;
block-size: 22px;
border-radius: var(--r-full);
object-fit: cover;
flex: none;
}
.rx-avatar-blank {
display: grid;
place-items: center;
background: var(--bg-hover);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.rx-emoji {
font-size: 24px;
line-height: 1;
flex: none;
}
.rx-text {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rx-who {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
max-inline-size: 90px;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── The picker ──────────────────────────────────────────────────────────
A bottom sheet: the only region a thumb reaches on a tall phone. */
.rx-panel {
position: fixed;
inset-inline: 0;
inset-block-end: 0;
z-index: 950;
display: flex;
flex-direction: column;
gap: var(--s-3);
padding: var(--s-2) var(--s-4) calc(var(--s-4) + env(safe-area-inset-bottom, 0px));
background: var(--bg-elevated);
border-start-start-radius: var(--r-xl);
border-start-end-radius: var(--r-xl);
border-block-start: 1px solid var(--border);
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.5);
transform: translateY(100%);
transition: transform var(--dur-normal) cubic-bezier(0.16, 1, 0.3, 1);
}
.rx-panel.rx-panel-in { transform: translateY(0); }
.rx-panel-grip {
inline-size: 36px;
block-size: 4px;
border-radius: var(--r-full);
background: var(--border-hover, rgba(255, 255, 255, 0.18));
margin: var(--s-1) auto var(--s-1);
}
.rx-row {
display: flex;
gap: var(--s-2);
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
padding-block-end: var(--s-1);
}
.rx-row::-webkit-scrollbar { display: none; }
/* 48px keeps every control above the 44px minimum tap target with room to spare. */
.rx-pick {
flex: none;
min-block-size: 48px;
border-radius: var(--r-md);
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-primary);
cursor: pointer;
font-family: inherit;
transition: transform var(--dur-fast) ease, background var(--dur-fast) ease;
}
.rx-pick-emote {
inline-size: 48px;
font-size: 24px;
line-height: 1;
display: grid;
place-items: center;
}
.rx-pick-phrase {
padding-inline: var(--s-4);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.rx-pick:hover { background: var(--bg-hover); }
.rx-pick:active { transform: scale(0.93); }
.rx-pick:disabled { opacity: 0.4; cursor: default; }
.rx-pick:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.rx-bubble, .rx-panel, .rx-pick { transition: none; }
}
......@@ -42,6 +42,18 @@ const strings = {
'game.you_win': 'فزت!',
'game.you_lose': 'خسرت',
'game.draw_result': 'تعادل',
'reaction.emotes': 'تفاعلات',
'reaction.phrases': 'رسائل سريعة',
'reaction.good_luck': 'بالتوفيق!',
'reaction.good_game': 'لعبة حلوة',
'reaction.well_played': 'لعب جميل',
'reaction.nice_try': 'محاولة كويسة',
'reaction.close_one': 'كانت قريبة',
'reaction.oops': 'أوبس',
'reaction.sorry': 'آسف!',
'reaction.thanks': 'شكرًا',
'reaction.hurry_please': 'دورك ⏱️',
'reaction.rematch': 'نعيدها؟',
'game.thinking': 'يفكر...',
'game.bot_unavailable': 'الخصم الآلي لا يستجيب. هل تريد إنهاء هذه المباراة؟',
'game.your_turn': 'دورك',
......@@ -757,6 +769,18 @@ const strings = {
'game.you_win': 'You Win!',
'game.you_lose': 'You Lose',
'game.draw_result': 'Draw',
'reaction.emotes': 'Reactions',
'reaction.phrases': 'Quick chat',
'reaction.good_luck': 'Good luck!',
'reaction.good_game': 'Good game',
'reaction.well_played': 'Well played',
'reaction.nice_try': 'Nice try',
'reaction.close_one': 'That was close',
'reaction.oops': 'Oops',
'reaction.sorry': 'Sorry!',
'reaction.thanks': 'Thanks',
'reaction.hurry_please': 'Your turn ⏱️',
'reaction.rematch': 'Rematch?',
'game.thinking': 'Thinking...',
'game.bot_unavailable': 'The computer opponent is not responding. End this game?',
'game.your_turn': 'Your Turn',
......
This diff is collapsed.
......@@ -14,6 +14,7 @@ import { createCube, canDouble, offerDouble, acceptDouble, declineDouble, should
import { drawBoard, hitTest } from '../canvas/board-renderer.js';
import { drawDice, createRollAnimation } from '../canvas/dice-renderer.js';
import { createMoveAnimation } from '../canvas/move-animator.js';
import * as reactions from '../../../core/reactions.js';
let canvas, ctx, layout;
let game, match;
......@@ -106,7 +107,6 @@ export function mountGame(el, p) {
</div>
</div>
<div class="bgg-emote-panel" id="emote-panel" style="display:none;"></div>
</div>
${getStyles()}
`;
......@@ -124,7 +124,6 @@ export function mountGame(el, p) {
el.querySelector('#btn-double')?.addEventListener('click', onDoubleClick);
el.querySelector('#btn-accept-double')?.addEventListener('click', onAcceptDouble);
el.querySelector('#btn-decline-double')?.addEventListener('click', onDeclineDouble);
el.querySelector('#btn-emote')?.addEventListener('click', toggleEmotePanel);
el.querySelector('#btn-quit')?.addEventListener('click', onQuit);
window.addEventListener('resize', resizeCanvas);
......@@ -141,6 +140,7 @@ export function mountGame(el, p) {
}
export function unmountGame() {
reactions.stop();
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
window.removeEventListener('resize', resizeCanvas);
matchSession.destroy();
......@@ -661,36 +661,26 @@ function handleServerState(data) {
// ── Emotes ──
function setupEmotePanel(el) {
const panel = el.querySelector('#emote-panel');
const emotes = ['🎲', '😤', '🤔', '👏', '😂', '🔥', '💀', '🫡'];
const phrases = [t('emote.nice_game'), t('emote.great_move'), t('emote.lucky'), t('emote.think_faster'), t('emote.rematch'), 'gg wp'];
panel.innerHTML = `
<div class="bgg-emotes">${emotes.map(e => `<button class="bgg-emote-btn">${e}</button>`).join('')}</div>
<div class="bgg-phrases">${phrases.map(p => `<button class="bgg-phrase-btn">${p}</button>`).join('')}</div>
`;
let lastT = 0;
panel.querySelectorAll('.bgg-emote-btn').forEach(btn => {
btn.onclick = () => {
if (Date.now() - lastT < 3000) return; lastT = Date.now();
audio.play('sfx_emote');
showBubble(btn.textContent);
panel.style.display = 'none';
if (params.mode === 'live') net.post('backgammon-match.php', { action: 'emote', match_id: params.matchId, emote: btn.textContent }).catch(() => {});
};
// Backgammon used to post reactions to `backgammon-match.php action:'emote'`,
// which wrote a `last_emote` column that NO client ever read — so the opponent
// never saw anything. (That table does not exist in the database either.)
// Reactions now use the shared module and the shared channel.
reactions.start({
matchId: params.matchId,
gameKey: 'backgammon',
myId: store.get('auth.userId'),
live: params.mode === 'live',
});
panel.querySelectorAll('.bgg-phrase-btn').forEach(btn => {
btn.onclick = () => {
if (Date.now() - lastT < 3000) return; lastT = Date.now();
audio.play('sfx_emote');
showBubble(btn.textContent);
panel.style.display = 'none';
};
reactions.seat(store.get('auth.userId'), el.querySelector('#my-card'), {
name: store.get('player.display_name') || store.get('player.username') || t('common.you'),
avatarUrl: store.get('player.avatar_url'),
side: 'bottom',
});
}
function toggleEmotePanel() {
const p = container?.querySelector('#emote-panel');
if (p) p.style.display = p.style.display === 'none' ? '' : 'none';
reactions.seat('bgg-opponent', el.querySelector('#opp-card'), {
name: params.mode === 'live' ? t('common.opponent') : t('game.bot'),
side: 'top',
});
reactions.mountPanel(document.body, el.querySelector('#btn-emote'));
}
function showBubble(text) {
......
// Emote panel for backgammon — with cooldown + net sync
import * as audio from '../../../core/audio.js';
import * as net from '../../../core/net.js';
import { t } from '../../../core/i18n.js';
const EMOTES = ['🎲', '😤', '🤔', '👏', '😂', '🔥', '💀', '🫡'];
const PHRASES = [
() => t('emote.nice_game'),
() => t('emote.great_move'),
() => t('emote.lucky'),
() => t('emote.think_faster'),
() => t('emote.rematch'),
() => 'gg wp'
];
const COOLDOWN = 3000;
let lastEmoteTime = 0;
export function createEmotePanel(container, options = {}) {
const { matchId, mode, onEmote } = options;
const panel = document.createElement('div');
panel.className = 'bgg-emote-panel';
panel.style.display = 'none';
panel.innerHTML = `
<div class="bgg-emotes">${EMOTES.map(e => `<button class="bgg-emote-btn">${e}</button>`).join('')}</div>
<div class="bgg-phrases">${PHRASES.map(p => `<button class="bgg-phrase-btn">${p()}</button>`).join('')}</div>
`;
panel.querySelectorAll('.bgg-emote-btn').forEach(btn => {
btn.onclick = () => {
if (Date.now() - lastEmoteTime < COOLDOWN) return;
lastEmoteTime = Date.now();
audio.play('sfx_emote');
onEmote?.(btn.textContent, 'emote');
panel.style.display = 'none';
if (mode === 'live' && matchId) {
net.post('backgammon-match.php', { action: 'emote', match_id: matchId, emote: btn.textContent }).catch(() => {});
}
};
});
panel.querySelectorAll('.bgg-phrase-btn').forEach(btn => {
btn.onclick = () => {
if (Date.now() - lastEmoteTime < COOLDOWN) return;
lastEmoteTime = Date.now();
audio.play('sfx_emote');
onEmote?.(btn.textContent, 'phrase');
panel.style.display = 'none';
};
});
container.appendChild(panel);
return panel;
}
export function toggle(panel) {
if (!panel) return;
panel.style.display = panel.style.display === 'none' ? '' : 'none';
}
......@@ -31,7 +31,10 @@ function loadPieceImages() {
if (pieceImages[piece]?.src === url) continue;
const img = new Image();
pending++;
img.onload = () => { if (--pending === 0 && boardInstance) boardInstance.render(); };
// draw(), not render() — there is no render() on ChessBoard, so this threw
// every time a themed piece finished loading and the board never repainted
// with the custom art.
img.onload = () => { if (--pending === 0 && boardInstance) boardInstance.draw(); };
img.onerror = () => { --pending; };
img.src = url;
pieceImages[piece] = img;
......
// In-game emote system for chess
// Preset emotes that both players can send during a match
import { t } from '../../../core/i18n.js';
const EMOTES = [
{ key: 'gg', emoji: '🤝', label: 'GG' },
{ key: 'good_move', emoji: '👏', get label() { return t('emote.good_move'); } },
{ key: 'think', emoji: '🤔', get label() { return t('emote.think'); } },
{ key: 'hurry', emoji: '⏱️', get label() { return t('emote.hurry'); } },
{ key: 'wow', emoji: '😮', get label() { return t('emote.wow'); } },
{ key: 'laugh', emoji: '😂', get label() { return t('emote.laugh'); } },
{ key: 'angry', emoji: '😤', get label() { return t('emote.angry'); } },
{ key: 'hello', emoji: '👋', get label() { return t('emote.hello'); } },
];
let emoteBar = null;
let emoteCallback = null;
let lastEmoteTime = 0;
const COOLDOWN = 3000; // 3 seconds between emotes
export function create(container, onSend) {
emoteCallback = onSend;
emoteBar = document.createElement('div');
emoteBar.className = 'emote-bar';
emoteBar.innerHTML = `
<div class="emote-panel hidden" id="emote-panel">
${EMOTES.map(e => `
<button class="emote-btn" data-key="${e.key}" title="${e.label}">
<span style="font-size:22px;">${e.emoji}</span>
</button>
`).join('')}
</div>
`;
const style = document.createElement('style');
style.textContent = `
.emote-bar { position:relative;z-index:30; }
.emote-panel { display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px;background:var(--bg-elevated);border-radius:12px;border:1px solid rgba(255,255,255,0.08);box-shadow:0 4px 20px rgba(0,0,0,0.5);animation:slideUpBounce 0.3s cubic-bezier(0.16,1,0.3,1);max-width:100%; }
.emote-panel.hidden { display:none; }
.emote-btn { width:40px;height:40px;border-radius:8px;background:rgba(255,255,255,0.05);border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:transform 0.1s,background 0.15s; }
.emote-btn:hover { background:rgba(255,255,255,0.1); }
.emote-btn:active { transform:scale(0.85); }
.emote-btn.cooldown { opacity:0.3;pointer-events:none; }
.emote-received { position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:48px;animation:emoteFloat 3s ease-out forwards;pointer-events:none;z-index:40; }
@keyframes emoteFloat { 0%{opacity:0;transform:translate(-50%,-50%) scale(0.3);} 15%{opacity:1;transform:translate(-50%,-50%) scale(1.1);} 25%{transform:translate(-50%,-50%) scale(1);} 75%{opacity:1;transform:translate(-50%,-60%) scale(1);} 100%{opacity:0;transform:translate(-50%,-80%) scale(0.9);} }
`;
container.appendChild(style);
container.appendChild(emoteBar);
// Use the inline toggle button already in the player bar
const inlineToggle = container.querySelector('#emote-inline-toggle');
if (inlineToggle) {
inlineToggle.addEventListener('click', () => {
const panel = emoteBar.querySelector('#emote-panel');
panel.classList.toggle('hidden');
});
}
// Emote buttons
emoteBar.querySelectorAll('.emote-btn').forEach(btn => {
btn.addEventListener('click', () => {
const now = Date.now();
if (now - lastEmoteTime < COOLDOWN) return;
lastEmoteTime = now;
const key = btn.dataset.key;
const emote = EMOTES.find(e => e.key === key);
if (emote && emoteCallback) {
emoteCallback(emote);
showSentFeedback(btn);
}
// Close panel
emoteBar.querySelector('#emote-panel').classList.add('hidden');
});
});
}
function showSentFeedback(btn) {
btn.classList.add('cooldown');
setTimeout(() => btn.classList.remove('cooldown'), COOLDOWN);
}
export function showReceived(container, emote, fromElement) {
const emojiText = emote.emoji || emote;
const el = document.createElement('div');
el.textContent = emojiText;
el.style.cssText = 'position:fixed;font-size:36px;z-index:999;pointer-events:none;';
document.body.appendChild(el);
let x, y;
if (fromElement) {
const fromRect = fromElement.getBoundingClientRect();
x = fromRect.left + fromRect.width / 2;
y = fromRect.top + fromRect.height / 2;
} else {
const containerRect = container.getBoundingClientRect();
x = containerRect.left + containerRect.width / 2;
y = containerRect.top + 40;
}
el.animate([
{ left: x + 'px', top: y + 'px', transform: 'translate(-50%,-50%) scale(0)', opacity: 0 },
{ left: x + 'px', top: y + 'px', transform: 'translate(-50%,-50%) scale(1.15)', opacity: 1, offset: 0.12 },
{ left: x + 'px', top: y + 'px', transform: 'translate(-50%,-50%) scale(1)', opacity: 1, offset: 0.2 },
{ left: x + 'px', top: (y - 20) + 'px', transform: 'translate(-50%,-50%) scale(1)', opacity: 1, offset: 0.75 },
{ left: x + 'px', top: (y - 30) + 'px', transform: 'translate(-50%,-50%) scale(0.85)', opacity: 0 }
], {
duration: 3000,
easing: 'ease-out',
fill: 'forwards'
}).onfinish = () => el.remove();
}
// Legacy function for backward compat
export function showReceivedAt(container, emote) {
showReceived(container, emote, null);
}
export function getEmojiForKey(key) {
const emote = EMOTES.find(e => e.key === key);
return emote ? emote.emoji : '😮';
}
export function destroy() {
if (emoteBar) {
emoteBar.remove();
emoteBar = null;
}
}
......@@ -10,7 +10,7 @@ import { ChessClock, parseTimeControl } from '../logic/clock.js';
import * as juice from '../../../core/juice.js';
import { getOpeningName } from '../logic/openings.js';
import { getMaterialAdvantage, formatAdvantage } from '../logic/material.js';
import * as emoteSystem from '../components/emotes.js';
import * as reactions from '../../../core/reactions.js';
import * as mp from '../../../core/multiplayer.js';
import * as modal from '../../../core/modal.js';
import { emoji } from '../../../core/theme.js';
......@@ -93,7 +93,10 @@ export function mountGame(el, params) {
<div class="chess-bar" style="display:flex;align-items:center;justify-content:space-between;padding:8px 14px;background:var(--bg-surface);">
<div style="display:flex;align-items:center;gap:10px;">
<div id="opponent-avatar" style="width:36px;height:36px;border-radius:50%;background:var(--bg-elevated);display:flex;align-items:center;justify-content:center;overflow:hidden;border:2px solid ${mode === 'bot' ? 'var(--text-muted)' : 'var(--blue)'};">
${mode === 'bot' ? `<img src="${STOCKFISH_URL}/portraits/${botId || 'amina'}.png" style="width:100%;height:100%;object-fit:cover;" onerror="this.style.display='none';this.parentNode.innerHTML='${emoji('robot', '🤖', 16)}'">` : `<span style="font-size:16px;">${emoji('person', '👤', 16)}</span>`}
${mode === 'bot'
? `<img src="${STOCKFISH_URL}/portraits/${botId || 'amina'}.png" class="avatar-img" style="width:100%;height:100%;object-fit:cover;">
<span class="avatar-fallback" style="display:none;font-size:16px;">${emoji('robot', '🤖', 16)}</span>`
: `<span style="font-size:16px;">${emoji('person', '👤', 16)}</span>`}
</div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--text-primary);" id="opponent-name">${mode === 'bot' ? (botId || t('game.bot')) : t('game.loading_opponent')}</div>
......@@ -160,6 +163,22 @@ export function mountGame(el, params) {
</style>
`;
// Bot portrait fallback, wired in JS.
//
// This used to be an inline onerror that embedded the result of emoji() —
// which returns an <img src="..."> when a themed asset exists. Its double
// quotes closed the onerror attribute, breaking the markup and throwing a
// SyntaxError on every bot game. The stray '"> even showed up as text in the
// avatar. Never build markup inside an inline handler attribute.
const avatarImg = el.querySelector('#opponent-avatar .avatar-img');
if (avatarImg) {
avatarImg.addEventListener('error', () => {
avatarImg.style.display = 'none';
const fb = el.querySelector('#opponent-avatar .avatar-fallback');
if (fb) fb.style.display = 'flex';
});
}
const boardContainer = el.querySelector('#board-container');
board = new ChessBoard(boardContainer, {
flipped: gameState.playerColor === 'b',
......@@ -302,21 +321,21 @@ export function mountGame(el, params) {
}
}
// Mount emote panel right after the player bar (second .chess-bar)
// Reactions. Both seats are registered so every bubble is drawn on the player
// who sent it, with their name — previously it floated with no attribution.
const opponentBar = el.querySelectorAll('.chess-bar')[0];
const playerBar = el.querySelectorAll('.chess-bar')[1];
const emoteContainer = el.querySelector('.chess-layout');
emoteSystem.create(emoteContainer, (emote) => {
audio.play('notification');
emoteSystem.showReceived(emoteContainer, emote.emoji, playerBar);
if (gameState.mode === 'live' && matchId) {
mp.sendEmote(matchId, 'chess', emote.key);
}
reactions.start({ matchId, gameKey: 'chess', myId: store.get('auth.userId'), live: mode === 'live' });
reactions.seat(store.get('auth.userId'), playerBar, {
name: store.get('player.display_name') || store.get('player.username') || t('common.you') || 'You',
avatarUrl: store.get('player.avatar_url'),
side: 'bottom',
});
// Move emote panel to sit right after player bar
const emotePanel = emoteContainer.querySelector('.emote-bar');
if (emotePanel && playerBar && playerBar.nextSibling) {
emoteContainer.insertBefore(emotePanel, playerBar.nextSibling);
if (mode === 'bot') {
reactions.seat('bot', opponentBar, { name: botId || t('game.bot'), side: 'top' });
}
reactions.mountPanel(document.body, el.querySelector('#emote-inline-toggle'));
bus.emit('game:started', { gameKey: 'chess', matchId, opponent: botId, mode });
}
......@@ -685,7 +704,10 @@ async function confirmColorFromServer(el, matchId, opponentIdHint, attempt = 0)
const oppId = data.opponent_id
|| (data.white_player_id === myId ? data.black_player_id : data.white_player_id)
|| opponentIdHint;
if (oppId) fetchAndRenderOpponent(el, oppId);
if (oppId) {
reactions.seat(oppId, el.querySelectorAll('.chess-bar')[0], { name: t('game.loading_opponent'), side: 'top' });
fetchAndRenderOpponent(el, oppId);
}
// Adopt the server's position if it is ahead of ours (rejoining a game).
if (data.current_fen && (data.move_count || 0) > gameState.moveCount) {
......@@ -766,15 +788,9 @@ function handleLivePollData(el, data) {
mp.updateConnectionStatus(true);
// Emotes — always check regardless of whose turn
// Reactions arrive on their own channel (api/match-chat.php) and are drawn on
// the sender's seat, so nothing about them is read out of game_state here.
const myId = store.get('auth.userId');
const emoteData = mp.checkForEmote(data.game_state, myId);
if (emoteData) {
const boardContainer = el.querySelector('#board-container');
const oppBar = el.querySelector('.chess-bar');
emoteSystem.showReceived(boardContainer, emoteSystem.getEmojiForKey(emoteData.key), oppBar);
audio.play('notification');
}
// Draw offer/response — always check
checkDrawOffer(el, data.game_state, myId);
......@@ -1043,6 +1059,11 @@ function fetchAndRenderOpponent(el, oppId) {
if (opp.chess_rating && gameState) {
gameState.opponentRating = opp.chess_rating;
}
// Keep the reaction bubble's name and avatar in step with the bar.
reactions.updateSeat(oppId, {
name: opp.display_name || opp.username || t('common.opponent'),
avatarUrl: opp.avatar_url || null,
});
}
}).catch(() => {});
}
......@@ -1169,6 +1190,7 @@ export function unmountGame() {
try { liveSession.cleanup(); } catch (e) {}
liveSession = null;
}
reactions.stop();
mp.cleanup();
if (clock) { clock.stop(); clock = null; }
if (board) { board.destroy?.(); board = null; }
......
......@@ -6,6 +6,7 @@ import * as net from '../../../core/net.js';
import * as store from '../../../core/store.js';
import * as matchLive from '../../../core/match-live.js';
import * as mp from '../../../core/multiplayer.js';
import * as reactions from '../../../core/reactions.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
import * as modal from '../../../core/modal.js';
......@@ -59,7 +60,18 @@ export function mountGame(el, params) {
el.querySelector('#btn-draw').addEventListener('click', () => drawFromBoneyard(el));
el.querySelector('#btn-pass').addEventListener('click', () => passTurn(el));
el.querySelector('#btn-resign')?.addEventListener('click', () => confirmResign(el));
el.querySelector('#btn-emote')?.addEventListener('click', () => showEmoteMenu(el));
// Reactions: seats registered so a bubble lands on whoever sent it.
reactions.start({ matchId, gameKey: 'domino', myId: store.get('auth.userId'), live: mode === 'live' });
reactions.seat(store.get('auth.userId'), el.querySelector('#domino-controls'), {
name: store.get('player.display_name') || store.get('player.username') || t('common.you'),
avatarUrl: store.get('player.avatar_url'),
side: 'bottom',
});
reactions.seat('domino-opponent', el.querySelector('#domino-opp-bar'), {
name: mode === 'live' ? t('common.opponent') : t('game.bot'),
side: 'top',
});
reactions.mountPanel(document.body, el.querySelector('#btn-emote'));
if (mode === 'bot') {
createServerRecord();
......@@ -492,7 +504,8 @@ function handleLivePollData(el, data) {
const gs = data.game_state ? (typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state) : {};
const myId = store.get('auth.userId');
checkEmote(el, gs, myId);
// Reactions travel on their own channel now (api/match-chat.php); reading them
// out of game_state meant a single shared slot that each player overwrote.
if (data.status === 'completed' && !state.matchOver) {
const resigned = gs.resigned_by;
......@@ -664,83 +677,9 @@ async function syncRoundEndToServer(winnerIdx, roundPoints) {
// EMOTES
// ═══════════════════════════════════════
function checkEmote(el, gs, myId) {
if (!gs.emote) return;
const emote = gs.emote;
if (emote.from === myId) return;
if (emote.t <= state.lastEmoteHandled) return;
if (Date.now() - emote.t > 15000) return;
state.lastEmoteHandled = emote.t;
showEmoteBubble(el, emote.key);
}
function showEmoteBubble(el, emoteKey) {
const emotes = { laugh: '😂', think: '🤔', wow: '😮', angry: '😡', gg: '👏', love: '❤️', fire: '🔥', cry: '😢' };
const display = el.querySelector('#emote-display');
if (!display) return;
const bubble = document.createElement('div');
bubble.className = 'emote-bubble';
bubble.textContent = emotes[emoteKey] || '😄';
display.appendChild(bubble);
audio.play('notification');
setTimeout(() => bubble.remove(), 2000);
}
function showEmoteMenu(el) {
const emotes = [
{ key: 'laugh', icon: '😂' }, { key: 'think', icon: '🤔' },
{ key: 'wow', icon: '😮' }, { key: 'angry', icon: '😡' },
{ key: 'gg', icon: '👏' }, { key: 'love', icon: '❤️' },
{ key: 'fire', icon: '🔥' }, { key: 'cry', icon: '😢' }
];
const existing = el.querySelector('#emote-menu');
if (existing) { existing.remove(); return; }
const menu = document.createElement('div');
menu.id = 'emote-menu';
menu.style.cssText = `
position:absolute;bottom:110px;left:50%;transform:translateX(-50%);
display:flex;gap:6px;padding:10px 14px;background:var(--bg-card);
border-radius:16px;border:1px solid rgba(228,172,56,0.15);
box-shadow:0 8px 24px rgba(0,0,0,0.6);z-index:60;
animation:fadeIn 0.2s ease;
`;
menu.innerHTML = emotes.map(e => `
<button data-emote="${e.key}" style="font-size:24px;background:none;border:none;cursor:pointer;padding:4px;border-radius:8px;transition:transform 0.1s;">
${e.icon}
</button>
`).join('');
const wrap = el.querySelector('#domino-wrap');
wrap.appendChild(menu);
menu.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.emote;
sendEmote(key);
showEmoteBubble(el, key);
menu.remove();
});
});
setTimeout(() => menu.remove(), 5000);
}
async function sendEmote(key) {
if (!state.matchId) return;
const myId = store.get('auth.userId');
try {
await net.post('domino-match.php', {
action: 'move',
match_id: state.matchId,
game_state: JSON.stringify({ emote: { key, from: myId, t: Date.now() } })
});
} catch (e) {}
}
// ═══════════════════════════════════════
// INTERACTION HANDLERS
......@@ -1128,7 +1067,7 @@ function executeBotTurn(el) {
audio.play('place', 'game');
if (bot.shouldEmote(state.botPersonality)) {
setTimeout(() => showEmoteBubble(el, bot.getRandomEmote()), 300);
setTimeout(() => reactions.showLocal('domino-opponent', 'emote', bot.getRandomEmote()), 300);
}
updateUI(el);
......@@ -1326,6 +1265,7 @@ async function createServerRecord() {
// ═══════════════════════════════════════
export function unmountGame() {
reactions.stop();
if (botTimeout) { clearTimeout(botTimeout); botTimeout = null; }
if (autoPassTimeout) { clearTimeout(autoPassTimeout); autoPassTimeout = null; }
liveSession?.cleanup?.();
......
......@@ -8,6 +8,7 @@ import * as rules from '../logic/rules.js';
import * as juice from '../../../core/juice.js';
import { getPiecePosition, getHomeBasePosition, SAFE_SQUARES, HOME_COLUMNS, SHARED_PATH } from '../logic/board-map.js';
import * as mp from '../../../core/multiplayer.js';
import * as reactions from '../../../core/reactions.js';
import { emoji, getAsset, getColor } from '../../../core/theme.js';
// matchLive import removed — Ludo uses its own consolidated poller to avoid dual-poll conflicts
import * as net from '../../../core/net.js';
......@@ -217,7 +218,19 @@ export function mountGame(el, params) {
updatePanels(el);
el.querySelector('#roll-btn').addEventListener('click', () => handleRoll(el));
el.querySelector('#exit-btn').addEventListener('click', () => handleExit(el));
el.querySelector('#emote-btn').addEventListener('click', () => showEmotePanel(el));
// Reactions: every seat is registered so a bubble is drawn on the player who
// sent it, carrying their name.
reactions.start({ matchId, gameKey: 'ludo', myId: store.get('auth.userId'), live: mode === 'live' });
for (let i = 0; i < 4; i++) {
const panelEl = el.querySelector(`#pp-${i}`);
if (!panelEl) continue;
const pid = (i === myPlayerIndex) ? store.get('auth.userId') : (livePlayerIds[i] || `seat-${i}`);
reactions.seat(pid, panelEl, {
name: PLAYER_NAMES[i] || `P${i + 1}`,
side: i === myPlayerIndex ? 'bottom' : 'top',
});
}
reactions.mountPanel(document.body, el.querySelector('#emote-btn'));
// Emotes + multiplayer (panel created on demand via emote-btn)
......@@ -226,11 +239,11 @@ export function mountGame(el, params) {
// Only use matchLive for session persistence (tab recovery), not for polling
localStorage.setItem('el3ab_active_match', JSON.stringify({ matchId, gameType: 'ludo', timestamp: Date.now() }));
mp.onEmoteReceived((emote) => {
const senderIdx = emote.from === store.get('auth.userId') ? myPlayerIndex : findPlayerByUserId(emote.from);
showEmoteBubble(el, senderIdx, emote.key);
audio.play('sfx_emote', 'ui');
});
// Reactions arrive on their own channel. The previous registration here went
// to mp.onEmoteReceived, which only stored a callback that nothing ever
// invoked — Ludo players never saw a reaction at all. Sending was worse: it
// posted game_state through the move endpoint, which REPLACES game_state
// wholesale, wiping turn_count and breaking the game's sync loop.
// Fetch opponent profiles and update player panels (name + avatar + tap for profile)
if (params.players) {
......@@ -469,8 +482,8 @@ async function botLoop(el) {
if (move.type === 'capture' && Math.random() > 0.5) {
setTimeout(() => {
const botEmotes = ['😂', '💪', '🎉', '😎'];
showEmoteBubble(el, game.currentPlayer, botEmotes[Math.floor(Math.random() * botEmotes.length)]);
const botEmotes = ['laugh', 'fire', 'gg', 'salute'];
reactions.showLocal(`seat-${game.currentPlayer}`, 'emote', botEmotes[Math.floor(Math.random() * botEmotes.length)]);
}, 300);
}
......@@ -1466,143 +1479,8 @@ function endGame(el) {
}, 1500);
}
// ===== SOCIAL: EMOTES + PHRASES =====
const EMOTES = [
{ key: '😂', get label() { return t('emote.laugh'); } },
{ key: '😮', get label() { return t('emote.wow'); } },
{ key: '😡', get label() { return t('emote.angry'); } },
{ key: '👏', get label() { return t('emote.good_move'); } },
{ key: '🔥', get label() { return t('emote.hurry'); } },
{ key: '😢', get label() { return t('ludo.emote_sad'); } },
{ key: '💪', get label() { return t('ludo.emote_strong'); } },
{ key: '😎', get label() { return t('ludo.emote_cool'); } },
];
const PHRASES = [
{ key: 'gl', get text() { return t('ludo.phrase_gl'); } },
{ key: 'gg', text: 'GG!' },
{ key: 'hurry', get text() { return t('ludo.phrase_hurry'); } },
{ key: 'nice', get text() { return t('ludo.phrase_nice'); } },
{ key: 'oops', get text() { return t('ludo.phrase_oops'); } },
{ key: 'wow', get text() { return t('ludo.phrase_wow'); } },
];
let emoteCooldown = false;
function showEmotePanel(el) {
const existing = el.querySelector('#ludo-emote-panel');
if (existing) { existing.remove(); return; }
const panel = document.createElement('div');
panel.id = 'ludo-emote-panel';
panel.style.cssText = `
position:absolute;bottom:80px;left:50%;transform:translateX(-50%);
background:var(--bg-card);border:1px solid rgba(228,172,56,0.15);
border-radius:16px;padding:12px 14px;z-index:70;
box-shadow:0 8px 30px rgba(0,0,0,0.7);
animation:fadeIn 0.2s ease;display:flex;flex-direction:column;gap:10px;
max-width:320px;width:90vw;
`;
panel.innerHTML = `
<div style="display:flex;gap:4px;justify-content:center;flex-wrap:wrap;">
${EMOTES.map(e => `
<button class="lep-emoji" data-key="${e.key}" title="${e.label}" style="font-size:24px;background:none;border:none;cursor:pointer;padding:6px;border-radius:8px;transition:transform 0.1s,background 0.1s;">
${e.key}
</button>
`).join('')}
</div>
<div style="height:1px;background:var(--border);"></div>
<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:center;">
${PHRASES.map(p => `
<button class="lep-phrase" data-key="${p.key}" data-text="${p.text}" style="font-size:12px;font-weight:600;padding:6px 12px;border-radius:10px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:var(--text-light);cursor:pointer;transition:transform 0.1s,background 0.1s;">
${p.text}
</button>
`).join('')}
</div>
`;
el.querySelector('#ludo-wrap')?.appendChild(panel);
panel.querySelectorAll('.lep-emoji').forEach(btn => {
btn.addEventListener('click', () => {
if (emoteCooldown) return;
sendSocialAction(el, btn.dataset.key, 'emoji');
panel.remove();
});
});
panel.querySelectorAll('.lep-phrase').forEach(btn => {
btn.addEventListener('click', () => {
if (emoteCooldown) return;
sendSocialAction(el, btn.dataset.text, 'phrase');
panel.remove();
});
});
setTimeout(() => {
const closeFn = (e) => {
if (!panel.contains(e.target) && e.target.id !== 'emote-btn') {
panel.remove();
document.removeEventListener('pointerdown', closeFn);
}
};
document.addEventListener('pointerdown', closeFn);
}, 100);
}
function sendSocialAction(el, content, type) {
emoteCooldown = true;
setTimeout(() => { emoteCooldown = false; }, 3000);
audio.play('sfx_emote', 'ui');
showEmoteBubble(el, myPlayerIndex, content, type);
if (game.mode === 'live' && matchId) {
mp.sendEmote(matchId, 'ludo', content);
}
}
function showEmoteBubble(el, senderIdx, content, type = 'emoji') {
const panel = el.querySelector(`#pp-${senderIdx}`);
if (!panel) return;
const rect = panel.getBoundingClientRect();
const wrapRect = el.getBoundingClientRect();
const bubble = document.createElement('div');
bubble.className = 'ludo-emote-bubble';
if (type === 'phrase') {
bubble.style.cssText = `
position:absolute;z-index:60;pointer-events:none;
left:${rect.left - wrapRect.left + rect.width / 2}px;
top:${rect.top - wrapRect.top - 10}px;
transform:translate(-50%,-100%);
background:var(--bg-card);border:1px solid rgba(228,172,56,0.2);
border-radius:12px;padding:6px 12px;
font-size:13px;font-weight:700;color:var(--text-primary);
white-space:nowrap;
animation:phraseBubble 2.5s cubic-bezier(0.34,1.56,0.64,1) forwards;
`;
bubble.textContent = content;
} else {
bubble.style.cssText = `
position:absolute;z-index:60;pointer-events:none;
left:${rect.left - wrapRect.left + rect.width / 2}px;
top:${rect.top - wrapRect.top - 10}px;
transform:translate(-50%,-100%);
font-size:36px;
animation:emojiBubble 2s cubic-bezier(0.34,1.56,0.64,1) forwards;
`;
bubble.textContent = content;
}
el.appendChild(bubble);
bubble.addEventListener('animationend', () => bubble.remove());
}
export function unmountGame() {
reactions.stop();
stopRenderLoop();
stopLudoPolling();
clearTurnTimer();
......
import puppeteer from 'puppeteer';
const CHROME='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const OUT=process.env.SHOTS;
const b=await puppeteer.launch({executablePath:CHROME, headless:'new', args:['--no-sandbox','--disable-dev-shm-usage']});
const p=await b.newPage();
p.on('console',m=>{ if(m.type()==='error') console.log(' [console.error]', m.text().slice(0,160)); });
p.on('pageerror',e=>console.log(' [pageerror]', String(e).slice(0,200)));
await p.setViewport({width:390,height:844,deviceScaleFactor:2,isMobile:true,hasTouch:true});
await p.goto('https://el3ab-player.caprover.al-arcade.com/',{waitUntil:'networkidle2',timeout:45000});
await new Promise(r=>setTimeout(r,2500));
await p.screenshot({path:OUT+'/00-boot.png'});
console.log('title:', await p.title());
console.log('body :', (await p.evaluate(()=>document.body.innerText)).slice(0,400).replace(/\n+/g,' | '));
await b.close();
/**
* End-to-end check of in-game reactions against a live deployment.
* Creates two throwaway accounts, puts them in a real match, exchanges
* reactions both ways, and cleans up.
* node tools/test-reactions.mjs [--base <url>]
*/
const args=process.argv.slice(2);
const argOf=(n,d)=>{const i=args.indexOf(n);return i>=0?args[i+1]:d;};
const B=argOf('--base','https://el3ab-player.caprover.al-arcade.com')+'/api';
const SB='https://safe-supabase-kong.caprover.al-arcade.com';
const SK='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4';
const fails=[]; const users=[]; let matchId=null;
const check=(l,c,d='')=>{ if(c) console.log(' ok '+l); else {fails.push(l);console.log(' FAIL '+l+(d?' — '+d:''));} };
const api=async(ep,body,tok,method='POST')=>{
const o={method,headers:{'Content-Type':'application/json'}};
if(tok)o.headers.Authorization='Bearer '+tok;
if(method==='POST')o.body=JSON.stringify(body);
const r=await fetch(`${B}/${ep}`,o); const t=await r.text();
try{return{status:r.status,data:JSON.parse(t)};}catch{return{status:r.status,data:{_raw:t.slice(0,200)}};}
};
const get=(ep,p,tok)=>api(`${ep}?${new URLSearchParams(p)}`,null,tok,'GET');
const sb=async(path,init={})=>{const r=await fetch(`${SB}/rest/v1/${path}`,{...init,headers:{apikey:SK,Authorization:'Bearer '+SK,'Content-Type':'application/json',Prefer:'return=representation'}});const t=await r.text();try{return JSON.parse(t)}catch{return t}};
try{
for(let i=0;i<3;i++){
const g=await(await fetch(`${B}/auth.php`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'guest'})})).json();
if(!g.access_token)break;
users.push({id:g.profile.id,token:g.access_token});
await sb(`profiles?id=eq.${g.profile.id}`,{method:'PATCH',body:JSON.stringify({display_name:`ZZZ-RX-${i+1}`})});
}
check('three throwaway accounts',users.length===3,`${users.length}`);
const [A,Bp,C]=users;
await sb('matchmaking_queue?player_id=eq.'+A.id,{method:'DELETE'});
await api('matchmaking.php',{action:'queue',game_key:'chess',time_control:'blitz_5_0'},A.token);
const m=(await api('matchmaking.php',{action:'queue',game_key:'chess',time_control:'blitz_5_0'},Bp.token)).data;
matchId=m.match_id;
check('a real match exists',!!matchId,JSON.stringify(m));
console.log('\n=== sending ===');
let r=await api('match-chat.php',{action:'send',match_id:matchId,game_key:'chess',kind:'emote',key:'gg'},A.token);
check('player A sends an emote',r.data.ok===true,JSON.stringify(r.data));
r=await api('match-chat.php',{action:'send',match_id:matchId,game_key:'chess',kind:'emote',key:'wow'},A.token);
check('the server enforces its own cooldown',r.data.cooldown===true,JSON.stringify(r.data));
r=await api('match-chat.php',{action:'send',match_id:matchId,game_key:'chess',kind:'phrase',key:'good_luck'},Bp.token);
check('player B sends a phrase in the same window',r.data.ok===true,JSON.stringify(r.data));
r=await api('match-chat.php',{action:'send',match_id:matchId,game_key:'chess',kind:'emote',key:'not_a_real_key'},Bp.token);
check('an unknown emote key is rejected',r.status===400,`status ${r.status}`);
r=await api('match-chat.php',{action:'send',match_id:matchId,game_key:'chess',kind:'emote',key:'gg'},C.token);
check('a non-participant cannot post into the match',r.status===403,`status ${r.status}`);
console.log('\n=== receiving ===');
const pa=(await get('match-chat.php',{action:'poll',match_id:matchId,game_key:'chess'},A.token)).data;
check('A sees both reactions',(pa.messages||[]).length===2,JSON.stringify((pa.messages||[]).map(x=>x.key)));
const mine=(pa.messages||[]).filter(x=>x.mine);
check('A\'s own reaction is flagged as theirs',mine.length===1&&mine[0].key==='gg',JSON.stringify(mine.map(x=>x.key)));
const theirs=(pa.messages||[]).find(x=>!x.mine);
check('the other reaction carries the sender id',theirs?.sender_id===Bp.id,String(theirs?.sender_id));
check('…and the sender NAME, so you can tell who spoke',!!theirs?.name,String(theirs?.name));
check('an emote carries its emoji',(pa.messages||[]).find(x=>x.key==='gg')?.emoji==='🤝');
const pc=await get('match-chat.php',{action:'poll',match_id:matchId,game_key:'chess'},C.token);
check('a non-participant cannot read the match chat',pc.status===403,`status ${pc.status}`);
const inc=(await get('match-chat.php',{action:'poll',match_id:matchId,game_key:'chess',after:pa.now},A.token)).data;
check('incremental poll returns nothing new',(inc.messages||[]).length===0,JSON.stringify(inc.messages));
console.log('\n=== isolation from game state ===');
const mrow=(await sb(`matches?id=eq.${matchId}&select=game_state,current_fen,move_count,status`))[0];
check('the match game_state was NOT touched by reactions',
JSON.stringify(mrow.game_state||{}).indexOf('emote')===-1 && JSON.stringify(mrow.game_state||{}).indexOf('react')===-1,
JSON.stringify(mrow.game_state));
check('the board is untouched',mrow.move_count===0&&mrow.status==='in_progress',JSON.stringify({mc:mrow.move_count,s:mrow.status}));
}catch(e){fails.push('threw: '+e.message);console.log('ERROR',e.message);}
finally{
console.log('\n=== teardown ===');
if(matchId){await sb(`chat_messages?channel_id=eq.${matchId}`,{method:'DELETE'});await sb(`mp_log?match_id=eq.${matchId}`,{method:'DELETE'});await sb(`matches?id=eq.${matchId}`,{method:'DELETE'});}
for(const u of users){
for(const p of [`chat_messages?sender_id=eq.${u.id}`,`matchmaking_queue?player_id=eq.${u.id}`,`mp_log?player_id=eq.${u.id}`,`rating_history?player_id=eq.${u.id}`,`matches?white_player_id=eq.${u.id}`,`matches?black_player_id=eq.${u.id}`]) await sb(p,{method:'DELETE'});
await sb(`profiles?id=eq.${u.id}`,{method:'DELETE'});
await fetch(`${SB}/auth/v1/admin/users/${u.id}`,{method:'DELETE',headers:{apikey:SK,Authorization:'Bearer '+SK}});
}
console.log(` cleaned ${users.length} accounts`);
}
console.log('');
if(fails.length){console.log(`${fails.length} FAILURE(S)`);fails.forEach(f=>console.log(' - '+f));process.exit(1);}
console.log('REACTIONS OK');
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