Skip to content

Commit

Permalink
feat(back): static map list system
Browse files Browse the repository at this point in the history
  • Loading branch information
tsa96 committed Jan 16, 2024
1 parent 2cb5d6a commit bd02a48
Show file tree
Hide file tree
Showing 11 changed files with 337 additions and 34 deletions.
13 changes: 13 additions & 0 deletions apps/backend/src/app/dto/map/map-list-version.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { MapListVersion } from '@momentum/constants';
import { ApiProperty } from '@nestjs/swagger';
import { IsInt } from 'class-validator';

export class MapListVersionDto implements MapListVersion {
@ApiProperty({ description: 'Latest version of the main map list' })
@IsInt()
approved: number;

@ApiProperty({ description: 'Latest version of the submission map list' })
@IsInt()
submissions: number;
}
81 changes: 81 additions & 0 deletions apps/backend/src/app/modules/maps/map-list.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { FlatMapList } from '@momentum/constants';
import { MapListService } from './map-list.service';
import { Test, TestingModule } from '@nestjs/testing';
import { PRISMA_MOCK_PROVIDER } from '../../../../test/prisma-mock.const';
import { mockDeep } from 'jest-mock-extended';
import { FileStoreService } from '../filestore/file-store.service';

describe('MapListService', () => {
describe('onModuleInit', () => {
let service: MapListService;
const fileStoreMock = {
listFileKeys: jest.fn(() => Promise.resolve([])),
deleteFiles: jest.fn()
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MapListService,
PRISMA_MOCK_PROVIDER,
{ provide: FileStoreService, useValue: fileStoreMock }
]
})
.useMocker(mockDeep)
.compile();

service = module.get(MapListService);
});

it('should set version values based on files in storage', async () => {
fileStoreMock.listFileKeys.mockResolvedValueOnce([
'maplist/approved/1.json.deflate'
]);
fileStoreMock.listFileKeys.mockResolvedValueOnce([
'maplist/submissions/15012024.json.deflate'
]);

await service.onModuleInit();

expect(service['version']).toMatchObject({
[FlatMapList.APPROVED]: 1,
[FlatMapList.SUBMISSION]: 15012024
});

expect(fileStoreMock.deleteFiles).not.toHaveBeenCalled();
});

it('should set version to 0 when no versions exist in storage', async () => {
await service.onModuleInit();

expect(service['version']).toMatchObject({
[FlatMapList.APPROVED]: 0,
[FlatMapList.SUBMISSION]: 0
});

expect(fileStoreMock.deleteFiles).not.toHaveBeenCalled();
});

it('should pick most recent when multiple versions exist in storage, and wipe old versions', async () => {
fileStoreMock.listFileKeys.mockResolvedValueOnce([
'maplist/approved/4.json.deflate',
'maplist/approved/5.json.deflate',
'maplist/approved/3.json.deflate',
'maplist/approved/1.json.deflate'
]);

await service.onModuleInit();

expect(service['version']).toMatchObject({
[FlatMapList.APPROVED]: 5,
[FlatMapList.SUBMISSION]: 0
});

expect(fileStoreMock.deleteFiles).toHaveBeenCalledWith([
'maplist/approved/4.json.deflate',
'maplist/approved/3.json.deflate',
'maplist/approved/1.json.deflate'
]);
});
});
});
100 changes: 100 additions & 0 deletions apps/backend/src/app/modules/maps/map-list.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { FileStoreService } from '../filestore/file-store.service';
import { EXTENDED_PRISMA_SERVICE } from '../database/db.constants';
import { ExtendedPrismaService } from '../database/prisma.extension';
import { DtoFactory } from '../../dto';
import { MapListVersionDto } from '../../dto/map/map-list-version.dto';
import {
CombinedMapStatuses,
FlatMapList,
mapListDir,
mapListPath,
MapStatusNew
} from '@momentum/constants';
import * as zlib from 'node:zlib';
import { promisify } from 'node:util';

@Injectable()
export class MapListService implements OnModuleInit {
constructor(
@Inject(EXTENDED_PRISMA_SERVICE) private readonly db: ExtendedPrismaService,
private readonly fileStoreService: FileStoreService
) {}

private version: Record<FlatMapList, number> = {
[FlatMapList.APPROVED]: 0,
[FlatMapList.SUBMISSION]: 0
};

async onModuleInit(): Promise<void> {
for (const type of [FlatMapList.APPROVED, FlatMapList.SUBMISSION]) {
const keys = await this.fileStoreService.listFileKeys(mapListDir(type));

if (keys.length === 0) {
this.version[type] = 0;
} else if (keys.length === 1) {
this.version[type] = this.extractVersionFromFileKey(keys[0]);
} else {
// If > 1 we have some old versions sitting around for some reason,
// just delete.
const sortedKeys = keys
.map((k) => this.extractVersionFromFileKey(k))
.sort((a, b) => b - a); // Largest to smallest
this.version[type] = sortedKeys[0];
await this.fileStoreService.deleteFiles(
sortedKeys.slice(1).map((k) => mapListPath(type, k))
);
}
}
}

getMapList(): MapListVersionDto {
return DtoFactory(MapListVersionDto, {
approved: this.version[FlatMapList.APPROVED],
submissions: this.version[FlatMapList.SUBMISSION]
});
}

async updateMapList(type: FlatMapList): Promise<void> {
// Important: Seed script (seed.ts) copies this logic, if changing here,
// change there as well.
const maps = await this.db.mMap.findMany({
where: {
status:
type === FlatMapList.APPROVED
? MapStatusNew.APPROVED
: { in: CombinedMapStatuses.IN_SUBMISSION }
},
select: {
id: true,
name: true,
fileName: true,
hash: true,
status: true,
createdAt: true,
thumbnail: true,
leaderboards: true,
info: true
}
});

const mapListJson = JSON.stringify(maps);
const compressed = await promisify(zlib.deflate)(mapListJson);

const oldVersion = this.version[type];
const newVersion = this.updateMapListVersion(type);
const oldKey = mapListPath(type, oldVersion);
const newKey = mapListPath(type, newVersion);

await this.fileStoreService.deleteFile(oldKey);
await this.fileStoreService.storeFile(compressed, newKey);
}

private updateMapListVersion(type: FlatMapList): number {
return ++this.version[type];
}

private extractVersionFromFileKey(key: string): number {
return Number(/(?<=\/)\d+(?=.json)/.exec(key)[0]);
}
}
13 changes: 12 additions & 1 deletion apps/backend/src/app/modules/maps/maps.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ import { MapReviewService } from './map-review.service';
import { MapImageService } from './map-image.service';
import { MapTestingRequestService } from './map-testing-request.service';
import { MapsService } from './maps.service';
import { MapListService } from './map-list.service';
import { MapListVersionDto } from '../../dto/map/map-list-version.dto';

@Controller('maps')
@UseGuards(RolesGuard)
Expand All @@ -93,7 +95,8 @@ export class MapsController {
private readonly mapReviewService: MapReviewService,
private readonly mapImageService: MapImageService,
private readonly mapTestingRequestService: MapTestingRequestService,
private readonly runsService: LeaderboardRunsService
private readonly runsService: LeaderboardRunsService,
private readonly mapListService: MapListService
) {}

//#region Maps
Expand Down Expand Up @@ -155,12 +158,20 @@ export class MapsController {
return this.mapsService.updateAsSubmitter(mapID, userID, body);
}

@Get('/maplistversion')
@ApiOperation({ summary: 'Retrieve the latest map list version number' })
@ApiOkResponse({ type: MapListVersionDto })
getMapListVersion(): MapListVersionDto {
return this.mapListService.getMapList();
}

//#endregion

//#region Map Submission

@Get('/submissions')
@ApiOperation({ summary: 'Retrieve a paginated list of maps in submission' })
@ApiOkResponse({ type: PagedResponseDto<MapDto> })
getSubmissions(
@Query() query: MapsGetAllSubmissionQueryDto,
@LoggedInUser('id') userID: number
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/src/app/modules/maps/maps.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { MapReviewService } from './map-review.service';
import { MapCreditsService } from './map-credits.service';
import { MapImageService } from './map-image.service';
import { MapTestingRequestService } from './map-testing-request.service';
import { MapListService } from './map-list.service';

@Module({
imports: [
Expand All @@ -29,7 +30,8 @@ import { MapTestingRequestService } from './map-testing-request.service';
MapReviewService,
MapCreditsService,
MapImageService,
MapTestingRequestService
MapTestingRequestService,
MapListService
],
exports: [MapsService, MapLibraryService]
})
Expand Down
Loading

0 comments on commit bd02a48

Please sign in to comment.