Commit 11ef42ba authored by nanahira's avatar nanahira

first

parents
Pipeline #35417 passed with stages
in 7 minutes and 49 seconds
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
/data
/output
/config.yaml
.git*
Dockerfile
.dockerignore
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
/data
/output
/config.yaml
\ No newline at end of file
stages:
- build
- deploy
variables:
GIT_DEPTH: "1"
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
.build-image:
stage: build
script:
- docker build --pull -t $TARGET_IMAGE .
- docker push $TARGET_IMAGE
build-x86:
extends: .build-image
tags:
- docker
variables:
TARGET_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-x86
build-arm:
extends: .build-image
tags:
- docker-arm
variables:
TARGET_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-arm
.deploy:
stage: deploy
tags:
- docker
script:
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-x86
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-arm
- docker manifest create $TARGET_IMAGE --amend $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-x86 --amend
$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-arm
- docker manifest push $TARGET_IMAGE
deploy_latest:
extends: .deploy
variables:
TARGET_IMAGE: $CI_REGISTRY_IMAGE:latest
only:
- master
deploy_branch:
extends: .deploy
variables:
TARGET_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
/install-npm.sh
.git*
/data
/output
/config.yaml
.idea
.dockerignore
Dockerfile
/src
{
"singleQuote": true,
"trailingComma": "all"
}
\ No newline at end of file
FROM node:lts-bookworm-slim as base
LABEL Author="Nanahira <nanahira@momobako.com>"
RUN apt update && apt -y install python3 build-essential libpq-dev && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/*
WORKDIR /usr/src/app
COPY ./package*.json ./
FROM base as builder
RUN npm ci && npm cache clean --force
COPY . ./
RUN npm run build
FROM base
ENV NODE_ENV production
RUN npm ci && npm cache clean --force
COPY --from=builder /usr/src/app/dist ./dist
COPY ./config.example.yaml ./config.yaml
ENV NODE_PG_FORCE_NATIVE=true
EXPOSE 3000
CMD [ "npm", "run", "start:prod" ]
This diff is collapsed.
# App name
App description.
## Installation
```bash
$ npm install
```
## Config
Make a copy of `config.example.yaml` to `config.yaml`.
## Running the app
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## License
AGPLv3
host: '::'
port: 3000
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off'
},
},
);
\ No newline at end of file
#!/bin/bash
npm install --save typeorm @nestjs/typeorm pg pg-native nicot
#!/bin/bash
npm install --save \
class-validator \
class-transformer \
@nestjs/swagger \
@nestjs/config \
yaml \
nesties
npm install --save-dev \
@types/express
npm i --save-exact --save-dev eslint@8.22.0
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"]
}
}
This diff is collapsed.
{
"name": "tabulator-another",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/axios": "^4.0.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.1.5",
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"lodash": "^4.17.21",
"nesties": "^1.1.1",
"nestjs-mycard": "^4.0.2",
"nicot": "^1.1.6",
"pg": "^8.14.1",
"pg-native": "^3.3.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.22",
"yaml": "^2.7.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/express": "^5.0.1",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.17.16",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"eslint": "^9.25.1",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { loadConfig } from './utility/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MycardAuthModule } from 'nestjs-mycard';
import { Tournament } from './tournament/entities/Tournament.entity';
import { Match } from './match/entities/match.entity';
import { Participant } from './participant/entities/participant.entity';
import { TournamentService } from './tournament/tournament.service';
import { MatchService } from './match/match.service';
import { ParticipantService } from './participant/participant.service';
import { TournamentController } from './tournament/tournament.controller';
import { MatchController } from './match/match.controller';
import { ParticipantController } from './participant/participant.controller';
@Module({
imports: [
{
...MycardAuthModule.register(),
global: true,
},
ConfigModule.forRoot({
load: [loadConfig],
isGlobal: true,
ignoreEnvVars: true,
ignoreEnvFile: true,
}),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: async (config: ConfigService) => ({
type: 'postgres',
entities: [],
autoLoadEntities: true,
synchronize: !config.get('DB_NO_INIT'),
host: config.get('DB_HOST'),
port: parseInt(config.get('DB_PORT')) || 5432,
username: config.get('DB_USER'),
password: config.get('DB_PASS'),
database: config.get('DB_NAME'),
supportBigNumbers: true,
bigNumberStrings: false,
// logging: true,
}),
}),
TypeOrmModule.forFeature([Tournament, Match, Participant]),
],
providers: [TournamentService, MatchService, ParticipantService],
controllers: [TournamentController, MatchController, ParticipantController],
})
export class AppModule {}
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.setGlobalPrefix('api');
if (process.env.NODE_ENV !== 'production') {
app.enableCors();
}
app.set('trust proxy', ['172.16.0.0/12', 'loopback']);
const documentConfig = new DocumentBuilder()
.setTitle('tabulator')
.setDescription('排表器')
.setVersion('1.0.0')
.build();
const document = SwaggerModule.createDocument(app, documentConfig);
SwaggerModule.setup('docs', app, document);
const config = app.get(ConfigService);
await app.listen(
config.get<number>('port') || 3000,
config.get<string>('host') || '::',
);
}
bootstrap();
import {
Entity,
Index,
ManyToOne,
OneToMany,
SelectQueryBuilder,
} from 'typeorm';
import {
applyQueryProperty,
BoolColumn,
EnumColumn,
IdBase,
IntColumn,
NotChangeable,
NotColumn,
QueryEqual,
QueryMatchBoolean,
} from 'nicot';
import { TournamentIdColumn } from '../../utility/decorators/tournament-id-column';
import { Tournament } from '../../tournament/entities/Tournament.entity';
import { Participant } from '../../participant/entities/participant.entity';
export enum MatchStatus {
Pending = 'Pending',
Running = 'Running',
Finished = 'Finished',
Abandoned = 'Abandoned',
}
@Entity()
export class Match extends IdBase() {
@NotChangeable()
@TournamentIdColumn(true)
@QueryEqual()
tournamentId: number;
@NotColumn()
@ManyToOne(() => Tournament, (tournament) => tournament.matches)
tournament: Tournament;
@Index()
@NotChangeable()
@IntColumn('smallint', {
unsigned: true,
required: true,
description: '比赛轮次。',
})
@QueryEqual()
round: number;
@NotChangeable()
@BoolColumn({ default: false, description: '是否为季军赛。' })
@QueryMatchBoolean()
isThirdPlaceMatch: boolean;
@EnumColumn(MatchStatus, {
default: MatchStatus.Pending,
description: '比赛状态',
})
@NotChangeable()
@QueryEqual()
status: MatchStatus;
@NotChangeable()
@IntColumn('bigint', {
unsigned: true,
description: '玩家 1 ID',
})
@QueryEqual()
player1Id: number;
@IntColumn('smallint', { description: '玩家 1 分数', required: false })
@QueryEqual()
player1Score: number;
@NotColumn()
@ManyToOne(() => Participant, (participant) => participant.matches1)
player1: Participant;
@NotChangeable()
@IntColumn('bigint', {
unsigned: true,
description: '玩家 2 ID',
})
@QueryEqual()
player2Id: number;
@IntColumn('smallint', { description: '玩家 2 分数', required: false })
@QueryEqual()
player2Score: number;
@NotColumn()
@ManyToOne(() => Participant, (participant) => participant.matches2)
player2: Participant;
@IntColumn('bigint', {
unsigned: true,
description:
'胜者 ID。update 的时候填写 null 代表平局,undefined 代表还未结束。',
})
@QueryEqual()
winnerId: number;
@NotColumn()
@ManyToOne(() => Participant, (participant) => participant.wonMatches)
winner: Participant;
loserId() {
return this.player1Id === this.winnerId ? this.player2Id : this.player1Id;
}
loser() {
return this.player1Id === this.winnerId ? this.player2 : this.player1;
}
@NotChangeable()
@IntColumn('bigint', {
unsigned: true,
description: '晋级通往的比赛 ID',
required: false,
})
@QueryEqual()
childMatchId: number;
setChildMatch(match: Match) {
this.childMatchId = match.id;
return this;
}
@NotColumn()
@ManyToOne(() => Match, (match) => match.parentMatches)
childMatch: Match;
@NotColumn()
@OneToMany(() => Match, (match) => match.childMatch)
parentMatches: Match[];
participated(id: number) {
return this.player1Id === id || this.player2Id === id;
}
opponentId(id: number) {
return this.player1Id === id ? this.player2Id : this.player1Id;
}
clone() {
const match = new Match();
Object.assign(match, this);
return match;
}
buildTree(matches: Match[]) {
this.parentMatches = matches
.filter((match) => match.childMatchId === this.id)
.map((match) => match.clone().buildTree(matches));
return this;
}
}
import { Test, TestingModule } from '@nestjs/testing';
import { MatchController } from './match.controller';
import { MatchService } from './match.service';
describe('MatchController', () => {
let controller: MatchController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [MatchController],
providers: [MatchService],
}).compile();
controller = module.get<MatchController>(MatchController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
import { Controller } from '@nestjs/common';
import { MatchService } from './match.service';
import { ApiError, RestfulFactory } from 'nicot';
import { Match } from './entities/match.entity';
import { ApiTags } from '@nestjs/swagger';
import { ApiMycardUser, MycardUser, PutMycardUser } from 'nestjs-mycard';
const factory = new RestfulFactory(Match);
class FindMatchDto extends factory.findAllDto {}
class UpdateMatchDto extends factory.updateDto {}
@Controller('match')
@ApiMycardUser({ optional: true })
@ApiTags('match')
@ApiError(404, '相关资源未找到')
@ApiError(403, '无法操作,弹 Toast 即可')
export class MatchController {
constructor(private readonly matchService: MatchService) {}
@factory.findOne()
findOne(@factory.idParam() id: number, @PutMycardUser() user: MycardUser) {
return this.matchService.getMatch(id, user);
}
@factory.findAll()
findAll(
@factory.findAllParam() dto: FindMatchDto,
@PutMycardUser() user: MycardUser,
) {
return this.matchService.getMatches(dto, user);
}
@factory.update()
update(
@factory.idParam() id: number,
@factory.updateParam() dto: UpdateMatchDto,
@PutMycardUser() user: MycardUser,
) {
return this.matchService.updateMatch(id, dto, user);
}
}
import { Test, TestingModule } from '@nestjs/testing';
import { MatchService } from './match.service';
describe('MatchService', () => {
let service: MatchService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [MatchService],
}).compile();
service = module.get<MatchService>(MatchService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
import { Injectable } from '@nestjs/common';
import { BlankReturnMessageDto, CrudService, Inner } from 'nicot';
import { Match, MatchStatus } from './entities/match.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { MycardUser } from 'nestjs-mycard';
import {
Tournament,
TournamentRule,
TournamentStatus,
} from '../tournament/entities/Tournament.entity';
import { TournamentService } from '../tournament/tournament.service';
import { MoreThan } from 'typeorm';
@Injectable()
export class MatchService extends CrudService(Match, {
relations: [Inner('tournament'), 'player1', 'player2', 'winner'],
}) {
constructor(
@InjectRepository(Match) repo,
private tournamentService: TournamentService,
) {
super(repo);
}
getMatch(id: number, user: MycardUser) {
return this.findOne(id, (qb) =>
Tournament.extraQueryForUser(user, qb, 'tournament'),
);
}
getMatches(dto: Partial<Match>, user: MycardUser) {
return this.findAll(dto, (qb) =>
Tournament.extraQueryForUser(user, qb, 'tournament'),
);
}
async updateMatch(id: number, dto: Partial<Match>, user: MycardUser) {
const match = await this.repo.findOne({
where: { id },
relations: ['tournament'],
select: {
id: true,
status: true,
round: true,
tournamentId: true,
player1Id: true,
player2Id: true,
tournament: {
id: true,
status: true,
creator: true,
collaborators: true,
rule: true,
},
},
});
if (!match) {
throw new BlankReturnMessageDto(404, '对局不存在。').toException();
}
match.tournament.checkPermission(user);
if (dto.winnerId && !match.participated(dto.winnerId)) {
throw new BlankReturnMessageDto(
400,
'对局胜者不在对局中。',
).toException();
}
if (match.status === MatchStatus.Pending) {
throw new BlankReturnMessageDto(
400,
'对局尚未开始,无法修改。',
).toException();
}
if (match.tournament.status === TournamentStatus.Finished) {
throw new BlankReturnMessageDto(
400,
'比赛已结束,无法修改。',
).toException();
}
if (
dto.winnerId === null &&
match.tournament.rule !== TournamentRule.Swiss
) {
throw new BlankReturnMessageDto(
400,
'非瑞士轮对局胜者不能为空。',
).toException();
}
if (dto.winnerId !== undefined) {
dto.status = MatchStatus.Finished;
}
if (match.status === MatchStatus.Finished && dto.winnerId !== undefined) {
// clean all other matches in greater rounds
await this.repo.update(
{ round: MoreThan(match.round), tournamentId: match.tournamentId },
{
winnerId: null,
status: MatchStatus.Pending,
player1Id: null,
player2Id: null,
player1Score: null,
player2Score: null,
},
);
}
dto.winnerId ||= null;
const result = await this.update(id, dto);
await this.tournamentService.afterMatchUpdate(match.tournamentId);
return result;
}
}
import { Entity, ManyToOne, OneToMany } from 'typeorm';
import { NamedBase } from '../../utility/NamedBase.entity';
import {
BoolColumn,
NotChangeable,
NotColumn,
QueryEqual,
QueryMatchBoolean,
} from 'nicot';
import { TournamentIdColumn } from '../../utility/decorators/tournament-id-column';
import { Tournament } from '../../tournament/entities/Tournament.entity';
import { MycardUser } from 'nestjs-mycard';
import { Match } from '../../match/entities/match.entity';
import { ApiProperty } from '@nestjs/swagger';
export class ParticipantScore {
@ApiProperty({ description: '排名' })
rank: number;
@ApiProperty({ description: '得分' })
score: number;
@ApiProperty({ description: '胜场' })
win: number;
@ApiProperty({ description: '平场' })
draw: number;
@ApiProperty({ description: '负场' })
lose: number;
@ApiProperty({ description: '轮空' })
bye: number;
@ApiProperty({ description: '平局分' })
tieBreaker: number;
}
@Entity({ orderBy: { id: 'ASC' } })
export class Participant extends NamedBase {
@QueryMatchBoolean()
@BoolColumn({ default: false, description: '是否已经退赛' })
quit: boolean;
@QueryEqual()
@NotChangeable()
@TournamentIdColumn(true)
tournamentId: number;
@NotColumn()
@ManyToOne(() => Tournament, (tournament) => tournament.participants)
tournament: Tournament;
@NotColumn()
@OneToMany(() => Match, (match) => match.player1)
matches1: Match[];
@NotColumn()
@OneToMany(() => Match, (match) => match.player2)
matches2: Match[];
@NotColumn()
@OneToMany(() => Match, (match) => match.winner)
wonMatches: Match[];
@NotColumn()
@ApiProperty({ type: [Match], description: '参与的比赛。' })
matches: Match[];
@NotColumn({
description: '该选手的成绩。',
required: false,
})
score: ParticipantScore;
getMatches() {
return this.matches1.concat(this.matches2);
}
async afterGet() {
await super.afterGet();
this.matches = this.getMatches();
}
checkPermission(user: MycardUser) {
return this.tournament?.checkPermission(user);
}
}
import { Test, TestingModule } from '@nestjs/testing';
import { ParticipantController } from './participant.controller';
import { ParticipantService } from './participant.service';
describe('ParticipantController', () => {
let controller: ParticipantController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ParticipantController],
providers: [ParticipantService],
}).compile();
controller = module.get<ParticipantController>(ParticipantController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
import { Controller } from '@nestjs/common';
import { ParticipantService } from './participant.service';
import { Participant } from './entities/participant.entity';
import { ApiError, RestfulFactory } from 'nicot';
import { ApiMycardUser, MycardUser, PutMycardUser } from 'nestjs-mycard';
import { ApiTags } from '@nestjs/swagger';
const factory = new RestfulFactory(Participant);
class FindParticipantDto extends factory.findAllDto {}
class UpdateParticipantDto extends factory.updateDto {}
class ImportParticipantDto extends factory.importDto {}
@ApiTags('participant')
@Controller('participant')
@ApiMycardUser({ optional: true })
@ApiError(404, '相关资源未找到')
@ApiError(403, '无法操作,弹 Toast 即可')
export class ParticipantController {
constructor(private readonly participantService: ParticipantService) {}
@factory.create()
async create(
@factory.createParam() participant: Participant,
@PutMycardUser() user: MycardUser,
) {
return this.participantService.createParticipant(participant, user);
}
@factory.findOne()
async findOne(
@factory.idParam() id: number,
@PutMycardUser(false) user: MycardUser,
) {
return this.participantService.getParticipant(id, user);
}
@factory.findAll()
async findAll(
@factory.findAllParam() dto: FindParticipantDto,
@PutMycardUser(false) user: MycardUser,
) {
return this.participantService.getParticipants(dto, user);
}
@factory.update()
async update(
@factory.idParam() id: number,
@factory.updateParam() dto: UpdateParticipantDto,
@PutMycardUser() user: MycardUser,
) {
return this.participantService.updateParticipant(id, dto, user);
}
@factory.delete()
async delete(
@factory.idParam() id: number,
@PutMycardUser() user: MycardUser,
) {
return this.participantService.deleteParticipant(id, user);
}
@factory.import()
async import(
@PutMycardUser() user: MycardUser,
@factory.createParam() data: ImportParticipantDto,
) {
return this.participantService.importParticipants(data.data, user);
}
}
import { Test, TestingModule } from '@nestjs/testing';
import { ParticipantService } from './participant.service';
describe('ParticipantService', () => {
let service: ParticipantService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ParticipantService],
}).compile();
service = module.get<ParticipantService>(ParticipantService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
import { Injectable } from '@nestjs/common';
import { BlankReturnMessageDto, CrudService, Inner } from 'nicot';
import { Participant } from './entities/participant.entity';
import { InjectRepository } from '@nestjs/typeorm';
import {
Tournament,
TournamentStatus,
} from '../tournament/entities/Tournament.entity';
import { MycardUser } from 'nestjs-mycard';
import { TournamentService } from '../tournament/tournament.service';
@Injectable()
export class ParticipantService extends CrudService(Participant, {
relations: [Inner('tournament')],
}) {
constructor(
@InjectRepository(Participant) repo,
private readonly tournamentService: TournamentService,
) {
super(repo);
}
getParticipant(id: number, user: MycardUser) {
return this.findOne(id, (qb) =>
Tournament.extraQueryForUser(user, qb, 'tournament'),
);
}
getParticipants(dto: Partial<Participant>, user: MycardUser) {
return this.findAll(dto, (qb) =>
Tournament.extraQueryForUser(user, qb, 'tournament'),
);
}
async createParticipant(dto: Participant, user: MycardUser) {
await this.tournamentService.canModifyParticipants(dto.tournamentId, user);
return this.create(dto);
}
async updateParticipant(
id: number,
dto: Partial<Participant>,
user: MycardUser,
) {
await this.checkPermissionOfParticipant(id, user, true);
return this.update(id, dto);
}
async deleteParticipant(id: number, user: MycardUser) {
await this.checkPermissionOfParticipant(id, user);
return this.delete(id);
}
async checkPermissionOfParticipant(
id: number,
user: MycardUser,
allowRunning = false,
) {
const participant = await this.repo.findOne({
where: { id },
select: {
id: true,
tournament: {
id: true,
creator: true,
status: true,
collaborators: true,
},
},
relations: ['tournament'],
});
if (!participant?.tournament) {
throw new BlankReturnMessageDto(404, '未找到该参赛者。').toException();
}
if (participant.tournament.status === TournamentStatus.Finished) {
throw new BlankReturnMessageDto(
400,
'比赛已结束,无法修改参赛者。',
).toException();
}
if (
!allowRunning &&
participant.tournament.status !== TournamentStatus.Ready
) {
throw new BlankReturnMessageDto(
400,
'比赛已开始,无法修改参赛者。',
).toException();
}
return participant.tournament.checkPermission(user);
}
async importParticipants(participants: Participant[], user: MycardUser) {
return this.importEntities(participants, async (p) => {
try {
await this.tournamentService.canModifyParticipants(
p.tournamentId,
user,
);
} catch (e) {
return `玩家 ${p.name} 对应的比赛 ${p.tournamentId} 不存在或您没有权限。`;
}
});
}
}
import { Tournament } from '../tournament/entities/Tournament.entity';
import { Match, MatchStatus } from '../match/entities/match.entity';
import { Repository } from 'typeorm';
import {
Participant,
ParticipantScore,
} from '../participant/entities/participant.entity';
export class TournamentRuleBase {
protected participantMap = new Map<number, Participant>();
constructor(
protected tournament: Tournament,
protected repo?: Repository<Match>,
) {
this.tournament.participants?.forEach((p) =>
this.participantMap.set(p.id, p),
);
}
async saveMatch(matches: Match[]) {
matches.forEach((m) => (m.tournamentId = this.tournament.id));
return this.repo.save(matches);
}
currentRoundCount() {
return Math.max(
...this.tournament.matches
.filter(
(m) =>
m.status === MatchStatus.Finished ||
m.status === MatchStatus.Running,
)
.map((m) => m.round),
);
}
nextRoundCount() {
return this.currentRoundCount() + 1;
}
async initialize() {}
nextRound(): Partial<Match[]> {
return [];
}
specificMatches(status: MatchStatus) {
return this.tournament.matches.filter((m) => m.status === status);
}
participantScore(participant: Participant): Partial<ParticipantScore> {
const finishedMatches = this.specificMatches(MatchStatus.Finished);
return {
win: finishedMatches.filter((m) => m.winnerId === participant.id).length,
lose: finishedMatches.filter(
(m) =>
m.winnerId &&
m.winnerId !== participant.id &&
m.participated(participant.id),
).length,
draw: finishedMatches.filter(
(m) => !m.winnerId && m.participated(participant.id),
).length,
};
}
participantScoreAfter(participant: Participant): Partial<ParticipantScore> {
return {};
}
}
import { TournamentRule } from '../tournament/entities/Tournament.entity';
import { SingleElimination } from './rules/single-elimination';
import { TournamentRuleBase } from './base';
import { Swiss } from './rules/swiss';
export const TournamentRules = new Map<
TournamentRule,
typeof TournamentRuleBase
>();
TournamentRules.set(<TournamentRule>'SingleElimination', SingleElimination);
TournamentRules.set(<TournamentRule>'Swiss', Swiss);
import { TournamentRuleBase } from '../base';
import { Match, MatchStatus } from '../../match/entities/match.entity';
import {
Participant,
ParticipantScore,
} from '../../participant/entities/participant.entity';
import _ from 'lodash';
export class SingleElimination extends TournamentRuleBase {
totalRoundCount() {
return Math.ceil(Math.log2(this.tournament.participants.length));
}
async initialize() {
const roundCount = this.totalRoundCount();
const matchStack: Record<number, Match[]> = {};
for (let i = roundCount; i > 0; i--) {
let matches: Match[];
if (i === roundCount) {
matches = [new Match()];
} else {
const nextRoundMatches = matchStack[i + 1];
matches = nextRoundMatches.flatMap((m) => [
new Match().setChildMatch(m),
new Match().setChildMatch(m),
]);
if (i === 1) {
// add participants to first round
const participants = _.sortBy(
this.tournament.participants,
(p) => -p.id,
);
const neededMatchesCount =
participants.length - nextRoundMatches.length * 2;
matches = matches.slice(0, neededMatchesCount);
for (const match of matches) {
match.player1Id = participants.pop().id;
match.player2Id = participants.pop().id;
match.status = MatchStatus.Running;
}
}
}
matches.forEach((m) => (m.round = i));
matches = await this.saveMatch(matches);
matchStack[i] = matches;
}
if (
this.tournament.participants.length >= 4 &&
this.tournament.ruleSettings.hasThirdPlaceMatch
) {
// add third place match
const thirdPlaceMatch = new Match();
thirdPlaceMatch.isThirdPlaceMatch = true;
thirdPlaceMatch.round = roundCount;
await this.saveMatch([thirdPlaceMatch]);
}
}
nextRound() {
const finishedMatches = this.specificMatches(MatchStatus.Finished);
const survivedParticipants = this.tournament.participants
.filter(
(p) =>
!finishedMatches.some(
(m) => m.participated(p.id) && m.winnerId !== p.id,
),
)
.reverse();
const nextRoundCount = this.nextRoundCount();
const matches = this.specificMatches(MatchStatus.Pending).filter(
(m) => m.round === nextRoundCount && !m.isThirdPlaceMatch,
);
for (const match of matches) {
match.player1Id = survivedParticipants.pop().id;
match.player2Id = survivedParticipants.pop().id;
match.status = MatchStatus.Running;
}
if (
nextRoundCount === this.totalRoundCount() &&
this.tournament.ruleSettings.hasThirdPlaceMatch
) {
const thirdPlaceMatch = this.tournament.matches.find(
(m) => m.isThirdPlaceMatch && m.status === MatchStatus.Pending,
);
const losers = finishedMatches
.filter((m) => m.round === nextRoundCount - 1)
.map((m) => m.loserId());
if (thirdPlaceMatch && losers.length >= 2) {
thirdPlaceMatch.player1Id = losers[0];
thirdPlaceMatch.player2Id = losers[1];
thirdPlaceMatch.status = MatchStatus.Running;
matches.push(thirdPlaceMatch);
}
}
return matches;
}
participantScore(participant: Participant): Partial<ParticipantScore> {
const matches = this.specificMatches(MatchStatus.Finished);
const scores = matches
.filter((m) => m.participated(participant.id))
.map((m) => {
if (m.winnerId !== participant.id) {
return 0;
}
let point = Math.pow(2, m.round);
if (m.isThirdPlaceMatch) {
point *= 0.25;
}
return point;
});
const standardRoundCount = Math.log2(this.tournament.participants.length);
return {
...super.participantScore(participant),
bye:
standardRoundCount - Math.ceil(standardRoundCount) &&
!matches.some((m) => m.participated(participant.id) && m.round === 1)
? 1
: 0,
score: scores.reduce((a, b) => a + b, 0),
tieBreaker: 0,
};
}
}
import { TournamentRuleBase } from '../base';
import { RuleSettings } from '../../tournament/entities/Tournament.entity';
import { Match, MatchStatus } from '../../match/entities/match.entity';
import _ from 'lodash';
import {
Participant,
ParticipantScore,
} from '../../participant/entities/participant.entity';
export class Swiss extends TournamentRuleBase {
private settings: Partial<RuleSettings> = {
rounds:
this.tournament.ruleSettings?.rounds ??
Math.ceil(Math.log2(this.tournament.participants?.length || 2)),
winScore: this.tournament.ruleSettings?.winScore ?? 3,
drawScore: this.tournament.ruleSettings?.drawScore ?? 1,
byeScore: this.tournament.ruleSettings?.byeScore ?? 3,
};
async initialize() {
const matchCountPerRound = Math.floor(
this.tournament.participants.length / 2,
);
const allMatches: Match[] = [];
for (let r = 1; r <= this.settings.rounds; ++r) {
const matches: Match[] = [];
for (let i = 0; i < matchCountPerRound; ++i) {
const match = new Match();
match.round = r;
match.status = MatchStatus.Pending;
matches.push(match);
}
if (r === 1) {
const participants = _.sortBy(
this.tournament.participants,
(p) => -p.id,
);
for (const match of matches) {
match.status = MatchStatus.Running;
match.player1Id = participants.pop().id;
match.player2Id = participants.pop().id;
}
}
allMatches.push(...matches);
}
await this.saveMatch(allMatches);
}
nextRound(): Partial<Match[]> {
this.tournament.calculateScore();
const participants = this.tournament.participants
.filter((p) => !p.quit)
.reverse();
const nextRoundCount = this.nextRoundCount();
const matches = this.tournament.matches.filter(
(m) => m.round === nextRoundCount,
);
for (const match of matches) {
match.status = MatchStatus.Running;
match.player1Id = participants.pop()?.id;
match.player2Id = participants.pop()?.id;
if (!match.player1Id || !match.player2Id) {
match.status = MatchStatus.Abandoned;
match.player1Id = null;
match.player2Id = null;
}
}
return matches;
}
participantScore(participant: Participant): Partial<ParticipantScore> {
const data = super.participantScore(participant);
let bye = 0;
for (let i = 1; i <= this.currentRoundCount(); ++i) {
if (
!this.tournament.matches.some(
(m) => m.round === i && m.participated(participant.id),
)
) {
++bye;
}
}
return {
...data,
bye,
score:
data.win * this.settings.winScore +
data.draw * this.settings.drawScore +
bye * this.settings.byeScore,
};
}
participantScoreAfter(participant: Participant): Partial<ParticipantScore> {
const opponentIds = this.specificMatches(MatchStatus.Finished)
.filter((m) => m.participated(participant.id))
.map((m) => m.opponentId(participant.id));
const opponents = opponentIds.map((id) => this.participantMap.get(id));
return {
tieBreaker: _.sumBy(opponents, (p) => p.score.score),
};
}
}
import {
Column,
Entity,
Index,
OneToMany,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import {
BlankReturnMessageDto,
DateColumn,
EnumColumn,
IntColumn,
JsonColumn,
NotChangeable,
NotColumn,
NotQueryable,
NotWritable,
QueryEqual,
} from 'nicot';
import { MycardUser } from 'nestjs-mycard';
import { DescBase } from '../../utility/NamedBase.entity';
import {
Participant,
ParticipantScore,
} from '../../participant/entities/participant.entity';
import { IsArray, IsInt, IsOptional, IsPositive } from 'class-validator';
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { Match } from '../../match/entities/match.entity';
import { TournamentRules } from '../../tournament-rules/rule-map';
import _ from 'lodash';
import { RenameClass } from 'nicot/dist/src/utility/rename-class';
export enum TournamentRule {
SingleElimination = 'SingleElimination',
Swiss = 'Swiss',
}
export enum TournamentVisibility {
Public = 'Public',
Internal = 'Internal',
Private = 'Private',
}
export enum TournamentStatus {
Ready = 'Ready',
Running = 'Running',
Finished = 'Finished',
}
export class RuleSettings {
@ApiProperty({ description: '瑞士轮局数。' })
rounds: number;
@ApiProperty({ description: '瑞士轮胜利分。' })
winScore: number;
@ApiProperty({ description: '瑞士轮平局分。' })
drawScore: number;
@ApiProperty({ description: '瑞士轮轮空分。' })
byeScore: number;
@ApiProperty({ description: '淘汰赛中是否是季军塞' })
hasThirdPlaceMatch: boolean;
}
@Entity()
export class Tournament extends DescBase {
@QueryEqual()
@NotChangeable()
@EnumColumn(TournamentRule, {
default: TournamentRule.SingleElimination,
description: '规则',
})
rule: TournamentRule;
getRuleHandler(repo?: Repository<Match>) {
const ruleClass = TournamentRules.get(this.rule);
return new ruleClass(this, repo);
}
@NotChangeable()
@JsonColumn(RenameClass(PartialType(RuleSettings), 'RuleSettingsPartial'), {
description: '比赛规则参数',
})
ruleSettings: RuleSettings;
@QueryEqual()
@EnumColumn(TournamentVisibility, {
default: TournamentVisibility.Public,
description: '可见性',
})
visibility: TournamentVisibility;
@NotWritable()
@EnumColumn(TournamentStatus, {
default: TournamentStatus.Ready,
description: '状态',
})
status: TournamentStatus;
@NotWritable()
@Index()
@IntColumn('int', {
description: '创建者 MC ID',
unsigned: true,
columnExtras: { nullable: false },
})
creator: number;
@NotQueryable()
@Index()
@IsArray()
@IsOptional()
@IsPositive({ each: true })
@IsInt({ each: true })
@ApiProperty({ type: [Number], description: '协作者 MC ID', required: false })
@Column('int', { array: true, default: [], comment: '协作者 MC ID' })
collaborators: number[];
@NotQueryable()
@NotWritable()
@Index()
@DateColumn({ description: '创建时间', columnExtras: { nullable: false } })
createdAt: Date;
@NotColumn()
@OneToMany(() => Participant, (participant) => participant.tournament)
participants: Participant[];
@NotColumn()
@OneToMany(() => Match, (match) => match.tournament)
matches: Match[];
@NotColumn()
@ApiProperty({ description: '对阵图树' })
matchTree: Match;
async beforeCreate() {
this.createdAt = new Date();
}
override isValidInCreate() {
if (!this.getRuleHandler()) {
return '该规则目前不受支持。';
}
return;
}
static extraQueryForUser(
user: MycardUser,
qb: SelectQueryBuilder<any>,
entityName: string,
) {
if (!user) {
qb.andWhere(`${entityName}.visibility = :public`, {
public: TournamentVisibility.Public,
});
} else {
qb.andWhere(
`(${entityName}.visibility != :private OR ${entityName}.creator = :self OR :self = ANY(${entityName}.collaborators))`,
{
private: TournamentVisibility.Private,
self: user.id,
},
);
}
}
checkPermission(user: MycardUser) {
if (
this.creator !== user.id &&
!user.admin &&
!this.collaborators.includes(user.id)
) {
throw new BlankReturnMessageDto(403, '您无权操作该比赛。').toException();
}
return this;
}
calculateScore() {
const rule = this.getRuleHandler();
this.participants.forEach((p) => {
p.score = new ParticipantScore();
Object.assign(p.score, rule.participantScore(p));
});
this.participants.forEach((p) => {
const editScore = rule.participantScoreAfter(p);
Object.assign(p.score, editScore);
});
this.participants = _.sortBy(
this.participants,
(p) => -p.score.score,
(p) => -p.score.tieBreaker,
(p) => p.id,
);
this.participants.forEach((p, i) => {
p.score.rank = i + 1;
});
}
calculateTree() {
const finalMatch = _.maxBy(
this.matches.filter((m) => !m.isThirdPlaceMatch),
(m) => m.round,
);
this.matchTree = finalMatch.clone().buildTree(this.matches);
}
analytics() {
if (!this.participants || this.status === TournamentStatus.Ready) {
return;
}
this.calculateScore();
if (this.rule === TournamentRule.SingleElimination) {
this.calculateTree();
}
}
}
import { Test, TestingModule } from '@nestjs/testing';
import { TournamentController } from './tournament.controller';
import { TournamentService } from './tournament.service';
describe('TournamentController', () => {
let controller: TournamentController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TournamentController],
providers: [TournamentService],
}).compile();
controller = module.get<TournamentController>(TournamentController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
import { Controller, HttpCode, Post } from '@nestjs/common';
import { TournamentService } from './tournament.service';
import { ApiError, BlankReturnMessageDto, RestfulFactory } from 'nicot';
import { Tournament } from './entities/Tournament.entity';
import { ApiMycardUser, MycardUser, PutMycardUser } from 'nestjs-mycard';
import {
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
const factory = new RestfulFactory(Tournament);
class FindTournamentDto extends factory.findAllDto {}
class UpdateTournamentDto extends factory.updateDto {}
@ApiTags('tournament')
@Controller('tournament')
@ApiMycardUser({ optional: true })
@ApiError(404, '相关资源未找到')
@ApiError(403, '无法操作,弹 Toast 即可')
export class TournamentController {
constructor(private readonly tournamentService: TournamentService) {}
@factory.create()
async create(
@factory.createParam() tournament: Tournament,
@PutMycardUser() user: MycardUser,
) {
return this.tournamentService.createTournament(tournament, user);
}
@factory.findOne()
async findOne(
@factory.idParam() id: number,
@PutMycardUser(false) user: MycardUser,
) {
return this.tournamentService.getTournament(id, user);
}
@factory.findAll()
async findAll(
@factory.findAllParam() dto: FindTournamentDto,
@PutMycardUser(false) user: MycardUser,
) {
return this.tournamentService.getTournaments(dto, user);
}
@factory.update()
async update(
@factory.idParam() id: number,
@factory.updateParam() dto: UpdateTournamentDto,
@PutMycardUser() user: MycardUser,
) {
return this.tournamentService.updateTournament(id, dto, user);
}
@factory.delete()
async delete(
@factory.idParam() id: number,
@PutMycardUser() user: MycardUser,
) {
return this.tournamentService.deleteTournament(id, user);
}
@Post(':id/start')
@HttpCode(200)
@ApiOperation({ summary: 'Start a tournament' })
@ApiParam({ name: 'id', description: 'Tournament ID' })
@ApiOkResponse({ type: BlankReturnMessageDto })
async start(
@factory.idParam() id: number,
@PutMycardUser() user: MycardUser,
) {
return this.tournamentService.startTournament(id, user);
}
@Post(':id/reset')
@HttpCode(200)
@ApiOperation({ summary: 'Reset a tournament' })
@ApiParam({ name: 'id', description: 'Tournament ID' })
@ApiOkResponse({ type: BlankReturnMessageDto })
async reset(
@factory.idParam() id: number,
@PutMycardUser() user: MycardUser,
) {
return this.tournamentService.resetTournament(id, user);
}
@Post(':id/finish')
@HttpCode(200)
@ApiOperation({ summary: 'Finish a tournament' })
@ApiParam({ name: 'id', description: 'Tournament ID' })
@ApiOkResponse({ type: BlankReturnMessageDto })
async finish(
@factory.idParam() id: number,
@PutMycardUser() user: MycardUser,
) {
return this.tournamentService.endTournament(id, user);
}
}
import { Test, TestingModule } from '@nestjs/testing';
import { TournamentService } from './tournament.service';
describe('TournamentService', () => {
let service: TournamentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [TournamentService],
}).compile();
service = module.get<TournamentService>(TournamentService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
import { Injectable } from '@nestjs/common';
import { BlankReturnMessageDto, CrudService } from 'nicot';
import { Tournament, TournamentStatus } from './entities/Tournament.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { MycardUser } from 'nestjs-mycard';
import { Match, MatchStatus } from '../match/entities/match.entity';
import { In, Repository } from 'typeorm';
@Injectable()
export class TournamentService extends CrudService(Tournament, {
relations: [
'participants',
'matches',
'matches.player1',
'matches.player2',
'matches.winner',
],
}) {
constructor(
@InjectRepository(Tournament) repo,
@InjectRepository(Match) private readonly matchRepo: Repository<Match>,
) {
super(repo);
}
async getTournament(id: number, user: MycardUser) {
const result = await this.findOne(id, (qb) =>
Tournament.extraQueryForUser(user, qb, this.entityAliasName),
);
result.data?.analytics();
return result;
}
getTournaments(dto: Partial<Tournament>, user: MycardUser) {
return this.findAll(dto, (qb) =>
Tournament.extraQueryForUser(user, qb, this.entityAliasName),
);
}
createTournament(tournament: Tournament, user: MycardUser) {
tournament.creator = user.id;
return this.create(tournament);
}
async updateTournament(
id: number,
dto: Partial<Tournament>,
user: MycardUser,
) {
await this.checkPermissionOfTournament(id, user);
return this.update(id, dto);
}
async deleteTournament(id: number, user: MycardUser) {
await this.checkPermissionOfTournament(id, user);
return this.delete(id);
}
async checkPermissionOfTournament(
id: number,
user: MycardUser,
extraGets: (keyof Tournament)[] = [],
relations: string[] = [],
) {
const tournament = await this.repo.findOne({
where: { id },
select: ['id', 'creator', 'collaborators', ...extraGets],
relations,
});
if (!tournament) {
throw new BlankReturnMessageDto(404, '未找到该比赛。').toException();
}
return tournament.checkPermission(user);
}
async canModifyParticipants(id: number, user: MycardUser) {
const tournamnt = await this.checkPermissionOfTournament(id, user, [
'status',
]);
if (tournamnt.status !== TournamentStatus.Ready) {
throw new BlankReturnMessageDto(
403,
'比赛已经开始,不能修改参赛者。',
).toException();
}
return tournamnt;
}
async resetTournament(id: number, user: MycardUser) {
const tournament = await this.checkPermissionOfTournament(id, user, [
'status',
]);
if (tournament.status === TournamentStatus.Ready) {
throw new BlankReturnMessageDto(
403,
'比赛还未开始,不能重置。',
).toException();
}
await this.repo.manager.transaction(async (tdb) => {
await tdb.update(Tournament, id, { status: TournamentStatus.Ready });
await tdb.softDelete(Match, { tournamentId: id });
});
return new BlankReturnMessageDto(200, 'success');
}
async startTournament(id: number, user: MycardUser) {
const tournament = await this.checkPermissionOfTournament(
id,
user,
['status', 'rule', 'ruleSettings'],
['participants'],
);
if (tournament.status !== TournamentStatus.Ready) {
throw new BlankReturnMessageDto(
403,
'比赛已经开始,不能重复开始。',
).toException();
}
if (tournament.participants.length < 2) {
throw new BlankReturnMessageDto(
403,
'参赛者数量不足,不能开始。',
).toException();
}
await this.repo.manager.transaction(async (edb) => {
await tournament.getRuleHandler(edb.getRepository(Match)).initialize();
await edb.update(Tournament, id, { status: TournamentStatus.Running });
});
return new BlankReturnMessageDto(200, 'success');
}
async endTournament(id: number, user: MycardUser) {
const tournament = await this.checkPermissionOfTournament(id, user, [
'status',
]);
if (tournament.status !== TournamentStatus.Running) {
throw new BlankReturnMessageDto(
403,
'比赛还未开始,不能结束。',
).toException();
}
if (
await this.matchRepo.exists({
where: {
tournamentId: id,
status: In([MatchStatus.Running, MatchStatus.Pending]),
},
take: 1,
})
) {
throw new BlankReturnMessageDto(
403,
'比赛还有未完成的对局,不能结束。',
).toException();
}
await this.repo.update(id, { status: TournamentStatus.Finished });
return new BlankReturnMessageDto(200, 'success');
}
async afterMatchUpdate(id: number) {
if (
!(await this.matchRepo.exists({
where: { tournamentId: id, status: MatchStatus.Running },
})) &&
(await this.matchRepo.exists({
where: { tournamentId: id, status: MatchStatus.Pending },
}))
) {
const tournament = await this.repo.findOne({
where: { id },
select: ['id', 'status', 'rule', 'ruleSettings'],
relations: ['participants', 'matches'],
});
await this.repo.manager.transaction(async (edb) => {
const repo = edb.getRepository(Match);
const updates = tournament.getRuleHandler().nextRound();
await Promise.all(
updates.map((update) =>
repo.update(update.id, {
player1Id: update.player1Id,
player2Id: update.player2Id,
status: update.status,
}),
),
);
});
}
}
}
import { IdBase, NotQueryable, QueryLike, StringColumn } from 'nicot';
import { Index } from 'typeorm';
export class NamedBase extends IdBase() {
@Index()
@QueryLike()
@StringColumn(128, { required: true, description: '名称' })
name: string;
}
export class DescBase extends NamedBase {
@NotQueryable()
@StringColumn(10000, { default: '暂无介绍。', description: '描述' })
description: string;
}
import yaml from 'yaml';
import * as fs from 'fs';
const defaultConfig = {
host: '::',
port: 3000,
DB_HOST: 'localhost',
DB_PORT: 5432,
DB_USER: 'mycard',
DB_PASS: 'mycard',
DB_NAME: 'mycard',
};
export type Config = typeof defaultConfig;
export async function loadConfig(): Promise<Config> {
let readConfig: Partial<Config> = {};
try {
const configText = await fs.promises.readFile('./config.yaml', 'utf-8');
readConfig = yaml.parse(configText);
} catch (e) {
console.error(`Failed to read config: ${e.toString()}`);
}
return {
...defaultConfig,
...readConfig,
...process.env,
};
}
import { IntColumn } from 'nicot';
export const TournamentIdColumn = (required = false) =>
IntColumn('bigint', { unsigned: true, description: '比赛 ID。', required });
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
/* it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
}); */
});
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"esModuleInterop": true
},
"compileOnSave": true,
"allowJs": true
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment