Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0b9dde93e |
@@ -8,3 +8,4 @@ data/
|
|||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
|
TODO
|
||||||
@@ -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
|
||||||
+84
-12
@@ -24,9 +24,16 @@ let widowSelected = [];
|
|||||||
let currentBidAmount = 85;
|
let currentBidAmount = 85;
|
||||||
let lastBidHandNumber = -1;
|
let lastBidHandNumber = -1;
|
||||||
|
|
||||||
// Turn reminder state
|
// Turn reminder / escalation state
|
||||||
let turnReminderTimer = null;
|
let turnReminderTimer = null;
|
||||||
let turnReminderActive = false;
|
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)
|
// Unlock Web Audio on first user gesture (required by iOS)
|
||||||
let _audioCtx = null;
|
let _audioCtx = null;
|
||||||
@@ -57,23 +64,36 @@ function playTurnChime() {
|
|||||||
} catch (e) { /* audio not available */ }
|
} 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) {
|
function handleTurnReminder(isMyTurn) {
|
||||||
if (isMyTurn && !turnReminderActive) {
|
if (isMyTurn && !turnReminderActive) {
|
||||||
turnReminderActive = true;
|
turnReminderActive = true;
|
||||||
|
turnEscalationLevel = 0;
|
||||||
$('my-area')?.classList.remove('turn-urgent');
|
$('my-area')?.classList.remove('turn-urgent');
|
||||||
turnReminderTimer = setTimeout(() => {
|
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');
|
$('my-area')?.classList.add('turn-urgent');
|
||||||
// Vibrate on Android; chime on iOS (vibrate not supported there)
|
if (navigator.vibrate) navigator.vibrate([300, 100, 300]);
|
||||||
if (navigator.vibrate) {
|
else playTurnChime();
|
||||||
navigator.vibrate([300, 100, 300]);
|
clearInterval(turnReminderTimer);
|
||||||
} else {
|
turnReminderTimer = null;
|
||||||
playTurnChime();
|
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
} else if (!isMyTurn && (turnReminderActive || turnReminderTimer)) {
|
} else if (!isMyTurn && turnReminderActive) {
|
||||||
clearTimeout(turnReminderTimer);
|
if (turnReminderTimer) { clearInterval(turnReminderTimer); turnReminderTimer = null; }
|
||||||
turnReminderTimer = null;
|
|
||||||
turnReminderActive = false;
|
turnReminderActive = false;
|
||||||
|
turnEscalationLevel = 0;
|
||||||
|
applyTurnEscalation(0);
|
||||||
$('my-area')?.classList.remove('turn-urgent');
|
$('my-area')?.classList.remove('turn-urgent');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +110,8 @@ function loadPlayMode() {
|
|||||||
function savePlayMode(m) { localStorage.setItem('shelem_play_mode', m); }
|
function savePlayMode(m) { localStorage.setItem('shelem_play_mode', m); }
|
||||||
function loadHandMode() {
|
function loadHandMode() {
|
||||||
const s = localStorage.getItem('shelem_hand_mode');
|
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 saveHandMode(m) { localStorage.setItem('shelem_hand_mode', m); }
|
||||||
function loadBarBottom() { return localStorage.getItem('shelem_bar_bottom') === '1'; }
|
function loadBarBottom() { return localStorage.getItem('shelem_bar_bottom') === '1'; }
|
||||||
@@ -512,6 +533,23 @@ function initSocketHandlers() {
|
|||||||
socket.on('handOver', onHandOver);
|
socket.on('handOver', onHandOver);
|
||||||
socket.on('gameOver', onGameOver);
|
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', () => {});
|
socket.on('disconnect', () => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,7 +679,7 @@ function renderInfoBar(st) {
|
|||||||
const td = $('trump-display');
|
const td = $('trump-display');
|
||||||
const bd = $('bid-display');
|
const bd = $('bid-display');
|
||||||
if (st.trump) {
|
if (st.trump) {
|
||||||
td.textContent = `Trump: ${suitSymbol(st.trump)} ${suitName(st.trump)}`;
|
td.textContent = `Hokm:\n${suitSymbol(st.trump)}`;
|
||||||
show('trump-display');
|
show('trump-display');
|
||||||
} else {
|
} else {
|
||||||
hide('trump-display');
|
hide('trump-display');
|
||||||
@@ -1255,6 +1293,30 @@ async function showLeaderboard() {
|
|||||||
} catch { /* ignore */ }
|
} 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 ──────────────────────────────────────────────
|
// ─── Event wiring ──────────────────────────────────────────────
|
||||||
function wireGameEvents() {
|
function wireGameEvents() {
|
||||||
// Waiting room
|
// Waiting room
|
||||||
@@ -1337,6 +1399,7 @@ function wireGameEvents() {
|
|||||||
$('btn-exit-confirm-yes').addEventListener('click', () => {
|
$('btn-exit-confirm-yes').addEventListener('click', () => {
|
||||||
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
||||||
clearSession();
|
clearSession();
|
||||||
|
clearGameState();
|
||||||
hide('overlay-exit-confirm');
|
hide('overlay-exit-confirm');
|
||||||
hide('overlay-hand');
|
hide('overlay-hand');
|
||||||
hide('overlay-gameover');
|
hide('overlay-gameover');
|
||||||
@@ -1432,6 +1495,15 @@ function wireGameEvents() {
|
|||||||
} catch { $('change-pass-msg').textContent = 'Network error'; }
|
} 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 () => {
|
$('btn-toggle-signups').addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/admin/toggle-signups', {
|
const r = await fetch('/api/admin/toggle-signups', {
|
||||||
|
|||||||
@@ -166,6 +166,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 banner -->
|
||||||
|
<div id="afk-banner" class="afk-banner hidden">
|
||||||
|
<span id="afk-banner-msg"></span>
|
||||||
|
<button id="afk-vote-btn" class="btn-text" style="color:var(--gold);font-weight:700">Let AI Play</button>
|
||||||
|
<button id="afk-dismiss-btn" class="btn-text">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">
|
||||||
|
|
||||||
|
|||||||
+62
-1
@@ -338,13 +338,17 @@ input:focus { border-color: var(--gold); }
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.team-score {
|
.team-score {
|
||||||
font-size: .78rem;
|
font-size: .78rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
border-radius: 20px;
|
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-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); }
|
.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);
|
background: rgba(245,197,24,.2);
|
||||||
border: 1px solid rgba(245,197,24,.4);
|
border: 1px solid rgba(245,197,24,.4);
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
|
white-space: pre;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.bid-display {
|
.bid-display {
|
||||||
font-size: .75rem;
|
font-size: .75rem;
|
||||||
@@ -610,6 +617,26 @@ input:focus { border-color: var(--gold); }
|
|||||||
from { opacity: .7; }
|
from { opacity: .7; }
|
||||||
to { opacity: 1; }
|
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 */
|
/* Drop-zone pulse when dragging */
|
||||||
#trick-area { position: relative; }
|
#trick-area { position: relative; }
|
||||||
@@ -1026,6 +1053,40 @@ input:focus { border-color: var(--gold); }
|
|||||||
flex-shrink: 0;
|
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 ─────────────────────────────────────── */
|
/* ── Util ─────────────────────────────────────── */
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -394,6 +394,13 @@ function newRoom(id) {
|
|||||||
winScore: 505,
|
winScore: 505,
|
||||||
spectators: new Set(),
|
spectators: new Set(),
|
||||||
trickTimer: null,
|
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 ──────────────────────────────────────────────────
|
// ─── Bidding ──────────────────────────────────────────────────
|
||||||
function startBidding(room) {
|
function startBidding(room) {
|
||||||
|
clearAfkTimer(room);
|
||||||
|
for (const seat of room.aiControlledSeats) broadcastAiControl(room, seat, false);
|
||||||
|
room.aiControlledSeats.clear();
|
||||||
room.state = 'BIDDING';
|
room.state = 'BIDDING';
|
||||||
// Bidding starts at right of dealer (counter-clockwise first seat)
|
// Bidding starts at right of dealer (counter-clockwise first seat)
|
||||||
room.currentBidder = (room.dealer + 3) % 4;
|
room.currentBidder = (room.dealer + 3) % 4;
|
||||||
@@ -577,10 +587,13 @@ function startPlaying(room) {
|
|||||||
room.currentTurn = room.declarer;
|
room.currentTurn = room.declarer;
|
||||||
room.trick = [];
|
room.trick = [];
|
||||||
broadcastState(room, 'roomInfo');
|
broadcastState(room, 'roomInfo');
|
||||||
|
scheduleAfkTimer(room);
|
||||||
scheduleBotPlay(room);
|
scheduleBotPlay(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCardPlayed(room, player, card) {
|
function onCardPlayed(room, player, card) {
|
||||||
|
clearAfkTimer(room);
|
||||||
|
|
||||||
// First card played by the declarer sets trump (jokers excluded by legalCards)
|
// First card played by the declarer sets trump (jokers excluded by legalCards)
|
||||||
if (room.trump === null) room.trump = suitOf(card);
|
if (room.trump === null) room.trump = suitOf(card);
|
||||||
|
|
||||||
@@ -590,6 +603,7 @@ function onCardPlayed(room, player, card) {
|
|||||||
if (room.trick.length < 4) {
|
if (room.trick.length < 4) {
|
||||||
room.currentTurn = (player + 3) % 4; // anti-clockwise: next player is to the right
|
room.currentTurn = (player + 3) % 4; // anti-clockwise: next player is to the right
|
||||||
broadcastState(room, 'cardPlayed');
|
broadcastState(room, 'cardPlayed');
|
||||||
|
scheduleAfkTimer(room);
|
||||||
scheduleBotPlay(room);
|
scheduleBotPlay(room);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -616,6 +630,7 @@ function onCardPlayed(room, player, card) {
|
|||||||
room.currentTurn = winner;
|
room.currentTurn = winner;
|
||||||
room.trick = [];
|
room.trick = [];
|
||||||
broadcastState(room, 'roomInfo');
|
broadcastState(room, 'roomInfo');
|
||||||
|
scheduleAfkTimer(room);
|
||||||
scheduleBotPlay(room);
|
scheduleBotPlay(room);
|
||||||
}, 1400);
|
}, 1400);
|
||||||
}
|
}
|
||||||
@@ -623,6 +638,7 @@ function onCardPlayed(room, player, card) {
|
|||||||
|
|
||||||
// ─── Hand scoring ─────────────────────────────────────────────
|
// ─── Hand scoring ─────────────────────────────────────────────
|
||||||
function finishHand(room) {
|
function finishHand(room) {
|
||||||
|
clearAfkTimer(room);
|
||||||
const dTeam = teamOf(room.declarer);
|
const dTeam = teamOf(room.declarer);
|
||||||
const oTeam = 1 - dTeam;
|
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 ────────────────────────────────────────────────
|
// ─── Game init ────────────────────────────────────────────────
|
||||||
function tryStartGame(room) {
|
function tryStartGame(room) {
|
||||||
const filled = room.seats.map((s, i) => !!s || room.bots[i]);
|
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.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 (!legalCards(room, seat).includes(card)) return socket.emit('playError', 'Illegal card');
|
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);
|
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 ──────────────────────────────────────────────────
|
// ── 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());
|
||||||
@@ -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 ────────────────────────────────────────────────────
|
// ─── Start ────────────────────────────────────────────────────
|
||||||
httpServer.listen(HTTP_PORT, () =>
|
httpServer.listen(HTTP_PORT, () =>
|
||||||
console.log(`Shelem HTTP → http://localhost:${HTTP_PORT}`)
|
console.log(`Shelem HTTP → http://localhost:${HTTP_PORT}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user