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:
parent
2b0c831399
commit
cb76142b2b
106
server/index.js
106
server/index.js
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
231
src/App.css
231
src/App.css
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
95
src/App.js
95
src/App.js
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue