diff --git a/.gitignore b/.gitignore
index 8b13a87..7f3f178 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ data/
node_modules/
npm-debug.log*
+TODO
\ No newline at end of file
diff --git a/compose.yml b/compose.build.yml
similarity index 100%
rename from compose.yml
rename to compose.build.yml
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..94d57f6
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,15 @@
+services:
+ shelem:
+ container_name: shelem
+ image: git.goyban.com/goyban/shelem:latest
+ pull_policy: missing
+ ports:
+ - "4001:4000"
+ - "4444:4443"
+ restart: unless-stopped
+ volumes:
+ - ./data:/app/data
+ - /root/hokm/data:/hokm-data:ro
+ - /root/hearts/public/cards:/app/public/cards:ro
+ env_file:
+ - .env
diff --git a/public/app.js b/public/app.js
index 64b18a5..32d66d4 100644
--- a/public/app.js
+++ b/public/app.js
@@ -24,9 +24,16 @@ let widowSelected = [];
let currentBidAmount = 85;
let lastBidHandNumber = -1;
-// Turn reminder state
-let turnReminderTimer = null;
-let turnReminderActive = false;
+// Turn reminder / escalation state
+let turnReminderTimer = null;
+let turnReminderActive = false;
+let turnEscalationLevel = 0;
+
+// AFK state
+let afkBannerVoted = false;
+
+// AI control state (which seats are AI-controlled)
+let aiControlledSeats = new Set();
// Unlock Web Audio on first user gesture (required by iOS)
let _audioCtx = null;
@@ -57,23 +64,36 @@ function playTurnChime() {
} catch (e) { /* 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;
+ turnReminderActive = true;
+ turnEscalationLevel = 0;
$('my-area')?.classList.remove('turn-urgent');
- turnReminderTimer = setTimeout(() => {
- $('my-area')?.classList.add('turn-urgent');
- // Vibrate on Android; chime on iOS (vibrate not supported there)
- if (navigator.vibrate) {
- navigator.vibrate([300, 100, 300]);
- } else {
- playTurnChime();
+ 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 || turnReminderTimer)) {
- clearTimeout(turnReminderTimer);
- turnReminderTimer = null;
- turnReminderActive = false;
+ } else if (!isMyTurn && turnReminderActive) {
+ if (turnReminderTimer) { clearInterval(turnReminderTimer); turnReminderTimer = null; }
+ turnReminderActive = false;
+ turnEscalationLevel = 0;
+ applyTurnEscalation(0);
$('my-area')?.classList.remove('turn-urgent');
}
}
@@ -90,7 +110,8 @@ function loadPlayMode() {
function savePlayMode(m) { localStorage.setItem('shelem_play_mode', m); }
function loadHandMode() {
const s = localStorage.getItem('shelem_hand_mode');
- return ['scroll','fan','playables'].includes(s) ? s : 'scroll';
+ if (['scroll','fan','playables'].includes(s)) return s;
+ return isTouchDevice() ? 'fan' : 'scroll';
}
function saveHandMode(m) { localStorage.setItem('shelem_hand_mode', m); }
function loadBarBottom() { return localStorage.getItem('shelem_bar_bottom') === '1'; }
@@ -512,6 +533,23 @@ function initSocketHandlers() {
socket.on('handOver', onHandOver);
socket.on('gameOver', onGameOver);
+ socket.on('afkWarning', ({ name } = {}) => {
+ afkBannerVoted = false;
+ $('afk-banner-msg').textContent = `${name} hasn't played for 1 minute.`;
+ const btn = $('afk-vote-btn');
+ if (btn) { btn.disabled = false; btn.textContent = 'Let AI Play'; }
+ show('afk-banner');
+ });
+ socket.on('afkResolved', () => {
+ afkBannerVoted = false;
+ hide('afk-banner');
+ });
+ socket.on('aiControl', ({ seat, active, name } = {}) => {
+ if (active) aiControlledSeats.add(seat);
+ else aiControlledSeats.delete(seat);
+ updateAiControlBanner();
+ });
+
socket.on('disconnect', () => {});
}
@@ -641,7 +679,7 @@ function renderInfoBar(st) {
const td = $('trump-display');
const bd = $('bid-display');
if (st.trump) {
- td.textContent = `Trump: ${suitSymbol(st.trump)} ${suitName(st.trump)}`;
+ td.textContent = `Hokm:\n${suitSymbol(st.trump)}`;
show('trump-display');
} else {
hide('trump-display');
@@ -1255,6 +1293,30 @@ async function showLeaderboard() {
} catch { /* ignore */ }
}
+function updateAiControlBanner() {
+ if (aiControlledSeats.size === 0) {
+ hide('ai-control-banner');
+ return;
+ }
+ const isMe = aiControlledSeats.has(mySeat);
+ let msg;
+ if (isMe) {
+ msg = '🤖 AI is playing for you! Play a card to take back control.';
+ } else {
+ const names = [...aiControlledSeats].map(s => lastState?.names[s] || `Seat ${s + 1}`);
+ msg = `🤖 AI is playing for: ${names.join(', ')}`;
+ }
+ $('ai-control-msg').textContent = msg;
+ $('ai-control-banner').classList.toggle('is-me', isMe);
+ show('ai-control-banner');
+}
+
+function clearGameState() {
+ aiControlledSeats.clear();
+ hide('ai-control-banner');
+ hide('afk-banner');
+}
+
// ─── Event wiring ──────────────────────────────────────────────
function wireGameEvents() {
// Waiting room
@@ -1337,6 +1399,7 @@ function wireGameEvents() {
$('btn-exit-confirm-yes').addEventListener('click', () => {
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
clearSession();
+ clearGameState();
hide('overlay-exit-confirm');
hide('overlay-hand');
hide('overlay-gameover');
@@ -1432,6 +1495,15 @@ function wireGameEvents() {
} catch { $('change-pass-msg').textContent = 'Network error'; }
});
+ $('afk-vote-btn')?.addEventListener('click', () => {
+ if (afkBannerVoted) return;
+ afkBannerVoted = true;
+ socket?.emit('voteAITakeover', { roomId: myRoomId, seat: mySeat, token: myToken });
+ const btn = $('afk-vote-btn');
+ if (btn) { btn.disabled = true; btn.textContent = 'Voted ✓'; }
+ });
+ $('afk-dismiss-btn')?.addEventListener('click', () => hide('afk-banner'));
+
$('btn-toggle-signups').addEventListener('click', async () => {
try {
const r = await fetch('/api/admin/toggle-signups', {
diff --git a/public/index.html b/public/index.html
index 8ce33d5..3845e66 100644
--- a/public/index.html
+++ b/public/index.html
@@ -166,6 +166,18 @@
👁 Spectating — watching only
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/style.css b/public/style.css
index 5cb0f0b..8dadbcb 100644
--- a/public/style.css
+++ b/public/style.css
@@ -338,13 +338,17 @@ input:focus { border-color: var(--gold); }
gap: 8px;
flex: 1;
justify-content: center;
+ min-width: 0;
+ flex-wrap: wrap;
}
.team-score {
font-size: .78rem;
font-weight: 700;
padding: 2px 10px;
border-radius: 20px;
- white-space: nowrap;
+ word-break: break-word;
+ text-align: center;
+ min-width: 0;
}
.team-score.team-a { background: rgba(79,195,247,.2); border: 1px solid rgba(79,195,247,.4); color: var(--team-a); }
.team-score.team-b { background: rgba(239,154,154,.2); border: 1px solid rgba(239,154,154,.4); color: var(--team-b); }
@@ -357,6 +361,9 @@ input:focus { border-color: var(--gold); }
background: rgba(245,197,24,.2);
border: 1px solid rgba(245,197,24,.4);
color: var(--gold);
+ white-space: pre;
+ text-align: center;
+ line-height: 1.2;
}
.bid-display {
font-size: .75rem;
@@ -610,6 +617,26 @@ input:focus { border-color: var(--gold); }
from { opacity: .7; }
to { opacity: 1; }
}
+/* Escalating urgency — applied every 5 s while it's your turn */
+.phase-msg.your-turn-lvl1 {
+ font-size: .96rem;
+ color: #ffc400;
+ animation: your-turn-flash .6s ease-in-out infinite alternate;
+}
+.phase-msg.your-turn-lvl2 {
+ font-size: 1.18rem;
+ color: #ffeb3b;
+ font-weight: 800;
+ text-shadow: 0 0 10px rgba(255, 220, 0, 0.6);
+ animation: your-turn-flash .4s ease-in-out infinite alternate;
+}
+.phase-msg.your-turn-lvl3 {
+ font-size: 1.4rem;
+ color: #fff;
+ font-weight: 800;
+ text-shadow: 0 0 22px rgba(255, 200, 0, 1), 0 0 6px rgba(255,255,255,.8);
+ animation: your-turn-flash .25s ease-in-out infinite alternate;
+}
/* Drop-zone pulse when dragging */
#trick-area { position: relative; }
@@ -1026,6 +1053,40 @@ input:focus { border-color: var(--gold); }
flex-shrink: 0;
}
+/* ── AFK banner ───────────────────────────────── */
+.afk-banner {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 12px;
+ background: rgba(200, 100, 0, 0.28);
+ border-bottom: 1px solid rgba(200, 120, 0, 0.45);
+ font-size: .82rem;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+}
+.afk-banner.hidden { display: none !important; }
+
+/* ── AI control banner ────────────────────────── */
+.ai-control-banner {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 12px;
+ background: rgba(80, 0, 180, 0.3);
+ border-bottom: 1px solid rgba(120, 60, 220, 0.5);
+ font-size: .8rem;
+ flex-shrink: 0;
+ color: #ce93d8;
+}
+.ai-control-banner.is-me {
+ background: rgba(180, 0, 100, 0.35);
+ border-bottom-color: rgba(220, 60, 140, 0.55);
+ color: #f48fb1;
+ font-weight: 700;
+}
+.ai-control-banner.hidden { display: none !important; }
+
/* ── Util ─────────────────────────────────────── */
.hidden { display: none !important; }
diff --git a/push.sh b/push.sh
new file mode 100755
index 0000000..61a206d
--- /dev/null
+++ b/push.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REGISTRY="git.goyban.com"
+USER="goyban"
+IMAGE="shelem"
+
+VERSION="${1:-latest}" # pass version as argument, e.g. ./push.sh v1.0.1
+
+BASE="${REGISTRY}/${USER}/${IMAGE}"
+
+echo "▶ Building ${BASE}:${VERSION} ..."
+docker build -t "${BASE}:${VERSION}" .
+
+if [ "${VERSION}" != "latest" ]; then
+ echo "▶ Tagging as latest ..."
+ docker tag "${BASE}:${VERSION}" "${BASE}:latest"
+fi
+
+echo "▶ Pushing ${BASE}:${VERSION} ..."
+docker push "${BASE}:${VERSION}"
+
+if [ "${VERSION}" != "latest" ]; then
+ echo "▶ Pushing ${BASE}:latest ..."
+ docker push "${BASE}:latest"
+fi
+
+echo "✓ Done: ${BASE}:${VERSION} + ${BASE}:latest"
diff --git a/server.js b/server.js
index 74e0d6e..ecf10b3 100644
--- a/server.js
+++ b/server.js
@@ -394,6 +394,13 @@ function newRoom(id) {
winScore: 505,
spectators: new Set(),
trickTimer: null,
+ // AFK tracking
+ afkTimer: null,
+ afkSeat: -1,
+ afkVotes: new Set(),
+ // AI control (persistent per seat until player acts)
+ aiControlledSeats: new Set(),
+ createdAt: Date.now(),
};
}
@@ -468,6 +475,9 @@ function dealHand(room) {
// ─── Bidding ──────────────────────────────────────────────────
function startBidding(room) {
+ clearAfkTimer(room);
+ for (const seat of room.aiControlledSeats) broadcastAiControl(room, seat, false);
+ room.aiControlledSeats.clear();
room.state = 'BIDDING';
// Bidding starts at right of dealer (counter-clockwise first seat)
room.currentBidder = (room.dealer + 3) % 4;
@@ -577,10 +587,13 @@ function startPlaying(room) {
room.currentTurn = room.declarer;
room.trick = [];
broadcastState(room, 'roomInfo');
+ scheduleAfkTimer(room);
scheduleBotPlay(room);
}
function onCardPlayed(room, player, card) {
+ clearAfkTimer(room);
+
// First card played by the declarer sets trump (jokers excluded by legalCards)
if (room.trump === null) room.trump = suitOf(card);
@@ -590,6 +603,7 @@ function onCardPlayed(room, player, card) {
if (room.trick.length < 4) {
room.currentTurn = (player + 3) % 4; // anti-clockwise: next player is to the right
broadcastState(room, 'cardPlayed');
+ scheduleAfkTimer(room);
scheduleBotPlay(room);
return;
}
@@ -616,6 +630,7 @@ function onCardPlayed(room, player, card) {
room.currentTurn = winner;
room.trick = [];
broadcastState(room, 'roomInfo');
+ scheduleAfkTimer(room);
scheduleBotPlay(room);
}, 1400);
}
@@ -623,6 +638,7 @@ function onCardPlayed(room, player, card) {
// ─── Hand scoring ─────────────────────────────────────────────
function finishHand(room) {
+ clearAfkTimer(room);
const dTeam = teamOf(room.declarer);
const oTeam = 1 - dTeam;
@@ -698,6 +714,65 @@ function finishGame(room) {
}
}
+// ─── AFK timer ────────────────────────────────────────────────
+function clearAfkTimer(room) {
+ if (room.afkTimer) { clearTimeout(room.afkTimer); room.afkTimer = null; }
+ if (room.afkSeat >= 0) {
+ for (let i = 0; i < 4; i++) {
+ if (room.seats[i]) io.to(room.seats[i]).emit('afkResolved', {});
+ }
+ room.afkSeat = -1;
+ room.afkVotes = new Set();
+ }
+}
+
+function broadcastAiControl(room, seat, active) {
+ const data = { seat, active, name: room.names[seat] };
+ for (let i = 0; i < 4; i++) {
+ if (room.seats[i]) io.to(room.seats[i]).emit('aiControl', data);
+ }
+ for (const sid of room.spectators) io.to(sid).emit('aiControl', data);
+}
+
+function scheduleAfkTimer(room) {
+ clearAfkTimer(room);
+ if (room.state !== 'PLAYING') return;
+ const seat = room.currentTurn;
+ if (room.bots[seat] || !room.seats[seat]) return;
+
+ // Seat is AI-controlled: play automatically after 10s
+ if (room.aiControlledSeats.has(seat)) {
+ room.afkTimer = setTimeout(() => {
+ if (room.state !== 'PLAYING' || room.currentTurn !== seat) return;
+ if (!room.aiControlledSeats.has(seat)) return;
+ const card = botChooseCard(room, seat);
+ if (card) onCardPlayed(room, seat, card);
+ }, 10000);
+ return;
+ }
+
+ // Normal 60s AFK warning timer
+ room.afkTimer = setTimeout(() => {
+ if (room.state !== 'PLAYING' || room.currentTurn !== seat) return;
+ room.afkSeat = seat;
+ room.afkVotes = new Set();
+
+ const otherHumans = room.seats
+ .map((sid, i) => ({ sid, i }))
+ .filter(({ sid, i }) => sid && i !== seat);
+
+ if (otherHumans.length === 0) {
+ room.afkSeat = -1;
+ const card = botChooseCard(room, seat);
+ if (card) onCardPlayed(room, seat, card);
+ return;
+ }
+ for (const { sid } of otherHumans) {
+ io.to(sid).emit('afkWarning', { seat, name: room.names[seat] });
+ }
+ }, 60000);
+}
+
// ─── Game init ────────────────────────────────────────────────
function tryStartGame(room) {
const filled = room.seats.map((s, i) => !!s || room.bots[i]);
@@ -1038,9 +1113,40 @@ io.on('connection', (socket) => {
if (room.tokens[seat] !== token) return;
if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn');
if (!legalCards(room, seat).includes(card)) return socket.emit('playError', 'Illegal card');
+ // Player acted themselves — release AI control
+ if (room.aiControlledSeats.has(seat)) {
+ room.aiControlledSeats.delete(seat);
+ broadcastAiControl(room, seat, false);
+ }
onCardPlayed(room, seat, card);
});
+ // ── Vote AI takeover (AFK) ─────────────────────────────────
+ socket.on('voteAITakeover', ({ roomId, seat, token } = {}) => {
+ const room = rooms.get((roomId || '').toUpperCase());
+ if (!room || room.state !== 'PLAYING') return;
+ if (room.tokens[seat] !== token) return;
+ if (room.afkSeat < 0 || seat === room.afkSeat) return;
+
+ room.afkVotes.add(seat);
+
+ const otherHumanSeats = room.seats
+ .map((sid, i) => ({ sid, i }))
+ .filter(({ sid, i }) => sid && i !== room.afkSeat);
+ const needed = Math.ceil(otherHumanSeats.length / 2);
+
+ if (room.afkVotes.size >= needed) {
+ const afkSeat = room.afkSeat;
+ clearAfkTimer(room);
+ room.aiControlledSeats.add(afkSeat);
+ broadcastAiControl(room, afkSeat, true);
+ if (room.state === 'PLAYING' && room.currentTurn === afkSeat) {
+ const card = botChooseCard(room, afkSeat);
+ if (card) onCardPlayed(room, afkSeat, card);
+ }
+ }
+ });
+
// ── Leave ──────────────────────────────────────────────────
socket.on('leave', ({ roomId, seat, token } = {}) => {
const room = rooms.get((roomId || '').toUpperCase());
@@ -1058,6 +1164,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); // check every 30 minutes
+
// ─── Start ────────────────────────────────────────────────────
httpServer.listen(HTTP_PORT, () =>
console.log(`Shelem HTTP → http://localhost:${HTTP_PORT}`)