589 lines
16 KiB
JavaScript
589 lines
16 KiB
JavaScript
const express = require('express');
|
|
const http = require('http');
|
|
const { Server } = require('socket.io');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const cookieParser = require('cookie-parser');
|
|
//const { v4: uuidv4 } = require('uuid');
|
|
const { randomUUID } = require('node:crypto');
|
|
const BotFactory = require('./bots/BotFactory');
|
|
|
|
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: 50,
|
|
timeLimit: 60,
|
|
botCount: 0,
|
|
startText: "It was a dark and stormy night..."
|
|
};
|
|
this.bots = [];
|
|
this.players = []; // { player_id, name, score, socketId }
|
|
this.disconnectedPlayers = []; // So people can keep their score when they reconnect
|
|
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;
|
|
console.log(`Existing player ${name} reconnected to game ${this.roomName}.`)
|
|
return existingPlayer;
|
|
} else {
|
|
console.log(`New player ${name} joined game ${this.roomName}.`)
|
|
}
|
|
|
|
const existingDisconnectedPlayer = this.disconnectedPlayers.find(p => p.player_id === playerId);
|
|
if (existingDisconnectedPlayer) {
|
|
existingDisconnectedPlayer.socketId = socketId;
|
|
existingDisconnectedPlayer.name = name;
|
|
this.players.push(existingDisconnectedPlayer);
|
|
console.log(`Existing recently-disconnected player ${name} reconnected to game ${this.roomName}.`)
|
|
return existingDisconnectedPlayer;
|
|
}
|
|
|
|
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) {
|
|
console.log(`Player ${this.players[index].name} dropped.`)
|
|
const removed = this.players.splice(index, 1);
|
|
this.disconnectedPlayers.push(removed[0]);
|
|
|
|
// Assign new host if host left
|
|
if (this.gameHost === playerId && this.getHumanPlayers().length > 0) {
|
|
const human_players = this.getHumanPlayers();
|
|
this.gameHost = human_players[0].player_id;
|
|
}
|
|
}
|
|
return this.players.length;
|
|
}
|
|
|
|
getHumanPlayers() {
|
|
return this.players.filter(p => !this.bots.some(b => b.bot_id === p.player_id));
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
startBots(botCount) {
|
|
console.log("Creating bots...")
|
|
for (let i = 0; i < botCount; i++) {
|
|
const bot_id = randomUUID()
|
|
const bot = BotFactory.createBot("llm");
|
|
this.bots.push({ bot_id, bot });
|
|
const bot_player = {
|
|
player_id: bot_id,
|
|
name: BotFactory.generateBotName(),
|
|
score: 0,
|
|
socketId: null
|
|
};
|
|
this.players.push(bot_player);
|
|
console.log(`Created bot with UUID ${bot_id}`)
|
|
}
|
|
}
|
|
|
|
async startGame(settings) {
|
|
this.gameSettings = { ...this.gameSettings, ...settings };
|
|
this.startBots(this.gameSettings.botCount);
|
|
this.inProgress = true;
|
|
this.yarnStory = [{ player: "Narrator", str: this.gameSettings.startText }];
|
|
this.players.forEach(p => p.score = 0);
|
|
await this.startWritingPhase();
|
|
}
|
|
|
|
async processBotWriting() {
|
|
for (const botObj of this.bots) {
|
|
const botText = await botObj.bot.Write(this.getStoryText());
|
|
console.log(`Bot text: ${botText}`)
|
|
this.submitEntry(botObj.bot_id, botText)
|
|
}
|
|
}
|
|
|
|
async startWritingPhase() {
|
|
this.currentPhase = 'writing';
|
|
this.round_data = [];
|
|
this.submittedPlayers = new Set();
|
|
this.timeRemaining = this.gameSettings.timeLimit;
|
|
this.processBotWriting()
|
|
}
|
|
|
|
getStoryText() {
|
|
return this.yarnStory.map(entry => entry.str).join('');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async processBotVoting() {
|
|
for (const botObj of this.bots) {
|
|
const botVote = await botObj.bot.Vote(this.getStoryText(), this.round_data);
|
|
this.submitVote(botObj.bot_id, botVote)
|
|
}
|
|
}
|
|
|
|
async startVotingPhase() {
|
|
this.currentPhase = 'voting';
|
|
this.votedPlayers = new Set();
|
|
this.timeRemaining = 30; // 30 seconds for voting
|
|
await this.processBotVoting()
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
purgeBots() {
|
|
for (const botObj of this.bots) {
|
|
this.removePlayer(botObj.bot_id)
|
|
}
|
|
this.bots = [];
|
|
}
|
|
|
|
resetGame() {
|
|
this.inProgress = false;
|
|
this.currentPhase = 'lobby';
|
|
this.purgeBots();
|
|
this.round_data = [];
|
|
this.yarnStory = [];
|
|
this.submittedPlayers = new Set();
|
|
this.votedPlayers = new Set();
|
|
if (this.timer) {
|
|
clearInterval(this.timer);
|
|
this.timer = null;
|
|
}
|
|
}
|
|
|
|
saveStory() {
|
|
const story_text = this.getStoryText();
|
|
const now = new Date();
|
|
const dateStamp = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
const timeStamp = now.toTimeString().slice(0, 8).replace(/:/g, '-'); // HH-MM-SS
|
|
const fileName = `${this.roomName} - ${dateStamp} ${timeStamp}.txt`;
|
|
const filePath = path.join('stories', fileName);
|
|
|
|
fs.mkdirSync('stories', { recursive: true }); // ensure dir exists
|
|
fs.writeFileSync(filePath, story_text, 'utf8');
|
|
}
|
|
|
|
botBanter() {
|
|
for (const botObj of this.bots) {
|
|
if (Math.random() > 0.8) {
|
|
console.log("Bot bantering")
|
|
const botBanter = botObj.bot.Banter(this.getStoryText());
|
|
console.log(botBanter)
|
|
const chatMsg = this.addChatMessage(botObj.bot_id, botBanter)
|
|
io.to(this.roomName).emit('newChatMessage', chatMsg); //I don't like using io calls here but chat is weird
|
|
}
|
|
}
|
|
}
|
|
|
|
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]) {
|
|
console.log(`Creating new room with name ${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', async (settings) => {
|
|
if (!currentRoom || !playerId) return;
|
|
|
|
const game = games[currentRoom];
|
|
if (!game || game.gameHost !== playerId) return;
|
|
if (game.players.length < 2 && settings.botCount == 0) {
|
|
socket.emit('error', { message: 'Need at least 2 players to start' });
|
|
return;
|
|
}
|
|
|
|
console.log(`Starting new game with settings ${JSON.stringify(settings)}`)
|
|
await game.startGame(settings);
|
|
io.to(currentRoom).emit('gameStarted', game.getState());
|
|
|
|
await startTimer(game);
|
|
});
|
|
|
|
socket.on('submitEntry', async (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);
|
|
await 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.saveStory();
|
|
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());
|
|
|
|
if (remaining === 0 || game.bots.length === game.players.length) {
|
|
// Clean up empty rooms
|
|
console.log(`Removing empty room ${currentRoom}`)
|
|
if (game.timer) clearInterval(game.timer);
|
|
delete games[currentRoom];
|
|
}
|
|
});
|
|
});
|
|
|
|
async function startTimer(game) {
|
|
if (game.timer) clearInterval(game.timer);
|
|
|
|
game.timer = setInterval(async () => {
|
|
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, "");
|
|
}
|
|
}
|
|
|
|
await 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(async () => {
|
|
game.startWritingPhase();
|
|
io.to(game.roomName).emit('newRound', game.getState());
|
|
await 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`);
|
|
});
|