| import { nanoid } from 'nanoid' |
| import type { VoteAction, VoteState, User } from '@music-together/shared' |
| import { TIMING } from '@music-together/shared' |
| import { logger } from '../utils/logger.js' |
|
|
| interface Vote { |
| id: string |
| roomId: string |
| action: VoteAction |
| initiatorId: string |
| initiatorNickname: string |
| votes: Record<string, boolean> |
| requiredVotes: number |
| totalUsers: number |
| expiresAt: number |
| timeoutHandle: ReturnType<typeof setTimeout> |
| hostId: string |
| payload?: Record<string, unknown> |
| } |
|
|
| interface CastResult { |
| vote: Vote |
| decided: boolean |
| passed: boolean |
| reason?: string |
| } |
|
|
| |
| const activeVotes = new Map<string, Vote>() |
|
|
| |
| |
| |
| |
| export function createVote( |
| roomId: string, |
| hostId: string, |
| initiator: User, |
| action: VoteAction, |
| totalUsers: number, |
| payload?: Record<string, unknown>, |
| ): Vote | null { |
| if (activeVotes.has(roomId)) return null |
|
|
| const requiredVotes = Math.floor(totalUsers / 2) + 1 |
|
|
| const vote: Vote = { |
| id: nanoid(8), |
| roomId, |
| action, |
| initiatorId: initiator.id, |
| initiatorNickname: initiator.nickname, |
| votes: { [initiator.id]: true }, |
| requiredVotes, |
| totalUsers, |
| expiresAt: Date.now() + TIMING.VOTE_TIMEOUT_MS, |
| timeoutHandle: null as unknown as ReturnType<typeof setTimeout>, |
| hostId, |
| payload, |
| } |
|
|
| activeVotes.set(roomId, vote) |
| logger.info(`用户“${initiator.nickname}”在房间 ${roomId} 发起投票:${action}`, { |
| event: 'vote.created', |
| roomId, |
| voteId: vote.id, |
| action, |
| initiatorId: initiator.id, |
| initiator: initiator.nickname, |
| requiredVotes, |
| totalUsers, |
| }) |
| return vote |
| } |
|
|
| |
| |
| |
| |
| export function castVote(roomId: string, userId: string, approve: boolean): CastResult | null { |
| const vote = activeVotes.get(roomId) |
| if (!vote) return null |
|
|
| |
| if (userId in vote.votes) return null |
|
|
| vote.votes[userId] = approve |
|
|
| |
| if (userId === vote.hostId && !approve) { |
| return { vote, decided: true, passed: false, reason: 'host_veto' } |
| } |
|
|
| const approveCount = Object.values(vote.votes).filter(Boolean).length |
| const rejectCount = Object.values(vote.votes).filter((v) => !v).length |
|
|
| |
| if (approveCount >= vote.requiredVotes) { |
| return { vote, decided: true, passed: true } |
| } |
|
|
| |
| if (rejectCount > vote.totalUsers - vote.requiredVotes) { |
| return { vote, decided: true, passed: false, reason: 'rejected' } |
| } |
|
|
| |
| return { vote, decided: false, passed: false } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function updateVoteThreshold(roomId: string, currentUserCount: number, departedUserId?: string): boolean { |
| const vote = activeVotes.get(roomId) |
| if (!vote) return false |
|
|
| |
| if (departedUserId && departedUserId in vote.votes) { |
| delete vote.votes[departedUserId] |
| } |
|
|
| const newRequired = Math.floor(currentUserCount / 2) + 1 |
| vote.requiredVotes = newRequired |
| vote.totalUsers = currentUserCount |
| logger.debug('房间人数变化,投票通过门槛已更新', { |
| roomId, |
| requiredVotes: newRequired, |
| totalUsers: currentUserCount, |
| }) |
| return true |
| } |
|
|
| export function getActiveVote(roomId: string): Vote | null { |
| return activeVotes.get(roomId) ?? null |
| } |
|
|
| export function cancelVote(roomId: string): void { |
| const vote = activeVotes.get(roomId) |
| if (vote) { |
| clearTimeout(vote.timeoutHandle) |
| activeVotes.delete(roomId) |
| } |
| } |
|
|
| export function cleanupRoom(roomId: string): void { |
| cancelVote(roomId) |
| } |
|
|
| |
| export function toVoteState(vote: Vote): VoteState { |
| return { |
| id: vote.id, |
| action: vote.action, |
| initiatorId: vote.initiatorId, |
| initiatorNickname: vote.initiatorNickname, |
| votes: { ...vote.votes }, |
| requiredVotes: vote.requiredVotes, |
| totalUsers: vote.totalUsers, |
| expiresAt: vote.expiresAt, |
| payload: vote.payload, |
| } |
| } |
|
|