mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-08 06:00:40 +00:00
2a82b01e30
Hosts can't sit on a public listing: setListed(true) records listedAt, and once listedAt + HOSTED_LOBBY_AUTO_START_MS passes, GameManager's tick arms the normal start countdown (same path as the host's Start button, honoring startDelay). Cancelling the countdown re-arms on the next tick; unlisting cancels the deadline and relisting starts a fresh one. Duplicate setListed(true) calls don't extend it. The deadline rides to the host as autoStartAt in lobby info, rendered as an amber countdown next to the Public/Private switch in the host modal header (hidden once the real start countdown takes over). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
824 lines
24 KiB
TypeScript
824 lines
24 KiB
TypeScript
import EventEmitter from "events";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { WebSocket } from "ws";
|
|
import {
|
|
Difficulty,
|
|
GameMapSize,
|
|
GameMapType,
|
|
GameMode,
|
|
GameType,
|
|
} from "../../src/core/game/Game";
|
|
import {
|
|
HOSTED_LOBBY_AUTO_START_MS,
|
|
MAX_HOSTED_LOBBIES,
|
|
} from "../../src/core/Schemas";
|
|
import { Client } from "../../src/server/Client";
|
|
import { GameManager } from "../../src/server/GameManager";
|
|
import {
|
|
GamePhase,
|
|
GameServer,
|
|
hashPersistentID,
|
|
} from "../../src/server/GameServer";
|
|
import {
|
|
InternalGameInfo,
|
|
InternalPublicGames,
|
|
} from "../../src/server/IPCBridgeSchema";
|
|
import { MasterLobbyService } from "../../src/server/MasterLobbyService";
|
|
import { ServerEnv } from "../../src/server/ServerEnv";
|
|
import { WorkerLobbyService } from "../../src/server/WorkerLobbyService";
|
|
|
|
vi.mock("../../src/server/Logger", () => ({
|
|
logger: {
|
|
child: () => ({
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../src/server/PollingLoop", () => ({
|
|
startPolling: vi.fn(),
|
|
}));
|
|
|
|
const mockLogger: any = {
|
|
child: vi.fn().mockReturnThis(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
|
|
const CREATOR = "11111111-1111-4111-8111-111111111111";
|
|
const OTHER_CREATOR = "22222222-2222-4222-8222-222222222222";
|
|
|
|
function makeGame(
|
|
id = "test-game",
|
|
creatorPersistentID: string | undefined = CREATOR,
|
|
config: Record<string, unknown> = {},
|
|
) {
|
|
return new GameServer(
|
|
id,
|
|
mockLogger,
|
|
Date.now(),
|
|
{ gameType: GameType.Private, ...config } as any,
|
|
creatorPersistentID,
|
|
);
|
|
}
|
|
|
|
describe("GameServer listing", () => {
|
|
it("is unlisted by default and toggles via setListed", () => {
|
|
const game = makeGame();
|
|
expect(game.isListed()).toBe(false);
|
|
game.setListed(true);
|
|
expect(game.isListed()).toBe(true);
|
|
game.setListed(false);
|
|
expect(game.isListed()).toBe(false);
|
|
});
|
|
|
|
it("cannot be listed through update_game_config", () => {
|
|
const game = makeGame();
|
|
const result = game.handleIntent(
|
|
{ type: "update_game_config", config: { listed: true } } as any,
|
|
{
|
|
clientID: "c1",
|
|
isLobbyCreator: true,
|
|
isAdmin: false,
|
|
isAdminBot: false,
|
|
},
|
|
);
|
|
expect(result.status).toBe(200);
|
|
expect(game.isListed()).toBe(false);
|
|
});
|
|
|
|
it("identifies its creator", () => {
|
|
const game = makeGame();
|
|
expect(game.isCreator(CREATOR)).toBe(true);
|
|
expect(game.isCreator(OTHER_CREATOR)).toBe(false);
|
|
});
|
|
|
|
it("never matches a creator when created without one", () => {
|
|
const game = new GameServer("no-creator", mockLogger, Date.now(), {
|
|
gameType: GameType.Private,
|
|
} as any);
|
|
expect(game.isCreator(CREATOR)).toBe(false);
|
|
expect(game.hashedCreatorID()).toBeUndefined();
|
|
});
|
|
|
|
it("exposes a stable hash of the creator id, not the raw id", () => {
|
|
const game = makeGame();
|
|
const hashed = game.hashedCreatorID();
|
|
expect(hashed).toBe(hashPersistentID(CREATOR));
|
|
expect(hashed).not.toContain(CREATOR);
|
|
expect(hashed).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it("reports a join whitelist only when non-empty", () => {
|
|
expect(makeGame().hasJoinWhitelist()).toBe(false);
|
|
expect(
|
|
makeGame("g", CREATOR, { allowedPublicIds: [] }).hasJoinWhitelist(),
|
|
).toBe(false);
|
|
expect(
|
|
makeGame("g", CREATOR, { allowedPublicIds: ["p1"] }).hasJoinWhitelist(),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("delists when a join whitelist is added via config update", () => {
|
|
const game = makeGame();
|
|
game.setListed(true);
|
|
|
|
game.updateGameConfig({ allowedPublicIds: [] });
|
|
expect(game.isListed()).toBe(true);
|
|
|
|
game.updateGameConfig({ allowedPublicIds: ["p1"] });
|
|
expect(game.isListed()).toBe(false);
|
|
});
|
|
|
|
it("exposes the listed flag in gameInfo for private lobbies only", () => {
|
|
const game = makeGame();
|
|
expect(game.gameInfo().listed).toBe(false);
|
|
game.setListed(true);
|
|
expect(game.gameInfo().listed).toBe(true);
|
|
|
|
const pub = new GameServer("pub", mockLogger, Date.now(), {
|
|
gameType: GameType.Public,
|
|
} as any);
|
|
expect(pub.gameInfo().listed).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("GameManager.listedLobbies", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("returns only listed private lobbies still in the lobby phase", () => {
|
|
const gm = new GameManager(mockLogger);
|
|
const unlisted = gm.createGame("g-unlisted", undefined, CREATOR)!;
|
|
const listed = gm.createGame("g-listed", undefined, CREATOR)!;
|
|
listed.setListed(true);
|
|
|
|
expect(gm.listedLobbies().map((g) => g.id)).toEqual(["g-listed"]);
|
|
expect(gm.publicLobbies()).toEqual([]);
|
|
expect(unlisted.isListed()).toBe(false);
|
|
});
|
|
|
|
it("excludes public games even if flagged listed", () => {
|
|
const gm = new GameManager(mockLogger);
|
|
const pub = gm.createGame(
|
|
"g-public",
|
|
{ gameType: GameType.Public } as any,
|
|
undefined,
|
|
)!;
|
|
pub.setListed(true);
|
|
|
|
expect(gm.listedLobbies()).toEqual([]);
|
|
expect(gm.publicLobbies().map((g) => g.id)).toEqual(["g-public"]);
|
|
});
|
|
|
|
it("drops a listed lobby once its game starts", () => {
|
|
const gm = new GameManager(mockLogger);
|
|
const game = gm.createGame("g-started", undefined, CREATOR)!;
|
|
game.setListed(true);
|
|
expect(gm.listedLobbies()).toHaveLength(1);
|
|
|
|
(game as any)._hasStarted = true;
|
|
expect(gm.listedLobbies()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("listed lobby auto-start", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("tracks the deadline from when the lobby was listed", () => {
|
|
const game = makeGame();
|
|
expect(game.autoStartAt()).toBeUndefined();
|
|
|
|
game.setListed(true);
|
|
const deadline = Date.now() + HOSTED_LOBBY_AUTO_START_MS;
|
|
expect(game.autoStartAt()).toBe(deadline);
|
|
expect(game.gameInfo().autoStartAt).toBe(deadline);
|
|
|
|
// A duplicate toggle must not extend the deadline...
|
|
vi.setSystemTime(Date.now() + 60_000);
|
|
game.setListed(true);
|
|
expect(game.autoStartAt()).toBe(deadline);
|
|
|
|
// ...unlisting cancels it, and relisting starts a fresh one.
|
|
game.setListed(false);
|
|
expect(game.autoStartAt()).toBeUndefined();
|
|
game.setListed(true);
|
|
expect(game.autoStartAt()).toBe(Date.now() + HOSTED_LOBBY_AUTO_START_MS);
|
|
});
|
|
|
|
it("arms the start countdown only once the deadline passes", () => {
|
|
const gm = new GameManager(mockLogger);
|
|
const game = gm.createGame(
|
|
"g-auto-start",
|
|
{ startDelay: 30 } as any,
|
|
CREATOR,
|
|
)!;
|
|
game.setListed(true);
|
|
|
|
vi.setSystemTime(Date.now() + HOSTED_LOBBY_AUTO_START_MS - 1000);
|
|
gm.tick();
|
|
expect(game.gameInfo().startsAt).toBeUndefined();
|
|
|
|
vi.setSystemTime(Date.now() + 2000);
|
|
gm.tick();
|
|
expect(game.gameInfo().startsAt).toBe(Date.now() + 30_000);
|
|
// Still listed while the start countdown runs (the phase change on
|
|
// start delists).
|
|
expect(gm.listedLobbies()).toHaveLength(1);
|
|
});
|
|
|
|
it("never auto-starts an unlisted lobby", () => {
|
|
const gm = new GameManager(mockLogger);
|
|
const game = gm.createGame("g-manual", undefined, CREATOR)!;
|
|
|
|
vi.setSystemTime(Date.now() + HOSTED_LOBBY_AUTO_START_MS * 2);
|
|
gm.tick();
|
|
expect(game.gameInfo().startsAt).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
function fakeWs() {
|
|
const ws = new EventEmitter() as any;
|
|
ws.readyState = WebSocket.OPEN;
|
|
ws.send = vi.fn();
|
|
ws.close = vi.fn();
|
|
return ws;
|
|
}
|
|
|
|
function makeClient(clientID: string, persistentID: string, ws: any) {
|
|
return new Client(
|
|
clientID,
|
|
persistentID,
|
|
null,
|
|
null,
|
|
undefined,
|
|
"1.2.3.4",
|
|
`user_${clientID}`,
|
|
null,
|
|
ws,
|
|
undefined,
|
|
undefined,
|
|
[],
|
|
);
|
|
}
|
|
|
|
describe("host-left lobby teardown", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("ends, delists and prunes an unstarted lobby when the host leaves", () => {
|
|
const gm = new GameManager(mockLogger);
|
|
const game = gm.createGame("g-host-leaves", undefined, CREATOR)!;
|
|
game.setListed(true);
|
|
|
|
const hostWs = fakeWs();
|
|
const guestWs = fakeWs();
|
|
expect(game.joinClient(makeClient("host", CREATOR, hostWs))).toBe("joined");
|
|
expect(game.joinClient(makeClient("guest", OTHER_CREATOR, guestWs))).toBe(
|
|
"joined",
|
|
);
|
|
expect(gm.listedLobbies()).toHaveLength(1);
|
|
|
|
hostWs.emit("close");
|
|
|
|
// Remaining players are kicked and the ghost leaves the listing...
|
|
expect(guestWs.close).toHaveBeenCalled();
|
|
expect(game.phase()).toBe(GamePhase.Finished);
|
|
expect(gm.listedLobbies()).toEqual([]);
|
|
|
|
// ...and the next manager tick prunes the game entirely, freeing the
|
|
// creator's one-listing quota.
|
|
gm.tick();
|
|
expect(gm.game("g-host-leaves")).toBeNull();
|
|
});
|
|
|
|
it("tears down even when the host socket was already dead on join", () => {
|
|
const gm = new GameManager(mockLogger);
|
|
const game = gm.createGame("g-dead-socket", undefined, CREATOR)!;
|
|
game.setListed(true);
|
|
|
|
const hostWs = fakeWs();
|
|
hostWs.readyState = WebSocket.CLOSED;
|
|
game.joinClient(makeClient("host", CREATOR, hostWs));
|
|
|
|
expect(game.phase()).toBe(GamePhase.Finished);
|
|
expect(gm.listedLobbies()).toEqual([]);
|
|
});
|
|
|
|
it("rejects joins into an ended lobby before it is pruned", () => {
|
|
const game = makeGame();
|
|
(game as any)._hasEnded = true;
|
|
expect(game.joinClient({} as any)).toBe("rejected");
|
|
});
|
|
});
|
|
|
|
describe("listed lobby host powers", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
const asHost = {
|
|
clientID: "host",
|
|
isLobbyCreator: true,
|
|
isAdmin: false,
|
|
isAdminBot: false,
|
|
};
|
|
|
|
it("reports host cheats only when a cheat is actually granted", () => {
|
|
expect(makeGame().hasHostCheats()).toBe(false);
|
|
expect(makeGame("g", CREATOR, { hostCheats: {} }).hasHostCheats()).toBe(
|
|
false,
|
|
);
|
|
expect(
|
|
makeGame("g", CREATOR, {
|
|
hostCheats: { infiniteGold: false, infiniteTroops: false },
|
|
}).hasHostCheats(),
|
|
).toBe(false);
|
|
expect(
|
|
makeGame("g", CREATOR, {
|
|
hostCheats: { infiniteGold: true },
|
|
}).hasHostCheats(),
|
|
).toBe(true);
|
|
expect(
|
|
makeGame("g", CREATOR, {
|
|
hostCheats: { goldMultiplier: 2 },
|
|
}).hasHostCheats(),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("blocks host kicks while listed; admins still can", () => {
|
|
const game = makeGame("g-kick");
|
|
game.joinClient(makeClient("host", CREATOR, fakeWs()));
|
|
game.joinClient(makeClient("guest", OTHER_CREATOR, fakeWs()));
|
|
game.setListed(true);
|
|
|
|
const kick = { type: "kick_player", targetClientID: "guest" } as any;
|
|
expect(game.handleIntent(kick, asHost).status).toBe(403);
|
|
|
|
// Unlisting restores the host's kick power.
|
|
game.setListed(false);
|
|
expect(game.handleIntent(kick, asHost).status).toBe(200);
|
|
});
|
|
|
|
it("lets admins kick in a listed lobby", () => {
|
|
const game = makeGame("g-admin-kick");
|
|
game.joinClient(makeClient("host", CREATOR, fakeWs()));
|
|
game.joinClient(makeClient("guest", OTHER_CREATOR, fakeWs()));
|
|
game.setListed(true);
|
|
|
|
const asAdmin = {
|
|
clientID: "admin",
|
|
isLobbyCreator: false,
|
|
isAdmin: true,
|
|
isAdminBot: false,
|
|
};
|
|
expect(
|
|
game.handleIntent(
|
|
{ type: "kick_player", targetClientID: "guest" } as any,
|
|
asAdmin,
|
|
).status,
|
|
).toBe(200);
|
|
});
|
|
|
|
it("rejects enabling host cheats while listed", () => {
|
|
const game = makeGame();
|
|
game.setListed(true);
|
|
|
|
const cheats = {
|
|
type: "update_game_config",
|
|
config: { hostCheats: { infiniteGold: true } },
|
|
} as any;
|
|
expect(game.handleIntent(cheats, asHost).status).toBe(409);
|
|
expect(game.hasHostCheats()).toBe(false);
|
|
|
|
// A neutral hostCheats block still goes through (the client always
|
|
// sends the field), as do real cheats once the lobby is unlisted.
|
|
expect(
|
|
game.handleIntent(
|
|
{ type: "update_game_config", config: { hostCheats: {} } } as any,
|
|
asHost,
|
|
).status,
|
|
).toBe(200);
|
|
game.setListed(false);
|
|
expect(game.handleIntent(cheats, asHost).status).toBe(200);
|
|
expect(game.hasHostCheats()).toBe(true);
|
|
});
|
|
});
|
|
|
|
function hostedLobby(
|
|
gameID: string,
|
|
creatorID: string | undefined,
|
|
extra: Partial<InternalGameInfo> = {},
|
|
): InternalGameInfo {
|
|
return {
|
|
gameID,
|
|
numClients: 0,
|
|
publicGameType: "hosted",
|
|
creatorID,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
describe("MasterLobbyService hosted lobbies", () => {
|
|
function createService() {
|
|
vi.spyOn(ServerEnv, "numWorkers").mockReturnValue(2);
|
|
vi.spyOn(ServerEnv, "workerIndex").mockReturnValue(1);
|
|
vi.spyOn(ServerEnv, "gameCreationRate").mockReturnValue(60_000);
|
|
const playlist = {
|
|
gameConfig: vi.fn().mockResolvedValue({ gameType: GameType.Public }),
|
|
};
|
|
const log = { info: vi.fn(), error: vi.fn() } as any;
|
|
const service = new MasterLobbyService(playlist as any, log);
|
|
|
|
const workers = [1, 2].map((id) => {
|
|
const worker = new EventEmitter();
|
|
(worker as any).send = vi.fn();
|
|
service.registerWorker(id, worker as any);
|
|
return worker;
|
|
});
|
|
return { service, workers };
|
|
}
|
|
|
|
function sentMessages(worker: EventEmitter): any[] {
|
|
return ((worker as any).send as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
(c) => c[0],
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("aggregates hosted lobbies and dedupes by creator across workers", () => {
|
|
const { service, workers } = createService();
|
|
workers[0].emit("message", {
|
|
type: "lobbyList",
|
|
lobbies: [
|
|
hostedLobby("bbb", "creator-a"),
|
|
hostedLobby("ccc", "creator-b"),
|
|
],
|
|
});
|
|
// Same creator listed a second lobby on another worker before the first
|
|
// broadcast landed; only the stable-sort winner may survive.
|
|
workers[1].emit("message", {
|
|
type: "lobbyList",
|
|
lobbies: [hostedLobby("aaa", "creator-a")],
|
|
});
|
|
|
|
(service as any).broadcastLobbies();
|
|
|
|
const broadcast = sentMessages(workers[0]).find(
|
|
(m) => m.type === "lobbiesBroadcast",
|
|
);
|
|
expect(broadcast).toBeDefined();
|
|
const hosted = broadcast.publicGames.games.hosted;
|
|
expect(hosted.map((l: InternalGameInfo) => l.gameID)).toEqual([
|
|
"aaa",
|
|
"ccc",
|
|
]);
|
|
});
|
|
|
|
it("delists a dedup loser only after two consecutive losing broadcasts", () => {
|
|
const { service, workers } = createService();
|
|
workers[0].emit("message", {
|
|
type: "lobbyList",
|
|
lobbies: [hostedLobby("bbb", "creator-a")],
|
|
});
|
|
workers[1].emit("message", {
|
|
type: "lobbyList",
|
|
lobbies: [hostedLobby("aaa", "creator-a")],
|
|
});
|
|
|
|
(service as any).broadcastLobbies();
|
|
(service as any).broadcastLobbies();
|
|
|
|
const broadcasts = sentMessages(workers[0]).filter(
|
|
(m) => m.type === "lobbiesBroadcast",
|
|
);
|
|
// First loss could be a stale worker report; only the second in a row
|
|
// triggers the delist.
|
|
expect(broadcasts[0].delistGameIDs).toBeUndefined();
|
|
expect(broadcasts[1].delistGameIDs).toEqual(["bbb"]);
|
|
});
|
|
|
|
it("caps hosted lobbies at MAX_HOSTED_LOBBIES and delists the overflow", () => {
|
|
const { service, workers } = createService();
|
|
const lobbies = Array.from({ length: MAX_HOSTED_LOBBIES + 1 }, (_, i) =>
|
|
hostedLobby(`g${String(i).padStart(2, "0")}`, `creator-${i}`),
|
|
);
|
|
workers[0].emit("message", { type: "lobbyList", lobbies });
|
|
|
|
(service as any).broadcastLobbies();
|
|
(service as any).broadcastLobbies();
|
|
|
|
const broadcasts = sentMessages(workers[0]).filter(
|
|
(m) => m.type === "lobbiesBroadcast",
|
|
);
|
|
expect(broadcasts[0].publicGames.games.hosted).toHaveLength(
|
|
MAX_HOSTED_LOBBIES,
|
|
);
|
|
// The sort loser (highest gameID) is dropped from the broadcast and,
|
|
// after two consecutive losing cycles, delisted.
|
|
expect(broadcasts[0].delistGameIDs).toBeUndefined();
|
|
expect(broadcasts[1].delistGameIDs).toEqual([`g${MAX_HOSTED_LOBBIES}`]);
|
|
});
|
|
|
|
it("does not delist when the duplicate disappears after one broadcast", () => {
|
|
const { service, workers } = createService();
|
|
workers[0].emit("message", {
|
|
type: "lobbyList",
|
|
lobbies: [hostedLobby("bbb", "creator-a")],
|
|
});
|
|
workers[1].emit("message", {
|
|
type: "lobbyList",
|
|
lobbies: [hostedLobby("aaa", "creator-a")],
|
|
});
|
|
(service as any).broadcastLobbies();
|
|
|
|
// The losing entry was stale: the next report no longer contains it.
|
|
workers[0].emit("message", { type: "lobbyList", lobbies: [] });
|
|
(service as any).broadcastLobbies();
|
|
|
|
for (const msg of sentMessages(workers[0])) {
|
|
if (msg.type === "lobbiesBroadcast") {
|
|
expect(msg.delistGameIDs).toBeUndefined();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("never schedules or sets countdowns on hosted lobbies", async () => {
|
|
const { service, workers } = createService();
|
|
workers[0].emit("message", {
|
|
type: "lobbyList",
|
|
lobbies: [hostedLobby("hosted1", "creator-a")],
|
|
});
|
|
|
|
await (service as any).maybeScheduleLobby();
|
|
|
|
for (const worker of workers) {
|
|
for (const msg of sentMessages(worker)) {
|
|
if (msg.type === "updateLobby") {
|
|
expect(msg.gameID).not.toBe("hosted1");
|
|
}
|
|
if (msg.type === "createGame") {
|
|
expect(msg.publicGameType).not.toBe("hosted");
|
|
}
|
|
}
|
|
}
|
|
// The scheduled types still get their replacement lobbies.
|
|
const created = workers.flatMap((w) =>
|
|
sentMessages(w).filter((m) => m.type === "createGame"),
|
|
);
|
|
expect(created.map((m) => m.publicGameType).sort()).toEqual([
|
|
"ffa",
|
|
"special",
|
|
"team",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("WorkerLobbyService hosted lobbies", () => {
|
|
let service: WorkerLobbyService;
|
|
let gm: any;
|
|
let sendToMaster: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
gm = {
|
|
publicLobbies: vi.fn().mockReturnValue([]),
|
|
listedLobbies: vi.fn().mockReturnValue([]),
|
|
game: vi.fn().mockReturnValue(null),
|
|
};
|
|
const server = new EventEmitter();
|
|
service = new WorkerLobbyService(
|
|
server as any,
|
|
{ handleUpgrade: vi.fn() } as any,
|
|
gm,
|
|
mockLogger,
|
|
);
|
|
// Never touch the real process IPC channel: vitest forks use it.
|
|
sendToMaster = vi.fn();
|
|
(service as any).sendToMaster = sendToMaster;
|
|
});
|
|
|
|
function emitBroadcast(
|
|
games: InternalPublicGames["games"],
|
|
serverTime = 1000,
|
|
delistGameIDs?: string[],
|
|
) {
|
|
(service as any).handleMasterMessage({
|
|
type: "lobbiesBroadcast",
|
|
publicGames: { serverTime, games },
|
|
delistGameIDs,
|
|
});
|
|
}
|
|
|
|
function connectClient() {
|
|
const ws = {
|
|
send: vi.fn(),
|
|
on: vi.fn(),
|
|
readyState: WebSocket.OPEN,
|
|
};
|
|
(service as any).lobbiesWss.emit("connection", ws);
|
|
return ws;
|
|
}
|
|
|
|
function sentPayloads(ws: { send: ReturnType<typeof vi.fn> }): any[] {
|
|
return ws.send.mock.calls.map((c) => JSON.parse(c[0]));
|
|
}
|
|
|
|
it("reports listed lobbies to master as hosted, with creatorID and without host-only config", () => {
|
|
const game = makeGame("hosted-g1", CREATOR, {
|
|
allowedPublicIds: ["p1"],
|
|
nameReveals: ["c1"],
|
|
nameRevealPublicIds: ["p2"],
|
|
hostCheats: { infiniteGold: true },
|
|
});
|
|
game.setListed(true);
|
|
gm.listedLobbies.mockReturnValue([game]);
|
|
|
|
emitBroadcast({ ffa: [], team: [], special: [], hosted: [] });
|
|
|
|
const lobbyList = sendToMaster.mock.calls
|
|
.map((c: any[]) => c[0])
|
|
.find((m: any) => m.type === "lobbyList");
|
|
expect(lobbyList.lobbies).toHaveLength(1);
|
|
const reported = lobbyList.lobbies[0];
|
|
expect(reported.publicGameType).toBe("hosted");
|
|
expect(reported.creatorID).toBe(hashPersistentID(CREATOR));
|
|
expect(reported.gameConfig.allowedPublicIds).toBeUndefined();
|
|
expect(reported.gameConfig.nameReveals).toBeUndefined();
|
|
expect(reported.gameConfig.nameRevealPublicIds).toBeUndefined();
|
|
expect(reported.gameConfig.hostCheats).toBeUndefined();
|
|
});
|
|
|
|
it("strips creatorID from broadcasts and primed snapshots sent to clients", () => {
|
|
const ws = connectClient();
|
|
emitBroadcast({
|
|
ffa: [],
|
|
team: [],
|
|
special: [],
|
|
hosted: [hostedLobby("g1", "secret-hash")],
|
|
});
|
|
|
|
const full = sentPayloads(ws).find((p) => p.type === "full");
|
|
expect(full.games.hosted[0].gameID).toBe("g1");
|
|
expect(full.games.hosted[0].creatorID).toBeUndefined();
|
|
|
|
// A client connecting after the broadcast gets the primed snapshot,
|
|
// which must be sanitized too.
|
|
const lateWs = connectClient();
|
|
const primed = sentPayloads(lateWs)[0];
|
|
expect(primed.type).toBe("full");
|
|
expect(primed.games.hosted[0].creatorID).toBeUndefined();
|
|
});
|
|
|
|
it("re-sends a full when a hosted lobby's config changes without a gameID change", () => {
|
|
const ws = connectClient();
|
|
// A schema-valid config: an invalid one would be rejected by the IPC
|
|
// message parse and the broadcast silently dropped.
|
|
const games = (gameMap: GameMapType) => ({
|
|
ffa: [],
|
|
team: [],
|
|
special: [],
|
|
hosted: [
|
|
hostedLobby("g1", "hash", {
|
|
gameConfig: {
|
|
gameMap,
|
|
difficulty: Difficulty.Easy,
|
|
donateGold: false,
|
|
donateTroops: false,
|
|
gameType: GameType.Private,
|
|
gameMode: GameMode.FFA,
|
|
gameMapSize: GameMapSize.Normal,
|
|
nations: "default",
|
|
bots: 0,
|
|
infiniteGold: false,
|
|
infiniteTroops: false,
|
|
instantBuild: false,
|
|
randomSpawn: false,
|
|
} as any,
|
|
}),
|
|
],
|
|
});
|
|
|
|
emitBroadcast(games(GameMapType.World), 1000);
|
|
emitBroadcast(games(GameMapType.World), 2000); // unchanged -> counts delta
|
|
emitBroadcast(games(GameMapType.Europe), 3000); // host changed map -> fresh full
|
|
|
|
const types = sentPayloads(ws).map((p) => p.type);
|
|
expect(types).toEqual(["full", "counts", "full"]);
|
|
});
|
|
|
|
describe("creatorHasListedLobby", () => {
|
|
it("finds other lobbies by the same creator in the master broadcast", () => {
|
|
emitBroadcast({
|
|
ffa: [],
|
|
team: [],
|
|
special: [],
|
|
hosted: [hostedLobby("g1", "hash-a")],
|
|
});
|
|
|
|
expect(service.creatorHasListedLobby("hash-a", "other-game")).toBe(true);
|
|
// The lobby being toggled itself does not count.
|
|
expect(service.creatorHasListedLobby("hash-a", "g1")).toBe(false);
|
|
expect(service.creatorHasListedLobby("hash-b", "other-game")).toBe(false);
|
|
});
|
|
|
|
it("also checks this worker's own lobbies, ahead of the broadcast", () => {
|
|
const game = makeGame("local-g1", CREATOR);
|
|
game.setListed(true);
|
|
gm.listedLobbies.mockReturnValue([game]);
|
|
|
|
const hash = hashPersistentID(CREATOR);
|
|
expect(service.creatorHasListedLobby(hash, "other-game")).toBe(true);
|
|
expect(service.creatorHasListedLobby(hash, "local-g1")).toBe(false);
|
|
});
|
|
|
|
it("ignores stale broadcast entries for own games no longer listed", () => {
|
|
// The lobby was just delisted on this worker, but the master broadcast
|
|
// still contains it for a round-trip or two. Local state wins so the
|
|
// creator can immediately list a new lobby.
|
|
const game = makeGame("stale-g1", CREATOR);
|
|
game.setListed(false);
|
|
gm.game.mockImplementation((id: string) =>
|
|
id === "stale-g1" ? game : null,
|
|
);
|
|
const hash = hashPersistentID(CREATOR);
|
|
emitBroadcast({
|
|
ffa: [],
|
|
team: [],
|
|
special: [],
|
|
hosted: [hostedLobby("stale-g1", hash)],
|
|
});
|
|
|
|
expect(service.creatorHasListedLobby(hash, "other-game")).toBe(false);
|
|
|
|
game.setListed(true);
|
|
expect(service.creatorHasListedLobby(hash, "other-game")).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("counts broadcast lobbies plus local listed ones not yet reported", () => {
|
|
const local = makeGame("local-g1", CREATOR);
|
|
local.setListed(true);
|
|
gm.listedLobbies.mockReturnValue([local]);
|
|
|
|
emitBroadcast({
|
|
ffa: [],
|
|
team: [],
|
|
special: [],
|
|
hosted: [hostedLobby("g1", "hash-a"), hostedLobby("g2", "hash-b")],
|
|
});
|
|
expect(service.hostedLobbyCount()).toBe(3);
|
|
|
|
// Once the local lobby reaches the broadcast it only counts once.
|
|
emitBroadcast({
|
|
ffa: [],
|
|
team: [],
|
|
special: [],
|
|
hosted: [hostedLobby("g1", "hash-a"), hostedLobby("local-g1", "hash-c")],
|
|
});
|
|
expect(service.hostedLobbyCount()).toBe(2);
|
|
});
|
|
|
|
it("clears the listed flag when the master delists a duplicate", () => {
|
|
const game = makeGame("dup-g1", CREATOR);
|
|
game.setListed(true);
|
|
gm.game.mockImplementation((id: string) => (id === "dup-g1" ? game : null));
|
|
|
|
emitBroadcast({ ffa: [], team: [], special: [], hosted: [] }, 1000, [
|
|
"dup-g1",
|
|
"not-mine",
|
|
]);
|
|
|
|
expect(game.isListed()).toBe(false);
|
|
});
|
|
});
|