feat: Add veto speaker button when boredom exceeds 66%

- Add veto button that appears when collective boredom > 66%
- Implement 15-second voting period for veto decisions
- Majority vote (50%+) required to pass veto
- Add animated UI for veto voting progress
- Show veto result (passed/failed) with animations
- WebSocket broadcasts veto state to all participants

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-08 06:22:27 +01:00
parent 2b0c831399
commit cb76142b2b
3 changed files with 428 additions and 4 deletions

View File

@ -4,12 +4,16 @@ const crypto = require('crypto');
const PORT = process.env.PORT || 3001;
// Room storage: { roomId: { users: Map, createdAt: Date, name: string } }
// Room storage: { roomId: { users: Map, createdAt: Date, name: string, veto: object } }
const rooms = new Map();
// Global room (the default public room with bots)
const GLOBAL_ROOM_ID = 'global';
// Veto threshold - when collective boredom exceeds this, veto becomes available
const VETO_THRESHOLD = 66;
const VETO_DURATION = 15000; // 15 seconds to vote
// Generate short room codes
const generateRoomCode = () => {
return crypto.randomBytes(3).toString('hex').toUpperCase();
@ -81,15 +85,102 @@ const getRoomStats = (roomId) => {
name: u.name || null
}));
// Include veto state
const veto = room.veto ? {
active: true,
initiator: room.veto.initiatorName || 'Someone',
votes: room.veto.votes.size,
needed: Math.ceil(count / 2),
timeLeft: Math.max(0, Math.ceil((room.veto.endTime - Date.now()) / 1000))
} : null;
return {
average,
count,
individuals,
roomName: room.name,
roomId
roomId,
vetoAvailable: average >= VETO_THRESHOLD,
veto
};
};
// Start a veto vote
const startVeto = (roomId, initiatorId, initiatorName) => {
const room = rooms.get(roomId);
if (!room || room.veto) return false;
const stats = getRoomStats(roomId);
if (stats.average < VETO_THRESHOLD) return false;
room.veto = {
initiatorId,
initiatorName: initiatorName || 'Someone',
votes: new Set([initiatorId]), // Initiator automatically votes yes
startTime: Date.now(),
endTime: Date.now() + VETO_DURATION
};
console.log(`Veto started in room ${roomId} by ${initiatorName}`);
// Broadcast veto started
broadcastToRoom(roomId);
// Set timeout to end veto
setTimeout(() => {
endVeto(roomId, false);
}, VETO_DURATION);
return true;
};
// Cast a veto vote
const castVetoVote = (roomId, voterId) => {
const room = rooms.get(roomId);
if (!room || !room.veto) return false;
room.veto.votes.add(voterId);
// Check if majority reached
const realUsers = Array.from(room.users.values()).filter(u => !u.isBot && u.ws);
const needed = Math.ceil(realUsers.length / 2);
if (room.veto.votes.size >= needed) {
endVeto(roomId, true);
return true;
}
broadcastToRoom(roomId);
return true;
};
// End veto voting
const endVeto = (roomId, passed) => {
const room = rooms.get(roomId);
if (!room || !room.veto) return;
const voteCount = room.veto.votes.size;
room.veto = null;
// Broadcast result
const message = JSON.stringify({
type: 'vetoResult',
passed,
votes: voteCount
});
room.users.forEach((user) => {
if (user.ws && user.ws.readyState === WebSocket.OPEN) {
user.ws.send(message);
}
});
console.log(`Veto in room ${roomId} ${passed ? 'PASSED' : 'failed'} with ${voteCount} votes`);
// Also broadcast updated stats (veto no longer active)
setTimeout(() => broadcastToRoom(roomId), 100);
};
// Broadcast to all users in a room
const broadcastToRoom = (roomId) => {
const room = rooms.get(roomId);
@ -270,6 +361,17 @@ wss.on('connection', (ws, req) => {
broadcastToRoom(roomId);
}
}
// Veto actions
if (message.type === 'startVeto') {
const user = room.users.get(userId);
const name = user?.name || 'Someone';
startVeto(roomId, userId, name);
}
if (message.type === 'vetoVote') {
castVetoVote(roomId, userId);
}
} catch (err) {
console.error('Invalid message:', err.message);
}

View File

@ -697,3 +697,234 @@ body {
font-size: 1rem;
}
}
/* ==================== VETO SPEAKER ==================== */
.veto-section {
width: 100%;
max-width: 400px;
margin: 1.5rem auto;
padding: 1.5rem;
border-radius: 1rem;
text-align: center;
animation: vetoSlideIn 0.3s ease-out;
}
@keyframes vetoSlideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Veto Available State */
.veto-available {
background: linear-gradient(135deg, rgba(251, 146, 60, 0.15), rgba(239, 68, 68, 0.15));
border: 1px solid rgba(251, 146, 60, 0.3);
}
.veto-alert {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.veto-icon {
font-size: 1.5rem;
animation: vietoPulse 1s ease-in-out infinite;
}
@keyframes vietoPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.veto-message {
font-size: 1rem;
font-weight: 600;
color: #fb923c;
}
.btn-veto {
background: linear-gradient(135deg, #fb923c, #ef4444);
color: white;
font-size: 1.1rem;
padding: 1rem 2rem;
border: none;
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
animation: vetoPulse 2s ease-in-out infinite;
}
@keyframes vetoPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(251, 146, 60, 0.4); }
50% { box-shadow: 0 0 20px 5px rgba(251, 146, 60, 0.2); }
}
.btn-veto:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 8px 25px rgba(251, 146, 60, 0.4);
}
/* Veto Active (Voting) State */
.veto-active {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2));
border: 2px solid rgba(139, 92, 246, 0.5);
animation: vetoSlideIn 0.3s ease-out, vetoBorder 1s ease-in-out infinite;
}
@keyframes vetoBorder {
0%, 100% { border-color: rgba(139, 92, 246, 0.5); }
50% { border-color: rgba(236, 72, 153, 0.5); }
}
.veto-voting {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.veto-title {
font-size: 1.3rem;
font-weight: 700;
color: #e4e4e7;
margin: 0;
}
.veto-initiator {
font-size: 0.9rem;
color: #a1a1aa;
margin: 0;
}
.veto-progress {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.veto-bar {
height: 12px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
overflow: hidden;
}
.veto-bar-fill {
height: 100%;
background: linear-gradient(90deg, #a78bfa, #ec4899);
border-radius: 6px;
transition: width 0.3s ease;
}
.veto-count {
font-size: 0.85rem;
color: #c4b5fd;
font-weight: 500;
}
.veto-timer {
font-size: 1.2rem;
font-weight: 700;
color: #fbbf24;
animation: timerPulse 1s ease-in-out infinite;
}
@keyframes timerPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.btn-vote {
background: linear-gradient(135deg, #a78bfa, #ec4899);
color: white;
font-size: 1.1rem;
padding: 1rem 2rem;
border: none;
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 0.5rem;
}
.btn-vote:hover {
transform: scale(1.05);
box-shadow: 0 8px 25px rgba(167, 139, 250, 0.4);
}
/* Veto Result States */
.veto-result {
padding: 2rem;
}
.veto-result-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.veto-result-icon {
font-size: 3rem;
animation: resultBounce 0.5s ease-out;
}
@keyframes resultBounce {
0% { transform: scale(0); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
.veto-result h3 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.veto-result p {
font-size: 0.9rem;
color: #a1a1aa;
margin: 0;
}
.veto-passed {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.3));
border: 2px solid rgba(239, 68, 68, 0.5);
}
.veto-passed h3 {
color: #f87171;
}
.veto-failed {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(22, 163, 74, 0.2));
border: 2px solid rgba(34, 197, 94, 0.4);
}
.veto-failed h3 {
color: #4ade80;
}
/* Mobile adjustments for veto */
@media (max-width: 500px) {
.veto-section {
margin: 1rem auto;
padding: 1rem;
}
.veto-title {
font-size: 1.1rem;
}
.btn-veto,
.btn-vote {
padding: 0.875rem 1.5rem;
font-size: 1rem;
}
}

View File

@ -27,6 +27,9 @@ const useWebSocket = (roomId = 'global', userName = null) => {
const [userCount, setUserCount] = useState(0);
const [individuals, setIndividuals] = useState([]);
const [error, setError] = useState(null);
const [vetoAvailable, setVetoAvailable] = useState(false);
const [veto, setVeto] = useState(null);
const [vetoResult, setVetoResult] = useState(null);
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
@ -56,10 +59,19 @@ const useWebSocket = (roomId = 'global', userName = null) => {
setGlobalBoredom(data.average || 50);
setUserCount(data.count || 0);
setIndividuals(data.individuals || []);
setVetoAvailable(data.vetoAvailable || false);
setVeto(data.veto || null);
} else if (data.type === 'stats') {
setGlobalBoredom(data.average || 50);
setUserCount(data.count || 0);
setIndividuals(data.individuals || []);
setVetoAvailable(data.vetoAvailable || false);
setVeto(data.veto || null);
} else if (data.type === 'vetoResult') {
setVetoResult({ passed: data.passed, votes: data.votes });
setVeto(null);
// Clear result after 5 seconds
setTimeout(() => setVetoResult(null), 5000);
}
} catch (err) {
console.error('Failed to parse message:', err);
@ -114,6 +126,22 @@ const useWebSocket = (roomId = 'global', userName = null) => {
}
}, []);
const startVeto = useCallback(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'startVeto'
}));
}
}, []);
const voteVeto = useCallback(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'vetoVote'
}));
}
}, []);
return {
isConnected,
userId,
@ -123,7 +151,12 @@ const useWebSocket = (roomId = 'global', userName = null) => {
individuals,
error,
sendBoredom,
sendName
sendName,
vetoAvailable,
veto,
vetoResult,
startVeto,
voteVeto
};
};
@ -310,7 +343,10 @@ function RoomPage() {
const navigate = useNavigate();
const [myBoredom, setMyBoredom] = useState(50);
const [showShare, setShowShare] = useState(false);
const { isConnected, userId, roomName, globalBoredom, userCount, individuals, error, sendBoredom } = useWebSocket(roomId);
const {
isConnected, userId, roomName, globalBoredom, userCount, individuals, error, sendBoredom,
vetoAvailable, veto, vetoResult, startVeto, voteVeto
} = useWebSocket(roomId);
const handleBoredomChange = useCallback((value) => {
setMyBoredom(value);
@ -453,6 +489,61 @@ function RoomPage() {
))}
</div>
</div>
{/* Veto Speaker Section */}
{vetoAvailable && !veto && !vetoResult && (
<div className="veto-section veto-available">
<div className="veto-alert">
<span className="veto-icon"></span>
<span className="veto-message">Collective boredom exceeds 66%!</span>
</div>
<button className="btn btn-veto" onClick={startVeto}>
🗳 Veto Speaker
</button>
</div>
)}
{veto && (
<div className="veto-section veto-active">
<div className="veto-voting">
<h3 className="veto-title">🗳 Veto in Progress!</h3>
<p className="veto-initiator">{veto.initiator} called for a vote</p>
<div className="veto-progress">
<div className="veto-bar">
<div
className="veto-bar-fill"
style={{ width: `${(veto.votes / veto.needed) * 100}%` }}
/>
</div>
<span className="veto-count">{veto.votes} / {veto.needed} votes needed</span>
</div>
<div className="veto-timer"> {veto.timeLeft}s remaining</div>
<button className="btn btn-vote" onClick={voteVeto}>
Vote to Veto
</button>
</div>
</div>
)}
{vetoResult && (
<div className={`veto-section veto-result ${vetoResult.passed ? 'veto-passed' : 'veto-failed'}`}>
<div className="veto-result-content">
{vetoResult.passed ? (
<>
<span className="veto-result-icon">🚫</span>
<h3>Speaker Vetoed!</h3>
<p>The group has spoken. Time for a change!</p>
</>
) : (
<>
<span className="veto-result-icon"></span>
<h3>Veto Failed</h3>
<p>Not enough votes. The speaker continues.</p>
</>
)}
</div>
</div>
)}
</main>
<footer className="app-footer">