Skip to content

Commit

Permalink
Merge branch 'main' into feat/58-run-result-page
Browse files Browse the repository at this point in the history
  • Loading branch information
speak-mentaiko authored Oct 11, 2024
2 parents 4123224 + 908af01 commit c6ad611
Show file tree
Hide file tree
Showing 6 changed files with 226 additions and 13 deletions.
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
);

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

0 comments on commit c6ad611

Please sign in to comment.