diff --git a/hasura/functions/draft-game/is_draft_game_player_organizer.sql b/hasura/functions/draft-game/is_draft_game_player_organizer.sql index a5dc54e6..8b5e9a91 100644 --- a/hasura/functions/draft-game/is_draft_game_player_organizer.sql +++ b/hasura/functions/draft-game/is_draft_game_player_organizer.sql @@ -10,5 +10,24 @@ AS $$ FROM public.draft_games dg WHERE dg.id = draft_game_player.draft_game_id AND dg.host_steam_id = (hasura_session ->> 'x-hasura-user-id')::bigint + ) + -- In a Teams lobby each side is run by its own team: its owner or captain + -- arranges that side's starters and backups without organizing the lobby. + OR EXISTS ( + SELECT 1 + FROM public.draft_games dg + INNER JOIN public.teams t ON t.id IN (dg.team_1_id, dg.team_2_id) + WHERE dg.id = draft_game_player.draft_game_id + AND dg.mode = 'Teams' + AND dg.match_id IS NULL + AND ( + t.owner_steam_id = (hasura_session ->> 'x-hasura-user-id')::bigint + OR t.captain_steam_id = (hasura_session ->> 'x-hasura-user-id')::bigint + ) + AND ( + COALESCE(dg.inner_squad, false) + OR draft_game_player.lineup IS NULL + OR t.id = CASE draft_game_player.lineup WHEN 2 THEN dg.team_2_id ELSE dg.team_1_id END + ) ); $$; diff --git a/hasura/triggers/draft_game_players.sql b/hasura/triggers/draft_game_players.sql index 6865ffea..a0bdbf1e 100644 --- a/hasura/triggers/draft_game_players.sql +++ b/hasura/triggers/draft_game_players.sql @@ -115,10 +115,13 @@ BEGIN FROM public.draft_game_players WHERE draft_game_id = game.id AND status = 'Accepted'; EXIT WHEN accepted_count >= game.capacity; + -- A backup is pinned to the side it subs for, so promoting one from the + -- other team would leave that side over capacity while this one is short. SELECT steam_id INTO promote_steam_id FROM public.draft_game_players WHERE draft_game_id = game.id AND status = 'Waitlist' - ORDER BY joined_at ASC LIMIT 1; + AND (OLD.lineup IS NULL OR lineup IS NULL OR lineup = OLD.lineup) + ORDER BY (lineup IS DISTINCT FROM OLD.lineup), joined_at ASC LIMIT 1; EXIT WHEN promote_steam_id IS NULL; UPDATE public.draft_game_players SET status = 'Accepted' diff --git a/src/draft-games/draft-game.service.ts b/src/draft-games/draft-game.service.ts index 7946be74..d7542b75 100644 --- a/src/draft-games/draft-game.service.ts +++ b/src/draft-games/draft-game.service.ts @@ -44,6 +44,9 @@ export interface CreateDraftGameSettings { export interface DraftRosterEntry { steam_id: string; lineup: number | null; + // Which side this player belongs to even when benched, so backups stay + // pinned to the team they would sub for. + side?: number | null; } @Injectable() @@ -108,8 +111,7 @@ export class DraftGameService { // Organizers can open a lobby they manage but do not play in; everyone else // is always seeded as the first accepted player. const hostJoins = - settings.host_joins === false && - isRoleAbove(user.role, "match_organizer") + settings.host_joins === false && isRoleAbove(user.role, "match_organizer") ? false : true; @@ -509,10 +511,14 @@ export class DraftGameService { continue; } seen.add(steamId); - const lineup = + const starting = entry.lineup === 1 || entry.lineup === 2 ? entry.lineup : null; - const status = lineup === null ? "Waitlist" : "Accepted"; - const pickOrder = lineup === null ? null : ++pickOrders[lineup]; + const status = starting === null ? "Waitlist" : "Accepted"; + const pickOrder = starting === null ? null : ++pickOrders[starting]; + // A benched roster member is a backup for their own side, not a floating + // spare: keep the side so the room can list them under that team. + const side = entry.side === 1 || entry.side === 2 ? entry.side : null; + const lineup = starting ?? side; const elo = await this.getPlayerElo(steamId, type); if (steamId === hostSteamId) { @@ -912,7 +918,10 @@ export class DraftGameService { await this.hasura.mutation({ update_draft_game_players_by_pk: { __args: { - pk_columns: { draft_game_id: draftGameId, steam_id: user.steam_id }, + pk_columns: { + draft_game_id: draftGameId, + steam_id: user.steam_id, + }, _set: { status }, }, __typename: true, @@ -1382,6 +1391,7 @@ export class DraftGameService { match_options_id: draft_games_by_pk.match_options_id, team_1_id: draft_games_by_pk.team_1_id, team_2_id: draft_games_by_pk.team_2_id, + inner_squad: draft_games_by_pk.inner_squad, captain_selection: draft_games_by_pk.captain_selection, draft_order: draft_games_by_pk.draft_order, min_elo: draft_games_by_pk.min_elo, diff --git a/src/draft-games/draft-match.service.ts b/src/draft-games/draft-match.service.ts index 5c12b032..2ba147a4 100644 --- a/src/draft-games/draft-match.service.ts +++ b/src/draft-games/draft-match.service.ts @@ -1,13 +1,14 @@ import { Logger } from "@nestjs/common"; import { forwardRef, Inject, Injectable } from "@nestjs/common"; import { e_map_pool_types_enum } from "generated"; +import { ExpectedPlayers } from "src/discord-bot/enums/ExpectedPlayers"; import { HasuraService } from "src/hasura/hasura.service"; import { CacheService } from "src/cache/cache.service"; import { MatchAssistantService } from "src/matches/match-assistant/match-assistant.service"; import { ChatService } from "src/chat/chat.service"; import { ChatLobbyType } from "src/chat/enums/ChatLobbyTypes"; import { DraftGameService } from "./draft-game.service"; -import { DraftGame } from "./types/DraftGame"; +import { DraftGame, DraftGamePlayer } from "./types/DraftGame"; @Injectable() export class DraftMatchService { @@ -128,64 +129,224 @@ export class DraftMatchService { draftGame: DraftGame, match: { lineup_1_id?: string | null; lineup_2_id?: string | null }, ) { - const lineupIds = [match.lineup_1_id, match.lineup_2_id].filter( - (id): id is string => !!id, - ); + const maxPerLineup = await this.maxPlayersPerLineup(draftGame); + const { team1, team2 } = this.buildTeams(draftGame, maxPerLineup); + const captains = await this.getTeamCaptains(draftGame); + + const sides: Array<{ + lineupId: string; + players: Array; + teamCaptainSteamId?: string; + }> = []; + + if (match.lineup_1_id && team1.length > 0) { + sides.push({ + lineupId: match.lineup_1_id, + players: team1, + teamCaptainSteamId: captains.team1, + }); + } - if (lineupIds.length === 0) { - return; + if (match.lineup_2_id && team2.length > 0) { + sides.push({ + lineupId: match.lineup_2_id, + players: team2, + teamCaptainSteamId: captains.team2, + }); + } + + // Prune every lineup before filling any of them: a player the draft moved to + // the other side is still seated here, and match_lineup_players rejects + // anyone already present anywhere in the match. + const pending = []; + for (const side of sides) { + pending.push({ + ...side, + add: await this.pruneLineup(side.lineupId, side.players), + }); } + for (const side of pending) { + await this.fillLineup(side.lineupId, side.add); + await this.assignCaptain( + side.lineupId, + side.players, + side.teamCaptainSteamId, + ); + } + } + + // Creating the match with a team_id makes tai_match seed the lineup straight + // off the team roster, which ignores the slots the host assigned in the draft + // lobby. The draft assignment wins, so drop everyone it did not assign and + // report back who still has to be added. + private async pruneLineup( + lineupId: string, + players: Array, + ): Promise> { const { match_lineup_players } = await this.hasura.query({ match_lineup_players: { __args: { - where: { match_lineup_id: { _in: lineupIds } }, - limit: 1, + where: { match_lineup_id: { _eq: lineupId } }, }, steam_id: true, }, }); - if (match_lineup_players.length > 0) { - return; + const assigned = new Set(players.map((player) => String(player.steam_id))); + const existing = new Set( + match_lineup_players.map((player) => String(player.steam_id)), + ); + + const remove = Array.from(existing).filter( + (steamId) => !assigned.has(steamId), + ); + + if (remove.length > 0) { + await this.hasura.mutation({ + delete_match_lineup_players: { + __args: { + where: { + match_lineup_id: { _eq: lineupId }, + steam_id: { _in: remove }, + }, + }, + __typename: true, + }, + }); } - const { team1, team2 } = this.buildTeams(draftGame); - const captain1 = this.captainSteamId(team1); - const captain2 = this.captainSteamId(team2); + return Array.from(assigned).filter((steamId) => !existing.has(steamId)); + } + + private async fillLineup(lineupId: string, steamIds: Array) { + if (steamIds.length === 0) { + return; + } await this.hasura.mutation({ insert_match_lineup_players: { __args: { - objects: team1.map((player) => ({ - steam_id: player.steam_id, - match_lineup_id: match.lineup_1_id, - captain: player.steam_id === captain1, + objects: steamIds.map((steamId) => ({ + steam_id: steamId, + match_lineup_id: lineupId, })), }, __typename: true, }, }); + } + + private async assignCaptain( + lineupId: string, + players: Array, + teamCaptainSteamId?: string, + ) { + // The captain has to be someone who actually starts, never a backup + // riding in a substitute slot. + const starters = players.filter((player) => player.status !== "Waitlist"); + const eligible = starters.length > 0 ? starters : players; + const steamIds = new Set(eligible.map((player) => String(player.steam_id))); + + const captain = + teamCaptainSteamId && steamIds.has(teamCaptainSteamId) + ? teamCaptainSteamId + : this.captainSteamId(eligible); + + if (!captain) { + return; + } await this.hasura.mutation({ - insert_match_lineup_players: { + update_match_lineup_players: { __args: { - objects: team2.map((player) => ({ - steam_id: player.steam_id, - match_lineup_id: match.lineup_2_id, - captain: player.steam_id === captain2, - })), + where: { + match_lineup_id: { _eq: lineupId }, + steam_id: { _eq: captain }, + }, + _set: { captain: true }, }, __typename: true, }, }); } - private buildTeams(draftGame: DraftGame) { + private async getTeamCaptains(draftGame: DraftGame): Promise<{ + team1?: string; + team2?: string; + }> { + const teamIds = [draftGame.team_1_id, draftGame.team_2_id].filter( + (teamId): teamId is string => !!teamId, + ); + + if (teamIds.length === 0) { + return {}; + } + + const { teams } = await this.hasura.query({ + teams: { + __args: { + where: { id: { _in: teamIds } }, + }, + id: true, + captain_steam_id: true, + }, + }); + + const captains = new Map( + teams.map((team) => [team.id, String(team.captain_steam_id)]), + ); + return { - team1: draftGame.players.filter((player) => player.lineup === 1), - team2: draftGame.players.filter((player) => player.lineup === 2), + team1: draftGame.team_1_id + ? captains.get(draftGame.team_1_id) + : undefined, + team2: draftGame.team_2_id + ? captains.get(draftGame.team_2_id) + : undefined, + }; + } + + // A side is its starters followed by its backups: the lobby's backups ride + // along in the match's substitute slots so a no-show can be swapped in + // without re-inviting anyone. Anything past the lineup's capacity is dropped. + private buildTeams(draftGame: DraftGame, maxPerLineup: number) { + const side = (lineup: number) => { + const players = draftGame.players.filter( + (player) => player.lineup === lineup, + ); + + const starters = players + .filter((player) => player.status !== "Waitlist") + .sort((a, b) => (a.pick_order ?? 0) - (b.pick_order ?? 0)); + + const backups = players + .filter((player) => player.status === "Waitlist") + .sort((a, b) => + String(a.joined_at || "").localeCompare(String(b.joined_at || "")), + ); + + return [...starters, ...backups].slice(0, maxPerLineup); }; + + return { team1: side(1), team2: side(2) }; + } + + private async maxPlayersPerLineup(draftGame: DraftGame): Promise { + const starters = Math.floor(ExpectedPlayers[draftGame.type] / 2); + + if (!draftGame.match_options_id) { + return starters; + } + + const { match_options_by_pk } = await this.hasura.query({ + match_options_by_pk: { + __args: { id: draftGame.match_options_id }, + number_of_substitutes: true, + }, + }); + + return starters + (match_options_by_pk?.number_of_substitutes ?? 0); } private async findExistingMatch(draftGame: DraftGame): Promise<{ @@ -279,11 +440,13 @@ export class DraftMatchService { return match; } - private captainSteamId( - team: Array<{ steam_id: string; pick_order?: number }>, - ) { + // Backups carry no pick_order, so they have to sort last or a substitute + // would end up captaining the lineup. + private captainSteamId(team: Array) { return [...team].sort( - (a, b) => (a.pick_order ?? 0) - (b.pick_order ?? 0), + (a, b) => + (a.pick_order ?? Number.MAX_SAFE_INTEGER) - + (b.pick_order ?? Number.MAX_SAFE_INTEGER), )[0]?.steam_id; } diff --git a/test/draft-team-lineups.spec.ts b/test/draft-team-lineups.spec.ts new file mode 100644 index 00000000..69951da5 --- /dev/null +++ b/test/draft-team-lineups.spec.ts @@ -0,0 +1,175 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { bootMigratedDb, runAsUser, SqlTestDb } from "./utils/sql-test-db"; + +// A Teams draft lobby creates its match with a team_id on each lineup, which +// makes tai_match seed the lineup off the team roster and throw away the slots +// the host assigned in the lobby. DraftMatchService reconciles the lineup back +// to the draft assignment; these cover the database guards that reconcile has +// to pass through. +describe("draft team lineups (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fixtures: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("DraftTeamLineupsTest"); + postgres = db.postgres; + fixtures = new Fixtures(postgres, 76561193400000000n); + await fixtures.region(); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + const rosteredTeam = async () => { + const { id, owner } = await fixtures.team(); + const starters = [owner]; + const substitutes: Array = []; + + for (let i = 0; i < 4; i++) { + const mate = await fixtures.player(); + starters.push(mate); + await runAsUser(postgres, owner, "admin", (query) => + query( + "INSERT INTO team_roster (team_id, player_steam_id, status) VALUES ($1, $2, 'Starter')", + [id, mate], + ), + ); + } + + for (let i = 0; i < 2; i++) { + const sub = await fixtures.player(); + substitutes.push(sub); + await runAsUser(postgres, owner, "admin", (query) => + query( + "INSERT INTO team_roster (team_id, player_steam_id, status) VALUES ($1, $2, 'Substitute')", + [id, sub], + ), + ); + } + + return { id, captain: owner, starters, substitutes }; + }; + + const createTeamMatch = async ( + team1Id: string, + team2Id: string, + substitutes: number, + ) => { + const optionsId = await fixtures.matchOptions({ substitutes }); + const [lineup1] = await postgres.query>( + "INSERT INTO match_lineups (team_id) VALUES ($1) RETURNING id", + [team1Id], + ); + const [lineup2] = await postgres.query>( + "INSERT INTO match_lineups (team_id) VALUES ($1) RETURNING id", + [team2Id], + ); + const [match] = await postgres.query>( + `INSERT INTO matches (match_options_id, lineup_1_id, lineup_2_id) + VALUES ($1, $2, $3) RETURNING id, status`, + [optionsId, lineup1.id, lineup2.id], + ); + return { match, lineup1: lineup1.id, lineup2: lineup2.id }; + }; + + const lineupSteamIds = async (lineupId: string) => { + const rows = await postgres.query>( + "SELECT steam_id FROM match_lineup_players WHERE match_lineup_id = $1", + [lineupId], + ); + return rows.map((row) => row.steam_id).sort(); + }; + + const reconcile = async (lineupId: string, assigned: Array) => { + await postgres.query( + "DELETE FROM match_lineup_players WHERE match_lineup_id = $1 AND NOT (steam_id = ANY($2::bigint[]))", + [lineupId, assigned], + ); + const existing = await lineupSteamIds(lineupId); + for (const steamId of assigned.filter((id) => !existing.includes(id))) { + await postgres.query( + "INSERT INTO match_lineup_players (match_lineup_id, steam_id) VALUES ($1, $2)", + [lineupId, steamId], + ); + } + }; + + it("seeds the lineup off the team roster, ignoring the draft assignment", async () => { + const team1 = await rosteredTeam(); + const team2 = await rosteredTeam(); + const { lineup1 } = await createTeamMatch(team1.id, team2.id, 0); + + expect(await lineupSteamIds(lineup1)).toEqual(team1.starters.sort()); + }); + + it("takes substitutes once the match allows them", async () => { + const team1 = await rosteredTeam(); + const team2 = await rosteredTeam(); + const { lineup1 } = await createTeamMatch(team1.id, team2.id, 2); + + expect(await lineupSteamIds(lineup1)).toEqual( + [...team1.starters, ...team1.substitutes].sort(), + ); + }); + + it("lets the draft assignment replace the seeded roster while picking players", async () => { + const team1 = await rosteredTeam(); + const team2 = await rosteredTeam(); + const { match, lineup1 } = await createTeamMatch(team1.id, team2.id, 2); + + expect(match.status).toEqual("PickingPlayers"); + + // Host benched two starters in the lobby and slotted both substitutes in. + const assigned = [ + team1.captain, + ...team1.starters.slice(1, 3), + ...team1.substitutes, + ]; + + await reconcile(lineup1, assigned); + + expect(await lineupSteamIds(lineup1)).toEqual(assigned.sort()); + }); + + it("keeps the captain on a player the draft assigned", async () => { + const team1 = await rosteredTeam(); + const team2 = await rosteredTeam(); + const { lineup1 } = await createTeamMatch(team1.id, team2.id, 2); + + // The team captain is benched, so the seeded captain flag has to move. + const assigned = [...team1.starters.slice(1), ...team1.substitutes]; + + await reconcile(lineup1, assigned); + await postgres.query( + "UPDATE match_lineup_players SET captain = true WHERE match_lineup_id = $1 AND steam_id = $2", + [lineup1, assigned[0]], + ); + + const captains = await postgres.query>( + "SELECT steam_id FROM match_lineup_players WHERE match_lineup_id = $1 AND captain = true", + [lineup1], + ); + + expect(captains.map((row) => row.steam_id)).toEqual([assigned[0]]); + }); + + it("moves a player across lineups when both are pruned first", async () => { + const team1 = await rosteredTeam(); + const team2 = await rosteredTeam(); + const { lineup1, lineup2 } = await createTeamMatch(team1.id, team2.id, 2); + + // Inner-squad style split: a team 1 starter is assigned to the other side. + const crossed = team1.starters[4]; + const side1 = team1.starters.slice(0, 4); + const side2 = [...team2.starters, crossed]; + + await reconcile(lineup1, side1); + await reconcile(lineup2, side2); + + expect(await lineupSteamIds(lineup1)).toEqual(side1.sort()); + expect(await lineupSteamIds(lineup2)).toEqual(side2.sort()); + }); +}); diff --git a/test/draft-team-slots.spec.ts b/test/draft-team-slots.spec.ts new file mode 100644 index 00000000..ea2d2254 --- /dev/null +++ b/test/draft-team-slots.spec.ts @@ -0,0 +1,172 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// In a Teams lobby `lineup` is which side a player sits on and `status` is +// whether they start there, so a Waitlist row with a lineup is that side's +// backup. These cover the two database behaviours that model depends on. +describe("draft team slots (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fixtures: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("DraftTeamSlotsTest"); + postgres = db.postgres; + fixtures = new Fixtures(postgres, 76561193500000000n); + await fixtures.region(); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + const createTeamsDraft = async (options: { innerSquad?: boolean } = {}) => { + const team1 = await fixtures.team(); + const team2 = await fixtures.team(); + const host = await fixtures.player("host"); + + const [draft] = await postgres.query>( + `INSERT INTO draft_games (host_steam_id, type, mode, status, team_1_id, team_2_id, inner_squad) + VALUES ($1, 'Competitive', 'Teams', 'Open', $2, $3, $4) RETURNING id`, + [ + host, + team1.id, + options.innerSquad ? null : team2.id, + !!options.innerSquad, + ], + ); + + return { id: draft.id, host, team1, team2 }; + }; + + const addPlayer = async ( + draftId: string, + steamId: string, + status: string, + lineup: number | null, + ) => { + await postgres.query( + `INSERT INTO draft_game_players (draft_game_id, steam_id, status, lineup) + VALUES ($1, $2, $3, $4)`, + [draftId, steamId, status, lineup], + ); + }; + + const statusOf = async (draftId: string, steamId: string) => { + const [row] = await postgres.query>( + "SELECT status FROM draft_game_players WHERE draft_game_id = $1 AND steam_id = $2", + [draftId, steamId], + ); + return row?.status; + }; + + const isOrganizerFor = async ( + draftId: string, + steamId: string, + actorSteamId: string, + ) => { + const [row] = await postgres.query>( + `SELECT is_draft_game_player_organizer(p, $3::json) AS result + FROM draft_game_players p + WHERE p.draft_game_id = $1 AND p.steam_id = $2`, + [ + draftId, + steamId, + JSON.stringify({ + "x-hasura-role": "user", + "x-hasura-user-id": actorSteamId, + }), + ], + ); + return row?.result; + }; + + it("promotes a backup from the side that lost a starter", async () => { + const draft = await createTeamsDraft(); + + const starter1 = await fixtures.player("starter1"); + await addPlayer(draft.id, starter1, "Accepted", 1); + const backup1 = await fixtures.player("backup1"); + const backup2 = await fixtures.player("backup2"); + // Side 2's backup joined first, so a side-blind promotion would take them. + await addPlayer(draft.id, backup2, "Waitlist", 2); + await addPlayer(draft.id, backup1, "Waitlist", 1); + + await postgres.query( + "DELETE FROM draft_game_players WHERE draft_game_id = $1 AND steam_id = $2", + [draft.id, starter1], + ); + + expect(await statusOf(draft.id, backup1)).toEqual("Accepted"); + expect(await statusOf(draft.id, backup2)).toEqual("Waitlist"); + }); + + it("still promotes an unsided backup when the removed player had no side", async () => { + const draft = await createTeamsDraft(); + + const spare = await fixtures.player("spare"); + await addPlayer(draft.id, spare, "Accepted", null); + const backup = await fixtures.player("backup"); + await addPlayer(draft.id, backup, "Waitlist", 2); + + await postgres.query( + "DELETE FROM draft_game_players WHERE draft_game_id = $1 AND steam_id = $2", + [draft.id, spare], + ); + + expect(await statusOf(draft.id, backup)).toEqual("Accepted"); + }); + + it("lets a team owner manage their own side but not the other", async () => { + const draft = await createTeamsDraft(); + + const mine = await fixtures.player("mine"); + const theirs = await fixtures.player("theirs"); + await addPlayer(draft.id, mine, "Accepted", 1); + await addPlayer(draft.id, theirs, "Accepted", 2); + + expect(await isOrganizerFor(draft.id, mine, draft.team1.owner)).toBe(true); + expect(await isOrganizerFor(draft.id, theirs, draft.team1.owner)).toBe( + false, + ); + expect(await isOrganizerFor(draft.id, theirs, draft.team2.owner)).toBe( + true, + ); + }); + + it("lets a team owner manage unsided players on either roster", async () => { + const draft = await createTeamsDraft(); + + const spare = await fixtures.player("spare"); + await addPlayer(draft.id, spare, "Waitlist", null); + + expect(await isOrganizerFor(draft.id, spare, draft.team1.owner)).toBe(true); + expect(await isOrganizerFor(draft.id, spare, draft.team2.owner)).toBe(true); + }); + + it("gives an inner squad owner both sides", async () => { + const draft = await createTeamsDraft({ innerSquad: true }); + + const sideTwo = await fixtures.player("sideTwo"); + await addPlayer(draft.id, sideTwo, "Accepted", 2); + + expect(await isOrganizerFor(draft.id, sideTwo, draft.team1.owner)).toBe( + true, + ); + expect(await isOrganizerFor(draft.id, sideTwo, draft.team2.owner)).toBe( + false, + ); + }); + + it("keeps an outsider off every side", async () => { + const draft = await createTeamsDraft(); + + const player = await fixtures.player("player"); + await addPlayer(draft.id, player, "Accepted", 1); + const outsider = await fixtures.player("outsider"); + + expect(await isOrganizerFor(draft.id, player, outsider)).toBe(false); + expect(await isOrganizerFor(draft.id, player, draft.host)).toBe(true); + }); +});