Skip to content

Commit 03f6b49

Browse files
authored
Merge pull request #210 from SugaretaNajja/MultiplayerQueueService-matchmaking
MultiplayerQueueService-matchmaking
2 parents ee17336 + ece3a89 commit 03f6b49

3 files changed

Lines changed: 583 additions & 5 deletions

File tree

backend/src/multiplayer-queue/multiplayer-queue.service.prop.spec.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,249 @@ function createMockRepos() {
9292
// ---------------------------------------------------------------------------
9393

9494
describe('MultiplayerQueueService — property-based', () => {
95+
// ── computeCompatibilityScore ──────────────────────────────────────
96+
describe('computeCompatibilityScore', () => {
97+
function score(
98+
service: MultiplayerQueueService,
99+
a: Queue,
100+
b: Queue,
101+
): number {
102+
return (service as any).computeCompatibilityScore(a, b);
103+
}
104+
105+
it('is symmetric for neutral players (no preferences)', async () => {
106+
const { mocks, module } = await buildModule();
107+
const service = module.get<MultiplayerQueueService>(MultiplayerQueueService);
108+
109+
await fc.assert(
110+
fc.property(queuePlayerArb, queuePlayerArb, (p1, p2) => {
111+
const a: Queue = {
112+
...p1, id: 'p1', userId: 'u1',
113+
preferences: { ...p1.preferences, avoidOpponents: undefined, preferredOpponents: undefined },
114+
};
115+
const b: Queue = {
116+
...p2, id: 'p2', userId: 'u2',
117+
preferences: { ...p2.preferences, avoidOpponents: undefined, preferredOpponents: undefined },
118+
};
119+
const sAB = score(service, a, b);
120+
const sBA = score(service, b, a);
121+
expect(sAB).toBe(sBA);
122+
expect(sAB).toBe(10);
123+
}),
124+
);
125+
126+
module.close();
127+
});
128+
129+
it('returns -1 when either player avoids the other', async () => {
130+
const { mocks, module } = await buildModule();
131+
const service = module.get<MultiplayerQueueService>(MultiplayerQueueService);
132+
133+
await fc.assert(
134+
fc.property(queuePlayerArb, queuePlayerArb, (p1, p2) => {
135+
const a: Queue = {
136+
...p1, id: 'p1', userId: 'u1',
137+
preferences: { ...p1.preferences, avoidOpponents: ['u2'] },
138+
};
139+
const b: Queue = {
140+
...p2, id: 'p2', userId: 'u2',
141+
preferences: { ...p2.preferences, avoidOpponents: undefined },
142+
};
143+
expect(score(service, a, b)).toBe(-1);
144+
}),
145+
);
146+
147+
module.close();
148+
});
149+
150+
it('returns 100 for mutual preferredOpponents', async () => {
151+
const { mocks, module } = await buildModule();
152+
const service = module.get<MultiplayerQueueService>(MultiplayerQueueService);
153+
154+
await fc.assert(
155+
fc.property(queuePlayerArb, queuePlayerArb, (p1, p2) => {
156+
const a: Queue = {
157+
...p1, id: 'p1', userId: 'u1',
158+
preferences: { ...p1.preferences, preferredOpponents: ['u2'], avoidOpponents: undefined },
159+
};
160+
const b: Queue = {
161+
...p2, id: 'p2', userId: 'u2',
162+
preferences: { ...p2.preferences, preferredOpponents: ['u1'], avoidOpponents: undefined },
163+
};
164+
expect(score(service, a, b)).toBe(100);
165+
}),
166+
);
167+
168+
module.close();
169+
});
170+
171+
it('scores avoidOpponents over preferredOpponents', async () => {
172+
const { mocks, module } = await buildModule();
173+
const service = module.get<MultiplayerQueueService>(MultiplayerQueueService);
174+
175+
await fc.assert(
176+
fc.property(queuePlayerArb, queuePlayerArb, (p1, p2) => {
177+
const a: Queue = {
178+
...p1, id: 'p1', userId: 'u1',
179+
preferences: {
180+
...p1.preferences,
181+
preferredOpponents: ['u2'],
182+
avoidOpponents: ['u2'],
183+
},
184+
};
185+
const b: Queue = {
186+
...p2, id: 'p2', userId: 'u2',
187+
preferences: { ...p2.preferences, avoidOpponents: undefined },
188+
};
189+
// avoidOpponents takes priority over preferredOpponents
190+
expect(score(service, a, b)).toBe(-1);
191+
}),
192+
);
193+
194+
module.close();
195+
});
196+
});
197+
198+
// ── pairPlayersInGroup ──────────────────────────────────────────────
199+
describe('pairPlayersInGroup', () => {
200+
function pairPlayers(
201+
service: MultiplayerQueueService,
202+
group: Queue[],
203+
): [Queue, Queue][] {
204+
return (service as any).pairPlayersInGroup(group);
205+
}
206+
207+
it('every player in a pair is compatible (no avoidOpponents violated)', async () => {
208+
const { mocks, module } = await buildModule();
209+
const service = module.get<MultiplayerQueueService>(MultiplayerQueueService);
210+
211+
// Batch of 6 players with random avoidOpponents
212+
const playerBatch6Arb = fc.array(queuePlayerArb, { minLength: 4, maxLength: 8 });
213+
214+
await fc.assert(
215+
fc.property(playerBatch6Arb, (players) => {
216+
// Assign unique IDs to avoid self-avoid issues
217+
const indexed = players.map((p, i) => ({
218+
...p,
219+
id: `p${i}`,
220+
userId: `u${i}`,
221+
preferences: {
222+
...p.preferences,
223+
// Ensure avoidOpponents only reference valid userIds that exist
224+
avoidOpponents: p.preferences?.avoidOpponents?.filter(
225+
(uid) => uid !== `u${i}`, // can't avoid yourself
226+
),
227+
},
228+
}));
229+
230+
// Map avoidOpponents to actual indices
231+
for (const p of indexed) {
232+
if (p.preferences?.avoidOpponents) {
233+
p.preferences.avoidOpponents = p.preferences.avoidOpponents.filter(
234+
(uid) => indexed.some((op) => op.userId === uid),
235+
);
236+
}
237+
}
238+
239+
const pairs = pairPlayers(service, indexed);
240+
241+
// Verify no pair violates avoidOpponents
242+
for (const [a, b] of pairs) {
243+
const aAvoidsB = a.preferences?.avoidOpponents?.includes(b.userId) ?? false;
244+
const bAvoidsA = b.preferences?.avoidOpponents?.includes(a.userId) ?? false;
245+
expect(aAvoidsB || bAvoidsA).toBe(false);
246+
}
247+
}),
248+
);
249+
250+
module.close();
251+
});
252+
253+
it('never matches a player twice', async () => {
254+
const { mocks, module } = await buildModule();
255+
const service = module.get<MultiplayerQueueService>(MultiplayerQueueService);
256+
257+
const playerBatchArb = fc.array(queuePlayerArb, { minLength: 2, maxLength: 20 });
258+
259+
await fc.assert(
260+
fc.property(playerBatchArb, (players) => {
261+
const indexed = players.map((p, i) => ({
262+
...p,
263+
id: `p${i}`,
264+
userId: `u${i}`,
265+
}));
266+
267+
const pairs = pairPlayers(service, indexed);
268+
const matchedIds = new Set<string>();
269+
270+
for (const [a, b] of pairs) {
271+
expect(matchedIds.has(a.userId)).toBe(false);
272+
expect(matchedIds.has(b.userId)).toBe(false);
273+
matchedIds.add(a.userId);
274+
matchedIds.add(b.userId);
275+
}
276+
}),
277+
);
278+
279+
module.close();
280+
});
281+
282+
it('leftover count is at most 1 (odd group leaves 1 unmatched)', async () => {
283+
const { mocks, module } = await buildModule();
284+
const service = module.get<MultiplayerQueueService>(MultiplayerQueueService);
285+
286+
await fc.assert(
287+
fc.property(
288+
fc.array(queuePlayerArb, { minLength: 2, maxLength: 20 }),
289+
(players) => {
290+
const indexed = players.map((p, i) => ({
291+
...p,
292+
id: `p${i}`,
293+
userId: `u${i}`,
294+
preferences: {
295+
...p.preferences,
296+
avoidOpponents: undefined, // no avoid conflicts
297+
},
298+
}));
299+
300+
const pairs = pairPlayers(service, indexed);
301+
const matchedCount = pairs.length * 2;
302+
const leftoverCount = indexed.length - matchedCount;
303+
304+
expect(leftoverCount).toBeLessThanOrEqual(1);
305+
},
306+
),
307+
);
308+
309+
module.close();
310+
});
311+
312+
it('result is deterministic', async () => {
313+
const { mocks, module } = await buildModule();
314+
const service = module.get<MultiplayerQueueService>(MultiplayerQueueService);
315+
316+
await fc.assert(
317+
fc.property(
318+
fc.array(queuePlayerArb, { minLength: 2, maxLength: 12 }),
319+
(players) => {
320+
const indexed = players.map((p, i) => ({
321+
...p,
322+
id: `p${i}`,
323+
userId: `u${i}`,
324+
}));
325+
326+
const result1 = pairPlayers(service, indexed);
327+
const result2 = pairPlayers(service, indexed);
328+
329+
expect(result1).toEqual(result2);
330+
},
331+
),
332+
);
333+
334+
module.close();
335+
});
336+
});
337+
95338
// ── groupPlayersForMatching ─────────────────────────────────────────
96339
describe('groupPlayersForMatching', () => {
97340
/** Access the private method. */

0 commit comments

Comments
 (0)