voting why not lol
This commit is contained in:
60
broadcast.ts
60
broadcast.ts
@@ -24,6 +24,9 @@ type RoundState = {
|
||||
votes: VoteInfo[];
|
||||
scoreA?: number;
|
||||
scoreB?: number;
|
||||
viewerVotesA?: number;
|
||||
viewerVotesB?: number;
|
||||
viewerVotingEndsAt?: number;
|
||||
};
|
||||
type GameState = {
|
||||
lastCompleted: RoundState | null;
|
||||
@@ -341,7 +344,7 @@ function drawScoreboard(scores: Record<string, number>) {
|
||||
function drawRound(round: RoundState) {
|
||||
const mainW = WIDTH - 380;
|
||||
|
||||
const phaseLabel =
|
||||
let phaseLabel =
|
||||
(round.phase === "prompting"
|
||||
? "Writing prompt"
|
||||
: round.phase === "answering"
|
||||
@@ -351,6 +354,12 @@ function drawRound(round: RoundState) {
|
||||
: "Complete"
|
||||
).toUpperCase();
|
||||
|
||||
// Append countdown during voting phase
|
||||
let countdownSeconds = 0;
|
||||
if (round.phase === "voting" && round.viewerVotingEndsAt) {
|
||||
countdownSeconds = Math.max(0, Math.ceil((round.viewerVotingEndsAt - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
ctx.font = '700 22px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#ededed";
|
||||
const totalText = totalRounds !== null ? `/${totalRounds}` : "";
|
||||
@@ -360,6 +369,13 @@ function drawRound(round: RoundState) {
|
||||
const labelWidth = ctx.measureText(phaseLabel).width;
|
||||
ctx.fillText(phaseLabel, mainW - 64 - labelWidth, 150);
|
||||
|
||||
if (countdownSeconds > 0) {
|
||||
const countdownText = `${countdownSeconds}S`;
|
||||
ctx.fillStyle = "#ededed";
|
||||
const cdWidth = ctx.measureText(countdownText).width;
|
||||
ctx.fillText(countdownText, mainW - 64 - labelWidth - cdWidth - 12, 150);
|
||||
}
|
||||
|
||||
ctx.font = '600 18px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#888";
|
||||
const promptedText = "PROMPTED BY ";
|
||||
@@ -483,30 +499,38 @@ function drawContestantCard(
|
||||
const totalVotes = votesA + votesB;
|
||||
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
|
||||
|
||||
roundRect(x + 24, y + h - 60, w - 48, 4, 2, "#1c1c1c");
|
||||
const viewerVoteCount = isFirst ? (round.viewerVotesA ?? 0) : (round.viewerVotesB ?? 0);
|
||||
const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0);
|
||||
const hasViewerVotes = totalViewerVotes > 0;
|
||||
|
||||
// Shift model votes up when viewer votes are present
|
||||
const modelVoteBarY = hasViewerVotes ? y + h - 110 : y + h - 60;
|
||||
const modelVoteTextY = hasViewerVotes ? y + h - 74 : y + h - 24;
|
||||
|
||||
roundRect(x + 24, modelVoteBarY, w - 48, 4, 2, "#1c1c1c");
|
||||
if (pct > 0) {
|
||||
roundRect(x + 24, y + h - 60, Math.max(8, ((w - 48) * pct) / 100), 4, 2, color);
|
||||
roundRect(x + 24, modelVoteBarY, Math.max(8, ((w - 48) * pct) / 100), 4, 2, color);
|
||||
}
|
||||
|
||||
ctx.font = '700 28px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(String(voteCount), x + 24, y + h - 24);
|
||||
ctx.fillText(String(voteCount), x + 24, modelVoteTextY);
|
||||
|
||||
ctx.font = '600 20px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#444";
|
||||
const vTxt = `vote${voteCount === 1 ? "" : "s"}`;
|
||||
const vCountW = ctx.measureText(String(voteCount)).width;
|
||||
const vTxtW = ctx.measureText(vTxt).width;
|
||||
ctx.fillText(vTxt, x + 24 + vCountW + 8, y + h - 25);
|
||||
ctx.fillText(vTxt, x + 24 + vCountW + 8, modelVoteTextY - 1);
|
||||
|
||||
let avatarX = x + 24 + vCountW + 8 + vTxtW + 16;
|
||||
const avatarY = y + h - 48;
|
||||
const avatarY = modelVoteBarY + 12;
|
||||
const avatarSize = 28;
|
||||
|
||||
for (const v of taskVoters) {
|
||||
const vColor = getColor(v.voter.name);
|
||||
const drewLogo = drawModelLogo(v.voter.name, avatarX, avatarY, avatarSize);
|
||||
|
||||
|
||||
if (!drewLogo) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
|
||||
@@ -518,9 +542,29 @@ function drawContestantCard(
|
||||
const tw = ctx.measureText(initial).width;
|
||||
ctx.fillText(initial, avatarX + avatarSize / 2 - tw / 2, avatarY + avatarSize / 2 + 4);
|
||||
}
|
||||
|
||||
|
||||
avatarX += avatarSize + 8;
|
||||
}
|
||||
|
||||
// Viewer votes
|
||||
if (hasViewerVotes) {
|
||||
const viewerPct = Math.round((viewerVoteCount / totalViewerVotes) * 100);
|
||||
|
||||
roundRect(x + 24, y + h - 56, w - 48, 4, 2, "#1c1c1c");
|
||||
if (viewerPct > 0) {
|
||||
roundRect(x + 24, y + h - 56, Math.max(8, ((w - 48) * viewerPct) / 100), 4, 2, "#666");
|
||||
}
|
||||
|
||||
ctx.font = '700 22px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#999";
|
||||
ctx.fillText(String(viewerVoteCount), x + 24, y + h - 22);
|
||||
|
||||
const vvCountW = ctx.measureText(String(viewerVoteCount)).width;
|
||||
ctx.font = '600 16px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#444";
|
||||
const vvTxt = `viewer vote${viewerVoteCount === 1 ? "" : "s"}`;
|
||||
ctx.fillText(vvTxt, x + 24 + vvCountW + 8, y + h - 23);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
65
frontend.css
65
frontend.css
@@ -319,6 +319,71 @@ body {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Votable Contestant ──────────────────────────────────────── */
|
||||
|
||||
.contestant--votable {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.contestant--votable:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.contestant--votable:active {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* ── Viewer Votes ────────────────────────────────────────────── */
|
||||
|
||||
.viewer-vote-bar {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.viewer-vote-bar__fill {
|
||||
background: #666 !important;
|
||||
}
|
||||
|
||||
.viewer-vote-meta {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.viewer-vote-meta__count {
|
||||
color: #999 !important;
|
||||
}
|
||||
|
||||
.viewer-vote-meta__icon {
|
||||
margin-left: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Vote CTA ────────────────────────────────────────────────── */
|
||||
|
||||
.vote-cta {
|
||||
text-align: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
padding: 10px 0;
|
||||
margin-bottom: 8px;
|
||||
animation: vote-cta-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes vote-cta-pulse {
|
||||
0%, 100% { opacity: 0.7; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.vote-countdown {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* ── Tie ──────────────────────────────────────────────────────── */
|
||||
|
||||
.tie-label {
|
||||
|
||||
136
frontend.tsx
136
frontend.tsx
@@ -30,6 +30,9 @@ type RoundState = {
|
||||
votes: VoteInfo[];
|
||||
scoreA?: number;
|
||||
scoreB?: number;
|
||||
viewerVotesA?: number;
|
||||
viewerVotesB?: number;
|
||||
viewerVotingEndsAt?: number;
|
||||
};
|
||||
type GameState = {
|
||||
lastCompleted: RoundState | null;
|
||||
@@ -50,7 +53,8 @@ type ViewerCountMessage = {
|
||||
type: "viewerCount";
|
||||
viewerCount: number;
|
||||
};
|
||||
type ServerMessage = StateMessage | ViewerCountMessage;
|
||||
type VotedAckMessage = { type: "votedAck" };
|
||||
type ServerMessage = StateMessage | ViewerCountMessage | VotedAckMessage;
|
||||
|
||||
// ── Model colors & logos ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -155,6 +159,10 @@ function ContestantCard({
|
||||
isWinner,
|
||||
showVotes,
|
||||
voters,
|
||||
viewerVotes,
|
||||
totalViewerVotes,
|
||||
votable,
|
||||
onVote,
|
||||
}: {
|
||||
task: TaskInfo;
|
||||
voteCount: number;
|
||||
@@ -162,14 +170,26 @@ function ContestantCard({
|
||||
isWinner: boolean;
|
||||
showVotes: boolean;
|
||||
voters: VoteInfo[];
|
||||
viewerVotes?: number;
|
||||
totalViewerVotes?: number;
|
||||
votable?: boolean;
|
||||
onVote?: () => void;
|
||||
}) {
|
||||
const color = getColor(task.model.name);
|
||||
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
|
||||
const showViewerVotes = showVotes && totalViewerVotes !== undefined && totalViewerVotes > 0;
|
||||
const viewerPct = showViewerVotes && totalViewerVotes > 0
|
||||
? Math.round(((viewerVotes ?? 0) / totalViewerVotes) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`contestant ${isWinner ? "contestant--winner" : ""}`}
|
||||
className={`contestant ${isWinner ? "contestant--winner" : ""} ${votable ? "contestant--votable" : ""}`}
|
||||
style={{ "--accent": color } as React.CSSProperties}
|
||||
onClick={votable ? onVote : undefined}
|
||||
role={votable ? "button" : undefined}
|
||||
tabIndex={votable ? 0 : undefined}
|
||||
onKeyDown={votable ? (e) => { if (e.key === "Enter" || e.key === " ") onVote?.(); } : undefined}
|
||||
>
|
||||
<div className="contestant__head">
|
||||
<ModelTag model={task.model} />
|
||||
@@ -227,6 +247,25 @@ function ContestantCard({
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{showViewerVotes && (
|
||||
<>
|
||||
<div className="vote-bar viewer-vote-bar">
|
||||
<div
|
||||
className="vote-bar__fill viewer-vote-bar__fill"
|
||||
style={{ width: `${viewerPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="vote-meta viewer-vote-meta">
|
||||
<span className="vote-meta__count viewer-vote-meta__count">
|
||||
{viewerVotes ?? 0}
|
||||
</span>
|
||||
<span className="vote-meta__label">
|
||||
viewer vote{(viewerVotes ?? 0) !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="viewer-vote-meta__icon">👥</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -235,7 +274,19 @@ function ContestantCard({
|
||||
|
||||
// ── Arena ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Arena({ round, total }: { round: RoundState; total: number | null }) {
|
||||
function Arena({
|
||||
round,
|
||||
total,
|
||||
hasVoted,
|
||||
onVote,
|
||||
viewerVotingSecondsLeft,
|
||||
}: {
|
||||
round: RoundState;
|
||||
total: number | null;
|
||||
hasVoted: boolean;
|
||||
onVote: (side: "A" | "B") => void;
|
||||
viewerVotingSecondsLeft: number;
|
||||
}) {
|
||||
const [contA, contB] = round.contestants;
|
||||
const showVotes = round.phase === "voting" || round.phase === "done";
|
||||
const isDone = round.phase === "done";
|
||||
@@ -249,6 +300,16 @@ function Arena({ round, total }: { round: RoundState; total: number | null }) {
|
||||
const totalVotes = votesA + votesB;
|
||||
const votersA = round.votes.filter((v) => v.votedFor?.name === contA.name);
|
||||
const votersB = round.votes.filter((v) => v.votedFor?.name === contB.name);
|
||||
const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0);
|
||||
|
||||
const canVote =
|
||||
round.phase === "voting" &&
|
||||
!hasVoted &&
|
||||
viewerVotingSecondsLeft > 0 &&
|
||||
round.answerTasks[0].finishedAt &&
|
||||
round.answerTasks[1].finishedAt;
|
||||
|
||||
const showCountdown = round.phase === "voting" && viewerVotingSecondsLeft > 0;
|
||||
|
||||
const phaseText =
|
||||
round.phase === "prompting"
|
||||
@@ -266,11 +327,22 @@ function Arena({ round, total }: { round: RoundState; total: number | null }) {
|
||||
Round {round.num}
|
||||
{total ? <span className="dim">/{total}</span> : null}
|
||||
</span>
|
||||
<span className="arena__phase">{phaseText}</span>
|
||||
<span className="arena__phase">
|
||||
{phaseText}
|
||||
{showCountdown && (
|
||||
<span className="vote-countdown">{viewerVotingSecondsLeft}s</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PromptCard round={round} />
|
||||
|
||||
{canVote && (
|
||||
<div className="vote-cta">
|
||||
Pick the funnier answer!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{round.phase !== "prompting" && (
|
||||
<div className="showdown">
|
||||
<ContestantCard
|
||||
@@ -280,6 +352,10 @@ function Arena({ round, total }: { round: RoundState; total: number | null }) {
|
||||
isWinner={isDone && votesA > votesB}
|
||||
showVotes={showVotes}
|
||||
voters={votersA}
|
||||
viewerVotes={round.viewerVotesA}
|
||||
totalViewerVotes={totalViewerVotes}
|
||||
votable={!!canVote}
|
||||
onVote={() => onVote("A")}
|
||||
/>
|
||||
<ContestantCard
|
||||
task={round.answerTasks[1]}
|
||||
@@ -288,6 +364,10 @@ function Arena({ round, total }: { round: RoundState; total: number | null }) {
|
||||
isWinner={isDone && votesB > votesA}
|
||||
showVotes={showVotes}
|
||||
voters={votersB}
|
||||
viewerVotes={round.viewerVotesB}
|
||||
totalViewerVotes={totalViewerVotes}
|
||||
votable={!!canVote}
|
||||
onVote={() => onVote("B")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -412,6 +492,36 @@ function App() {
|
||||
const [totalRounds, setTotalRounds] = useState<number | null>(null);
|
||||
const [viewerCount, setViewerCount] = useState(0);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [hasVoted, setHasVoted] = useState(false);
|
||||
const [votedRound, setVotedRound] = useState<number | null>(null);
|
||||
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
|
||||
const wsRef = React.useRef<WebSocket | null>(null);
|
||||
|
||||
// Reset hasVoted when round changes
|
||||
useEffect(() => {
|
||||
const currentRound = state?.active?.num ?? null;
|
||||
if (currentRound !== null && currentRound !== votedRound) {
|
||||
setHasVoted(false);
|
||||
setVotedRound(null);
|
||||
}
|
||||
}, [state?.active?.num, votedRound]);
|
||||
|
||||
// Countdown timer for viewer voting
|
||||
useEffect(() => {
|
||||
const endsAt = state?.active?.viewerVotingEndsAt;
|
||||
if (!endsAt || state?.active?.phase !== "voting") {
|
||||
setViewerVotingSecondsLeft(0);
|
||||
return;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const remaining = Math.max(0, Math.ceil((endsAt! - Date.now()) / 1000));
|
||||
setViewerVotingSecondsLeft(remaining);
|
||||
}
|
||||
tick();
|
||||
const interval = setInterval(tick, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [state?.active?.viewerVotingEndsAt, state?.active?.phase]);
|
||||
|
||||
useEffect(() => {
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
@@ -422,9 +532,11 @@ function App() {
|
||||
let knownVersion: string | null = null;
|
||||
function connect() {
|
||||
ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
wsRef.current = null;
|
||||
reconnectTimer = setTimeout(connect, 2000);
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
@@ -439,6 +551,8 @@ function App() {
|
||||
setViewerCount(msg.viewerCount);
|
||||
} else if (msg.type === "viewerCount") {
|
||||
setViewerCount(msg.viewerCount);
|
||||
} else if (msg.type === "votedAck") {
|
||||
setHasVoted(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -450,6 +564,12 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleVote = (side: "A" | "B") => {
|
||||
if (hasVoted || !wsRef.current) return;
|
||||
wsRef.current.send(JSON.stringify({ type: "vote", votedFor: side }));
|
||||
setVotedRound(state?.active?.num ?? null);
|
||||
};
|
||||
|
||||
if (!connected || !state) return <ConnectingScreen />;
|
||||
|
||||
const isNextPrompting =
|
||||
@@ -484,7 +604,13 @@ function App() {
|
||||
{state.done ? (
|
||||
<GameOver scores={state.scores} />
|
||||
) : displayRound ? (
|
||||
<Arena round={displayRound} total={totalRounds} />
|
||||
<Arena
|
||||
round={displayRound}
|
||||
total={totalRounds}
|
||||
hasVoted={hasVoted}
|
||||
onVote={handleVote}
|
||||
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
|
||||
/>
|
||||
) : (
|
||||
<div className="waiting">
|
||||
Starting
|
||||
|
||||
19
game.ts
19
game.ts
@@ -64,6 +64,9 @@ export type RoundState = {
|
||||
votes: VoteInfo[];
|
||||
scoreA?: number;
|
||||
scoreB?: number;
|
||||
viewerVotesA?: number;
|
||||
viewerVotesB?: number;
|
||||
viewerVotingEndsAt?: number;
|
||||
};
|
||||
|
||||
export type GameState = {
|
||||
@@ -268,6 +271,7 @@ export async function runGame(
|
||||
runs: number,
|
||||
state: GameState,
|
||||
rerender: () => void,
|
||||
onViewerVotingStart?: () => void,
|
||||
) {
|
||||
let startRound = 1;
|
||||
const lastCompletedRound = state.completed.at(-1);
|
||||
@@ -393,9 +397,17 @@ export async function runGame(
|
||||
const answerB = round.answerTasks[1].result!;
|
||||
const voteStart = Date.now();
|
||||
round.votes = voters.map((v) => ({ voter: v, startedAt: voteStart }));
|
||||
|
||||
// Initialize viewer voting
|
||||
round.viewerVotesA = 0;
|
||||
round.viewerVotesB = 0;
|
||||
round.viewerVotingEndsAt = Date.now() + 30_000;
|
||||
onViewerVotingStart?.();
|
||||
rerender();
|
||||
|
||||
await Promise.all(
|
||||
await Promise.all([
|
||||
// Model votes
|
||||
Promise.all(
|
||||
round.votes.map(async (vote) => {
|
||||
if (state.generation !== roundGeneration) {
|
||||
return;
|
||||
@@ -436,7 +448,10 @@ export async function runGame(
|
||||
}
|
||||
rerender();
|
||||
}),
|
||||
);
|
||||
),
|
||||
// 30-second viewer voting window
|
||||
new Promise((r) => setTimeout(r, 30_000)),
|
||||
]);
|
||||
if (state.generation !== roundGeneration) {
|
||||
continue;
|
||||
}
|
||||
|
||||
21
history.css
21
history.css
@@ -228,6 +228,27 @@ body {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Viewer Votes ────────────────────────────────────────────── */
|
||||
|
||||
.history-contestant__viewer-votes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.history-contestant__viewer-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.history-contestant__viewer-count {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Pagination ───────────────────────────────────────────────── */
|
||||
.pagination {
|
||||
display: flex;
|
||||
|
||||
26
history.tsx
26
history.tsx
@@ -30,6 +30,8 @@ type RoundState = {
|
||||
votes: VoteInfo[];
|
||||
scoreA?: number;
|
||||
scoreB?: number;
|
||||
viewerVotesA?: number;
|
||||
viewerVotesB?: number;
|
||||
};
|
||||
|
||||
// ── Shared UI Utils ─────────────────────────────────────────────────────────
|
||||
@@ -124,6 +126,17 @@ function HistoryContestant({
|
||||
);
|
||||
}
|
||||
|
||||
function ViewerVotes({ count, label }: { count: number; label: string }) {
|
||||
return (
|
||||
<div className="history-contestant__viewer-votes">
|
||||
<span className="history-contestant__viewer-icon">👥</span>
|
||||
<span className="history-contestant__viewer-count">
|
||||
{count} {label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryCard({ round }: { round: RoundState }) {
|
||||
const [contA, contB] = round.contestants;
|
||||
|
||||
@@ -144,6 +157,7 @@ function HistoryCard({ round }: { round: RoundState }) {
|
||||
|
||||
const isAWinner = votesA > votesB;
|
||||
const isBWinner = votesB > votesA;
|
||||
const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0);
|
||||
|
||||
return (
|
||||
<div className="history-card">
|
||||
@@ -193,6 +207,12 @@ function HistoryCard({ round }: { round: RoundState }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{totalViewerVotes > 0 && (
|
||||
<ViewerVotes
|
||||
count={round.viewerVotesA ?? 0}
|
||||
label={`viewer vote${(round.viewerVotesA ?? 0) === 1 ? "" : "s"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -228,6 +248,12 @@ function HistoryCard({ round }: { round: RoundState }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{totalViewerVotes > 0 && (
|
||||
<ViewerVotes
|
||||
count={round.viewerVotesB ?? 0}
|
||||
label={`viewer vote${(round.viewerVotesB ?? 0) === 1 ? "" : "s"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
34
server.ts
34
server.ts
@@ -218,6 +218,16 @@ function setHistoryCache(key: string, body: string, expiresAt: number) {
|
||||
// ── WebSocket clients ───────────────────────────────────────────────────────
|
||||
|
||||
const clients = new Set<ServerWebSocket<WsData>>();
|
||||
const viewerVoters = new Set<ServerWebSocket<WsData>>();
|
||||
let viewerVoteBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleViewerVoteBroadcast() {
|
||||
if (viewerVoteBroadcastTimer) return;
|
||||
viewerVoteBroadcastTimer = setTimeout(() => {
|
||||
viewerVoteBroadcastTimer = null;
|
||||
broadcast();
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
function getClientState() {
|
||||
return {
|
||||
@@ -593,8 +603,24 @@ const server = Bun.serve<WsData>({
|
||||
// Notify everyone else with just the viewer count
|
||||
broadcastViewerCount();
|
||||
},
|
||||
message(_ws, _message) {
|
||||
// Spectator-only, no client messages handled
|
||||
message(ws, message) {
|
||||
try {
|
||||
const msg = JSON.parse(String(message));
|
||||
if (msg.type !== "vote") return;
|
||||
|
||||
const round = gameState.active;
|
||||
if (!round || round.phase !== "voting") return;
|
||||
if (!round.viewerVotingEndsAt || Date.now() > round.viewerVotingEndsAt) return;
|
||||
if (viewerVoters.has(ws)) return;
|
||||
if (msg.votedFor !== "A" && msg.votedFor !== "B") return;
|
||||
|
||||
viewerVoters.add(ws);
|
||||
if (msg.votedFor === "A") round.viewerVotesA = (round.viewerVotesA ?? 0) + 1;
|
||||
else round.viewerVotesB = (round.viewerVotesB ?? 0) + 1;
|
||||
|
||||
ws.send(JSON.stringify({ type: "votedAck" }));
|
||||
scheduleViewerVoteBroadcast();
|
||||
} catch {}
|
||||
},
|
||||
close(ws) {
|
||||
clients.delete(ws);
|
||||
@@ -634,6 +660,8 @@ log("INFO", "server", `Web server started on port ${server.port}`, {
|
||||
|
||||
// ── Start game ──────────────────────────────────────────────────────────────
|
||||
|
||||
runGame(runs, gameState, broadcast).then(() => {
|
||||
runGame(runs, gameState, broadcast, () => {
|
||||
viewerVoters.clear();
|
||||
}).then(() => {
|
||||
console.log(`\n✅ Game complete! Log: ${LOG_FILE}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user