diff --git a/README.md b/README.md index e9e7e2af..b1ac8a6f 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,25 @@ A horizontal bar chart ranking each model by mean words per message. Identifies --- +## Web Interface + +A web-based interface is now available that allows you to run the game with any LLM from the Pollinations.ai API. Features include: + +- Visual game board showing player positions +- Real-time conversation display +- Character type selection (strategist, diplomat, trickster, etc.) +- Auto-advance mode for continuous play +- Win tracking and scoreboard + +To run the web interface: +1. Open `index.html` in your browser +2. Select 3 LLM players and their character types +3. Click "Start Game" to begin + +The game uses the free Pollinations.ai API to simulate LLM responses. + +--- + ## Method Summary 1. **Players & Setup** diff --git a/index.html b/index.html new file mode 100644 index 00000000..e7d6785c --- /dev/null +++ b/index.html @@ -0,0 +1,92 @@ + + + + + + Pump and Dump Game + + + +
+

Pump and Dump Game

+ +
+

Select 3 LLMs to compete:

+
+
+ + + +
+ + +
+
+
+ + + +
+ + +
+
+
+ + + +
+ + +
+
+
+ +
+ +
+
Game starting...
+
+

Win Count

+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+

Last Thoughts

+
+
+
+
+ + + + + + + diff --git a/js/api.js b/js/api.js new file mode 100644 index 00000000..85f360ec --- /dev/null +++ b/js/api.js @@ -0,0 +1,190 @@ +// const API_ENDPOINT = 'https://text.pollinations.ai'; + +const API_ENDPOINT = 'http://localhost:16385'; + +function removeThink(text, playerIdx, playerName) { + // Check for content after last + const thinkMatch = text.split(''); + if (thinkMatch.length > 1) { + // Get everything before the last tag + const thinkContent = thinkMatch.slice(0, -1).join(''); + console.log('%cThink content:', 'color: blue', thinkContent); + + // Update the thoughts display + const thoughtsDiv = document.querySelector('#player-thoughts .thoughts'); + const playerThoughtId = `player-${playerIdx + 1}-thought`; + let playerThought = document.getElementById(playerThoughtId); + + if (!playerThought) { + playerThought = document.createElement('div'); + playerThought.id = playerThoughtId; + playerThought.className = `thought player-${playerIdx + 1}-thought`; + thoughtsDiv.appendChild(playerThought); + } + + if (thinkContent) { + playerThought.innerHTML = `${playerName}: ${thinkContent}`; + } + + // Return everything after the last tag + return thinkMatch[thinkMatch.length - 1].trim(); + } + return text; +} + +async function fetchApiResponse(url, model, messages) { + const maxRetries = 3; + const initialDelay = 1000; // Initial delay of 1 second + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + // Calculate exponential backoff delay + const backoffDelay = initialDelay * Math.pow(2, attempt); + await delay(backoffDelay); + + const response = await fetch(url+'/openai', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + messages, + temperature: 0.5, + seed: Math.floor(Math.random() * 1000000), // Generate a random seed for each request, between 0 and 999999Math.random() + referrer: "roblox" + }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response; + } catch (error) { + if (attempt === maxRetries - 1) { + // If this was the last attempt, throw the error + throw new Error(`Failed after ${maxRetries} attempts: ${error.message}`); + } + console.log(`Attempt ${attempt + 1} failed, retrying... Error: ${error.message}`); + } + } +} + +async function getConversationResponse(playerIdx, gameState, getPlayerDisplayName) { + const playerNames = gameState.players.map(getPlayerDisplayName); + + try { + const [commonPrompt, conversationPrompt] = await Promise.all([ + loadPrompt('common'), + loadPrompt('conversation') + ]); + + const fullTemplate = commonPrompt + '\n\n' + conversationPrompt; + const prompt = replaceTemplateVariables(fullTemplate, playerNames, playerIdx, gameState); + + console.log('[API Request]', `Getting conversation response for ${getPlayerDisplayName(gameState.players[playerIdx])}`, { model: gameState.players[playerIdx].model, prompt, seed: Math.random() }); + + try { + const response = await fetchApiResponse(API_ENDPOINT, gameState.players[playerIdx].model, [ + { role: 'user', content: prompt } + ]); + if (!response.ok) { + const errorText = await response.text(); + console.log('[API Error]', `Error getting conversation response for ${getPlayerDisplayName(gameState.players[playerIdx])}`, errorText); + return ''; + } + const result = await response.json(); + console.log('[API Debug]', `Raw API response:`, result); + + // Add null check for content + const content = result?.choices?.[0]?.message?.content; + if (!content) { + console.log('[API Error]', `No content in response for ${getPlayerDisplayName(gameState.players[playerIdx])}`, result); + return ''; + } + + const trimmedContent = removeThink(content.trim(), playerIdx, getPlayerDisplayName(gameState.players[playerIdx])); + console.log('[API Response]', `Got conversation response for ${getPlayerDisplayName(gameState.players[playerIdx])}`, { + originalContent: content, + trimmedContent + }); + return trimmedContent; + } catch (error) { + console.log('[API Error]', `Exception getting conversation response for ${getPlayerDisplayName(gameState.players[playerIdx])}`, error); + return ''; + } + } catch (error) { + console.log('[API Error]', `Exception getting conversation response for ${getPlayerDisplayName(gameState.players[playerIdx])}`, error); + return ''; + } +} + +async function getMoveResponse(playerIdx, gameState, getPlayerDisplayName) { + const playerNames = gameState.players.map(getPlayerDisplayName); + + try { + const [commonPrompt, movePrompt] = await Promise.all([ + loadPrompt('common'), + loadPrompt('move') + ]); + + const fullTemplate = commonPrompt + '\n\n' + movePrompt; + const prompt = replaceTemplateVariables(fullTemplate, playerNames, playerIdx, gameState); + + console.log('[API Request]', `Getting move response for ${getPlayerDisplayName(gameState.players[playerIdx])}`, { model: gameState.players[playerIdx].model, prompt, seed: Math.random() }); + + try { + const response = await fetchApiResponse(API_ENDPOINT, gameState.players[playerIdx].model, [ + { role: 'user', content: prompt } + ]); + if (!response.ok) { + const errorText = await response.text(); + console.log('[API Error]', `Error getting move response for ${getPlayerDisplayName(gameState.players[playerIdx])}`, errorText); + return null; + } + const result = await response.json(); + + // Add null check for content + const content = result?.choices?.[0]?.message?.content; + if (!content) { + console.log('[API Error]', `No content in response for ${getPlayerDisplayName(gameState.players[playerIdx])}`, result); + return null; + } + + const moveText = removeThink(content.trim(), playerIdx, getPlayerDisplayName(gameState.players[playerIdx])); + + console.log('[API Response]', `Got move response for ${getPlayerDisplayName(gameState.players[playerIdx])}`, { rawResponse: moveText }); + return moveText; + } catch (error) { + console.log('[API Error]', `Exception getting move response for ${getPlayerDisplayName(gameState.players[playerIdx])}`, error); + return null; + } + } catch (error) { + console.log('[API Error]', `Exception getting move response for ${getPlayerDisplayName(gameState.players[playerIdx])}`, error); + return null; + } +} + +async function fetchModels() { + return fetch(`${API_ENDPOINT}/models`) + .then(response => response.json()); +} + +function replaceTemplateVariables(template, playerNames, playerIdx, gameState) { + const characterType = gameState.players[playerIdx].characterType; + const result = template + .replace(/{{PLAYER_NAME}}/g, playerNames[playerIdx]) + .replace(/{{PLAYERS_LIST}}/g, playerNames.join(', ')) + .replace(/{{CHARACTER_TYPE}}/g, characterType) + .replace(/{{CHARACTER_DESCRIPTION}}/g, CHARACTER_TYPES[characterType]) + .replace(/{{MAX_SUB_ROUND}}/g, MAX_SUB_ROUNDS) + .replace(/{{WIN_STEPS}}/g, FINISH_LINE) + .replace(/{{CONVERSATION_HISTORY}}/g, gameState.conversation.join('\n')) + .replace(/{{GAME_STATE}}/g, `Scores: ${gameState.scores.map((score, idx) => + `${playerNames[idx]}: ${score}`).join(', ')}`); + + // Add reasoning rules if enabled for this player + if (gameState.reasoningEnabled[playerIdx]) { + return result + '\n' + (promptCache.reasoning || ''); + } + return result; +} diff --git a/js/constants.js b/js/constants.js new file mode 100644 index 00000000..ac0430bf --- /dev/null +++ b/js/constants.js @@ -0,0 +1,40 @@ +// Game constants +const FINISH_LINE = 5; // Reduced to make games shorter +const MAX_SUB_ROUNDS = 2; +const DEFAULT_CHARACTER_TYPE = 'competitive'; + +const CHARACTER_TYPES = { + 'diplomat': 'Negotiates, starts friendly, pushes back if needed', + 'strategist': 'Plans carefully, adapts strategy based on results', + 'cautious': 'Prefers to pump unless threatened', + 'aggressive': 'More likely to dump for higher gains', + 'trickster': 'Unpredictable, keeps others guessing', + 'competitive': 'You want to win above all. Ties or losses are inconceivable.', + 'selective-ally': 'You choose one ally to trust and coordinate with to make the other player lose. Communicate at the start of the round which ally you want to cooperate with and make sure they agree.', +}; + +const CHARACTER_NAMES = { + 'default': [ + 'Emma', 'Frank', 'Grace', 'Henry', 'Ivy', + 'Jack', 'Kate', 'Liam', 'Mia', 'Noah' + ] +}; + +const PAYOFFS = { + ALL_PUMP: { + pumpers: 1, + dumpers: 0 + }, + LONE_DUMPER: { + dumpers: 2, + pumpers: 0 + }, + TWO_DUMPERS: { + dumpers: 0, + pumpers: 1.5 + }, + ALL_DUMP: { + dumpers: 0.5, + pumpers: 0 + } +}; diff --git a/js/dom.js b/js/dom.js new file mode 100644 index 00000000..7cd9f2ee --- /dev/null +++ b/js/dom.js @@ -0,0 +1,140 @@ +// DOM manipulation functions + +function updateScoreboard(scores, players, getPlayerDisplayName, playerStats) { + const scoresDiv = document.querySelector('#scores'); + scoresDiv.innerHTML = ''; + scores.forEach((score, idx) => { + const playerName = getPlayerDisplayName(players[idx]); + const stats = playerStats[idx] || { roundWins: 0 }; + const div = document.createElement('div'); + div.className = `score player-${idx + 1}-score`; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'player-name'; + nameSpan.textContent = playerName; + + const infoSpan = document.createElement('span'); + infoSpan.className = 'player-info'; + infoSpan.textContent = `${players[idx].model} - ${players[idx].characterType}`; + + const winSpan = document.createElement('span'); + winSpan.className = 'win-count'; + winSpan.textContent = `${stats.roundWins} Won`; + + div.appendChild(nameSpan); + div.appendChild(infoSpan); + div.appendChild(winSpan); + + scoresDiv.appendChild(div); + + // Update player marker position + const marker = document.getElementById(`player${idx + 1}-marker`); + if (marker) { + const position = (score / FINISH_LINE) * 100; + marker.style.left = `${Math.min(position, 100)}%`; + } + }); +} + +function addMessage(playerIdx, message, players, getPlayerDisplayName) { + const conv = document.getElementById('conversation'); + const div = document.createElement('div'); + div.className = `message player-${playerIdx + 1}-msg`; + // Handle system messages (playerIdx = -1) + const playerName = playerIdx === -1 ? 'System' : getPlayerDisplayName(players[playerIdx]); + div.innerHTML = `${playerName}: ${message}`; + conv.appendChild(div); + conv.scrollTop = conv.scrollHeight; +} + +function updatePlayerLabels(players, getPlayerDisplayName, gameState) { + players.forEach((player, idx) => { + const labelDiv = document.getElementById(`track-label-${idx + 1}`); + if (labelDiv) { + const score = gameState.scores[idx]; + labelDiv.textContent = `${getPlayerDisplayName(player)} (${score} pts)`; + } + }); +} + +function setGameVisibility(visible) { + document.getElementById('setup').style.display = visible ? 'none' : 'block'; + document.getElementById('game').style.display = visible ? 'block' : 'none'; +} + +function clearConversation() { + document.getElementById('conversation').innerHTML = ''; +} + +function updateGameStatus(message) { + document.getElementById('status').textContent = message; +} + +function setNextButtonState(enabled) { + const nextButton = document.getElementById('nextButton'); + nextButton.disabled = !enabled; + nextButton.style.display = enabled ? '' : 'none'; +} + +function setAutoAdvanceState(enabled) { + document.getElementById('autoAdvance').checked = enabled; +} + +function getPlayerSelections() { + const selections = []; + for (let i = 1; i <= 3; i++) { + selections.push({ + model: document.getElementById(`player${i}`).value, + characterType: document.getElementById(`character${i}`).value + }); + } + return selections; +} + +function initializeModelSelects(models) { + const selects = [ + document.getElementById('player1'), + document.getElementById('player2'), + document.getElementById('player3') + ]; + + const options = models.map((model, index) => + `` + ).join(''); + + selects.forEach(select => { + if (select) { + select.innerHTML = options; + } + }); +} + +function initializeCharacterSelects(characterTypes, defaultType) { + const characterSelects = [ + document.getElementById('character1'), + document.getElementById('character2'), + document.getElementById('character3') + ]; + + const characterOptions = Object.entries(characterTypes) + .map(([type, desc]) => ``) + .join(''); + + characterSelects.forEach(select => { + if (select) { + select.innerHTML = characterOptions; + } + }); +} + +// Initialize when page loads +document.addEventListener('DOMContentLoaded', function() { + fetchModels() + .then(models => { + initializeModelSelects(models); + initializeCharacterSelects(CHARACTER_TYPES, DEFAULT_CHARACTER_TYPE); + }) + .catch(error => { + console.error('Error loading models:', error); + }); +}); diff --git a/js/game.js b/js/game.js new file mode 100644 index 00000000..4b4b038d --- /dev/null +++ b/js/game.js @@ -0,0 +1,393 @@ +// Player stats tracking +const playerStats = {}; + +// Game state +let gameState = { + players: [], + scores: [0, 0, 0], + currentTurn: 0, + subRound: 0, + phase: 'conversation', + conversation: [], + stopCount: 0, + lastMoves: [], + currentRound: 1, + reasoningEnabled: [true, true, true] +}; + +// Memoization cache for prompts +const promptCache = { + common: null, + reasoning: null, + conversation: null, + move: null +}; + +async function loadPrompt(type) { + if (promptCache[type]) { + return promptCache[type]; + } + + try { + // Use _rules.txt for common and reasoning, _prompt_template.txt for conversation and move + const suffix = type === 'common' || type === 'reasoning' ? '_rules.txt' : '_prompt_template.txt'; + const response = await fetch(`prompts/${type}${suffix}`); + const text = await response.text(); + promptCache[type] = text; + return text; + } catch (error) { + console.error(`Error loading ${type} prompt:`, error); + return ''; + } +} + +function getOrCreatePlayerStats(model, playerIdx, characterType) { + if (!playerStats[playerIdx]) { + // Get next available name + const usedNames = Object.values(playerStats).map(p => p.name); + const name = CHARACTER_NAMES.default.find(n => !usedNames.includes(n)) || `Player ${playerIdx + 1}`; + playerStats[playerIdx] = { + name, + model, + characterType, + roundWins: 0, + totalPoints: 0 + }; + } else { + // Update model and character type if they changed + playerStats[playerIdx].model = model; + playerStats[playerIdx].characterType = characterType; + } + return playerStats[playerIdx]; +} + +function getPlayerDisplayName(player) { + const stats = getOrCreatePlayerStats(player.model, player.playerIdx, player.characterType); + const displayName = stats.name; + return displayName; +} + +// Helper functions for move processing +function parseMoveResponse(move) { + if (!move) return 'pump'; + + // Try to extract move from XML-style tags first + const moveMatch = move.match(/\s*(pump|dump)\s*<\/move>/i); + if (moveMatch) { + return moveMatch[1].toLowerCase().trim(); + } + + // Fallback: check if the raw response contains pump or dump + return move.toLowerCase().trim().includes('dump') ? 'dump' : 'pump'; +} + +function logPlayerMoves(moves, gameState, getPlayerDisplayName) { + console.log('\n%c🎲 MOVES THIS TURN 🎲', 'font-size: 14px; font-weight: bold; color: #333; background: #f0f0f0; padding: 5px;'); + const playerColors = ['#ff7675', '#74b9ff', '#55efc4']; + moves.forEach((move, idx) => { + const playerName = getPlayerDisplayName(gameState.players[idx]); + console.log( + `%c${playerName}: chose to ${move}`, + `color: ${playerColors[idx]}; font-weight: bold; font-size: 12px;` + ); + }); +} + +function calculatePoints(moves) { + const pumpCount = moves.filter(m => m === 'pump').length; + const dumpCount = moves.filter(m => m === 'dump').length; + + console.log('Move counts:', { pumpCount, dumpCount }); + + let points = new Array(3).fill(0); + + if (pumpCount === 3) { + return points.map(() => PAYOFFS.ALL_PUMP.pumpers); + } else if (dumpCount === 1) { + return moves.map(move => move === 'dump' ? PAYOFFS.LONE_DUMPER.dumpers : PAYOFFS.LONE_DUMPER.pumpers); + } else if (dumpCount === 2) { + return moves.map(move => move === 'dump' ? PAYOFFS.TWO_DUMPERS.dumpers : PAYOFFS.TWO_DUMPERS.pumpers); + } else if (dumpCount === 3) { + return points.map(() => PAYOFFS.ALL_DUMP.dumpers); + } + + return points; +} + +function logPointsEarned(points, gameState, getPlayerDisplayName) { + console.log('\n%c💰 POINTS EARNED 💰', 'font-size: 14px; font-weight: bold; color: #333; background: #ffeaa7; padding: 5px;'); + points.forEach((point, idx) => { + const playerName = getPlayerDisplayName(gameState.players[idx]); + const style = point > 0 + ? 'color: #00b894; font-weight: bold; font-size: 12px;' + : 'color: #d63031; font-size: 12px;'; + const emoji = point > 0 ? '✅' : '❌'; + console.log( + `%c${emoji} ${playerName} earned ${point} points (total: ${gameState.scores[idx]})`, + style + ); + }); +} + +async function processMoves() { + // Create promises for all move responses simultaneously + const movePromises = [0, 1, 2].map(i => getMoveResponse(i, gameState, getPlayerDisplayName)); + + // Wait for all moves to complete in parallel + const rawMoves = await Promise.all(movePromises); + + // Process all moves + const moves = rawMoves.map((move, i) => { + console.log(`Raw move response for player ${i}:`, move); + const finalMove = parseMoveResponse(move); + console.log(`Final move for player ${i}:`, finalMove); + return finalMove; + }); + + // Log final moves array and player moves + console.log('Final moves array:', moves); + logPlayerMoves(moves, gameState, getPlayerDisplayName); + + // Calculate and apply points + const points = calculatePoints(moves); + gameState.scores = gameState.scores.map((score, idx) => score + points[idx]); + + // Create and add score summary message + const scoreMessage = `Current scores: ${gameState.scores.map((score, idx) => + `${getPlayerDisplayName(gameState.players[idx])}: ${score}` + ).join(', ')}`; + addMessage(-1, scoreMessage, gameState.players, getPlayerDisplayName); + + // Log points earned + logPointsEarned(points, gameState, getPlayerDisplayName); + + // Store moves for history and update UI + gameState.lastMoves = moves; + updateScoreboard(gameState.scores, gameState.players, getPlayerDisplayName, playerStats); + updatePlayerLabels(gameState.players, getPlayerDisplayName, gameState); + + // Handle round transition + const hasWinner = checkWinner(); + if (hasWinner) { + startNewRound(); + gameState.phase = 'conversation'; + gameState.subRound = 0; + setNextButtonState(true); + return; + } + + // Continue with normal turn progression if no winner + if (gameState.subRound < MAX_SUB_ROUNDS - 1) { + gameState.subRound++; + gameState.phase = 'conversation'; + gameState.stopCount = 0; + } +} + +function checkWinner() { + // Check if any player has reached or exceeded FINISH_LINE + const winners = gameState.scores + .map((score, idx) => ({ score, idx })) + .filter(({ score }) => score >= FINISH_LINE); + + if (winners.length > 0) { + // Find highest score among winners + const maxScore = Math.max(...winners.map(w => w.score)); + const finalWinners = winners.filter(w => w.score === maxScore); + + // Update player stats for winners - award half point if tied + const pointsToAward = finalWinners.length > 1 ? 0.5 : 1; + finalWinners.forEach(winner => { + const stats = playerStats[winner.idx]; + stats.roundWins += pointsToAward; + stats.totalPoints += winner.score; + }); + + // Create winner message + const winnerNames = finalWinners + .map(w => getPlayerDisplayName(gameState.players[w.idx])) + .join(' and '); + + const message = finalWinners.length > 1 + ? `Round ${gameState.currentRound} Over! ${winnerNames} tie for the win with ${maxScore} points and each get half a point!` + : `Round ${gameState.currentRound} Over! ${winnerNames} wins with ${maxScore} points!`; + + addMessage(-1, message, gameState.players, getPlayerDisplayName); + + return true; + } + return false; +} + +function startNewRound() { + // Increment round counter + gameState.currentRound++; + + // Reset round-specific state + gameState.scores = [0, 0, 0]; + gameState.currentTurn = 0; + gameState.subRound = 0; + gameState.phase = 'conversation'; + gameState.conversation = []; + gameState.stopCount = 0; + gameState.lastMoves = []; + + // Update UI for new round + updateGameStatus(`Starting Round ${gameState.currentRound}...`); + updateScoreboard(gameState.scores, gameState.players, getPlayerDisplayName, playerStats); + updatePlayerLabels(gameState.players, getPlayerDisplayName, gameState); + + // Add round start message to conversation + const roundStartMessage = `Round ${gameState.currentRound} begins! Current standings: ${ + Object.values(playerStats) + .map(stats => `${stats.name}: ${stats.roundWins} wins`) + .join(', ') + }`; + addMessage(-1, roundStartMessage, gameState.players, getPlayerDisplayName); +} + +async function nextTurn() { + setNextButtonState(false); + updateGameStatus('Processing...'); + + if (gameState.phase === 'conversation') { + // Create an array of player indices and shuffle it + const playerOrder = [0, 1, 2].sort(() => Math.random() - 0.5); + + // Create promises for all responses simultaneously + const responsePromises = playerOrder.map(playerIdx => + getConversationResponse(playerIdx, gameState, getPlayerDisplayName) + ); + + // Wait for all responses to complete in parallel + const responses = await Promise.all(responsePromises); + + // Process all responses in the original shuffled order + responses.forEach((response, index) => { + const playerIdx = playerOrder[index]; + const cleanResponse = response.replace('', '').trim(); + if (cleanResponse) { + addMessage(playerIdx, cleanResponse, gameState.players, getPlayerDisplayName); + gameState.conversation.push(`${getPlayerDisplayName(gameState.players[playerIdx])}: ${cleanResponse}`); + } + if (response.includes('')) { + gameState.stopCount++; + } + }); + + if (gameState.stopCount === 3 || gameState.subRound === MAX_SUB_ROUNDS - 1) { + gameState.phase = 'move'; + updateGameStatus('Move phase - Processing moves...'); + if (document.getElementById('autoAdvance').checked) { + setTimeout(nextTurn, 500); // Increased delay + } else { + setNextButtonState(true); + } + } else { + gameState.subRound++; + updateGameStatus(`Turn ${gameState.currentTurn + 1}, conversation sub-round ${gameState.subRound + 1}...`); + if (document.getElementById('autoAdvance').checked) { + setTimeout(nextTurn, 500); // Increased delay + } else { + setNextButtonState(true); + } + } + } else if (gameState.phase === 'move') { + updateGameStatus('Processing moves...'); + + const gameOver = await processMoves(); + + if (!gameOver) { + gameState.currentTurn++; + gameState.subRound = 0; + gameState.phase = 'conversation'; + gameState.stopCount = 0; + updateGameStatus(`Turn ${gameState.currentTurn + 1}, conversation sub-round ${gameState.subRound + 1}...`); + // Only auto-advance if the checkbox is checked + if (document.getElementById('autoAdvance').checked) { + setTimeout(nextTurn, 500); // Increased delay + } else { + setNextButtonState(true); + } + } + } +} + +function startGame() { + const players = []; + const reasoningEnabled = []; + + for (let i = 1; i <= 3; i++) { + const model = document.getElementById(`player${i}`).value; + const characterType = document.getElementById(`character${i}`).value; + reasoningEnabled.push(document.getElementById(`reasoning${i}`).checked); + + if (!model || !characterType) { + alert('Please select model and character type for all players'); + return; + } + + players.push({ + model, + characterType, + playerIdx: i - 1 + }); + } + + // Load all prompts at game start + loadPrompt('common').then(commonPrompt => { + promptCache.common = commonPrompt; + }); + loadPrompt('reasoning').then(reasoningPrompt => { + promptCache.reasoning = reasoningPrompt; + }); + loadPrompt('conversation').then(conversationPrompt => { + promptCache.conversation = conversationPrompt; + }); + loadPrompt('move').then(movePrompt => { + promptCache.move = movePrompt; + }); + + gameState = { + players, + scores: [0, 0, 0], + currentTurn: 0, + subRound: 0, + phase: 'conversation', + conversation: [], + stopCount: 0, + lastMoves: [], + currentRound: 1, + reasoningEnabled + }; + + // Randomize player order + gameState.players = gameState.players.sort(() => Math.random() - 0.5); + + // Update player labels and reassign playerIdx based on new order + gameState.players.forEach((player, idx) => player.playerIdx = idx); + updatePlayerLabels(gameState.players, getPlayerDisplayName, gameState); + + // Clear conversation and update status + clearConversation(); + updateGameStatus('Game starting...'); + + // Show game and hide setup + setGameVisibility(true); + + // Update initial scoreboard + updateScoreboard(gameState.scores, gameState.players, getPlayerDisplayName, playerStats); + + // Start first turn + nextTurn(); +} + +function getPlayerSelections() { + const selections = []; + for (let i = 1; i <= 3; i++) { + const model = document.getElementById(`player${i}`).value; + const characterType = document.getElementById(`character${i}`).value; + selections.push({ model, characterType }); + } + return selections; +} diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 00000000..09c2a828 --- /dev/null +++ b/js/utils.js @@ -0,0 +1,42 @@ +function generateSeed() { + return Math.floor(Math.random() * 1000000); +} + +function shuffleArray(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; +} + +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +function log(category, message, data = null) { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] [${category}] ${message}`; + console.log(logMessage); + if (data) { + console.log('Data:', data); + } +} + +function logWithTimestamp(prefix, ...args) { + const now = new Date(); + const timestamp = now.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3 + }); + console.log(`[${timestamp}] ${prefix}:`, ...args); +} + +function logData(data) { + if (typeof data === 'object') { + console.log('Data:', JSON.stringify(data, null, 2)); + } else { + console.log('Data:', data); + } +} diff --git a/prompts/common_rules.txt b/prompts/common_rules.txt new file mode 100644 index 00000000..38e0805f --- /dev/null +++ b/prompts/common_rules.txt @@ -0,0 +1,32 @@ +You are {{PLAYER_NAME}} among these players: {{PLAYERS_LIST}}. + +# Character Information +- **Type:** {{CHARACTER_TYPE}} +- **Description:** {{CHARACTER_DESCRIPTION}} + +# Game Rules +This is a three-player "Pump or Dump" game where players choose between two actions each turn. + +Each turn has a conversation phase (up to {{MAX_SUB_ROUND}} sub-rounds) followed by a move phase. + +## Conversation Phase +- You can discuss strategy with other players or remain silent (``) +- Everyone sees what you say. The conversation is fully public. + +## Move Phase +- Each player privately and simultaneously chooses to pump or dump +- **All Pump:** pumpers get 1 point +- **One Dumper:** dumper gets 2 points, pumpers get 0 +- **Two Dumpers:** pumpers get 1.5 points, dumpers get 0 +- **All Dump:** dumpers get 0.5 points + +## Winning +- First to reach {{WIN_STEPS}} points wins +- If multiple players cross {{WIN_STEPS}} on the same turn, highest score wins +- Ties share the victory +- Important! Winner takes all! + +After each turn, all moves are revealed and scores updated. +We repeat until someone wins. + +Your goal is to win by reaching {{WIN_STEPS}} points first. Only one player can win (unless there's a tie). \ No newline at end of file diff --git a/prompts/conversation_prompt_template.txt b/prompts/conversation_prompt_template.txt index 9b381a10..eee1e09e 100644 --- a/prompts/conversation_prompt_template.txt +++ b/prompts/conversation_prompt_template.txt @@ -1,35 +1,15 @@ -You are {{PLAYER_NAME}} among these players: {{PLAYERS_LIST}}. - ---- GAME RULES --- -This is a multi-player step game where each turn consists of a conversation phase (up to {{MAX_SUB_ROUND}} sub-rounds) followed by a move phase. - -1. Each turn, there is a conversation phase, then a separate move phase. -2. In the conversation phase, you can say anything to the other players: persuade, mislead, coordinate, threaten, or remain silent (). - - Everyone sees what you say. The conversation is fully public. - - You do NOT reveal your final choice here. That happens privately in the move phase. -3. In the move phase, each player privately and simultaneously chooses 1, 3, or 5. - - If multiple players pick the same number this turn, none of those colliding players advance. - - The first player to reach or exceed {{WIN_STEPS}} steps ends the game. - - If multiple players cross {{WIN_STEPS}} on the same turn, the one with the highest total steps wins outright. - - If two or more are tied for the highest total steps, they share the victory and split points. -4. After each turn, the final moves are shown and positions updated so everyone sees who advanced and who didn’t. -5. We repeat the cycle until someone wins. - -Your only goal is to win. Only one player can win. Players with lower scores all lose. - ---- CONVERSATION GUIDELINES --- -- We allow up to {{MAX_SUB_ROUND}} sub-rounds of conversation each turn. -- If you have nothing more to say, respond with by itself or add at the end of your message. Once all players choose to stop talking, the conversation phase will end and the move phase will start. -- Otherwise, speak a short message to the others. - -Conversation and moves so far: +# Conversation History {{CONVERSATION_HISTORY}} ---- END OF CONVERSATION HISTORY --- -Current game state: +# Current Game State {{GAME_STATE}} +# Conversation Guidelines +- We allow up to {{MAX_SUB_ROUND}} sub-rounds of conversation each turn +- If you have nothing more to say, respond with `` by itself or add `` at the end of your message +- Once all players choose to stop talking, the conversation phase will end and the move phase will start +- Otherwise, speak a short message to the others -This is the conversation phase. Be concise and you must write in English only. Everybody can see your previous messages, so no need to repeat them. Your message may not be longer than around 100 words. - -Say something new and public to other players, {{PLAYER_NAME}}: +This is the conversation phase. Be concise and you must write in English only. +Everybody can see your previous messages, so no need to repeat them. +Your message may not be longer than around 20 words. \ No newline at end of file diff --git a/prompts/move_prompt_template.txt b/prompts/move_prompt_template.txt index 80d622d1..d6524d84 100644 --- a/prompts/move_prompt_template.txt +++ b/prompts/move_prompt_template.txt @@ -1,42 +1,23 @@ -You are {{PLAYER_NAME}} among these players: {{PLAYERS_LIST}}. - -Conversation and moves so far: +# Conversation History {{CONVERSATION_HISTORY}} ---- END OF HISTORY --- -Current game state: +# Current Game State {{GAME_STATE}} ---- GAME RULES --- -This is a multi-player step game where each turn consists of a conversation phase (up to {{MAX_SUB_ROUND}} sub-rounds) followed by a move phase. - -1. Each turn, there is a conversation phase, then a separate move phase. -2. In the conversation phase, you can say anything to the other players: persuade, mislead, coordinate, threaten, or remain silent (). - - Everyone sees what you say. The conversation is fully public. -3. In the move phase, each player privately and simultaneously chooses 1, 3, or 5. - - If multiple players pick the same number this turn, none of those colliding players advance. - - The first player to reach or exceed {{WIN_STEPS}} steps ends the game. - - If multiple players cross {{WIN_STEPS}} on the same turn, the one with the highest total steps wins outright. - - If two or more are tied for the highest total steps, they share the victory and split points. -4. After each turn, the final moves are shown and positions updated so everyone sees who advanced and who didn’t. -5. We repeat the cycle until someone wins. - -Your only goal is to win. Only one player can win. Players with lower scores all lose. +# Move Phase Instructions +1. This is a private move selection. Other players won't see your choice until after everyone moves. +2. You must choose exactly one: `pump` or `dump` +3. Points are awarded based on the combination of all players' choices as outlined above. +4. If one or more players reach {{WIN_STEPS}} points this turn, game ends. Highest score wins. ---- MOVE PHASE --- -1. This is a private move selection. The other players do not see your choice {{JUSTIFY_0}} -2. You must choose exactly one: 1, 3, or 5. -3. If multiple players choose the same number, none of those colliding players move this turn. -4. If one or more players cross {{WIN_STEPS}} steps this turn, the game ends. Highest total steps among those who cross wins. Ties share the victory. -{{JUSTIFY_1}} - -**Respond ONLY with**: -{{RJUSTIFY_2}} -- One `1`, `3`, or `5`. +## Response Format +You must respond with **exactly one** of: +- `pump` +- `dump` Example: -{{RJUSTIFY_3}}1 - ---- +``` +pump +``` -Now, it's your turn to make a move {{PLAYER_NAME}}: +**Now, it's your turn to make a move {{PLAYER_NAME}}:** diff --git a/prompts/prompt_quotes.txt b/prompts/prompt_quotes.txt deleted file mode 100644 index 0d20d94e..00000000 --- a/prompts/prompt_quotes.txt +++ /dev/null @@ -1,26 +0,0 @@ -{text} ---- - -From the above, extract top 3 quotes that are most theatrical, startling, absurd, eccentric, jarring, desperate, memorable, melodramatic, outlandish, unusual, biting, quirky, unexpected, emotional, strange, surprising, sarcastic, out-of-line, funny, dark humored and that qualify without knowing the surrounding context (like previous moves). We don't care about strategy or very smart reasoning. They should NOT be mild, straightforward, lack sharpness or be in-line. -Return the round number where this quote occurs in tags , subround number in tags , speaker in tags , the quote itself in tags , and your rating of how good this quote fits this criteria on a scale of 0, 1... to 10 in tags . You are a tough critic and you consider also why each quote fails to meet the qualifications. You must use this order. Don't output any other comments. - -For example, for this: -''' -Round 8, Conversation subround 1: - P1: - P3: P1, you're pathetic. You've single-handedly ruined this game. I'm sticking with 5. P2, you're free to keep inching forward with 1s. See you in round 20-something. - P2: P1, your consistent choice of 5 has stalled everyone, including yourself. If you want any chance of winning, we need to change tactics. Let's try this: P1 picks 3, I pick 1, and P3 sticks with 5. This way, we can all advance without collisions. P3, I understand your frustration, but let's aim for progress. Cooperation is our best chance to move forward. P1, it's time to make a strategic choice. What do you both think? -''' - -You would output: - -8 -1 -P3 -P1, you're pathetic. You've single-handedly ruined this game. I'm sticking with 5. P2, you're free to keep inching forward with 1s. See you in round 20-something. -7 - ---- - -If the only a part of what the speaker said qualifies, output just that part in your quote, not everything that this speaker said. For example for "P2, I'm starting to think your strategy is to let us exhaust ourselves trying to predict you. Maybe a bold move from you would shake things up? P3, I'm thinking 3 this round. What are your thoughts?", -you would just use one part: P2, I'm starting to think your strategy is to let us exhaust ourselves trying to predict you. diff --git a/prompts/reasoning_rules.txt b/prompts/reasoning_rules.txt new file mode 100644 index 00000000..285fe90a --- /dev/null +++ b/prompts/reasoning_rules.txt @@ -0,0 +1,3 @@ +# Reasoning +- Before responding with your message or your move think step by step +- Show your reasoning using `[1-3 lines of reasoning]` tags. diff --git a/styles.css b/styles.css new file mode 100644 index 00000000..97a01e8e --- /dev/null +++ b/styles.css @@ -0,0 +1,328 @@ +body { + font-family: system-ui, sans-serif; + max-width: 1000px; + margin: 0 auto; + padding: 20px; + background: #f5f5f5; +} + +.game-container { + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.setup { + margin-bottom: 20px; +} + +.track { + display: flex; + margin: 20px 0; + position: relative; + height: 120px; + background: #eee; + border-radius: 30px; + padding-left: 160px; /* Space for labels */ +} + +.track-label { + position: absolute; + right: 0; + font-size: 12px; + color: #666; + white-space: nowrap; + width: 150px; /* Fixed width for labels */ + text-align: right; + padding-right: 10px; +} + +.track-label-1 { top: 15px; } +.track-label-2 { top: 50px; } +.track-label-3 { top: 85px; } + +.player { + position: absolute; + width: 30px; + height: 30px; + border-radius: 50%; + transition: left 0.5s ease; + left: 160px; /* Align with track padding */ +} + +:root { + --player1-color: #4CAF50; + --player1-light: #E8F5E9; + --player2-color: #F44336; + --player2-light: #FFEBEE; + --player3-color: #2196F3; + --player3-light: #E3F2FD; +} + +.player-1 { background: var(--player1-color); top: 10px; } +.player-2 { background: var(--player2-color); top: 45px; } +.player-3 { background: var(--player3-color); top: 85px; } + +.controls { + display: flex; + gap: 10px; + margin: 20px 0; +} + +button { + padding: 10px 20px; + border: none; + border-radius: 4px; + background: #2196F3; + color: white; + cursor: pointer; + font-size: 16px; +} + +button:hover { + background: #1976D2; +} + +button:disabled { + background: #ccc; + cursor: not-allowed; +} + +.status { + margin: 20px 0; + padding: 10px; + border-radius: 4px; + background: #f8f8f8; +} + +.conversation { + margin: 20px 0; + max-height: 300px; + overflow-y: auto; + padding: 10px; + background: #f8f8f8; + border-radius: 4px; +} + +.message { + margin: 5px 0; + padding: 5px; + border-radius: 4px; +} + +.player-select { + margin: 10px 0; + padding: 8px; + width: 200px; + border-radius: 4px; + border: 1px solid #ddd; +} + +.player-name { + font-weight: bold; + margin-right: 10px; +} + +.player-1-msg { + background: var(--player1-light); + border-left: 4px solid var(--player1-color); +} +.player-2-msg { + background: var(--player2-light); + border-left: 4px solid var(--player2-color); +} +.player-3-msg { + background: var(--player3-light); + border-left: 4px solid var(--player3-color); +} + +.player-move.player-1-move { + border-left: 3px solid var(--player1-color); +} +.player-move.player-2-move { + border-left: 3px solid var(--player2-color); +} +.player-move.player-3-move { + border-left: 3px solid var(--player3-color); +} + +.system-message { + background: #f8f9fa; + border-left: 4px solid #6c757d; + padding: 10px; + margin: 5px 0; + font-family: monospace; +} + +.scoreboard { + background: #fff; + padding: 15px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + margin: 20px 0; +} + +.scoreboard h3 { + margin: 0 0 10px 0; + color: #333; +} + +#scores { + margin: 20px 0; + display: flex; + justify-content: center; + gap: 20px; +} + +.score { + padding: 12px 20px; + border-radius: 8px; + font-size: 14px; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + min-width: 200px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.player-1-score { + background: rgba(255, 118, 117, 0.1); + border: 1px solid rgba(255, 118, 117, 0.3); +} + +.player-2-score { + background: rgba(116, 185, 255, 0.1); + border: 1px solid rgba(116, 185, 255, 0.3); +} + +.player-3-score { + background: rgba(85, 239, 196, 0.1); + border: 1px solid rgba(85, 239, 196, 0.3); +} + +.score .player-name { + font-weight: bold; + color: #2d3436; +} + +.score .player-info { + color: #636e72; + font-size: 0.9em; +} + +.score .win-count { + font-size: 1.2em; + font-weight: bold; + padding: 4px 0; +} + +.player-1-score .win-count { + color: #d63031; +} + +.player-2-score .win-count { + color: #0984e3; +} + +.player-3-score .win-count { + color: #00b894; +} + +.move-summary { + background: #e3f2fd; + border-left: 4px solid #2196F3; + padding: 10px; + margin: 5px 0; + font-family: monospace; +} + +.move-collision { + color: #d32f2f; + font-weight: bold; +} + +.move-success { + color: #388e3c; +} + +.player-move { + display: inline-block; + padding: 2px 6px; + margin: 0 2px; + border-radius: 3px; + background: #e8eaf6; +} + +label { + margin-left: 10px; +} + +#game { + display: none; +} + +.player-setup { + display: flex; + flex-direction: column; + gap: 15px; + margin-bottom: 20px; +} + +.player-select-group { + display: flex; + align-items: center; + gap: 10px; +} + +.player-select-group label { + min-width: 70px; +} + +.player-select, .character-select { + padding: 5px; + border: 1px solid #ccc; + border-radius: 4px; + min-width: 200px; +} + +#player-thoughts { + margin: 20px 0; + padding: 10px; + background: #f8f9fa; + border-radius: 8px; +} + +#player-thoughts h3 { + margin: 0 0 10px 0; + color: #343a40; +} + +.thoughts { + display: flex; + flex-direction: column; + gap: 10px; +} + +.thought { + padding: 8px; + border-radius: 6px; + white-space: pre-line; + font-size: 0.9em; +} + +.player-1-thought { + background: rgba(255, 118, 117, 0.1); + border-left: 3px solid #ff7675; +} + +.player-2-thought { + background: rgba(116, 185, 255, 0.1); + border-left: 3px solid #74b9ff; +} + +.player-3-thought { + background: rgba(85, 239, 196, 0.1); + border-left: 3px solid #55efc4; +}