diff --git a/modules/lobbies/actors/lobby_manager.ts b/modules/lobbies/actors/lobby_manager.ts new file mode 100644 index 00000000..02ecb7ee --- /dev/null +++ b/modules/lobbies/actors/lobby_manager.ts @@ -0,0 +1,578 @@ +import { ActorBase, ActorContext, RuntimeError } from "../module.gen.ts"; +import { Lobby, Player, getLobbyConfig, lobbyTagsMatch } from "../utils/types.ts"; + +const GC_INTERVAL = 1000; + +type Input = undefined; + +interface State { + currentVersion: string; + lobbies: Record; + servers: Record; +} + +interface Server { + id: string; + createdAt: number; + createFinishedAt?: number; +} + +// TODO: Document why we make everything sync in this actor and use background jobs + +export class Actor extends ActorBase { + initialize() { + this.schedule.after(GC_INTERVAL, "gc", undefined); + + return { + // TODO: + currentVersion: "TODO", + lobbies: {}, + servers: {}, + }; + + } + + // MARK: Lobby + public createLobby(ctx: ActorContext, req: CreateLobbyRequest): CreateLobbyResponse { + // TODO: Insert lobby before call create server & handle error + + const lobbyConfig = getLobbyConfig(ctx.config, req.lobby.tags); + + if (req.players.length > req.lobby.maxPlayers) { + throw new RuntimeError("more_players_than_max") + } + + if ( + lobbyConfig.destroyOnEmptyAfter != undefined && + (!req.players.length || req.players.length == 0) + ) { + throw new RuntimeError("lobby_create_missing_players"); + } + + // Create lobby + const serverId = crypto.randomUUID(); + const lobby: Lobby = { + id: req.lobby.lobbyId, + tags: req.lobby.tags, + createdAt: Date.now(), + emptyAt: Date.now(), + players: new Map(), + maxPlayers: req.lobby.maxPlayers, + maxPlayersDirect: req.lobby.maxPlayersDirect, + serverId, + version: req.lobby.version, + }; + this.state.lobbies[lobby.id] = lobby; + + // Add server + const server: Server = { id: serverId, createdAt: Date.now() }; + this.state.servers[server.id] = server; + + // Create players + const { players } = this.createPlayers(ctx, { + lobbyId: lobby.id, + players: req.players, + }); + + // Run background job + this.runInBackground(this.createLobbyBackground(server.id)); + + return { lobby, players }; + } + + private async createLobbyBackground(serverId: string) { + // TODO: Race condition with publishign & deleting lobby if delete request gets processed first + + // Create server + await RIVET.servers.create({ serverId }); + + // Update server state + const server = this.state.servers[serverId]; + if (server) { + server.createFinishedAt = Date.now(); + } + } + + destroyLobby(_ctx: ActorContext, req: DestroyLobbyRequest) { + // Remove lobby + const lobby = this.state.lobbies[req.lobbyId]; + delete this.state.lobbies[req.lobbyId]; + if (!lobby) { + throw new RuntimeError("lobby_not_found", { + meta: { lobbyId: req.lobbyId }, + }); + } + + // TODO: Optimize + // Get server + const didDeleteServer = delete this.state.servers[lobby.serverId]; + if (didDeleteServer) { + // Run background job + this.runInBackground(this.destroyLobbyBackground(lobby.serverId)); + } else { + console.warn("Did not find server to delete", lobby.serverId); + + } + } + + private async destroyLobbyBackground(serverId: string) { + // Destroy server + await RIVET.servers.destroy({ serverId }); + } + + findLobby(ctx: ActorContext, req: FindLobbyRequest): FindLobbyResponse { + const lobby = this.queryLobby(req.query, req.players.length); + if (!lobby) { + throw new RuntimeError("no_matching_lobbies", { + meta: { + playerCount: req.players.length, + query: req.query, + } + }) + } + return this.createPlayers(ctx, { lobbyId: lobby.id, players: req.players }); + } + + findOrCreateLobby(ctx: ActorContext, req: FindOrCreateLobbyRequest): FindOrCreateLobbyResponse { + const lobby = this.queryLobby(req.query, req.players.length); + if (lobby) { + return this.createPlayers(ctx, { lobbyId: lobby.id, players: req.players }); + } else { + return this.createLobby(ctx, { lobby: req.lobby, players: req.players }); + } + } + + setLobbyReady(_ctx: ActorContext, req: SetLobbyReadyRequest) { + // Get lobby. Fail gracefully since there may be a race condition with deleting lobby. + const lobby = this.state.lobbies[req.lobbyId]; + if (!lobby) return; + + // Update ready state + if (lobby.readyAt !== undefined) { + throw new RuntimeError("lobby_already_ready"); + } + + lobby.readyAt = Date.now(); + + // TODO: Call handlers + } + + listLobbies(ctx: ActorContext, req: ListLobbiesRequest): ListLobbiesResponse { + return { lobbies: Object.values(this.state.lobbies) }; + } + + createPlayers(ctx: ActorContext, req: CreatePlayersRequest): CreatePlayersResponse { + const lobby = this.getLobby(req.lobbyId); + + if (req.players.length == 0) { + return { lobby, players: [] }; + } + + // Check for too many players for IP + if (ctx.config.players.maxPerIp != undefined) { + // Count the number of IPs for the request + const reqIpCounts = new Map(); + for (const player of req.players) { + if (player.publicIp) { + const count = reqIpCounts.get(player.publicIp) ?? 0; + reqIpCounts.set(player.publicIp, count + 1); + } + } + + // Valdiate IPs + for (const [ip, reqIpCount] of reqIpCounts) { + const playersForIp = this.playersForIp(ip); + + // Calculate the number of players over the max player count, + // including the player making the request. + const ipOverflow = (playersForIp.length + reqIpCount) - ctx.config.players.maxPerIp; + + // Handle too many players per IP + if (ipOverflow > 0) { + // Before throwing an error, we'll try removing players + // that have not connected to a server yet. This helps + // mitigate the edge case where the game has a bug causing + // players to fail to connect, leaving a lot of unconnected + // players in the matchmaker. In this situation, new + // players can still be created. + // + // If there are unconnected players that can be removed, + // those players will be removed and this will continue as + // normal. + + // Find players that have not connected yet, sorted oldest + // to newest. This does not include the player that is + // making the request. + const unconnectedPlayersForIp = playersForIp + .filter(x => x.connectedAt == undefined) + .sort((a, b) => a.createdAt - b.createdAt); + + // Check if there are enough players that we can delete to + // make space for the new players + if (unconnectedPlayersForIp.length >= ipOverflow) { + console.warn("Removing unconnected player to make space for new player. The game server is likely having issues accepting connections.", { + ip, + ipOverflow, + maxPerIp: ctx.config.players.maxPerIp, + }); + + // Remove oldest players first in favor of the new + // player we're about to add + for (let i = 0; i < ipOverflow; i++) { + const unconnectedPlayer = unconnectedPlayersForIp[i]; + this.destroyPlayers(ctx, { lobbyId: unconnectedPlayer.lobbyId, playerIds: [unconnectedPlayer.id] }); + } + } else { + // Fail + throw new RuntimeError("too_many_players_for_ip", { + meta: { ip } + }) + } + } + } + } + + // Check if we need to remove unconnected players + if (ctx.config.players.maxUnconnected != undefined) { + const unconnectedPlayers = this.unconnectedPlayers(); + + const unconnectedOverflow = (unconnectedPlayers.length + req.players.length) - ctx.config.players.maxUnconnected; + if (unconnectedOverflow > 0) { + // Calc number of players to remove + const unconnectedPlayersToRemove = Math.min(unconnectedOverflow, unconnectedPlayers.length); + console.warn("Removing unconnected player to make space for new player. The game server is likely having issues accepting connections.", { + maxUnconnected: ctx.config.players.maxUnconnected, + unconnectedOverflow, + unconnectedPlayersToRemove + }) + + // Remove unconnected players from oldest to newest + unconnectedPlayers.sort((a, b) => a.createdAt - b.createdAt); + for (let i = 0; i < unconnectedPlayersToRemove; i++) { + const player = unconnectedPlayers[i]; + this.destroyPlayers(ctx, { + lobbyId: player.lobbyId, + playerIds: [player.id], + }) + } + } + } + + // Check for available spots in lobby + if (lobby.maxPlayers - req.players.length < 0) { + throw new RuntimeError("lobby_full", { meta: { lobbyId: req.lobbyId }}); + } + + // Create players + const players = []; + for (const playerOpts of req.players) { + const player: Player = { + id: playerOpts.playerId, + lobbyId: lobby.id, + createdAt: Date.now(), + publicIp: playerOpts.publicIp, + }; + lobby.players.set(player.id, player); + players.push(player); + } + + // Make lobby not empty + lobby.emptyAt = undefined; + + return { lobby, players }; + } + + destroyPlayers(ctx: ActorContext, req: DestroyPlayersRequest) { + const lobby = this.getLobby(req.lobbyId); + const lobbyConfig = getLobbyConfig(ctx.config, lobby.tags); + + // Remove player + for (const playerId of req.playerIds) { + lobby.players.delete(playerId); + } + + // Destroy lobby immediately on empty + if (lobby.players.size == 0) { + lobby.emptyAt = Date.now(); + + if (lobbyConfig.destroyOnEmptyAfter == 0) { + console.log("Destroying empty lobby", { lobbyId: lobby.id, unreadyExpireAfter: ctx.config.lobbies.unreadyExpireAfter }); + this.destroyLobby(ctx, { lobbyId: lobby.id }); + } + } + } + + setPlayerConnected(ctx: ActorContext, req: SetPlayersConnectedRequest) { + const lobby = this.getLobby(req.lobbyId); + + // Validate players + const allPlayers = []; + for (const playerId of req.playerIds) { + const player = lobby.players.get(playerId); + if (player) { + // TODO: Allow reusing connection token + // TODO: What if the player already connected + if (player.connectedAt != undefined) { + throw new RuntimeError("player_already_connected", { + meta: { lobbyId: lobby.id, playerId } + }) + } + + allPlayers.push(player); + } else { + throw new RuntimeError("player_disconnected", { + meta: { lobbyId: lobby.id, playerId } + }) + } + } + + // Update players + for (const player of allPlayers) { + player.connectedAt = Date.now(); + } + } + + gc(ctx: ActorContext) { + // Schedule next GC + this.schedule.after(GC_INTERVAL, "gc", undefined); + + let unreadyLobbies = 0; + let emptyLobbies = 0; + let unconnectedPlayers = 0; + let oldPlayers = 0; + for (const lobby of Object.values(this.state.lobbies)) { + const lobbyConfig = getLobbyConfig(ctx.config, lobby.tags); + + // Destroy lobby if unready + // TODO: pass this on lobby create instead of in config? + if (lobby.readyAt == undefined && Date.now() - lobby.createdAt > ctx.config.lobbies.unreadyExpireAfter) { + console.warn("Destroying unready lobby", { lobbyId: lobby.id, unreadyExpireAfter: ctx.config.lobbies.unreadyExpireAfter }); + this.destroyLobby(ctx, { lobbyId: lobby.id }); + unreadyLobbies++; + continue; + } + + // Destroy lobby if empty for long enough + if (lobbyConfig.destroyOnEmptyAfter != undefined && lobby.emptyAt != undefined && Date.now() - lobby.emptyAt > lobbyConfig.destroyOnEmptyAfter) { + console.log("Destroying empty lobby", { lobbyId: lobby.id, unreadyExpireAfter: ctx.config.lobbies.unreadyExpireAfter }); + this.destroyLobby(ctx, { lobbyId: lobby.id }); + emptyLobbies++; + continue; + } + + if (lobby.readyAt != undefined) { + for (const player of Array.from(lobby.players.values())) { + // If joining a preemptively created lobby, the player's + // created timestamp will be earlier than when the lobby + // actually becomes able to be connected to. + // + // GC players based on the timestamp the lobby started if + // needed. + const startAt = Math.max(player.createdAt, lobby.readyAt); + + // Clean up unconnected players + if (player.connectedAt == undefined && Date.now() - startAt > ctx.config.players.unconnectedExpireAfter) { + console.log("Destroying unconnected player", { + playerId: player.id, + unconnectedExpireAfter: ctx.config.players.unconnectedExpireAfter, + }); + this.destroyPlayers(ctx, { + lobbyId: player.lobbyId, + playerIds: [player.id] + }); + unconnectedPlayers++; + continue; + } + + // Clean up really old players + if (ctx.config.players.autoDestroyAfter != undefined && Date.now() - startAt > ctx.config.players.autoDestroyAfter) { + console.log("Destroying old player", { + playerId: player.id, + autoDestroyAfter: ctx.config.players.autoDestroyAfter, + }); + this.destroyPlayers(ctx, { + lobbyId: player.lobbyId, + playerIds: [player.id] + }); + oldPlayers++; + continue; + } + } + } + } + + console.log("GC summary", { unreadyLobbies, emptyLobbies, unconnectedPlayers, oldPlayers }); + } + + /** + * Returns a lobby or throws `lobby_not_found`. + */ + private getLobby(lobbyId: string): Lobby { + const lobby = this.state.lobbies[lobbyId]; + if (lobby === undefined) { + throw new RuntimeError("lobby_not_found", { + meta: { lobbyId }, + }); + } + return lobby; + } + + /** + * Finds a lobby for a given query. + */ + private queryLobby(query: QueryRequest, playerCount: number): Lobby | undefined { + // TODO: optimize + // Find largest lobby that can fit the requested players + const lobbies = Object.values(this.state.lobbies) + .filter(x => x.version == query.version) + .filter(x => x.players.size <= x.maxPlayers - playerCount) + .filter(x => lobbyTagsMatch(query.tags, x.tags)) + .sort((a, b) => b.createdAt - a.createdAt) + .sort((a, b) => b.players.size - a.players.size); + return lobbies[0]; + } + + playersForIp(ip: string): Player[] { + // TODO: optimize + const players = []; + for (const lobby of Object.values(this.state.lobbies)) { + for (const player of lobby.players.values()) { + if (player.publicIp == ip) { + players.push(player); + } + } + } + return players; + } + + unconnectedPlayers(): Player[] { + // TODO: optimize + const players = []; + for (const lobby of Object.values(this.state.lobbies)) { + // Don't count unready lobbies since these players haven't had time to connect yet + if (lobby.readyAt == undefined) continue; + + for (const player of lobby.players.values()) { + if (player.connectedAt == undefined) { + players.push(player); + } + } + } + return players; + } +} + +interface CommonResponse { + lobby: Lobby, + players: Player[]; +} + +// MARK: Create Lobby +export interface CreateLobbyRequest { + lobby: LobbyRequest, + players: PlayerRequest[]; +} + +export type CreateLobbyResponse = CommonResponse; + +// MARK: Destroy Lobby +export interface DestroyLobbyRequest { + lobbyId: string; +} + +// MARK: Find Lobby +export interface FindLobbyRequest { + query: QueryRequest, + players: PlayerRequest[]; +} + +export type FindLobbyResponse = CommonResponse; + +// MARK: Find or Create +export interface FindOrCreateLobbyRequest { + query: QueryRequest, + lobby: LobbyRequest, + players: PlayerRequest[]; +} + +export type FindOrCreateLobbyResponse = CommonResponse; + +// MARK: Set Lobby Ready +export interface SetLobbyReadyRequest { + lobbyId: string; +} + +// MARK: List Lobbies +export interface ListLobbiesRequest { + +} + +export interface ListLobbiesResponse { + lobbies: Lobby[] +} + +// MARK: Create Players +export interface CreatePlayersRequest { + lobbyId: string; + players: PlayerRequest[], +} + +export interface CreatePlayersResponse { + lobby: Lobby, + players: Player[], +} + +// MARK: Destroy Players +export interface DestroyPlayersRequest { + lobbyId: string; + playerIds: string[]; +} + +// MARK: Set Players Connected +export interface SetPlayersConnectedRequest { + lobbyId: string; + playerIds: string[]; +} + +// MARK: Common +export interface QueryRequest { + version: string; + tags: Record; +} + +export interface LobbyRequest { + lobbyId: string; + version: string, + tags: Record, + lobbyToken: string; + maxPlayers: number; + maxPlayersDirect: number; +} + +export interface PlayerRequest { + playerId: string; + publicIp?: string; +} + + +// MARK: Rivet +interface ServersCreateRequest { + serverId?: string, +} + +interface RivetDynamicServer { + serverId: string; +} + +// TODO: Use https://developers.cloudflare.com/workers/runtime-apis/context/#waituntil for all API requests +const RIVET = { + servers: { + async create(req: ServersCreateRequest): Promise { + await new Promise(resolve => setTimeout(resolve, 1000)); + return { serverId: crypto.randomUUID() }; + }, + async destroy(req: { serverId: string }): Promise { + await new Promise(resolve => setTimeout(resolve, 1000)); + }, + }, +}; diff --git a/modules/lobbies/config.ts b/modules/lobbies/config.ts new file mode 100644 index 00000000..9d7a2136 --- /dev/null +++ b/modules/lobbies/config.ts @@ -0,0 +1,35 @@ +export interface Config { + lobbies: LobbyConfig, + lobbyRules: LobbyRule[], + players: { + maxPerIp?: number; + maxUnconnected?: number; + unconnectedExpireAfter: number; + autoDestroyAfter?: number; + } +} + +export interface LobbyRule { + tags: Record, + config: Partial, +} + +export interface LobbyConfig extends Record { + destroyOnEmptyAfter?: number | null; + unreadyExpireAfter: number; + maxPlayers: number; + maxPlayersDirect: number; + enableDynamicMaxPlayers?: PlayerRange, + enableDynamicMaxPlayersDirect?: PlayerRange, + enableCreate: boolean, + enableDestroy: boolean, + enableFind: boolean, + enableFindOrCreate: boolean, + enableJoin: boolean, + enableList: boolean, +} + +export interface PlayerRange { + min: number, + max: number, +} diff --git a/modules/lobbies/docs/UNCONNECTED_PLAYERS.md b/modules/lobbies/docs/UNCONNECTED_PLAYERS.md new file mode 100644 index 00000000..1a183fee --- /dev/null +++ b/modules/lobbies/docs/UNCONNECTED_PLAYERS.md @@ -0,0 +1,15 @@ +# Unconnected Players + +## Why it exists? + +- high load & low player caps +- preventing botting + +## What happens when players fail to connect? + +- Unconnected players stack up +- How lobbies API handles it + - Max players per IP: if creating another player and goes over ip limit, will + delete the old unconnected player for the same IP + - Maximum unconnected players: if too many unconnected players, we'll start + discarding the oldest unconnected player diff --git a/modules/lobbies/module.json b/modules/lobbies/module.json new file mode 100644 index 00000000..30fede5f --- /dev/null +++ b/modules/lobbies/module.json @@ -0,0 +1,128 @@ +{ + "name": "Lobbies", + "description": "Lobby & player management.", + "icon": "game-board", + "tags": [ + "core", "multiplayer" + ], + "authors": [ + "NathanFlurry" + ], + "status": "stable", + "scripts": { + "create": { + "name": "Create Lobby", + "description": "Creates a new lobby on-demand.", + "public": true + }, + "destroy": { + "name": "Destroy Lobby", + "description": "Destroys an existing lobby.", + "public": true + }, + "find_or_create": { + "name": "Find Or Create Lobby", + "description": "Finds a lobby or creates one if there are no available spots for players.", + "public": true + }, + "join": { + "name": "Join Lobby", + "description": "Add a player to an existing lobby.", + "public": true + }, + "list": { + "name": "List Lobbies", + "description": "List & query all lobbies.", + "public": true + }, + "set_lobby_ready": { + "name": "Set Lobby Ready", + "description": "Called on lobby startup after initiation to notify it can start accepting player. This should be called after operations like loading maps are complete.", + "public": true + }, + "set_player_connected": { + "name": "Set Player Connected", + "description": "Called when a player connects to the lobby.", + "public": true + }, + "set_player_disconnected": { + "name": "Set Player Disconnected", + "description": "Called when a player disconnects from the lobby.", + "public": true + }, + "find": { + "name": "Find Lobby", + "description": "Finds an existing lobby with a given query. This will not create a new lobby, see `find_or_create` instead.", + "public": true + }, + "force_gc": { + "name": "Force Garbage Collection", + "description": "Rarely used. Forces the matchmaker to purge lobbies & players." + } + }, + "actors": { + "lobby_manager": {} + }, + "errors": { + "lobby_not_found": { + "name": "Lobby Not Found", + "description": "Lobby not found." + }, + "lobby_create_missing_players": { + "name": "Lobby Create Missing Players", + "description": "When creating a lobby with `config.lobbies.autoDestroyWhenEmpty`, a lobby must be created with players in order to avoid creating an empty lobby." + }, + "lobby_full": { + "name": "Lobby Full", + "description": "No more players can join this lobby." + }, + "more_players_than_max": { + "name": "More Players Than Max", + "description": "More players were passed to the create lobby than the number of max players in a lobby." + }, + "lobby_already_ready": { + "name": "Lobby Already Ready", + "description": "Lobby already set as ready." + }, + "player_already_connected": { + "name": "Player Already Connected", + "description": "The player has already connected to this server. This error helps mitigate botting attacks by only allowing one scoket to connect to a game server for every player." + }, + "player_disconnected": { + "name": "Player Disconnected", + "description": "The player has already disconnected from the server. Create a new player for the specified lobby using the `join` script." + }, + "no_matching_lobbies": { + "name": "No Matching Lobbies", + "description": "No lobbies matched the given query." + }, + "too_many_players_for_ip": { + "name": "Too Many Players For IP", + "description": "The player has too many existing players for the given IP." + } + }, + "dependencies": { + "tokens": {} + }, + "defaultConfig": { + "lobbies": { + "destroyOnEmptyAfter": 60000, + "unreadyExpireAfter": 300000, + "maxPlayers": 16, + "maxPlayersDirect": 16, + "enableCreate": false, + "enableDestroy": false, + "enableFind": true, + "enableFindOrCreate": true, + "enableJoin": true, + "enableList": true + }, + "lobbyRules": [], + "players": { + "maxPerIp": 8, + "maxUnconnected": 128, + "unconnectedExpireAfter": 60000, + "autoDestroyAfter": 4147200000 + } + } +} diff --git a/modules/lobbies/scripts/create.ts b/modules/lobbies/scripts/create.ts new file mode 100644 index 00000000..6400216f --- /dev/null +++ b/modules/lobbies/scripts/create.ts @@ -0,0 +1,75 @@ +import { + CreateLobbyRequest, + CreateLobbyResponse, +} from "../actors/lobby_manager.ts"; +import { ScriptContext } from "../module.gen.ts"; +import { + Lobby, + PlayerRequest, + PlayerWithToken, +} from "../utils/types.ts"; + +export interface Request { + version: string; + tags: Record; + maxPlayers: number; + maxPlayersDirect: number; + + players: PlayerRequest[]; +} + +export interface Response { + lobby: Lobby; + players: PlayerWithToken[]; +} + +// TODO: Doc why we create tokens on the script and not the DO + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Setup lobby + // + // This token will be disposed if the lobby is not created + const lobbyId = crypto.randomUUID(); + const { token: lobbyToken } = await ctx.modules.tokens.create({ + type: "lobby", + meta: { lobbyId: lobbyId }, + }); + + // Setup players + const playerOpts: PlayerRequest[] = []; + const playerTokens: Record = {}; + for (const _player of req.players) { + const playerId = crypto.randomUUID(); + const { token: playerToken } = await ctx.modules.tokens.create({ + type: "player", + meta: { lobbyId: lobbyId, playerId: playerId }, + }); + playerOpts.push({ playerId }); + playerTokens[playerId] = playerToken.token; + } + + const { lobby, players }: CreateLobbyResponse = await ctx.actors.lobbyManager.getOrCreateAndCall( + "default", + undefined, + "createLobby", + { + lobby: { + lobbyId, + version: req.version, + tags: req.tags, + lobbyToken: lobbyToken.token, + maxPlayers: req.maxPlayers, + maxPlayersDirect: req.maxPlayersDirect, + }, + players: playerOpts, + } as CreateLobbyRequest, + ); + + return { + lobby, + players: players.map((x) => ({ token: playerTokens[x.id], ...x })), + }; +} diff --git a/modules/lobbies/scripts/destroy.ts b/modules/lobbies/scripts/destroy.ts new file mode 100644 index 00000000..3374c096 --- /dev/null +++ b/modules/lobbies/scripts/destroy.ts @@ -0,0 +1,23 @@ +import { DestroyLobbyRequest } from "../actors/lobby_manager.ts"; +import { ScriptContext } from "../module.gen.ts"; + +export interface Request { + lobbyId: string; +} + +export interface Response { +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.actors.lobbyManager.getOrCreateAndCall( + "default", + undefined, + "destroyLobby", + { lobbyId: req.lobbyId } as DestroyLobbyRequest, + ); + + return {}; +} diff --git a/modules/lobbies/scripts/find.ts b/modules/lobbies/scripts/find.ts new file mode 100644 index 00000000..7871bb3e --- /dev/null +++ b/modules/lobbies/scripts/find.ts @@ -0,0 +1,53 @@ +import { + FindLobbyRequest, + FindLobbyResponse, +} from "../actors/lobby_manager.ts"; +import { ScriptContext } from "../module.gen.ts"; +import { Lobby, PlayerRequest, PlayerWithToken } from "../utils/types.ts"; + +export interface Request { + version: string; + tags: Record; + players: PlayerRequest[]; +} + +export interface Response { + lobby: Lobby; + players: PlayerWithToken[]; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Setup players + const playerOpts: PlayerRequest[] = []; + const playerTokens: Record = {}; + for (const _player of req.players) { + const playerId = crypto.randomUUID(); + const { token: playerToken } = await ctx.modules.tokens.create({ + type: "player", + meta: { playerId: playerId }, + }); + playerOpts.push({ playerId }); + playerTokens[playerId] = playerToken.token; + } + + const { lobby, players }: FindLobbyResponse = await ctx.actors.lobbyManager.getOrCreateAndCall( + "default", + undefined, + "findLobby", + { + query: { + version: req.version, + tags: req.tags, + }, + players: playerOpts, + } as FindLobbyRequest, + ); + + return { + lobby, + players: players.map((x) => ({ token: playerTokens[x.id], ...x })), + }; +} diff --git a/modules/lobbies/scripts/find_or_create.ts b/modules/lobbies/scripts/find_or_create.ts new file mode 100644 index 00000000..81f1bf2d --- /dev/null +++ b/modules/lobbies/scripts/find_or_create.ts @@ -0,0 +1,74 @@ +import { + FindOrCreateLobbyRequest, + FindOrCreateLobbyResponse, +} from "../actors/lobby_manager.ts"; +import { ScriptContext } from "../module.gen.ts"; +import { Lobby, PlayerRequest, PlayerWithToken } from "../utils/types.ts"; + +export interface Request { + version: string, + tags: Record; + players: PlayerRequest[]; + + createConfig: { + tags: Record; + maxPlayers: number; + maxPlayersDirect: number; + } +} + +export interface Response { + lobby: Lobby; + players: PlayerWithToken[]; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Setup lobby + const lobbyId = crypto.randomUUID(); + const { token: lobbyToken } = await ctx.modules.tokens.create({ + type: "lobby", + meta: { lobbyId: lobbyId }, + }); + + // Setup players + const playerOpts: PlayerRequest[] = []; + const playerTokens: Record = {}; + for (const _player of req.players) { + const playerId = crypto.randomUUID(); + const { token: playerToken } = await ctx.modules.tokens.create({ + type: "player", + meta: { playerId: playerId }, + }); + playerOpts.push({ playerId }); + playerTokens[playerId] = playerToken.token; + } + + const { lobby, players }: FindOrCreateLobbyResponse = await ctx.actors.lobbyManager.getOrCreateAndCall( + "default", + undefined, + "findOrCreateLobby", + { + query: { + version: req.version, + tags: req.tags, + }, + lobby: { + lobbyId, + version: req.version, + tags: req.createConfig.tags, + lobbyToken: lobbyToken.token, + maxPlayers: req.createConfig.maxPlayers, + maxPlayersDirect: req.createConfig.maxPlayersDirect, + }, + players: playerOpts, + } as FindOrCreateLobbyRequest, + ); + + return { + lobby, + players: players.map((x) => ({ token: playerTokens[x.id], ...x })), + }; +} diff --git a/modules/lobbies/scripts/force_gc.ts b/modules/lobbies/scripts/force_gc.ts new file mode 100644 index 00000000..9dd2c4f4 --- /dev/null +++ b/modules/lobbies/scripts/force_gc.ts @@ -0,0 +1,21 @@ +import { ScriptContext } from "../module.gen.ts"; + +export interface Request { +} + +export interface Response { +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.actors.lobbyManager.getOrCreateAndCall( + "default", + undefined, + "gc", + undefined + ); + + return {}; +} diff --git a/modules/lobbies/scripts/join.ts b/modules/lobbies/scripts/join.ts new file mode 100644 index 00000000..c4008c5a --- /dev/null +++ b/modules/lobbies/scripts/join.ts @@ -0,0 +1,53 @@ +import { + CreatePlayersRequest, + CreatePlayersResponse, +} from "../actors/lobby_manager.ts"; +import { ScriptContext } from "../module.gen.ts"; +import { + Lobby, + PlayerRequest, + PlayerWithToken, +} from "../utils/types.ts"; + +export interface Request { + lobbyId: string; + players: PlayerRequest[]; +} + +export interface Response { + lobby: Lobby; + players: PlayerWithToken[]; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Setup players + const playerOpts: PlayerRequest[] = []; + const playerTokens: Record = {}; + for (const _player of req.players) { + const playerId = crypto.randomUUID(); + const { token: playerToken } = await ctx.modules.tokens.create({ + type: "player", + meta: { playerId: playerId }, + }); + playerOpts.push({ playerId }); + playerTokens[playerId] = playerToken.token; + } + + const { lobby, players }: CreatePlayersResponse = await ctx.actors.lobbyManager.getOrCreateAndCall( + "default", + undefined, + "createPlayers", + { + lobbyId: req.lobbyId, + players: playerOpts, + } as CreatePlayersRequest, + ); + + return { + lobby, + players: players.map((x) => ({ token: playerTokens[x.id], ...x })), + }; +} diff --git a/modules/lobbies/scripts/list.ts b/modules/lobbies/scripts/list.ts new file mode 100644 index 00000000..cf6011f9 --- /dev/null +++ b/modules/lobbies/scripts/list.ts @@ -0,0 +1,26 @@ +import { ListLobbiesRequest, ListLobbiesResponse } from "../actors/lobby_manager.ts"; +import { ScriptContext } from "../module.gen.ts"; +import { Lobby } from "../utils/types.ts"; + +export interface Request { +} + +export interface Response { + lobbies: Lobby[]; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // TODO: Cache this without hitting the DO + + const { lobbies } = await ctx.actors.lobbyManager.getOrCreateAndCall( + "default", + undefined, + "listLobbies", + {} + ); + + return { lobbies }; +} diff --git a/modules/lobbies/scripts/set_lobby_ready.ts b/modules/lobbies/scripts/set_lobby_ready.ts new file mode 100644 index 00000000..516f3c6b --- /dev/null +++ b/modules/lobbies/scripts/set_lobby_ready.ts @@ -0,0 +1,24 @@ +import { SetLobbyReadyRequest } from "../actors/lobby_manager.ts"; +import { ScriptContext } from "../module.gen.ts"; + +export interface Request { + lobbyToken: string; +} + +export interface Response { +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + let manager; + const { token } = await ctx.modules.tokens.validate({ + token: req.lobbyToken, + }); + const lobbyId: string = token.meta.lobbyId; + + await ctx.actors.lobbyManager.getOrCreateAndCall("default", undefined, "setLobbyReady", { lobbyId } as SetLobbyReadyRequest); + + return {}; +} diff --git a/modules/lobbies/scripts/set_player_connected.ts b/modules/lobbies/scripts/set_player_connected.ts new file mode 100644 index 00000000..3214fe9f --- /dev/null +++ b/modules/lobbies/scripts/set_player_connected.ts @@ -0,0 +1,36 @@ +import { SetPlayersConnectedRequest } from "../actors/lobby_manager.ts"; +import { ScriptContext } from "../module.gen.ts"; + +export interface Request { + lobbyToken: string; + playerTokens: string[]; +} + +export interface Response { +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + let manager; + const { token: lobbyToken } = await ctx.modules.tokens.validate({ + token: req.lobbyToken, + }); + const lobbyId: string = lobbyToken.meta.lobbyId; + + const playerIds: string[] = []; + for (const playerToken of req.playerTokens) { + const { token } = await ctx.modules.tokens.validate({ token: playerToken }); + playerIds.push(token.meta.playerId); + } + + await ctx.actors.lobbyManager.getOrCreateAndCall( + "default", + undefined, + "setPlayerConnected", + { lobbyId, playerIds } as SetPlayersConnectedRequest, + ); + + return {}; +} diff --git a/modules/lobbies/scripts/set_player_disconnected.ts b/modules/lobbies/scripts/set_player_disconnected.ts new file mode 100644 index 00000000..4e69bd70 --- /dev/null +++ b/modules/lobbies/scripts/set_player_disconnected.ts @@ -0,0 +1,36 @@ +import { DestroyPlayersRequest } from "../actors/lobby_manager.ts"; +import { ScriptContext } from "../module.gen.ts"; + +export interface Request { + lobbyToken: string; + playerTokens: string[]; +} + +export interface Response { +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + let manager; + const { token: lobbyToken } = await ctx.modules.tokens.validate({ + token: req.lobbyToken, + }); + const lobbyId: string = lobbyToken.meta.lobbyId; + + const playerIds: string[] = []; + for (const playerToken of req.playerTokens) { + const { token } = await ctx.modules.tokens.validate({ token: playerToken }); + playerIds.push(token.meta.playerId); + } + + await ctx.actors.lobbyManager.getOrCreateAndCall( + "default", + undefined, + "destroyPlayers", + { lobbyId, playerIds } as DestroyPlayersRequest, + ); + + return {}; +} diff --git a/modules/lobbies/tests/e2e.ts b/modules/lobbies/tests/e2e.ts new file mode 100644 index 00000000..c22ced89 --- /dev/null +++ b/modules/lobbies/tests/e2e.ts @@ -0,0 +1,217 @@ +import { RuntimeError, test, TestContext } from "../module.gen.ts"; +import { + assertArrayIncludes, + assertEquals, + assertRejects, +} from "https://deno.land/std@0.208.0/assert/mod.ts"; + +// problems: +// - server token vs lobby token for mlps +// - players connected & disconnected will not work with non-server backend +// +// next steps: +// - add way of handling versions: add fallback script that returns based on the current backend +// - update with new config +// - wait requests: wait until lobby ready to return with long req +// - add public ips for players +// - call real ds api +// +// next big ticket: +// - mlps + lobby events +// - persistent +// - validate tags +// - captchas +// - pluggable backends +// +// post mvp: +// - put together html5 demo +// - figure out player surge issue +// +// small things to fix: +// - improve find player count req +// - assert must provide players in req +// - add validation comments to config +// - custom serialization for lobby & player response +// - rate limits +// - ask gpt to write tests based on rust code +// +// common configs: +// - game modes + regions +// +// nice to haves: +// - captchas +// - ipinfo integration +// - auto shutdown after version expired +// - benchmark +// +// --- +// +// all new features: +// - configs via tags instead of game modes +// - better handling of unconnected players with max players per ip +// - see new config properties +// - creating multiple players in batch + +const VERSION = "TODO"; + +test("e2e", async (ctx: TestContext) => { + + // MARK: Create lobby + const { lobby, players } = await ctx.modules.lobbies.create({ + version: VERSION, + tags: {}, + players: [{}, {}], + maxPlayers: 8, + maxPlayersDirect: 8, + }); + + // Issue another token for the lobby for tests + const { token: { token: lobbyToken } } = await ctx.modules.tokens.create({ + type: "lobby_test", + meta: { lobbyId: lobby.id }, + }); + + // MARK: List lobbies + { + const { lobbies } = await ctx.modules.lobbies.list({}); + assertEquals(lobbies.length, 1); + assertEquals(lobbies[0].id, lobby.id); + } + + // MARK: Connect lobby + await ctx.modules.lobbies.setLobbyReady({ + lobbyToken, + }); + + // MARK: Connect players + await ctx.modules.lobbies.setPlayerConnected({ + lobbyToken, + playerTokens: [players[0].token, players[1].token], + }); + + // MARK: Disconnect players + await ctx.modules.lobbies.setPlayerDisconnected({ + lobbyToken, + playerTokens: [players[0].token, players[1].token], + }); + + // MARK: Create players + { + const { players: players2 } = await ctx.modules.lobbies.join({ + lobbyId: lobby.id, + players: [{}], + }); + await ctx.modules.lobbies.setPlayerConnected({ + lobbyToken, + playerTokens: [players2[0].token], + }); + await ctx.modules.lobbies.setPlayerDisconnected({ + lobbyToken, + playerTokens: [players2[0].token], + }); + } + + // MARK: Destroy lobby + await ctx.modules.lobbies.destroy({ + lobbyId: lobby.id, + }); + + { + const { lobbies } = await ctx.modules.lobbies.list({}); + assertEquals(lobbies.length, 0); + } + + const error = await assertRejects(async () => { + await ctx.modules.lobbies.destroy({ lobbyId: lobby.id }); + }, RuntimeError); + assertEquals(error.code, "lobby_not_found"); +}); + +test("lobby tags", async (ctx: TestContext) => { + // MARK: Create lobbies + const { lobby: lobby1, players: players1 } = await ctx.modules.lobbies.create( + { + version: VERSION, + tags: { gameMode: "a", region: "atl" }, + players: [{}], + maxPlayers: 8, + maxPlayersDirect: 8, + }, + ); + const { lobby: lobby2, players: players2 } = await ctx.modules.lobbies.create( + { + version: VERSION, + tags: { gameMode: "a", region: "fra" }, + players: [{}], + maxPlayers: 8, + maxPlayersDirect: 8, + }, + ); + const { lobby: lobby3, players: players3 } = await ctx.modules.lobbies.create( + { + version: VERSION, + tags: { gameMode: "b", region: "fra" }, + players: [{}], + maxPlayers: 8, + maxPlayersDirect: 8, + }, + ); + + // MARK: Find lobbies + const { lobby: lobby4, players: players4 } = await ctx.modules.lobbies.find({ + version: VERSION, + tags: { gameMode: "a" }, + players: [{}], + }); + console.log("my lobby", lobby4.id, lobby1.id, lobby2.id); + assertArrayIncludes([lobby1.id, lobby2.id], [lobby4.id]); + + const { lobby: lobby5, players: players5 } = await ctx.modules.lobbies.find({ + version: VERSION, + tags: { gameMode: "b" }, + players: [{}], + }); + assertEquals(lobby5.id, lobby3.id); + + const { lobby: lobby6, players: players6 } = await ctx.modules.lobbies.find({ + version: VERSION, + tags: { gameMode: "a", region: "fra" }, + players: [{}], + }); + assertEquals(lobby6.id, lobby2.id); +}); + +test("sort order", async (ctx: TestContext) => { + // TODO: +}); + +test("lobby size", async (ctx: TestContext) => { + // TODO: +}); + +test("max players per ip", async (ctx: TestContext) => { + // TODO: +}); + +test("max players per ip with unconnected players", async (ctx: TestContext) => { + // TODO: +}); + +test("max unconnected players", async (ctx: TestContext) => { + // TODO: +}); + +test("player unconnected expire", async (ctx: TestContext) => { + // TODO: +}); + +test("old player expire", async (ctx: TestContext) => { +}); + +test("lobby unready expire", async (ctx: TestContext) => { + // TODO: +}); + +test("empty lobby expire", async (ctx: TestContext) => { + // TODO: +}); diff --git a/modules/lobbies/utils/types.ts b/modules/lobbies/utils/types.ts new file mode 100644 index 00000000..211afaa3 --- /dev/null +++ b/modules/lobbies/utils/types.ts @@ -0,0 +1,59 @@ +import { Config, LobbyConfig } from "../config.ts"; +import { deepMerge } from "https://deno.land/std@0.224.0/collections/deep_merge.ts"; + +export interface Lobby { + id: string; + version: string; + tags: Record; + + createdAt: number; + readyAt?: number; + /** + * Timestamp at which the last player left the lobby. + */ + emptyAt?: number; + + players: Map; + + maxPlayers: number; + maxPlayersDirect: number; + + serverId: string; +} + +export interface Player { + id: string; + lobbyId: string; + createdAt: number; + connectedAt?: number; + publicIp?: string; +} + +export interface PlayerWithToken extends Player { + token: string; +} + +export type PlayerRequest = Record; + +export function getLobbyConfig(userConfig: Config, lobbyTags: Record): LobbyConfig { + let lobbyConfig = userConfig.lobbies; + + // Apply rules + for (const rule of userConfig.lobbyRules) { + if (lobbyTagsMatch(rule.tags, lobbyTags)) { + lobbyConfig = deepMerge(lobbyConfig, rule.config); + } + } + + return lobbyConfig; +} + +/** + * Check if a lobby with the given tags matches a query. + */ +export function lobbyTagsMatch(query: Record, target: Record): boolean { + for (const key in query) { + if (target[key] != query[key]) return false; + } + return true; +} diff --git a/tests/basic/backend.json b/tests/basic/backend.json index 897a398e..6b1f6575 100644 --- a/tests/basic/backend.json +++ b/tests/basic/backend.json @@ -1,50 +1,53 @@ { - "registries": { - "local": { - "local": { - "directory": "../../modules" - } - } - }, - "modules": { - "currency": { - "registry": "local" - }, - "friends": { - "registry": "local" - }, - "rate_limit": { - "registry": "local" - }, - "tokens": { - "registry": "local" - }, - "users": { - "registry": "local" - }, - "uploads": { - "registry": "local", - "config": { - "maxFilesPerUpload": 16, - "maxUploadSize": "100mib", - "maxMultipartUploadSize": "10gib" - } - }, - "auth": { - "registry": "local", - "config": { - "email": { - "fromEmail": "hello@rivet.gg" - } - } - }, - "email": { - "registry": "local", - "config": { - "provider": { - "test": {} - } - } - } - } -} + "registries": { + "local": { + "local": { + "directory": "../../modules" + } + } + }, + "modules": { + "currency": { + "registry": "local" + }, + "friends": { + "registry": "local" + }, + "rate_limit": { + "registry": "local" + }, + "tokens": { + "registry": "local" + }, + "users": { + "registry": "local" + }, + "uploads": { + "registry": "local", + "config": { + "maxFilesPerUpload": 16, + "maxUploadSize": "100mib", + "maxMultipartUploadSize": "10gib" + } + }, + "auth": { + "registry": "local", + "config": { + "email": { + "fromEmail": "hello@rivet.gg" + } + } + }, + "email": { + "registry": "local", + "config": { + "provider": { + "test": {} + } + } + }, + "lobbies": { + "registry": "local" + } + } +} \ No newline at end of file diff --git a/tests/basic/deno.lock b/tests/basic/deno.lock index f493f1ee..6a2a2f2b 100644 --- a/tests/basic/deno.lock +++ b/tests/basic/deno.lock @@ -214,6 +214,8 @@ "https://deno.land/std@0.220.0/assert/unimplemented.ts": "47ca67d1c6dc53abd0bd729b71a31e0825fc452dbcd4fde4ca06789d5644e7fd", "https://deno.land/std@0.220.0/assert/unreachable.ts": "3670816a4ab3214349acb6730e3e6f5299021234657eefe05b48092f3848c270", "https://deno.land/std@0.220.0/fmt/colors.ts": "d239d84620b921ea520125d778947881f62c50e78deef2657073840b8af9559a", + "https://deno.land/std@0.224.0/collections/_utils.ts": "b2ec8ada31b5a72ebb1d99774b849b4c09fe4b3a38d07794bd010bd218a16e0b", + "https://deno.land/std@0.224.0/collections/deep_merge.ts": "04f8d2a6cfa15c7580e788689bcb5e162512b9ccb18bab1241824b432a78551e", "https://deno.land/x/deno_faker@v1.0.3/lib/address.ts": "d461912c0a8c14fb6d277016e4e2e0098fcba4dee0fe77f5de248c7fc2aaa601", "https://deno.land/x/deno_faker@v1.0.3/lib/commerce.ts": "797e10dd360b1f63b2d877b368db5bedabb90c07d5ccb4cc63fded644648c8b5", "https://deno.land/x/deno_faker@v1.0.3/lib/company.ts": "c241dd2ccfcee7a400b94badcdb5ee9657784dd47a86417b54952913023cbd11",