diff --git a/.gitignore b/.gitignore
index 71ea255..3bc41db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,7 @@
.env
data/
+data-dev/
node_modules/
-push.sh
\ No newline at end of file
+push.sh
+rerun.sh
+compose.dev.yml
diff --git a/README.md b/README.md
index 76496c0..d9a2ce6 100644
--- a/README.md
+++ b/README.md
@@ -82,4 +82,53 @@ git pull
## Data
-Game data (users, stats, config) is stored in `./data/` which is bind-mounted into the container. It persists across restarts and rebuilds.
+Game data is stored in `./data/`, bind-mounted into the container, and persists
+across restarts and rebuilds:
+
+| File | Contents |
+|---|---|
+| `users.json` | Local accounts (Hokm/Shelem accounts come from `SHARED_USERS_FILE`) |
+| `stats.json` | Per-player totals behind the profile and leaderboard |
+| `config.json` | Runtime config (e.g. whether signups are open) |
+| `history.json` | Finished games with their round-by-round breakdown |
+
+`history.json` keeps the most recent 2000 games. Games with a bot at the table
+are not recorded, so neither history nor the leaderboard counts them.
+
+## Game history
+
+Every finished all-human game is saved with the score each player picked up in
+each hand. Players reach it from **Profile β π Game History**; tapping a game
+shows the final standings and a hand-by-hand table with a ruled-off totals row.
+The same breakdown appears on the end-of-hand and end-of-game screens, so the
+running scores are visible from the first hand onwards rather than only at the
+end.
+
+## Leaderboard
+
+Ranked by **win rate** β the percentage of finished games in which the player
+had the lowest score. A game that ends in a tie for lowest counts as a win for
+each tied player. Average score per game breaks ties in the ranking, and there
+*lower* is better, since Hearts scores are penalty points.
+
+A player needs **5 finished games** before they take a rank, so one lucky game
+can't top the board. Players below that are still listed underneath the ranking,
+unranked and dimmed, showing how many more games they need. Change
+`MIN_RANKED_GAMES` in `server.js` to adjust; the client reads the value back from
+`/api/config`, so the label follows automatically.
+
+Games with a bot at the table are not counted at all.
+
+## Card-count integrity
+
+Hearts games could previously end early if one seat somehow got two cards into a
+single trick: that player ran out while everyone else still held cards. The
+server now rejects a second card from a seat that has already played in the
+current trick, refuses cards that aren't in the player's hand, and puts nobody
+on turn while a trick is resolving.
+
+As a backstop, it snapshots the table at the start of every trick (only when all
+four hands are level) and re-checks the counts after each trick and at the end of
+each hand. If they have drifted apart it rewinds to that last sound snapshot,
+tells every client (they show a brief notice), and play resumes from the start of
+that trick.
diff --git a/package.json b/package.json
index 46eef56..173fd70 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "hearts",
- "version": "1.0.0",
+ "version": "1.1.0",
"description": "Hearts card game - multiplayer",
"main": "server.js",
"scripts": {
diff --git a/public/app.js b/public/app.js
index 975aa6e..4ceae1d 100644
--- a/public/app.js
+++ b/public/app.js
@@ -2,28 +2,98 @@
// βββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββ
let socket;
-let myName = '';
-let mySeat = -1;
-let myToken = null;
-let myRoomId = null;
+let mySeat = -1;
+let myToken = null;
+let myRoomId = null;
let spectating = false;
let authToken = localStorage.getItem('hearts_token') || null;
let authUser = localStorage.getItem('hearts_user') || null;
let lastState = null;
-let deferredInstallPrompt = null;
-// βββ Turn escalation state ββββββββββββββββββββββββββββββββββββ
-let turnEscalationInterval = null;
-let turnEscalationLevel = 0;
-let wasMyTurn = false;
-
-// βββ AFK / AI-takeover state ββββββββββββββββββββββββββββββββββ
-let aiControlledSeats = new Set();
-let afkWarnSeat = -1;
+// Lobby selections
let selectedScore = 100;
let selectedPublic = false;
+let swapSelectedSeat = -1; // seat highlighted for swap in the waiting room
-// βββ Play mode & hand display mode βββββββββββββββββββββββββββ
+// Pass (3-card exchange) state
+let passSelected = [];
+let passActive = false; // true while the pass overlay owns a live selection
+
+// Turn reminder / escalation state
+let turnReminderTimer = null;
+let turnReminderActive = false;
+let turnEscalationLevel = 0;
+
+// AFK / AI control state
+let afkBannerVoted = false;
+let aiControlledSeats = new Set();
+
+// PWA
+let deferredInstallPrompt = null;
+
+// βββ Audio (unlocked on first gesture β required by iOS) ββββββ
+let _audioCtx = null;
+function getAudioCtx() {
+ if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ if (_audioCtx.state === 'suspended') _audioCtx.resume();
+ return _audioCtx;
+}
+document.addEventListener('pointerdown', () => getAudioCtx(), { once: true });
+
+function playTurnChime() {
+ try {
+ const ctx = getAudioCtx();
+ const now = ctx.currentTime;
+ [[784, 0], [988, 0.18]].forEach(([freq, delay]) => {
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.connect(gain); gain.connect(ctx.destination);
+ osc.type = 'sine';
+ osc.frequency.value = freq;
+ gain.gain.setValueAtTime(0, now + delay);
+ gain.gain.linearRampToValueAtTime(0.22, now + delay + 0.02);
+ gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.45);
+ osc.start(now + delay);
+ osc.stop(now + delay + 0.5);
+ });
+ } catch { /* audio not available */ }
+}
+
+function applyTurnEscalation(level) {
+ const pm = $('phase-msg');
+ if (!pm) return;
+ pm.classList.remove('your-turn-lvl1', 'your-turn-lvl2', 'your-turn-lvl3');
+ if (level > 0) pm.classList.add(`your-turn-lvl${level}`);
+}
+
+function handleTurnReminder(isMyTurn) {
+ if (isMyTurn && !turnReminderActive) {
+ turnReminderActive = true;
+ turnEscalationLevel = 0;
+ $('my-area')?.classList.remove('turn-urgent');
+ applyTurnEscalation(0);
+ // Escalate every 5 seconds: level 1 β 2 β 3 (vibrate/chime at level 3)
+ turnReminderTimer = setInterval(() => {
+ turnEscalationLevel = Math.min(turnEscalationLevel + 1, 3);
+ applyTurnEscalation(turnEscalationLevel);
+ if (turnEscalationLevel >= 3) {
+ $('my-area')?.classList.add('turn-urgent');
+ if (navigator.vibrate) navigator.vibrate([300, 100, 300]);
+ else playTurnChime();
+ clearInterval(turnReminderTimer);
+ turnReminderTimer = null;
+ }
+ }, 5000);
+ } else if (!isMyTurn && turnReminderActive) {
+ if (turnReminderTimer) { clearInterval(turnReminderTimer); turnReminderTimer = null; }
+ turnReminderActive = false;
+ turnEscalationLevel = 0;
+ applyTurnEscalation(0);
+ $('my-area')?.classList.remove('turn-urgent');
+ }
+}
+
+// βββ Play / hand-display mode βββββββββββββββββββββββββββββββββ
function isTouchDevice() {
return window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window;
}
@@ -35,28 +105,38 @@ function loadPlayMode() {
function savePlayMode(m) { localStorage.setItem('hearts_play_mode', m); }
function loadHandMode() {
const s = localStorage.getItem('hearts_hand_mode');
- return s === 'fan' ? 'fan' : 'scroll';
+ if (['scroll', 'fan', 'playables'].includes(s)) return s;
+ return isTouchDevice() ? 'fan' : 'scroll';
}
function saveHandMode(m) { localStorage.setItem('hearts_hand_mode', m); }
+function loadBarBottom() { return localStorage.getItem('hearts_bar_bottom') === '1'; }
+function saveBarBottom(v) { localStorage.setItem('hearts_bar_bottom', v ? '1' : '0'); }
-let playMode = loadPlayMode();
-let handMode = loadHandMode();
+let playMode = loadPlayMode();
+let handMode = loadHandMode();
+let barAtBottom = loadBarBottom();
+
+function applyBarBottom() {
+ $('screen-game')?.classList.toggle('bar-bottom', barAtBottom);
+ const btn = $('btn-toggle-bar');
+ if (btn) btn.textContent = barAtBottom ? 'β¬ Bar to top' : 'β¬ Bar to bottom';
+}
+
+const HAND_MODES = ['scroll', 'fan', 'playables'];
+const HAND_LABELS = { scroll: 'π Scroll', fan: 'π Fan', playables: 'β
Playables' };
function updatePlayModeBtn() {
const btn = $('btn-play-mode');
if (!btn) return;
- const isDrag = playMode === 'drag';
- btn.textContent = isDrag ? 'β Drag' : 'π Tap';
- btn.classList.toggle('drag-mode', isDrag);
- btn.title = isDrag ? 'Switch to tap mode' : 'Switch to drag mode';
+ const drag = playMode === 'drag';
+ btn.textContent = drag ? 'β Drag' : 'π Tap';
+ btn.classList.toggle('drag-mode', drag);
+ btn.title = drag ? 'Switch to tap mode' : 'Switch to drag mode';
}
-const HAND_MODES = ['scroll', 'fan', 'playables'];
-const HAND_MODE_LABEL = { scroll: 'π Scroll', fan: 'π Fan', playables: 'β
Playables' };
-
function updateHandModeBtn() {
const btn = $('btn-hand-mode');
if (!btn) return;
- btn.textContent = HAND_MODE_LABEL[handMode] || 'π Scroll';
+ btn.textContent = HAND_LABELS[handMode] || 'π Scroll';
btn.classList.toggle('fan-mode', handMode === 'fan' || handMode === 'playables');
}
function applyHandMode() {
@@ -64,14 +144,11 @@ function applyHandMode() {
const handEl = $('my-hand');
handEl?.classList.toggle('fan-mode', fanLike);
$('my-area')?.classList.toggle('fan-active', fanLike);
- // Reset inline justify so scroll mode's CSS takes over cleanly
if (!fanLike && handEl) handEl.style.justifyContent = '';
}
-// Dynamic card spacing for fan/playables mode.
-// If cards overflow β minimum overlap to fit (+2px buffer).
-// If cards fit β spread up to 16px but never exceed available space
-// (prevents fan overflow on screens where cards are borderline).
+// Dynamic overlap for fan/playables mode: overlap just enough when the cards
+// don't fit, spread up to 16px when they do.
function updateHandSpacing() {
const handEl = $('my-hand');
if (!handEl || handMode === 'scroll') return;
@@ -79,63 +156,130 @@ function updateHandSpacing() {
const n = cards.length;
if (n === 0) return;
if (n === 1) { cards[0].style.marginLeft = '0'; return; }
-
const cardW = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-w')) || 68;
const containerW = handEl.offsetWidth || (window.innerWidth - 24);
const overflow = n * cardW - containerW;
-
let ml;
if (overflow > 0) {
- // Cards don't fit β overlap just enough, plus 2px safety buffer
ml = -(Math.ceil(overflow / (n - 1)) + 2);
handEl.style.justifyContent = 'flex-start';
} else {
- // Cards fit β spread up to 16px, capped to available space
ml = Math.min(16, Math.floor(-overflow / (n - 1)));
- // Center the group so few cards don't hug the left edge
handEl.style.justifyContent = 'center';
}
-
cards.forEach((c, i) => { c.style.marginLeft = i === 0 ? '0' : ml + 'px'; });
}
-// Attach touch drag-to-play handlers to a card element
-function addDragHandlers(cardEl, code) {
- const handEl = $('my-hand');
- // Magnifier only helps when the hand is dense (>8 cards); sparse fans are readable
- const cardCount = $('my-hand')?.querySelectorAll('.card').length ?? 0;
- const useMagnifier = isTouchDevice() && playMode === 'drag'
- && (handMode === 'fan' || handMode === 'playables')
- && cardCount > 8;
- let touchStartY = 0, touchStartX = 0, isDragging = false, ghost = null;
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// CARD ASSETS β loaded once, never refetched
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Cards used to visibly flash at the start of every trick because each render
+// built brand-new elements. Now every face is decoded once into a
+// template image; everything on screen is a clone of that template, and the
+// renderers below reuse existing DOM nodes instead of rebuilding them.
+
+const SUIT_FILE = { H: 'HEART', D: 'DIAMOND', C: 'CLUB', S: 'SPADE' };
+const RANK_FILE = {
+ 'A': '1', '2': '2', '3': '3', '4': '4', '5': '5', '6': '6',
+ '7': '7', '8': '8', '9': '9', '10': '10',
+ 'J': '11-JACK', 'Q': '12-QUEEN', 'K': '13-KING',
+};
+const SUITS = ['C', 'D', 'H', 'S'];
+const RANKS = ['2','3','4','5','6','7','8','9','10','J','Q','K','A'];
+const ALL_CARDS = SUITS.flatMap(s => RANKS.map(r => `${s}-${r}`));
+
+function cardSvg(code) {
+ const [s, r] = code.split('-');
+ return `/cards/${SUIT_FILE[s]}-${RANK_FILE[r]}.svg`;
+}
+
+const cardTemplates = new Map();
+function cardTemplate(code) {
+ let t = cardTemplates.get(code);
+ if (!t) {
+ t = new Image();
+ t.src = cardSvg(code);
+ t.alt = code;
+ t.draggable = false;
+ t.dataset.card = code;
+ cardTemplates.set(code, t);
+ }
+ return t;
+}
+// Cloning a loaded image reuses the decoded bitmap β no request, no flash.
+function cardImg(code) { return cardTemplate(code).cloneNode(true); }
+function preloadCards() { ALL_CARDS.forEach(cardTemplate); }
+
+function makeCardEl(code) {
+ const el = document.createElement('div');
+ el.className = 'card';
+ el.dataset.card = code;
+ el.appendChild(cardImg(code));
+ wireCardDrag(el);
+ return el;
+}
+
+// Reconcile a row of cards against `codes`, keeping the DOM nodes that are
+// already there. Returns the elements in display order.
+function syncCards(container, codes) {
+ const existing = new Map();
+ for (const el of Array.from(container.children)) {
+ if (el.dataset?.card) existing.set(el.dataset.card, el);
+ }
+ const wanted = codes.map(code => existing.get(code) || makeCardEl(code));
+ const keep = new Set(wanted);
+ for (const el of existing.values()) if (!keep.has(el)) el.remove();
+ // appendChild/insertBefore on an existing node moves it; the image is untouched
+ wanted.forEach((el, i) => {
+ if (container.children[i] !== el) container.insertBefore(el, container.children[i] || null);
+ });
+ return wanted;
+}
+
+// A trick slot holds at most one card. Leave it alone if it already shows it.
+function setSlotCard(slotId, code) {
+ const el = $(slotId);
+ if (!el) return;
+ const cur = el.firstElementChild;
+ if (!code) { if (cur) el.replaceChildren(); return; }
+ if (cur && cur.dataset.card === code) return;
+ el.replaceChildren(cardImg(code));
+}
+
+// βββ Drag-to-play (touch devices) βββββββββββββββββββββββββββββ
+// Wired once per card element; the mode checks happen at touch time so a mode
+// switch doesn't require rebuilding the hand.
+function wireCardDrag(cardEl) {
+ let touchStartY = 0, isDragging = false, ghost = null, magnifying = false;
cardEl.addEventListener('touchstart', e => {
+ if (!cardEl.classList.contains('playable')) return;
touchStartY = e.touches[0].clientY;
- touchStartX = e.touches[0].clientX;
isDragging = false;
- if (useMagnifier) cardEl.classList.add('magnified');
+ const count = $('my-hand')?.querySelectorAll('.card').length ?? 0;
+ magnifying = isTouchDevice() && playMode === 'drag'
+ && (handMode === 'fan' || handMode === 'playables') && count > 8;
+ if (magnifying) cardEl.classList.add('magnified');
}, { passive: true });
cardEl.addEventListener('touchmove', e => {
+ if (!cardEl.classList.contains('playable')) return;
const dy = touchStartY - e.touches[0].clientY;
-
- // Horizontal sweep in fan+drag mode β magnifier picks cards
- if (useMagnifier && !isDragging && dy < 20) {
+ // Horizontal sweep with the magnifier picks a card without playing it
+ if (magnifying && !isDragging && dy < 20) {
cardEl.classList.remove('magnified');
- const el2 = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY);
- const tgt = el2?.closest('.card');
- if (tgt) tgt.classList.add('magnified');
+ const under = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY);
+ under?.closest('.card')?.classList.add('magnified');
return;
}
-
if (dy > 20) {
e.preventDefault();
isDragging = true;
- handEl?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified'));
+ $('my-hand')?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified'));
if (!ghost) {
ghost = document.createElement('div');
ghost.className = 'drag-ghost';
- ghost.appendChild(cardImg(code));
+ ghost.appendChild(cardImg(cardEl.dataset.card));
document.body.appendChild(ghost);
$('trick-area')?.classList.add('drag-active');
}
@@ -147,10 +291,10 @@ function addDragHandlers(cardEl, code) {
const endDrag = e => {
if (ghost) { ghost.remove(); ghost = null; }
$('trick-area')?.classList.remove('drag-active');
- handEl?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified'));
- if (isDragging) {
+ $('my-hand')?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified'));
+ if (isDragging && cardEl.classList.contains('playable')) {
const endY = (e.changedTouches?.[0] ?? e.touches?.[0])?.clientY ?? touchStartY;
- if (endY < window.innerHeight * 0.70) playCard(code);
+ if (endY < window.innerHeight * 0.70) playCard(cardEl.dataset.card);
}
isDragging = false;
};
@@ -158,103 +302,34 @@ function addDragHandlers(cardEl, code) {
cardEl.addEventListener('touchcancel', endDrag);
}
-const SUIT_ICON = { H: 'β₯', D: 'β¦', C: 'β£', S: 'β ' };
-const SUIT_COLOR = { H: '#e53935', D: '#e53935', C: '#111', S: '#111' };
-const PASS_DIR_LABEL = { left: 'β Pass Left', right: 'β Pass Right', across: 'β Pass Across', hold: 'No Pass (Hold)' };
-
-// Map from internal code (e.g. "S-Q") to SVG filename (e.g. "SPADE-12-QUEEN.svg")
-const SUIT_FILE = { H: 'HEART', D: 'DIAMOND', C: 'CLUB', S: 'SPADE' };
-const RANK_FILE = {
- 'A': '1', '2': '2', '3': '3', '4': '4', '5': '5', '6': '6',
- '7': '7', '8': '8', '9': '9', '10': '10',
- 'J': '11-JACK', 'Q': '12-QUEEN', 'K': '13-KING',
-};
-
-// βββ Turn escalation ββββββββββββββββββββββββββββββββββββββββββ
-function handleTurnReminder(isMyTurn) {
- clearInterval(turnEscalationInterval);
- turnEscalationLevel = 0;
- applyTurnEscalation(0);
- if (!isMyTurn) return;
- turnEscalationInterval = setInterval(() => {
- turnEscalationLevel = Math.min(turnEscalationLevel + 1, 3);
- applyTurnEscalation(turnEscalationLevel);
- if (turnEscalationLevel >= 3) {
- if (navigator.vibrate) navigator.vibrate([200, 100, 200]);
- try {
- const ctx = new (window.AudioContext || window.webkitAudioContext)();
- const o = ctx.createOscillator();
- const g = ctx.createGain();
- o.connect(g); g.connect(ctx.destination);
- o.frequency.value = 880;
- g.gain.setValueAtTime(0.3, ctx.currentTime);
- g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
- o.start(); o.stop(ctx.currentTime + 0.4);
- } catch(e) {}
- }
- }, 5000);
-}
-
-function applyTurnEscalation(level) {
- const el = $('phase-msg');
- if (!el) return;
- el.classList.remove('your-turn-lvl1', 'your-turn-lvl2', 'your-turn-lvl3');
- if (level > 0) el.classList.add(`your-turn-lvl${level}`);
-}
-
-// βββ AFK / AI-takeover UI βββββββββββββββββββββββββββββββββββββ
-function updateAiControlBanner() {
- const banner = $('ai-control-banner');
- const msg = $('ai-control-msg');
- if (!banner || !msg) return;
- if (aiControlledSeats.size === 0) { hide('ai-control-banner'); return; }
- if (aiControlledSeats.has(mySeat)) {
- msg.textContent = 'AI is playing for you β play a card to resume control';
- banner.className = 'ai-control-banner ai-self';
- } else {
- const names = [...aiControlledSeats].map(s => lastState?.names[s] || `P${s+1}`).join(', ');
- msg.textContent = `AI is playing for ${names}`;
- banner.className = 'ai-control-banner ai-other';
- }
- show('ai-control-banner');
-}
-
// βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
function $(id) { return document.getElementById(id); }
function show(...ids) { ids.forEach(id => $(id)?.classList.remove('hidden')); }
function hide(...ids) { ids.forEach(id => $(id)?.classList.add('hidden')); }
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
- $(id).classList.add('active');
+ $(id)?.classList.add('active');
}
-function cardImg(code) {
- const [s, r] = code.split('-');
- const img = document.createElement('img');
- img.src = `/cards/${SUIT_FILE[s]}-${RANK_FILE[r]}.svg`;
- img.alt = code;
- img.draggable = false;
- return img;
+function escHtml(s) {
+ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
-function suitOf(code) { return code.split('-')[0]; }
-function rankOf(code) { return code.split('-')[1]; }
-function isRed(code) { const s = suitOf(code); return s === 'H' || s === 'D'; }
-function isHeart(code){ return suitOf(code) === 'H'; }
-function isQoS(code) { return code === 'S-Q'; }
-function cardPoints(code) { return isHeart(code) ? 1 : isQoS(code) ? 13 : 0; }
+function suitOf(code) { return code.split('-')[0]; }
+function isHeart(code) { return suitOf(code) === 'H'; }
+function cardPoints(code) { return isHeart(code) ? 1 : code === 'S-Q' ? 13 : 0; }
-// Seat mapping: my seat is bottom; left/top/right follow clockwise
-function relativePosition(mySeat, theirSeat) {
- const delta = (theirSeat - mySeat + 4) % 4;
- return ['bottom', 'right', 'top', 'left'][delta];
-}
-function positionToArea(pos) {
- return { top: 'top', left: 'left', right: 'right', bottom: 'bottom' }[pos];
+const PASS_DIR_LABEL = {
+ left: 'β Pass Left', right: 'β Pass Right', across: 'β Pass Across', hold: 'No Pass (Hold)',
+};
+
+// Seats run clockwise from the viewer: left = +1, across = +2, right = +3.
+// Play runs the other way (seat β seat+3), i.e. bottom β right β top β left.
+function seatSlots() {
+ const base = mySeat < 0 ? 0 : mySeat;
+ return { bottom: base, left: (base + 1) % 4, top: (base + 2) % 4, right: (base + 3) % 4 };
}
-// βββ Auth helpers βββββββββββββββββββββββββββββββββββββββββββββ
-function authHeaders() {
- return authToken ? { 'Authorization': `Bearer ${authToken}` } : {};
-}
+// βββ Auth fetch helpers βββββββββββββββββββββββββββββββββββββββ
+function authHeaders() { return authToken ? { Authorization: `Bearer ${authToken}` } : {}; }
async function apiFetch(url, opts = {}) {
const res = await fetch(url, {
...opts,
@@ -264,862 +339,100 @@ async function apiFetch(url, opts = {}) {
return res.json();
}
-function setAuth(token, username) {
- authToken = token;
- authUser = username;
- if (token) {
- localStorage.setItem('hearts_token', token);
- localStorage.setItem('hearts_user', username);
- } else {
- localStorage.removeItem('hearts_token');
- localStorage.removeItem('hearts_user');
- }
+// βββ Lobby init βββββββββββββββββββββββββββββββββββββββββββββββ
+function initLobby() {
updateAuthBar();
-}
-function updateAuthBar() {
- if (authUser) {
- $('auth-status').textContent = `Logged in as ${authUser}`;
- show('btn-show-profile', 'btn-logout');
- hide('btn-show-auth');
- // Pre-fill the name input so the user doesn't have to type it
- const nameInput = $('input-name');
- if (nameInput && !nameInput.value) nameInput.value = authUser;
- } else {
- $('auth-status').textContent = 'Playing as guest';
- hide('btn-show-profile', 'btn-logout');
- show('btn-show-auth');
- }
-}
-
-// βββ Session helpers ββββββββββββββββββββββββββββββββββββββββββ
-function saveSession(roomId, seat, token) {
- localStorage.setItem('hearts_session', JSON.stringify({ roomId, seat, token, ts: Date.now() }));
-}
-function loadSession() {
- try {
- const s = JSON.parse(localStorage.getItem('hearts_session'));
- if (s && Date.now() - s.ts < 3 * 60 * 60 * 1000) return s;
- } catch { /* ignore */ }
- return null;
-}
-function clearSession() { localStorage.removeItem('hearts_session'); }
-
-// βββ Socket init ββββββββββββββββββββββββββββββββββββββββββββββ
-function initSocket() {
- socket = io({ auth: { token: authToken } });
-
- socket.on('connect', () => {
- // Try to rejoin an active session
- const sess = loadSession();
- if (sess && sess.roomId) {
- socket.emit('rejoin', { roomId: sess.roomId, seat: sess.seat, token: sess.token });
- }
- });
-
- socket.on('created', ({ roomId, seat, token }) => {
- myRoomId = roomId;
- mySeat = seat;
- myToken = token;
- saveSession(roomId, seat, token);
- $('display-room-code').textContent = roomId;
- showScreen('screen-waiting');
- });
-
- socket.on('joined', ({ roomId, seat, token }) => {
- myRoomId = roomId;
- mySeat = seat;
- myToken = token;
- saveSession(roomId, seat, token);
- $('display-room-code').textContent = roomId;
- showScreen('screen-waiting');
- });
-
- socket.on('spectating', ({ roomId }) => {
- myRoomId = roomId;
- mySeat = -1;
- spectating = true;
- showScreen('screen-game');
- show('spectator-banner');
- });
-
- socket.on('rejoined', ({ roomId, seat, token }) => {
- myRoomId = roomId;
- mySeat = seat;
- myToken = token;
- saveSession(roomId, seat, token);
- hide('overlay-pass', 'overlay-hand', 'overlay-gameover');
- });
-
- socket.on('rejoinError', (msg) => {
- clearSession();
- $('lobby-error').textContent = msg || 'Could not rejoin';
- showScreen('screen-lobby');
- });
-
- socket.on('hasActiveGame', ({ roomId, seat, token }) => {
- if (myRoomId) return; // already in a session
- myRoomId = roomId; mySeat = seat; myToken = token;
- saveSession(roomId, seat, token);
- socket.emit('rejoin', { roomId, seat, token });
- });
-
- // Core game events β all handled by renderState
- socket.on('roomInfo', state => handleState(state));
- socket.on('cardPlayed',state => handleState(state));
- socket.on('trickWon', state => handleTrickWon(state));
- socket.on('handOver', state => handleHandOver(state));
- socket.on('gameOver', state => handleGameOver(state));
-
- socket.on('error', msg => { $('lobby-error').textContent = msg; });
- socket.on('joinError', msg => { $('lobby-error').textContent = msg; });
- socket.on('passError', msg => console.warn('passError:', msg));
- socket.on('playError', msg => console.warn('playError:', msg));
-
- socket.on('afkWarning', ({ seat, name }) => {
- afkWarnSeat = seat;
- const isMe = seat === mySeat;
- $('afk-banner-msg').textContent = isMe
- ? "You haven't played yet!"
- : `${name} hasn't played. Let AI take over?`;
- if (isMe) hide('afk-vote-btn'); else show('afk-vote-btn');
- show('afk-banner');
- });
-
- socket.on('afkResolved', () => {
- afkWarnSeat = -1;
- hide('afk-banner');
- });
-
- socket.on('aiControl', ({ seat, active, name }) => {
- if (active) aiControlledSeats.add(seat); else aiControlledSeats.delete(seat);
- updateAiControlBanner();
- });
-}
-
-// βββ State rendering ββββββββββββββββββββββββββββββββββββββββββ
-function syncAiState(state) {
- aiControlledSeats = new Set(state.aiControlledSeats || []);
- updateAiControlBanner();
-}
-
-function handleState(state) {
- lastState = state;
- syncAiState(state);
-
- // Restore locally-selected pass cards that the server doesn't know about yet
- if (state.state === 'PASSING' && mySeat >= 0 && !state.passReady[mySeat]) {
- state.passSelected = passSelectedLocal;
- } else {
- passSelectedLocal = [];
- }
-
- // Always dismiss result overlays when fresh state arrives
- hide('overlay-hand', 'overlay-gameover');
-
- if (state.state === 'WAITING') {
- renderWaitingRoom(state);
- return;
- }
-
- showScreen('screen-game');
- renderInfoBar(state);
- renderTable(state);
-
- if (state.state === 'PASSING') {
- renderPassOverlay(state);
- } else {
- hide('overlay-pass');
- }
-}
-
-function handleTrickWon(state) {
- lastState = state;
- syncAiState(state);
- renderInfoBar(state);
- renderTable(state);
- // Flash the winning slot briefly
- const winnerPos = spectating ? null : relativePosition(mySeat, state.lastTrickWinner);
- const slot = winnerPos ? $(`trick-${winnerPos}`) : null;
- if (slot) slot.classList.add('trick-winner-flash');
- setTimeout(() => slot?.classList.remove('trick-winner-flash'), 600);
-}
-
-function handleHandOver(state) {
- lastState = state;
- syncAiState(state);
- hide('afk-banner');
- renderInfoBar(state);
- renderTable(state);
- hide('overlay-pass');
- showHandOverlay(state);
-}
-
-function handleGameOver(state) {
- lastState = state;
- syncAiState(state);
- hide('afk-banner');
- renderInfoBar(state);
- renderTable(state);
- hide('overlay-pass', 'overlay-hand');
- showGameOverOverlay(state);
-}
-
-// ββ Info bar ββββββββββββββββββββββββββββββββββββββββββββββββββ
-function renderInfoBar(state) {
- // Score display
- const scoreDiv = $('score-display');
- scoreDiv.innerHTML = '';
- const hpDiv = $('hand-points-display');
- hpDiv.innerHTML = '';
-
- const minScore = Math.min(...state.scores);
- state.names.forEach((name, i) => {
- const isMine = i === mySeat;
- const entry = document.createElement('div');
- entry.className = 'score-entry' + (isMine ? ' my-score' : '') + (state.scores[i] === minScore && state.scores.filter(s => s === minScore).length < 4 ? ' leading' : '');
- const nameEl = document.createElement('span');
- nameEl.className = 'score-name';
- nameEl.textContent = name || `P${i+1}`;
- const valEl = document.createElement('span');
- valEl.className = 'score-val';
- valEl.textContent = state.scores[i];
- entry.appendChild(nameEl);
- entry.appendChild(valEl);
- scoreDiv.appendChild(entry);
-
- });
- // Hand points row: only show the local player's own points
- const myHpVal = state.handPoints[mySeat];
- if (myHpVal != null && myHpVal > 0 && mySeat >= 0) {
- const hp = document.createElement('span');
- hp.className = 'hp-entry';
- const hn = document.createElement('span'); hn.className = 'hp-name'; hn.textContent = 'You:';
- const hv = document.createElement('span'); hv.className = 'hp-val has-pts'; hv.textContent = myHpVal;
- hp.appendChild(hn); hp.appendChild(hv);
- hpDiv.appendChild(hp);
- }
-
- // Hearts broken
- if (state.heartsBroken) { show('hearts-broken-display'); }
- else { hide('hearts-broken-display'); }
-
- // Pass direction
- if (state.state === 'PASSING') {
- $('pass-dir-display').textContent = PASS_DIR_LABEL[state.passDirection] || state.passDirection;
- show('pass-dir-display');
- } else {
- hide('pass-dir-display');
- }
-}
-
-// ββ Waiting room ββββββββββββββββββββββββββββββββββββββββββββββ
-function renderWaitingRoom(state) {
- showScreen('screen-waiting');
- const filled = state.names.filter((n, i) => n && (state.bots[i] || true)).length;
- const humanCount = state.names.filter((n, i) => n && !state.bots[i]).length;
- const botCount = state.bots.filter(Boolean).length;
- const total = humanCount + botCount;
-
- for (let i = 0; i < 4; i++) {
- const slot = document.querySelector(`.seat-slot[data-seat="${i}"]`);
- if (!slot) continue;
- const nameSpan = slot.querySelector('.seat-name');
- if (state.names[i]) {
- nameSpan.textContent = state.names[i] + (state.bots[i] ? ' π€' : '');
- slot.classList.add('filled');
- } else {
- nameSpan.textContent = 'β';
- slot.classList.remove('filled');
- }
- }
-
- const remaining = 4 - total;
- $('waiting-status').textContent = remaining > 0
- ? `Waiting for ${remaining} more player${remaining > 1 ? 's' : ''}β¦`
- : 'All players ready!';
-
- // Show fill-bots button if we're the first seat and there are empty seats
- if (mySeat === 0 && total < 4) {
- show('btn-fill-bots');
- } else {
- hide('btn-fill-bots');
- }
-
- const badge = $('waiting-public-badge');
- if (badge) badge.textContent = state.isPublic ? 'π Public' : 'π Private';
-}
-
-// ββ Game table ββββββββββββββββββββββββββββββββββββββββββββββββ
-function renderTable(state) {
- if (spectating) {
- renderSpectatorTable(state);
- return;
- }
-
- const positions = ['top', 'left', 'right'];
- const otherSeats = [
- (mySeat + 2) % 4, // top (across)
- (mySeat + 3) % 4, // left
- (mySeat + 1) % 4, // right
- ];
-
- positions.forEach((pos, idx) => {
- const seat = otherSeats[idx];
- $(`${pos}-name`).textContent = state.names[seat] || `P${seat+1}`;
- $(`${pos}-score`).textContent = state.scores[seat];
-
- // Turn indicator
- if (state.currentTurn === seat && state.state === 'PLAYING') {
- show(`${pos}-turn`);
- } else {
- hide(`${pos}-turn`);
- }
- // No card-back display β opponents' hand size is hidden
- $(`${pos}-cards`).innerHTML = '';
- });
-
- // My area
- $('my-name').textContent = state.names[mySeat] || 'You';
- $('my-score').textContent = state.scores[mySeat];
- // Only show my own in-hand points (server sends null for others)
- const myHp = state.handPoints[mySeat];
- $('my-hand-pts').textContent = myHp > 0 ? `+${myHp}β₯` : '';
-
- if (state.currentTurn === mySeat && state.state === 'PLAYING') {
- show('my-turn');
- } else {
- hide('my-turn');
- }
-
- // Render my hand
- const myHand = Array.isArray(state.hands[mySeat]) ? state.hands[mySeat] : [];
- renderMyHand(myHand, state);
-
- // Render trick
- renderTrick(state);
-
- // Phase message
- renderPhaseMsg(state);
-}
-
-function renderSpectatorTable(state) {
- // For spectators, show all hands as backs, no interaction
- ['top', 'left', 'right'].forEach((pos, idx) => {
- const seat = [2, 3, 1][idx];
- $(`${pos}-name`).textContent = state.names[seat] || `P${seat+1}`;
- $(`${pos}-score`).textContent = state.scores[seat];
- $(`${pos}-cards`).innerHTML = ''; // no card backs
- if (state.currentTurn === seat && state.state === 'PLAYING') show(`${pos}-turn`);
- else hide(`${pos}-turn`);
- });
- $('my-name').textContent = state.names[0] || 'P1';
- $('my-score').textContent = state.scores[0];
- $('my-hand-pts').textContent = '';
- $('my-hand').innerHTML = '';
- renderTrick(state);
- renderPhaseMsg(state);
-}
-
-function renderOppCards(containerId, count, vertical) {
- const el = $(containerId);
- if (!el) return;
- el.innerHTML = '';
- const max = vertical ? 8 : 13;
- for (let i = 0; i < Math.min(count, max); i++) {
- const back = document.createElement('div');
- back.className = 'card-back';
- el.appendChild(back);
- }
-}
-
-function renderMyHand(hand, state) {
- const el = $('my-hand');
- el.innerHTML = '';
- applyHandMode();
- if (!hand.length) return;
-
- const isMyTurn = state.state === 'PLAYING' && state.currentTurn === mySeat;
- const legal = isMyTurn ? legalCards(hand, state) : [];
-
- // "Only Playables" mode: when it's my turn, render ONLY legal cards
- // (all cards shown when not my turn or during passing β player still needs to plan)
- const displayHand = (handMode === 'playables' && isMyTurn && state.state === 'PLAYING')
- ? legal
- : hand;
-
- displayHand.forEach(code => {
- const div = document.createElement('div');
- div.className = 'card';
- if (isQoS(code)) div.classList.add('card-qos');
- if (isHeart(code)) div.classList.add('card-heart');
-
- if (state.state === 'PASSING') {
- const selected = state.passSelected || [];
- if (selected.includes(code)) div.classList.add('selected');
- div.addEventListener('click', () => togglePassSelect(code, state));
- } else if (isMyTurn) {
- // In playables mode every shown card is legal β no illegal overlay needed
- const isLegal = legal.includes(code);
- if (!isLegal) {
- div.classList.add('illegal');
- } else {
- if (playMode === 'tap') {
- div.addEventListener('click', () => playCard(code));
- }
- addDragHandlers(div, code);
- }
- }
-
- div.appendChild(cardImg(code));
- el.appendChild(div);
- });
-
- // Show/hide play-mode toggle (touch devices only, during play)
- if (!spectating) {
- if (isTouchDevice() && state.state === 'PLAYING') {
- show('btn-play-mode');
- } else {
- hide('btn-play-mode');
- }
- updatePlayModeBtn();
- updateHandModeBtn();
- }
-
- // Fan / Playables: dynamic overlap spacing; Scroll: center visible window
- if (handMode === 'fan' || handMode === 'playables') {
- requestAnimationFrame(updateHandSpacing);
- } else {
- requestAnimationFrame(() => {
- const overflow = el.scrollWidth - el.clientWidth;
- if (overflow > 0) el.scrollLeft = overflow / 2;
+ document.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
+ document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
+ btn.classList.add('active');
+ $('tab-' + btn.dataset.tab)?.classList.add('active');
+ if (btn.dataset.tab === 'join') loadPublicRooms();
});
- }
-}
-
-function renderTrick(state) {
- const trick = state.trick || [];
- const seats = spectating
- ? { bottom: 0, right: 1, top: 2, left: 3 }
- : {
- bottom: mySeat,
- right: (mySeat + 1) % 4,
- top: (mySeat + 2) % 4,
- left: (mySeat + 3) % 4,
- };
-
- ['top', 'left', 'right', 'bottom'].forEach(pos => {
- const slot = $(`trick-${pos}`);
- if (!slot) return;
- slot.innerHTML = '';
- const seat = seats[pos];
- const entry = trick.find(t => t.player === seat);
- if (entry) {
- slot.appendChild(cardImg(entry.card));
- }
});
-}
-function renderPhaseMsg(state) {
- const el = $('phase-msg');
- if (!el) return;
-
- const isMyTurn = state.state === 'PLAYING' && state.currentTurn === mySeat && !spectating;
- if (isMyTurn !== wasMyTurn) {
- wasMyTurn = isMyTurn;
- handleTurnReminder(isMyTurn);
- }
-
- if (state.state === 'WAITING') {
- el.textContent = 'Waitingβ¦';
- } else if (state.state === 'PASSING') {
- const pending = state.passReady.filter(r => !r).length;
- el.textContent = pending > 0 ? `${pending} player${pending > 1 ? 's' : ''} passingβ¦` : 'Exchangingβ¦';
- } else if (state.state === 'PLAYING') {
- if (state.trick.length === 0) {
- const name = state.names[state.currentTurn] || `P${state.currentTurn + 1}`;
- el.textContent = state.currentTurn === mySeat ? 'Your lead' : `${name} leads`;
- } else {
- const name = state.names[state.currentTurn] || `P${state.currentTurn + 1}`;
- el.textContent = state.currentTurn === mySeat ? 'Your turn' : `${name}β¦`;
- }
- } else {
- el.textContent = '';
- }
-}
-
-// ββ Pass overlay ββββββββββββββββββββββββββββββββββββββββββββββ
-// Clubs β Diamonds β Spades β Hearts, one row per suit
-const PASS_SUIT_ORDER = ['C', 'D', 'S', 'H'];
-const PASS_SUIT_LABEL = { C: 'β£', D: 'β¦', S: 'β ', H: 'β₯' };
-
-function renderPassOverlay(state) {
- if (state.passDirection === 'hold') {
- hide('overlay-pass');
- return;
- }
-
- const myHand = Array.isArray(state.hands[mySeat]) ? state.hands[mySeat] : [];
- const selected = state.passSelected || [];
-
- $('pass-title').textContent = 'Pass 3 cards';
- $('pass-hint').textContent = PASS_DIR_LABEL[state.passDirection] || '';
-
- // Render hand grouped by suit, one row per suit
- const passHandEl = $('pass-hand');
- passHandEl.innerHTML = '';
- PASS_SUIT_ORDER.forEach(s => {
- const suitCards = myHand.filter(c => suitOf(c) === s);
- if (suitCards.length === 0) return;
-
- const row = document.createElement('div');
- row.className = 'pass-suit-row';
-
- const lbl = document.createElement('span');
- lbl.className = 'pass-suit-label suit-' + s.toLowerCase();
- lbl.textContent = PASS_SUIT_LABEL[s];
- row.appendChild(lbl);
-
- const cardsWrap = document.createElement('div');
- cardsWrap.className = 'pass-suit-cards';
- suitCards.forEach(code => {
- const div = document.createElement('div');
- div.className = 'card' + (selected.includes(code) ? ' selected' : '');
- div.appendChild(cardImg(code));
- div.addEventListener('click', () => togglePassSelect(code, state));
- cardsWrap.appendChild(div);
+ document.querySelectorAll('.score-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.score-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ selectedScore = +btn.dataset.score;
});
- row.appendChild(cardsWrap);
- passHandEl.appendChild(row);
});
- // Preview selected
- const preview = $('pass-selected-preview');
- preview.innerHTML = '';
- for (let i = 0; i < 3; i++) {
- if (selected[i]) {
- const div = document.createElement('div');
- div.className = 'card';
- div.appendChild(cardImg(selected[i]));
- preview.appendChild(div);
- } else {
- const ph = document.createElement('div');
- ph.className = 'pass-placeholder';
- preview.appendChild(ph);
- }
- }
-
- // Confirm button
- $('btn-confirm-pass').disabled = selected.length !== 3;
-
- // Show waiting status
- const readyCount = state.passReady.filter(Boolean).length;
- $('pass-waiting').textContent = readyCount > 0 ? `${readyCount}/4 readyβ¦` : '';
-
- // If already passed, show waiting message
- if (state.passReady[mySeat]) {
- $('btn-confirm-pass').disabled = true;
- $('pass-waiting').textContent = 'Waiting for othersβ¦';
- }
-
- show('overlay-pass');
-}
-
-let passSelectedLocal = [];
-
-function togglePassSelect(code, state) {
- if (state.passReady[mySeat]) return; // already passed
-
- const myHand = Array.isArray(state.hands[mySeat]) ? state.hands[mySeat] : [];
- const selected = [...(state.passSelected || [])];
- const idx = selected.indexOf(code);
-
- if (idx !== -1) {
- selected.splice(idx, 1);
- } else if (selected.length < 3) {
- selected.push(code);
- } else {
- return; // already 3 selected
- }
-
- // Optimistic update for immediate feedback; persist so server pushes don't wipe it
- state.passSelected = selected;
- passSelectedLocal = selected;
- renderPassOverlay(state);
- // Also update my-hand in game table
- renderMyHand(myHand, state);
-}
-
-function confirmPass() {
- const state = lastState;
- if (!state) return;
- const selected = state.passSelected || [];
- if (selected.length !== 3) return;
- passSelectedLocal = [];
- socket.emit('passCards', { roomId: myRoomId, seat: mySeat, token: myToken, cards: selected });
-}
-
-// ββ Play ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-function playCard(code) {
- socket.emit('play', { roomId: myRoomId, seat: mySeat, token: myToken, card: code });
-}
-
-// ββ Client-side legal card computation (mirrors server) βββββββ
-function legalCards(hand, state) {
- const trick = state.trick || [];
-
- // First card of first trick
- if (trick.length === 0 && state.tricksPlayed === 0) {
- return hand.includes('C-2') ? ['C-2'] : hand;
- }
-
- // Leading
- if (trick.length === 0) {
- const nonHearts = hand.filter(c => !isHeart(c));
- if (!state.heartsBroken && nonHearts.length > 0) return nonHearts;
- return hand;
- }
-
- // Following
- const leadSuit = suitOf(trick[0].card);
- const suitCards = hand.filter(c => suitOf(c) === leadSuit);
- if (suitCards.length > 0) return suitCards;
-
- // Can't follow β first trick avoids points if possible
- if (state.tricksPlayed === 0) {
- const safe = hand.filter(c => cardPoints(c) === 0);
- if (safe.length > 0) return safe;
- }
-
- return hand;
-}
-
-// ββ Hand over overlay βββββββββββββββββββββββββββββββββββββββββ
-function showHandOverlay(state) {
- const moon = state.moonShooter;
- if (moon !== -1) {
- $('hand-result-icon').textContent = 'π';
- $('hand-result-title').textContent = `${state.names[moon]} shot the moon!`;
- $('hand-result-detail').textContent = 'Everyone else gets 26 points.';
- } else {
- $('hand-result-icon').textContent = 'π';
- $('hand-result-title').textContent = 'Hand over';
- $('hand-result-detail').textContent = '';
- }
-
- const scoresEl = $('hand-result-scores');
- scoresEl.innerHTML = '';
- const deltas = state.handDeltas || [0,0,0,0];
- state.names.forEach((name, i) => {
- const row = document.createElement('div');
- row.className = 'result-row' + (moon === i ? ' moon' : '');
- const nameEl = document.createElement('span');
- nameEl.textContent = name || `P${i+1}`;
- const deltaEl = document.createElement('span');
- deltaEl.className = 'result-delta ' + (deltas[i] === 0 ? 'zero' : deltas[i] >= 13 ? 'red' : '');
- deltaEl.textContent = `+${deltas[i]} β ${state.scores[i]}`;
- row.appendChild(nameEl);
- row.appendChild(deltaEl);
- scoresEl.appendChild(row);
+ document.querySelectorAll('.visibility-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.visibility-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ selectedPublic = btn.dataset.public === 'true';
+ });
});
- show('overlay-hand');
-}
+ $('btn-create').addEventListener('click', createGame);
+ $('btn-join').addEventListener('click', joinGame);
+ $('btn-spectate').addEventListener('click', spectateGame);
+ $('btn-refresh-rooms').addEventListener('click', loadPublicRooms);
-// ββ Game over overlay βββββββββββββββββββββββββββββββββββββββββ
-function showGameOverOverlay(state) {
- const winners = Array.isArray(state.gameWinner) ? state.gameWinner : [state.gameWinner];
- const minScore = Math.min(...state.scores);
+ $('btn-show-auth').addEventListener('click', () => {
+ show('overlay-auth');
+ $('auth-login-user').focus();
+ });
+ $('btn-auth-close').addEventListener('click', () => hide('overlay-auth'));
+ $('btn-show-leaderboard').addEventListener('click', showLeaderboard);
+ $('btn-show-profile').addEventListener('click', () => showProfile(authUser));
+ $('btn-logout').addEventListener('click', doLogout);
- $('gameover-title').textContent = winners.length === 1
- ? `${state.names[winners[0]]} wins!`
- : `Tie game!`;
-
- const scoresEl = $('gameover-scores');
- scoresEl.innerHTML = '';
- // Sort by score ascending for display
- const order = state.names.map((n, i) => i).sort((a, b) => state.scores[a] - state.scores[b]);
- order.forEach(i => {
- const row = document.createElement('div');
- const isWin = winners.includes(i);
- row.className = 'go-row' + (isWin ? ' winner' : '');
- const nameEl = document.createElement('span');
- nameEl.className = 'go-name';
- nameEl.textContent = state.names[i] || `P${i+1}`;
- const scoreEl = document.createElement('span');
- scoreEl.className = 'go-score';
- scoreEl.textContent = state.scores[i];
- if (isWin) {
- const badge = document.createElement('span');
- badge.className = 'go-badge';
- badge.textContent = 'π';
- scoreEl.appendChild(badge);
- }
- row.appendChild(nameEl);
- row.appendChild(scoreEl);
- scoresEl.appendChild(row);
+ document.querySelectorAll('.auth-tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+ document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active'));
+ document.querySelectorAll('.auth-panel').forEach(p => p.classList.remove('active'));
+ tab.classList.add('active');
+ $('auth-panel-' + tab.dataset.authTab)?.classList.add('active');
+ });
});
- show('overlay-gameover');
-}
-
-// βββ Auth flow ββββββββββββββββββββββββββββββββββββββββββββββββ
-async function doLogin() {
- const username = $('auth-login-user').value.trim();
- const password = $('auth-login-pass').value;
- $('auth-login-error').textContent = '';
- if (!username || !password) { $('auth-login-error').textContent = 'Fill in all fields'; return; }
-
- const data = await apiFetch('/api/login', { method: 'POST', body: { username, password } });
- if (data.error) { $('auth-login-error').textContent = data.error; return; }
- setAuth(data.token, data.username);
- hide('overlay-auth');
- // Re-init socket with new token
- socket.disconnect();
- initSocket();
-}
-
-let regEmail = '';
-async function doRegister() {
- const username = $('auth-reg-user').value.trim();
- const email = $('auth-reg-email').value.trim();
- const password = $('auth-reg-pass').value;
- $('auth-reg-error').textContent = '';
-
- const body = { username, email, password };
- const cfWidget = window.turnstile;
- if (cfWidget) body.cfToken = cfWidget.getResponse?.() || '';
-
- const data = await apiFetch('/api/register/initiate', { method: 'POST', body });
- if (data.error) { $('auth-reg-error').textContent = data.error; return; }
-
- if (data.done) {
- setAuth(data.token, data.username);
- hide('overlay-auth');
- socket.disconnect();
- initSocket();
- return;
- }
-
- // Email verification step
- regEmail = data.email;
- $('reg-verify-hint').textContent = `A 6-digit code was sent to ${data.email}`;
- hide('reg-step-form');
- show('reg-step-verify');
-}
-
-async function doVerify() {
- const code = $('auth-reg-code').value.trim();
- $('auth-verify-error').textContent = '';
- const data = await apiFetch('/api/register/confirm', { method: 'POST', body: { email: regEmail, code } });
- if (data.error) { $('auth-verify-error').textContent = data.error; return; }
- setAuth(data.token, data.username);
- hide('overlay-auth');
- socket.disconnect();
- initSocket();
-}
-
-async function loadProfile() {
- if (!authUser) return;
- const data = await apiFetch(`/api/profile/${encodeURIComponent(authUser)}`);
- if (data.error) return;
- $('profile-username').textContent = data.username;
- $('stat-games-played').textContent = data.games_played || 0;
- $('stat-games-won').textContent = data.games_won || 0;
- $('stat-moon-shots').textContent = data.moon_shots || 0;
- $('stat-total-score').textContent = data.total_score || 0;
-
- // Admin panel
- if (data.username === (window._adminUser || '')) {
- show('admin-panel');
- loadAdminState();
- } else {
- hide('admin-panel');
- }
-}
-
-async function loadAdminState() {
- const data = await apiFetch('/api/config');
- if (data.signupsOpen !== undefined) {
- $('btn-toggle-signups').textContent = data.signupsOpen ? 'Open β' : 'Closed β';
- }
-}
-
-async function doChangePassword() {
- const cur = $('change-pass-current').value;
- const nw = $('change-pass-new').value;
- $('change-pass-msg').textContent = '';
- const data = await apiFetch('/api/change-password', { method: 'POST', body: { currentPassword: cur, newPassword: nw } });
- if (data.error) { $('change-pass-msg').textContent = data.error; return; }
- $('change-pass-msg').style.color = '#69f0ae';
- $('change-pass-msg').textContent = 'Password updated!';
-}
-
-async function loadLeaderboard() {
- const data = await apiFetch('/api/leaderboard');
- if (!Array.isArray(data)) return;
- const tbody = $('lb-body');
- tbody.innerHTML = '';
- data.forEach((row, idx) => {
- const tr = document.createElement('tr');
- if (idx < 3) tr.className = 'lb-top';
- const avg = row.score_per_game != null ? row.score_per_game.toFixed(1) : 'β';
- tr.innerHTML = `
-
Loadingβ¦
'; try { - const list = await apiFetch('/api/rooms'); + const list = await fetch('/api/rooms').then(r => r.json()); if (!Array.isArray(list) || list.length === 0) { listEl.innerHTML = 'No public rooms right now.
'; return; @@ -1131,9 +444,9 @@ async function loadPublicRooms() { row.innerHTML = `No hands played yet.
'; + return; + } + const totals = finalScores || rounds[rounds.length - 1].scores; + + let html = '| '; + for (let i = 0; i < 4; i++) html += ` | ${escHtml(names?.[i] || `P${i + 1}`)} | `; + html += '
|---|---|
| H${r.hand} | `; + for (let i = 0; i < 4; i++) { + const cls = r.moonShooter === i ? 'rb-moon' : deltas[i] === best ? 'rb-best' : ''; + html += `${r.moonShooter === i ? 'π ' : ''}+${deltas[i]} | `; + } + html += '
| Total | '; + for (let i = 0; i < 4; i++) html += `${totals[i]} | `; + html += '
Loadingβ¦
'; + try { + const games = await fetch(`/api/history/${encodeURIComponent(username)}`).then(r => r.json()); + if (!Array.isArray(games) || games.length === 0) { + listEl.innerHTML = 'No games played yet.
'; + return; + } + listEl.innerHTML = ''; + games.forEach(g => { + const row = document.createElement('div'); + row.className = 'history-item' + (g.won ? ' won' : ' lost'); + const date = new Date(g.endedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + const others = g.playerNames.filter((_, i) => i !== g.mySeat).join(', '); + row.innerHTML = ` +Failed to load history.
'; + } +} + +function showHistoryDetail(g) { + hide('history-list'); + show('history-detail'); + $('history-detail-title').textContent = + `${g.won ? 'π ' : ''}Game to ${g.winScore} β ${new Date(g.endedAt).toLocaleString()}`; + + const scoresEl = $('history-detail-scores'); + scoresEl.replaceChildren(); + g.playerNames + .map((n, i) => i) + .sort((a, b) => g.finalScores[a] - g.finalScores[b]) + .forEach(i => { + const row = document.createElement('div'); + row.className = 'gameover-row' + (g.winners.includes(i) ? ' winner' : ''); + row.innerHTML = `${escHtml(g.playerNames[i] || `P${i + 1}`)}${i === g.mySeat ? ' (you)' : ''}` + + `${g.finalScores[i]}`; + scoresEl.appendChild(row); + }); + + renderRoundBreakdown($('history-detail-breakdown'), g.rounds, g.playerNames, g.finalScores); +} + +// βββ Event wiring βββββββββββββββββββββββββββββββββββββββββββββ +function leaveAndReturnToLobby() { + socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); + clearSession(); + clearGameState(); + mySeat = -1; myRoomId = null; myToken = null; spectating = false; + swapSelectedSeat = -1; + showScreen('screen-lobby'); +} + +function wireGameEvents() { + // ββ Waiting room ββ + $('btn-leave-waiting').addEventListener('click', leaveAndReturnToLobby); + $('btn-waiting-menu').addEventListener('click', e => { + e.stopPropagation(); + $('waiting-menu-dropdown').classList.toggle('hidden'); + }); + $('btn-waiting-reload').addEventListener('click', () => { + if (myRoomId && mySeat >= 0 && myToken) { + socket?.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken }); + } + hide('waiting-menu-dropdown'); + }); + $('btn-waiting-exit').addEventListener('click', () => { + hide('waiting-menu-dropdown'); + leaveAndReturnToLobby(); + }); + $('btn-copy').addEventListener('click', async () => { + const code = $('display-room-code').textContent.trim(); + const btn = $('btn-copy'); + try { + if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(code); + else { + const ta = document.createElement('textarea'); + ta.value = code; + ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0'; + document.body.appendChild(ta); + ta.focus(); ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + btn.textContent = 'β'; btn.style.color = '#69f0ae'; + } catch { btn.textContent = 'β'; } + setTimeout(() => { btn.textContent = 'β§'; btn.style.color = ''; }, 1500); + }); + $('btn-fill-bots').addEventListener('click', () => socket?.emit('fillBots', { roomId: myRoomId })); + $('btn-start-game').addEventListener('click', () => socket?.emit('forceStart', { roomId: myRoomId })); + + // ββ Game menu ββ + $('btn-game-menu').addEventListener('click', e => { + e.stopPropagation(); + $('game-menu-dropdown').classList.toggle('hidden'); + }); + document.addEventListener('click', e => { + if (!e.target.closest('.game-menu-wrap')) { + hide('game-menu-dropdown', 'waiting-menu-dropdown'); + } + }); + $('btn-refresh-game').addEventListener('click', () => { + if (myRoomId && mySeat >= 0 && myToken) { + socket?.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken }); + } + hide('game-menu-dropdown'); + }); + $('btn-toggle-bar').addEventListener('click', () => { + barAtBottom = !barAtBottom; + saveBarBottom(barAtBottom); + applyBarBottom(); + hide('game-menu-dropdown'); + }); + $('btn-exit-game').addEventListener('click', () => { + hide('game-menu-dropdown'); + show('overlay-exit-confirm'); + }); + $('btn-leave-game').addEventListener('click', () => { + if (spectating) { spectating = false; clearGameState(); showScreen('screen-lobby'); return; } + show('overlay-exit-confirm'); + }); + // Leave buttons inside phase overlays β those cover the info bar, so players + // need their own way out while passing. + document.querySelectorAll('[data-overlay-leave]').forEach(btn => { + btn.addEventListener('click', () => show('overlay-exit-confirm')); + }); + $('btn-exit-confirm-yes').addEventListener('click', () => { + hide('overlay-exit-confirm'); + leaveAndReturnToLobby(); + }); + $('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm')); + + // ββ Cards: one delegated tap handler each, so reused nodes never stack listeners ββ + $('my-hand').addEventListener('click', e => { + const card = e.target.closest('.card'); + if (!card || !card.classList.contains('playable')) return; + if (playMode !== 'tap') return; + playCard(card.dataset.card); + }); + $('pass-hand').addEventListener('click', e => { + const card = e.target.closest('.card'); + if (!card || card.classList.contains('illegal')) return; + togglePassSelect(card.dataset.card); + }); + $('btn-confirm-pass').addEventListener('click', confirmPass); + + // ββ Display modes ββ + $('btn-play-mode').addEventListener('click', () => { + playMode = playMode === 'tap' ? 'drag' : 'tap'; + savePlayMode(playMode); + updatePlayModeBtn(); + }); + $('btn-hand-mode').addEventListener('click', () => { + handMode = HAND_MODES[(HAND_MODES.indexOf(handMode) + 1) % HAND_MODES.length]; + saveHandMode(handMode); + updateHandModeBtn(); + if (lastState) renderMyHand(lastState); + }); + + // ββ Post-game ββ + $('btn-new-game').addEventListener('click', () => { + hide('overlay-gameover'); + leaveAndReturnToLobby(); + }); + + // ββ Screens ββ + $('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby')); + $('btn-lb-back').addEventListener('click', () => showScreen('screen-lobby')); + $('btn-details-close').addEventListener('click', () => hide('overlay-player-details')); + $('btn-show-history').addEventListener('click', e => + showHistory(e.currentTarget.dataset.username || authUser)); + $('btn-history-back').addEventListener('click', () => showScreen('screen-profile')); + $('btn-history-detail-back').addEventListener('click', () => { + hide('history-detail'); show('history-list'); + }); + + // ββ Change password ββ + $('btn-show-change-pass').addEventListener('click', () => + $('change-pass-form').classList.toggle('hidden')); + $('btn-do-change-pass').addEventListener('click', async () => { + const cur = $('change-pass-current').value; + const nw = $('change-pass-new').value; + $('change-pass-msg').textContent = ''; + try { + const d = await apiFetch('/api/change-password', { + method: 'POST', body: { currentPassword: cur, newPassword: nw }, + }); + $('change-pass-msg').textContent = d.error ? d.error : 'β Password updated'; + $('change-pass-msg').style.color = d.error ? '#ff6b6b' : '#69f0ae'; + } catch { $('change-pass-msg').textContent = 'Network error'; } + }); + + // ββ AFK ββ + $('afk-vote-btn').addEventListener('click', () => { + if (afkBannerVoted) return; + afkBannerVoted = true; + socket?.emit('voteAITakeover', { roomId: myRoomId }); + const btn = $('afk-vote-btn'); + btn.disabled = true; btn.textContent = 'Voted β'; + }); + $('afk-dismiss-btn').addEventListener('click', () => hide('afk-banner')); + + // ββ Admin ββ + $('btn-toggle-signups').addEventListener('click', async () => { + const d = await apiFetch('/api/admin/toggle-signups', { method: 'POST', body: {} }); + if (d.signupsOpen !== undefined) { + const btn = $('btn-toggle-signups'); + btn.textContent = d.signupsOpen ? 'Open β' : 'Closed β'; + btn.classList.toggle('signup-open', d.signupsOpen); + } + }); + + // ββ PWA ββ + $('btn-install').addEventListener('click', () => deferredInstallPrompt?.prompt()); + $('pwa-banner-install').addEventListener('click', () => deferredInstallPrompt?.prompt()); + $('pwa-banner-dismiss').addEventListener('click', () => $('pwa-banner').classList.remove('show')); + + window.addEventListener('resize', () => { + if (handMode === 'fan' || handMode === 'playables') updateHandSpacing(); + }); +} + window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); deferredInstallPrompt = e; show('btn-install'); - // Show banner after 3s setTimeout(() => { const banner = $('pwa-banner'); banner.classList.remove('hidden'); @@ -1158,252 +1536,61 @@ window.addEventListener('beforeinstallprompt', e => { }, 3000); }); -// βββ Event listeners ββββββββββββββββββββββββββββββββββββββββββ -window.addEventListener('DOMContentLoaded', () => { - // Load config (turnstile, signups) - apiFetch('/api/config').then(cfg => { - window._turnstileSiteKey = cfg.turnstileSiteKey; - if (cfg.turnstileSiteKey) { - // Load Turnstile script +// βββ Version footer βββββββββββββββββββββββββββββββββββββββββββ +async function loadConfig() { + try { + const d = await fetch('/api/config').then(r => r.json()); + if (d.version) $('app-version').textContent = `v${d.version} Β· tap to update`; + if (d.turnstileSiteKey) { const s = document.createElement('script'); s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; - document.head.appendChild(s); s.onload = () => { if (window.turnstile) { - window.turnstile.render('#auth-captcha-wrap', { sitekey: cfg.turnstileSiteKey }); + window.turnstile.render('#auth-captcha-wrap', { sitekey: d.turnstileSiteKey }); show('auth-captcha-wrap'); } }; + document.head.appendChild(s); } - }); + } catch { /* ignore */ } +} - updateAuthBar(); - initSocket(); - - // Score limit buttons - document.querySelectorAll('.score-btn').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('.score-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - selectedScore = parseInt(btn.dataset.score); - }); - }); - - // Lobby tabs - document.querySelectorAll('.tab-btn').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); - document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); - btn.classList.add('active'); - $(`tab-${btn.dataset.tab}`)?.classList.add('active'); - if (btn.dataset.tab === 'join') loadPublicRooms(); - }); - }); - - // Visibility toggle (public/private) - document.querySelectorAll('.visibility-btn').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('.visibility-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - selectedPublic = btn.dataset.public === 'true'; - }); - }); - - // Public rooms refresh - $('btn-refresh-rooms').addEventListener('click', loadPublicRooms); - - // Create game - $('btn-create').addEventListener('click', () => { - const name = $('input-name').value.trim(); - if (!name) { $('lobby-error').textContent = 'Enter your name'; return; } - $('lobby-error').textContent = ''; - myName = name; - socket.emit('create', { name, winScore: selectedScore, isPublic: selectedPublic }); - }); - - // Join game - $('btn-join').addEventListener('click', joinGame); - - // Spectate - $('btn-spectate').addEventListener('click', () => { - const roomId = $('input-spectate-code').value.trim().toUpperCase(); - if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; } - spectating = true; - socket.emit('spectate', { roomId }); - }); - - // Leave waiting room - $('btn-leave-waiting').addEventListener('click', () => { - clearSession(); - socket.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); - myRoomId = null; mySeat = -1; myToken = null; - showScreen('screen-lobby'); - }); - - // Fill bots - $('btn-fill-bots').addEventListener('click', () => { - socket.emit('fillBots', { roomId: myRoomId }); - }); - - // Copy room code - $('btn-copy').addEventListener('click', () => { - navigator.clipboard?.writeText($('display-room-code').textContent); - }); - - // Pass overlay confirm - $('btn-confirm-pass').addEventListener('click', confirmPass); - - // AFK banner - $('afk-vote-btn').addEventListener('click', () => { - socket.emit('voteAITakeover', { roomId: myRoomId }); - hide('afk-banner'); - }); - $('afk-dismiss-btn').addEventListener('click', () => hide('afk-banner')); - - // Leave game - $('btn-leave-game').addEventListener('click', () => { - if (spectating) { - spectating = false; - clearSession(); - showScreen('screen-lobby'); - return; +// Tapping the version drops the service worker and every cache, so a phone +// stuck on stale assets can pull the latest build. +async function forceUpdate() { + const el = $('app-version'); + if (el) el.textContent = 'Updatingβ¦'; + try { + if ('serviceWorker' in navigator) { + const regs = await navigator.serviceWorker.getRegistrations(); + await Promise.all(regs.map(r => r.unregister())); } - show('overlay-exit-confirm'); - hide('game-menu-dropdown'); - }); + if (window.caches) { + const keys = await caches.keys(); + await Promise.all(keys.map(k => caches.delete(k))); + } + } catch { /* ignore */ } + location.reload(); +} - $('btn-exit-confirm-yes').addEventListener('click', () => { - hide('overlay-exit-confirm'); - clearSession(); - socket.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); - myRoomId = null; mySeat = -1; myToken = null; spectating = false; - wasMyTurn = false; handleTurnReminder(false); - aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner'); - showScreen('screen-lobby'); - }); - $('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm')); - - // New game (after game over) - $('btn-new-game').addEventListener('click', () => { - hide('overlay-gameover'); - clearSession(); - myRoomId = null; mySeat = -1; myToken = null; spectating = false; - wasMyTurn = false; handleTurnReminder(false); - aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner'); - showScreen('screen-lobby'); - }); - - // Game menu - // Play mode toggle (tap β drag) β touch only - $('btn-play-mode').addEventListener('click', () => { - playMode = playMode === 'drag' ? 'tap' : 'drag'; - savePlayMode(playMode); - updatePlayModeBtn(); - if (lastState) renderMyHand(Array.isArray(lastState.hands[mySeat]) ? lastState.hands[mySeat] : [], lastState); - }); - - // Hand display mode β cycles: scroll β fan β playables β scroll - $('btn-hand-mode').addEventListener('click', () => { - const idx = HAND_MODES.indexOf(handMode); - handMode = HAND_MODES[(idx + 1) % HAND_MODES.length]; - saveHandMode(handMode); - applyHandMode(); - updateHandModeBtn(); - if (lastState) renderMyHand(Array.isArray(lastState.hands[mySeat]) ? lastState.hands[mySeat] : [], lastState); - }); - - // Initialize mode buttons with saved preferences +// βββ Boot βββββββββββββββββββββββββββββββββββββββββββββββββββββ +document.addEventListener('DOMContentLoaded', async () => { + preloadCards(); // decode every face once, up front + initLobby(); + wireGameEvents(); + applyBarBottom(); updatePlayModeBtn(); updateHandModeBtn(); applyHandMode(); if (!isTouchDevice()) hide('btn-play-mode'); + loadConfig(); + $('app-version').addEventListener('click', forceUpdate); - $('btn-game-menu').addEventListener('click', (e) => { - e.stopPropagation(); - $('game-menu-dropdown').classList.toggle('hidden'); - }); - document.addEventListener('click', () => { hide('game-menu-dropdown'); }); + await validateAuth(); + if (authUser) $('input-name').value = authUser; - $('btn-info-pos').addEventListener('click', () => { - $('screen-game').classList.toggle('info-bottom'); - hide('game-menu-dropdown'); - }); - - $('btn-refresh-game').addEventListener('click', () => location.reload()); - - $('btn-exit-game').addEventListener('click', () => { - show('overlay-exit-confirm'); - hide('game-menu-dropdown'); - }); - - // Auth modal - $('btn-show-auth').addEventListener('click', () => show('overlay-auth')); - $('btn-auth-close').addEventListener('click', () => hide('overlay-auth')); - - document.querySelectorAll('.auth-tab').forEach(tab => { - tab.addEventListener('click', () => { - document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active')); - document.querySelectorAll('.auth-panel').forEach(p => p.classList.remove('active')); - tab.classList.add('active'); - $(`auth-panel-${tab.dataset.authTab}`)?.classList.add('active'); - }); - }); - - $('btn-do-login').addEventListener('click', doLogin); - $('auth-login-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); }); - $('btn-do-register').addEventListener('click', doRegister); - $('btn-do-verify').addEventListener('click', doVerify); - $('btn-reg-back').addEventListener('click', () => { - hide('reg-step-verify'); - show('reg-step-form'); - $('auth-verify-error').textContent = ''; - }); - - // Profile - $('btn-show-profile').addEventListener('click', () => { - loadProfile(); - showScreen('screen-profile'); - }); - $('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby')); - $('btn-logout').addEventListener('click', () => { - setAuth(null, null); - socket.disconnect(); - initSocket(); - }); - - $('btn-show-change-pass').addEventListener('click', () => { - $('change-pass-form').classList.toggle('hidden'); - }); - $('btn-do-change-pass').addEventListener('click', doChangePassword); - - $('btn-toggle-signups').addEventListener('click', async () => { - const data = await apiFetch('/api/admin/toggle-signups', { method: 'POST', body: {} }); - if (data.signupsOpen !== undefined) { - $('btn-toggle-signups').textContent = data.signupsOpen ? 'Open β' : 'Closed β'; - } - }); - - // Leaderboard - $('btn-show-leaderboard').addEventListener('click', () => { - loadLeaderboard(); - showScreen('screen-leaderboard'); - }); - $('btn-lb-back').addEventListener('click', () => showScreen('screen-lobby')); - $('btn-details-close').addEventListener('click', () => hide('overlay-player-details')); - - // PWA - $('btn-install').addEventListener('click', () => { - deferredInstallPrompt?.prompt(); - }); - $('pwa-banner-install').addEventListener('click', () => { - deferredInstallPrompt?.prompt(); - }); - $('pwa-banner-dismiss').addEventListener('click', () => { - $('pwa-banner').classList.remove('show'); - }); - - // Recalculate fan spacing on resize / orientation change - window.addEventListener('resize', () => { - if (handMode === 'fan' || handMode === 'playables') updateHandSpacing(); - }); + tryRejoin(); + // Authenticated with no local session: connect anyway so the server can push + // hasActiveGame for a cross-device rejoin + if (authToken && !myRoomId) connectSocket(() => {}); }); diff --git a/public/index.html b/public/index.html index a3dfb11..79a96c3 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@Switch to this tab to load rooms.
Loadingβ¦
Share this code β 4 players needed
+Every player for themselves β lowest score wins
Waiting for 3 more playersβ¦
- + +Hokm accounts work here too
+Hokm and Shelem accounts work here too
@@ -307,39 +330,50 @@ - +| # | -Player | -Avg β₯ | -W | -Played | -π | -+ | # | Player | Win % | W | P | Avg | π |
|---|