diff --git a/packages/kcms/src/match/adaptor/controller/match.ts b/packages/kcms/src/match/adaptor/controller/match.ts index 6fbfe4e7..c22f5f91 100644 --- a/packages/kcms/src/match/adaptor/controller/match.ts +++ b/packages/kcms/src/match/adaptor/controller/match.ts @@ -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, @@ -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>> { @@ -247,4 +250,32 @@ export class MatchController { ); } } + + async getRanking( + matchType: MatchType, + departmentType: DepartmentType + ): Promise>> { + 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(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, + })) + ); + } } diff --git a/packages/kcms/src/match/adaptor/validator/match.ts b/packages/kcms/src/match/adaptor/validator/match.ts index b083ac59..aa411da1 100644 --- a/packages/kcms/src/match/adaptor/validator/match.ts +++ b/packages/kcms/src/match/adaptor/validator/match.ts @@ -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( diff --git a/packages/kcms/src/match/main.ts b/packages/kcms/src/match/main.ts index 70c6e710..5a49fe01 100644 --- a/packages/kcms/src/match/main.ts +++ b/packages/kcms/src/match/main.ts @@ -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'; @@ -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(); @@ -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: { diff --git a/packages/kcms/src/match/service/generateRanking.test.ts b/packages/kcms/src/match/service/generateRanking.test.ts new file mode 100644 index 00000000..6c16b7ba --- /dev/null +++ b/packages/kcms/src/match/service/generateRanking.test.ts @@ -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); + }); +}); diff --git a/packages/kcms/src/match/service/generateRanking.ts b/packages/kcms/src/match/service/generateRanking.ts new file mode 100644 index 00000000..f232fd71 --- /dev/null +++ b/packages/kcms/src/match/service/generateRanking.ts @@ -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> { + 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(); + 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); + } +} diff --git a/packages/kcms/src/testData/match.ts b/packages/kcms/src/testData/match.ts index de116886..20398241 100644 --- a/packages/kcms/src/testData/match.ts +++ b/packages/kcms/src/testData/match.ts @@ -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({ @@ -129,7 +131,7 @@ export const testRankingPreMatchData = [ id: '19' as RunResultID, teamId: '6' as TeamID, points: 4, - goalTimeSeconds: 90, + goalTimeSeconds: 100, finishState: 'GOAL', }), ], @@ -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', }), ], @@ -220,7 +222,7 @@ export const testRankingPreMatchData = [ id: '26' as RunResultID, teamId: '9' as TeamID, points: 1, - goalTimeSeconds: 200, + goalTimeSeconds: 100, finishState: 'GOAL', }), ], @@ -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 = [