File size: 4,618 Bytes
00a912e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | 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
}
/** Active vote per room (at most one at a time) */
const activeVotes = new Map<string, Vote>()
/**
* Create a new vote. Returns null if a vote is already in progress.
* The initiator automatically votes "approve".
*/
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 }, // auto-approve by initiator
requiredVotes,
totalUsers,
expiresAt: Date.now() + TIMING.VOTE_TIMEOUT_MS,
timeoutHandle: null as unknown as ReturnType<typeof setTimeout>, // set by controller
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
}
/**
* Cast a vote. Returns the result or null if no active vote.
* Conductor veto: if conductor (hostId) votes reject, immediately decided as failed.
*/
export function castVote(roomId: string, userId: string, approve: boolean): CastResult | null {
const vote = activeVotes.get(roomId)
if (!vote) return null
// Already voted
if (userId in vote.votes) return null
vote.votes[userId] = approve
// Conductor veto check
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
// Passed: enough approvals
if (approveCount >= vote.requiredVotes) {
return { vote, decided: true, passed: true }
}
// Mathematically impossible to pass
if (rejectCount > vote.totalUsers - vote.requiredVotes) {
return { vote, decided: true, passed: false, reason: 'rejected' }
}
// Not decided yet
return { vote, decided: false, passed: false }
}
/**
* Update the vote threshold when users leave during an active vote.
* Recalculates requiredVotes based on current user count and removes
* the departing user's vote if they had cast one.
*
* Returns true if the vote state was modified (caller should broadcast updated state).
*/
export function updateVoteThreshold(roomId: string, currentUserCount: number, departedUserId?: string): boolean {
const vote = activeVotes.get(roomId)
if (!vote) return false
// Remove departed user's vote if they had cast one
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)
}
/** Convert internal Vote to client-safe VoteState */
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,
}
}
|