Fully containerized
This commit is contained in:
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user