Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ランキング生成APIの実装 #464

Merged
merged 8 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion packages/kcms/src/match/adaptor/controller/match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { FetchTeamService } from '../../../team/service/get';
import { MainMatchID } from '../../model/main';
import { PreMatch, PreMatchID } from '../../model/pre';
import { GeneratePreMatchService } from '../../service/generatePre';
import { GenerateRankingService } from '../../service/generateRanking';
import { GetMatchService } from '../../service/get';
import {
GetMatchIdResponseSchema,
GetMatchResponseSchema,
GetMatchTypeResponseSchema,
GetRankingResponseSchema,
MainSchema,
PostMatchGenerateResponseSchema,
PreSchema,
Expand All @@ -21,7 +23,8 @@ export class MatchController {
constructor(
private readonly getMatchService: GetMatchService,
private readonly fetchTeamService: FetchTeamService,
private readonly generatePreMatchService: GeneratePreMatchService
private readonly generatePreMatchService: GeneratePreMatchService,
private readonly generateRankingService: GenerateRankingService
) {}

async getAll(): Promise<Result.Result<Error, z.infer<typeof GetMatchResponseSchema>>> {
Expand Down Expand Up @@ -247,4 +250,32 @@ export class MatchController {
);
}
}

async getRanking(
matchType: MatchType,
departmentType: DepartmentType
): Promise<Result.Result<Error, z.infer<typeof GetRankingResponseSchema>>> {
if (matchType !== 'pre') {
return Result.err(new Error('Not implemented'));
}

const rankingRes = await this.generateRankingService.generatePreMatchRanking(departmentType);
if (Result.isErr(rankingRes)) return rankingRes;
const ranking = Result.unwrap(rankingRes);

// NOTE: 一つずつ取得しても良いが、エラーの扱いが煩雑になるので簡単化のために*一時的に*全て取得するようにした
const teamsRes = await this.fetchTeamService.findAll();
if (Result.isErr(teamsRes)) return teamsRes;
const teamsMap = new Map<TeamID, Team>(Result.unwrap(teamsRes).map((v) => [v.getId(), v]));

return Result.ok(
ranking.map((v) => ({
rank: v.rank,
teamID: v.teamID,
teamName: teamsMap.get(v.teamID)!.getTeamName(),
points: v.points,
goalTimeSeconds: v.goalTimeSeconds,
}))
);
}
}
2 changes: 1 addition & 1 deletion packages/kcms/src/match/adaptor/validator/match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export const PostMatchRunResultRequestSchema = z.array(RunResultSchema).max(4).m

export const GetRankingParamsSchema = z.object({
matchType: MatchTypeSchema,
DepartmentType: DepartmentTypeSchema,
departmentType: DepartmentTypeSchema,
});

export const GetRankingResponseSchema = z.array(
Expand Down
17 changes: 16 additions & 1 deletion packages/kcms/src/match/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import {
GetMatchIdRoute,
GetMatchRoute,
GetMatchTypeRoute,
GetRankingRoute,
PostMatchGenerateRoute,
} from './routing';
import { GeneratePreMatchService } from './service/generatePre';
import { GenerateRankingService } from './service/generateRanking';
import { GetMatchService } from './service/get';

const isProduction = process.env.NODE_ENV === 'production';
Expand All @@ -41,10 +43,12 @@ const generatePreMatchService = new GeneratePreMatchService(
idGenerator,
preMatchRepository
);
const generateRankingService = new GenerateRankingService(preMatchRepository);
const matchController = new MatchController(
getMatchService,
fetchTeamService,
generatePreMatchService
generatePreMatchService,
generateRankingService
);

export const matchHandler = new OpenAPIHono();
Expand Down Expand Up @@ -92,6 +96,17 @@ matchHandler.openapi(GetMatchTypeRoute, async (c) => {
return c.json(Result.unwrap(res), 200);
});

matchHandler.openapi(GetRankingRoute, async (c) => {
const { matchType, departmentType } = c.req.valid('param');

const res = await matchController.getRanking(matchType, departmentType);
if (Result.isErr(res)) {
const error = Result.unwrapErr(res);
return c.json({ description: error.message }, 400);
}
return c.json(Result.unwrap(res), 200);
});

matchHandler.doc('/openapi/match.json', {
openapi: '3.0.0',
info: {
Expand Down
37 changes: 37 additions & 0 deletions packages/kcms/src/match/service/generateRanking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Result } from '@mikuroxina/mini-fn';
import { describe, expect, it } from 'vitest';
import { TeamID } from '../../team/models/team';
import { testRankingPreMatchData } from '../../testData/match';
import { DummyPreMatchRepository } from '../adaptor/dummy/preMatchRepository';
import { GenerateRankingService } from './generateRanking';

describe('GenerateRankingService', () => {
const preMatchRepository = new DummyPreMatchRepository(testRankingPreMatchData);
const service = new GenerateRankingService(preMatchRepository);

it('部門ごとのランキングが正しく生成できる', async () => {
const res = await service.generatePreMatchRanking('elementary');
expect(Result.isErr(res)).toBe(false);
expect(Result.unwrap(res)).toHaveLength(11);
});

it('予選: 同じ順位の場合、ゴールタイムでソートする', async () => {
const expected = [
{ rank: 1, teamID: '1' as TeamID, points: 12, goalTimeSeconds: 60 },
{ rank: 2, teamID: '2' as TeamID, points: 10, goalTimeSeconds: 64 },
{ rank: 3, teamID: '3' as TeamID, points: 9, goalTimeSeconds: 70 },
{ rank: 4, teamID: '4' as TeamID, points: 7, goalTimeSeconds: 74 },
{ rank: 5, teamID: '5' as TeamID, points: 5, goalTimeSeconds: 80 },
{ rank: 6, teamID: '7' as TeamID, points: 4, goalTimeSeconds: 90 },
{ rank: 7, teamID: '6' as TeamID, points: 4, goalTimeSeconds: 100 },
{ rank: 8, teamID: '8' as TeamID, points: 1, goalTimeSeconds: 100 },
{ rank: 8, teamID: '9' as TeamID, points: 1, goalTimeSeconds: 100 },
{ rank: 9, teamID: '10' as TeamID, points: 0, goalTimeSeconds: Infinity },
{ rank: 9, teamID: '11' as TeamID, points: 0, goalTimeSeconds: Infinity },
];

const res = await service.generatePreMatchRanking('elementary');
expect(Result.isErr(res)).toBe(false);
expect(Result.unwrap(res)).toStrictEqual(expected);
});
});
109 changes: 109 additions & 0 deletions packages/kcms/src/match/service/generateRanking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Result } from '@mikuroxina/mini-fn';
import { DepartmentType } from 'config';
import { TeamID } from '../../team/models/team';
import { PreMatchRepository } from '../model/repository';

export interface RankingDatum {
rank: number;
teamID: TeamID;
points: number;
goalTimeSeconds: number;
}

export class GenerateRankingService {
constructor(private readonly preMatchRepository: PreMatchRepository) {}

async generatePreMatchRanking(
departmentType: DepartmentType
): Promise<Result.Result<Error, RankingDatum[]>> {
const matchesRes = await this.preMatchRepository.findAll();
if (Result.isErr(matchesRes)) {
return matchesRes;
}
const departmentMatches = Result.unwrap(matchesRes).filter(
(match) => match.getDepartmentType() === departmentType
);

// 各チームごとに走行結果を集める
const teamResults = new Map<TeamID, { points: number; goalTimeSeconds: number }>();
for (const v of departmentMatches) {
// チーム1の結果
const team1 = v.getTeamId1();
// 存在しないなら何もしない
if (team1 !== undefined) {
const team1Result = v.getRunResults().filter((v) => v.getTeamId() === team1);
// 試合が始まっていないなら
if (team1Result.length === 0) {
teamResults.set(team1, {
points: 0,
goalTimeSeconds: Infinity,
});
} else if (teamResults.has(team1)) {
const prev = teamResults.get(team1)!;
// 1つの予選試合には1回ずつしか結果がないので、0個めを取る
teamResults.set(team1, {
points: prev.points + team1Result[0].getPoints(),
// NOTE: 早い方を採用する
goalTimeSeconds: Math.min(prev.goalTimeSeconds, team1Result[0].getGoalTimeSeconds()),
});
} else {
teamResults.set(team1, {
points: team1Result[0].getPoints(),
goalTimeSeconds: team1Result[0].getGoalTimeSeconds(),
});
}
}

// チーム2の結果
const team2 = v.getTeamId2();
// 存在しないなら何もしない
if (team2 !== undefined) {
const team2Result = v.getRunResults().filter((v) => v.getTeamId() === team2);
if (team2Result.length === 0) {
teamResults.set(team2, {
points: 0,
goalTimeSeconds: Infinity,
});
} else if (teamResults.has(team2)) {
const prev = teamResults.get(team2)!;
teamResults.set(team2, {
// 1つの予選試合には1回ずつしか結果がないので、0個めを取る
points: prev.points + team2Result[0].getPoints(),
goalTimeSeconds: Math.min(prev.goalTimeSeconds, team2Result[0].getGoalTimeSeconds()),
});
} else {
teamResults.set(team2, {
points: team2Result[0].getPoints(),
goalTimeSeconds: team2Result[0].getGoalTimeSeconds(),
});
}
}
}
// 点数でソート (ゴールタイム: 早い順(asc)、ポイント: 大きい順(desc))
const sortedTeams = [...teamResults.entries()].sort((a, b) =>
a[1].points === b[1].points
? a[1].goalTimeSeconds - b[1].goalTimeSeconds
: b[1].points - a[1].points
laminne marked this conversation as resolved.
Show resolved Hide resolved
);

const rankingData: RankingDatum[] = [];
// NOTE: Mapをfor..ofで回すと[key,value]しか取れないので一回展開して[index,value]にしている
for (const [i, v] of [...sortedTeams.entries()]) {
/** 前回の点数 */
const prevPoints = rankingData[i - 1]?.points ?? -1;
/** 前回のゴールタイム */
const prevGoalTime = rankingData[i - 1]?.goalTimeSeconds ?? Infinity;
const rank = rankingData[i - 1]?.rank ?? 0;
const isSameRank = v[1].points === prevPoints && v[1].goalTimeSeconds === prevGoalTime;

rankingData.push({
rank: isSameRank ? rank : rank + 1,
teamID: v[0],
points: v[1].points,
goalTimeSeconds: v[1].goalTimeSeconds,
});
}

return Result.ok(rankingData);
}
}
41 changes: 31 additions & 10 deletions packages/kcms/src/testData/match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import { TeamID } from '../team/models/team.js';
// ランキング生成用の試合データ
// Openは予選がないので、予選の試合データはない
/*
チームID: 1 2 3 4 5 6 7 8 9
得点: 12, 10, 9, 7, 5, 4, 4, 2 1
時間: 60, 64, 70, 74, 80, 90, 100, 180 200
チームID: 1 2 3 4 5 6 7 8 9 10 11
得点: 12, 10, 9, 7, 5, 4, 4, 1 1 n/a n/a
時間: 60, 64, 70, 74, 80, 100, 90, 100, 100 n/a n/a

10,11は試合が始まっていない
*/
export const testRankingPreMatchData = [
PreMatch.new({
Expand Down Expand Up @@ -129,7 +131,7 @@ export const testRankingPreMatchData = [
id: '19' as RunResultID,
teamId: '6' as TeamID,
points: 4,
goalTimeSeconds: 90,
goalTimeSeconds: 100,
finishState: 'GOAL',
}),
],
Expand Down Expand Up @@ -171,14 +173,14 @@ export const testRankingPreMatchData = [
id: '22' as RunResultID,
teamId: '7' as TeamID,
points: 4,
goalTimeSeconds: 100,
goalTimeSeconds: 90,
finishState: 'GOAL',
}),
RunResult.new({
id: '23' as RunResultID,
teamId: '8' as TeamID,
points: 2,
goalTimeSeconds: 180,
points: 1,
goalTimeSeconds: 100,
finishState: 'GOAL',
}),
],
Expand Down Expand Up @@ -220,7 +222,7 @@ export const testRankingPreMatchData = [
id: '26' as RunResultID,
teamId: '9' as TeamID,
points: 1,
goalTimeSeconds: 200,
goalTimeSeconds: 100,
finishState: 'GOAL',
}),
],
Expand All @@ -237,11 +239,30 @@ export const testRankingPreMatchData = [
id: '27' as RunResultID,
teamId: '9' as TeamID,
points: 0,
goalTimeSeconds: Infinity,
finishState: 'FINISHED',
goalTimeSeconds: 100,
finishState: 'GOAL',
}),
],
}),

PreMatch.new({
id: '108' as PreMatchID,
courseIndex: 0,
matchIndex: 11,
departmentType: config.departments[0].type,
teamId1: '10' as TeamID,
teamId2: '11' as TeamID,
runResults: [],
}),
PreMatch.new({
id: '109' as PreMatchID,
courseIndex: 0,
matchIndex: 12,
departmentType: config.departments[0].type,
teamId1: '11' as TeamID,
teamId2: '10' as TeamID,
runResults: [],
}),
];

export const testRankingMainMatchData = [
Expand Down