@@ -3,6 +3,7 @@ import Utils from "../utility/utils.js";
33import { QueueDescriptor } from "../types/queues/QueueDescriptor.js"
44import { QueueItem } from "../types/queues/QueueItem.js" ;
55import { createClient } from "redis" ;
6+ import { db } from "./db.js" ;
67
78const redis = createClient ( { url : config . get ( "server.redisUrl" ) , } ) ;
89const DEFAULT_TTL_SECONDS : number = config . get ( "server.queueTTL" ) == 0 ? 3600 : config . get ( "server.queueTTL" ) ;
@@ -184,6 +185,202 @@ export class QueueService {
184185 return meta . maxSize ;
185186 }
186187
188+ // Normalize player ratings helper
189+ static normalizePlayerRatings ( players : QueueItem [ ] ) : QueueItem [ ] {
190+ const knownRatings = players
191+ . map ( ( p ) => p . hltvRating )
192+ . filter ( ( r ) => typeof r === 'number' ) as number [ ] ;
193+ let fallbackRating = 1.0 ;
194+ if ( knownRatings . length > 0 ) {
195+ knownRatings . sort ( ( a , b ) => a - b ) ;
196+ const mid = Math . floor ( knownRatings . length / 2 ) ;
197+ fallbackRating = knownRatings . length % 2 === 0
198+ ? ( knownRatings [ mid - 1 ] + knownRatings [ mid ] ) / 2
199+ : knownRatings [ mid ] ;
200+ }
201+
202+ return players . map ( ( p ) => {
203+ if ( typeof p . hltvRating === 'number' ) return { ...p , hltvRating : p . hltvRating } ;
204+ const jitter = ( Math . random ( ) - 0.5 ) * 0.1 * fallbackRating ;
205+ return { ...p , hltvRating : fallbackRating + jitter } ;
206+ } ) ;
207+ }
208+
209+ /**
210+ * Create two teams from the queue for the given slug.
211+ * - Uses the first `maxSize` players in the queue
212+ * - Attempts to balance teams by `hltvRating` while keeping randomness
213+ * - Stores result in `queue-teams:<slug>` and removes selected players from the queue
214+ * - Team name is `team_<CAPTAIN>` where CAPTAIN is the first member's steamId
215+ */
216+ static async createTeamsFromQueue ( slug : string ) : Promise < { teams : { name : string ; members : QueueItem [ ] } [ ] } > {
217+ const key = `queue:${ slug } ` ;
218+ const meta = await getQueueMetaOrThrow ( slug ) ;
219+
220+ // Ensure redis connected
221+ if ( redis . isOpen === false ) {
222+ await redis . connect ( ) ;
223+ }
224+
225+ const rawItems = await redis . lRange ( key , 0 , - 1 ) ;
226+ if ( ! rawItems || rawItems . length === 0 ) {
227+ throw new Error ( `Queue ${ slug } is empty.` ) ;
228+ }
229+
230+ const maxPlayers = meta . maxSize || rawItems . length ;
231+
232+ if ( rawItems . length < maxPlayers ) {
233+ throw new Error ( `Not enough players in queue to form teams. Have ${ rawItems . length } , need ${ maxPlayers } .` ) ;
234+ }
235+
236+ // Take the first N entries (FIFO semantics)
237+ const selectedRaw = rawItems . slice ( 0 , maxPlayers ) ;
238+ const players : QueueItem [ ] = selectedRaw . map ( ( r ) => JSON . parse ( r ) ) ;
239+
240+ // Compute a robust fallback for missing ratings: use median of known ratings
241+ const knownRatings = players
242+ . map ( ( p ) => p . hltvRating )
243+ . filter ( ( r ) => typeof r === 'number' ) as number [ ] ;
244+ let fallbackRating = 1.0 ;
245+ if ( knownRatings . length > 0 ) {
246+ knownRatings . sort ( ( a , b ) => a - b ) ;
247+ const mid = Math . floor ( knownRatings . length / 2 ) ;
248+ fallbackRating = knownRatings . length % 2 === 0
249+ ? ( knownRatings [ mid - 1 ] + knownRatings [ mid ] ) / 2
250+ : knownRatings [ mid ] ;
251+ }
252+
253+ // Normalize ratings so every player has a numeric rating using helper
254+ const normPlayers = QueueService . normalizePlayerRatings ( players ) ;
255+
256+ // Sort players by rating descending (strongest first)
257+ normPlayers . sort ( ( a : QueueItem , b : QueueItem ) => ( b . hltvRating ! - a . hltvRating ! ) ) ;
258+
259+ // Greedy assignment with small randomness to avoid deterministic splits
260+ const teamA : QueueItem [ ] = [ ] ;
261+ const teamB : QueueItem [ ] = [ ] ;
262+ let sumA = 0 ;
263+ let sumB = 0 ;
264+ const flipProb = 0.10 ; // 10% chance to flip assignment to add randomness
265+
266+ const targetSizeA = Math . ceil ( maxPlayers / 2 ) ;
267+ const targetSizeB = Math . floor ( maxPlayers / 2 ) ;
268+
269+ for ( const p of normPlayers ) {
270+ // If one team is already full, push to the other
271+ if ( teamA . length >= targetSizeA ) {
272+ teamB . push ( p ) ;
273+ sumB += p . hltvRating ! ;
274+ continue ;
275+ }
276+ if ( teamB . length >= targetSizeB ) {
277+ teamA . push ( p ) ;
278+ sumA += p . hltvRating ! ;
279+ continue ;
280+ }
281+
282+ // Normally assign to the team with smaller total rating
283+ let assignToA = sumA <= sumB ;
284+
285+ // small random flip
286+ if ( Math . random ( ) < flipProb ) assignToA = ! assignToA ;
287+
288+ if ( assignToA ) {
289+ teamA . push ( p ) ;
290+ sumA += p . hltvRating ! ;
291+ } else {
292+ teamB . push ( p ) ;
293+ sumB += p . hltvRating ! ;
294+ }
295+ }
296+
297+ // Final size-adjustment (move lowest-rated if needed)
298+ while ( teamA . length > targetSizeA ) {
299+ // move lowest-rated from A to B
300+ teamA . sort ( ( a , b ) => a . hltvRating ! - b . hltvRating ! ) ;
301+ const moved = teamA . shift ( ) ! ;
302+ sumA -= moved . hltvRating ! ;
303+ teamB . push ( moved ) ;
304+ sumB += moved . hltvRating ! ;
305+ }
306+ while ( teamB . length > targetSizeB ) {
307+ teamB . sort ( ( a , b ) => a . hltvRating ! - b . hltvRating ! ) ;
308+ const moved = teamB . shift ( ) ! ;
309+ sumB -= moved . hltvRating ! ;
310+ teamA . push ( moved ) ;
311+ sumA += moved . hltvRating ! ;
312+ }
313+
314+ // Captain is first user in each team array
315+ const captainA = teamA [ 0 ] ;
316+ const captainB = teamB [ 0 ] ;
317+
318+ const teams = [
319+ { name : `team_${ captainA ?. steamId ?? 'A' } ` , members : teamA } ,
320+ { name : `team_${ captainB ?. steamId ?? 'B' } ` , members : teamB } ,
321+ ] ;
322+
323+ // Persist teams to database (team + team_auth_names)
324+ // Resolve queue owner to internal user_id if present
325+ let ownerUserId : number | null = 0 ;
326+ try {
327+ if ( meta . ownerId ) {
328+ const ownerRows = await db . query ( 'SELECT id FROM user WHERE steam_id = ?' , [ meta . ownerId ] ) ;
329+ if ( ownerRows && ownerRows . length > 0 && ownerRows [ 0 ] . id ) {
330+ ownerUserId = ownerRows [ 0 ] . id ;
331+ }
332+ }
333+ } catch ( err ) {
334+ // fallback to 0 (system) if DB lookup fails
335+ ownerUserId = 0 ;
336+ }
337+
338+ for ( const t of teams ) {
339+ const teamInsert = await db . query ( "INSERT INTO team (user_id, name, flag, logo, tag, public_team) VALUES ?" , [ [ [
340+ ownerUserId || 0 ,
341+ t . name ,
342+ null ,
343+ null ,
344+ null ,
345+ 0
346+ ] ] ] ) ;
347+ // @ts -ignore insertId from RowDataPacket
348+ const insertedTeamId = ( teamInsert as any ) . insertId || null ;
349+ if ( insertedTeamId ) {
350+ // prepare team_auth_names bulk insert
351+ const authRows : Array < Array < any > > = [ ] ;
352+ for ( let i = 0 ; i < t . members . length ; i ++ ) {
353+ const member = t . members [ i ] ;
354+ const isCaptain = i === 0 ? 1 : 0 ;
355+ authRows . push ( [ insertedTeamId , member . steamId , '' , isCaptain , 0 ] ) ;
356+ }
357+ if ( authRows . length > 0 ) {
358+ await db . query ( "INSERT INTO team_auth_names (team_id, auth, name, captain, coach) VALUES ?" , [ authRows ] ) ;
359+ }
360+ }
361+ }
362+
363+ // Store teams in Redis and remove selected players from queue
364+ const teamsKey = `queue-teams:${ slug } ` ;
365+ // TTL based on remaining queue meta TTL
366+ const remainingSeconds = Math . max ( 1 , Math . floor ( ( meta . expiresAt - Date . now ( ) ) / 1000 ) ) ;
367+
368+ await redis . set ( teamsKey , JSON . stringify ( { teams } ) , { EX : remainingSeconds } ) ;
369+
370+ // Remove selected players from queue list and update meta
371+ for ( const raw of selectedRaw ) {
372+ // remove one occurrence
373+ await redis . lRem ( key , 1 , raw ) ;
374+ meta . currentPlayers -= 1 ;
375+ }
376+
377+ // Persist updated meta and expire
378+ await redis . set ( `queue-meta:${ slug } ` , JSON . stringify ( meta ) , { EX : remainingSeconds } ) ;
379+ await redis . expire ( key , remainingSeconds ) ;
380+
381+ return { teams } ;
382+ }
383+
187384}
188385
189386async function getQueueMetaOrThrow ( slug : string ) : Promise < QueueDescriptor > {
0 commit comments