Skip to content

Commit ba3029b

Browse files
ryanbarlow97claude
andcommitted
fix(server): keep the rotation moving when a new lobby fills up early
Queued lobbies are joinable, and one that reaches maxPlayers starts on the spot — hasReachedMaxPlayerCount flips the phase whether or not a countdown is running. So a lobby can leave from anywhere in the queue, including before the next scheduling tick sees it. The next type came from the newest surviving lobby, which meant a lobby that vanished that fast was never accounted for: the master handed out the same type again, and again, and the other two were never created. The type we last created is now remembered past its lobby's report, so the rotation advances even when nothing survives to read it from. Tests cover both that and the promotion order it doesn't change: the live game is still the head of the queue, so a type whose lobbies keep filling early is skipped every time rather than reordered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 593c4ce commit ba3029b

2 files changed

Lines changed: 109 additions & 8 deletions

File tree

src/server/MasterLobbyService.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ interface RotationEntry {
5959
}
6060

6161
/**
62-
* The type to create next: successor of the newest lobby's type. Stateless on
63-
* purpose — no cursor to desynchronise on a restart or a failed create.
62+
* The type to create next: successor of the newest entry's type. Takes the
63+
* queue rather than a cursor, so a restart picks the rotation back up from
64+
* whatever is already open.
6465
*/
6566
export function nextRotationType(
6667
queue: readonly RotationEntry[],
@@ -120,6 +121,13 @@ export class MasterLobbyService {
120121
// gameID => when we told its worker to start counting down, until the worker
121122
// reports the lobby carrying a startsAt.
122123
private readonly pendingPromotions = new Map<string, number>();
124+
// The last type we created, kept after its lobby is reported. Queued lobbies
125+
// are joinable, so one can fill up and start before the next create; reading
126+
// the rotation from survivors alone then hands out that same type forever and
127+
// the other two are never created again.
128+
private lastCreate:
129+
| { publicGameType: ScheduledPublicGameType; createdAt: number }
130+
| undefined;
123131
private started = false;
124132

125133
constructor(
@@ -366,13 +374,16 @@ export class MasterLobbyService {
366374
return;
367375
}
368376

369-
const publicGameType = nextRotationType([...queue, ...pending]);
377+
const publicGameType = nextRotationType([
378+
...queue,
379+
...pending,
380+
...(this.lastCreate ? [this.lastCreate] : []),
381+
]);
370382
const gameID = generateID();
371-
this.pendingCreates.set(gameID, {
372-
publicGameType,
373-
// The worker stamps the real one; this only orders pending creates.
374-
createdAt: Date.now(),
375-
});
383+
// The worker stamps the real one; this only orders pending creates.
384+
const createdAt = Date.now();
385+
this.pendingCreates.set(gameID, { publicGameType, createdAt });
386+
this.lastCreate = { publicGameType, createdAt };
376387
this.sendMessageToWorker({
377388
type: "createGame",
378389
gameID,

tests/server/QueueRotation.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,93 @@ describe("promotion bookkeeping", () => {
187187
).toHaveLength(1);
188188
});
189189
});
190+
191+
/**
192+
* Queued lobbies are joinable, and a lobby that reaches maxPlayers starts on
193+
* the spot (hasReachedMaxPlayerCount flips the phase, startsAt or not). So a
194+
* lobby can leave from the middle of the queue, and the rotation has to cope.
195+
*/
196+
describe("lobbies that fill up before their turn", () => {
197+
afterEach(() => {
198+
vi.restoreAllMocks();
199+
});
200+
201+
const queued = (entry: Entry) => ({ ...entry, numClients: 0 });
202+
const creates = (sent: Record<string, string>[]) =>
203+
sent.filter((m) => m.type === "createGame");
204+
205+
// One short of the target, so exactly one lobby is created per round and the
206+
// rounds can be read off the create messages.
207+
const ALMOST_FULL = 17;
208+
209+
it("keeps rotating when each new lobby fills up before the next round", async () => {
210+
// 17 queued means the newest is a team lobby, so special is created next.
211+
const queue = buildQueue(ALMOST_FULL).map(queued);
212+
const { service, setLobbies, sent } = createScheduler(queue);
213+
const created: string[] = [];
214+
215+
for (let round = 0; round < 3; round++) {
216+
await service.maybeScheduleLobby();
217+
const all = creates(sent());
218+
const create = all[all.length - 1];
219+
created.push(create.publicGameType);
220+
// Its worker reports it, and then it fills up and starts, so the next
221+
// round sees the same 17 lobbies it started with.
222+
setLobbies([
223+
...queue,
224+
queued({
225+
gameID: create.gameID,
226+
publicGameType: create.publicGameType as Entry["publicGameType"],
227+
createdAt: 9000 + round,
228+
}),
229+
]);
230+
await service.maybeScheduleLobby();
231+
setLobbies(queue);
232+
}
233+
234+
expect(created).toEqual(["special", "ffa", "team"]);
235+
});
236+
237+
/**
238+
* Promotion takes the head of the queue, so the live game is the rotation
239+
* with the early starters removed — types can be skipped, and a type whose
240+
* lobbies keep filling early is skipped every time.
241+
*/
242+
it("skips a type entirely when the lobby behind the head keeps filling up", async () => {
243+
let open = buildQueue(18).map(queued);
244+
const { service, setLobbies, sent } = createScheduler(open);
245+
const live: Entry[] = [];
246+
247+
for (let round = 0; round < 9; round++) {
248+
await service.maybeScheduleLobby();
249+
const promotions = sent().filter((m) => m.type === "updateLobby");
250+
const gameID = promotions[promotions.length - 1].gameID;
251+
const head = open.find((l) => l.gameID === gameID)!;
252+
live.push(head);
253+
// The head's countdown runs out. On odd rounds the lobby behind it fills
254+
// up and starts as well, leaving from the middle of the queue.
255+
const alsoStarting = round % 2 === 1 ? open[1] : undefined;
256+
open = open.filter((l) => l !== head && l !== alsoStarting);
257+
setLobbies(open);
258+
}
259+
260+
// Always the queue's own order, oldest first.
261+
expect(live.map((l) => l.createdAt)).toEqual(
262+
[...live]
263+
.sort((a, b) => a.createdAt! - b.createdAt!)
264+
.map((l) => l.createdAt),
265+
);
266+
// Every special sat behind a head that started early, so none ever went live.
267+
expect(live.map((l) => l.publicGameType)).toEqual([
268+
"ffa",
269+
"team",
270+
"ffa",
271+
"team",
272+
"ffa",
273+
"team",
274+
"ffa",
275+
"team",
276+
"ffa",
277+
]);
278+
});
279+
});

0 commit comments

Comments
 (0)