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
+119
View File
@@ -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}`)