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,
  }
}