Compare commits
4 Commits
v0.0.1
..
e499e89027
| Author | SHA1 | Date | |
|---|---|---|---|
| e499e89027 | |||
| f78fbe2dfa | |||
| 029302b9e9 | |||
| 6c6e23f921 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
.env
|
||||
rerun.sh
|
||||
data/
|
||||
node_modules/
|
||||
push.sh
|
||||
@@ -0,0 +1,85 @@
|
||||
# Hearts
|
||||
|
||||
A multiplayer Hearts card game with real-time gameplay via WebSockets. Shares accounts with [Hokm](../hokm) — log in with the same credentials on both games.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker & Docker Compose
|
||||
- [Hokm](../hokm) installed at `/root/hokm` (provides card assets and shared user accounts)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://git.goyban.com/goyban/hearts.git
|
||||
cd hearts
|
||||
cp .env.example .env
|
||||
# edit .env — see below
|
||||
```
|
||||
|
||||
Then start:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Or use the helper script (stops, rebuilds, and tails logs):
|
||||
|
||||
```bash
|
||||
./rerun.sh
|
||||
```
|
||||
|
||||
The game is available at:
|
||||
- HTTP: `http://<host>:4000`
|
||||
- HTTPS: `https://<host>:4443` (self-signed cert, needed for PWA install)
|
||||
|
||||
## .env reference
|
||||
|
||||
```dotenv
|
||||
# Secret used to sign JWT session tokens. Change this to a random string.
|
||||
JWT_SECRET=hearts-secret-change-me
|
||||
|
||||
# Username that gets admin privileges (access to /admin panel).
|
||||
# Leave empty to disable admin.
|
||||
ADMIN_USERNAME=
|
||||
|
||||
# HTTP and HTTPS ports (must match docker-compose.yml port mapping if changed).
|
||||
PORT=4000
|
||||
HTTPS_PORT=4443
|
||||
|
||||
# Path to Hokm's users.json so Hokm accounts work here too.
|
||||
# Docker path (default, matches the volume mount in docker-compose.yml):
|
||||
SHARED_USERS_FILE=/hokm-data/users.json
|
||||
# If running directly with `node server.js` instead of Docker, use:
|
||||
# SHARED_USERS_FILE=/root/hokm/data/users.json
|
||||
|
||||
# Optional: Resend (resend.com) for email verification codes on signup.
|
||||
# Leave blank to skip email verification entirely.
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM=noreply@example.com
|
||||
|
||||
# Optional: Cloudflare Turnstile CAPTCHA on the signup form.
|
||||
# Get keys at dash.cloudflare.com → Turnstile.
|
||||
# Leave blank to disable CAPTCHA.
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET=
|
||||
```
|
||||
|
||||
### Minimum required changes
|
||||
|
||||
| Variable | What to set |
|
||||
|---|---|
|
||||
| `JWT_SECRET` | Any long random string (e.g. `openssl rand -hex 32`) |
|
||||
| `ADMIN_USERNAME` | Your username, to unlock the admin panel |
|
||||
|
||||
Everything else is optional.
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
git pull
|
||||
./rerun.sh
|
||||
```
|
||||
|
||||
## Data
|
||||
|
||||
Game data (users, stats, config) is stored in `./data/` which is bind-mounted into the container. It persists across restarts and rebuilds.
|
||||
@@ -1,5 +1,6 @@
|
||||
services:
|
||||
hearts:
|
||||
container_name: hearts
|
||||
build: .
|
||||
ports:
|
||||
- "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
|
||||
+185
-11
@@ -11,7 +11,17 @@ 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;
|
||||
let selectedScore = 100;
|
||||
let selectedPublic = false;
|
||||
|
||||
// ─── Play mode & hand display mode ───────────────────────────
|
||||
function isTouchDevice() {
|
||||
@@ -160,6 +170,55 @@ const RANK_FILE = {
|
||||
'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')); }
|
||||
@@ -295,6 +354,14 @@ function initSocket() {
|
||||
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
|
||||
@@ -308,11 +375,44 @@ function initSocket() {
|
||||
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');
|
||||
@@ -335,6 +435,7 @@ function handleState(state) {
|
||||
|
||||
function handleTrickWon(state) {
|
||||
lastState = state;
|
||||
syncAiState(state);
|
||||
renderInfoBar(state);
|
||||
renderTable(state);
|
||||
// Flash the winning slot briefly
|
||||
@@ -346,6 +447,8 @@ function handleTrickWon(state) {
|
||||
|
||||
function handleHandOver(state) {
|
||||
lastState = state;
|
||||
syncAiState(state);
|
||||
hide('afk-banner');
|
||||
renderInfoBar(state);
|
||||
renderTable(state);
|
||||
hide('overlay-pass');
|
||||
@@ -354,6 +457,8 @@ function handleHandOver(state) {
|
||||
|
||||
function handleGameOver(state) {
|
||||
lastState = state;
|
||||
syncAiState(state);
|
||||
hide('afk-banner');
|
||||
renderInfoBar(state);
|
||||
renderTable(state);
|
||||
hide('overlay-pass', 'overlay-hand');
|
||||
@@ -440,6 +545,9 @@ function renderWaitingRoom(state) {
|
||||
} else {
|
||||
hide('btn-fill-bots');
|
||||
}
|
||||
|
||||
const badge = $('waiting-public-badge');
|
||||
if (badge) badge.textContent = state.isPublic ? '🌐 Public' : '🔒 Private';
|
||||
}
|
||||
|
||||
// ── Game table ────────────────────────────────────────────────
|
||||
@@ -616,6 +724,12 @@ 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') {
|
||||
@@ -728,8 +842,9 @@ function togglePassSelect(code, state) {
|
||||
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;
|
||||
passSelectedLocal = selected;
|
||||
renderPassOverlay(state);
|
||||
// Also update my-hand in game table
|
||||
renderMyHand(myHand, state);
|
||||
@@ -740,6 +855,7 @@ function confirmPass() {
|
||||
if (!state) return;
|
||||
const selected = state.passSelected || [];
|
||||
if (selected.length !== 3) return;
|
||||
passSelectedLocal = [];
|
||||
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,'"');
|
||||
}
|
||||
|
||||
// ─── 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 ──────────────────────────────────────────────
|
||||
window.addEventListener('beforeinstallprompt', e => {
|
||||
e.preventDefault();
|
||||
@@ -1038,28 +1196,33 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
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 });
|
||||
socket.emit('create', { name, winScore: selectedScore, isPublic: selectedPublic });
|
||||
});
|
||||
|
||||
// Join game
|
||||
$('btn-join').addEventListener('click', () => {
|
||||
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 });
|
||||
});
|
||||
$('btn-join').addEventListener('click', joinGame);
|
||||
|
||||
// Spectate
|
||||
$('btn-spectate').addEventListener('click', () => {
|
||||
@@ -1090,6 +1253,13 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// 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) {
|
||||
@@ -1107,6 +1277,8 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
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'));
|
||||
@@ -1116,6 +1288,8 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
+29
-2
@@ -51,15 +51,29 @@
|
||||
<button class="score-btn" data-score="200">200</button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
</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">
|
||||
<div class="field">
|
||||
<label>Watch as spectator</label>
|
||||
@@ -77,6 +91,7 @@
|
||||
<div class="waiting-box">
|
||||
<button id="btn-leave-waiting" class="btn-leave-screen">← Leave</button>
|
||||
<h2>Waiting for Players</h2>
|
||||
<div id="waiting-public-badge" class="waiting-public-badge">🔒 Private</div>
|
||||
<div class="room-code-box">
|
||||
<span class="label">Room Code</span>
|
||||
<span id="display-room-code" class="room-code">——</span>
|
||||
@@ -126,6 +141,18 @@
|
||||
<!-- Spectator banner -->
|
||||
<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 -->
|
||||
<div id="table-grid">
|
||||
|
||||
|
||||
@@ -1019,3 +1019,165 @@ html, body {
|
||||
:root { --card-w: 52px; --card-h: 74px; }
|
||||
#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); }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "==> Stopping..."
|
||||
docker compose down
|
||||
|
||||
echo "==> Building and starting..."
|
||||
docker compose up -d --build
|
||||
|
||||
echo "==> Logs (Ctrl+C to exit)..."
|
||||
docker compose logs -f
|
||||
@@ -348,6 +348,20 @@ app.get('/api/leaderboard', (_req, res) => {
|
||||
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) => {
|
||||
if (!isAdmin(req.user)) return res.status(403).json({ error: 'Forbidden' });
|
||||
config.signupsOpen = !config.signupsOpen;
|
||||
@@ -416,7 +430,13 @@ function newRoom(id) {
|
||||
handDeltas: null,
|
||||
winScore: 100,
|
||||
spectators: new Set(),
|
||||
isPublic: false,
|
||||
createdAt: Date.now(),
|
||||
trickTimer: null,
|
||||
afkTimer: null,
|
||||
afkSeat: -1,
|
||||
afkVotes: new Set(),
|
||||
aiControlledSeats: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -453,6 +473,8 @@ function publicInfo(room, seat = -1) {
|
||||
handDeltas: room.handDeltas,
|
||||
gameWinner: room.gameWinner,
|
||||
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 ──────────────────────────────────────────────────
|
||||
function dealHand(room) {
|
||||
const deck = shuffle(makeDeck());
|
||||
@@ -563,6 +625,7 @@ function exchangePassCards(room) {
|
||||
|
||||
// ─── Game flow ────────────────────────────────────────────────
|
||||
function startPassing(room) {
|
||||
clearAfkTimer(room);
|
||||
room.state = 'PASSING';
|
||||
broadcastState(room, 'roomInfo');
|
||||
// Schedule bots to pass
|
||||
@@ -578,6 +641,7 @@ function startPlaying(room) {
|
||||
room.trick = [];
|
||||
broadcastState(room, 'roomInfo');
|
||||
scheduleBotPlay(room);
|
||||
scheduleAfkTimer(room);
|
||||
}
|
||||
|
||||
function onCardPlayed(room, player, card) {
|
||||
@@ -592,6 +656,7 @@ function onCardPlayed(room, player, card) {
|
||||
room.currentTurn = (player + 3) % 4; // anti-clockwise
|
||||
broadcastState(room, 'cardPlayed');
|
||||
scheduleBotPlay(room);
|
||||
scheduleAfkTimer(room);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -605,10 +670,12 @@ function onCardPlayed(room, player, card) {
|
||||
|
||||
if (room.tricksPlayed === 13) {
|
||||
broadcastState(room, 'trickWon');
|
||||
clearAfkTimer(room);
|
||||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||||
room.trickTimer = setTimeout(() => finishHand(room), 1400);
|
||||
} else {
|
||||
broadcastState(room, 'trickWon');
|
||||
clearAfkTimer(room);
|
||||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||||
room.trickTimer = setTimeout(() => {
|
||||
room.trickLead = winner;
|
||||
@@ -616,11 +683,15 @@ function onCardPlayed(room, player, card) {
|
||||
room.trick = [];
|
||||
broadcastState(room, 'roomInfo');
|
||||
scheduleBotPlay(room);
|
||||
scheduleAfkTimer(room);
|
||||
}, 1400);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
const shooter = room.handPoints.findIndex(p => p === 26);
|
||||
let deltas;
|
||||
@@ -815,14 +886,25 @@ io.use((socket, next) => {
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
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 ────────────────────────────────────────────
|
||||
socket.on('create', ({ name, winScore } = {}) => {
|
||||
socket.on('create', ({ name, winScore, isPublic } = {}) => {
|
||||
if (!name?.trim()) return socket.emit('error', 'Name is required');
|
||||
const id = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
const room = newRoom(id);
|
||||
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.userIds[0] = user?.id || null;
|
||||
room.seats[0] = socket.id;
|
||||
@@ -840,12 +922,14 @@ io.on('connection', (socket) => {
|
||||
if (!room) return socket.emit('joinError', 'Room not found');
|
||||
if (room.state !== 'WAITING') return socket.emit('joinError', 'Game already in progress');
|
||||
|
||||
// Find first open seat (not filled by a human or bot)
|
||||
let openSeat = -1;
|
||||
const openSeats = [];
|
||||
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.userIds[openSeat] = user?.id || null;
|
||||
@@ -874,11 +958,21 @@ io.on('connection', (socket) => {
|
||||
socket.on('rejoin', ({ roomId, seat, token } = {}) => {
|
||||
const room = rooms.get((roomId || '').toUpperCase());
|
||||
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;
|
||||
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));
|
||||
broadcastState(room, 'roomInfo');
|
||||
});
|
||||
@@ -929,9 +1023,41 @@ io.on('connection', (socket) => {
|
||||
if (room.tokens[seat] !== token) return;
|
||||
if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn');
|
||||
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);
|
||||
});
|
||||
|
||||
// ── 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 ──────────────────────────────────────────────────
|
||||
socket.on('leave', ({ roomId, seat, token } = {}) => {
|
||||
const room = rooms.get((roomId || '').toUpperCase());
|
||||
@@ -939,6 +1065,7 @@ io.on('connection', (socket) => {
|
||||
if (room.tokens[seat] === token) {
|
||||
room.seats[seat] = null;
|
||||
room.tokens[seat] = null;
|
||||
room.userIds[seat] = null;
|
||||
}
|
||||
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 ────────────────────────────────────────────
|
||||
httpServer.listen(HTTP_PORT, () =>
|
||||
console.log(`Hearts HTTP → http://localhost:${HTTP_PORT}`)
|
||||
|
||||
Reference in New Issue
Block a user