commit 6e55209159843526c6aa8f0993cc4c89751aa558 Author: Cam Spry Date: Sun Mar 1 04:47:14 2026 -0500 Initial commit diff --git a/package.json b/package.json new file mode 100644 index 0000000..2992b84 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "yarn", + "version": "1.0.0", + "description": "", + "main": "server.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node server.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "cookie-parser": "^1.4.7", + "express": "^5.2.1", + "socket.io": "^4.8.3", + "uuid": "^13.0.0" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..9693a3f --- /dev/null +++ b/public/index.html @@ -0,0 +1,837 @@ + + + + + + YARN 2026 – Spin Your Story! + + + + +
+ + + +
+ Waiting for the game to begin... +
+ +
+ +
+ +
+ +
+
Players
+
+ +
+
+ +
+
+ +
+ + +
+ +
+ +
+ + + + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..f85ef00 --- /dev/null +++ b/server.js @@ -0,0 +1,488 @@ +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`); +});