yarn-ng/server.js

489 lines
13 KiB
JavaScript

const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const path = require('path');
const cookieParser = require('cookie-parser');
//const { v4: uuidv4 } = require('uuid');
const { randomUUID } = require('node:crypto');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
// Store all game rooms
const games = {};
// YarnGame class
class YarnGame {
constructor(roomName) {
this.roomName = roomName;
this.inProgress = false;
this.gameHost = null;
this.gameSettings = {
scoreLimit: 25,
timeLimit: 60
};
this.players = []; // { player_id, name, score, socketId }
this.yarnStory = []; // { player: "", str: "" }
this.chat = []; // { id, name, msg }
this.round_data = []; // { entry_text, player_id, votes: [] }
this.currentPhase = 'lobby'; // lobby, writing, voting, gameOver
this.timer = null;
this.timeRemaining = 0;
this.submittedPlayers = new Set();
this.votedPlayers = new Set();
}
addPlayer(playerId, name, socketId) {
const existingPlayer = this.players.find(p => p.player_id === playerId);
if (existingPlayer) {
existingPlayer.socketId = socketId;
existingPlayer.name = name;
return existingPlayer;
}
const player = {
player_id: playerId,
name: name,
score: 0,
socketId: socketId
};
this.players.push(player);
// First player becomes host
if (this.players.length === 1) {
this.gameHost = playerId;
}
return player;
}
removePlayer(playerId) {
const index = this.players.findIndex(p => p.player_id === playerId);
if (index !== -1) {
this.players.splice(index, 1);
// Assign new host if host left
if (this.gameHost === playerId && this.players.length > 0) {
this.gameHost = this.players[0].player_id;
}
}
return this.players.length;
}
getPlayer(playerId) {
return this.players.find(p => p.player_id === playerId);
}
getPlayerBySocket(socketId) {
return this.players.find(p => p.socketId === socketId);
}
updatePlayerName(playerId, name) {
const player = this.getPlayer(playerId);
if (player) {
player.name = name;
}
}
startGame(settings) {
this.gameSettings = { ...this.gameSettings, ...settings };
this.inProgress = true;
this.yarnStory = [{ player: "Narrator", str: "It was a dark and stormy night..." }];
this.players.forEach(p => p.score = 0);
this.startWritingPhase();
}
startWritingPhase() {
this.currentPhase = 'writing';
this.round_data = [];
this.submittedPlayers = new Set();
this.timeRemaining = this.gameSettings.timeLimit;
}
submitEntry(playerId, entryText) {
if (this.currentPhase !== 'writing') return false;
if (this.submittedPlayers.has(playerId)) return false;
this.round_data.push({
entry_text: entryText.trim(),
player_id: playerId,
votes: []
});
this.submittedPlayers.add(playerId);
return true;
}
allPlayersSubmitted() {
return this.submittedPlayers.size >= this.players.length;
}
startVotingPhase() {
this.currentPhase = 'voting';
this.votedPlayers = new Set();
this.timeRemaining = 30; // 30 seconds for voting
}
submitVote(playerId, entryIndex) {
if (this.currentPhase !== 'voting') return false;
if (this.votedPlayers.has(playerId)) return false;
const entry = this.round_data[entryIndex];
// Can't vote for own entry
//if (!entry || entry.player_id === playerId) return false;
entry.votes.push(playerId);
this.votedPlayers.add(playerId);
return true;
}
allPlayersVoted() {
return this.votedPlayers.size >= this.players.length;
}
tallyVotes() {
if (this.round_data.length === 0) return null;
// Find winner (most votes)
let winner = this.round_data[0];
for (const entry of this.round_data) {
if (entry.votes.length > winner.votes.length) {
winner = entry;
}
}
// Award points
const winnerPlayer = this.getPlayer(winner.player_id);
if (winnerPlayer) {
winnerPlayer.score += 5 + winner.votes.length;
}
// Add to story
if (winner.entry_text) {
this.yarnStory.push({
player: winnerPlayer ? winnerPlayer.name : "Unknown",
str: winner.entry_text
});
}
return winner;
}
checkGameOver() {
const winner = this.players.find(p => p.score >= this.gameSettings.scoreLimit);
if (winner) {
this.currentPhase = 'gameOver';
this.inProgress = false;
return winner;
}
return null;
}
resetGame() {
this.inProgress = false;
this.currentPhase = 'lobby';
this.round_data = [];
this.yarnStory = [];
this.submittedPlayers = new Set();
this.votedPlayers = new Set();
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
addChatMessage(playerId, msg) {
const player = this.getPlayer(playerId);
const chatMsg = {
id: playerId,
name: player ? player.name : 'Unknown',
msg: msg
};
this.chat.push(chatMsg);
// Keep last 100 messages
if (this.chat.length > 100) {
this.chat.shift();
}
return chatMsg;
}
getState() {
return {
roomName: this.roomName,
inProgress: this.inProgress,
gameHost: this.gameHost,
gameSettings: this.gameSettings,
players: this.players.map(p => ({
player_id: p.player_id,
name: p.name,
score: p.score,
hasSubmitted: this.submittedPlayers.has(p.player_id),
hasVoted: this.votedPlayers.has(p.player_id)
})),
yarnStory: this.yarnStory,
currentPhase: this.currentPhase,
timeRemaining: this.timeRemaining,
round_data: this.currentPhase === 'voting' ? this.round_data.map((e, i) => ({
index: i,
entry_text: e.entry_text,
voteCount: e.votes.length
})) : []
};
}
}
// Get or create a game room
function getOrCreateGame(roomName) {
if (!games[roomName]) {
games[roomName] = new YarnGame(roomName);
}
return games[roomName];
}
// Route for game rooms
app.get('/:roomName', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Socket.IO connection handling
io.on('connection', (socket) => {
let currentRoom = null;
let playerId = null;
socket.on('joinRoom', (data) => {
const { roomName, playerUUID, playerName } = data;
currentRoom = roomName;
playerId = playerUUID;
socket.join(roomName);
const game = getOrCreateGame(roomName);
if (playerName) {
game.addPlayer(playerId, playerName, socket.id);
}
// Send current game state to the joining player
socket.emit('gameState', game.getState());
socket.emit('chatHistory', game.chat);
// Notify others
if (playerName) {
io.to(roomName).emit('playerJoined', {
player_id: playerId,
name: playerName
});
io.to(roomName).emit('playersUpdate', game.getState().players);
}
});
socket.on('setName', (data) => {
const { name } = data;
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game) return;
game.addPlayer(playerId, name, socket.id);
socket.emit('nameSet', { success: true, name });
io.to(currentRoom).emit('playersUpdate', game.getState().players);
io.to(currentRoom).emit('gameState', game.getState());
});
socket.on('updateSettings', (data) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game || game.gameHost !== playerId) return;
game.gameSettings = { ...game.gameSettings, ...data };
io.to(currentRoom).emit('settingsUpdate', game.gameSettings);
});
socket.on('startGame', (settings) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game || game.gameHost !== playerId) return;
if (game.players.length < 2) {
socket.emit('error', { message: 'Need at least 2 players to start' });
return;
}
game.startGame(settings);
io.to(currentRoom).emit('gameStarted', game.getState());
startTimer(game);
});
socket.on('submitEntry', (data) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game) return;
const success = game.submitEntry(playerId, data.entry);
if (success) {
io.to(currentRoom).emit('playersUpdate', game.getState().players);
if (game.allPlayersSubmitted()) {
clearInterval(game.timer);
game.startVotingPhase();
io.to(currentRoom).emit('votingPhase', {
entries: game.round_data.map((e, i) => ({
index: i,
entry_text: e.entry_text
})),
timeRemaining: game.timeRemaining
});
startVotingTimer(game);
}
}
});
socket.on('submitVote', (data) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game) return;
const success = game.submitVote(playerId, data.entryIndex);
if (success) {
io.to(currentRoom).emit('playersUpdate', game.getState().players);
if (game.allPlayersVoted()) {
clearInterval(game.timer);
endVotingPhase(game);
}
}
});
socket.on('chatMessage', (data) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game) return;
const chatMsg = game.addChatMessage(playerId, data.msg);
io.to(currentRoom).emit('newChatMessage', chatMsg);
});
socket.on('playAgain', (settings) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game || game.gameHost !== playerId) return;
game.resetGame();
io.to(currentRoom).emit('gameReset', game.getState());
});
socket.on('disconnect', () => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game) return;
const player = game.getPlayer(playerId);
const playerName = player ? player.name : 'Unknown';
const remaining = game.removePlayer(playerId);
io.to(currentRoom).emit('playerLeft', { player_id: playerId, name: playerName });
io.to(currentRoom).emit('playersUpdate', game.getState().players);
io.to(currentRoom).emit('gameState', game.getState());
// Clean up empty rooms
if (remaining === 0) {
if (game.timer) clearInterval(game.timer);
delete games[currentRoom];
}
});
});
function startTimer(game) {
if (game.timer) clearInterval(game.timer);
game.timer = setInterval(() => {
game.timeRemaining--;
io.to(game.roomName).emit('timerUpdate', game.timeRemaining);
if (game.timeRemaining <= 0) {
clearInterval(game.timer);
// Force submit empty entries for players who haven't submitted
for (const player of game.players) {
if (!game.submittedPlayers.has(player.player_id)) {
game.submitEntry(player.player_id, "");
}
}
game.startVotingPhase();
io.to(game.roomName).emit('votingPhase', {
entries: game.round_data.map((e, i) => ({
index: i,
entry_text: e.entry_text
})),
timeRemaining: game.timeRemaining
});
startVotingTimer(game);
}
}, 1000);
}
function startVotingTimer(game) {
if (game.timer) clearInterval(game.timer);
game.timer = setInterval(() => {
game.timeRemaining--;
io.to(game.roomName).emit('timerUpdate', game.timeRemaining);
if (game.timeRemaining <= 0) {
clearInterval(game.timer);
endVotingPhase(game);
}
}, 1000);
}
function endVotingPhase(game) {
const winner = game.tallyVotes();
io.to(game.roomName).emit('roundResults', {
winner: winner,
players: game.getState().players,
story: game.yarnStory
});
// Check for game over
const gameWinner = game.checkGameOver();
if (gameWinner) {
io.to(game.roomName).emit('gameOver', {
winner: gameWinner,
players: game.getState().players,
story: game.yarnStory
});
} else {
// Start next round after a short delay
setTimeout(() => {
game.startWritingPhase();
io.to(game.roomName).emit('newRound', game.getState());
startTimer(game);
}, 5000);
}
}
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`YARN server running on http://localhost:${PORT}`);
console.log(`Join a room at http://localhost:${PORT}/game101`);
});