diff --git a/playwright.config.ts b/playwright.config.ts index 3b1020ee..01e337d1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -24,19 +24,13 @@ export default defineConfig({ }, projects: [ - { - name: 'setup database', - testMatch: /database\.setup\.ts/, - }, { name: 'chromium', use: { ...devices['Desktop Chrome'] }, - dependencies: ['setup database'], }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, - dependencies: ['setup database'], }, // { diff --git a/src/database/models/game-slot.model.ts b/src/database/models/game-slot.model.ts index 974c2905..37d8de3f 100644 --- a/src/database/models/game-slot.model.ts +++ b/src/database/models/game-slot.model.ts @@ -5,7 +5,6 @@ import type { SteamId64 } from '../../shared/types/steam-id-64' export enum SlotStatus { active = 'active', waitingForSubstitute = 'waiting for substitute', - replaced = 'replaced', } export enum PlayerConnectionStatus { diff --git a/src/games/find-player-slot.ts b/src/games/find-player-slot.ts deleted file mode 100644 index ea876f41..00000000 --- a/src/games/find-player-slot.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { collections } from '../database/collections' -import { SlotStatus } from '../database/models/game-slot.model' -import type { GameModel } from '../database/models/game.model' -import type { SteamId64 } from '../shared/types/steam-id-64' - -export async function findPlayerSlot(game: GameModel, player: SteamId64) { - for (const slot of game.slots.filter(s => s.status !== SlotStatus.replaced)) { - const ps = await collections.players.findOne({ steamId: slot.player }) - if (!ps) { - throw new Error(`player in slot does not exist: ${slot.player.toString()}`) - } - - if (ps.steamId === player) { - return slot - } - } - - return null -} diff --git a/src/games/plugins/manage-in-game-players.ts b/src/games/plugins/manage-in-game-players.ts index 0d1add37..b95c8559 100644 --- a/src/games/plugins/manage-in-game-players.ts +++ b/src/games/plugins/manage-in-game-players.ts @@ -23,6 +23,10 @@ export default fp( events.on( 'game:playerReplaced', safe(async ({ game, replacee, replacement }) => { + if (replacee === replacement) { + return + } + const re = await collections.players.findOne({ steamId: replacee }) if (!re) { throw new Error(`player not found: ${replacee}`) diff --git a/src/games/rcon/blacklist-player.ts b/src/games/rcon/blacklist-player.ts index d1494232..698fed19 100644 --- a/src/games/rcon/blacklist-player.ts +++ b/src/games/rcon/blacklist-player.ts @@ -4,11 +4,6 @@ import { delGamePlayer } from './commands' import { withRcon } from './with-rcon' export async function blacklistPlayer(game: GameModel, steamId: SteamId64) { - const slot = game.slots.find(slot => slot.player === steamId) - if (!slot) { - throw new Error(`player not found in game: ${steamId}`) - } - return await withRcon(game, async ({ rcon }) => { await rcon.send(delGamePlayer(steamId)) }) diff --git a/src/games/rcon/configure.ts b/src/games/rcon/configure.ts index 33aa5f4b..a46c5763 100644 --- a/src/games/rcon/configure.ts +++ b/src/games/rcon/configure.ts @@ -3,7 +3,6 @@ import { deburr } from 'lodash-es' import { configuration } from '../../configuration' import { collections } from '../../database/collections' import { GameEventType } from '../../database/models/game-event.model' -import { SlotStatus } from '../../database/models/game-slot.model' import { type GameModel, GameState } from '../../database/models/game.model' import { environment } from '../../environment' import { logger } from '../../logger' @@ -147,15 +146,13 @@ async function compileConfig(game: GameModel, password: string): Promise slot.status !== SlotStatus.replaced) - .map(async slot => { - const player = await collections.players.findOne({ steamId: slot.player }) - if (player === null) { - throw new Error(`player ${slot.player} not found`) - } - return addGamePlayer(player.steamId, deburr(player.name), slot.team, slot.gameClass) - }), + game.slots.map(async slot => { + const player = await collections.players.findOne({ steamId: slot.player }) + if (player === null) { + throw new Error(`player ${slot.player} not found`) + } + return addGamePlayer(player.steamId, deburr(player.name), slot.team, slot.gameClass) + }), ), ) .concat(enablePlayerWhitelist()) diff --git a/src/games/replace-player.ts b/src/games/replace-player.ts index 997604cf..a599dd1c 100644 --- a/src/games/replace-player.ts +++ b/src/games/replace-player.ts @@ -37,32 +37,7 @@ export async function replacePlayer({ } let newGame: GameModel - - if (replacee === replacement) { - newGame = await update( - { number }, - { - $set: { - 'slots.$[slot].status': SlotStatus.active, - }, - $push: { - events: { - event: GameEventType.playerReplaced, - at: new Date(), - replacee, - replacement, - }, - }, - }, - { - arrayFilters: [ - { - $and: [{ 'slot.player': { $eq: replacee } }], - }, - ], - }, - ) - } else { + if (replacee !== replacement) { const rm = await collections.players.findOne({ steamId: replacement }) if (!rm) { throw new Error(`replacement player not found: ${replacement}`) @@ -71,51 +46,42 @@ export async function replacePlayer({ if (rm.activeGame !== undefined) { throw new Error(`player denied: player has active game`) } + } - await update( - { number }, - { - $push: { - slots: { - player: replacement, - team: slot.team, - gameClass: slot.gameClass, - status: SlotStatus.active, - connectionStatus: PlayerConnectionStatus.offline, - }, - events: { - event: GameEventType.playerReplaced, - at: new Date(), - replacee, - replacement, - }, - }, + newGame = await update( + { number }, + { + $set: { + 'slots.$[slot].status': SlotStatus.active, + 'slots.$[slot].player': replacement, + ...(replacee === replacement + ? {} + : { 'slots.$[slot].connectionStatus': PlayerConnectionStatus.offline }), }, - ) - - newGame = await update( - { number }, - { - $set: { - 'slots.$[slot].status': SlotStatus.replaced, + $push: { + events: { + event: GameEventType.playerReplaced, + at: new Date(), + replacee, + replacement, }, }, - { - arrayFilters: [ - { - $and: [ - { 'slot.player': { $eq: replacee } }, - { - 'slot.status': { - $eq: SlotStatus.waitingForSubstitute, - }, + }, + { + arrayFilters: [ + { + $and: [ + { 'slot.player': { $eq: replacee } }, + { + 'slot.status': { + $eq: SlotStatus.waitingForSubstitute, }, - ], - }, - ], - }, - ) - } + }, + ], + }, + ], + }, + ) events.emit('game:playerReplaced', { game: newGame, replacee, replacement }) return game diff --git a/src/games/request-substitute.ts b/src/games/request-substitute.ts index c30d036c..0da2761e 100644 --- a/src/games/request-substitute.ts +++ b/src/games/request-substitute.ts @@ -19,7 +19,7 @@ export async function requestSubstitute({ actor: SteamId64 | Bot reason?: string }): Promise { - logger.trace({ number, replacee, actor, reason }, 'substitutePlayer()') + logger.trace({ number, replacee, actor, reason }, 'games.requestSubstitute()') const game = await collections.games.findOne({ number }) if (game === null) { diff --git a/src/games/views/html/game-slot.tsx b/src/games/views/html/game-slot.tsx index 80d9cd0a..8f3345c9 100644 --- a/src/games/views/html/game-slot.tsx +++ b/src/games/views/html/game-slot.tsx @@ -30,7 +30,6 @@ export async function GameSlot(props: { { [SlotStatus.active]: 'active', [SlotStatus.waitingForSubstitute]: 'waiting-for-substitute', - [SlotStatus.replaced]: 'replaced', }[props.slot.status], ]} data-player={player.steamId} diff --git a/src/migrations/005-remove-replaced-game-slots.ts b/src/migrations/005-remove-replaced-game-slots.ts new file mode 100644 index 00000000..cce2acde --- /dev/null +++ b/src/migrations/005-remove-replaced-game-slots.ts @@ -0,0 +1,15 @@ +import { collections } from '../database/collections' +import type { SlotStatus } from '../database/models/game-slot.model' + +export async function up() { + await collections.games.updateMany( + {}, + { + $pull: { + slots: { + status: 'replaced' as SlotStatus, + }, + }, + }, + ) +} diff --git a/tests/20-game/08-player-substitutes.spec.ts b/tests/20-game/08-player-substitutes.spec.ts new file mode 100644 index 00000000..ef79efa8 --- /dev/null +++ b/tests/20-game/08-player-substitutes.spec.ts @@ -0,0 +1,70 @@ +import { secondsToMilliseconds } from 'date-fns' +import { launchGame as test, expect } from '../fixtures/launch-game' + +test.use({ waitForStage: 'started' }) +test.describe('substitutes', () => { + test('substitute self', async ({ gameNumber, users, page, gameServer }) => { + const admin = users.getAdmin() + const adminsPage = await admin.gamePage(gameNumber) + const mayflowersPage = await users.byName('Mayflower').gamePage(gameNumber) + + await expect(adminsPage.playerLink('Mayflower')).toBeVisible() + await adminsPage.requestSubstitute('Mayflower') + await expect(adminsPage.playerLink('Mayflower')).not.toBeVisible() + await expect(gameServer).toHaveCommand(`say Looking for replacement for Mayflower...`, { + timeout: secondsToMilliseconds(1), + }) + + await expect(adminsPage.gameEvent(`${admin.playerName} requested substitute`)).toBeVisible() + await expect(adminsPage.playerLink('Mayflower')).not.toBeVisible() + await expect(mayflowersPage.gameEvent(`${admin.playerName} requested substitute`)).toBeVisible() + await expect(mayflowersPage.playerLink('Mayflower')).not.toBeVisible() + + await expect( + page.getByText(`Team BLU needs a substitute for scout in game #${mayflowersPage.gameNumber}`), + ).toBeVisible() + + await mayflowersPage.replacePlayer('Mayflower') + + await expect(adminsPage.playerLink('Mayflower')).toBeVisible() + await expect(adminsPage.gameEvent(`Mayflower replaced Mayflower`)).toBeVisible() + await expect(mayflowersPage.playerLink('Mayflower')).toBeVisible() + await expect(mayflowersPage.gameEvent(`Mayflower replaced Mayflower`)).toBeVisible() + }) + + test('substitute other', async ({ gameNumber, users, page, gameServer }) => { + const admin = users.getAdmin() + const adminsPage = await admin.gamePage(gameNumber) + const tommyGunsPage = await users.byName('TommyGun').gamePage(gameNumber) + await tommyGunsPage.goto() + + await expect(adminsPage.playerLink('Mayflower')).toBeVisible() + await adminsPage.requestSubstitute('Mayflower') + await expect(adminsPage.playerLink('Mayflower')).not.toBeVisible() + await expect(gameServer).toHaveCommand(`say Looking for replacement for Mayflower...`, { + timeout: secondsToMilliseconds(1), + }) + + await expect(adminsPage.gameEvent(`${admin.playerName} requested substitute`)).toBeVisible() + await expect(adminsPage.playerLink('Mayflower')).not.toBeVisible() + await expect(tommyGunsPage.gameEvent(`${admin.playerName} requested substitute`)).toBeVisible() + await expect(tommyGunsPage.playerLink('Mayflower')).not.toBeVisible() + + await expect( + page.getByText(`Team BLU needs a substitute for scout in game #${adminsPage.gameNumber}`), + ).toBeVisible() + + await tommyGunsPage.replacePlayer('Mayflower') + + await expect(adminsPage.playerLink('TommyGun')).toBeVisible() + await expect(adminsPage.gameEvent(`TommyGun replaced Mayflower`)).toBeVisible() + await expect(tommyGunsPage.playerLink('TommyGun')).toBeVisible() + await expect(tommyGunsPage.gameEvent(`TommyGun replaced Mayflower`)).toBeVisible() + + await expect(gameServer).toHaveCommand(`sm_game_player_add ${users.byName('TommyGun').steamId}`) + await expect(gameServer).toHaveCommand( + `sm_game_player_del ${users.byName('Mayflower').steamId}`, + ) + await expect(gameServer).toHaveCommand(`say Mayflower has been replaced by TommyGun`) + }) +}) diff --git a/tests/50-player-behavior/01-auto-requests-substitute.spec.ts b/tests/20-game/09-auto-substitute-requests.spec.ts similarity index 99% rename from tests/50-player-behavior/01-auto-requests-substitute.spec.ts rename to tests/20-game/09-auto-substitute-requests.spec.ts index e4df151e..cfb244c3 100644 --- a/tests/50-player-behavior/01-auto-requests-substitute.spec.ts +++ b/tests/20-game/09-auto-substitute-requests.spec.ts @@ -2,7 +2,6 @@ import { secondsToMilliseconds } from 'date-fns' import { GamePage } from '../pages/game.page' import { expect, mergeTests } from '@playwright/test' import { accessMongoDb } from '../fixtures/access-mongo-db' -import type { UserContext } from '../user-manager' import { launchGame } from '../fixtures/launch-game' const test = mergeTests(launchGame, accessMongoDb) diff --git a/tests/40-bans/01-lock-queue.spec.ts b/tests/30-bans/01-lock-queue.spec.ts similarity index 100% rename from tests/40-bans/01-lock-queue.spec.ts rename to tests/30-bans/01-lock-queue.spec.ts diff --git a/tests/40-bans/02-ban-alert.spec.ts b/tests/30-bans/02-ban-alert.spec.ts similarity index 100% rename from tests/40-bans/02-ban-alert.spec.ts rename to tests/30-bans/02-ban-alert.spec.ts diff --git a/tests/30-player-substitutes/01-substitute-self.spec.ts b/tests/30-player-substitutes/01-substitute-self.spec.ts deleted file mode 100644 index 07160e8a..00000000 --- a/tests/30-player-substitutes/01-substitute-self.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { launchGame, expect } from '../fixtures/launch-game' - -launchGame('substitute self', async ({ gameNumber, users, page }) => { - const admin = users.getAdmin() - const adminsPage = await admin.gamePage(gameNumber) - const mayflowersPage = await users.byName('Mayflower').gamePage(gameNumber) - - await expect(adminsPage.playerLink('Mayflower')).toBeVisible() - await adminsPage.requestSubstitute('Mayflower') - await expect(adminsPage.playerLink('Mayflower')).not.toBeVisible() - - await expect(adminsPage.gameEvent(`${admin.playerName} requested substitute`)).toBeVisible() - await expect(adminsPage.playerLink('Mayflower')).not.toBeVisible() - await expect(mayflowersPage.gameEvent(`${admin.playerName} requested substitute`)).toBeVisible() - await expect(mayflowersPage.playerLink('Mayflower')).not.toBeVisible() - - await expect( - page.getByText(`Team BLU needs a substitute for scout in game #${mayflowersPage.gameNumber}`), - ).toBeVisible() - - await mayflowersPage.replacePlayer('Mayflower') - - await expect(adminsPage.playerLink('Mayflower')).toBeVisible() - await expect(adminsPage.gameEvent(`Mayflower replaced Mayflower`)).toBeVisible() - await expect(mayflowersPage.playerLink('Mayflower')).toBeVisible() - await expect(mayflowersPage.gameEvent(`Mayflower replaced Mayflower`)).toBeVisible() -}) diff --git a/tests/30-player-substitutes/02-substitute-other.spec.ts b/tests/30-player-substitutes/02-substitute-other.spec.ts deleted file mode 100644 index 3cda005c..00000000 --- a/tests/30-player-substitutes/02-substitute-other.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { launchGame, expect } from '../fixtures/launch-game' - -launchGame('substitute other', async ({ gameNumber, users, page }) => { - const admin = users.getAdmin() - const adminsPage = await admin.gamePage(gameNumber) - const tommyGunsPage = await users.byName('TommyGun').gamePage(gameNumber) - await tommyGunsPage.goto() - - await expect(adminsPage.playerLink('Mayflower')).toBeVisible() - await adminsPage.requestSubstitute('Mayflower') - await expect(adminsPage.playerLink('Mayflower')).not.toBeVisible() - - await expect(adminsPage.gameEvent(`${admin.playerName} requested substitute`)).toBeVisible() - await expect(adminsPage.playerLink('Mayflower')).not.toBeVisible() - await expect(tommyGunsPage.gameEvent(`${admin.playerName} requested substitute`)).toBeVisible() - await expect(tommyGunsPage.playerLink('Mayflower')).not.toBeVisible() - - await expect( - page.getByText(`Team BLU needs a substitute for scout in game #${adminsPage.gameNumber}`), - ).toBeVisible() - - await tommyGunsPage.replacePlayer('Mayflower') - - await expect(adminsPage.playerLink('TommyGun')).toBeVisible() - await expect(adminsPage.gameEvent(`TommyGun replaced Mayflower`)).toBeVisible() - await expect(tommyGunsPage.playerLink('TommyGun')).toBeVisible() - await expect(tommyGunsPage.gameEvent(`TommyGun replaced Mayflower`)).toBeVisible() -}) diff --git a/tests/30-player-substitutes/03-manages-in-game.spec.ts b/tests/30-player-substitutes/03-manages-in-game.spec.ts deleted file mode 100644 index fe1bfcc5..00000000 --- a/tests/30-player-substitutes/03-manages-in-game.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { secondsToMilliseconds } from 'date-fns' -import { expect, launchGame } from '../fixtures/launch-game' - -launchGame.use({ waitForStage: 'started' }) -launchGame('manages in-game', async ({ gameNumber, users, gameServer }) => { - const admin = users.getAdmin() - - const adminsPage = await admin.gamePage(gameNumber) - const tommyGunsPage = await users.byName('TommyGun').gamePage(gameNumber) - await tommyGunsPage.goto() - - await expect(adminsPage.playerLink('Mayflower')).toBeVisible() - await adminsPage.requestSubstitute('Mayflower') - - await expect(gameServer).toHaveCommand(`say Looking for replacement for Mayflower...`, { - timeout: secondsToMilliseconds(1), - }) - - await tommyGunsPage.replacePlayer('Mayflower') - - await expect(gameServer).toHaveCommand(`sm_game_player_add ${users.byName('TommyGun').steamId}`) - await expect(gameServer).toHaveCommand(`sm_game_player_del ${users.byName('Mayflower').steamId}`) - await expect(gameServer).toHaveCommand(`say Mayflower has been replaced by TommyGun`) -}) diff --git a/tests/database.setup.ts b/tests/database.setup.ts deleted file mode 100644 index fad224a1..00000000 --- a/tests/database.setup.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { MongoClient } from 'mongodb' -import { test as setup } from '@playwright/test' -import { users } from './data' - -const client = new MongoClient(process.env['MONGODB_URI']!) -await client.connect() -const db = client.db() -const players = db.collection('players') -const games = db.collection('games') - -async function upsertPlayer({ - steamId, - name, - roles, -}: { - steamId: string - name: string - roles?: readonly string[] -}) { - await players.updateOne( - { steamId }, - { - $set: { - name, - joinedAt: new Date(), - avatar: { - small: 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg', - medium: - 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_medium.jpg', - large: - 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', - }, - roles: roles ?? [], - hasAcceptedRules: true, - cooldownLevel: 0, - }, - $unset: { - activeGame: 1, - skill: 1, - }, - }, - { - upsert: true, - }, - ) -} - -setup('create test users accounts', async () => { - await games.updateMany( - { state: { $in: ['created', 'launching', 'configuring'] } }, - { $set: { state: 'interrupted' } }, - ) - - for (const user of users) { - await upsertPlayer(user) - } -}) diff --git a/tests/fixtures/auth-users.ts b/tests/fixtures/auth-users.ts index 864723d8..2e041576 100644 --- a/tests/fixtures/auth-users.ts +++ b/tests/fixtures/auth-users.ts @@ -9,6 +9,7 @@ import { users } from '../data' interface AuthUsersFixture { steamIds: UserSteamId[] users: UserManager + usersCreatedInDb: void } const randomBytes = promisify(randomBytesCb) @@ -17,6 +18,42 @@ export const authUsers = test.extend({ steamIds: async ({}, use) => { await use(users.map(u => u.steamId)) }, + usersCreatedInDb: [ + async ({ db }, use) => { + const players = db.collection('players') + for (const user of users) { + await players.updateOne( + { steamId: user.steamId }, + { + $set: { + name: user.name, + joinedAt: new Date(), + avatar: { + small: + 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg', + medium: + 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_medium.jpg', + large: + 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', + }, + roles: 'roles' in user ? (user.roles ?? []) : [], + hasAcceptedRules: true, + cooldownLevel: 0, + }, + $unset: { + activeGame: 1, + skill: 1, + }, + }, + { + upsert: true, + }, + ) + } + await use() + }, + { auto: true }, + ], users: async ({ db, steamIds, browser, baseURL }, use) => { // opening a new context takes some time test.setTimeout(minutesToMilliseconds(1))