├── .deepsource.toml ├── .dockerignore ├── .editorconfig ├── .eslintrc.json ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .prettierrc ├── .release-it.json ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── __mocks__ ├── @tf2pickup-org │ └── mumble-client.ts ├── discord.js.ts └── dns.js ├── client ├── index.html └── logged-in-with-twitch-tv.html ├── codecov.yml ├── configs ├── discord.ts ├── queue │ ├── 6v6.json │ ├── 9v9.json │ ├── bball.json │ ├── test.json │ └── ultiduo.json └── urls.ts ├── docker-compose.yml ├── e2e.jest.config.ts ├── e2e ├── app.e2e-spec.ts ├── assign-and-release-gameserver.e2e-spec.ts ├── cancel-player-substitute.e2e-spec.ts ├── configuration.e2e-spec.ts ├── dump │ └── tf2pickuppl_e2e_tests │ │ ├── configuration.bson │ │ ├── configuration.metadata.json │ │ ├── documents.bson │ │ ├── documents.metadata.json │ │ ├── futureplayerskills.bson │ │ ├── futureplayerskills.metadata.json │ │ ├── games.bson │ │ ├── games.metadata.json │ │ ├── gameserverdiagnosticruns.bson │ │ ├── gameserverdiagnosticruns.metadata.json │ │ ├── gameservers.bson │ │ ├── gameservers.metadata.json │ │ ├── keys.bson │ │ ├── keys.metadata.json │ │ ├── maps.bson │ │ ├── maps.metadata.json │ │ ├── migrations.bson │ │ ├── migrations.metadata.json │ │ ├── playerpreferences.bson │ │ ├── playerpreferences.metadata.json │ │ ├── players.bson │ │ ├── players.metadata.json │ │ ├── playerskills.bson │ │ ├── playerskills.metadata.json │ │ ├── refreshtokens.bson │ │ ├── refreshtokens.metadata.json │ │ ├── twitchtvprofiles.bson │ │ └── twitchtvprofiles.metadata.json ├── game-server-diagnostics.e2e-spec.ts ├── game-server-heartbeat.e2e-spec.ts ├── launch-game.e2e-spec.ts ├── manage-player-bans.e2e-spec.ts ├── player-action-logs.e2e-spec.ts ├── player-disconnects-and-gets-substituted.e2e-spec.ts ├── player-does-not-join-and-gets-substituted.e2e-spec.ts ├── player-substitutes-another-player.e2e-spec.ts ├── player-substitutes-himself.e2e-spec.ts ├── reassign-game-server.e2e-spec.ts ├── test-data.ts ├── update-player-skill.e2e-spec.ts ├── utils │ ├── wait-a-bit.ts │ └── wait-for-the-game-to-launch.ts └── websocket.e2e-spec.ts ├── jest.config.ts ├── migrations ├── 1615906051508-move-keystore-to-database.js ├── 1616081071738-new-configuration-model.js ├── 1616687902073-mark-all-game-servers-as-not-deleted.js ├── 1616694341420-extended-player-roles.js ├── 1618360976309-separate-twitch-tv-profile-model.js ├── 1628156071212-mumble-server-to-configuration.js ├── 1629138778872-rename-game-server-mumble-to-voice-channel-name.js ├── 1630058041029-even-newer-configuration-model.js ├── 1632143538928-all-game-servers-offline.js ├── 1635720162156-add-game-servers-priority.js ├── 1635887923233-add-endedAt-to-game.js ├── 1636162329824-add-player-roles.js ├── 1646354644758-game-server-v83.js ├── 1652720907471-game-server-v9.js ├── 1662421999258-remove-game-server-model.js ├── 1666740945986-merge-player-skill-collection.js ├── 1671836518103-create-game-events-array.js ├── 1674146545831-configuration-v2.js ├── 1674218801762-player-joined-at-to-string.js ├── 1676334656027-initialize-cooldown-on-all-players.js ├── 1697047396927-move-discord-configuration.js └── 1705011828390-remove-serveme-tf-reservations-collection.js ├── nest-cli.json ├── package.json ├── pnpm-lock.yaml ├── renovate.json ├── sample.env ├── src ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── app.ts ├── auth │ ├── auth.module.ts │ ├── controllers │ │ ├── auth.controller.spec.ts │ │ └── auth.controller.ts │ ├── decorators │ │ ├── auth.decorator.ts │ │ ├── secret.decorator.ts │ │ ├── user.decorator.ts │ │ └── ws-authorized.decorator.ts │ ├── errors │ │ └── invalid-token.error.ts │ ├── gateways │ │ ├── auth.gateway.spec.ts │ │ └── auth.gateway.ts │ ├── guards │ │ ├── role.guard.spec.ts │ │ ├── role.guard.ts │ │ ├── ws-authorized.guard.spec.ts │ │ └── ws-authorized.guard.ts │ ├── middleware │ │ ├── set-redirect-url-cookie.spec.ts │ │ └── set-redirect-url-cookie.ts │ ├── models │ │ └── key.ts │ ├── services │ │ ├── auth.service.spec.ts │ │ └── auth.service.ts │ ├── strategies │ │ ├── game-server-secret.strategy.ts │ │ ├── jwt.strategy.ts │ │ ├── steam.strategy.spec.ts │ │ └── steam.strategy.ts │ ├── tokens │ │ ├── auth-token-key.token.ts │ │ ├── context-token-key.token.ts │ │ └── websocket-secret.token.ts │ ├── types │ │ ├── jwt-token-purpose.ts │ │ ├── key-name.ts │ │ ├── key-pair.ts │ │ └── secret-purpose.ts │ └── utils │ │ ├── import-or-generate-keys.spec.ts │ │ └── import-or-generate-keys.ts ├── certificates │ ├── certificates.module.ts │ ├── models │ │ └── certificate.ts │ └── services │ │ ├── certificates.service.spec.ts │ │ └── certificates.service.ts ├── configuration │ ├── configuration-entry.ts │ ├── configuration.module.ts │ ├── controllers │ │ ├── configuration.controller.spec.ts │ │ └── configuration.controller.ts │ ├── dto │ │ └── set-configuration-entry.ts │ ├── errors │ │ └── configuration-entry-not-found.error.ts │ ├── filters │ │ ├── configuration-entry-error.filter.spec.ts │ │ └── configuration-entry-error.filter.ts │ ├── models │ │ └── configuration-item.ts │ └── services │ │ ├── configuration.service.spec.ts │ │ └── configuration.service.ts ├── configure-application.ts ├── documents │ ├── controllers │ │ ├── documents.controller.spec.ts │ │ └── documents.controller.ts │ ├── default │ │ ├── privacy-policy.md │ │ └── rules.md │ ├── documents.module.ts │ ├── models │ │ └── document.ts │ └── services │ │ ├── documents.service.spec.ts │ │ └── documents.service.ts ├── environment │ ├── environment.module.ts │ ├── environment.spec.ts │ └── environment.ts ├── etf2l │ ├── errors │ │ ├── etf2l-api.error.ts │ │ └── no-etf2l-account.error.ts │ ├── etf2l.module.ts │ ├── services │ │ ├── etf2l-api.service.spec.ts │ │ └── etf2l-api.service.ts │ └── types │ │ └── etf2l-profile.ts ├── events │ ├── events.module.ts │ └── events.ts ├── game-configs │ ├── configs │ │ └── default.cfg │ ├── game-configs.module.ts │ └── services │ │ ├── game-configs.service.spec.ts │ │ └── game-configs.service.ts ├── game-coordinator │ ├── errors │ │ ├── cannot-cleanup-game-server.error.ts │ │ ├── cannot-configure-game.error.ts │ │ ├── game-has-already-ended.error.ts │ │ └── game-server-not-assigned.error.ts │ ├── game-coordinator.module.ts │ ├── services │ │ ├── auto-end-games.service.spec.ts │ │ ├── auto-end-games.service.ts │ │ ├── game-event-listener.service.spec.ts │ │ ├── game-event-listener.service.ts │ │ ├── game-runtime.service.spec.ts │ │ ├── game-runtime.service.ts │ │ ├── player-behavior-handler.service.spec.ts │ │ ├── player-behavior-handler.service.ts │ │ ├── round-tracker.service.spec.ts │ │ ├── round-tracker.service.ts │ │ ├── server-cleanup.service.spec.ts │ │ ├── server-cleanup.service.ts │ │ ├── server-configurator.service.spec.ts │ │ └── server-configurator.service.ts │ └── utils │ │ ├── extract-con-var-value.spec.ts │ │ ├── extract-con-var-value.ts │ │ ├── fix-team-name.spec.ts │ │ ├── fix-team-name.ts │ │ ├── make-connect-string.spec.ts │ │ ├── make-connect-string.ts │ │ └── rcon-commands.ts ├── game-servers │ ├── controllers │ │ ├── game-servers.controller.spec.ts │ │ └── game-servers.controller.ts │ ├── dto │ │ └── game-server-option.dto.ts │ ├── errors │ │ └── no-free-game-server-available.error.ts │ ├── game-server-provider.ts │ ├── game-servers.module.ts │ ├── interfaces │ │ ├── game-server-controls.ts │ │ ├── game-server-details.ts │ │ └── game-server-option.ts │ ├── providers │ │ ├── game-servers-providers.module.ts │ │ ├── serveme-tf │ │ │ ├── config.ts │ │ │ ├── controllers │ │ │ │ ├── serveme-tf.controller.spec.ts │ │ │ │ └── serveme-tf.controller.ts │ │ │ ├── serveme-tf-client.token.ts │ │ │ ├── serveme-tf-server-controls.ts │ │ │ ├── serveme-tf.module.ts │ │ │ └── services │ │ │ │ ├── serveme-tf-configuration.service.spec.ts │ │ │ │ ├── serveme-tf-configuration.service.ts │ │ │ │ ├── serveme-tf.service.spec.ts │ │ │ │ └── serveme-tf.service.ts │ │ └── static-game-server │ │ │ ├── config.ts │ │ │ ├── controllers │ │ │ ├── game-server-diagnostics.controller.spec.ts │ │ │ ├── game-server-diagnostics.controller.ts │ │ │ ├── static-game-servers.controller.spec.ts │ │ │ └── static-game-servers.controller.ts │ │ │ ├── diagnostic-checks │ │ │ ├── log-forwarding.ts │ │ │ └── rcon-connection.ts │ │ │ ├── dto │ │ │ └── game-server-heartbeat.ts │ │ │ ├── interfaces │ │ │ ├── diagnostic-check-result.ts │ │ │ └── diagnostic-check-runner.ts │ │ │ ├── models │ │ │ ├── diagnostic-check-status.ts │ │ │ ├── diagnostic-check.ts │ │ │ ├── diagnostic-run-status.ts │ │ │ ├── game-server-diagnostic-run.ts │ │ │ └── static-game-server.ts │ │ │ ├── services │ │ │ ├── game-server-diagnostics.service.spec.ts │ │ │ ├── game-server-diagnostics.service.ts │ │ │ ├── static-game-servers.service.spec.ts │ │ │ └── static-game-servers.service.ts │ │ │ ├── static-game-server-controls.spec.ts │ │ │ ├── static-game-server-controls.ts │ │ │ ├── static-game-server-provider-name.ts │ │ │ ├── static-game-server.module.ts │ │ │ └── utils │ │ │ └── generate-logsecret.ts │ └── services │ │ ├── game-servers.service.spec.ts │ │ └── game-servers.service.ts ├── games │ ├── controllers │ │ ├── games-with-substitution-requests.controller.spec.ts │ │ ├── games-with-substitution-requests.controller.ts │ │ ├── games.controller.spec.ts │ │ └── games.controller.ts │ ├── dto │ │ ├── connect-info.dto.ts │ │ ├── game-event.dto.ts │ │ ├── game-server-option-identifier.ts │ │ ├── game-slot-dto.ts │ │ ├── game-sort-params.ts │ │ ├── game.dto.ts │ │ └── paginated-game-list.dto.ts │ ├── errors │ │ ├── cannot-assign-gameserver.error.ts │ │ ├── game-in-wrong-state.error.ts │ │ └── player-not-in-this-game.error.ts │ ├── filters │ │ ├── game-in-wrong-state-error.filter.spec.ts │ │ ├── game-in-wrong-state-error.filter.ts │ │ ├── player-not-in-this-game-error.filter.spec.ts │ │ └── player-not-in-this-game-error.filter.ts │ ├── games.module.ts │ ├── gateways │ │ ├── game-slots.gateway.spec.ts │ │ ├── game-slots.gateway.ts │ │ ├── games.gateway.spec.ts │ │ └── games.gateway.ts │ ├── guards │ │ ├── can-replace-player.guard.spec.ts │ │ └── can-replace-player.guard.ts │ ├── models │ │ ├── events │ │ │ ├── game-created.ts │ │ │ ├── game-ended.ts │ │ │ ├── game-server-assigned.ts │ │ │ ├── game-server-initialized.ts │ │ │ ├── game-started.ts │ │ │ ├── player-joined-game-server-team.ts │ │ │ ├── player-joined-game-server.ts │ │ │ ├── player-left-game-server.ts │ │ │ ├── player-replaced.ts │ │ │ ├── round-ended.ts │ │ │ └── substitute-requested.ts │ │ ├── game-event-type.ts │ │ ├── game-event.ts │ │ ├── game-logs.ts │ │ ├── game-server.ts │ │ ├── game-slot.ts │ │ ├── game-state.ts │ │ ├── game.ts │ │ ├── player-connection-status.ts │ │ ├── slot-status.ts │ │ └── tf2-team.ts │ ├── pipes │ │ ├── game-by-id-or-number.pipe.spec.ts │ │ ├── game-by-id-or-number.pipe.ts │ │ ├── parse-sort-params.pipe.spec.ts │ │ └── parse-sort-params.pipe.ts │ ├── services │ │ ├── __mocks__ │ │ │ └── games.service.ts │ │ ├── game-event-handler.service.spec.ts │ │ ├── game-event-handler.service.ts │ │ ├── game-logs.service.spec.ts │ │ ├── game-logs.service.ts │ │ ├── game-server-assigner.service.spec.ts │ │ ├── game-server-assigner.service.ts │ │ ├── games-configuration.service.spec.ts │ │ ├── games-configuration.service.ts │ │ ├── games.service.spec.ts │ │ ├── games.service.ts │ │ ├── player-substitution.service.spec.ts │ │ ├── player-substitution.service.ts │ │ ├── voice-channel-urls.service.spec.ts │ │ └── voice-channel-urls.service.ts │ ├── tokens │ │ └── game-model-mutex.token.ts │ ├── types │ │ ├── cooldown-level.ts │ │ ├── game-id.ts │ │ ├── logs-tf-upload-method.ts │ │ ├── most-active-players.ts │ │ └── voice-server-type.ts │ └── utils │ │ ├── pick-teams.spec.ts │ │ └── pick-teams.ts ├── log-receiver │ ├── errors │ │ └── log-message-invalid.error.ts │ ├── log-receiver.module.ts │ ├── services │ │ └── log-receiver.service.ts │ ├── types │ │ └── log-message.ts │ └── utils │ │ └── parse-log-message.ts ├── logs-tf │ ├── errors │ │ └── logs-tf-upload.error.ts │ ├── logs-tf.module.ts │ └── services │ │ ├── logs-tf-api.service.spec.ts │ │ └── logs-tf-api.service.ts ├── main.ts ├── migrations │ ├── migration.store.ts │ ├── migrations.module.ts │ ├── services │ │ └── migrations.service.ts │ └── stores │ │ └── mongo-db.store.ts ├── player-actions-logger │ ├── controllers │ │ ├── player-action-logs.controller.spec.ts │ │ └── player-action-logs.controller.ts │ ├── dto │ │ └── player-action.dto.ts │ ├── models │ │ └── player-action-entry.ts │ ├── pipes │ │ ├── parse-filters.pipe.spec.ts │ │ └── parse-filters.pipe.ts │ ├── player-actions-logger.module.ts │ ├── player-actions │ │ ├── player-action.ts │ │ ├── player-connected-to-gameserver.ts │ │ ├── player-online-status-changed.ts │ │ └── player-said-in-match-chat.ts │ └── services │ │ ├── player-action-logger.service.spec.ts │ │ ├── player-action-logger.service.ts │ │ ├── player-actions-repository.service.spec.ts │ │ └── player-actions-repository.service.ts ├── player-preferences │ ├── models │ │ └── player-preferences.ts │ ├── player-preferences.module.ts │ └── services │ │ ├── player-preferences.service.spec.ts │ │ └── player-preferences.service.ts ├── players │ ├── controllers │ │ ├── hall-of-fame.controller.spec.ts │ │ ├── hall-of-fame.controller.ts │ │ ├── online-players.controller.spec.ts │ │ ├── online-players.controller.ts │ │ ├── player-skill-wrapper.ts │ │ ├── players.controller.spec.ts │ │ └── players.controller.ts │ ├── dto │ │ ├── add-player-ban.schema.ts │ │ ├── force-create-player.schema.ts │ │ ├── import-skills-response.dto.ts │ │ ├── linked-profiles.dto.ts │ │ ├── player-ban.dto.ts │ │ ├── player-skill.dto.ts │ │ ├── player-stats.dto.ts │ │ ├── player.dto.ts │ │ └── update-player.schema.ts │ ├── errors │ │ ├── account-banned.error.ts │ │ ├── insufficient-tf2-in-game-hours.error.ts │ │ ├── player-name-taken.error.ts │ │ └── player-skill-record-malformed.error.ts │ ├── gateways │ │ ├── players.gateway.spec.ts │ │ └── players.gateway.ts │ ├── models │ │ ├── future-player-skill.ts │ │ ├── player-avatar.ts │ │ ├── player-ban.ts │ │ ├── player-role.ts │ │ └── player.ts │ ├── pipes │ │ ├── player-by-id.pipe.spec.ts │ │ ├── player-by-id.pipe.ts │ │ ├── validate-skill.pipe.spec.ts │ │ └── validate-skill.pipe.ts │ ├── players.module.ts │ ├── services │ │ ├── __mocks__ │ │ │ └── players.service.ts │ │ ├── future-player-skill.service.spec.ts │ │ ├── future-player-skill.service.ts │ │ ├── import-export-skill.service.spec.ts │ │ ├── import-export-skill.service.ts │ │ ├── linked-profiles.service.spec.ts │ │ ├── linked-profiles.service.ts │ │ ├── online-players.service.spec.ts │ │ ├── online-players.service.ts │ │ ├── player-bans.service.spec.ts │ │ ├── player-bans.service.ts │ │ ├── player-cooldown.service.spec.ts │ │ ├── player-cooldown.service.ts │ │ ├── players-configuration.service.spec.ts │ │ ├── players-configuration.service.ts │ │ ├── players.service.spec.ts │ │ └── players.service.ts │ ├── steam-profile.ts │ └── types │ │ ├── linked-profile-provider-name.ts │ │ ├── linked-profile.ts │ │ ├── player-ban-id.ts │ │ └── player-id.ts ├── plugins │ ├── discord │ │ ├── controllers │ │ │ ├── discord.controller.spec.ts │ │ │ └── discord.controller.ts │ │ ├── discord-client.token.ts │ │ ├── discord.module.ts │ │ ├── emojis-to-install.ts │ │ ├── notifications │ │ │ ├── colors.ts │ │ │ ├── game-force-ended.ts │ │ │ ├── game-server-added.ts │ │ │ ├── game-server-offline.ts │ │ │ ├── game-server-online.ts │ │ │ ├── index.ts │ │ │ ├── maps-scrambled.ts │ │ │ ├── new-player.ts │ │ │ ├── no-free-game-servers-available.ts │ │ │ ├── player-ban-added.ts │ │ │ ├── player-ban-revoked.ts │ │ │ ├── player-profile-updated.ts │ │ │ ├── player-skill-changed.ts │ │ │ ├── queue-preview.ts │ │ │ ├── substitute-request.ts │ │ │ ├── substitute-requested.ts │ │ │ └── substitute-was-needed.ts │ │ ├── player-changes.ts │ │ ├── services │ │ │ ├── admin-notifications.service.spec.ts │ │ │ ├── admin-notifications.service.ts │ │ │ ├── discord-configuration.service.spec.ts │ │ │ ├── discord-configuration.service.ts │ │ │ ├── discord.service.spec.ts │ │ │ ├── discord.service.ts │ │ │ ├── emoji-installer.service.spec.ts │ │ │ ├── emoji-installer.service.ts │ │ │ ├── player-subs-notifications.service.spec.ts │ │ │ ├── player-subs-notifications.service.ts │ │ │ ├── queue-prompts.service.spec.ts │ │ │ └── queue-prompts.service.ts │ │ ├── types │ │ │ └── guild-configuration.ts │ │ └── utils │ │ │ └── extract-player-changes.ts │ ├── plugins.module.ts │ └── twitch │ │ ├── controllers │ │ ├── twitch.controller.spec.ts │ │ └── twitch.controller.ts │ │ ├── gateways │ │ ├── twitch.gateway.spec.ts │ │ └── twitch.gateway.ts │ │ ├── models │ │ ├── twitch-stream.ts │ │ └── twitch-tv-profile.ts │ │ ├── services │ │ ├── twitch-auth.service.spec.ts │ │ ├── twitch-auth.service.ts │ │ ├── twitch-tv-api.service.spec.ts │ │ ├── twitch-tv-api.service.ts │ │ ├── twitch-tv-configuration.service.spec.ts │ │ ├── twitch-tv-configuration.service.ts │ │ ├── twitch.service.spec.ts │ │ └── twitch.service.ts │ │ ├── twitch.module.ts │ │ ├── types │ │ ├── twitch-tv-get-streams-response.ts │ │ └── twitch-tv-get-users-response.ts │ │ └── utils │ │ ├── split-to-chunks.spec.ts │ │ └── split-to-chunks.ts ├── profile │ ├── controllers │ │ ├── profile.controller.spec.ts │ │ └── profile.controller.ts │ ├── dto │ │ └── profile.dto.ts │ ├── interfaces │ │ └── restriction.ts │ ├── profile.module.ts │ └── services │ │ ├── profile.service.spec.ts │ │ └── profile.service.ts ├── queue-config │ ├── queue-config.module.ts │ ├── schemas │ │ └── queue-config.schema.ts │ ├── tokens │ │ ├── queue-config-json.token.ts │ │ └── queue-config.token.ts │ └── types │ │ └── queue-config.ts ├── queue │ ├── controllers │ │ ├── queue-slot-wrapper.ts │ │ ├── queue-wrapper.ts │ │ ├── queue.controller.spec.ts │ │ └── queue.controller.ts │ ├── default-map-pool.ts │ ├── dto │ │ ├── map-pool-item.dto.ts │ │ ├── queue-slot.dto.ts │ │ └── queue.dto.ts │ ├── errors │ │ ├── cannot-join-at-this-queue-state.error.ts │ │ ├── cannot-leave-at-this-queue-state.error.ts │ │ ├── cannot-mark-player-as-friend.error.ts │ │ ├── no-such-player.error.ts │ │ ├── no-such-slot.error.ts │ │ ├── player-already-marked-as-friend.error.ts │ │ ├── player-not-in-the-queue.error.ts │ │ ├── slot-occupied.error.ts │ │ └── wrong-queue-state.error.ts │ ├── gateways │ │ ├── queue.gateway.spec.ts │ │ └── queue.gateway.ts │ ├── guards │ │ ├── can-join-queue.guard.spec.ts │ │ └── can-join-queue.guard.ts │ ├── models │ │ ├── map-pool-entry.spec.ts │ │ └── map-pool-entry.ts │ ├── queue.module.ts │ ├── services │ │ ├── auto-game-launcher.service.spec.ts │ │ ├── auto-game-launcher.service.ts │ │ ├── friends.service.spec.ts │ │ ├── friends.service.ts │ │ ├── map-pool.service.spec.ts │ │ ├── map-pool.service.ts │ │ ├── map-vote.service.spec.ts │ │ ├── map-vote.service.ts │ │ ├── queue-announcements.service.spec.ts │ │ ├── queue-announcements.service.ts │ │ ├── queue-configuration.service.spec.ts │ │ ├── queue-configuration.service.ts │ │ ├── queue.service.spec.ts │ │ └── queue.service.ts │ └── types │ │ ├── map-vote-result.ts │ │ ├── queue-slot.ts │ │ ├── queue-state.ts │ │ └── substitute-request.ts ├── shared │ ├── decorators │ │ ├── deprecated.ts │ │ └── transform-object-id.ts │ ├── errors │ │ └── player-denied.error.ts │ ├── extract-client-ip.spec.ts │ ├── extract-client-ip.ts │ ├── filters │ │ ├── all-exceptions.filter.spec.ts │ │ ├── all-exceptions.filter.ts │ │ ├── data-parsing-error.filter.spec.ts │ │ ├── data-parsing-error.filter.ts │ │ ├── document-not-found.filter.spec.ts │ │ ├── document-not-found.filter.ts │ │ ├── mongo-db-error.filter.spec.ts │ │ ├── mongo-db-error.filter.ts │ │ ├── zod.filter.spec.ts │ │ └── zod.filter.ts │ ├── interceptors │ │ ├── serializer.interceptor.spec.ts │ │ └── serializer.interceptor.ts │ ├── models │ │ ├── link.ts │ │ ├── object-id-or-steam-id.ts │ │ └── tf2-class-name.ts │ ├── pipes │ │ ├── is-one-of.pipe.spec.ts │ │ ├── is-one-of.pipe.ts │ │ ├── object-id-or-steam-id.pipe.spec.ts │ │ ├── object-id-or-steam-id.pipe.ts │ │ ├── object-id-validation.pipe.spec.ts │ │ ├── object-id-validation.pipe.ts │ │ ├── parse-date.pipe.spec.ts │ │ ├── parse-date.pipe.ts │ │ ├── parse-enum-array.pipe.spec.ts │ │ ├── parse-enum-array.pipe.ts │ │ ├── steam-id-validation.pipe.spec.ts │ │ ├── steam-id-validation.pipe.ts │ │ ├── zod.pipe.spec.ts │ │ └── zod.pipe.ts │ ├── serializable.ts │ ├── serialize.spec.ts │ ├── serialize.ts │ ├── user-metadata.ts │ └── websocket-event-emitter.ts ├── statistics │ ├── controllers │ │ ├── statistics.controller.spec.ts │ │ └── statistics.controller.ts │ ├── interfaces │ │ ├── game-launch-time-span.ts │ │ ├── game-launches-per-day.ts │ │ └── played-map-count.ts │ ├── services │ │ ├── statistics.service.spec.ts │ │ └── statistics.service.ts │ └── statistics.module.ts ├── steam │ ├── errors │ │ └── steam-api.error.ts │ ├── services │ │ ├── steam-api.service.spec.ts │ │ └── steam-api.service.ts │ ├── steam-api-response.json │ └── steam.module.ts ├── utils │ ├── assert-is-error.ts │ ├── create-rcon.ts │ ├── generate-gameserver-password.ts │ ├── generate-rcon-password.ts │ ├── mongoose-document.ts │ ├── testing-mongoose-module.ts │ ├── wait-a-bit.ts │ └── workaround-model-provider.ts ├── validate-environment.ts ├── voice-servers │ ├── errors │ │ ├── mumble-channel-does-not-exist.error.ts │ │ └── mumble-client-not-connected.error.ts │ ├── mumble-bot.service.spec.ts │ ├── mumble-bot.service.ts │ ├── mumble-bot.spec.ts │ ├── mumble-bot.ts │ └── voice-servers.module.ts └── websocket-event.ts ├── tsconfig.build.json └── tsconfig.json /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | test_patterns = [ 4 | "**/*.spec.ts", 5 | "**/*.e2e-spec.ts" 6 | ] 7 | 8 | exclude_patterns = [ 9 | "**/node_modules/**", 10 | "migrations/**", 11 | "**/__mocks__/**" 12 | ] 13 | 14 | [[analyzers]] 15 | name = "javascript" 16 | enabled = true 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | __mocks__/ 2 | .git/ 3 | .github/ 4 | .vscode/ 5 | coverage/ 6 | dist/ 7 | e2e/ 8 | node_modules/ 9 | .editorconfig 10 | .env 11 | .eslintrc.json 12 | .gitignore 13 | .keystore 14 | .migrate 15 | .prettierrc 16 | .release-it.json 17 | *.md 18 | docker-compose.yml 19 | *jest.config.ts 20 | LICENSE 21 | renovate.json 22 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: tf2pickuporg 2 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | - '*.*.*' 8 | - 'renovate/**' 9 | tags: 10 | - '*.*.*' 11 | pull_request: 12 | branches: 13 | - 'master' 14 | merge_group: 15 | 16 | jobs: 17 | lint: 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup pnpm 25 | uses: pnpm/action-setup@v2 26 | with: 27 | version: 8 28 | 29 | - name: Setup Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: '18.x' 33 | cache: pnpm 34 | 35 | - name: Install dependencies 36 | run: pnpm install 37 | 38 | - name: Lint 39 | run: pnpm lint 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | # Yarn 37 | .pnp.* 38 | .yarn/* 39 | !.yarn/patches 40 | !.yarn/plugins 41 | !.yarn/releases 42 | !.yarn/sdks 43 | !.yarn/versions 44 | 45 | .env 46 | .keystore 47 | .migrate 48 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "endOfLine":"auto" 5 | } -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commitMessage": "chore: release version ${version}" 4 | }, 5 | "github": { 6 | "release": true 7 | }, 8 | "npm": { 9 | "publish": false 10 | }, 11 | "plugins": { 12 | "@release-it/conventional-changelog": { 13 | "preset": "angular", 14 | "infile": "CHANGELOG.md" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "changelevel", 4 | "Configurator", 5 | "Cooldown", 6 | "demoman", 7 | "gameserver", 8 | "gameservers", 9 | "Logsecret", 10 | "Logsecrets", 11 | "ma", 12 | "mały", 13 | "Microtasks", 14 | "mongod", 15 | "nestjs", 16 | "patreon", 17 | "pyro", 18 | "rcon", 19 | "relaypassword", 20 | "Serveme", 21 | "snakewater", 22 | "subchannels", 23 | "Subdocument", 24 | "tftrue", 25 | "typegoose", 26 | "unmark", 27 | "upsert", 28 | "y" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | I am really glad you are reading this, because we need volunteer developers to help this project come to fruition. 4 | 5 | If you haven't already, add [mały](https://discord.com/users/130809187465691136) on Discord and join the [tf2pickup.org project server](https://discord.gg/SXtcadpQTK). 6 | 7 | ## Submitting changes 8 | 9 | This project uses [conventional commits](https://www.conventionalcommits.org) to describe changes. Please make sure you describe each pull request using this standard. 10 | 11 | Also, when contributing, make sure to [sign your commits](https://docs.github.com/en/authentication/managing-commit-signature-verification) to increase security! 12 | 13 | ## Coding conventions 14 | 15 | Install eslint and prettier VSCode extensions and try to get rid of all of the warnings. 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-slim AS base 2 | ENV PNPM_HOME="/pnpm" 3 | ENV PATH="$PNPM_HOME:$PATH" 4 | RUN corepack enable 5 | COPY . /tf2pickup.pl 6 | WORKDIR /tf2pickup.pl 7 | 8 | FROM base AS prod-deps 9 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile 10 | 11 | FROM base AS build 12 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 13 | RUN pnpm run build 14 | 15 | FROM base 16 | ARG NODE_ENV=production 17 | ENV NODE_ENV=${NODE_ENV} 18 | RUN apt update && apt install -y --no-install-recommends openssl 19 | COPY --from=prod-deps /tf2pickup.pl/node_modules /tf2pickup.pl/node_modules 20 | COPY --from=build /tf2pickup.pl/dist /tf2pickup.pl/dist 21 | COPY --from=build /tf2pickup.pl/configs/queue /tf2pickup.pl/configs/queue 22 | COPY --from=build /tf2pickup.pl/client /tf2pickup.pl/client 23 | COPY --from=build /tf2pickup.pl/migrations /tf2pickup.pl/migrations 24 | 25 | USER node 26 | CMD [ "node", "dist/src/main" ] 27 | EXPOSE 3000 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 tf2pickup.pl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /__mocks__/dns.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const dns = jest.requireActual('dns'); 4 | 5 | function resolve(hostname, callback) { 6 | callback(null, ['1.2.3.4']); 7 | } 8 | 9 | dns.resolve = resolve; 10 | 11 | module.exports = dns; 12 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /client/logged-in-with-twitch-tv.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Logged in with twitch.tv 6 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | after_n_builds: 17 4 | 5 | coverage: 6 | status: 7 | project: 8 | default: 9 | threshold: 0.05 10 | -------------------------------------------------------------------------------- /configs/discord.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Where to find an icon that gets added to message embeds when sending a message to 3 | * a Discord channel. This path is added to environment CLIENT_URL variable. 4 | */ 5 | export const iconUrlPath = '/assets/favicon.png'; 6 | -------------------------------------------------------------------------------- /configs/queue/6v6.json: -------------------------------------------------------------------------------- 1 | { 2 | "teamCount": 2, 3 | "classes": [ 4 | { 5 | "name": "scout", 6 | "count": 2 7 | }, 8 | { 9 | "name": "soldier", 10 | "count": 2 11 | }, 12 | { 13 | "name": "demoman", 14 | "count": 1 15 | }, 16 | { 17 | "name": "medic", 18 | "count": 1, 19 | "canMakeFriendsWith": ["scout", "soldier", "demoman"] 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /configs/queue/9v9.json: -------------------------------------------------------------------------------- 1 | { 2 | "teamCount": 2, 3 | "classes": [ 4 | { 5 | "name": "scout", 6 | "count": 1 7 | }, 8 | { 9 | "name": "soldier", 10 | "count": 1 11 | }, 12 | { 13 | "name": "pyro", 14 | "count": 1 15 | }, 16 | { 17 | "name": "demoman", 18 | "count": 1 19 | }, 20 | { 21 | "name": "heavy", 22 | "count": 1 23 | }, 24 | { 25 | "name": "engineer", 26 | "count": 1 27 | }, 28 | { 29 | "name": "medic", 30 | "count": 1, 31 | "canMakeFriendsWith": ["scout", "soldier", "pyro", "demoman", "heavy", "engineer", "sniper", "spy"] 32 | }, 33 | { 34 | "name": "sniper", 35 | "count": 1 36 | }, 37 | { 38 | "name": "spy", 39 | "count": 1 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /configs/queue/bball.json: -------------------------------------------------------------------------------- 1 | { 2 | "teamCount": 2, 3 | "classes": [ 4 | { 5 | "name": "soldier", 6 | "count": 2 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /configs/queue/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "teamCount": 2, 3 | "classes": [ 4 | { 5 | "name": "soldier", 6 | "count": 1 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /configs/queue/ultiduo.json: -------------------------------------------------------------------------------- 1 | { 2 | "teamCount": 2, 3 | "classes": [ 4 | { 5 | "name": "soldier", 6 | "count": 1 7 | }, 8 | { 9 | "name": "medic", 10 | "count": 1 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /configs/urls.ts: -------------------------------------------------------------------------------- 1 | export const twitchTvApiEndpoint = 'https://api.twitch.tv/helix'; 2 | export const logsTfUploadEndpoint = 'https://logs.tf/upload'; 3 | export const etf2lApiEndpoint = 'http://api-v2.etf2l.org'; 4 | export const steamApiEndpoint = 'https://api.steampowered.com'; 5 | -------------------------------------------------------------------------------- /e2e.jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from 'ts-jest/dist/types'; 2 | import { defaults } from 'ts-jest/presets'; 3 | import { pathsToModuleNameMapper } from 'ts-jest'; 4 | import { compilerOptions } from './tsconfig.json'; 5 | 6 | const config: JestConfigWithTsJest = { 7 | ...defaults, 8 | collectCoverage: true, 9 | coverageDirectory: 'coverage', 10 | collectCoverageFrom: [ 11 | 'src/**/*.(t|j)s', 12 | '!src/**/*.spec.(t|j)s', 13 | '!src/utils/testing-mongoose-module.ts', 14 | ], 15 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 16 | prefix: '/', 17 | }), 18 | preset: 'ts-jest', 19 | testEnvironment: 'node', 20 | testMatch: ['/e2e/**/*.e2e-spec.ts'], 21 | testTimeout: 5 * 60 * 1000, // 5 minutes 22 | slowTestThreshold: 3 * 60 * 1000, // 3 minutes 23 | setupFiles: ['trace-unhandled/register'], 24 | }; 25 | export default config; 26 | -------------------------------------------------------------------------------- /e2e/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/expect-expect */ 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | import * as request from 'supertest'; 5 | import { AppModule } from '@/app.module'; 6 | import { configureApplication } from '@/configure-application'; 7 | 8 | describe('AppController (e2e)', () => { 9 | let app: INestApplication; 10 | 11 | beforeAll(async () => { 12 | const moduleFixture: TestingModule = await Test.createTestingModule({ 13 | imports: [AppModule], 14 | }).compile(); 15 | 16 | app = moduleFixture.createNestApplication(); 17 | configureApplication(app); 18 | await app.init(); 19 | }); 20 | 21 | afterAll(async () => { 22 | await app.close(); 23 | }); 24 | 25 | it('GET /', async () => { 26 | await request(app.getHttpServer()).get('/').expect(200); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/configuration.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/configuration.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/configuration.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.configuration"},{"v":{"$numberInt":"2"},"unique":true,"key":{"key":{"$numberInt":"1"}},"name":"key_1","ns":"pickup-test.configuration","background":true}],"uuid":"449a452221d549d990be168c9e913aaa","collectionName":"configuration","type":"collection"} -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/documents.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/documents.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/documents.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.documents"}],"uuid":"6716cee24b6f4f818b533170ba0e1bb3","collectionName":"documents","type":"collection"} -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/futureplayerskills.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/futureplayerskills.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/futureplayerskills.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.futureplayerskills"},{"v":{"$numberInt":"2"},"unique":true,"key":{"steamId":{"$numberInt":"1"}},"name":"steamId_1","ns":"pickup-test.futureplayerskills","background":true}],"uuid":"b019341518cc4f838948a9342cec920a","collectionName":"futureplayerskills","type":"collection"} -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/games.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/games.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/games.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.games"},{"v":{"$numberInt":"2"},"unique":true,"key":{"logSecret":{"$numberInt":"1"}},"name":"logSecret_1","ns":"pickup-test.games","sparse":true,"background":true},{"v":{"$numberInt":"2"},"key":{"state":{"$numberInt":"1"}},"name":"state_1","ns":"pickup-test.games","background":true},{"v":{"$numberInt":"2"},"key":{"slots.status":{"$numberInt":"1"}},"name":"slots.status_1","ns":"pickup-test.games","background":true},{"v":{"$numberInt":"2"},"key":{"slots.player":{"$numberInt":"1"}},"name":"slots.player_1","ns":"pickup-test.games","background":true},{"v":{"$numberInt":"2"},"unique":true,"key":{"number":{"$numberInt":"1"}},"name":"number_1","ns":"pickup-test.games","background":true}],"uuid":"f9d81afd5765493b929f94b2c6f07c49","collectionName":"games","type":"collection"} -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/gameserverdiagnosticruns.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/gameserverdiagnosticruns.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/gameserverdiagnosticruns.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.gameserverdiagnosticruns"}],"uuid":"dc7adc6f119a45d2a4b9d900ea443a02","collectionName":"gameserverdiagnosticruns","type":"collection"} -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/gameservers.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/gameservers.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/gameservers.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.gameservers"}],"uuid":"59172896368248d6993981a1b5cbc3c7","collectionName":"gameservers","type":"collection"} -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/keys.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/keys.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/keys.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.keys"},{"v":{"$numberInt":"2"},"unique":true,"key":{"name":{"$numberInt":"1"}},"name":"name_1","ns":"pickup-test.keys","background":true}],"uuid":"f779bc731251417ebed460dc45784603","collectionName":"keys","type":"collection"} -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/maps.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/maps.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/maps.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.maps"},{"v":{"$numberInt":"2"},"unique":true,"key":{"name":{"$numberInt":"1"}},"name":"name_1","ns":"pickup-test.maps","background":true}],"uuid":"85361cc84307467e8a68a7ec8df3317c","collectionName":"maps","type":"collection"} -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/migrations.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/migrations.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/migrations.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.migrations"}],"uuid":"d9c60427e3b143daa13ec31c708c7099","collectionName":"migrations","type":"collection"} -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/playerpreferences.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/playerpreferences.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/playerpreferences.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.playerpreferences"},{"v":{"$numberInt":"2"},"unique":true,"key":{"player":{"$numberInt":"1"}},"name":"player_1","ns":"pickup-test.playerpreferences","background":true}],"uuid":"88cf42a893d04b0fa20dad5a02b3597e","collectionName":"playerpreferences","type":"collection"} -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/players.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/players.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/players.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.players"},{"v":{"$numberInt":"2"},"key":{"etf2lProfileId":{"$numberInt":"1"}},"name":"etf2lProfileId_1","ns":"pickup-test.players","background":true},{"v":{"$numberInt":"2"},"unique":true,"key":{"steamId":{"$numberInt":"1"}},"name":"steamId_1","ns":"pickup-test.players","background":true},{"v":{"$numberInt":"2"},"unique":true,"key":{"name":{"$numberInt":"1"}},"name":"name_1","ns":"pickup-test.players","background":true}],"uuid":"913c0cac8ba045f58ab7c0eb13fe36b1","collectionName":"players","type":"collection"} -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/playerskills.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/playerskills.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/playerskills.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.playerskills"},{"v":{"$numberInt":"2"},"unique":true,"key":{"player":{"$numberInt":"1"}},"name":"player_1","ns":"pickup-test.playerskills","background":true}],"uuid":"15238e1bd6a4418d8664d9e38150f83d","collectionName":"playerskills","type":"collection"} -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/refreshtokens.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/refreshtokens.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/refreshtokens.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.refreshtokens"},{"v":{"$numberInt":"2"},"key":{"value":{"$numberInt":"1"}},"name":"value_1","ns":"pickup-test.refreshtokens","background":true},{"v":{"$numberInt":"2"},"key":{"createdAt":{"$numberInt":"1"}},"name":"createdAt_1","ns":"pickup-test.refreshtokens","expireAfterSeconds":{"$numberInt":"604800"},"background":true}],"uuid":"e454753804104506b8fc21a6145e26ca","collectionName":"refreshtokens","type":"collection"} -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/twitchtvprofiles.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/e2e/dump/tf2pickuppl_e2e_tests/twitchtvprofiles.bson -------------------------------------------------------------------------------- /e2e/dump/tf2pickuppl_e2e_tests/twitchtvprofiles.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_","ns":"pickup-test.twitchtvprofiles"},{"v":{"$numberInt":"2"},"key":{"userId":{"$numberInt":"1"}},"name":"userId_1","ns":"pickup-test.twitchtvprofiles","background":true},{"v":{"$numberInt":"2"},"key":{"player":{"$numberInt":"1"}},"name":"player_1","ns":"pickup-test.twitchtvprofiles","background":true}],"uuid":"4f2342499bf740b6b5bc8bbc433e9a56","collectionName":"twitchtvprofiles","type":"collection"} -------------------------------------------------------------------------------- /e2e/test-data.ts: -------------------------------------------------------------------------------- 1 | export const players: string[] = [ 2 | '76561199195756652', 3 | '76561199195935001', 4 | '76561199195486701', 5 | '76561199195468328', 6 | '76561199195972852', 7 | '76561199195926019', 8 | '76561199195611071', 9 | '76561199195733445', 10 | '76561199195601536', 11 | '76561199196157187', 12 | '76561199195855422', 13 | '76561199195188363', 14 | '76561199203544766', 15 | ]; 16 | -------------------------------------------------------------------------------- /e2e/utils/wait-a-bit.ts: -------------------------------------------------------------------------------- 1 | export const waitABit = (timeout: number) => 2 | new Promise((resolve) => setTimeout(resolve, timeout)); 3 | -------------------------------------------------------------------------------- /e2e/utils/wait-for-the-game-to-launch.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | // skipcq: JS-C1003 3 | import * as request from 'supertest'; 4 | 5 | export const waitForTheGameToLaunch = (app: INestApplication, gameId: string) => 6 | new Promise((resolve) => { 7 | const i = setInterval(async () => { 8 | await request(app.getHttpServer()) 9 | .get(`/games/${gameId}`) 10 | .then((response) => { 11 | const body = response.body; 12 | if (body.state === 'launching') { 13 | clearInterval(i); 14 | resolve(); 15 | } 16 | }); 17 | }, 1000); 18 | }); 19 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from 'ts-jest/dist/types'; 2 | import { defaults } from 'ts-jest/presets'; 3 | import { pathsToModuleNameMapper } from 'ts-jest'; 4 | import { compilerOptions } from './tsconfig.json'; 5 | 6 | const config: JestConfigWithTsJest = { 7 | ...defaults, 8 | collectCoverage: true, 9 | coverageDirectory: 'coverage', 10 | collectCoverageFrom: [ 11 | 'src/**/*.(t|j)s', 12 | '!src/**/*.spec.(t|j)s', 13 | '!src/utils/testing-mongoose-module.ts', 14 | ], 15 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 16 | prefix: '/', 17 | }), 18 | preset: 'ts-jest', 19 | testEnvironment: 'node', 20 | setupFiles: ['trace-unhandled/register'], 21 | fakeTimers: { 22 | legacyFakeTimers: false, 23 | doNotFake: ['nextTick', 'setImmediate'], 24 | }, 25 | }; 26 | 27 | export default config; 28 | -------------------------------------------------------------------------------- /migrations/1616687902073-mark-all-game-servers-as-not-deleted.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 'use strict'; 3 | 4 | // 5 | // Set deleted to false on all game servers that do not have the 'deleted' property. 6 | // 7 | 8 | const { config } = require('dotenv'); 9 | const { MongoClient } = require('mongodb'); 10 | 11 | module.exports.up = (next) => { 12 | config(); 13 | 14 | MongoClient.connect(process.env.MONGODB_URI, { useUnifiedTopology: true }) 15 | .then((client) => client.db()) 16 | .then((db) => db.collection('gameservers')) 17 | .then((collection) => 18 | collection.updateMany( 19 | { 20 | deleted: { $exists: false }, 21 | }, 22 | { 23 | $set: { deleted: false }, 24 | }, 25 | ), 26 | ) 27 | .then(() => next()); 28 | }; 29 | -------------------------------------------------------------------------------- /migrations/1632143538928-all-game-servers-offline.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 'use strict'; 3 | 4 | const { config } = require('dotenv'); 5 | const { MongoClient } = require('mongodb'); 6 | 7 | module.exports.up = (next) => { 8 | config(); 9 | 10 | MongoClient.connect(process.env.MONGODB_URI, { useUnifiedTopology: true }) 11 | .then((client) => client.db()) 12 | .then((db) => db.collection('gameservers')) 13 | .then((collection) => 14 | Promise.all([ 15 | collection, 16 | collection.updateMany({}, { $set: { isOnline: false } }), 17 | ]), 18 | ) 19 | .then(([collection]) => 20 | collection.updateMany({}, { $unset: { deleted: 1 } }), 21 | ) 22 | .then(() => next()); 23 | }; 24 | -------------------------------------------------------------------------------- /migrations/1635720162156-add-game-servers-priority.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 'use strict'; 3 | 4 | const { config } = require('dotenv'); 5 | const { MongoClient } = require('mongodb'); 6 | 7 | module.exports.up = (next) => { 8 | config(); 9 | 10 | MongoClient.connect(process.env.MONGODB_URI, { useUnifiedTopology: true }) 11 | .then((client) => client.db()) 12 | .then((db) => db.collection('gameservers')) 13 | .then((collection) => 14 | Promise.all([ 15 | collection, 16 | collection.updateMany({}, { $set: { priority: 1 } }), 17 | ]), 18 | ) 19 | .then(() => next()); 20 | }; 21 | -------------------------------------------------------------------------------- /migrations/1635887923233-add-endedAt-to-game.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 'use strict'; 3 | 4 | const { config } = require('dotenv'); 5 | const { MongoClient } = require('mongodb'); 6 | 7 | module.exports.up = (next) => { 8 | config(); 9 | 10 | MongoClient.connect(process.env.MONGODB_URI, { useUnifiedTopology: true }) 11 | .then((client) => client.db()) 12 | .then((db) => db.collection('games')) 13 | .then((collection) => 14 | Promise.all([ 15 | collection, 16 | collection.find({ endedAt: { $exists: false } }).toArray(), 17 | ]), 18 | ) 19 | .then(([collection, servers]) => 20 | Promise.all( 21 | servers.map((server) => { 22 | const endedAt = new Date( 23 | server.launchedAt.getTime() + 1000 * 60 * 45, 24 | ); 25 | return collection.updateOne( 26 | { _id: server._id }, 27 | { $set: { endedAt: endedAt } }, 28 | ); 29 | }), 30 | ), 31 | ) 32 | .then(() => next()); 33 | }; 34 | -------------------------------------------------------------------------------- /migrations/1636162329824-add-player-roles.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 'use strict'; 3 | 4 | // 5 | // Follow-up to the extended-player-roles migration. 6 | // 7 | 8 | const { config } = require('dotenv'); 9 | const { MongoClient } = require('mongodb'); 10 | 11 | module.exports.up = (next) => { 12 | config(); 13 | 14 | MongoClient.connect(process.env.MONGODB_URI, { useUnifiedTopology: true }) 15 | .then((client) => client.db()) 16 | .then((db) => db.collection('players')) 17 | .then((collection) => 18 | Promise.all([ 19 | collection, 20 | collection.updateMany( 21 | { 22 | roles: { $exists: false }, 23 | }, 24 | { 25 | $set: { roles: [] }, 26 | $unset: { role: 1 }, 27 | }, 28 | ), 29 | ]), 30 | ) 31 | .then(() => next()); 32 | }; 33 | -------------------------------------------------------------------------------- /migrations/1646354644758-game-server-v83.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 'use strict'; 3 | 4 | module.exports.up = (next) => { 5 | // empty on purpose 6 | next(); 7 | }; 8 | -------------------------------------------------------------------------------- /migrations/1652720907471-game-server-v9.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 'use strict'; 3 | 4 | const { config } = require('dotenv'); 5 | const { MongoClient } = require('mongodb'); 6 | 7 | module.exports.up = (next) => { 8 | config(); 9 | 10 | MongoClient.connect(process.env.MONGODB_URI, { useUnifiedTopology: true }) 11 | .then((client) => client.db()) 12 | .then((db) => db.collection('gameservers')) 13 | .then((collection) => 14 | Promise.all([ 15 | collection, 16 | collection.updateMany( 17 | { provider: { $exists: false } }, 18 | { $set: { provider: 'static', isClean: true } }, 19 | ), 20 | ]), 21 | ) 22 | .then(([collection]) => 23 | Promise.all([ 24 | collection, 25 | collection.updateMany( 26 | {}, 27 | { 28 | $unset: { 29 | voiceChannelName: 1, 30 | resolvedIpAddresses: 1, 31 | isAvailable: 1, 32 | }, 33 | }, 34 | ), 35 | ]), 36 | ) 37 | .then(() => next()); 38 | }; 39 | -------------------------------------------------------------------------------- /migrations/1666740945986-merge-player-skill-collection.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 'use strict'; 3 | 4 | const { config } = require('dotenv'); 5 | const { MongoClient } = require('mongodb'); 6 | 7 | module.exports.up = function (next) { 8 | config(); 9 | 10 | MongoClient.connect(process.env.MONGODB_URI, { useUnifiedTopology: true }) 11 | .then((client) => client.db()) 12 | .then((db) => 13 | Promise.all([ 14 | db, 15 | db 16 | .collection('playerskills') 17 | .find({}) 18 | .toArray() 19 | .then((skills) => 20 | Promise.all( 21 | skills.map((skill) => { 22 | return db 23 | .collection('players') 24 | .updateOne( 25 | { _id: skill.player }, 26 | { $set: { skill: skill.skill } }, 27 | ); 28 | }), 29 | ), 30 | ), 31 | ]), 32 | ) 33 | .then(([db]) => db.collection('playerskills').drop()) 34 | .then(() => next()) 35 | .catch((error) => { 36 | if (error.message === 'ns not found') { 37 | next(); 38 | } else { 39 | throw error; 40 | } 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /migrations/1674218801762-player-joined-at-to-string.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 'use strict'; 3 | 4 | const { config } = require('dotenv'); 5 | const { MongoClient } = require('mongodb'); 6 | 7 | module.exports.up = function (next) { 8 | config(); 9 | 10 | MongoClient.connect(process.env.MONGODB_URI, { useUnifiedTopology: true }) 11 | .then((client) => client.db()) 12 | .then((db) => db.collection('players')) 13 | .then((collection) => 14 | Promise.all([ 15 | collection, 16 | collection.find({ joinedAt: { $exists: true, $type: 2 } }).toArray(), 17 | ]), 18 | ) 19 | .then(([collection, players]) => 20 | players.map((player) => 21 | collection.updateOne( 22 | { _id: player._id }, 23 | { $set: { joinedAt: new Date(player.joinedAt) } }, 24 | ), 25 | ), 26 | ) 27 | .then(() => next()); 28 | }; 29 | -------------------------------------------------------------------------------- /migrations/1676334656027-initialize-cooldown-on-all-players.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 'use strict'; 3 | 4 | const { config } = require('dotenv'); 5 | const { MongoClient } = require('mongodb'); 6 | 7 | module.exports.up = function (next) { 8 | config(); 9 | 10 | MongoClient.connect(process.env.MONGODB_URI, { useUnifiedTopology: true }) 11 | .then((client) => client.db()) 12 | .then((db) => db.collection('players')) 13 | .then((collection) => 14 | collection.updateMany({}, { $set: { cooldownLevel: 0 } }), 15 | ) 16 | .then(() => next()); 17 | }; 18 | -------------------------------------------------------------------------------- /migrations/1705011828390-remove-serveme-tf-reservations-collection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { config } = require('dotenv'); 4 | const { MongoClient, ObjectId } = require('mongodb'); 5 | 6 | module.exports.up = async function (next) { 7 | config(); 8 | 9 | const mongo = await MongoClient.connect(process.env.MONGODB_URI); 10 | const gamesWithServemeTfReservation = await mongo 11 | .db() 12 | .collection('games') 13 | .find({ 'gameServer.provider': 'serveme.tf' }) 14 | .toArray(); 15 | 16 | for (const game of gamesWithServemeTfReservation) { 17 | const reservation = await mongo 18 | .db() 19 | .collection('servemetfreservations') 20 | .findOne({ _id: new ObjectId(game.gameServer.id) }); 21 | 22 | if (reservation) { 23 | await mongo 24 | .db() 25 | .collection('games') 26 | .updateOne( 27 | { _id: game._id }, 28 | { $set: { 'gameServer.id': reservation.reservationId } }, 29 | ); 30 | } 31 | } 32 | 33 | try { 34 | await mongo.db().collection('servemetfreservations').drop(); 35 | } catch (error) { 36 | // empty 37 | } 38 | 39 | next(); 40 | }; 41 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "assets": [ 6 | { 7 | "include": "documents/default/*.md", 8 | "outDir": "dist/src" 9 | }, 10 | { 11 | "include": "game-configs/configs/*.cfg", 12 | "outDir": "dist/src" 13 | }, 14 | { 15 | "include": "../client/*", 16 | "outDir": "dist/client" 17 | } 18 | ], 19 | "watchAssets": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "packageRules": [ 6 | { 7 | "depTypeList": ["devDependencies"], 8 | "automerge": true 9 | }, 10 | { 11 | "updateTypes": ["minor", "patch", "pin", "digest"], 12 | "automerge": true 13 | } 14 | ], 15 | "automergeType": "branch", 16 | "labels": ["renovate"], 17 | "ignoreDeps": ["mongo"] 18 | } 19 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { Environment } from './environment/environment'; 4 | 5 | class EnvironmentStub { 6 | clientUrl = 'FAKE_CLIENT_URL'; 7 | apiUrl = 'FAKE_API_URL'; 8 | } 9 | 10 | describe('AppController', () => { 11 | let appController: AppController; 12 | 13 | beforeEach(async () => { 14 | const app: TestingModule = await Test.createTestingModule({ 15 | controllers: [AppController], 16 | providers: [{ provide: Environment, useClass: EnvironmentStub }], 17 | }).compile(); 18 | 19 | appController = app.get(AppController); 20 | }); 21 | 22 | describe('#index()', () => { 23 | it('should return api index', () => { 24 | expect(appController.index()).toEqual({ 25 | version: expect.any(String), 26 | clientUrl: 'FAKE_CLIENT_URL', 27 | loginUrl: 'FAKE_API_URL/auth/steam', 28 | }); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { version } from '../package.json'; 3 | import { Environment } from './environment/environment'; 4 | 5 | @Controller() 6 | export class AppController { 7 | constructor(private environment: Environment) {} 8 | 9 | @Get() 10 | index() { 11 | return { 12 | version, 13 | clientUrl: this.environment.clientUrl, 14 | loginUrl: `${this.environment.apiUrl}/auth/steam`, 15 | }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { INestApplicationContext } from '@nestjs/common'; 2 | 3 | export let app: INestApplicationContext; 4 | 5 | export const setApp = (newApp: INestApplicationContext): void => { 6 | app = newApp; 7 | }; 8 | -------------------------------------------------------------------------------- /src/auth/decorators/auth.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, UseGuards, SetMetadata } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { PlayerRole } from '@/players/models/player-role'; 4 | import { RoleGuard } from '../guards/role.guard'; 5 | 6 | export function Auth(...roles: PlayerRole[]) { 7 | return applyDecorators( 8 | UseGuards(AuthGuard('jwt')), 9 | SetMetadata('roles', roles), 10 | UseGuards(RoleGuard), 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/auth/decorators/secret.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, UseGuards } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { SecretPurpose } from '../types/secret-purpose'; 4 | 5 | export function Secret(purpose: SecretPurpose) { 6 | return applyDecorators(UseGuards(AuthGuard(purpose))); 7 | } 8 | -------------------------------------------------------------------------------- /src/auth/decorators/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Player } from '@/players/models/player'; 2 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 3 | import { Request } from 'express'; 4 | 5 | export const User = createParamDecorator( 6 | (data: unknown, ctx: ExecutionContext) => 7 | ctx.switchToHttp().getRequest().user as Player, 8 | ); 9 | -------------------------------------------------------------------------------- /src/auth/decorators/ws-authorized.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, UseGuards } from '@nestjs/common'; 2 | import { WsAuthorizedGuard } from '../guards/ws-authorized.guard'; 3 | 4 | export function WsAuthorized() { 5 | return applyDecorators(UseGuards(WsAuthorizedGuard)); 6 | } 7 | -------------------------------------------------------------------------------- /src/auth/errors/invalid-token.error.ts: -------------------------------------------------------------------------------- 1 | export class InvalidTokenError extends Error { 2 | constructor() { 3 | super('invalid token'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/auth/gateways/auth.gateway.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthGateway } from './auth.gateway'; 3 | import { PlayersService } from '@/players/services/players.service'; 4 | import { WEBSOCKET_SECRET } from '../tokens/websocket-secret.token'; 5 | 6 | class PlayersServiceStub {} 7 | 8 | describe('AuthGateway', () => { 9 | let gateway: AuthGateway; 10 | 11 | beforeEach(async () => { 12 | const module: TestingModule = await Test.createTestingModule({ 13 | providers: [ 14 | AuthGateway, 15 | { provide: PlayersService, useClass: PlayersServiceStub }, 16 | { provide: WEBSOCKET_SECRET, useValue: 'secret' }, 17 | ], 18 | }).compile(); 19 | 20 | gateway = module.get(AuthGateway); 21 | }); 22 | 23 | it('should be defined', () => { 24 | expect(gateway).toBeDefined(); 25 | }); 26 | 27 | describe('#onModuleInit()', () => { 28 | beforeEach( 29 | () => (gateway.server = { use: jest.fn().mockReturnValue(null) } as any), 30 | ); 31 | 32 | it('should register middleware', () => { 33 | gateway.onModuleInit(); 34 | expect(gateway.server?.use).toHaveBeenCalled(); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/auth/guards/role.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | UnauthorizedException, 6 | } from '@nestjs/common'; 7 | import { Reflector } from '@nestjs/core'; 8 | import { PlayerRole } from '@/players/models/player-role'; 9 | import { Player } from '@/players/models/player'; 10 | import { Request } from 'express'; 11 | 12 | @Injectable() 13 | export class RoleGuard implements CanActivate { 14 | constructor(private reflector: Reflector) {} 15 | 16 | canActivate(context: ExecutionContext): boolean { 17 | const roles = this.reflector.get( 18 | 'roles', 19 | context.getHandler(), 20 | ); 21 | if (roles?.length) { 22 | const request = context.switchToHttp().getRequest(); 23 | const user = request.user as Player; 24 | 25 | if (!user || !roles.some((r) => user.roles.includes(r))) { 26 | throw new UnauthorizedException(); 27 | } 28 | } 29 | 30 | return true; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/auth/guards/ws-authorized.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { WsAuthorizedGuard } from './ws-authorized.guard'; 2 | 3 | let client = {}; 4 | 5 | const context = { 6 | switchToWs: () => ({ 7 | getClient: jest.fn().mockReturnValue(client), 8 | }), 9 | }; 10 | 11 | describe('WsAuthorizedGuard', () => { 12 | it('should be defined', () => { 13 | expect(new WsAuthorizedGuard()).toBeDefined(); 14 | }); 15 | 16 | describe('when the user is not authenticated', () => { 17 | beforeEach(() => { 18 | client = {}; 19 | }); 20 | 21 | it('should deny', () => { 22 | const guard = new WsAuthorizedGuard(); 23 | expect(() => guard.canActivate(context as any)).toThrow('unauthorized'); 24 | }); 25 | }); 26 | 27 | describe('when the user is authenticated', () => { 28 | beforeEach(() => { 29 | client = { user: {} }; 30 | }); 31 | 32 | it('should pass', () => { 33 | const guard = new WsAuthorizedGuard(); 34 | expect(guard.canActivate(context as any)).toBe(true); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/auth/guards/ws-authorized.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { WsException } from '@nestjs/websockets'; 3 | import { Socket } from 'socket.io'; 4 | 5 | @Injectable() 6 | export class WsAuthorizedGuard implements CanActivate { 7 | canActivate(context: ExecutionContext): boolean { 8 | const client = context.switchToWs().getClient(); 9 | if (client.user) { 10 | return true; 11 | } else { 12 | throw new WsException('unauthorized'); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/auth/middleware/set-redirect-url-cookie.spec.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { setRedirectUrlCookie } from './set-redirect-url-cookie'; 3 | 4 | describe('setRedirectUrlCookie', () => { 5 | let nextFn: NextFunction; 6 | let request: Partial; 7 | let response: Partial; 8 | 9 | beforeEach(() => { 10 | nextFn = jest.fn(); 11 | response = { 12 | cookie: jest.fn(), 13 | }; 14 | request = { 15 | get: jest.fn().mockImplementation( 16 | (key: string) => 17 | ({ 18 | referer: 'FAKE_URL', 19 | })[key], 20 | ), 21 | }; 22 | }); 23 | 24 | it('should set the cookie', () => { 25 | setRedirectUrlCookie(request as Request, response as Response, nextFn); 26 | expect(response.cookie).toHaveBeenCalledWith('redirect-url', 'FAKE_URL'); 27 | }); 28 | 29 | it('should call the next function', () => { 30 | setRedirectUrlCookie(request as Request, response as Response, nextFn); 31 | expect(nextFn).toHaveBeenCalled(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/auth/middleware/set-redirect-url-cookie.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | 3 | export const redirectUrlCookieName = 'redirect-url'; 4 | 5 | export const setRedirectUrlCookie = ( 6 | req: Request, 7 | res: Response, 8 | next: NextFunction, 9 | ) => { 10 | const referer = req.get('referer'); 11 | 12 | // Set the referer url as a cookie so we can redirect to the exact url afterwards 13 | if (referer) { 14 | res.cookie(redirectUrlCookieName, referer); 15 | } 16 | 17 | next(); 18 | }; 19 | -------------------------------------------------------------------------------- /src/auth/models/key.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | 3 | @Schema() 4 | export class Key { 5 | @Prop({ required: true, unique: true }) 6 | name!: string; 7 | 8 | @Prop({ required: true }) 9 | privateKeyEncoded!: string; 10 | 11 | @Prop({ required: true }) 12 | publicKeyEncoded!: string; 13 | } 14 | 15 | export const keySchema = SchemaFactory.createForClass(Key); 16 | -------------------------------------------------------------------------------- /src/auth/strategies/game-server-secret.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from '@/environment/environment'; 2 | import { assertIsError } from '@/utils/assert-is-error'; 3 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 4 | import { PassportStrategy } from '@nestjs/passport'; 5 | import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; 6 | import { SecretPurpose } from '../types/secret-purpose'; 7 | 8 | @Injectable() 9 | export class GameServerSecretStrategy extends PassportStrategy( 10 | HeaderAPIKeyStrategy, 11 | SecretPurpose.gameServer, 12 | ) { 13 | constructor(private environment: Environment) { 14 | super( 15 | { 16 | header: 'Authorization', 17 | prefix: 'secret ', 18 | }, 19 | true, 20 | (secret: string, done: (err: Error | null, result?: boolean) => void) => { 21 | try { 22 | const result = this.validate(secret); 23 | done(null, result); 24 | } catch (error) { 25 | assertIsError(error); 26 | done(error); 27 | } 28 | }, 29 | ); 30 | } 31 | 32 | validate(secret: string) { 33 | if (secret !== this.environment.gameServerSecret) { 34 | throw new UnauthorizedException(); 35 | } 36 | 37 | return true; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/auth/tokens/auth-token-key.token.ts: -------------------------------------------------------------------------------- 1 | export const AUTH_TOKEN_KEY = 'AUTH_TOKEN_KEY'; 2 | -------------------------------------------------------------------------------- /src/auth/tokens/context-token-key.token.ts: -------------------------------------------------------------------------------- 1 | export const CONTEXT_TOKEN_KEY = 'CONTEXT_TOKEN_KEY'; 2 | -------------------------------------------------------------------------------- /src/auth/tokens/websocket-secret.token.ts: -------------------------------------------------------------------------------- 1 | export const WEBSOCKET_SECRET = 'WEBSOCKET_SECRET'; 2 | -------------------------------------------------------------------------------- /src/auth/types/jwt-token-purpose.ts: -------------------------------------------------------------------------------- 1 | export enum JwtTokenPurpose { 2 | auth /**< used by the client to make standard API calls (expiration time: 7 days) */, 3 | websocket /**< used by the client to identify himself over the websocket (expiration time: 10 minutes) */, 4 | context /**< used by the server to keep the context across external calls (e.g. twitch.tv OAuth) */, 5 | } 6 | -------------------------------------------------------------------------------- /src/auth/types/key-name.ts: -------------------------------------------------------------------------------- 1 | export enum KeyName { 2 | auth = 'auth', 3 | websocket = 'ws', 4 | context = 'context', 5 | } 6 | -------------------------------------------------------------------------------- /src/auth/types/key-pair.ts: -------------------------------------------------------------------------------- 1 | import { KeyObject } from 'crypto'; 2 | 3 | export interface KeyPair { 4 | privateKey: KeyObject; 5 | publicKey: KeyObject; 6 | } 7 | -------------------------------------------------------------------------------- /src/auth/types/secret-purpose.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Describes the purpose of a secret that might be used to authorize an API call. 3 | */ 4 | export enum SecretPurpose { 5 | gameServer = 'game-server' /**< used by the connector plugin */, 6 | } 7 | -------------------------------------------------------------------------------- /src/certificates/certificates.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { Certificate, certificateSchema } from './models/certificate'; 4 | import { CertificatesService } from './services/certificates.service'; 5 | 6 | @Module({ 7 | imports: [ 8 | MongooseModule.forFeature([ 9 | { name: Certificate.name, schema: certificateSchema }, 10 | ]), 11 | ], 12 | providers: [CertificatesService], 13 | exports: [CertificatesService], 14 | }) 15 | export class CertificatesModule {} 16 | -------------------------------------------------------------------------------- /src/certificates/models/certificate.ts: -------------------------------------------------------------------------------- 1 | import { MongooseDocument } from '@/utils/mongoose-document'; 2 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 3 | import { Transform } from 'class-transformer'; 4 | 5 | @Schema() 6 | export class Certificate extends MongooseDocument { 7 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access 8 | @Transform(({ value, obj }) => value ?? obj._id.toString()) 9 | id!: string; 10 | 11 | @Prop({ required: true }) 12 | purpose!: string; 13 | 14 | @Prop() 15 | clientKey?: string; 16 | 17 | @Prop() 18 | certificate?: string; 19 | } 20 | 21 | export const certificateSchema = SchemaFactory.createForClass(Certificate); 22 | -------------------------------------------------------------------------------- /src/configuration/configuration-entry.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodTypeAny } from 'zod'; 2 | 3 | export interface ConfigurationEntry { 4 | key: string; 5 | schema: SchemaType; 6 | default: z.infer; 7 | description?: string; 8 | } 9 | 10 | export const configurationEntry = ( 11 | key: string, 12 | schema: SchemaType, 13 | defaultValue: z.infer, 14 | description?: string, 15 | ): ConfigurationEntry => ({ 16 | key, 17 | schema, 18 | default: defaultValue, 19 | description, 20 | }); 21 | -------------------------------------------------------------------------------- /src/configuration/configuration.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigurationService } from './services/configuration.service'; 3 | import { ConfigurationController } from './controllers/configuration.controller'; 4 | import { 5 | ConfigurationItem, 6 | configurationItemSchema, 7 | } from './models/configuration-item'; 8 | import { MongooseModule } from '@nestjs/mongoose'; 9 | 10 | const configurationModelProvider = MongooseModule.forFeature([ 11 | { 12 | name: ConfigurationItem.name, 13 | schema: configurationItemSchema, 14 | }, 15 | ]); 16 | 17 | @Module({ 18 | imports: [configurationModelProvider], 19 | providers: [ConfigurationService], 20 | controllers: [ConfigurationController], 21 | exports: [ConfigurationService, configurationModelProvider], 22 | }) 23 | export class ConfigurationModule {} 24 | -------------------------------------------------------------------------------- /src/configuration/dto/set-configuration-entry.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class SetConfigurationEntry { 4 | @IsString() 5 | key!: string; 6 | 7 | @IsNotEmpty() 8 | value: unknown; 9 | } 10 | -------------------------------------------------------------------------------- /src/configuration/errors/configuration-entry-not-found.error.ts: -------------------------------------------------------------------------------- 1 | export class ConfigurationEntryNotFoundError extends Error { 2 | constructor(public readonly key: string) { 3 | super(`configuration entry ${key} not found`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/configuration/filters/configuration-entry-error.filter.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurationEntryErrorFilter } from './configuration-entry-error.filter'; 2 | 3 | describe('ConfigurationEntryErrorFilter', () => { 4 | it('should be defined', () => { 5 | expect(new ConfigurationEntryErrorFilter()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/configuration/filters/configuration-entry-error.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | import { ConfigurationEntryNotFoundError } from '../errors/configuration-entry-not-found.error'; 4 | 5 | @Catch(ConfigurationEntryNotFoundError) 6 | export class ConfigurationEntryErrorFilter implements ExceptionFilter { 7 | // skipcq: JS-0105 8 | catch(exception: ConfigurationEntryNotFoundError, host: ArgumentsHost) { 9 | const ctx = host.switchToHttp(); 10 | const response = ctx.getResponse(); 11 | 12 | response.status(404).json({ 13 | statusCode: 404, 14 | error: exception.message, 15 | key: exception.key, 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/configuration/models/configuration-item.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import mongoose from 'mongoose'; 3 | 4 | @Schema({ 5 | collection: 'configuration', 6 | id: false, 7 | _id: false, 8 | }) 9 | export class ConfigurationItem { 10 | @Prop({ unique: true }) 11 | key!: string; 12 | 13 | @Prop({ type: mongoose.Schema.Types.Mixed }) 14 | value: unknown; 15 | } 16 | 17 | export const configurationItemSchema = 18 | SchemaFactory.createForClass(ConfigurationItem); 19 | -------------------------------------------------------------------------------- /src/documents/documents.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { DocumentsController } from './controllers/documents.controller'; 4 | import { Document, documentSchema } from './models/document'; 5 | import { DocumentsService } from './services/documents.service'; 6 | 7 | @Module({ 8 | imports: [ 9 | MongooseModule.forFeature([ 10 | { name: Document.name, schema: documentSchema }, 11 | ]), 12 | ], 13 | controllers: [DocumentsController], 14 | providers: [DocumentsService], 15 | }) 16 | export class DocumentsModule {} 17 | -------------------------------------------------------------------------------- /src/documents/models/document.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Exclude, Expose } from 'class-transformer'; 3 | import { IsLocale, IsOptional, IsString } from 'class-validator'; 4 | 5 | @Exclude() 6 | @Schema() 7 | export class Document { 8 | @IsString() 9 | @Expose() 10 | @Prop({ required: true }) 11 | name!: string; 12 | 13 | @IsLocale() 14 | @Expose() 15 | @Prop({ required: true }) 16 | language!: string; 17 | 18 | @IsOptional() 19 | @IsString() 20 | @Expose() 21 | @Prop() 22 | body?: string; 23 | } 24 | 25 | export const documentSchema = SchemaFactory.createForClass(Document); 26 | -------------------------------------------------------------------------------- /src/environment/environment.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Global } from '@nestjs/common'; 2 | import { Environment } from './environment'; 3 | 4 | @Global() 5 | @Module({ 6 | providers: [Environment], 7 | exports: [Environment], 8 | }) 9 | export class EnvironmentModule {} 10 | -------------------------------------------------------------------------------- /src/etf2l/errors/etf2l-api.error.ts: -------------------------------------------------------------------------------- 1 | export class Etf2lApiError extends Error { 2 | constructor( 3 | public readonly url: string, 4 | public readonly message: string, 5 | ) { 6 | super(`ETF2L API error (${url}): ${message}`); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/etf2l/errors/no-etf2l-account.error.ts: -------------------------------------------------------------------------------- 1 | export class NoEtf2lAccountError extends Error { 2 | constructor(steamId: string) { 3 | super(`no ETF2L account (steamId=${steamId})`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/etf2l/etf2l.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpModule } from '@nestjs/axios'; 2 | import { Module } from '@nestjs/common'; 3 | import { Etf2lApiService } from './services/etf2l-api.service'; 4 | 5 | @Module({ 6 | imports: [HttpModule], 7 | providers: [Etf2lApiService], 8 | exports: [Etf2lApiService], 9 | }) 10 | export class Etf2lModule {} 11 | -------------------------------------------------------------------------------- /src/events/events.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { Events } from './events'; 3 | 4 | @Global() 5 | @Module({ 6 | providers: [Events], 7 | exports: [Events], 8 | }) 9 | export class EventsModule {} 10 | -------------------------------------------------------------------------------- /src/game-configs/configs/default.cfg: -------------------------------------------------------------------------------- 1 | sv_pausable 0 2 | sm plugins unload soap_tournament 3 | sm plugins unload soap_tf2dm 4 | mp_tournament_readymode 1 5 | mp_tournament_readymode_countdown 5 6 | mp_tournament_readymode_min 2 7 | mp_tournament_readymode_team_size {{teamSize}} 8 | -------------------------------------------------------------------------------- /src/game-configs/game-configs.module.ts: -------------------------------------------------------------------------------- 1 | import { QueueConfigModule } from '@/queue-config/queue-config.module'; 2 | import { QueueModule } from '@/queue/queue.module'; 3 | import { Module } from '@nestjs/common'; 4 | import { GameConfigsService } from './services/game-configs.service'; 5 | 6 | @Module({ 7 | imports: [QueueModule, QueueConfigModule], 8 | providers: [GameConfigsService], 9 | exports: [GameConfigsService], 10 | }) 11 | export class GameConfigsModule {} 12 | -------------------------------------------------------------------------------- /src/game-configs/services/game-configs.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; 2 | import { join } from 'path'; 3 | import { compile } from 'handlebars'; 4 | import { readFile } from 'fs/promises'; 5 | import { isEmpty } from 'lodash'; 6 | import { QueueConfig } from '@/queue-config/types/queue-config'; 7 | import { QUEUE_CONFIG } from '@/queue-config/tokens/queue-config.token'; 8 | 9 | @Injectable() 10 | export class GameConfigsService implements OnModuleInit { 11 | private template!: ReturnType; 12 | variables: Record; 13 | 14 | constructor(@Inject(QUEUE_CONFIG) private readonly queueConfig: QueueConfig) { 15 | this.variables = { 16 | teamSize: this.queueConfig.classes.reduce( 17 | (prev, curr) => prev + curr.count, 18 | 0, 19 | ), 20 | }; 21 | } 22 | 23 | async onModuleInit() { 24 | const source = await readFile( 25 | join(__dirname, '..', 'configs', 'default.cfg'), 26 | ); 27 | this.template = compile(source.toString()); 28 | } 29 | 30 | compileConfig(): string[] { 31 | return this.template(this.variables) 32 | .split('\n') 33 | .filter((line) => !isEmpty(line)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/game-coordinator/errors/cannot-cleanup-game-server.error.ts: -------------------------------------------------------------------------------- 1 | import { GameServerOptionWithProvider } from '@/game-servers/interfaces/game-server-option'; 2 | 3 | export class CannotCleanupGameServerError extends Error { 4 | constructor( 5 | public readonly gameServer: GameServerOptionWithProvider, 6 | public readonly errorMessage: string, 7 | ) { 8 | super(`could not cleanup server ${gameServer.name}: ${errorMessage}`); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/game-coordinator/errors/cannot-configure-game.error.ts: -------------------------------------------------------------------------------- 1 | import { Game } from '@/games/models/game'; 2 | 3 | export class CannotConfigureGameError extends Error { 4 | constructor( 5 | public readonly game: Game, 6 | public readonly errorMessage: string, 7 | ) { 8 | super( 9 | `cannot configure game #${game.number}${ 10 | game.gameServer ? `(using gameserver ${game.gameServer.name})` : '' 11 | }: ${errorMessage}`, 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/game-coordinator/errors/game-has-already-ended.error.ts: -------------------------------------------------------------------------------- 1 | export class GameHasAlreadyEndedError extends Error { 2 | constructor(gameId: string) { 3 | super(`game ${gameId} has already ended`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/game-coordinator/errors/game-server-not-assigned.error.ts: -------------------------------------------------------------------------------- 1 | export class GameServerNotAssignedError extends Error { 2 | constructor(gameId: string) { 3 | super(`game ${gameId} has no gameserver assigned`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/game-coordinator/utils/extract-con-var-value.spec.ts: -------------------------------------------------------------------------------- 1 | import { extractConVarValue } from './extract-con-var-value'; 2 | 3 | describe('extractConVarValue()', () => { 4 | it('should parse the rcon response when the values are sane', () => { 5 | expect( 6 | extractConVarValue(`"sv_password" = "some password" ( def. "" ) 7 | notify 8 | - Server password for entry into multiplayer games`), 9 | ).toEqual('some password'); 10 | }); 11 | 12 | it('should handle empty values', () => { 13 | expect( 14 | extractConVarValue(`"tv_password" = "" 15 | notify 16 | - SourceTV password for all clients`), 17 | ).toEqual(''); 18 | }); 19 | 20 | it('should handle non-cvar responses', () => { 21 | expect( 22 | extractConVarValue(` 23 | 0:2:"melkor TV" 24 | 1 users`), 25 | ).toBeUndefined(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/game-coordinator/utils/extract-con-var-value.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extracts cvar value from rcon response that looks like that: 3 | * `"tv_port" = "27020" 4 | * - Host SourceTV port` 5 | * @param rconResponse The plain rcon response 6 | */ 7 | export function extractConVarValue(rconResponse: string): string | undefined { 8 | return ( 9 | rconResponse 10 | ?.split(/\r?\n/)[0] 11 | // https://regex101.com/r/jeIrq2/1 12 | ?.match(/^"(.[^"]*)"\s=\s"(.*)"(\s\(\s?def\.\s"(.*)"\s?\))?$/)?.[2] 13 | ?.toString() 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/game-coordinator/utils/fix-team-name.spec.ts: -------------------------------------------------------------------------------- 1 | import { Tf2Team } from '@/games/models/tf2-team'; 2 | import { fixTeamName } from './fix-team-name'; 3 | 4 | describe('fixTeamName()', () => { 5 | it('should convert Red to red', () => { 6 | expect(fixTeamName('Red')).toBe(Tf2Team.red); 7 | }); 8 | 9 | it('should convert Blue to blu', () => { 10 | expect(fixTeamName('Blue')).toBe(Tf2Team.blu); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/game-coordinator/utils/fix-team-name.ts: -------------------------------------------------------------------------------- 1 | import { Tf2Team } from '@/games/models/tf2-team'; 2 | 3 | // converts 'Red' and 'Blue' to valid team names 4 | export const fixTeamName = (teamName: string): Tf2Team => 5 | teamName.toLowerCase().substring(0, 3) as Tf2Team; 6 | -------------------------------------------------------------------------------- /src/game-coordinator/utils/make-connect-string.spec.ts: -------------------------------------------------------------------------------- 1 | import { makeConnectString } from './make-connect-string'; 2 | 3 | describe('makeConnectString()', () => { 4 | describe('without password', () => { 5 | it('should create connect string', () => { 6 | expect( 7 | makeConnectString({ 8 | address: 'FAKE_ADDRESS', 9 | port: 27015, 10 | }), 11 | ).toEqual('connect FAKE_ADDRESS:27015'); 12 | 13 | expect( 14 | makeConnectString({ 15 | address: 'FAKE_ADDRESS', 16 | port: 27015, 17 | password: '', 18 | }), 19 | ).toEqual('connect FAKE_ADDRESS:27015'); 20 | }); 21 | }); 22 | 23 | describe('with password', () => { 24 | it('should create connect string', () => { 25 | expect( 26 | makeConnectString({ 27 | address: 'FAKE_ADDRESS', 28 | port: 27015, 29 | password: 'FAKE_PASSWORD', 30 | }), 31 | ).toEqual('connect FAKE_ADDRESS:27015; password FAKE_PASSWORD'); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/game-coordinator/utils/make-connect-string.ts: -------------------------------------------------------------------------------- 1 | interface MakeConnectStringProps { 2 | address: string; 3 | port: string | number; 4 | password?: string; 5 | } 6 | 7 | export const makeConnectString = (props: MakeConnectStringProps) => { 8 | let connectString = `connect ${props.address}:${props.port}`; 9 | if (props.password) { 10 | connectString += `; password ${props.password}`; 11 | } 12 | 13 | return connectString; 14 | }; 15 | -------------------------------------------------------------------------------- /src/game-servers/controllers/game-servers.controller.ts: -------------------------------------------------------------------------------- 1 | import { Auth } from '@/auth/decorators/auth.decorator'; 2 | import { PlayerRole } from '@/players/models/player-role'; 3 | import { Controller, Get } from '@nestjs/common'; 4 | import { GameServersService } from '../services/game-servers.service'; 5 | import { GameServerOptionDto } from '../dto/game-server-option.dto'; 6 | 7 | @Controller('game-servers') 8 | export class GameServersController { 9 | constructor(private readonly gameServersService: GameServersService) {} 10 | 11 | @Get('options') 12 | @Auth(PlayerRole.admin) 13 | async getGameServerOptions(): Promise { 14 | return await this.gameServersService.findAllGameServerOptions(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/game-servers/dto/game-server-option.dto.ts: -------------------------------------------------------------------------------- 1 | export interface GameServerOptionDto { 2 | id: string; 3 | provider: string; 4 | name: string; 5 | flag?: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/game-servers/errors/no-free-game-server-available.error.ts: -------------------------------------------------------------------------------- 1 | export class NoFreeGameServerAvailableError extends Error { 2 | constructor() { 3 | super('no free game server available'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/game-servers/game-servers.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | import { GameServersService } from './services/game-servers.service'; 3 | import { GamesModule } from '@/games/games.module'; 4 | import { GameServersProvidersModule } from './providers/game-servers-providers.module'; 5 | import { GameServersController } from './controllers/game-servers.controller'; 6 | 7 | @Module({ 8 | imports: [ 9 | GameServersProvidersModule.configure(), 10 | forwardRef(() => GamesModule), 11 | ], 12 | providers: [GameServersService], 13 | exports: [GameServersService, GameServersProvidersModule], 14 | controllers: [GameServersController], 15 | }) 16 | export class GameServersModule {} 17 | -------------------------------------------------------------------------------- /src/game-servers/interfaces/game-server-controls.ts: -------------------------------------------------------------------------------- 1 | import { Rcon } from 'rcon-client/lib'; 2 | 3 | export interface GameServerControls { 4 | start: () => void | Promise; 5 | rcon: () => Rcon | Promise; 6 | getLogsecret: () => string | Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/game-servers/interfaces/game-server-details.ts: -------------------------------------------------------------------------------- 1 | export interface GameServerDetails { 2 | id: string; 3 | name: string; 4 | address: string; 5 | port: number; 6 | } 7 | 8 | export interface GameServerDetailsWithProvider extends GameServerDetails { 9 | provider: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/game-servers/interfaces/game-server-option.ts: -------------------------------------------------------------------------------- 1 | export interface GameServerOption { 2 | id: string; 3 | name: string; 4 | flag?: string; 5 | } 6 | 7 | export interface GameServerOptionWithProvider extends GameServerOption { 8 | provider: string; 9 | } 10 | 11 | export interface GameServerOptionIdentifier { 12 | id: string; 13 | provider: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/game-servers/providers/game-servers-providers.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Module } from '@nestjs/common'; 2 | import { StaticGameServerModule } from './static-game-server/static-game-server.module'; 3 | import { ServemeTfModule } from './serveme-tf/serveme-tf.module'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | 6 | @Module({}) 7 | export class GameServersProvidersModule { 8 | static async configure(): Promise { 9 | await ConfigModule.envVariablesLoaded; 10 | 11 | const enabledProviders = [StaticGameServerModule]; 12 | 13 | if (process.env.SERVEME_TF_API_KEY) { 14 | enabledProviders.push(ServemeTfModule); 15 | } 16 | 17 | return { 18 | module: GameServersProvidersModule, 19 | imports: [...enabledProviders], 20 | exports: [...enabledProviders], 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/game-servers/providers/serveme-tf/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * When the match ends, end the reservation after this number of milliseconds. 3 | */ 4 | export const endReservationDelay = 30 * 1000; 5 | -------------------------------------------------------------------------------- /src/game-servers/providers/serveme-tf/controllers/serveme-tf.controller.ts: -------------------------------------------------------------------------------- 1 | import { Auth } from '@/auth/decorators/auth.decorator'; 2 | import { PlayerRole } from '@/players/models/player-role'; 3 | import { Controller, Get, Inject } from '@nestjs/common'; 4 | import { Client } from '@tf2pickup-org/serveme-tf-client'; 5 | import { SERVEME_TF_CLIENT } from '../serveme-tf-client.token'; 6 | 7 | @Controller('serveme-tf') 8 | @Auth(PlayerRole.admin) 9 | export class ServemeTfController { 10 | constructor( 11 | @Inject(SERVEME_TF_CLIENT) 12 | private readonly servemeTfClient: Client, 13 | ) {} 14 | 15 | @Get('/') 16 | isEnabled() { 17 | return { isEnabled: true }; 18 | } 19 | 20 | @Get('/servers') 21 | async listAllServers() { 22 | const { servers } = await this.servemeTfClient.findOptions(); 23 | return servers; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/game-servers/providers/serveme-tf/serveme-tf-client.token.ts: -------------------------------------------------------------------------------- 1 | export const SERVEME_TF_CLIENT = 'SERVEME_TF_CLIENT'; 2 | -------------------------------------------------------------------------------- /src/game-servers/providers/serveme-tf/serveme-tf-server-controls.ts: -------------------------------------------------------------------------------- 1 | import { GameServerControls } from '@/game-servers/interfaces/game-server-controls'; 2 | import { createRcon } from '@/utils/create-rcon'; 3 | import { Rcon } from 'rcon-client/lib'; 4 | import { Reservation } from '@tf2pickup-org/serveme-tf-client'; 5 | 6 | export class ServemeTfServerControls implements GameServerControls { 7 | constructor(private readonly reservation: Reservation) {} 8 | 9 | async start(): Promise { 10 | await this.reservation.waitForStarted(); 11 | } 12 | 13 | async rcon(): Promise { 14 | return await createRcon({ 15 | host: this.reservation.server.ip, 16 | port: parseInt(this.reservation.server.port, 10), 17 | rconPassword: this.reservation.rcon, 18 | }); 19 | } 20 | 21 | getLogsecret(): string { 22 | return this.reservation.logSecret; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/game-servers/providers/serveme-tf/services/serveme-tf-configuration.service.ts: -------------------------------------------------------------------------------- 1 | import { configurationEntry } from '@/configuration/configuration-entry'; 2 | import { ConfigurationService } from '@/configuration/services/configuration.service'; 3 | import { Injectable, OnModuleInit } from '@nestjs/common'; 4 | import { z } from 'zod'; 5 | 6 | @Injectable() 7 | export class ServemeTfConfigurationService implements OnModuleInit { 8 | constructor(private readonly configurationService: ConfigurationService) {} 9 | 10 | onModuleInit() { 11 | this.configurationService.register( 12 | configurationEntry( 13 | 'serveme_tf.preferred_region', 14 | z.string().optional(), 15 | undefined, 16 | ), 17 | configurationEntry('serveme_tf.ban_gameservers', z.array(z.string()), []), 18 | ); 19 | } 20 | 21 | async getPreferredRegion(): Promise { 22 | return await this.configurationService.get( 23 | 'serveme_tf.preferred_region', 24 | ); 25 | } 26 | 27 | async getBannedGameservers(): Promise { 28 | return await this.configurationService.get( 29 | 'serveme_tf.ban_gameservers', 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/game-servers/providers/static-game-server/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * When a match ends, cleanup the gameserver after this number of milliseconds. 3 | */ 4 | export const serverCleanupDelay = 120 * 1000; 5 | -------------------------------------------------------------------------------- /src/game-servers/providers/static-game-server/dto/game-server-heartbeat.ts: -------------------------------------------------------------------------------- 1 | import { IsNumberString, IsOptional, IsPort, IsString } from 'class-validator'; 2 | 3 | export class GameServerHeartbeat { 4 | @IsString() 5 | name!: string; 6 | 7 | @IsString() 8 | address!: string; 9 | 10 | @IsPort() 11 | port!: string; 12 | 13 | @IsString() 14 | rconPassword!: string; 15 | 16 | @IsOptional() 17 | @IsNumberString() 18 | priority?: number; 19 | 20 | @IsOptional() 21 | @IsString() 22 | internalIpAddress?: string; 23 | } 24 | -------------------------------------------------------------------------------- /src/game-servers/providers/static-game-server/interfaces/diagnostic-check-result.ts: -------------------------------------------------------------------------------- 1 | export interface DiagnosticCheckResult { 2 | success: boolean; 3 | reportedErrors: string[]; 4 | reportedWarnings: string[]; 5 | effects?: Map; 6 | } 7 | -------------------------------------------------------------------------------- /src/game-servers/providers/static-game-server/interfaces/diagnostic-check-runner.ts: -------------------------------------------------------------------------------- 1 | import { StaticGameServer } from '../models/static-game-server'; 2 | import { DiagnosticCheckResult } from './diagnostic-check-result'; 3 | 4 | export interface DiagnosticCheckRunner { 5 | name: string; 6 | critical: boolean; 7 | 8 | run(params: { 9 | gameServer: StaticGameServer; 10 | effects: Map; 11 | }): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /src/game-servers/providers/static-game-server/models/diagnostic-check-status.ts: -------------------------------------------------------------------------------- 1 | export enum DiagnosticCheckStatus { 2 | pending = 'pending', 3 | running = 'running', 4 | completed = 'completed', 5 | failed = 'failed', 6 | } 7 | -------------------------------------------------------------------------------- /src/game-servers/providers/static-game-server/models/diagnostic-check.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { DiagnosticCheckStatus } from './diagnostic-check-status'; 3 | 4 | @Schema() 5 | export class DiagnosticCheck { 6 | @Prop({ required: true }) 7 | name!: string; 8 | 9 | @Prop({ enum: DiagnosticCheckStatus, default: DiagnosticCheckStatus.pending }) 10 | status?: DiagnosticCheckStatus; 11 | 12 | @Prop({ type: [String], default: [] }) 13 | reportedWarnings!: string[]; 14 | 15 | @Prop({ type: [String], default: [] }) 16 | reportedErrors!: string[]; 17 | 18 | @Prop({ required: true }) 19 | critical!: boolean; 20 | } 21 | 22 | export const diagnosticCheckSchema = 23 | SchemaFactory.createForClass(DiagnosticCheck); 24 | -------------------------------------------------------------------------------- /src/game-servers/providers/static-game-server/models/diagnostic-run-status.ts: -------------------------------------------------------------------------------- 1 | export enum DiagnosticRunStatus { 2 | pending = 'pending', 3 | running = 'running', 4 | completed = 'completed', 5 | failed = 'failed', 6 | } 7 | -------------------------------------------------------------------------------- /src/game-servers/providers/static-game-server/static-game-server-provider-name.ts: -------------------------------------------------------------------------------- 1 | export const staticGameServerProviderName = 'static'; 2 | -------------------------------------------------------------------------------- /src/game-servers/providers/static-game-server/utils/generate-logsecret.ts: -------------------------------------------------------------------------------- 1 | import { generate } from 'generate-password'; 2 | 3 | /** 4 | * Generate string that will be used with the sv_logsecret command. 5 | */ 6 | export const generateLogsecret = () => 7 | generate({ 8 | length: 16, 9 | numbers: true, 10 | symbols: false, 11 | lowercase: false, 12 | uppercase: false, 13 | }); 14 | -------------------------------------------------------------------------------- /src/games/controllers/games-with-substitution-requests.controller.ts: -------------------------------------------------------------------------------- 1 | import { Serializable } from '@/shared/serializable'; 2 | import { Controller, Get } from '@nestjs/common'; 3 | import { GameDto } from '../dto/game.dto'; 4 | import { GamesService } from '../services/games.service'; 5 | 6 | @Controller('games-with-substitution-requests') 7 | export class GamesWithSubstitutionRequestsController { 8 | constructor(private gamesService: GamesService) {} 9 | 10 | @Get() 11 | async getGamesWithSubstitutionRequests(): Promise[]> { 12 | return await this.gamesService.getGamesWithSubstitutionRequests(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/games/dto/connect-info.dto.ts: -------------------------------------------------------------------------------- 1 | export interface ConnectInfoDto { 2 | gameId: string; 3 | connectInfoVersion: number; 4 | connectString?: string; 5 | voiceChannelUrl?: string; 6 | joinGameServerTimeout?: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/games/dto/game-event.dto.ts: -------------------------------------------------------------------------------- 1 | export interface GameEventDto { 2 | event: string; 3 | at: string; 4 | [key: string]: unknown; 5 | } 6 | -------------------------------------------------------------------------------- /src/games/dto/game-server-option-identifier.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class GameServerOptionIdentifier { 4 | @IsString() 5 | id!: string; 6 | 7 | @IsString() 8 | provider!: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/games/dto/game-slot-dto.ts: -------------------------------------------------------------------------------- 1 | import { PlayerDto } from '@/players/dto/player.dto'; 2 | import { Tf2ClassName } from '@/shared/models/tf2-class-name'; 3 | import { Serializable } from '@/shared/serializable'; 4 | import { Tf2Team } from '../models/tf2-team'; 5 | import { SlotStatus } from '../models/slot-status'; 6 | import { PlayerConnectionStatus } from '../models/player-connection-status'; 7 | 8 | export interface GameSlotDto { 9 | player: Serializable; 10 | team: Tf2Team; 11 | gameClass: Tf2ClassName; 12 | status: SlotStatus; 13 | connectionStatus: PlayerConnectionStatus; 14 | } 15 | -------------------------------------------------------------------------------- /src/games/dto/game-sort-params.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tf2pickup-org/server/96973ee9f8b15a9004293371e6b0d295719b7c58/src/games/dto/game-sort-params.ts -------------------------------------------------------------------------------- /src/games/dto/game.dto.ts: -------------------------------------------------------------------------------- 1 | import { Serializable } from '@/shared/serializable'; 2 | import { GameSlotDto } from './game-slot-dto'; 3 | import { GameState } from '../models/game-state'; 4 | 5 | export interface GameDto { 6 | id: string; 7 | launchedAt: string; 8 | endedAt?: string; 9 | number: number; 10 | map: string; 11 | state: GameState; 12 | connectInfoVersion: number; 13 | stvConnectString?: string; 14 | logsUrl?: string; 15 | demoUrl?: string; 16 | error?: string; 17 | gameServer?: { 18 | name: string; 19 | }; 20 | score?: { 21 | blu?: number; 22 | red?: number; 23 | }; 24 | 25 | // TODO v12: remove 26 | slots: Serializable[]; 27 | } 28 | -------------------------------------------------------------------------------- /src/games/dto/paginated-game-list.dto.ts: -------------------------------------------------------------------------------- 1 | import { Game } from '../models/game'; 2 | 3 | export interface PaginatedGameListDto { 4 | results: Game[]; 5 | itemCount: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/games/errors/cannot-assign-gameserver.error.ts: -------------------------------------------------------------------------------- 1 | import { Game } from '../models/game'; 2 | 3 | export class CannotAssignGameServerError extends Error { 4 | constructor( 5 | public readonly game: Game, 6 | public readonly reason: string, 7 | ) { 8 | super(`cannot assign a gameserver to game #${game.number}: ${reason}`); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/games/errors/game-in-wrong-state.error.ts: -------------------------------------------------------------------------------- 1 | import { GameId } from '../types/game-id'; 2 | import { GameState } from '../models/game-state'; 3 | 4 | export class GameInWrongStateError extends Error { 5 | constructor( 6 | public readonly gameId: GameId, 7 | public readonly gameState: GameState, 8 | ) { 9 | super(`game ${gameId.toString()} is in wrong state (${gameState})`); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/games/errors/player-not-in-this-game.error.ts: -------------------------------------------------------------------------------- 1 | import { PlayerId } from '@/players/types/player-id'; 2 | import { GameId } from '../types/game-id'; 3 | 4 | export class PlayerNotInThisGameError extends Error { 5 | constructor( 6 | public readonly playerId: PlayerId, 7 | public readonly gameId: GameId, 8 | ) { 9 | super( 10 | `player (id=${playerId.toString()}) does not take part in the game (id=${gameId.toString()})`, 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/games/filters/game-in-wrong-state-error.filter.spec.ts: -------------------------------------------------------------------------------- 1 | import { GameInWrongStateErrorFilter } from './game-in-wrong-state-error.filter'; 2 | 3 | describe('GameInWrongStateErrorFilter', () => { 4 | it('should be defined', () => { 5 | expect(new GameInWrongStateErrorFilter()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/games/filters/game-in-wrong-state-error.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; 2 | import { Request, Response } from 'express'; 3 | import { GameInWrongStateError } from '../errors/game-in-wrong-state.error'; 4 | 5 | @Catch(GameInWrongStateError) 6 | export class GameInWrongStateErrorFilter 7 | implements ExceptionFilter 8 | { 9 | catch(exception: GameInWrongStateError, host: ArgumentsHost) { 10 | const ctx = host.switchToHttp(); 11 | const response = ctx.getResponse(); 12 | const request = ctx.getRequest(); 13 | 14 | response.status(400).json({ 15 | statusCode: 400, 16 | path: request.url, 17 | error: `the game is in an invalid state (${exception.gameState})`, 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/games/filters/player-not-in-this-game-error.filter.spec.ts: -------------------------------------------------------------------------------- 1 | import { PlayerNotInThisGameErrorFilter } from './player-not-in-this-game-error.filter'; 2 | 3 | describe('PlayerNotInThisGameErrorFilter', () => { 4 | it('should be defined', () => { 5 | expect(new PlayerNotInThisGameErrorFilter()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/games/filters/player-not-in-this-game-error.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; 2 | import { Request, Response } from 'express'; 3 | import { PlayerNotInThisGameError } from '../errors/player-not-in-this-game.error'; 4 | 5 | @Catch(PlayerNotInThisGameError) 6 | export class PlayerNotInThisGameErrorFilter 7 | implements ExceptionFilter 8 | { 9 | catch(exception: PlayerNotInThisGameError, host: ArgumentsHost) { 10 | const ctx = host.switchToHttp(); 11 | const response = ctx.getResponse(); 12 | const request = ctx.getRequest(); 13 | 14 | response.status(401).json({ 15 | statusCode: 401, 16 | path: request.url, 17 | error: `you do not take part in this game`, 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/games/gateways/game-slots.gateway.ts: -------------------------------------------------------------------------------- 1 | import { WebsocketEvent } from '@/websocket-event'; 2 | import { WebSocketGateway } from '@nestjs/websockets'; 3 | import { GameSlotDto } from '../dto/game-slot-dto'; 4 | import { OnModuleInit } from '@nestjs/common'; 5 | import { Events } from '@/events/events'; 6 | import { WebsocketEventEmitter } from '@/shared/websocket-event-emitter'; 7 | import { filter, map } from 'rxjs/operators'; 8 | import { isEqual } from 'lodash'; 9 | 10 | @WebSocketGateway() 11 | export class GameSlotsGateway 12 | extends WebsocketEventEmitter 13 | implements OnModuleInit 14 | { 15 | constructor(private readonly events: Events) { 16 | super(); 17 | } 18 | 19 | onModuleInit() { 20 | this.events.gameChanges 21 | .pipe( 22 | filter( 23 | ({ oldGame, newGame }) => !isEqual(oldGame.slots, newGame.slots), 24 | ), 25 | map(({ newGame }) => ({ 26 | number: newGame.number, 27 | slots: newGame.slots, 28 | })), 29 | ) 30 | .subscribe(async ({ number, slots }) => { 31 | await this.emit({ 32 | room: `game/${number}`, 33 | event: WebsocketEvent.gameSlotsUpdated, 34 | payload: slots, 35 | }); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/games/models/events/game-created.ts: -------------------------------------------------------------------------------- 1 | import { Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { GameEvent } from '../game-event'; 3 | import { GameEventType } from '../game-event-type'; 4 | 5 | @Schema() 6 | export class GameCreated extends GameEvent { 7 | event = GameEventType.gameCreated; 8 | } 9 | 10 | export const gameCreatedSchema = SchemaFactory.createForClass(GameCreated); 11 | -------------------------------------------------------------------------------- /src/games/models/events/game-server-initialized.ts: -------------------------------------------------------------------------------- 1 | import { Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { GameEvent } from '../game-event'; 3 | import { GameEventType } from '../game-event-type'; 4 | 5 | @Schema() 6 | export class GameServerInitialized extends GameEvent { 7 | event = GameEventType.gameServerInitialized; 8 | } 9 | 10 | export const gameServerInitializedSchema = SchemaFactory.createForClass( 11 | GameServerInitialized, 12 | ); 13 | -------------------------------------------------------------------------------- /src/games/models/events/game-started.ts: -------------------------------------------------------------------------------- 1 | import { Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { GameEvent } from '../game-event'; 3 | import { GameEventType } from '../game-event-type'; 4 | 5 | @Schema() 6 | export class GameStarted extends GameEvent { 7 | event = GameEventType.gameStarted; 8 | } 9 | 10 | export const gameStartedSchema = SchemaFactory.createForClass(GameStarted); 11 | -------------------------------------------------------------------------------- /src/games/models/events/player-joined-game-server-team.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { GameEvent } from '../game-event'; 3 | import { GameEventType } from '../game-event-type'; 4 | import { PlayerId } from '@/players/types/player-id'; 5 | import { TransformObjectId } from '@/shared/decorators/transform-object-id'; 6 | import { Types } from 'mongoose'; 7 | import { app } from '@/app'; 8 | import { GameEventDto } from '@/games/dto/game-event.dto'; 9 | import { PlayersService } from '@/players/services/players.service'; 10 | 11 | @Schema() 12 | export class PlayerJoinedGameServerTeam extends GameEvent { 13 | event = GameEventType.playerJoinedGameServerTeam; 14 | 15 | @TransformObjectId() 16 | @Prop({ required: true, type: Types.ObjectId, ref: 'Player', index: true }) 17 | player!: PlayerId; 18 | 19 | async serialize(): Promise { 20 | const playersService = app.get(PlayersService); 21 | return { 22 | event: this.event, 23 | at: this.at.toISOString(), 24 | player: await playersService.getById(this.player), 25 | }; 26 | } 27 | } 28 | 29 | export const playerJoinedGameServerTeamSchema = SchemaFactory.createForClass( 30 | PlayerJoinedGameServerTeam, 31 | ); 32 | -------------------------------------------------------------------------------- /src/games/models/events/player-joined-game-server.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { GameEvent } from '../game-event'; 3 | import { GameEventType } from '../game-event-type'; 4 | import { PlayerId } from '@/players/types/player-id'; 5 | import { TransformObjectId } from '@/shared/decorators/transform-object-id'; 6 | import { Types } from 'mongoose'; 7 | import { app } from '@/app'; 8 | import { PlayersService } from '@/players/services/players.service'; 9 | import { GameEventDto } from '@/games/dto/game-event.dto'; 10 | 11 | @Schema() 12 | export class PlayerJoinedGameServer extends GameEvent { 13 | event = GameEventType.playerJoinedGameServer; 14 | 15 | @TransformObjectId() 16 | @Prop({ required: true, type: Types.ObjectId, ref: 'Player', index: true }) 17 | player!: PlayerId; 18 | 19 | async serialize(): Promise { 20 | const playersService = app.get(PlayersService); 21 | return { 22 | event: this.event, 23 | at: this.at.toISOString(), 24 | player: await playersService.getById(this.player), 25 | }; 26 | } 27 | } 28 | 29 | export const playerJoinedGameServerSchema = SchemaFactory.createForClass( 30 | PlayerJoinedGameServer, 31 | ); 32 | -------------------------------------------------------------------------------- /src/games/models/events/player-left-game-server.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { GameEvent } from '../game-event'; 3 | import { GameEventType } from '../game-event-type'; 4 | import { PlayerId } from '@/players/types/player-id'; 5 | import { TransformObjectId } from '@/shared/decorators/transform-object-id'; 6 | import { Types } from 'mongoose'; 7 | import { app } from '@/app'; 8 | import { GameEventDto } from '@/games/dto/game-event.dto'; 9 | import { PlayersService } from '@/players/services/players.service'; 10 | 11 | @Schema() 12 | export class PlayerLeftGameServer extends GameEvent { 13 | event = GameEventType.playerLeftGameServer; 14 | 15 | @TransformObjectId() 16 | @Prop({ required: true, type: Types.ObjectId, ref: 'Player', index: true }) 17 | player!: PlayerId; 18 | 19 | async serialize(): Promise { 20 | const playersService = app.get(PlayersService); 21 | return { 22 | event: this.event, 23 | at: this.at.toISOString(), 24 | player: await playersService.getById(this.player), 25 | }; 26 | } 27 | } 28 | 29 | export const playerLeftGameServerSchema = 30 | SchemaFactory.createForClass(PlayerLeftGameServer); 31 | -------------------------------------------------------------------------------- /src/games/models/events/round-ended.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { GameEvent } from '../game-event'; 3 | import { GameEventType } from '../game-event-type'; 4 | import { GameEventDto } from '@/games/dto/game-event.dto'; 5 | import { Type } from 'class-transformer'; 6 | import { Tf2Team } from '../tf2-team'; 7 | 8 | @Schema() 9 | class Score { 10 | @Prop({ required: true }) 11 | [Tf2Team.blu]!: number; 12 | 13 | @Prop({ required: true }) 14 | [Tf2Team.red]!: number; 15 | } 16 | 17 | const scoreSchema = SchemaFactory.createForClass(Score); 18 | 19 | @Schema() 20 | export class RoundEnded extends GameEvent { 21 | event = GameEventType.roundEnded; 22 | 23 | @Prop({ required: true }) 24 | winner!: Tf2Team; 25 | 26 | @Prop({ required: true }) 27 | lengthMs!: number; 28 | 29 | @Type(() => Score) 30 | @Prop({ type: scoreSchema, required: true }) 31 | score!: Score; 32 | 33 | serialize(): GameEventDto { 34 | return { 35 | event: this.event, 36 | at: this.at.toISOString(), 37 | score: { 38 | blu: this.score.blu, 39 | red: this.score.red, 40 | }, 41 | }; 42 | } 43 | } 44 | 45 | export const roundEndedSchema = SchemaFactory.createForClass(RoundEnded); 46 | -------------------------------------------------------------------------------- /src/games/models/game-event-type.ts: -------------------------------------------------------------------------------- 1 | export enum GameEventType { 2 | gameCreated = 'created', 3 | gameStarted = 'started', 4 | gameEnded = 'ended', 5 | 6 | gameServerAssigned = 'game server assigned', 7 | gameServerInitialized = 'game server initialized', 8 | 9 | substituteRequested = 'substitute requested', 10 | playerReplaced = 'player replaced', 11 | 12 | playerJoinedGameServer = 'player joined game server', 13 | playerJoinedGameServerTeam = 'player joined game server team', 14 | playerLeftGameServer = 'player left game server', 15 | 16 | roundEnded = 'round ended', 17 | } 18 | -------------------------------------------------------------------------------- /src/games/models/game-event.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { GameEventType } from './game-event-type'; 3 | import { Serializable } from '@/shared/serializable'; 4 | import { GameEventDto } from '../dto/game-event.dto'; 5 | 6 | @Schema({ discriminatorKey: 'event' }) 7 | export class GameEvent extends Serializable { 8 | event!: GameEventType; 9 | 10 | @Prop({ required: true, default: () => new Date() }) 11 | at!: Date; 12 | 13 | serialize(): GameEventDto | Promise { 14 | return { 15 | event: this.event, 16 | at: this.at.toISOString(), 17 | }; 18 | } 19 | } 20 | 21 | export const gameEventSchema = SchemaFactory.createForClass(GameEvent); 22 | -------------------------------------------------------------------------------- /src/games/models/game-logs.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | 3 | @Schema() 4 | export class GameLogs { 5 | @Prop({ required: true, unique: true, sparse: true }) 6 | logSecret!: string; 7 | 8 | @Prop({ required: true, default: [], type: [String] }) 9 | logs!: string[]; 10 | } 11 | 12 | export const gameLogsSchema = SchemaFactory.createForClass(GameLogs); 13 | -------------------------------------------------------------------------------- /src/games/models/game-server.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | 3 | @Schema() 4 | export class GameServer { 5 | @Prop({ required: true }) 6 | id!: string; 7 | 8 | @Prop({ required: true }) 9 | provider!: string; 10 | 11 | @Prop({ required: true, trim: true }) 12 | name!: string; 13 | 14 | @Prop({ required: true }) 15 | address!: string; 16 | 17 | @Prop({ required: true }) 18 | port!: number; 19 | } 20 | 21 | export const gameServerSchema = SchemaFactory.createForClass(GameServer); 22 | -------------------------------------------------------------------------------- /src/games/models/game-state.ts: -------------------------------------------------------------------------------- 1 | export enum GameState { 2 | // the game has been created and is awaiting to be assigned a gameserver 3 | created = 'created', 4 | 5 | // the game has been assigned a gameserver and it is being configured 6 | configuring = 'configuring', 7 | 8 | // the gameserver is fully configured and is waiting for the match to start 9 | launching = 'launching', 10 | 11 | // the match is in progress 12 | started = 'started', 13 | 14 | // the match has ended 15 | ended = 'ended', 16 | 17 | // the match has been interrupted by an admin (or another factor) 18 | interrupted = 'interrupted', 19 | } 20 | -------------------------------------------------------------------------------- /src/games/models/player-connection-status.ts: -------------------------------------------------------------------------------- 1 | export enum PlayerConnectionStatus { 2 | offline = 'offline', 3 | joining = 'joining', 4 | connected = 'connected', 5 | } 6 | -------------------------------------------------------------------------------- /src/games/models/slot-status.ts: -------------------------------------------------------------------------------- 1 | export enum SlotStatus { 2 | active = 'active', 3 | waitingForSubstitute = 'waiting for substitute', 4 | replaced = 'replaced', 5 | } 6 | -------------------------------------------------------------------------------- /src/games/models/tf2-team.ts: -------------------------------------------------------------------------------- 1 | export enum Tf2Team { 2 | red = 'red', 3 | blu = 'blu', 4 | } 5 | -------------------------------------------------------------------------------- /src/games/pipes/parse-sort-params.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | import { ParseSortParamsPipe } from './parse-sort-params.pipe'; 3 | 4 | describe('ParseSortParamsPipe', () => { 5 | let pipe: ParseSortParamsPipe; 6 | 7 | beforeEach(() => { 8 | pipe = new ParseSortParamsPipe(); 9 | }); 10 | 11 | it('should be defined', () => { 12 | expect(pipe).toBeDefined(); 13 | }); 14 | 15 | it('should handle launchedAt param', () => { 16 | expect(pipe.transform('launchedAt')).toEqual({ 'events.0.at': 1 }); 17 | expect(pipe.transform('launched_at')).toEqual({ 'events.0.at': 1 }); 18 | expect(pipe.transform('-launchedAt')).toEqual({ 'events.0.at': -1 }); 19 | expect(pipe.transform('-launched_at')).toEqual({ 'events.0.at': -1 }); 20 | }); 21 | 22 | it('should deny invalid params', () => { 23 | expect(() => pipe.transform('invalid')).toThrow(BadRequestException); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/games/pipes/parse-sort-params.pipe.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class ParseSortParamsPipe implements PipeTransform { 5 | // skipcq: JS-0105 6 | transform(value: string): Record { 7 | switch (value) { 8 | case '-launched_at': 9 | case '-launchedAt': 10 | return { 'events.0.at': -1 }; 11 | 12 | case 'launched_at': 13 | case 'launchedAt': 14 | return { 'events.0.at': 1 }; 15 | 16 | default: 17 | throw new BadRequestException('invalid sort parameters'); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/games/tokens/game-model-mutex.token.ts: -------------------------------------------------------------------------------- 1 | export const GAME_MODEL_MUTEX = 'GAME_MODEL_MUTEX'; 2 | -------------------------------------------------------------------------------- /src/games/types/cooldown-level.ts: -------------------------------------------------------------------------------- 1 | export interface CooldownLevel { 2 | level: number; 3 | banLengthMs: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/games/types/game-id.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | declare const _gameId: unique symbol; 4 | 5 | export type GameId = Types.ObjectId & { readonly [_gameId]: never }; 6 | -------------------------------------------------------------------------------- /src/games/types/logs-tf-upload-method.ts: -------------------------------------------------------------------------------- 1 | export enum LogsTfUploadMethod { 2 | Off = 'off', // logs won't be uploaded at all 3 | Backend = 'backend', // logs will be uploaded only by the backend 4 | Gameserver = 'gameserver', // logs will be uploaded only by the gameserver 5 | } 6 | -------------------------------------------------------------------------------- /src/games/types/most-active-players.ts: -------------------------------------------------------------------------------- 1 | interface MostActivePlayersEntry { 2 | player: string; 3 | count: number; 4 | } 5 | 6 | export type MostActivePlayers = MostActivePlayersEntry[]; 7 | -------------------------------------------------------------------------------- /src/games/types/voice-server-type.ts: -------------------------------------------------------------------------------- 1 | export enum VoiceServerType { 2 | none = 'none', 3 | staticLink = 'static link', 4 | mumble = 'mumble', 5 | } 6 | -------------------------------------------------------------------------------- /src/log-receiver/errors/log-message-invalid.error.ts: -------------------------------------------------------------------------------- 1 | export class LogMessageInvalidError extends Error { 2 | constructor(public reason: string) { 3 | super(`log message invalid (${reason})`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/log-receiver/log-receiver.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { LogReceiverService } from './services/log-receiver.service'; 3 | 4 | @Module({ 5 | providers: [LogReceiverService], 6 | exports: [LogReceiverService], 7 | }) 8 | export class LogReceiverModule {} 9 | -------------------------------------------------------------------------------- /src/log-receiver/types/log-message.ts: -------------------------------------------------------------------------------- 1 | export interface LogMessage { 2 | payload: string; 3 | password: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/logs-tf/errors/logs-tf-upload.error.ts: -------------------------------------------------------------------------------- 1 | export class LogsTfUploadError extends Error { 2 | constructor(public readonly errorMessage: string) { 3 | super(`logs.tf upload error: ${errorMessage}`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/logs-tf/logs-tf.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { LogsTfApiService } from './services/logs-tf-api.service'; 3 | 4 | @Module({ 5 | providers: [LogsTfApiService], 6 | exports: [LogsTfApiService], 7 | }) 8 | export class LogsTfModule {} 9 | -------------------------------------------------------------------------------- /src/logs-tf/services/logs-tf-api.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from '@/environment/environment'; 2 | import { logsTfUploadEndpoint } from '@configs/urls'; 3 | import { HttpService } from '@nestjs/axios'; 4 | import { Test, TestingModule } from '@nestjs/testing'; 5 | import * as FormData from 'form-data'; 6 | import { of, throwError } from 'rxjs'; 7 | import { LogsTfApiService } from './logs-tf-api.service'; 8 | 9 | jest.mock('@nestjs/axios'); 10 | jest.mock('@/environment/environment', () => ({ 11 | Environment: jest.fn().mockImplementation(() => ({ 12 | logsTfApiKey: 'FAKE_LOGS_TF_API_KEY', 13 | websiteName: 'FAKE_WEBSITE_NAME', 14 | })), 15 | })); 16 | 17 | describe('LogsTfApiService', () => { 18 | let service: LogsTfApiService; 19 | 20 | beforeEach(async () => { 21 | const module: TestingModule = await Test.createTestingModule({ 22 | providers: [LogsTfApiService, HttpService, Environment], 23 | }).compile(); 24 | 25 | service = module.get(LogsTfApiService); 26 | }); 27 | 28 | it('should be defined', () => { 29 | expect(service).toBeDefined(); 30 | }); 31 | 32 | describe('#uploadLogs()', () => { 33 | // TODO write tests 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { LogLevel } from '@nestjs/common'; 4 | import { configureApplication } from './configure-application'; 5 | 6 | async function bootstrap() { 7 | let logLevels: LogLevel[]; 8 | 9 | if (process.env.NODE_ENV === 'production') { 10 | logLevels = ['error', 'warn', 'log', 'verbose']; 11 | } else { 12 | logLevels = ['error', 'warn', 'log', 'verbose', 'debug']; 13 | } 14 | 15 | const app = await NestFactory.create(AppModule, { 16 | logger: logLevels, 17 | }); 18 | 19 | configureApplication(app); 20 | await app.listen(3000); 21 | } 22 | 23 | bootstrap().catch((error) => console.error(error)); 24 | -------------------------------------------------------------------------------- /src/migrations/migration.store.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/tj/node-migrate/blob/main/examples/custom-state-storage/mongo-state-storage.js 2 | 3 | export interface MigrationSet { 4 | lastRun?: string; 5 | // skipcq: JS-0323 6 | migrations?: any[]; 7 | } 8 | 9 | export interface MigrationStore { 10 | // skipcq: JS-0323 11 | load: (callback: (error: any, data: MigrationSet) => any) => any; 12 | // skipcq: JS-0323 13 | save: (set: MigrationSet, callback: (error: any, result: any) => any) => any; 14 | } 15 | -------------------------------------------------------------------------------- /src/migrations/migrations.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { getConnectionToken } from '@nestjs/mongoose'; 3 | import { Connection } from 'mongoose'; 4 | import { MigrationsService } from './services/migrations.service'; 5 | import { MongoDbStore } from './stores/mongo-db.store'; 6 | 7 | @Module({ 8 | providers: [ 9 | { 10 | provide: 'MIGRATION_STORE', 11 | inject: [getConnectionToken()], 12 | useFactory: (connection: Connection) => new MongoDbStore(connection), 13 | }, 14 | MigrationsService, 15 | ], 16 | }) 17 | export class MigrationsModule {} 18 | -------------------------------------------------------------------------------- /src/migrations/stores/mongo-db.store.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from 'mongoose'; 2 | import { MigrationSet, MigrationStore } from '../migration.store'; 3 | 4 | export class MongoDbStore implements MigrationStore { 5 | constructor(private connection: Connection) {} 6 | 7 | // skipcq: JS-0323 8 | async load(callback: (error: unknown, data: MigrationSet) => any) { 9 | const data = await this.connection.db.collection('migrations').findOne({}); 10 | // skipcq: JS-0323 11 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument 12 | return callback(null, (data ?? {}) as any); 13 | } 14 | 15 | // skipcq: JS-0323 16 | async save(set: MigrationSet, callback: (error: any, result: any) => any) { 17 | const result = await this.connection.db.collection('migrations').updateOne( 18 | {}, 19 | { 20 | $set: { 21 | lastRun: set.lastRun, 22 | }, 23 | $push: { 24 | migrations: { $each: set.migrations }, 25 | }, 26 | }, 27 | { 28 | upsert: true, 29 | }, 30 | ); 31 | 32 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 33 | return callback(null, result); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/player-actions-logger/controllers/player-action-logs.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PlayerActionsRepositoryService } from '../services/player-actions-repository.service'; 3 | import { PlayerActionLogsController } from './player-action-logs.controller'; 4 | 5 | jest.mock('../services/player-actions-repository.service'); 6 | jest.mock('../pipes/parse-filters.pipe'); 7 | 8 | describe('PlayerActionLogsController', () => { 9 | let controller: PlayerActionLogsController; 10 | 11 | beforeEach(async () => { 12 | const module: TestingModule = await Test.createTestingModule({ 13 | controllers: [PlayerActionLogsController], 14 | providers: [PlayerActionsRepositoryService], 15 | }).compile(); 16 | 17 | controller = module.get( 18 | PlayerActionLogsController, 19 | ); 20 | }); 21 | 22 | it('should be defined', () => { 23 | expect(controller).toBeDefined(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/player-actions-logger/dto/player-action.dto.ts: -------------------------------------------------------------------------------- 1 | import { Player } from '@/players/models/player'; 2 | import { Serializable } from '@/shared/serializable'; 3 | 4 | export interface PlayerActionDto { 5 | player: Player | Serializable; 6 | timestamp: string; 7 | action: string; 8 | 9 | ipAddress?: string; 10 | userAgent?: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/player-actions-logger/pipes/parse-filters.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { PlayersService } from '@/players/services/players.service'; 2 | import { ParseFiltersPipe } from './parse-filters.pipe'; 3 | 4 | jest.mock('@/players/services/players.service', () => ({ 5 | PlayersService: jest.fn().mockImplementation(() => ({ 6 | find: (query: any) => Promise.resolve([{ _id: 'FAKE_PLAYER_ID' }]), 7 | })), 8 | })); 9 | 10 | describe('ParseFiltersPipe', () => { 11 | let playersService: jest.Mocked; 12 | let pipe: ParseFiltersPipe; 13 | 14 | beforeEach(() => { 15 | // @ts-expect-error 16 | playersService = new PlayersService(); 17 | pipe = new ParseFiltersPipe(playersService); 18 | }); 19 | 20 | it('should be defined', () => { 21 | expect(pipe).toBeDefined(); 22 | }); 23 | 24 | it('should handle no input', async () => { 25 | expect(await pipe.transform({})).toEqual({}); 26 | }); 27 | 28 | it('should map players query', async () => { 29 | const query = await pipe.transform({ 30 | 'player.name': 'FAKE_PLAYER', 31 | ipAddress: '127.0.0.1', 32 | }); 33 | expect(query).toEqual({ 34 | player: ['FAKE_PLAYER_ID'], 35 | ipAddress: /127.0.0.1/, 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/player-actions-logger/player-actions-logger.module.ts: -------------------------------------------------------------------------------- 1 | import { GamesModule } from '@/games/games.module'; 2 | import { PlayersModule } from '@/players/players.module'; 3 | import { Module } from '@nestjs/common'; 4 | import { MongooseModule } from '@nestjs/mongoose'; 5 | import { 6 | PlayerActionEntry, 7 | playerActionEntrySchema, 8 | } from './models/player-action-entry'; 9 | import { PlayerActionLoggerService } from './services/player-action-logger.service'; 10 | import { PlayerActionLogsController } from './controllers/player-action-logs.controller'; 11 | import { PlayerActionsRepositoryService } from './services/player-actions-repository.service'; 12 | 13 | @Module({ 14 | imports: [ 15 | MongooseModule.forFeature([ 16 | { 17 | name: PlayerActionEntry.name, 18 | schema: playerActionEntrySchema, 19 | }, 20 | ]), 21 | PlayersModule, 22 | GamesModule, 23 | ], 24 | providers: [PlayerActionLoggerService, PlayerActionsRepositoryService], 25 | exports: [PlayerActionLoggerService], 26 | controllers: [PlayerActionLogsController], 27 | }) 28 | export class PlayerActionsLoggerModule {} 29 | -------------------------------------------------------------------------------- /src/player-actions-logger/player-actions/player-action.ts: -------------------------------------------------------------------------------- 1 | import { Player } from '@/players/models/player'; 2 | import { UserMetadata } from '@/shared/user-metadata'; 3 | 4 | export abstract class PlayerAction { 5 | constructor( 6 | public readonly player: Player, 7 | public readonly metadata: UserMetadata, 8 | ) {} 9 | 10 | abstract toString(): string; 11 | } 12 | -------------------------------------------------------------------------------- /src/player-actions-logger/player-actions/player-connected-to-gameserver.ts: -------------------------------------------------------------------------------- 1 | import { Game } from '@/games/models/game'; 2 | import { Player } from '@/players/models/player'; 3 | import { UserMetadata } from '@/shared/user-metadata'; 4 | import { PlayerAction } from './player-action'; 5 | 6 | export class PlayerConnectedToGameserver extends PlayerAction { 7 | constructor( 8 | player: Player, 9 | metadata: UserMetadata, 10 | public readonly game: Game, 11 | ) { 12 | super(player, metadata); 13 | } 14 | 15 | toString(): string { 16 | return `connected to gameserver (game #${this.game.number})`; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/player-actions-logger/player-actions/player-online-status-changed.ts: -------------------------------------------------------------------------------- 1 | import { Player } from '@/players/models/player'; 2 | import { UserMetadata } from '@/shared/user-metadata'; 3 | import { PlayerAction } from './player-action'; 4 | 5 | export class PlayerOnlineStatusChanged extends PlayerAction { 6 | constructor( 7 | player: Player, 8 | metadata: UserMetadata, 9 | public readonly online: boolean, 10 | ) { 11 | super(player, metadata); 12 | } 13 | 14 | toString(): string { 15 | return `went ${this.online ? 'online' : 'offline'}`; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/player-actions-logger/player-actions/player-said-in-match-chat.ts: -------------------------------------------------------------------------------- 1 | import { Player } from '@/players/models/player'; 2 | import { UserMetadata } from '@/shared/user-metadata'; 3 | import { PlayerAction } from './player-action'; 4 | 5 | export class PlayerSaidInMatchChat extends PlayerAction { 6 | constructor( 7 | player: Player, 8 | metadata: UserMetadata, 9 | public readonly message: string, 10 | ) { 11 | super(player, metadata); 12 | } 13 | 14 | toString(): string { 15 | return `said "${this.message}"`; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/player-actions-logger/services/player-actions-repository.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { plainToInstance } from 'class-transformer'; 4 | import { FilterQuery, Model } from 'mongoose'; 5 | import { PlayerActionEntry } from '../models/player-action-entry'; 6 | 7 | export interface PlayerActionsQuery { 8 | limit: number; 9 | filters?: FilterQuery; 10 | } 11 | 12 | @Injectable() 13 | export class PlayerActionsRepositoryService { 14 | constructor( 15 | @InjectModel(PlayerActionEntry.name) 16 | private readonly playerActionEntryModel: Model, 17 | ) {} 18 | 19 | async find( 20 | query: PlayerActionsQuery = { limit: 10, filters: {} }, 21 | ): Promise { 22 | return plainToInstance( 23 | PlayerActionEntry, 24 | await this.playerActionEntryModel 25 | .find(query.filters ?? {}) 26 | .limit(query.limit) 27 | .sort({ timestamp: -1 }) 28 | .lean() 29 | .exec(), 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/player-preferences/models/player-preferences.ts: -------------------------------------------------------------------------------- 1 | import { PlayerId } from '@/players/types/player-id'; 2 | import { Prop, raw, Schema, SchemaFactory } from '@nestjs/mongoose'; 3 | import { Types } from 'mongoose'; 4 | 5 | @Schema() 6 | export class PlayerPreferences { 7 | @Prop({ type: Types.ObjectId, ref: 'Player', unique: true }) 8 | player?: PlayerId; 9 | 10 | @Prop( 11 | raw({ 12 | type: Map, 13 | of: String, 14 | }), 15 | ) 16 | preferences!: Map; 17 | } 18 | 19 | export const playerPreferencesSchema = 20 | SchemaFactory.createForClass(PlayerPreferences); 21 | -------------------------------------------------------------------------------- /src/player-preferences/player-preferences.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { 4 | PlayerPreferences, 5 | playerPreferencesSchema, 6 | } from './models/player-preferences'; 7 | import { PlayerPreferencesService } from './services/player-preferences.service'; 8 | 9 | @Module({ 10 | imports: [ 11 | MongooseModule.forFeature([ 12 | { 13 | name: PlayerPreferences.name, 14 | schema: playerPreferencesSchema, 15 | }, 16 | ]), 17 | ], 18 | providers: [PlayerPreferencesService], 19 | exports: [PlayerPreferencesService], 20 | }) 21 | export class PlayerPreferencesModule {} 22 | -------------------------------------------------------------------------------- /src/player-preferences/services/player-preferences.service.ts: -------------------------------------------------------------------------------- 1 | import { PlayerId } from '@/players/types/player-id'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { InjectModel } from '@nestjs/mongoose'; 4 | import { Model } from 'mongoose'; 5 | import { PlayerPreferences } from '../models/player-preferences'; 6 | 7 | export type PreferencesType = Map; 8 | 9 | @Injectable() 10 | export class PlayerPreferencesService { 11 | constructor( 12 | @InjectModel(PlayerPreferences.name) 13 | private playerPreferences: Model, 14 | ) {} 15 | 16 | async getPlayerPreferences(playerId: PlayerId): Promise { 17 | return ( 18 | (await this.playerPreferences.findOne({ player: playerId })) 19 | ?.preferences ?? new Map() 20 | ); 21 | } 22 | 23 | async updatePlayerPreferences( 24 | playerId: PlayerId, 25 | preferences: PreferencesType, 26 | ): Promise { 27 | const ret = await this.playerPreferences.findOneAndUpdate( 28 | { player: playerId }, 29 | { preferences }, 30 | { new: true, upsert: true }, 31 | ); 32 | return ret.preferences; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/players/controllers/hall-of-fame.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { GamesService } from '@/games/services/games.service'; 3 | 4 | @Controller('hall-of-fame') 5 | export class HallOfFameController { 6 | constructor(private gamesService: GamesService) {} 7 | 8 | @Get() 9 | async getHallOfFame() { 10 | const mostActivePlayers = await this.gamesService.getMostActivePlayers(); 11 | const mostActiveMedics = await this.gamesService.getMostActiveMedics(); 12 | return { mostActivePlayers, mostActiveMedics }; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/players/controllers/online-players.controller.ts: -------------------------------------------------------------------------------- 1 | import { Serializable } from '@/shared/serializable'; 2 | import { Controller, Get } from '@nestjs/common'; 3 | import { PlayerDto } from '../dto/player.dto'; 4 | import { OnlinePlayersService } from '../services/online-players.service'; 5 | import { PlayersService } from '../services/players.service'; 6 | 7 | @Controller('online-players') 8 | export class OnlinePlayersController { 9 | constructor( 10 | private onlinePlayersService: OnlinePlayersService, 11 | private playersService: PlayersService, 12 | ) {} 13 | 14 | @Get() 15 | async getOnlinePlayers(): Promise[]> { 16 | return await this.playersService.getManyById( 17 | ...this.onlinePlayersService.onlinePlayers, 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/players/controllers/player-skill-wrapper.ts: -------------------------------------------------------------------------------- 1 | import { Serializable } from '@/shared/serializable'; 2 | import { PlayerSkillDto } from '../dto/player-skill.dto'; 3 | import { Player } from '../models/player'; 4 | 5 | export class PlayerSkillWrapper extends Serializable { 6 | constructor(public readonly player: Player) { 7 | super(); 8 | } 9 | 10 | async serialize(): Promise { 11 | return { 12 | ...(await this.player.serialize()), 13 | skill: this.player.skill ? Object.fromEntries(this.player.skill) : {}, 14 | }; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/players/dto/add-player-ban.schema.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | import { z } from 'zod'; 3 | import { PlayerId } from '../types/player-id'; 4 | 5 | export const addPlayerBanSchema = z.object({ 6 | player: z 7 | .string() 8 | .refine((val) => Types.ObjectId.isValid(val), { 9 | message: 'player has to be a valid player id', 10 | }) 11 | .transform((val) => new Types.ObjectId(val) as PlayerId), 12 | admin: z 13 | .string() 14 | .refine((val) => Types.ObjectId.isValid(val), { 15 | message: 'admin has to be a valid player id', 16 | }) 17 | .transform((val) => new Types.ObjectId(val) as PlayerId), 18 | start: z 19 | .string() 20 | .datetime() 21 | .transform((val) => new Date(val)), 22 | end: z 23 | .string() 24 | .datetime() 25 | .transform((val) => new Date(val)), 26 | reason: z.string().optional(), 27 | }); 28 | -------------------------------------------------------------------------------- /src/players/dto/force-create-player.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const forceCreatePlayerSchema = z.object({ 4 | name: z.string(), 5 | steamId: z.string().regex(/^\d{17}$/), 6 | }); 7 | -------------------------------------------------------------------------------- /src/players/dto/import-skills-response.dto.ts: -------------------------------------------------------------------------------- 1 | export interface ImportSkillsResponseDto { 2 | noImported: number; 3 | } 4 | -------------------------------------------------------------------------------- /src/players/dto/linked-profiles.dto.ts: -------------------------------------------------------------------------------- 1 | import { LinkedProfile } from '../types/linked-profile'; 2 | 3 | export interface LinkedProfilesDto { 4 | playerId: string; 5 | linkedProfiles: LinkedProfile[]; 6 | } 7 | -------------------------------------------------------------------------------- /src/players/dto/player-ban.dto.ts: -------------------------------------------------------------------------------- 1 | export interface PlayerBanDto { 2 | id: string; 3 | player: string; 4 | admin: string; 5 | start: string; 6 | end: string; 7 | reason?: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/players/dto/player-skill.dto.ts: -------------------------------------------------------------------------------- 1 | import { Tf2ClassName } from '@/shared/models/tf2-class-name'; 2 | import { PlayerDto } from './player.dto'; 3 | 4 | export interface PlayerSkillDto extends PlayerDto { 5 | skill: { [gameClass in Tf2ClassName]?: number }; 6 | } 7 | -------------------------------------------------------------------------------- /src/players/dto/player-stats.dto.ts: -------------------------------------------------------------------------------- 1 | import { Tf2ClassName } from '@/shared/models/tf2-class-name'; 2 | 3 | export interface PlayerStatsDto { 4 | player: string; 5 | gamesPlayed: number; 6 | classesPlayed: { [gameClass in Tf2ClassName]?: number }; 7 | } 8 | -------------------------------------------------------------------------------- /src/players/dto/player.dto.ts: -------------------------------------------------------------------------------- 1 | export interface PlayerDto { 2 | id: string; 3 | name: string; 4 | steamId: string; 5 | joinedAt: string; 6 | avatar: { 7 | small?: string; 8 | medium?: string; 9 | large?: string; 10 | }; 11 | roles: ('super user' | 'admin' | 'bot')[]; 12 | // TODO v12: remove 13 | etf2lProfileId?: number; 14 | _links: { 15 | href: string; 16 | title?: string; 17 | }[]; 18 | 19 | // TODO v12: remove 20 | gamesPlayed: number; 21 | } 22 | -------------------------------------------------------------------------------- /src/players/dto/update-player.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { PlayerRole } from '../models/player-role'; 3 | 4 | export const updatePlayerSchema = z.object({ 5 | name: z.string().optional(), 6 | avatar: z 7 | .object({ 8 | small: z.string(), 9 | medium: z.string(), 10 | large: z.string(), 11 | }) 12 | .optional(), 13 | roles: z.array(z.nativeEnum(PlayerRole)).optional(), 14 | }); 15 | -------------------------------------------------------------------------------- /src/players/errors/account-banned.error.ts: -------------------------------------------------------------------------------- 1 | export class AccountBannedError extends Error { 2 | constructor(steamId: string) { 3 | super(`account is banned on ETF2L (steamId: ${steamId})`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/players/errors/insufficient-tf2-in-game-hours.error.ts: -------------------------------------------------------------------------------- 1 | export class InsufficientTf2InGameHoursError extends Error { 2 | constructor( 3 | public steamId: string, 4 | public requiredHours: number, 5 | public reportedHours: number, 6 | ) { 7 | super( 8 | `insufficient TF2 in-game hours (steamId: ${steamId}, reported: ${reportedHours}, required: ${requiredHours})`, 9 | ); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/players/errors/player-name-taken.error.ts: -------------------------------------------------------------------------------- 1 | type Service = 'Steam' | 'ETF2L'; 2 | 3 | export class PlayerNameTakenError extends Error { 4 | constructor( 5 | name: string, 6 | readonly service: Service, 7 | ) { 8 | super(`${service} name '${name}' is already taken`); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/players/errors/player-skill-record-malformed.error.ts: -------------------------------------------------------------------------------- 1 | export class PlayerSkillRecordMalformedError extends Error { 2 | constructor(public readonly expectedSize: number) { 3 | super(`invalid record size (expected: ${expectedSize})`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/players/models/future-player-skill.ts: -------------------------------------------------------------------------------- 1 | import { Tf2ClassName } from '@/shared/models/tf2-class-name'; 2 | import { MongooseDocument } from '@/utils/mongoose-document'; 3 | import { Prop, raw, Schema, SchemaFactory } from '@nestjs/mongoose'; 4 | import { Type } from 'class-transformer'; 5 | 6 | /** 7 | * Imported skills for players that have not registered their account yet. 8 | */ 9 | @Schema() 10 | export class FuturePlayerSkill extends MongooseDocument { 11 | @Prop({ required: true, unique: true }) 12 | steamId!: string; 13 | 14 | @Type(() => Number) 15 | @Prop( 16 | raw({ 17 | type: Map, 18 | of: Number, 19 | }), 20 | ) 21 | skill!: Map; 22 | } 23 | 24 | export const futurePlayerSkillSchema = 25 | SchemaFactory.createForClass(FuturePlayerSkill); 26 | -------------------------------------------------------------------------------- /src/players/models/player-avatar.ts: -------------------------------------------------------------------------------- 1 | import { MongooseDocument } from '@/utils/mongoose-document'; 2 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 3 | 4 | @Schema() 5 | export class PlayerAvatar extends MongooseDocument { 6 | @Prop() 7 | small!: string; // 32x32 px 8 | 9 | @Prop() 10 | medium!: string; // 64x64 px 11 | 12 | @Prop() 13 | large!: string; // 184x184 px 14 | } 15 | 16 | export const playerAvatarSchema = SchemaFactory.createForClass(PlayerAvatar); 17 | -------------------------------------------------------------------------------- /src/players/models/player-role.ts: -------------------------------------------------------------------------------- 1 | export enum PlayerRole { 2 | superUser = 'super user', 3 | admin = 'admin', 4 | bot = 'bot', 5 | } 6 | -------------------------------------------------------------------------------- /src/players/services/import-export-skill.service.ts: -------------------------------------------------------------------------------- 1 | import { QueueConfig } from '@/queue-config/types/queue-config'; 2 | import { Inject, Injectable } from '@nestjs/common'; 3 | import { PlayerSkillRecordMalformedError } from '../errors/player-skill-record-malformed.error'; 4 | import { FuturePlayerSkillService } from './future-player-skill.service'; 5 | import { QUEUE_CONFIG } from '@/queue-config/tokens/queue-config.token'; 6 | 7 | @Injectable() 8 | export class ImportExportSkillService { 9 | private readonly expectedRecordLength = this.queueConfig.classes.length + 1; 10 | 11 | constructor( 12 | private readonly futurePlayerSkillService: FuturePlayerSkillService, 13 | @Inject(QUEUE_CONFIG) private readonly queueConfig: QueueConfig, 14 | ) {} 15 | 16 | async importRawSkillRecord(record: string[]) { 17 | if (record.length !== this.expectedRecordLength) { 18 | throw new PlayerSkillRecordMalformedError(this.expectedRecordLength); 19 | } 20 | 21 | const steamId64 = record[0]; 22 | const skills = new Map( 23 | this.queueConfig.classes 24 | .map((gameClass) => gameClass.name) 25 | .map((name, i) => [name, parseInt(record[i + 1], 10)]), 26 | ); 27 | await this.futurePlayerSkillService.registerSkill(steamId64, skills); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/players/services/players-configuration.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurationService } from '@/configuration/services/configuration.service'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { PlayersConfigurationService } from './players-configuration.service'; 4 | 5 | jest.mock('@/configuration/services/configuration.service'); 6 | 7 | describe('PlayersConfigurationService', () => { 8 | let service: PlayersConfigurationService; 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | providers: [PlayersConfigurationService, ConfigurationService], 13 | }).compile(); 14 | 15 | service = module.get( 16 | PlayersConfigurationService, 17 | ); 18 | }); 19 | 20 | it('should be defined', () => { 21 | expect(service).toBeDefined(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/players/services/players-configuration.service.ts: -------------------------------------------------------------------------------- 1 | import { configurationEntry } from '@/configuration/configuration-entry'; 2 | import { ConfigurationService } from '@/configuration/services/configuration.service'; 3 | import { Injectable, OnModuleInit } from '@nestjs/common'; 4 | import { z } from 'zod'; 5 | 6 | @Injectable() 7 | export class PlayersConfigurationService implements OnModuleInit { 8 | constructor(private readonly configurationService: ConfigurationService) {} 9 | 10 | onModuleInit() { 11 | this.configurationService.register( 12 | configurationEntry('players.etf2l_account_required', z.boolean(), false), 13 | configurationEntry('players.minimum_in_game_hours', z.number(), 0), 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/players/steam-profile.ts: -------------------------------------------------------------------------------- 1 | export type SteamProfilePhotos = { value: string }[]; 2 | 3 | export interface SteamProfile { 4 | provider: 'steam'; 5 | id: string; 6 | displayName: string; 7 | photos: SteamProfilePhotos; 8 | } 9 | -------------------------------------------------------------------------------- /src/players/types/linked-profile-provider-name.ts: -------------------------------------------------------------------------------- 1 | export type LinkedProfileProviderName = 'twitch.tv'; 2 | -------------------------------------------------------------------------------- /src/players/types/linked-profile.ts: -------------------------------------------------------------------------------- 1 | import { LinkedProfileProviderName } from './linked-profile-provider-name'; 2 | 3 | export interface LinkedProfile { 4 | provider: LinkedProfileProviderName; 5 | } 6 | -------------------------------------------------------------------------------- /src/players/types/player-ban-id.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | declare const _playerBanId: unique symbol; 4 | 5 | export type PlayerBanId = Types.ObjectId & { [_playerBanId]: never }; 6 | -------------------------------------------------------------------------------- /src/players/types/player-id.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | declare const _playerId: unique symbol; 4 | 5 | export type PlayerId = Types.ObjectId & { [_playerId]: never }; 6 | -------------------------------------------------------------------------------- /src/plugins/discord/controllers/discord.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { DiscordController } from './discord.controller'; 3 | import { DiscordService } from '../services/discord.service'; 4 | 5 | jest.mock('../services/discord.service'); 6 | 7 | describe('DiscordController', () => { 8 | let controller: DiscordController; 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | controllers: [DiscordController], 13 | providers: [DiscordService], 14 | }).compile(); 15 | 16 | controller = module.get(DiscordController); 17 | }); 18 | 19 | it('should be defined', () => { 20 | expect(controller).toBeDefined(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/plugins/discord/controllers/discord.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param } from '@nestjs/common'; 2 | import { DiscordService } from '../services/discord.service'; 3 | 4 | @Controller('discord') 5 | export class DiscordController { 6 | constructor(private readonly discordService: DiscordService) {} 7 | 8 | @Get('guilds') 9 | getGuilds() { 10 | return this.discordService 11 | .getGuilds() 12 | .map((guild) => ({ id: guild.id, name: guild.name })); 13 | } 14 | 15 | @Get('guilds/:id/text-channels') 16 | getTextChannels(@Param('id') guildId: string) { 17 | return this.discordService.getTextChannels(guildId)?.map((channel) => ({ 18 | id: channel.id, 19 | name: channel.name, 20 | position: channel.position, 21 | parent: channel.parent?.name, 22 | })); 23 | } 24 | 25 | @Get('guilds/:id/roles') 26 | getRoles(@Param('id') guildId: string) { 27 | return this.discordService.getRoles(guildId).map((role) => ({ 28 | id: role.id, 29 | name: role.name, 30 | position: role.position, 31 | })); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/plugins/discord/discord-client.token.ts: -------------------------------------------------------------------------------- 1 | export const DISCORD_CLIENT = 'DISCORD_CLIENT'; 2 | -------------------------------------------------------------------------------- /src/plugins/discord/notifications/colors.ts: -------------------------------------------------------------------------------- 1 | export enum Colors { 2 | PlayerBanAdded = '#dc3545', 3 | PlayerBanRevoked = '#9838dc', 4 | NewPlayer = '#33dc7f', 5 | PlayerProfileUpdated = '#5230dc', 6 | SubstituteRequest = '#ff557f', 7 | SkillChanged = '#ff953e', 8 | QueuePreview = '#f9f9f9', 9 | GameServerAdded = '#d3ffa9', 10 | GameServerRemoved = '#ffa6a7', 11 | GameForceEnded = '#ff5e61', 12 | SubstituteRequested = '#f1ff70', 13 | MapsScrambled = '#aaffff', 14 | } 15 | -------------------------------------------------------------------------------- /src/plugins/discord/notifications/game-force-ended.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js'; 2 | import { Colors } from './colors'; 3 | 4 | interface GameForceEndedOptions { 5 | admin: { 6 | name: string; 7 | profileUrl: string; 8 | avatarUrl?: string; 9 | }; 10 | client: { 11 | name: string; 12 | iconUrl: string; 13 | }; 14 | game: { 15 | number: string; 16 | url: string; 17 | }; 18 | } 19 | 20 | export const gameForceEnded = (options: GameForceEndedOptions) => 21 | new EmbedBuilder() 22 | .setColor(Colors.GameForceEnded) 23 | .setAuthor({ 24 | name: options.admin.name, 25 | iconURL: options.admin.avatarUrl, 26 | url: options.admin.profileUrl, 27 | }) 28 | .setTitle('Game force-ended') 29 | .setDescription( 30 | `Game number: **[${options.game.number}](${options.game.url})**`, 31 | ) 32 | .setFooter({ 33 | text: options.client.name, 34 | iconURL: options.client.iconUrl, 35 | }) 36 | .setTimestamp(); 37 | -------------------------------------------------------------------------------- /src/plugins/discord/notifications/game-server-added.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js'; 2 | import { Colors } from './colors'; 3 | 4 | interface GameServerAddedOptions { 5 | gameServer: { 6 | name: string; 7 | address: string; 8 | port: string; 9 | }; 10 | client: { 11 | name: string; 12 | iconUrl: string; 13 | }; 14 | } 15 | 16 | export const gameServerAdded = (options: GameServerAddedOptions) => 17 | new EmbedBuilder() 18 | .setColor(Colors.GameServerAdded) 19 | .setTitle('Game server added') 20 | .setDescription( 21 | [ 22 | `Name: **${options.gameServer.name}**`, 23 | `Address: **${options.gameServer.address}:${options.gameServer.port}**`, 24 | ].join('\n'), 25 | ) 26 | .setFooter({ 27 | text: options.client.name, 28 | iconURL: options.client.iconUrl, 29 | }) 30 | .setTimestamp(); 31 | -------------------------------------------------------------------------------- /src/plugins/discord/notifications/game-server-offline.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js'; 2 | import { Colors } from './colors'; 3 | 4 | interface GameServerOfflineOptions { 5 | gameServer: { 6 | name: string; 7 | address: string; 8 | port: string; 9 | }; 10 | client: { 11 | name: string; 12 | iconUrl: string; 13 | }; 14 | } 15 | 16 | export const gameServerOffline = (options: GameServerOfflineOptions) => 17 | new EmbedBuilder() 18 | .setColor(Colors.GameServerRemoved) 19 | .setTitle('Game server is offline') 20 | .setDescription( 21 | [ 22 | `Name: **${options.gameServer.name}**`, 23 | `Address: **${options.gameServer.address}:${options.gameServer.port}**`, 24 | ].join('\n'), 25 | ) 26 | .setFooter({ 27 | text: options.client.name, 28 | iconURL: options.client.iconUrl, 29 | }) 30 | .setTimestamp(); 31 | -------------------------------------------------------------------------------- /src/plugins/discord/notifications/game-server-online.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js'; 2 | import { Colors } from './colors'; 3 | 4 | interface GameServerOnlineOptions { 5 | gameServer: { 6 | name: string; 7 | address: string; 8 | port: string; 9 | }; 10 | client: { 11 | name: string; 12 | iconUrl: string; 13 | }; 14 | } 15 | 16 | export const gameServerOnline = (options: GameServerOnlineOptions) => 17 | new EmbedBuilder() 18 | .setColor(Colors.GameServerAdded) 19 | .setTitle('Game server is back online') 20 | .setDescription( 21 | [ 22 | `Name: **${options.gameServer.name}**`, 23 | `Address: **${options.gameServer.address}:${options.gameServer.port}**`, 24 | ].join('\n'), 25 | ) 26 | .setFooter({ 27 | text: options.client.name, 28 | iconURL: options.client.iconUrl, 29 | }) 30 | .setTimestamp(); 31 | -------------------------------------------------------------------------------- /src/plugins/discord/notifications/index.ts: -------------------------------------------------------------------------------- 1 | export { playerBanAdded } from './player-ban-added'; 2 | export { playerBanRevoked } from './player-ban-revoked'; 3 | export { newPlayer } from './new-player'; 4 | export { substituteRequest } from './substitute-request'; 5 | export { playerSkillChanged } from './player-skill-changed'; 6 | export { queuePreview } from './queue-preview'; 7 | export { playerProfileUpdated } from './player-profile-updated'; 8 | export { gameServerAdded } from './game-server-added'; 9 | export { gameServerOffline } from './game-server-offline'; 10 | export { gameForceEnded } from './game-force-ended'; 11 | export { gameServerOnline } from './game-server-online'; 12 | -------------------------------------------------------------------------------- /src/plugins/discord/notifications/maps-scrambled.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js'; 2 | import { Colors } from './colors'; 3 | 4 | interface MapsScrambledOptions { 5 | actor: { 6 | name: string; 7 | profileUrl: string; 8 | avatarUrl?: string; 9 | }; 10 | client: { 11 | name: string; 12 | iconUrl: string; 13 | }; 14 | } 15 | 16 | export const mapsScrambled = (options: MapsScrambledOptions) => 17 | new EmbedBuilder() 18 | .setColor(Colors.MapsScrambled) 19 | .setAuthor({ 20 | name: options.actor.name, 21 | iconURL: options.actor.avatarUrl, 22 | url: options.actor.profileUrl, 23 | }) 24 | .setTitle('Maps scrambled') 25 | .setThumbnail(options.actor.avatarUrl ?? null) 26 | .setFooter({ 27 | text: options.client.name, 28 | iconURL: options.client.iconUrl, 29 | }) 30 | .setTimestamp(); 31 | -------------------------------------------------------------------------------- /src/plugins/discord/notifications/new-player.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js'; 2 | import { Colors } from './colors'; 3 | 4 | interface NewPlayerOptions { 5 | name: string; 6 | profileUrl: string; 7 | } 8 | 9 | export const newPlayer = (options: NewPlayerOptions): EmbedBuilder => 10 | new EmbedBuilder() 11 | .setColor(Colors.NewPlayer) 12 | .setTitle('New player') 13 | .addFields({ 14 | name: 'Name', 15 | value: options.name, 16 | }) 17 | .setURL(options.profileUrl) 18 | .setTimestamp(); 19 | -------------------------------------------------------------------------------- /src/plugins/discord/notifications/no-free-game-servers-available.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js'; 2 | 3 | interface NoFreeGameServersAvailableOptions { 4 | game: { 5 | number: string; 6 | url: string; 7 | }; 8 | client: { 9 | name: string; 10 | iconUrl: string; 11 | }; 12 | } 13 | 14 | export const noFreeGameServersAvailable = ( 15 | options: NoFreeGameServersAvailableOptions, 16 | ): EmbedBuilder => { 17 | return new EmbedBuilder() 18 | .setTitle('No free game servers available') 19 | .setColor('#ff0000') 20 | .setDescription( 21 | `Game number: **[${options.game.number}](${options.game.url})**`, 22 | ) 23 | .setFooter({ 24 | text: options.client.name, 25 | iconURL: options.client.iconUrl, 26 | }) 27 | .setTimestamp(); 28 | }; 29 | -------------------------------------------------------------------------------- /src/plugins/discord/notifications/player-ban-revoked.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js'; 2 | import { Colors } from './colors'; 3 | 4 | interface PlayerBanRevokedOptions { 5 | admin: { 6 | name: string; 7 | profileUrl: string; 8 | avatarUrl?: string; 9 | }; 10 | player: { 11 | name: string; 12 | profileUrl: string; 13 | avatarUrl?: string; 14 | }; 15 | client: { 16 | name: string; 17 | iconUrl: string; 18 | }; 19 | reason?: string; 20 | } 21 | 22 | export const playerBanRevoked = (options: PlayerBanRevokedOptions) => 23 | new EmbedBuilder() 24 | .setColor(Colors.PlayerBanRevoked) 25 | .setAuthor({ 26 | name: options.admin.name, 27 | iconURL: options.admin.avatarUrl, 28 | url: options.admin.profileUrl, 29 | }) 30 | .setTitle('Player ban revoked') 31 | .setThumbnail(options.player.avatarUrl ?? null) 32 | .setDescription( 33 | [ 34 | `Player: **[${options.player.name}](${options.player.profileUrl})**`, 35 | `Reason: ${options.reason ? `**${options.reason}**` : '__no reason__'}`, 36 | ].join('\n'), 37 | ) 38 | .setFooter({ 39 | text: options.client.name, 40 | iconURL: options.client.iconUrl, 41 | }) 42 | .setTimestamp(); 43 | -------------------------------------------------------------------------------- /src/plugins/discord/notifications/substitute-request.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js'; 2 | import { Colors } from './colors'; 3 | 4 | interface SubstituteRequestOptions { 5 | gameNumber: number; 6 | gameClass: string; 7 | team: string; 8 | gameUrl: string; 9 | } 10 | 11 | export const substituteRequest = (options: SubstituteRequestOptions) => 12 | new EmbedBuilder() 13 | .setColor(Colors.SubstituteRequest) 14 | .setTitle('A substitute is needed') 15 | .addFields( 16 | { 17 | name: 'Game no.', 18 | value: `#${options.gameNumber}`, 19 | }, 20 | { 21 | name: 'Class', 22 | value: options.gameClass, 23 | }, 24 | { 25 | name: 'Team', 26 | value: options.team, 27 | }, 28 | ) 29 | .setURL(options.gameUrl ?? null) 30 | .setTimestamp(); 31 | -------------------------------------------------------------------------------- /src/plugins/discord/notifications/substitute-requested.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js'; 2 | import { Colors } from './colors'; 3 | 4 | interface SubstituteRequestedOptions { 5 | player: { 6 | name: string; 7 | profileUrl: string; 8 | }; 9 | admin: { 10 | name: string; 11 | profileUrl: string; 12 | avatarUrl?: string; 13 | }; 14 | game: { 15 | number: string; 16 | url: string; 17 | }; 18 | client: { 19 | name: string; 20 | iconUrl: string; 21 | }; 22 | } 23 | 24 | export const substituteRequested = (options: SubstituteRequestedOptions) => 25 | new EmbedBuilder() 26 | .setColor(Colors.SubstituteRequested) 27 | .setAuthor({ 28 | name: options.admin.name, 29 | iconURL: options.admin.avatarUrl, 30 | url: options.admin.profileUrl, 31 | }) 32 | .setTitle('Substitute requested') 33 | .setDescription( 34 | `Game number: **[${options.game.number}](${options.game.url})**\nPlayer: **[${options.player.name}](${options.player.profileUrl})**`, 35 | ) 36 | .setFooter({ 37 | text: options.client.name, 38 | iconURL: options.client.iconUrl, 39 | }) 40 | .setTimestamp(); 41 | -------------------------------------------------------------------------------- /src/plugins/discord/notifications/substitute-was-needed.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js'; 2 | import { Colors } from './colors'; 3 | 4 | interface SubstituteWasNeededOptions { 5 | gameNumber: number; 6 | gameUrl: string; 7 | } 8 | 9 | export const substituteWasNeeded = (options: SubstituteWasNeededOptions) => 10 | new EmbedBuilder() 11 | .setColor(Colors.SubstituteRequest) 12 | .setTitle('A substitute was needed') 13 | .addFields({ 14 | name: 'Game no.', 15 | value: `#${options.gameNumber}`, 16 | }) 17 | .setURL(options.gameUrl ?? null) 18 | .setTimestamp(); 19 | -------------------------------------------------------------------------------- /src/plugins/discord/player-changes.ts: -------------------------------------------------------------------------------- 1 | export type PlayerChanges = Record; 2 | -------------------------------------------------------------------------------- /src/plugins/discord/services/discord-configuration.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { DiscordConfigurationService } from './discord-configuration.service'; 3 | import { ConfigurationService } from '@/configuration/services/configuration.service'; 4 | 5 | jest.mock('@/configuration/services/configuration.service'); 6 | 7 | describe('DiscordConfigurationService', () => { 8 | let service: DiscordConfigurationService; 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | providers: [DiscordConfigurationService, ConfigurationService], 13 | }).compile(); 14 | 15 | service = module.get( 16 | DiscordConfigurationService, 17 | ); 18 | }); 19 | 20 | it('should be defined', () => { 21 | expect(service).toBeDefined(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/plugins/discord/services/discord-configuration.service.ts: -------------------------------------------------------------------------------- 1 | import { configurationEntry } from '@/configuration/configuration-entry'; 2 | import { ConfigurationService } from '@/configuration/services/configuration.service'; 3 | import { Injectable, OnModuleInit } from '@nestjs/common'; 4 | import { z } from 'zod'; 5 | import { guildConfigurationSchema } from '../types/guild-configuration'; 6 | 7 | @Injectable() 8 | export class DiscordConfigurationService implements OnModuleInit { 9 | constructor(private readonly configurationService: ConfigurationService) {} 10 | 11 | onModuleInit() { 12 | this.configurationService.register( 13 | configurationEntry( 14 | 'discord.guilds', 15 | z.array(guildConfigurationSchema), 16 | [], 17 | ), 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/plugins/discord/services/discord.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { ChannelType, Client, Role, TextChannel } from 'discord.js'; 3 | import { DISCORD_CLIENT } from '../discord-client.token'; 4 | 5 | @Injectable() 6 | export class DiscordService { 7 | constructor(@Inject(DISCORD_CLIENT) private readonly client: Client) {} 8 | 9 | getGuilds() { 10 | return this.client.guilds.cache; 11 | } 12 | 13 | getTextChannels(guildId: string): TextChannel[] { 14 | return Array.from( 15 | ( 16 | this.client.guilds.cache 17 | .get(guildId) 18 | ?.channels.cache.filter( 19 | (channel) => channel.type === ChannelType.GuildText, 20 | ) as Map 21 | ).values() ?? [], 22 | ); 23 | } 24 | 25 | getRoles(guildId: string): Role[] { 26 | return Array.from( 27 | this.client.guilds.resolve(guildId)?.roles.cache.values() ?? [], 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/plugins/discord/types/guild-configuration.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const guildConfigurationSchema = z.object({ 4 | id: z.string(), 5 | substituteNotifications: z 6 | .object({ 7 | channel: z.string(), 8 | role: z.string().optional(), 9 | }) 10 | .optional(), 11 | queuePrompts: z 12 | .object({ 13 | channel: z.string(), 14 | bumpPlayerThresholdRatio: z.number(), 15 | }) 16 | .optional(), 17 | adminNotifications: z 18 | .object({ 19 | channel: z.string(), 20 | }) 21 | .optional(), 22 | }); 23 | 24 | export type GuildConfiguration = z.infer; 25 | -------------------------------------------------------------------------------- /src/plugins/discord/utils/extract-player-changes.ts: -------------------------------------------------------------------------------- 1 | import { Player } from '@/players/models/player'; 2 | import { PlayerChanges } from '../player-changes'; 3 | 4 | export const extractPlayerChanges = (oldPlayer: Player, newPlayer: Player) => { 5 | const changes: PlayerChanges = {}; 6 | 7 | if (oldPlayer.name !== newPlayer.name) { 8 | changes.name = { old: oldPlayer.name, new: newPlayer.name }; 9 | } 10 | 11 | const [oldRoles, newRoles] = [oldPlayer, newPlayer].map((player) => 12 | player.roles.join(', '), 13 | ); 14 | 15 | if (oldRoles !== newRoles) { 16 | changes.role = { old: oldRoles, new: newRoles }; 17 | } 18 | 19 | return changes; 20 | }; 21 | -------------------------------------------------------------------------------- /src/plugins/plugins.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Module } from '@nestjs/common'; 2 | import { DiscordModule } from './discord/discord.module'; 3 | import { TwitchModule } from './twitch/twitch.module'; 4 | 5 | const discordModule = () => 6 | process.env.DISCORD_BOT_TOKEN ? [DiscordModule] : []; 7 | const twitchModule = () => 8 | process.env.TWITCH_CLIENT_ID && process.env.TWITCH_CLIENT_SECRET 9 | ? [TwitchModule] 10 | : []; 11 | 12 | @Module({}) 13 | export class PluginsModule { 14 | static configure(): DynamicModule { 15 | return { 16 | module: PluginsModule, 17 | imports: [...discordModule(), ...twitchModule()], 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/plugins/twitch/gateways/twitch.gateway.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { TwitchGateway } from './twitch.gateway'; 3 | 4 | class SocketStub { 5 | emit(ev: string, ...args: any[]) { 6 | return null; 7 | } 8 | } 9 | 10 | describe('TwitchGateway', () => { 11 | let gateway: TwitchGateway; 12 | let socket: SocketStub; 13 | 14 | beforeEach(async () => { 15 | const module: TestingModule = await Test.createTestingModule({ 16 | providers: [TwitchGateway], 17 | }).compile(); 18 | 19 | gateway = module.get(TwitchGateway); 20 | }); 21 | 22 | beforeEach(() => { 23 | socket = new SocketStub(); 24 | gateway.afterInit(socket as any); 25 | }); 26 | 27 | it('should be defined', () => { 28 | expect(gateway).toBeDefined(); 29 | }); 30 | 31 | describe('#emitStreamsUpdate()', () => { 32 | it('should emit the event through the socket', () => { 33 | const spy = jest.spyOn(socket, 'emit'); 34 | gateway.emitStreamsUpdate([]); 35 | expect(spy).toHaveBeenCalledWith('twitch streams update', []); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/plugins/twitch/gateways/twitch.gateway.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketGateway, OnGatewayInit } from '@nestjs/websockets'; 2 | import { Socket } from 'socket.io'; 3 | import { TwitchStream } from '../models/twitch-stream'; 4 | 5 | @WebSocketGateway() 6 | export class TwitchGateway implements OnGatewayInit { 7 | private socket?: Socket; 8 | 9 | emitStreamsUpdate(streams: TwitchStream[]) { 10 | this.socket?.emit('twitch streams update', streams); 11 | } 12 | 13 | afterInit(socket: Socket) { 14 | this.socket = socket; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/plugins/twitch/models/twitch-stream.ts: -------------------------------------------------------------------------------- 1 | export interface TwitchStream { 2 | playerId?: string; 3 | id: string; 4 | userName: string; 5 | title: string; 6 | thumbnailUrl: string; 7 | viewerCount: number; 8 | } 9 | -------------------------------------------------------------------------------- /src/plugins/twitch/models/twitch-tv-profile.ts: -------------------------------------------------------------------------------- 1 | import { PlayerId } from '@/players/types/player-id'; 2 | import { TransformObjectId } from '@/shared/decorators/transform-object-id'; 3 | import { MongooseDocument } from '@/utils/mongoose-document'; 4 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 5 | import { Types } from 'mongoose'; 6 | 7 | @Schema() 8 | export class TwitchTvProfile extends MongooseDocument { 9 | @TransformObjectId() 10 | @Prop({ type: Types.ObjectId, ref: 'Player', index: true, required: true }) 11 | player!: PlayerId; 12 | 13 | @Prop({ required: true, index: true }) 14 | userId!: string; 15 | 16 | @Prop({ required: true }) 17 | login!: string; 18 | 19 | @Prop() 20 | displayName?: string; 21 | 22 | @Prop() 23 | profileImageUrl?: string; 24 | } 25 | 26 | export const twitchTvProfileSchema = 27 | SchemaFactory.createForClass(TwitchTvProfile); 28 | -------------------------------------------------------------------------------- /src/plugins/twitch/services/twitch-tv-configuration.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurationService } from '@/configuration/services/configuration.service'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { TwitchTvConfigurationService } from './twitch-tv-configuration.service'; 4 | 5 | jest.mock('@/configuration/services/configuration.service'); 6 | 7 | describe('TwitchTvConfigurationService', () => { 8 | let service: TwitchTvConfigurationService; 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | providers: [TwitchTvConfigurationService, ConfigurationService], 13 | }).compile(); 14 | 15 | service = module.get( 16 | TwitchTvConfigurationService, 17 | ); 18 | }); 19 | 20 | it('should be defined', () => { 21 | expect(service).toBeDefined(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/plugins/twitch/services/twitch-tv-configuration.service.ts: -------------------------------------------------------------------------------- 1 | import { configurationEntry } from '@/configuration/configuration-entry'; 2 | import { ConfigurationService } from '@/configuration/services/configuration.service'; 3 | import { Injectable, OnModuleInit } from '@nestjs/common'; 4 | import { z } from 'zod'; 5 | 6 | @Injectable() 7 | export class TwitchTvConfigurationService implements OnModuleInit { 8 | constructor(private readonly configurationService: ConfigurationService) {} 9 | 10 | onModuleInit() { 11 | this.configurationService.register( 12 | configurationEntry('twitchtv.promoted_streams', z.array(z.string()), [ 13 | 'teamfortresstv', 14 | 'teamfortresstv2', 15 | 'teamfortresstv3', 16 | 'kritzkast', 17 | 'kritzkast2', 18 | 'rglgg', 19 | 'essentialstf', 20 | 'cappingtv', 21 | 'cappingtv2', 22 | 'cappingtv3', 23 | 'tflivetv', 24 | ]), 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/plugins/twitch/types/twitch-tv-get-streams-response.ts: -------------------------------------------------------------------------------- 1 | export interface TwitchTvGetStreamsResponse { 2 | data: { 3 | game_id: string; 4 | game_name: string; 5 | id: string; 6 | language: string; 7 | pagination: string; 8 | started_at: string; 9 | thumbnail_url: string; 10 | title: string; 11 | type: 'live' | ''; 12 | user_id: string; 13 | user_login: string; 14 | user_name: string; 15 | viewer_count: number; 16 | is_mature: boolean; 17 | }[]; 18 | } 19 | -------------------------------------------------------------------------------- /src/plugins/twitch/types/twitch-tv-get-users-response.ts: -------------------------------------------------------------------------------- 1 | export interface TwitchTvGetUsersResponse { 2 | data: { 3 | broadcaster_type: string; 4 | description: string; 5 | display_name: string; 6 | email: string; 7 | id: string; 8 | login: string; 9 | offline_image_url: string; 10 | profile_image_url: string; 11 | type: string; 12 | view_count: number; 13 | }[]; 14 | } 15 | -------------------------------------------------------------------------------- /src/plugins/twitch/utils/split-to-chunks.spec.ts: -------------------------------------------------------------------------------- 1 | import { splitToChunks } from './split-to-chunks'; 2 | 3 | it('should split array of numbers to chunks', () => { 4 | const array = [1, 2, 3, 4, 5, 6, 7, 8, 9]; 5 | const chunkSize = 3; 6 | const result = splitToChunks(array, chunkSize); 7 | expect(result).toEqual([ 8 | [1, 2, 3], 9 | [4, 5, 6], 10 | [7, 8, 9], 11 | ]); 12 | }); 13 | 14 | describe('when an array length is smaller than chunks size', () => { 15 | it('should return one chunk', () => { 16 | const array = [1, 2]; 17 | const chunkSize = 3; 18 | const result = splitToChunks(array, chunkSize); 19 | expect(result).toEqual([[1, 2]]); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/plugins/twitch/utils/split-to-chunks.ts: -------------------------------------------------------------------------------- 1 | export function splitToChunks(array: T[], chunkSize: number): T[][] { 2 | const chunks = []; 3 | for (let i = 0; i < array.length; i += chunkSize) { 4 | chunks.push(array.slice(i, i + chunkSize)); 5 | } 6 | return chunks; 7 | } 8 | -------------------------------------------------------------------------------- /src/profile/dto/profile.dto.ts: -------------------------------------------------------------------------------- 1 | import { PlayerBanDto } from '@/players/dto/player-ban.dto'; 2 | import { PlayerDto } from '@/players/dto/player.dto'; 3 | import { LinkedProfile } from '@/players/types/linked-profile'; 4 | import { Serializable } from '@/shared/serializable'; 5 | import { Restriction } from '../interfaces/restriction'; 6 | 7 | export interface ProfileDto { 8 | player: Serializable; 9 | hasAcceptedRules: boolean; 10 | activeGameId?: string; 11 | bans: Serializable[]; 12 | mapVote?: string; 13 | preferences: Record; 14 | linkedProfiles: LinkedProfile[]; 15 | 16 | // list of restrictions for this players 17 | restrictions: Restriction[]; 18 | } 19 | -------------------------------------------------------------------------------- /src/profile/interfaces/restriction.ts: -------------------------------------------------------------------------------- 1 | import { Tf2ClassName } from '@/shared/models/tf2-class-name'; 2 | 3 | export enum RestrictionReason { 4 | accountNeedsReview = 'account needs review', 5 | playerSkillBelowThreshold = 'player skill is below the threshold', 6 | } 7 | 8 | export interface Restriction { 9 | reason: RestrictionReason; 10 | gameClasses?: Tf2ClassName[]; // list of classes this player is restricted from playing 11 | } 12 | -------------------------------------------------------------------------------- /src/profile/profile.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ProfileController } from './controllers/profile.controller'; 3 | import { AuthModule } from '@/auth/auth.module'; 4 | import { PlayersModule } from '@/players/players.module'; 5 | import { GamesModule } from '@/games/games.module'; 6 | import { QueueModule } from '@/queue/queue.module'; 7 | import { PlayerPreferencesModule } from '@/player-preferences/player-preferences.module'; 8 | import { ProfileService } from './services/profile.service'; 9 | import { ConfigurationModule } from '@/configuration/configuration.module'; 10 | import { QueueConfigModule } from '@/queue-config/queue-config.module'; 11 | 12 | @Module({ 13 | imports: [ 14 | AuthModule, 15 | GamesModule, 16 | PlayersModule, 17 | QueueModule, 18 | PlayerPreferencesModule, 19 | ConfigurationModule, 20 | QueueConfigModule, 21 | ], 22 | controllers: [ProfileController], 23 | providers: [ProfileService], 24 | }) 25 | export class ProfileModule {} 26 | -------------------------------------------------------------------------------- /src/queue-config/schemas/queue-config.schema.ts: -------------------------------------------------------------------------------- 1 | import { Tf2ClassName } from '@/shared/models/tf2-class-name'; 2 | import { z } from 'zod'; 3 | 4 | export const queueConfigSchema = z.object({ 5 | teamCount: z.literal(2), 6 | classes: z.array( 7 | z.object({ 8 | name: z.nativeEnum(Tf2ClassName), 9 | count: z.number().gte(1), 10 | canMakeFriendsWith: z.array(z.nativeEnum(Tf2ClassName)).optional(), 11 | }), 12 | ), 13 | }); 14 | -------------------------------------------------------------------------------- /src/queue-config/tokens/queue-config-json.token.ts: -------------------------------------------------------------------------------- 1 | export const QUEUE_CONFIG_JSON = 'QUEUE_CONFIG_JSON'; 2 | -------------------------------------------------------------------------------- /src/queue-config/tokens/queue-config.token.ts: -------------------------------------------------------------------------------- 1 | export const QUEUE_CONFIG = 'QUEUE_CONFIG'; 2 | -------------------------------------------------------------------------------- /src/queue-config/types/queue-config.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { queueConfigSchema } from '../schemas/queue-config.schema'; 3 | 4 | export type QueueConfig = z.infer; 5 | -------------------------------------------------------------------------------- /src/queue/controllers/queue-slot-wrapper.ts: -------------------------------------------------------------------------------- 1 | import { app } from '@/app'; 2 | import { PlayersService } from '@/players/services/players.service'; 3 | import { Serializable } from '@/shared/serializable'; 4 | import { QueueSlotDto } from '../dto/queue-slot.dto'; 5 | import { QueueSlot } from '../types/queue-slot'; 6 | 7 | export class QueueSlotWrapper extends Serializable { 8 | constructor(public readonly slot: QueueSlot) { 9 | super(); 10 | } 11 | 12 | async serialize(): Promise { 13 | const playersService = app.get(PlayersService); 14 | return { 15 | id: this.slot.id, 16 | gameClass: this.slot.gameClass, 17 | player: this.slot.playerId 18 | ? await playersService.getById(this.slot.playerId) 19 | : undefined, 20 | ready: this.slot.ready, 21 | canMakeFriendsWith: this.slot.canMakeFriendsWith, 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/queue/default-map-pool.ts: -------------------------------------------------------------------------------- 1 | export const defaultMapPool = [ 2 | { 3 | name: 'cp_process_final', 4 | execConfig: 'etf2l_6v6_5cp', 5 | }, 6 | { 7 | name: 'cp_snakewater_final1', 8 | execConfig: 'etf2l_6v6_5cp', 9 | }, 10 | { 11 | name: 'cp_sunshine', 12 | execConfig: 'etf2l_6v6_5cp', 13 | }, 14 | { 15 | name: 'cp_granary_pro_rc8', 16 | execConfig: 'etf2l_6v6_5cp', 17 | }, 18 | { 19 | name: 'cp_gullywash_final1', 20 | execConfig: 'etf2l_6v6_5cp', 21 | }, 22 | { 23 | name: 'cp_metalworks', 24 | execConfig: 'etf2l_6v6_5cp', 25 | }, 26 | ]; 27 | -------------------------------------------------------------------------------- /src/queue/dto/map-pool-item.dto.ts: -------------------------------------------------------------------------------- 1 | export interface MapPoolEntryDto { 2 | // Map name 3 | name: string; 4 | 5 | // Config to execute when this map is in use 6 | execConfig?: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/queue/dto/queue-slot.dto.ts: -------------------------------------------------------------------------------- 1 | import { PlayerDto } from '@/players/dto/player.dto'; 2 | import { Tf2ClassName } from '@/shared/models/tf2-class-name'; 3 | import { Serializable } from '@/shared/serializable'; 4 | 5 | export interface QueueSlotDto { 6 | id: number; 7 | gameClass: Tf2ClassName; 8 | player?: Serializable; 9 | ready: boolean; 10 | canMakeFriendsWith?: Tf2ClassName[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/queue/dto/queue.dto.ts: -------------------------------------------------------------------------------- 1 | import { Tf2ClassName } from '@/shared/models/tf2-class-name'; 2 | import { Serializable } from '@/shared/serializable'; 3 | import { QueueSlotDto } from './queue-slot.dto'; 4 | 5 | export interface QueueDto { 6 | config: { 7 | teamCount: 2; 8 | classes: { 9 | name: Tf2ClassName; 10 | count: number; 11 | canMakeFriendsWith?: Tf2ClassName[]; 12 | }[]; 13 | }; 14 | 15 | slots: Serializable[]; 16 | 17 | // Queue state. Possible values: 18 | // waiting: not enough players 19 | // ready: players are expected to ready up 20 | // launching: the queue is full, a game is being launched 21 | state: 'waiting' | 'ready' | 'launching'; 22 | 23 | mapVoteResults: { 24 | map: string; 25 | voteCount: number; 26 | }[]; 27 | 28 | substituteRequests: { 29 | gameId: string; 30 | gameNumber: number; 31 | gameClass: Tf2ClassName; 32 | team: string; 33 | }[]; 34 | 35 | friendships: { 36 | sourcePlayerId: string; 37 | targetPlayerId: string; 38 | }[]; 39 | } 40 | -------------------------------------------------------------------------------- /src/queue/errors/cannot-join-at-this-queue-state.error.ts: -------------------------------------------------------------------------------- 1 | export class CannotJoinAtThisQueueStateError extends Error { 2 | constructor(public queueState: string) { 3 | super(`cannot join the queue at this state (${queueState})`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/queue/errors/cannot-leave-at-this-queue-state.error.ts: -------------------------------------------------------------------------------- 1 | export class CannotLeaveAtThisQueueStateError extends Error { 2 | constructor(public queueState: string) { 3 | super(`cannot leave the queue at this state (${queueState})`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/queue/errors/cannot-mark-player-as-friend.error.ts: -------------------------------------------------------------------------------- 1 | import { PlayerId } from '@/players/types/player-id'; 2 | 3 | export class CannotMarkPlayerAsFriendError extends Error { 4 | constructor( 5 | public readonly sourcePlayerId: PlayerId, 6 | public readonly sourceGameClass: string, 7 | public readonly targetPlayerId: PlayerId, 8 | public readonly targetGameClass: string, 9 | ) { 10 | super( 11 | `player ${sourcePlayerId.toString()} (added as ${sourceGameClass}) cannot mark ${targetPlayerId.toString()} (added as ${targetGameClass}) as friend`, 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/queue/errors/no-such-player.error.ts: -------------------------------------------------------------------------------- 1 | export class NoSuchPlayerError extends Error { 2 | constructor(public playerId: string) { 3 | super(`no such player (${playerId})`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/queue/errors/no-such-slot.error.ts: -------------------------------------------------------------------------------- 1 | export class NoSuchSlotError extends Error { 2 | constructor(public slotId: number) { 3 | super(`no such slot (${slotId})`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/queue/errors/player-already-marked-as-friend.error.ts: -------------------------------------------------------------------------------- 1 | import { PlayerId } from '@/players/types/player-id'; 2 | 3 | export class PlayerAlreadyMarkedAsFriendError extends Error { 4 | constructor(public playerId: PlayerId) { 5 | super( 6 | `player ${playerId.toString()} is already marked as friend by another player`, 7 | ); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/queue/errors/player-not-in-the-queue.error.ts: -------------------------------------------------------------------------------- 1 | import { PlayerId } from '@/players/types/player-id'; 2 | 3 | export class PlayerNotInTheQueueError extends Error { 4 | constructor(public playerId: PlayerId) { 5 | super(`player (${playerId.toString()}) not in the queue`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/queue/errors/slot-occupied.error.ts: -------------------------------------------------------------------------------- 1 | export class SlotOccupiedError extends Error { 2 | constructor(public slotId: number) { 3 | super(`slot (${slotId}) already occupied`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/queue/errors/wrong-queue-state.error.ts: -------------------------------------------------------------------------------- 1 | export class WrongQueueStateError extends Error { 2 | constructor(public queueState: string) { 3 | super(`wrong queue state (${queueState})`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/queue/models/map-pool-entry.spec.ts: -------------------------------------------------------------------------------- 1 | import { MapPoolEntry } from './map-pool-entry'; 2 | 3 | describe('MapPoolEntry', () => { 4 | it('should serialize', async () => { 5 | const map = new MapPoolEntry('cp_badlands', 'etf2l_6v6_5cp'); 6 | expect(await map.serialize()).toEqual({ 7 | name: 'cp_badlands', 8 | execConfig: 'etf2l_6v6_5cp', 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/queue/services/queue-announcements.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject, forwardRef } from '@nestjs/common'; 2 | import { GamesService } from '@/games/services/games.service'; 3 | import { SubstituteRequest } from '../types/substitute-request'; 4 | import { SlotStatus } from '@/games/models/slot-status'; 5 | 6 | @Injectable() 7 | export class QueueAnnouncementsService { 8 | constructor( 9 | @Inject(forwardRef(() => GamesService)) 10 | private readonly gamesService: GamesService, 11 | ) {} 12 | 13 | async substituteRequests(): Promise { 14 | const games = await this.gamesService.getGamesWithSubstitutionRequests(); 15 | return games.flatMap((game) => { 16 | return game.slots.flatMap((slot) => { 17 | if (slot.status !== SlotStatus.waitingForSubstitute) { 18 | return []; 19 | } 20 | 21 | return { 22 | gameId: game.id, 23 | gameNumber: game.number, 24 | gameClass: slot.gameClass, 25 | team: slot.team.toUpperCase(), 26 | }; 27 | }); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/queue/services/queue-configuration.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurationService } from '@/configuration/services/configuration.service'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { QueueConfigurationService } from './queue-configuration.service'; 4 | 5 | jest.mock('@/configuration/services/configuration.service'); 6 | 7 | describe('QueueConfigurationService', () => { 8 | let service: QueueConfigurationService; 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | providers: [QueueConfigurationService, ConfigurationService], 13 | }).compile(); 14 | 15 | service = module.get(QueueConfigurationService); 16 | }); 17 | 18 | it('should be defined', () => { 19 | expect(service).toBeDefined(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/queue/types/map-vote-result.ts: -------------------------------------------------------------------------------- 1 | export interface MapVoteResult { 2 | map: string; 3 | voteCount: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/queue/types/queue-slot.ts: -------------------------------------------------------------------------------- 1 | import { PlayerId } from '@/players/types/player-id'; 2 | import { Tf2ClassName } from '@/shared/models/tf2-class-name'; 3 | 4 | export interface QueueSlot { 5 | id: number; 6 | gameClass: Tf2ClassName; 7 | playerId: PlayerId | null; 8 | ready: boolean; 9 | canMakeFriendsWith?: Tf2ClassName[]; 10 | } 11 | -------------------------------------------------------------------------------- /src/queue/types/queue-state.ts: -------------------------------------------------------------------------------- 1 | export enum QueueState { 2 | // waiting for players to join the queue 3 | waiting = 'waiting', 4 | 5 | // players are expected to ready up 6 | ready = 'ready', 7 | 8 | // everybody has readied up, the game is being launched 9 | launching = 'launching', 10 | } 11 | -------------------------------------------------------------------------------- /src/queue/types/substitute-request.ts: -------------------------------------------------------------------------------- 1 | import { Tf2ClassName } from '@/shared/models/tf2-class-name'; 2 | 3 | export interface SubstituteRequest { 4 | gameId: string; 5 | gameNumber: number; 6 | gameClass: Tf2ClassName; 7 | team: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/decorators/deprecated.ts: -------------------------------------------------------------------------------- 1 | import { Header } from '@nestjs/common'; 2 | 3 | export const Deprecated = () => Header('Warning', '299 - "Deprecated API"'); 4 | -------------------------------------------------------------------------------- /src/shared/decorators/transform-object-id.ts: -------------------------------------------------------------------------------- 1 | // TODO: Find a way to remove the eslint-disable comments 2 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 4 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 5 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 6 | import { Transform, TransformationType } from 'class-transformer'; 7 | import { Types } from 'mongoose'; 8 | 9 | export const TransformObjectId: () => PropertyDecorator = 10 | () => (target: object, propertyKey: string | symbol) => { 11 | Transform(({ type, obj }) => { 12 | switch (type) { 13 | case TransformationType.PLAIN_TO_CLASS: 14 | return new Types.ObjectId(obj[propertyKey]); 15 | 16 | case TransformationType.CLASS_TO_PLAIN: 17 | return obj[propertyKey].toString(); 18 | 19 | case TransformationType.CLASS_TO_CLASS: 20 | return obj[propertyKey]; 21 | 22 | default: 23 | return undefined; 24 | } 25 | })(target, propertyKey); 26 | }; 27 | -------------------------------------------------------------------------------- /src/shared/errors/player-denied.error.ts: -------------------------------------------------------------------------------- 1 | import { Player } from '@/players/models/player'; 2 | 3 | export enum DenyReason { 4 | playerHasNotAcceptedRules = 'player has not accepted rules', 5 | noSkillAssigned = 'player has no skill assigned', 6 | playerIsBanned = 'player is banned', 7 | playerIsInvolvedInGame = 'player is involved in a game', 8 | playerSkillBelowThreshold = 'player skill is below a required threshold', 9 | } 10 | 11 | export class PlayerDeniedError extends Error { 12 | constructor( 13 | public readonly player: Player, 14 | public readonly reason: DenyReason, 15 | ) { 16 | super(`player ${player.name} denied from joining the queue (${reason})`); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/shared/extract-client-ip.spec.ts: -------------------------------------------------------------------------------- 1 | import { extractClientIp } from './extract-client-ip'; 2 | 3 | describe('extractClientIp', () => { 4 | it('should extract IP address from x-forwarded-for', () => { 5 | expect( 6 | extractClientIp({ 7 | 'x-forwarded-for': '192.168.0.1, 127.0.0.1', 8 | }), 9 | ).toEqual('192.168.0.1'); 10 | }); 11 | 12 | it('should extract IP address from x-real-ip', () => { 13 | expect(extractClientIp({ 'x-real-ip': '192.168.0.1' })).toEqual( 14 | '192.168.0.1', 15 | ); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/shared/extract-client-ip.ts: -------------------------------------------------------------------------------- 1 | import { IncomingHttpHeaders } from 'http'; 2 | 3 | export const extractClientIp = ( 4 | headers: IncomingHttpHeaders, 5 | ): string | undefined => { 6 | if (headers['x-forwarded-for']) { 7 | const xForwarderFor = headers['x-forwarded-for']; 8 | if (!Array.isArray(xForwarderFor)) { 9 | const first = xForwarderFor.split(',').at(0); 10 | if (first) { 11 | return first.trim(); 12 | } 13 | } else { 14 | return xForwarderFor[0]; 15 | } 16 | } 17 | 18 | if (headers['x-real-ip']) { 19 | return headers['x-real-ip'].toString(); 20 | } 21 | 22 | return undefined; 23 | }; 24 | -------------------------------------------------------------------------------- /src/shared/filters/all-exceptions.filter.spec.ts: -------------------------------------------------------------------------------- 1 | import { Player } from '@/players/models/player'; 2 | import { 3 | DenyReason, 4 | PlayerDeniedError, 5 | } from '@/shared/errors/player-denied.error'; 6 | import { AllExceptionsFilter } from './all-exceptions.filter'; 7 | 8 | describe('AllExceptionsFilter', () => { 9 | it('should be defined', () => { 10 | expect(new AllExceptionsFilter()).toBeDefined(); 11 | }); 12 | 13 | it('should handle any error', () => { 14 | const socket = { 15 | emit: jest.fn(), 16 | }; 17 | 18 | const host = { 19 | switchToWs: () => ({ 20 | getClient: jest.fn().mockImplementation(() => socket), 21 | }), 22 | }; 23 | 24 | const player = new Player(); 25 | player.name = 'FAKE_PLAYER_NAME'; 26 | 27 | const filter = new AllExceptionsFilter(); 28 | filter.catch( 29 | new PlayerDeniedError(player, DenyReason.playerIsBanned), 30 | host as any, 31 | ); 32 | expect(socket.emit).toHaveBeenCalledWith('exception', { 33 | message: expect.any(String), 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/shared/filters/all-exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { assertIsError } from '@/utils/assert-is-error'; 2 | import { ArgumentsHost, Catch } from '@nestjs/common'; 3 | import { BaseWsExceptionFilter } from '@nestjs/websockets'; 4 | import { Socket } from 'socket.io'; 5 | 6 | /** 7 | * Handle all exceptions gracefully 8 | */ 9 | @Catch() 10 | export class AllExceptionsFilter extends BaseWsExceptionFilter { 11 | // skipcq: JS-0105 12 | catch(exception: unknown, host: ArgumentsHost) { 13 | assertIsError(exception); 14 | const socket = host.switchToWs().getClient(); 15 | socket.emit('exception', { message: exception.message }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/filters/data-parsing-error.filter.spec.ts: -------------------------------------------------------------------------------- 1 | import { DataParsingErrorFilter } from './data-parsing-error.filter'; 2 | 3 | describe('DataParsingErrorFilter', () => { 4 | it('should be defined', () => { 5 | expect(new DataParsingErrorFilter()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/shared/filters/data-parsing-error.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | import { ZodError } from 'zod'; 4 | 5 | @Catch(ZodError) 6 | export class DataParsingErrorFilter implements ExceptionFilter { 7 | // skipcq: JS-0105 8 | catch(exception: ZodError, host: ArgumentsHost) { 9 | const ctx = host.switchToHttp(); 10 | const response = ctx.getResponse(); 11 | 12 | response.status(400).json({ 13 | statusCode: 404, 14 | errors: exception.errors, 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/filters/document-not-found.filter.spec.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNotFoundFilter } from './document-not-found.filter'; 2 | import { Error } from 'mongoose'; 3 | import { Request, Response } from 'express'; 4 | 5 | describe('DocumentNotFoundFilter', () => { 6 | it('should be defined', () => { 7 | expect(new DocumentNotFoundFilter()).toBeDefined(); 8 | }); 9 | 10 | it('should handle the error', () => { 11 | const response = { 12 | status: jest.fn().mockImplementation(() => response), 13 | json: jest.fn().mockImplementation(() => response), 14 | } as unknown as jest.Mocked; 15 | 16 | const request = { 17 | url: '/some/invalid/path', 18 | }; 19 | 20 | const ctx = { 21 | getResponse: () => response, 22 | getRequest: () => request, 23 | }; 24 | 25 | const host = { 26 | switchToHttp: () => ctx, 27 | }; 28 | 29 | const filter = new DocumentNotFoundFilter(); 30 | filter.catch(new Error.DocumentNotFoundError(''), host as any); 31 | expect(response.status).toHaveBeenCalledWith(404); 32 | expect(response.json).toHaveBeenCalledWith({ 33 | statusCode: 404, 34 | path: '/some/invalid/path', 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/shared/filters/document-not-found.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; 2 | import { Request, Response } from 'express'; 3 | import { Error } from 'mongoose'; 4 | 5 | @Catch(Error.DocumentNotFoundError) 6 | export class DocumentNotFoundFilter implements ExceptionFilter { 7 | catch(exception: Error.DocumentNotFoundError, host: ArgumentsHost) { 8 | const ctx = host.switchToHttp(); 9 | const response = ctx.getResponse(); 10 | const request = ctx.getRequest(); 11 | 12 | response.status(404).json({ 13 | statusCode: 404, 14 | path: request.url, 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/filters/mongo-db-error.filter.spec.ts: -------------------------------------------------------------------------------- 1 | import { MongoDbErrorFilter } from './mongo-db-error.filter'; 2 | 3 | describe('MongoDbErrorFilter', () => { 4 | it('should be defined', () => { 5 | expect(new MongoDbErrorFilter()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/shared/filters/mongo-db-error.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | import { MongoError } from 'mongodb'; 4 | 5 | @Catch(MongoError) 6 | export class MongoDbErrorFilter implements ExceptionFilter { 7 | catch(exception: MongoError, host: ArgumentsHost) { 8 | const ctx = host.switchToHttp(); 9 | const response = ctx.getResponse(); 10 | 11 | switch (exception.code) { 12 | case 11000: // duplicate key error 13 | response.status(422).json({ 14 | statusCode: 422, 15 | message: 'duplicate found', 16 | }); 17 | break; 18 | 19 | default: 20 | new Logger().error(exception); 21 | response.status(500).json({ 22 | statusCode: 500, 23 | message: 'Internal server error', 24 | }); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/shared/filters/zod.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; 2 | import { HttpStatusCode } from 'axios'; 3 | import { ZodError } from 'zod'; 4 | import { Response } from 'express'; 5 | import { Socket } from 'socket.io'; 6 | 7 | @Catch(ZodError) 8 | export class ZodFilter 9 | implements ExceptionFilter 10 | { 11 | catch(exception: T, host: ArgumentsHost) { 12 | switch (host.getType()) { 13 | case 'http': { 14 | const ctx = host.switchToHttp(); 15 | const response = ctx.getResponse(); 16 | 17 | return response.status(HttpStatusCode.BadRequest).json({ 18 | errors: exception.errors, 19 | message: exception.message, 20 | statusCode: HttpStatusCode.BadRequest, 21 | }); 22 | } 23 | 24 | case 'ws': { 25 | const ctx = host.switchToWs(); 26 | const client = ctx.getClient(); 27 | return client.emit('exception', { 28 | errors: exception.errors, 29 | message: exception.message, 30 | }); 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/shared/interceptors/serializer.interceptor.spec.ts: -------------------------------------------------------------------------------- 1 | import { SerializerInterceptor } from './serializer.interceptor'; 2 | 3 | describe('SerializerInterceptor', () => { 4 | it('should be defined', () => { 5 | expect(new SerializerInterceptor()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/shared/interceptors/serializer.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | import { from, Observable, switchMap } from 'rxjs'; 8 | import { serialize } from '../serialize'; 9 | 10 | @Injectable() 11 | export class SerializerInterceptor implements NestInterceptor { 12 | intercept(context: ExecutionContext, next: CallHandler): Observable { 13 | return next 14 | .handle() 15 | .pipe(switchMap((response) => from(serialize(response)))); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/models/link.ts: -------------------------------------------------------------------------------- 1 | interface LinkProps { 2 | href: string; 3 | title?: string; 4 | } 5 | 6 | /** 7 | * HATEOAS link. 8 | */ 9 | export class Link { 10 | constructor(props: LinkProps) { 11 | this.href = props.href; 12 | this.title = props.title; 13 | } 14 | 15 | readonly href: string; 16 | readonly title?: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/models/object-id-or-steam-id.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | interface ObjectId { 4 | type: 'object-id'; 5 | objectId: Types.ObjectId; 6 | } 7 | 8 | interface SteamId { 9 | type: 'steam-id'; 10 | steamId64: string; 11 | } 12 | 13 | export type ObjectIdOrSteamId = ObjectId | SteamId; 14 | -------------------------------------------------------------------------------- /src/shared/models/tf2-class-name.ts: -------------------------------------------------------------------------------- 1 | export enum Tf2ClassName { 2 | scout = 'scout', 3 | soldier = 'soldier', 4 | pyro = 'pyro', 5 | demoman = 'demoman', 6 | heavy = 'heavy', 7 | engineer = 'engineer', 8 | medic = 'medic', 9 | sniper = 'sniper', 10 | spy = 'spy', 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/pipes/is-one-of.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { IsOneOfPipe } from './is-one-of.pipe'; 2 | import { BadRequestException } from '@nestjs/common'; 3 | 4 | describe('IsOneOfPipe', () => { 5 | it('should be defined', () => { 6 | expect(new IsOneOfPipe([])).toBeDefined(); 7 | }); 8 | 9 | it("should return the value if it's allowed", () => { 10 | expect( 11 | new IsOneOfPipe(['one', 'two']).transform('one', { 12 | data: 'value', 13 | type: 'param', 14 | }), 15 | ).toEqual('one'); 16 | }); 17 | 18 | it('should throw an error if the value is not allowed', () => { 19 | expect(() => 20 | new IsOneOfPipe(['one', 'two']).transform('three', { 21 | type: 'query', 22 | data: 'field', 23 | }), 24 | ).toThrow(BadRequestException); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/shared/pipes/is-one-of.pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentMetadata, 3 | Injectable, 4 | PipeTransform, 5 | BadRequestException, 6 | } from '@nestjs/common'; 7 | 8 | @Injectable() 9 | export class IsOneOfPipe implements PipeTransform { 10 | constructor(private choices: string[]) {} 11 | 12 | transform(value: string, metadata: ArgumentMetadata) { 13 | if (!this.choices.includes(value)) { 14 | throw new BadRequestException( 15 | `${metadata.data} must be on of ${this.choices.join(', ')}`, 16 | ); 17 | } 18 | 19 | return value; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/shared/pipes/object-id-or-steam-id.pipe.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; 2 | import { ObjectIdOrSteamId } from '../models/object-id-or-steam-id'; 3 | import { ObjectIdValidationPipe } from './object-id-validation.pipe'; 4 | import { SteamIdValidationPipe } from './steam-id-validation.pipe'; 5 | 6 | @Injectable() 7 | export class ObjectIdOrSteamIdPipe 8 | implements PipeTransform 9 | { 10 | private objectIdValidationPipe = new ObjectIdValidationPipe(); 11 | private steamIdValidationPipe = new SteamIdValidationPipe(); 12 | 13 | transform(value: string): ObjectIdOrSteamId { 14 | try { 15 | const objectId = this.objectIdValidationPipe.transform(value); 16 | return { 17 | type: 'object-id', 18 | objectId, 19 | }; 20 | // eslint-disable-next-line no-empty 21 | } catch (error) {} 22 | 23 | try { 24 | const steamId64 = this.steamIdValidationPipe.transform(value); 25 | return { 26 | type: 'steam-id', 27 | steamId64, 28 | }; 29 | // eslint-disable-next-line no-empty 30 | } catch (error) {} 31 | 32 | throw new BadRequestException(`The provided ID (${value}) is invalid`); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/shared/pipes/object-id-validation.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { ObjectIdValidationPipe } from './object-id-validation.pipe'; 2 | import { Types } from 'mongoose'; 3 | 4 | describe('ObjectIdValidationPipe', () => { 5 | it('should be defined', () => { 6 | expect(new ObjectIdValidationPipe()).toBeDefined(); 7 | }); 8 | 9 | it('should pass valid object id', () => { 10 | const id = new Types.ObjectId().toString(); 11 | expect(new ObjectIdValidationPipe().transform(id)).toEqual( 12 | new Types.ObjectId(id), 13 | ); 14 | }); 15 | 16 | it('should deny invalid object id', () => { 17 | expect(() => 18 | new ObjectIdValidationPipe().transform('some invalid id'), 19 | ).toThrow(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/shared/pipes/object-id-validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, PipeTransform, BadRequestException } from '@nestjs/common'; 2 | import { Types } from 'mongoose'; 3 | 4 | /** 5 | * Throws BadRequestException if the provided value is not a valid ObjectId. 6 | * 7 | * @export 8 | * @class ObjectIdValidationPipe 9 | * @implements {PipeTransform} 10 | */ 11 | @Injectable() 12 | export class ObjectIdValidationPipe 13 | implements PipeTransform 14 | { 15 | transform(value: string): Types.ObjectId { 16 | if (!Types.ObjectId.isValid(value)) { 17 | throw new BadRequestException(`The provided ID (${value}) is invalid`); 18 | } 19 | 20 | return new Types.ObjectId(value); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/shared/pipes/parse-date.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | import { ParseDatePipe } from './parse-date.pipe'; 3 | 4 | describe('ParseDatePipe', () => { 5 | it('should be defined', () => { 6 | expect(new ParseDatePipe()).toBeDefined(); 7 | }); 8 | 9 | describe('#transform()', () => { 10 | let pipe: ParseDatePipe; 11 | 12 | beforeEach(() => { 13 | pipe = new ParseDatePipe(); 14 | }); 15 | 16 | it('should parse valid date', () => { 17 | expect(pipe.transform('2022-11-16')).toEqual(new Date(2022, 10, 16)); 18 | }); 19 | 20 | it('should throw BadRequestException for invalid date formats', () => { 21 | expect(() => pipe.transform('202211-16')).toThrow(BadRequestException); 22 | expect(() => pipe.transform('2022-11-32')).toThrow(BadRequestException); 23 | expect(() => pipe.transform('2022-31-12')).toThrow(BadRequestException); 24 | expect(() => pipe.transform('2a-13-16')).toThrow(BadRequestException); 25 | expect(() => pipe.transform('2022-1316')).toThrow(BadRequestException); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/shared/pipes/parse-date.pipe.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; 2 | import { parse, isValid } from 'date-fns'; 3 | 4 | @Injectable() 5 | export class ParseDatePipe implements PipeTransform { 6 | transform(value: string): Date | undefined { 7 | if (!value) { 8 | return undefined; 9 | } 10 | const date = parse(value, 'yyyy-MM-dd', new Date()); 11 | if (!isValid(date)) { 12 | throw new BadRequestException( 13 | `invalid date format (${value}), expected yyyy-MM-dd`, 14 | ); 15 | } 16 | return date; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/shared/pipes/parse-enum-array.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentMetadata, BadRequestException } from '@nestjs/common'; 2 | import { ParseEnumArrayPipe } from './parse-enum-array.pipe'; 3 | 4 | enum TestEnum { 5 | one = 'one', 6 | two = 'two', 7 | } 8 | 9 | describe('ParseEnumArrayPipe', () => { 10 | let pipe: ParseEnumArrayPipe; 11 | 12 | beforeEach(() => { 13 | pipe = new ParseEnumArrayPipe(TestEnum); 14 | }); 15 | 16 | it('should be defined', () => { 17 | expect(pipe).toBeDefined(); 18 | }); 19 | 20 | it('should split values', () => { 21 | expect( 22 | pipe.transform('one,two', { data: 'arg' } as ArgumentMetadata), 23 | ).toEqual([TestEnum.one, TestEnum.two]); 24 | }); 25 | 26 | describe('when value is empty', () => { 27 | it('should throw', () => { 28 | expect(() => 29 | pipe.transform('', { data: 'arg' } as ArgumentMetadata), 30 | ).toThrow(BadRequestException); 31 | }); 32 | }); 33 | 34 | describe('when value is invalid', () => { 35 | it('should throw', () => { 36 | expect(() => 37 | pipe.transform('one,two,three', { data: 'arg' } as ArgumentMetadata), 38 | ).toThrow(BadRequestException); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/shared/pipes/steam-id-validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { assertIsError } from '@/utils/assert-is-error'; 2 | import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; 3 | // skipcq: JS-C1003 4 | import * as SteamID from 'steamid'; 5 | 6 | @Injectable() 7 | export class SteamIdValidationPipe implements PipeTransform { 8 | // skipcq: JS-0105 9 | transform(value: string) { 10 | try { 11 | const sid = new SteamID(value); 12 | if (sid.isValidIndividual()) { 13 | return sid.getSteamID64(); 14 | } else { 15 | throw new BadRequestException('The provided SteamID is invalid'); 16 | } 17 | } catch (error) { 18 | assertIsError(error); 19 | throw new BadRequestException(error.message); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/shared/pipes/zod.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { ZodPipe } from './zod.pipe'; 3 | 4 | const testSchema = z.object({ 5 | foo: z.string(), 6 | }); 7 | 8 | describe('ZodPipe', () => { 9 | let pipe: ZodPipe; 10 | 11 | beforeEach(() => { 12 | pipe = new ZodPipe(testSchema); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(pipe).toBeDefined(); 17 | }); 18 | 19 | describe('when input is valid', () => { 20 | it('should return parsed value', () => { 21 | expect(pipe.transform({ foo: 'bar' })).toEqual({ foo: 'bar' }); 22 | }); 23 | }); 24 | 25 | describe('when input is invalid', () => { 26 | it('should throw', () => { 27 | expect(() => pipe.transform({})).toThrow(); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/shared/pipes/zod.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, PipeTransform } from '@nestjs/common'; 2 | import { ZodTypeAny } from 'zod'; 3 | 4 | @Injectable() 5 | export class ZodPipe 6 | implements PipeTransform 7 | { 8 | constructor(private readonly schema: T) {} 9 | 10 | transform(value: unknown): T { 11 | return this.schema.parse(value) as T; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/serializable.ts: -------------------------------------------------------------------------------- 1 | export abstract class Serializable { 2 | abstract serialize(): T | Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/serialize.spec.ts: -------------------------------------------------------------------------------- 1 | import { Serializable } from './serializable'; 2 | import { serialize } from './serialize'; 3 | 4 | it('should serialize deep nested serializable objects', async () => { 5 | interface TestDto { 6 | one: string; 7 | two: number; 8 | three: { 9 | four: string; 10 | }; 11 | } 12 | 13 | class TestSerializable extends Serializable { 14 | async serialize(): Promise { 15 | return { 16 | one: 'one', 17 | two: 2, 18 | three: { 19 | four: 'four', 20 | }, 21 | }; 22 | } 23 | } 24 | 25 | const input = { 26 | foo: 'bar', 27 | baz: new TestSerializable(), 28 | bounce: ['yay', new TestSerializable()], 29 | }; 30 | 31 | expect(await serialize(input)).toEqual({ 32 | foo: 'bar', 33 | baz: { 34 | one: 'one', 35 | two: 2, 36 | three: { 37 | four: 'four', 38 | }, 39 | }, 40 | bounce: [ 41 | 'yay', 42 | { 43 | one: 'one', 44 | two: 2, 45 | three: { 46 | four: 'four', 47 | }, 48 | }, 49 | ], 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/shared/user-metadata.ts: -------------------------------------------------------------------------------- 1 | export interface UserMetadata { 2 | ipAddress?: string; 3 | userAgent?: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/websocket-event-emitter.ts: -------------------------------------------------------------------------------- 1 | import { WebsocketEvent } from '@/websocket-event'; 2 | import { OnGatewayInit } from '@nestjs/websockets'; 3 | import { Socket } from 'socket.io'; 4 | import { Serializable } from './serializable'; 5 | import { serialize } from './serialize'; 6 | 7 | interface EmitParams { 8 | room?: string | string[]; 9 | event: WebsocketEvent; 10 | payload: Serializable | Serializable[]; 11 | } 12 | 13 | export class WebsocketEventEmitter implements OnGatewayInit { 14 | protected server!: Socket; 15 | 16 | afterInit(socket: Socket) { 17 | this.server = socket; 18 | } 19 | 20 | protected async emit({ room, event, payload }: EmitParams) { 21 | const data = await serialize(payload); 22 | if (room) { 23 | this.server.to(room).emit(event, data); 24 | } else { 25 | this.server.emit(event, data); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/statistics/controllers/statistics.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, DefaultValuePipe, Get, Query } from '@nestjs/common'; 2 | import { ParseDatePipe } from '../../shared/pipes/parse-date.pipe'; 3 | import { StatisticsService } from '../services/statistics.service'; 4 | import { sub } from 'date-fns'; 5 | 6 | @Controller('statistics') 7 | export class StatisticsController { 8 | constructor(private statisticsService: StatisticsService) {} 9 | 10 | @Get('played-maps-count') 11 | async getPlayedMapsCount() { 12 | return await this.statisticsService.getPlayedMapsCount(); 13 | } 14 | 15 | @Get('game-launch-time-spans') 16 | async getGameLaunchDays() { 17 | return await this.statisticsService.getGameLaunchTimeSpans(); 18 | } 19 | 20 | @Get('game-launches-per-day') 21 | async getGameLaunchesPerDay( 22 | @Query( 23 | 'since', 24 | ParseDatePipe, 25 | new DefaultValuePipe(sub(Date.now(), { years: 1 })), 26 | ) 27 | since: Date, 28 | ) { 29 | return await this.statisticsService.getGameLaunchesPerDay(since); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/statistics/interfaces/game-launch-time-span.ts: -------------------------------------------------------------------------------- 1 | type TimeOfTheDay = 2 | | 'morning' /* 06-12 */ 3 | | 'afternoon' /* 12-18 */ 4 | | 'evening' /* 18-24 */ 5 | | 'night' /* 24-06 */; 6 | 7 | export interface GameLaunchTimeSpan { 8 | dayOfWeek: number; // day of the week as a number between 1 (Sunday) and 7 (Saturday) 9 | timeOfTheDay: TimeOfTheDay; 10 | count: number; 11 | } 12 | -------------------------------------------------------------------------------- /src/statistics/interfaces/game-launches-per-day.ts: -------------------------------------------------------------------------------- 1 | export interface GameLaunchesPerDay { 2 | day: string; 3 | count: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/statistics/interfaces/played-map-count.ts: -------------------------------------------------------------------------------- 1 | export interface PlayedMapCount { 2 | mapName: string; 3 | count: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/statistics/statistics.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { StatisticsService } from './services/statistics.service'; 3 | import { StatisticsController } from './controllers/statistics.controller'; 4 | import { Game, gameSchema } from '@/games/models/game'; 5 | import { MongooseModule } from '@nestjs/mongoose'; 6 | 7 | @Module({ 8 | imports: [ 9 | MongooseModule.forFeature([{ name: Game.name, schema: gameSchema }]), 10 | ], 11 | providers: [StatisticsService], 12 | controllers: [StatisticsController], 13 | }) 14 | export class StatisticsModule {} 15 | -------------------------------------------------------------------------------- /src/steam/errors/steam-api.error.ts: -------------------------------------------------------------------------------- 1 | export class SteamApiError extends Error { 2 | constructor( 3 | public readonly code: number, 4 | public readonly message: string, 5 | ) { 6 | super(`steam API error (${code} ${message})`); 7 | this.name = SteamApiError.name; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/steam/steam.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpModule } from '@nestjs/axios'; 2 | import { Module } from '@nestjs/common'; 3 | import { SteamApiService } from './services/steam-api.service'; 4 | 5 | @Module({ 6 | imports: [HttpModule], 7 | providers: [SteamApiService], 8 | exports: [SteamApiService], 9 | }) 10 | export class SteamModule {} 11 | -------------------------------------------------------------------------------- /src/utils/assert-is-error.ts: -------------------------------------------------------------------------------- 1 | export function assertIsError(error: unknown): asserts error is Error { 2 | if (!(error instanceof Error)) { 3 | throw error; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/create-rcon.ts: -------------------------------------------------------------------------------- 1 | import { Rcon } from 'rcon-client'; 2 | 3 | interface CreateRconOptions { 4 | host: string; 5 | port: number; 6 | rconPassword: string; 7 | } 8 | 9 | export const createRcon = async (opts: CreateRconOptions): Promise => { 10 | const rcon = new Rcon({ 11 | host: opts.host, 12 | port: opts.port, 13 | password: opts.rconPassword, 14 | timeout: 30000, 15 | }); 16 | 17 | return await rcon.connect(); 18 | }; 19 | -------------------------------------------------------------------------------- /src/utils/generate-gameserver-password.ts: -------------------------------------------------------------------------------- 1 | import { generate } from 'generate-password'; 2 | 3 | export const generateGameserverPassword = () => 4 | generate({ length: 10, numbers: true, uppercase: true }); 5 | -------------------------------------------------------------------------------- /src/utils/generate-rcon-password.ts: -------------------------------------------------------------------------------- 1 | import { generate } from 'generate-password'; 2 | 3 | export const generateRconPassword = () => 4 | generate({ length: 10, numbers: true, uppercase: true }); 5 | -------------------------------------------------------------------------------- /src/utils/mongoose-document.ts: -------------------------------------------------------------------------------- 1 | import { TransformObjectId } from '@/shared/decorators/transform-object-id'; 2 | import { Exclude } from 'class-transformer'; 3 | import { Types } from 'mongoose'; 4 | 5 | export abstract class MongooseDocument { 6 | @Exclude({ toPlainOnly: true }) 7 | __v?: number; 8 | 9 | @Exclude({ toPlainOnly: true }) 10 | @TransformObjectId() 11 | _id?: Types.ObjectId; 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/testing-mongoose-module.ts: -------------------------------------------------------------------------------- 1 | import { MongooseModule } from '@nestjs/mongoose'; 2 | import { MongoMemoryServer } from 'mongodb-memory-server'; 3 | 4 | export const mongooseTestingModule = (mongod: MongoMemoryServer) => 5 | MongooseModule.forRoot(mongod.getUri()); 6 | -------------------------------------------------------------------------------- /src/utils/wait-a-bit.ts: -------------------------------------------------------------------------------- 1 | export const waitABit = async (ms: number) => 2 | new Promise((resolve) => setTimeout(resolve, ms)); 3 | -------------------------------------------------------------------------------- /src/utils/workaround-model-provider.ts: -------------------------------------------------------------------------------- 1 | import { getConnectionToken, getModelToken } from '@nestjs/mongoose'; 2 | import { Connection } from 'mongoose'; 3 | 4 | interface WorkaroundModelProviderOptions { 5 | name: string; 6 | schema: any; 7 | } 8 | 9 | export const workaroundModelProvider = ( 10 | options: WorkaroundModelProviderOptions, 11 | ) => ({ 12 | provide: getModelToken(options.name), 13 | inject: [getConnectionToken()], 14 | useFactory: (connection: Connection) => 15 | connection.model(options.name, options.schema), 16 | }); 17 | -------------------------------------------------------------------------------- /src/voice-servers/errors/mumble-channel-does-not-exist.error.ts: -------------------------------------------------------------------------------- 1 | export class MumbleChannelDoesNotExistError extends Error { 2 | constructor(public readonly channelName: string) { 3 | super(`mumble channel ${channelName} does not exist`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/voice-servers/errors/mumble-client-not-connected.error.ts: -------------------------------------------------------------------------------- 1 | interface Options { 2 | host: string; 3 | port: number; 4 | username: string; 5 | } 6 | 7 | export class MumbleClientNotConnectedError extends Error { 8 | constructor(public readonly options: Options) { 9 | super( 10 | `not connected to the mumble server (${options.username}@${options.host}:${options.port})`, 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/voice-servers/voice-servers.module.ts: -------------------------------------------------------------------------------- 1 | import { CertificatesModule } from '@/certificates/certificates.module'; 2 | import { ConfigurationModule } from '@/configuration/configuration.module'; 3 | import { EventsModule } from '@/events/events.module'; 4 | import { GameCoordinatorModule } from '@/game-coordinator/game-coordinator.module'; 5 | import { GamesModule } from '@/games/games.module'; 6 | import { Module } from '@nestjs/common'; 7 | import { MumbleBotService } from './mumble-bot.service'; 8 | 9 | @Module({ 10 | imports: [ 11 | ConfigurationModule, 12 | EventsModule, 13 | CertificatesModule, 14 | GamesModule, 15 | GameCoordinatorModule, 16 | ], 17 | providers: [MumbleBotService], 18 | }) 19 | export class VoiceServersModule {} 20 | -------------------------------------------------------------------------------- /src/websocket-event.ts: -------------------------------------------------------------------------------- 1 | export enum WebsocketEvent { 2 | profileUpdate = 'profile update', 3 | 4 | gameCreated = 'game created', 5 | gameUpdated = 'game updated', 6 | gameSlotsUpdated = 'game slots updated', 7 | 8 | queueSlotsUpdate = 'queue slots update', 9 | queueStateUpdate = 'queue state update', 10 | friendshipsUpdate = 'friendships update', 11 | mapVoteResultsUpdate = 'map vote results update', 12 | substituteRequestsUpdate = 'substitute requests update', 13 | 14 | playerConnected = 'player connected', 15 | playerDisconnected = 'player disconnected', 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "dist", 6 | "**/*spec.ts", 7 | "**/__mocks__", 8 | "*jest.config.*", 9 | "e2e" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "declaration": false, 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "target": "es2017", 10 | "lib": ["esnext"], 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "resolveJsonModule": true, 16 | "allowSyntheticDefaultImports": true, 17 | "types": ["jest"], 18 | "paths": { 19 | "@/*": [ "src/*" ], 20 | "@mocks/*": [ "__mocks__/*" ], 21 | "@configs/*": [ "configs/*" ] 22 | }, 23 | "skipLibCheck": true, 24 | "strict": true 25 | }, 26 | "exclude": [ 27 | "node_modules", 28 | "dist" 29 | ] 30 | } 31 | --------------------------------------------------------------------------------