Compare commits
2 Commits
029302b9e9
...
e499e89027
| Author | SHA1 | Date | |
|---|---|---|---|
| e499e89027 | |||
| f78fbe2dfa |
@@ -1,3 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
data/
|
data/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
push.sh
|
||||||
@@ -10,7 +10,7 @@ A multiplayer Hearts card game with real-time gameplay via WebSockets. Shares ac
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@git.goyban.com:goyban/hearts.git
|
git clone https://git.goyban.com/goyban/hearts.git
|
||||||
cd hearts
|
cd hearts
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# edit .env — see below
|
# edit .env — see below
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
hearts:
|
hearts:
|
||||||
|
container_name: hearts
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
hearts:
|
||||||
|
container_name: hearts
|
||||||
|
image: git.goyban.com/goyban/hearts:latest
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
- "4443:4443"
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
# Hokm's data dir (read-only) so Hearts can verify Hokm accounts
|
||||||
|
- /root/hokm/data:/hokm-data:ro
|
||||||
|
# Share Hokm's card SVGs — same card assets, no duplication
|
||||||
|
- /root/hokm/public/cards:/app/public/cards:ro
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
+186
-12
@@ -11,7 +11,17 @@ let authToken = localStorage.getItem('hearts_token') || null;
|
|||||||
let authUser = localStorage.getItem('hearts_user') || null;
|
let authUser = localStorage.getItem('hearts_user') || null;
|
||||||
let lastState = null;
|
let lastState = null;
|
||||||
let deferredInstallPrompt = null;
|
let deferredInstallPrompt = null;
|
||||||
let selectedScore = 100;
|
|
||||||
|
// ─── Turn escalation state ────────────────────────────────────
|
||||||
|
let turnEscalationInterval = null;
|
||||||
|
let turnEscalationLevel = 0;
|
||||||
|
let wasMyTurn = false;
|
||||||
|
|
||||||
|
// ─── AFK / AI-takeover state ──────────────────────────────────
|
||||||
|
let aiControlledSeats = new Set();
|
||||||
|
let afkWarnSeat = -1;
|
||||||
|
let selectedScore = 100;
|
||||||
|
let selectedPublic = false;
|
||||||
|
|
||||||
// ─── Play mode & hand display mode ───────────────────────────
|
// ─── Play mode & hand display mode ───────────────────────────
|
||||||
function isTouchDevice() {
|
function isTouchDevice() {
|
||||||
@@ -160,6 +170,55 @@ const RANK_FILE = {
|
|||||||
'J': '11-JACK', 'Q': '12-QUEEN', 'K': '13-KING',
|
'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 ──────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────
|
||||||
function $(id) { return document.getElementById(id); }
|
function $(id) { return document.getElementById(id); }
|
||||||
function show(...ids) { ids.forEach(id => $(id)?.classList.remove('hidden')); }
|
function show(...ids) { ids.forEach(id => $(id)?.classList.remove('hidden')); }
|
||||||
@@ -295,6 +354,14 @@ function initSocket() {
|
|||||||
socket.on('rejoinError', (msg) => {
|
socket.on('rejoinError', (msg) => {
|
||||||
clearSession();
|
clearSession();
|
||||||
$('lobby-error').textContent = msg || 'Could not rejoin';
|
$('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
|
// Core game events — all handled by renderState
|
||||||
@@ -308,11 +375,44 @@ function initSocket() {
|
|||||||
socket.on('joinError', msg => { $('lobby-error').textContent = msg; });
|
socket.on('joinError', msg => { $('lobby-error').textContent = msg; });
|
||||||
socket.on('passError', msg => console.warn('passError:', msg));
|
socket.on('passError', msg => console.warn('passError:', msg));
|
||||||
socket.on('playError', msg => console.warn('playError:', 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 ──────────────────────────────────────────
|
// ─── State rendering ──────────────────────────────────────────
|
||||||
|
function syncAiState(state) {
|
||||||
|
aiControlledSeats = new Set(state.aiControlledSeats || []);
|
||||||
|
updateAiControlBanner();
|
||||||
|
}
|
||||||
|
|
||||||
function handleState(state) {
|
function handleState(state) {
|
||||||
lastState = 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
|
// Always dismiss result overlays when fresh state arrives
|
||||||
hide('overlay-hand', 'overlay-gameover');
|
hide('overlay-hand', 'overlay-gameover');
|
||||||
@@ -335,6 +435,7 @@ function handleState(state) {
|
|||||||
|
|
||||||
function handleTrickWon(state) {
|
function handleTrickWon(state) {
|
||||||
lastState = state;
|
lastState = state;
|
||||||
|
syncAiState(state);
|
||||||
renderInfoBar(state);
|
renderInfoBar(state);
|
||||||
renderTable(state);
|
renderTable(state);
|
||||||
// Flash the winning slot briefly
|
// Flash the winning slot briefly
|
||||||
@@ -346,6 +447,8 @@ function handleTrickWon(state) {
|
|||||||
|
|
||||||
function handleHandOver(state) {
|
function handleHandOver(state) {
|
||||||
lastState = state;
|
lastState = state;
|
||||||
|
syncAiState(state);
|
||||||
|
hide('afk-banner');
|
||||||
renderInfoBar(state);
|
renderInfoBar(state);
|
||||||
renderTable(state);
|
renderTable(state);
|
||||||
hide('overlay-pass');
|
hide('overlay-pass');
|
||||||
@@ -354,6 +457,8 @@ function handleHandOver(state) {
|
|||||||
|
|
||||||
function handleGameOver(state) {
|
function handleGameOver(state) {
|
||||||
lastState = state;
|
lastState = state;
|
||||||
|
syncAiState(state);
|
||||||
|
hide('afk-banner');
|
||||||
renderInfoBar(state);
|
renderInfoBar(state);
|
||||||
renderTable(state);
|
renderTable(state);
|
||||||
hide('overlay-pass', 'overlay-hand');
|
hide('overlay-pass', 'overlay-hand');
|
||||||
@@ -440,6 +545,9 @@ function renderWaitingRoom(state) {
|
|||||||
} else {
|
} else {
|
||||||
hide('btn-fill-bots');
|
hide('btn-fill-bots');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const badge = $('waiting-public-badge');
|
||||||
|
if (badge) badge.textContent = state.isPublic ? '🌐 Public' : '🔒 Private';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Game table ────────────────────────────────────────────────
|
// ── Game table ────────────────────────────────────────────────
|
||||||
@@ -616,6 +724,12 @@ function renderPhaseMsg(state) {
|
|||||||
const el = $('phase-msg');
|
const el = $('phase-msg');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
|
const isMyTurn = state.state === 'PLAYING' && state.currentTurn === mySeat && !spectating;
|
||||||
|
if (isMyTurn !== wasMyTurn) {
|
||||||
|
wasMyTurn = isMyTurn;
|
||||||
|
handleTurnReminder(isMyTurn);
|
||||||
|
}
|
||||||
|
|
||||||
if (state.state === 'WAITING') {
|
if (state.state === 'WAITING') {
|
||||||
el.textContent = 'Waiting…';
|
el.textContent = 'Waiting…';
|
||||||
} else if (state.state === 'PASSING') {
|
} else if (state.state === 'PASSING') {
|
||||||
@@ -728,8 +842,9 @@ function togglePassSelect(code, state) {
|
|||||||
return; // already 3 selected
|
return; // already 3 selected
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimistic update for immediate feedback
|
// Optimistic update for immediate feedback; persist so server pushes don't wipe it
|
||||||
state.passSelected = selected;
|
state.passSelected = selected;
|
||||||
|
passSelectedLocal = selected;
|
||||||
renderPassOverlay(state);
|
renderPassOverlay(state);
|
||||||
// Also update my-hand in game table
|
// Also update my-hand in game table
|
||||||
renderMyHand(myHand, state);
|
renderMyHand(myHand, state);
|
||||||
@@ -740,6 +855,7 @@ function confirmPass() {
|
|||||||
if (!state) return;
|
if (!state) return;
|
||||||
const selected = state.passSelected || [];
|
const selected = state.passSelected || [];
|
||||||
if (selected.length !== 3) return;
|
if (selected.length !== 3) return;
|
||||||
|
passSelectedLocal = [];
|
||||||
socket.emit('passCards', { roomId: myRoomId, seat: mySeat, token: myToken, cards: selected });
|
socket.emit('passCards', { roomId: myRoomId, seat: mySeat, token: myToken, cards: selected });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,6 +1103,48 @@ function escHtml(s) {
|
|||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Lobby helpers ────────────────────────────────────────────
|
||||||
|
function joinGame() {
|
||||||
|
const name = $('input-name').value.trim();
|
||||||
|
const roomId = $('input-code').value.trim().toUpperCase();
|
||||||
|
if (!name) { $('lobby-error').textContent = 'Enter your name'; return; }
|
||||||
|
if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; }
|
||||||
|
$('lobby-error').textContent = '';
|
||||||
|
myName = name;
|
||||||
|
socket.emit('join', { name, roomId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPublicRooms() {
|
||||||
|
const listEl = $('public-rooms-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = '<p class="hint">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const list = await apiFetch('/api/rooms');
|
||||||
|
if (!Array.isArray(list) || list.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="hint">No public rooms right now.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
list.forEach(room => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'public-room-item';
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="public-room-info">
|
||||||
|
<span class="public-room-host">${escHtml(room.hostName)}'s room</span>
|
||||||
|
<span class="public-room-meta">${room.playerCount}/4 · score limit ${room.winScore}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn-join-pub" data-room="${escHtml(room.id)}">Join</button>`;
|
||||||
|
row.querySelector('.btn-join-pub').addEventListener('click', () => {
|
||||||
|
$('input-code').value = room.id;
|
||||||
|
joinGame();
|
||||||
|
});
|
||||||
|
listEl.appendChild(row);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
listEl.innerHTML = '<p class="hint" style="color:#ff6b6b">Failed to load rooms.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── PWA install ──────────────────────────────────────────────
|
// ─── PWA install ──────────────────────────────────────────────
|
||||||
window.addEventListener('beforeinstallprompt', e => {
|
window.addEventListener('beforeinstallprompt', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1038,28 +1196,33 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
$(`tab-${btn.dataset.tab}`)?.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
|
// Create game
|
||||||
$('btn-create').addEventListener('click', () => {
|
$('btn-create').addEventListener('click', () => {
|
||||||
const name = $('input-name').value.trim();
|
const name = $('input-name').value.trim();
|
||||||
if (!name) { $('lobby-error').textContent = 'Enter your name'; return; }
|
if (!name) { $('lobby-error').textContent = 'Enter your name'; return; }
|
||||||
$('lobby-error').textContent = '';
|
$('lobby-error').textContent = '';
|
||||||
myName = name;
|
myName = name;
|
||||||
socket.emit('create', { name, winScore: selectedScore });
|
socket.emit('create', { name, winScore: selectedScore, isPublic: selectedPublic });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Join game
|
// Join game
|
||||||
$('btn-join').addEventListener('click', () => {
|
$('btn-join').addEventListener('click', joinGame);
|
||||||
const name = $('input-name').value.trim();
|
|
||||||
const roomId = $('input-code').value.trim().toUpperCase();
|
|
||||||
if (!name) { $('lobby-error').textContent = 'Enter your name'; return; }
|
|
||||||
if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; }
|
|
||||||
$('lobby-error').textContent = '';
|
|
||||||
myName = name;
|
|
||||||
socket.emit('join', { name, roomId });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spectate
|
// Spectate
|
||||||
$('btn-spectate').addEventListener('click', () => {
|
$('btn-spectate').addEventListener('click', () => {
|
||||||
@@ -1090,6 +1253,13 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Pass overlay confirm
|
// Pass overlay confirm
|
||||||
$('btn-confirm-pass').addEventListener('click', confirmPass);
|
$('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
|
// Leave game
|
||||||
$('btn-leave-game').addEventListener('click', () => {
|
$('btn-leave-game').addEventListener('click', () => {
|
||||||
if (spectating) {
|
if (spectating) {
|
||||||
@@ -1107,6 +1277,8 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||||||
clearSession();
|
clearSession();
|
||||||
socket.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
socket.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
||||||
myRoomId = null; mySeat = -1; myToken = null; spectating = false;
|
myRoomId = null; mySeat = -1; myToken = null; spectating = false;
|
||||||
|
wasMyTurn = false; handleTurnReminder(false);
|
||||||
|
aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner');
|
||||||
showScreen('screen-lobby');
|
showScreen('screen-lobby');
|
||||||
});
|
});
|
||||||
$('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm'));
|
$('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm'));
|
||||||
@@ -1116,6 +1288,8 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||||||
hide('overlay-gameover');
|
hide('overlay-gameover');
|
||||||
clearSession();
|
clearSession();
|
||||||
myRoomId = null; mySeat = -1; myToken = null; spectating = false;
|
myRoomId = null; mySeat = -1; myToken = null; spectating = false;
|
||||||
|
wasMyTurn = false; handleTurnReminder(false);
|
||||||
|
aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner');
|
||||||
showScreen('screen-lobby');
|
showScreen('screen-lobby');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+29
-2
@@ -51,15 +51,29 @@
|
|||||||
<button class="score-btn" data-score="200">200</button>
|
<button class="score-btn" data-score="200">200</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Visibility</label>
|
||||||
|
<div class="visibility-toggle">
|
||||||
|
<button class="visibility-btn active" data-public="false">🔒 Private</button>
|
||||||
|
<button class="visibility-btn" data-public="true">🌐 Public</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button id="btn-create" class="btn-primary">Create Game</button>
|
<button id="btn-create" class="btn-primary">Create Game</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-join" class="tab-panel">
|
<div id="tab-join" class="tab-panel">
|
||||||
|
<div class="public-rooms-section">
|
||||||
|
<div class="rooms-header">
|
||||||
|
<span>Public Rooms</span>
|
||||||
|
<button id="btn-refresh-rooms" class="btn-refresh">↺ Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="public-rooms-list"><p class="hint">Switch to this tab to load rooms.</p></div>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Room Code</label>
|
<label>Or enter a Room Code</label>
|
||||||
<input id="input-code" type="text" maxlength="8" placeholder="e.g. AB12CD" autocomplete="off" style="text-transform:uppercase">
|
<input id="input-code" type="text" maxlength="8" placeholder="e.g. AB12CD" autocomplete="off" style="text-transform:uppercase">
|
||||||
</div>
|
</div>
|
||||||
<button id="btn-join" class="btn-primary">Join Game</button>
|
<button id="btn-join" class="btn-primary">Join by Code</button>
|
||||||
<hr style="border-color:rgba(255,255,255,.1);margin:4px 0">
|
<hr style="border-color:rgba(255,255,255,.1);margin:4px 0">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Watch as spectator</label>
|
<label>Watch as spectator</label>
|
||||||
@@ -77,6 +91,7 @@
|
|||||||
<div class="waiting-box">
|
<div class="waiting-box">
|
||||||
<button id="btn-leave-waiting" class="btn-leave-screen">← Leave</button>
|
<button id="btn-leave-waiting" class="btn-leave-screen">← Leave</button>
|
||||||
<h2>Waiting for Players</h2>
|
<h2>Waiting for Players</h2>
|
||||||
|
<div id="waiting-public-badge" class="waiting-public-badge">🔒 Private</div>
|
||||||
<div class="room-code-box">
|
<div class="room-code-box">
|
||||||
<span class="label">Room Code</span>
|
<span class="label">Room Code</span>
|
||||||
<span id="display-room-code" class="room-code">——</span>
|
<span id="display-room-code" class="room-code">——</span>
|
||||||
@@ -126,6 +141,18 @@
|
|||||||
<!-- Spectator banner -->
|
<!-- Spectator banner -->
|
||||||
<div id="spectator-banner" class="spectator-banner hidden">👁 Spectating — watching only</div>
|
<div id="spectator-banner" class="spectator-banner hidden">👁 Spectating — watching only</div>
|
||||||
|
|
||||||
|
<!-- AFK warning banner -->
|
||||||
|
<div id="afk-banner" class="afk-banner hidden">
|
||||||
|
<span id="afk-banner-msg"></span>
|
||||||
|
<button id="afk-vote-btn">Let AI Play</button>
|
||||||
|
<button id="afk-dismiss-btn">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI control banner -->
|
||||||
|
<div id="ai-control-banner" class="ai-control-banner hidden">
|
||||||
|
<span id="ai-control-msg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Table grid -->
|
<!-- Table grid -->
|
||||||
<div id="table-grid">
|
<div id="table-grid">
|
||||||
|
|
||||||
|
|||||||
@@ -1019,3 +1019,165 @@ html, body {
|
|||||||
:root { --card-w: 52px; --card-h: 74px; }
|
:root { --card-w: 52px; --card-h: 74px; }
|
||||||
#my-area { padding-bottom: 4px; }
|
#my-area { padding-bottom: 4px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Visibility toggle (Create tab) ──────────── */
|
||||||
|
.visibility-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.visibility-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255,255,255,.2);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255,255,255,.6);
|
||||||
|
font-size: .9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .2s;
|
||||||
|
}
|
||||||
|
.visibility-btn.active {
|
||||||
|
background: rgba(100,180,255,.2);
|
||||||
|
border-color: rgba(100,180,255,.5);
|
||||||
|
color: rgba(200,230,255,.95);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Public rooms list (Join tab) ─────────────── */
|
||||||
|
.public-rooms-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.rooms-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: rgba(255,255,255,.55);
|
||||||
|
}
|
||||||
|
.btn-refresh {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(255,255,255,.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255,255,255,.6);
|
||||||
|
font-size: .75rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.btn-refresh:hover { background: rgba(255,255,255,.1); color: #fff; }
|
||||||
|
#public-rooms-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.public-room-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255,255,255,.07);
|
||||||
|
border: 1px solid rgba(255,255,255,.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.public-room-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.public-room-host {
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.public-room-meta { font-size: .72rem; color: rgba(255,255,255,.5); }
|
||||||
|
.btn-join-pub {
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: var(--gold);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #000;
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.btn-join-pub:hover { opacity: .85; }
|
||||||
|
|
||||||
|
/* ── Waiting room public badge ─────────────────── */
|
||||||
|
.waiting-public-badge {
|
||||||
|
font-size: .78rem;
|
||||||
|
color: rgba(255,255,255,.65);
|
||||||
|
background: rgba(255,255,255,.09);
|
||||||
|
border: 1px solid rgba(255,255,255,.15);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Your-turn escalation ─────────────────────── */
|
||||||
|
@keyframes your-turn-flash {
|
||||||
|
0%,100% { opacity:1; } 50% { opacity:.55; }
|
||||||
|
}
|
||||||
|
.phase-msg.your-turn-lvl1 {
|
||||||
|
font-size: .96rem; color: #ffc400;
|
||||||
|
animation: your-turn-flash .6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.phase-msg.your-turn-lvl2 {
|
||||||
|
font-size: 1.18rem; color: #ffeb3b;
|
||||||
|
text-shadow: 0 0 10px #ffeb3b88;
|
||||||
|
animation: your-turn-flash .4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.phase-msg.your-turn-lvl3 {
|
||||||
|
font-size: 1.4rem; color: #fff;
|
||||||
|
text-shadow: 0 0 22px #ffffffcc, 0 0 8px #ffcc00cc;
|
||||||
|
animation: your-turn-flash .25s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── AFK warning banner ───────────────────────── */
|
||||||
|
.afk-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
background: rgba(180,60,30,.88);
|
||||||
|
border-bottom: 1px solid rgba(255,120,60,.35);
|
||||||
|
font-size: .82rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.afk-banner span { flex: 1; color: rgba(255,255,255,.9); }
|
||||||
|
.afk-banner button {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255,255,255,.35);
|
||||||
|
background: rgba(255,255,255,.18);
|
||||||
|
color: #fff;
|
||||||
|
font-size: .76rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.afk-banner button:hover { background: rgba(255,255,255,.3); }
|
||||||
|
|
||||||
|
/* ── AI control banner ────────────────────────── */
|
||||||
|
.ai-control-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 5px 14px;
|
||||||
|
font-size: .8rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: rgba(255,255,255,.9);
|
||||||
|
}
|
||||||
|
.ai-control-banner.ai-self { background: rgba(190,40,90,.75); border-bottom: 1px solid rgba(255,80,140,.25); }
|
||||||
|
.ai-control-banner.ai-other { background: rgba(70,45,160,.75); border-bottom: 1px solid rgba(120,90,220,.25); }
|
||||||
|
|||||||
@@ -348,6 +348,20 @@ app.get('/api/leaderboard', (_req, res) => {
|
|||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/rooms', (_req, res) => {
|
||||||
|
const list = [];
|
||||||
|
for (const [, room] of rooms) {
|
||||||
|
if (!room.isPublic || room.state !== 'WAITING') continue;
|
||||||
|
list.push({
|
||||||
|
id: room.id,
|
||||||
|
playerCount: room.seats.filter(Boolean).length + room.bots.filter(Boolean).length,
|
||||||
|
winScore: room.winScore,
|
||||||
|
hostName: room.names[0] || '?',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json(list);
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/admin/toggle-signups', requireAuth, (req, res) => {
|
app.post('/api/admin/toggle-signups', requireAuth, (req, res) => {
|
||||||
if (!isAdmin(req.user)) return res.status(403).json({ error: 'Forbidden' });
|
if (!isAdmin(req.user)) return res.status(403).json({ error: 'Forbidden' });
|
||||||
config.signupsOpen = !config.signupsOpen;
|
config.signupsOpen = !config.signupsOpen;
|
||||||
@@ -415,8 +429,14 @@ function newRoom(id) {
|
|||||||
moonShooter: -1,
|
moonShooter: -1,
|
||||||
handDeltas: null,
|
handDeltas: null,
|
||||||
winScore: 100,
|
winScore: 100,
|
||||||
spectators: new Set(),
|
spectators: new Set(),
|
||||||
trickTimer: null,
|
isPublic: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
trickTimer: null,
|
||||||
|
afkTimer: null,
|
||||||
|
afkSeat: -1,
|
||||||
|
afkVotes: new Set(),
|
||||||
|
aiControlledSeats: new Set(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +472,9 @@ function publicInfo(room, seat = -1) {
|
|||||||
moonShooter: room.moonShooter,
|
moonShooter: room.moonShooter,
|
||||||
handDeltas: room.handDeltas,
|
handDeltas: room.handDeltas,
|
||||||
gameWinner: room.gameWinner,
|
gameWinner: room.gameWinner,
|
||||||
spectatorCount: room.spectators.size,
|
spectatorCount: room.spectators.size,
|
||||||
|
isPublic: room.isPublic,
|
||||||
|
aiControlledSeats: [...room.aiControlledSeats],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +490,46 @@ function broadcastState(room, event = 'roomInfo') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── AFK / AI-takeover helpers ────────────────────────────────
|
||||||
|
function broadcast(room, event, data) {
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
if (room.seats[i]) io.to(room.seats[i]).emit(event, data);
|
||||||
|
}
|
||||||
|
for (const sid of room.spectators) io.to(sid).emit(event, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAfkTimer(room) {
|
||||||
|
if (room.afkTimer) { clearTimeout(room.afkTimer); room.afkTimer = null; }
|
||||||
|
if (room.afkSeat >= 0) {
|
||||||
|
broadcast(room, 'afkResolved', {});
|
||||||
|
room.afkSeat = -1;
|
||||||
|
room.afkVotes.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastAiControl(room, seat, active) {
|
||||||
|
broadcast(room, 'aiControl', { seat, active, name: room.names[seat] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAfkTimer(room) {
|
||||||
|
clearAfkTimer(room);
|
||||||
|
const seat = room.currentTurn;
|
||||||
|
if (room.bots[seat]) return;
|
||||||
|
if (room.aiControlledSeats.has(seat)) {
|
||||||
|
room.afkTimer = setTimeout(() => {
|
||||||
|
if (room.state !== 'PLAYING' || room.currentTurn !== seat) return;
|
||||||
|
const card = botChooseCard(room, seat);
|
||||||
|
if (card) onCardPlayed(room, seat, card);
|
||||||
|
}, 10000);
|
||||||
|
} else {
|
||||||
|
room.afkSeat = seat;
|
||||||
|
room.afkTimer = setTimeout(() => {
|
||||||
|
if (room.state !== 'PLAYING') return;
|
||||||
|
broadcast(room, 'afkWarning', { seat, name: room.names[seat] });
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Dealing ──────────────────────────────────────────────────
|
// ─── Dealing ──────────────────────────────────────────────────
|
||||||
function dealHand(room) {
|
function dealHand(room) {
|
||||||
const deck = shuffle(makeDeck());
|
const deck = shuffle(makeDeck());
|
||||||
@@ -563,6 +625,7 @@ function exchangePassCards(room) {
|
|||||||
|
|
||||||
// ─── Game flow ────────────────────────────────────────────────
|
// ─── Game flow ────────────────────────────────────────────────
|
||||||
function startPassing(room) {
|
function startPassing(room) {
|
||||||
|
clearAfkTimer(room);
|
||||||
room.state = 'PASSING';
|
room.state = 'PASSING';
|
||||||
broadcastState(room, 'roomInfo');
|
broadcastState(room, 'roomInfo');
|
||||||
// Schedule bots to pass
|
// Schedule bots to pass
|
||||||
@@ -578,6 +641,7 @@ function startPlaying(room) {
|
|||||||
room.trick = [];
|
room.trick = [];
|
||||||
broadcastState(room, 'roomInfo');
|
broadcastState(room, 'roomInfo');
|
||||||
scheduleBotPlay(room);
|
scheduleBotPlay(room);
|
||||||
|
scheduleAfkTimer(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCardPlayed(room, player, card) {
|
function onCardPlayed(room, player, card) {
|
||||||
@@ -592,6 +656,7 @@ function onCardPlayed(room, player, card) {
|
|||||||
room.currentTurn = (player + 3) % 4; // anti-clockwise
|
room.currentTurn = (player + 3) % 4; // anti-clockwise
|
||||||
broadcastState(room, 'cardPlayed');
|
broadcastState(room, 'cardPlayed');
|
||||||
scheduleBotPlay(room);
|
scheduleBotPlay(room);
|
||||||
|
scheduleAfkTimer(room);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,10 +670,12 @@ function onCardPlayed(room, player, card) {
|
|||||||
|
|
||||||
if (room.tricksPlayed === 13) {
|
if (room.tricksPlayed === 13) {
|
||||||
broadcastState(room, 'trickWon');
|
broadcastState(room, 'trickWon');
|
||||||
|
clearAfkTimer(room);
|
||||||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||||||
room.trickTimer = setTimeout(() => finishHand(room), 1400);
|
room.trickTimer = setTimeout(() => finishHand(room), 1400);
|
||||||
} else {
|
} else {
|
||||||
broadcastState(room, 'trickWon');
|
broadcastState(room, 'trickWon');
|
||||||
|
clearAfkTimer(room);
|
||||||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||||||
room.trickTimer = setTimeout(() => {
|
room.trickTimer = setTimeout(() => {
|
||||||
room.trickLead = winner;
|
room.trickLead = winner;
|
||||||
@@ -616,11 +683,15 @@ function onCardPlayed(room, player, card) {
|
|||||||
room.trick = [];
|
room.trick = [];
|
||||||
broadcastState(room, 'roomInfo');
|
broadcastState(room, 'roomInfo');
|
||||||
scheduleBotPlay(room);
|
scheduleBotPlay(room);
|
||||||
|
scheduleAfkTimer(room);
|
||||||
}, 1400);
|
}, 1400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishHand(room) {
|
function finishHand(room) {
|
||||||
|
clearAfkTimer(room);
|
||||||
|
for (const s of room.aiControlledSeats) broadcastAiControl(room, s, false);
|
||||||
|
room.aiControlledSeats.clear();
|
||||||
// Check shoot the moon (one player has all 26 pts)
|
// Check shoot the moon (one player has all 26 pts)
|
||||||
const shooter = room.handPoints.findIndex(p => p === 26);
|
const shooter = room.handPoints.findIndex(p => p === 26);
|
||||||
let deltas;
|
let deltas;
|
||||||
@@ -815,14 +886,25 @@ io.use((socket, next) => {
|
|||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
const user = socket.data.user;
|
const user = socket.data.user;
|
||||||
if (user) userSockets.set(user.id, socket);
|
if (user) {
|
||||||
|
userSockets.set(user.id, socket);
|
||||||
|
// Push active game to this user on any device (cross-device rejoin)
|
||||||
|
for (const [, room] of rooms) {
|
||||||
|
const seat = room.userIds.indexOf(user.id);
|
||||||
|
if (seat >= 0 && room.tokens[seat] !== null && room.state !== 'GAME_OVER') {
|
||||||
|
socket.emit('hasActiveGame', { roomId: room.id, seat, token: room.tokens[seat] });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Create room ────────────────────────────────────────────
|
// ── Create room ────────────────────────────────────────────
|
||||||
socket.on('create', ({ name, winScore } = {}) => {
|
socket.on('create', ({ name, winScore, isPublic } = {}) => {
|
||||||
if (!name?.trim()) return socket.emit('error', 'Name is required');
|
if (!name?.trim()) return socket.emit('error', 'Name is required');
|
||||||
const id = Math.random().toString(36).slice(2, 8).toUpperCase();
|
const id = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||||
const room = newRoom(id);
|
const room = newRoom(id);
|
||||||
room.winScore = Number.isFinite(+winScore) && winScore >= 50 ? Math.min(+winScore, 500) : 100;
|
room.winScore = Number.isFinite(+winScore) && winScore >= 50 ? Math.min(+winScore, 500) : 100;
|
||||||
|
room.isPublic = isPublic === true;
|
||||||
room.names[0] = name.trim().slice(0, 16);
|
room.names[0] = name.trim().slice(0, 16);
|
||||||
room.userIds[0] = user?.id || null;
|
room.userIds[0] = user?.id || null;
|
||||||
room.seats[0] = socket.id;
|
room.seats[0] = socket.id;
|
||||||
@@ -840,12 +922,14 @@ io.on('connection', (socket) => {
|
|||||||
if (!room) return socket.emit('joinError', 'Room not found');
|
if (!room) return socket.emit('joinError', 'Room not found');
|
||||||
if (room.state !== 'WAITING') return socket.emit('joinError', 'Game already in progress');
|
if (room.state !== 'WAITING') return socket.emit('joinError', 'Game already in progress');
|
||||||
|
|
||||||
// Find first open seat (not filled by a human or bot)
|
const openSeats = [];
|
||||||
let openSeat = -1;
|
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
if (!room.seats[i] && !room.bots[i]) { openSeat = i; break; }
|
if (!room.seats[i] && !room.bots[i]) openSeats.push(i);
|
||||||
}
|
}
|
||||||
if (openSeat === -1) return socket.emit('joinError', 'Room is full');
|
if (openSeats.length === 0) return socket.emit('joinError', 'Room is full');
|
||||||
|
const openSeat = room.isPublic
|
||||||
|
? openSeats[Math.floor(Math.random() * openSeats.length)]
|
||||||
|
: openSeats[0];
|
||||||
|
|
||||||
room.names[openSeat] = name.trim().slice(0, 16);
|
room.names[openSeat] = name.trim().slice(0, 16);
|
||||||
room.userIds[openSeat] = user?.id || null;
|
room.userIds[openSeat] = user?.id || null;
|
||||||
@@ -874,11 +958,21 @@ io.on('connection', (socket) => {
|
|||||||
socket.on('rejoin', ({ roomId, seat, token } = {}) => {
|
socket.on('rejoin', ({ roomId, seat, token } = {}) => {
|
||||||
const room = rooms.get((roomId || '').toUpperCase());
|
const room = rooms.get((roomId || '').toUpperCase());
|
||||||
if (!room) return socket.emit('rejoinError', 'Room no longer exists');
|
if (!room) return socket.emit('rejoinError', 'Room no longer exists');
|
||||||
if (room.tokens[seat] !== token) return socket.emit('rejoinError', 'Invalid session token');
|
|
||||||
|
const tokenOk = room.tokens[seat] === token;
|
||||||
|
const userOk = user &&
|
||||||
|
room.userIds[seat] != null &&
|
||||||
|
room.userIds[seat] === user.id &&
|
||||||
|
room.tokens[seat] !== null; // null means player explicitly left
|
||||||
|
|
||||||
|
if (!tokenOk && !userOk) return socket.emit('rejoinError', 'Invalid session token');
|
||||||
|
|
||||||
|
// Re-issue a fresh token when only userId matched (old token lives on the other device)
|
||||||
|
if (!tokenOk) room.tokens[seat] = makeToken();
|
||||||
|
|
||||||
room.seats[seat] = socket.id;
|
room.seats[seat] = socket.id;
|
||||||
socket.join(room.id);
|
socket.join(room.id);
|
||||||
socket.emit('rejoined', { roomId: room.id, seat, token });
|
socket.emit('rejoined', { roomId: room.id, seat, token: room.tokens[seat] });
|
||||||
socket.emit('roomInfo', publicInfo(room, seat));
|
socket.emit('roomInfo', publicInfo(room, seat));
|
||||||
broadcastState(room, 'roomInfo');
|
broadcastState(room, 'roomInfo');
|
||||||
});
|
});
|
||||||
@@ -929,16 +1023,49 @@ io.on('connection', (socket) => {
|
|||||||
if (room.tokens[seat] !== token) return;
|
if (room.tokens[seat] !== token) return;
|
||||||
if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn');
|
if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn');
|
||||||
if (!isLegal(room, seat, card)) return socket.emit('playError', 'Illegal card');
|
if (!isLegal(room, seat, card)) return socket.emit('playError', 'Illegal card');
|
||||||
|
if (room.aiControlledSeats.has(seat)) {
|
||||||
|
room.aiControlledSeats.delete(seat);
|
||||||
|
broadcastAiControl(room, seat, false);
|
||||||
|
clearAfkTimer(room);
|
||||||
|
}
|
||||||
onCardPlayed(room, seat, card);
|
onCardPlayed(room, seat, card);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Vote to let AI take over an AFK player ────────────────
|
||||||
|
socket.on('voteAITakeover', ({ roomId } = {}) => {
|
||||||
|
const room = rooms.get((roomId || '').toUpperCase());
|
||||||
|
if (!room || room.state !== 'PLAYING') return;
|
||||||
|
if (room.afkSeat < 0) return;
|
||||||
|
|
||||||
|
const voterSeat = room.seats.indexOf(socket.id);
|
||||||
|
if (voterSeat < 0 || voterSeat === room.afkSeat || room.bots[voterSeat]) return;
|
||||||
|
|
||||||
|
room.afkVotes.add(socket.id);
|
||||||
|
|
||||||
|
const eligible = room.seats.filter((s, i) => s && !room.bots[i] && i !== room.afkSeat).length;
|
||||||
|
const needed = eligible <= 1 ? 1 : Math.floor(eligible / 2) + 1;
|
||||||
|
const votes = [...room.afkVotes].filter(sid => {
|
||||||
|
const idx = room.seats.indexOf(sid);
|
||||||
|
return idx >= 0 && idx !== room.afkSeat && !room.bots[idx];
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
if (votes >= needed) {
|
||||||
|
const seat = room.afkSeat;
|
||||||
|
room.aiControlledSeats.add(seat);
|
||||||
|
broadcastAiControl(room, seat, true);
|
||||||
|
clearAfkTimer(room);
|
||||||
|
scheduleAfkTimer(room);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Leave ──────────────────────────────────────────────────
|
// ── Leave ──────────────────────────────────────────────────
|
||||||
socket.on('leave', ({ roomId, seat, token } = {}) => {
|
socket.on('leave', ({ roomId, seat, token } = {}) => {
|
||||||
const room = rooms.get((roomId || '').toUpperCase());
|
const room = rooms.get((roomId || '').toUpperCase());
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
if (room.tokens[seat] === token) {
|
if (room.tokens[seat] === token) {
|
||||||
room.seats[seat] = null;
|
room.seats[seat] = null;
|
||||||
room.tokens[seat] = null;
|
room.tokens[seat] = null;
|
||||||
|
room.userIds[seat] = null;
|
||||||
}
|
}
|
||||||
socket.leave(room.id);
|
socket.leave(room.id);
|
||||||
});
|
});
|
||||||
@@ -949,6 +1076,19 @@ io.on('connection', (socket) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Stale public room cleanup ─────────────────────────────────
|
||||||
|
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, room] of rooms) {
|
||||||
|
if (room.isPublic && room.state === 'WAITING' && now - room.createdAt > SIX_HOURS_MS) {
|
||||||
|
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||||||
|
if (room.afkTimer) clearTimeout(room.afkTimer);
|
||||||
|
rooms.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30 * 60 * 1000);
|
||||||
|
|
||||||
// ─── Server startup ────────────────────────────────────────────
|
// ─── Server startup ────────────────────────────────────────────
|
||||||
httpServer.listen(HTTP_PORT, () =>
|
httpServer.listen(HTTP_PORT, () =>
|
||||||
console.log(`Hearts HTTP → http://localhost:${HTTP_PORT}`)
|
console.log(`Hearts HTTP → http://localhost:${HTTP_PORT}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user