├── .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 |
--------------------------------------------------------------------------------