Skip to content

Commit cdd92e1

Browse files
committed
Include tests for team creations.
At max players create a team from the queue that is inserted into the database.
1 parent b562428 commit cdd92e1

File tree

5 files changed

+306
-3
lines changed

5 files changed

+306
-3
lines changed

__test__/queue.test.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { agent } from 'supertest';
2+
import app from '../app.js';
3+
import { db } from '../src/services/db.js';
4+
import { QueueService } from '../src/services/queue.js';
5+
const request = agent(app);
6+
7+
describe('Queue routes', () => {
8+
beforeAll(() => {
9+
// Authenticate mock steam user (mock strategy)
10+
return request.get('/auth/steam/return').expect(302);
11+
});
12+
13+
it('should create a queue and return URL', async () => {
14+
const payload = [ { maxPlayers: 4, private: false } ];
15+
const res = await request
16+
.post('/queue/')
17+
.set('Content-Type', 'application/json')
18+
.send(payload)
19+
.expect(200);
20+
21+
expect(res.body.url).toMatch(/\/queue\//);
22+
// Save the slug for subsequent tests
23+
const slug = res.body.url.split('/').pop();
24+
expect(slug).toBeDefined();
25+
// store on global for other tests
26+
global.__TEST_QUEUE_SLUG = slug;
27+
});
28+
29+
it('should add users to the queue and create teams when full', async () => {
30+
const slug = global.__TEST_QUEUE_SLUG;
31+
// Add 4 users; the first is the creator (already added) so add 3 more
32+
// Using the mockProfile steam id and some fake ids for others
33+
const extraUsers = ['76561198025644195','76561198025644196','76561198025644197'];
34+
// Add users directly to the queue service to simulate distinct steam IDs (route uses req.user)
35+
for (const id of extraUsers) {
36+
await QueueService.addUserToQueue(slug, id);
37+
}
38+
39+
// Now trigger team creation from the service (would normally be called by the route once full)
40+
const result = await QueueService.createTeamsFromQueue(slug);
41+
expect(result).toBeDefined();
42+
expect(Array.isArray(result.teams)).toBe(true);
43+
expect(result.teams.length).toBe(2);
44+
45+
// Check DB for created teams; there should be at least 2 inserted with team_auth_names
46+
const teams = await db.query('SELECT id FROM team WHERE name LIKE ?', [`team_%`]);
47+
expect(teams.length).toBeGreaterThanOrEqual(2);
48+
const teamId = teams[0].id;
49+
const auths = await db.query('SELECT auth FROM team_auth_names WHERE team_id = ?', [teamId]);
50+
expect(auths.length).toBeGreaterThan(0);
51+
});
52+
53+
describe('rating normalization', () => {
54+
test('uses median when some ratings are present and adds jitter for missing', () => {
55+
const realRandom = Math.random;
56+
Math.random = () => 0.5;
57+
58+
const players = [
59+
{ steamId: '1', timestamp: 1, hltvRating: 2 },
60+
{ steamId: '2', timestamp: 2, hltvRating: 4 },
61+
{ steamId: '3', timestamp: 3, hltvRating: undefined },
62+
];
63+
const out = QueueService.normalizePlayerRatings(players);
64+
expect(out.find(p => p.steamId === '3').hltvRating).toBeCloseTo(3);
65+
66+
Math.random = realRandom;
67+
});
68+
69+
test('all missing ratings fall back to 1.0 +/- jitter', () => {
70+
const realRandom = Math.random;
71+
Math.random = () => 0.25;
72+
73+
const players = [
74+
{ steamId: 'a', timestamp: 1, hltvRating: undefined },
75+
{ steamId: 'b', timestamp: 2, hltvRating: undefined }
76+
];
77+
const out = QueueService.normalizePlayerRatings(players);
78+
expect(out[0].hltvRating).toBeCloseTo(0.975);
79+
80+
Math.random = realRandom;
81+
});
82+
});
83+
});

jest_config/jest.queue.config.cjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
process.env.NODE_ENV = "test";
2+
module.exports = {
3+
preset: 'ts-jest/presets/js-with-ts-esm',
4+
resolver: "jest-ts-webcompat-resolver",
5+
clearMocks: true,
6+
globalTeardown: "./test-teardown-globals.cjs",
7+
testEnvironment: "node",
8+
roots: [
9+
"../__test__"
10+
],
11+
testMatch: [
12+
"**/__test__/queue.test.js",
13+
"**/@(queue.)+(spec|test).[tj]s?(x)"
14+
],
15+
verbose: false,
16+
};

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,12 @@
5151
"migrate-drop-prod": "MYSQL_FLAGS=\"-CONNECT_WITH_DB\" db-migrate --env production --config config/production.json db:drop get5",
5252
"migrate-drop-test": "MYSQL_FLAGS=\"-CONNECT_WITH_DB\" db-migrate --env test --config config/test.json db:drop get5test",
5353
"prod": "NODE_ENV=production yarn migrate-create-prod && yarn migrate-prod-upgrade",
54-
"test": "yarn build && NODE_ENV=test && yarn test:setup-user && yarn migrate-drop-test && yarn migrate-create-test && yarn migrate-test-upgrade && yarn test:user && yarn test:gameservers && yarn test:teams && yarn test:matches && yarn test:seasons && yarn test:vetoes && yarn test:mapstats && yarn test:playerstats && yarn test:vetosides && yarn test:maplists",
54+
"test": "yarn build && NODE_ENV=test && yarn test:setup-user && yarn migrate-drop-test && yarn migrate-create-test && yarn migrate-test-upgrade && yarn test:user && yarn test:gameservers && yarn test:teams && yarn test:matches && yarn test:seasons && yarn test:vetoes && yarn test:mapstats && yarn test:playerstats && yarn test:vetosides && yarn test:maplists && yarn test:queue",
5555
"test:gameservers": "yarn test:removeID && NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.gameservers.config.cjs",
5656
"test:mapstats": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.mapstats.config.cjs",
5757
"test:maplists": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.maplist.config.cjs",
5858
"test:matches": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.matches.config.cjs",
59+
"test:queue": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.queue.config.cjs",
5960
"test:playerstats": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.playerstats.config.cjs",
6061
"test:removeID": "sed -i -e 's.\"steam_ids\": \"[0-9][0-9]*\".\"steam_ids\": \"super_admins,go,here\".g' ./config/test.json",
6162
"test:seasons": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.seasons.config.cjs",

src/routes/queue.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,8 +334,14 @@ router.put('/:slug', Utils.ensureAuthenticated, async (req, res) => {
334334
if (action === 'join') {
335335
await QueueService.addUserToQueue(slug, req.user?.steam_id!);
336336
if (currentQueueCount == maxQueueCount) {
337-
// TODO: Add in logic to create teams, or create a new queue with users' steam IDs and queue ID
338-
// to get ready to shuffle teams.
337+
// Queue is full — create teams and persist them.
338+
try {
339+
const result = await QueueService.createTeamsFromQueue(slug);
340+
return res.status(200).json({ success: true, teams: result.teams });
341+
} catch (err) {
342+
console.error('Error creating teams from queue:', err);
343+
// Fall through to return success=true but without teams
344+
}
339345
}
340346
} else if (action === 'leave') {
341347
await QueueService.removeUserFromQueue(slug, req.user?.steam_id!, req.user?.steam_id!);

src/services/queue.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Utils from "../utility/utils.js";
33
import { QueueDescriptor } from "../types/queues/QueueDescriptor.js"
44
import { QueueItem } from "../types/queues/QueueItem.js";
55
import { createClient } from "redis";
6+
import { db } from "./db.js";
67

78
const redis = createClient({ url: config.get("server.redisUrl"), });
89
const 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

189386
async function getQueueMetaOrThrow(slug: string): Promise<QueueDescriptor> {

0 commit comments

Comments
 (0)