Fully containerized

This commit is contained in:
goyban
2026-05-24 15:59:24 +00:00
parent a4fefd92f1
commit e0b9dde93e
8 changed files with 326 additions and 18 deletions
+89 -17
View File
@@ -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', {
+12
View File
@@ -166,6 +166,18 @@
<!-- Spectator banner -->
<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 -->
<div id="table-grid">
+62 -1
View File
@@ -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; }