diff --git a/packages/kcms/src/match/adaptor/controller/match.ts b/packages/kcms/src/match/adaptor/controller/match.ts index 5cd92bca..820633ba 100644 --- a/packages/kcms/src/match/adaptor/controller/match.ts +++ b/packages/kcms/src/match/adaptor/controller/match.ts @@ -125,29 +125,24 @@ export class MatchController { async generateMatchManual( departmentType: DepartmentType, - team1ID: string, - team2ID: string + teamIDs: string[] ): Promise>> { - const res = await this.generateMainMatchService.handle( - departmentType, - team1ID as TeamID, - team2ID as TeamID - ); + const res = await this.generateMainMatchService.handle(departmentType, teamIDs as TeamID[]); if (Result.isErr(res)) return res; const match = Result.unwrap(res); - return Result.ok[]>([ - { - id: match.getID(), - matchCode: `${match.getCourseIndex()}-${match.getMatchIndex()}`, + return Result.ok[]>( + match.map((v) => ({ + id: v.getID(), + matchCode: `${v.getCourseIndex()}-${v.getMatchIndex()}`, matchType: 'main', - departmentType, - team1ID: match.getTeamID1(), - team2ID: match.getTeamID2(), + departmentType: v.getDepartmentType(), + team1ID: v.getTeamID1(), + team2ID: v.getTeamID2(), runResults: [], - winnerID: match.getWinnerID() ?? '', - }, - ]); + winnerID: v.getWinnerID() ?? '', + })) + ); } async getMatchByID( diff --git a/packages/kcms/src/match/adaptor/validator/match.ts b/packages/kcms/src/match/adaptor/validator/match.ts index 222435f5..d63a0387 100644 --- a/packages/kcms/src/match/adaptor/validator/match.ts +++ b/packages/kcms/src/match/adaptor/validator/match.ts @@ -120,8 +120,7 @@ export const PostMatchGenerateManualParamsSchema = z.object({ departmentType: DepartmentTypeSchema, }); export const PostMatchGenerateManualRequestSchema = z.object({ - team1ID: z.string().openapi({ example: '45098607' }), - team2ID: z.string().openapi({ example: '2230392' }), + teamIDs: z.array(z.string().openapi({ example: '45098607' })).min(2), }); export const PostMatchGenerateManualResponseSchema = z.array(ShortMainSchema); diff --git a/packages/kcms/src/match/main.ts b/packages/kcms/src/match/main.ts index c35a7aa3..58a623ff 100644 --- a/packages/kcms/src/match/main.ts +++ b/packages/kcms/src/match/main.ts @@ -1,6 +1,7 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import { Result } from '@mikuroxina/mini-fn'; import { apiReference } from '@scalar/hono-api-reference'; +import { config } from 'config'; import { prismaClient } from '../adaptor'; import { SnowflakeIDGenerator } from '../id/main'; import { errorToCode } from '../team/adaptor/errors'; @@ -56,7 +57,11 @@ const generatePreMatchService = new GeneratePreMatchService( ); const generateRankingService = new GenerateRankingService(preMatchRepository, mainMatchRepository); const fetchRunResultService = new FetchRunResultService(mainMatchRepository, preMatchRepository); -const generateMainMatchService = new GenerateMainMatchService(mainMatchRepository, idGenerator); +const generateMainMatchService = new GenerateMainMatchService( + mainMatchRepository, + idGenerator, + config.match.main.requiredTeams +); const matchController = new MatchController( getMatchService, fetchTeamService, @@ -115,7 +120,7 @@ matchHandler.openapi(PostMatchGenerateManualRoute, async (c) => { const { departmentType } = c.req.valid('param'); const req = c.req.valid('json'); - const res = await matchController.generateMatchManual(departmentType, req.team1ID, req.team2ID); + const res = await matchController.generateMatchManual(departmentType, req.teamIDs); if (Result.isErr(res)) { return c.json({ description: res[1].message }, 400); } diff --git a/packages/kcms/src/match/model/main.ts b/packages/kcms/src/match/model/main.ts index def23b70..9f6ecc28 100644 --- a/packages/kcms/src/match/model/main.ts +++ b/packages/kcms/src/match/model/main.ts @@ -35,8 +35,8 @@ export class MainMatch { private readonly courseIndex: number; private readonly matchIndex: number; private readonly departmentType: DepartmentType; - private readonly teamID1?: TeamID; - private readonly teamID2?: TeamID; + private teamID1?: TeamID; + private teamID2?: TeamID; /** * トーナメントで自分より後に行われる1試合のID\ @@ -95,10 +95,24 @@ export class MainMatch { return this.teamID1; } + setTeamID1(teamID: TeamID) { + if (this.teamID1 !== undefined) { + throw new Error('TeamID1 is already set'); + } + this.teamID1 = teamID; + } + getTeamID2(): TeamID | undefined { return this.teamID2; } + setTeamID2(teamID: TeamID) { + if (this.teamID2 !== undefined) { + throw new Error('TeamID2 is already set'); + } + this.teamID2 = teamID; + } + getWinnerID(): TeamID | undefined { return this.winnerID; } diff --git a/packages/kcms/src/match/service/generateMain.test.ts b/packages/kcms/src/match/service/generateMain.test.ts index 7e21d512..135fd125 100644 --- a/packages/kcms/src/match/service/generateMain.test.ts +++ b/packages/kcms/src/match/service/generateMain.test.ts @@ -1,6 +1,6 @@ import { Result } from '@mikuroxina/mini-fn'; import { config } from 'config'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { SnowflakeIDGenerator } from '../../id/main'; import { TeamID } from '../../team/models/team'; import { DummyMainMatchRepository } from '../adaptor/dummy/mainMatchRepository'; @@ -11,18 +11,105 @@ describe('GenerateMainMatchService', () => { BigInt(new Date('2024/01/01 00:00:00 UTC').getTime()) ); const mainMatchRepository = new DummyMainMatchRepository([]); - const service = new GenerateMainMatchService(mainMatchRepository, idGenerator); + const service = new GenerateMainMatchService( + mainMatchRepository, + idGenerator, + config.match.main.requiredTeams + ); - it.todo('必要なチーム数に一致しない場合はエラーになる', async () => { - + afterEach(() => { + mainMatchRepository.clear(); }); - it.todo("n=2のとき、試合が生成できる"); + it('制約が存在しない場合はエラーになる', async () => { + const res = await service.handle('open', ['1'] as TeamID[]); + expect(Result.isErr(res)).toStrictEqual(true); + }); - it.todo("n=4のとき、試合が生成できる"); + it('必要なチーム数に一致しない場合はエラーになる', async () => { + const res = await service.handle('elementary', ['1', '2'] as TeamID[]); + expect(Result.isErr(res)).toStrictEqual(true); + }); - it.todo("n=8のとき、試合が生成できる"); + it('n=2のとき、試合が生成できる', async () => { + const res = await new GenerateMainMatchService(mainMatchRepository, idGenerator, { + elementary: 2, + }).handle('elementary', ['1', '2'] as TeamID[]); - + expect(Result.isErr(res)).toStrictEqual(false); + const actual = Result.unwrap(res); + expect(actual.length).toStrictEqual(2 - 1); + }); + + it('n=4のとき、試合が生成できる', async () => { + const res = await new GenerateMainMatchService(mainMatchRepository, idGenerator, { + elementary: 4, + }).handle('elementary', ['1', '2', '3', '4'] as TeamID[]); + + expect(Result.isErr(res)).toStrictEqual(false); + const actual = Result.unwrap(res); + expect(actual.length).toStrictEqual(4 - 1); + }); + + it('n=8のとき、試合が生成できる', async () => { + const res = await new GenerateMainMatchService(mainMatchRepository, idGenerator, { + elementary: 8, + }).handle('elementary', ['1', '2', '3', '4', '5', '6', '7', '8'] as TeamID[]); + + expect(Result.isErr(res)).toStrictEqual(false); + const actual = Result.unwrap(res); + expect(actual.length).toStrictEqual(8 - 1); + }); + + it('親が存在しない試合は1つだけ生成される', async () => { + const res = await service.handle('elementary', ['1', '2', '3', '4'] as TeamID[]); + expect(Result.isErr(res)).toStrictEqual(false); + + const actual = Result.unwrap(res); + const notHasParentMatches = actual.filter((m) => m.getParentID() === undefined); + expect(notHasParentMatches.length).toStrictEqual(1); + }); + it('子が存在しない試合はn/2個生成される', async () => { + const res = await service.handle('elementary', ['1', '2', '3', '4'] as TeamID[]); + expect(Result.isErr(res)).toStrictEqual(false); + + const actual = Result.unwrap(res); + const notHasChildMatches = actual.filter((m) => m.getChildMatches() === undefined); + expect(notHasChildMatches.length).toStrictEqual(4 / 2); + }); + + it('孤児試合は生成されない', async () => { + const res = await service.handle('elementary', ['1', '2', '3', '4'] as TeamID[]); + expect(Result.isErr(res)).toStrictEqual(false); + + const actual = Result.unwrap(res); + const orphanMatches = actual.filter( + (m) => m.getChildMatches() === undefined && m.getParentID() === undefined + ); + expect(orphanMatches.length).toStrictEqual(0); + }); + + it('親/子が設定されている場合、それらの試合は必ず存在する', async () => { + const res = await service.handle('elementary', ['1', '2', '3', '4'] as TeamID[]); + expect(Result.isErr(res)).toStrictEqual(false); + + const actual = Result.unwrap(res); + const hasParent = actual.filter((m) => m.getParentID() !== undefined); + const hasChild = actual.filter((m) => m.getChildMatches() !== undefined); + + expect(hasParent.length).toStrictEqual(actual.length - 1); + expect(hasChild.length).toStrictEqual(actual.length - 4 / 2); + + const matchIDs = actual.map((m) => m.getID()); + + for (const match of hasParent) { + expect(matchIDs).toContain(match.getParentID()); + } + + for (const match of hasChild) { + expect(matchIDs).toContain(match.getChildMatches()?.match1.getID()); + expect(matchIDs).toContain(match.getChildMatches()?.match2.getID()); + } + }); }); diff --git a/packages/kcms/src/match/service/generateMain.ts b/packages/kcms/src/match/service/generateMain.ts index f4e56edc..ee192d34 100644 --- a/packages/kcms/src/match/service/generateMain.ts +++ b/packages/kcms/src/match/service/generateMain.ts @@ -2,43 +2,236 @@ import { Result } from '@mikuroxina/mini-fn'; import { DepartmentType } from 'config'; import { SnowflakeIDGenerator } from '../../id/main'; import { TeamID } from '../../team/models/team'; -import { MainMatch, MainMatchID } from '../model/main'; +import { ChildMatches, MainMatch, MainMatchID } from '../model/main'; import { MainMatchRepository } from '../model/repository'; export class GenerateMainMatchService { constructor( private readonly mainMatchRepository: MainMatchRepository, - private readonly idGenerator: SnowflakeIDGenerator + private readonly idGenerator: SnowflakeIDGenerator, + private readonly requiredTeams: Record ) {} async handle( departmentType: DepartmentType, - teamID1: TeamID, - teamID2: TeamID - ): Promise> { - const newIDRes = this.idGenerator.generate(); - if (Result.isErr(newIDRes)) { - return newIDRes; - } - const newID = Result.unwrap(newIDRes); - - const match = MainMatch.new({ - id: newID, - courseIndex: 1, - matchIndex: 1, - departmentType, - runResults: [], - teamID1: teamID1, - teamID2: teamID2, - parentMatchID: '999' as MainMatchID, - childMatches: undefined, - }); - - const matches = await this.mainMatchRepository.create(match); - if (Result.isErr(matches)) { - return matches; - } - - return Result.ok(match); + teamIDs: TeamID[] + ): Promise> { + const requiredTeams = Object.entries(this.requiredTeams).find(([k]) => k === departmentType); + if (!requiredTeams) { + return Result.err(new Error('制約が存在しないため、試合を生成できません(未実装)')); + } + + const requiredTeamCount = requiredTeams[1]; + if (teamIDs.length !== requiredTeamCount) { + return Result.err(new Error('必要なチーム数に一致しません')); + } + + const pairRes = this.generateMatchPair(teamIDs); + if (Result.isErr(pairRes)) { + return pairRes; + } + const pair = Result.unwrap(pairRes); + + const matchesRes = this.generateTournament(pair); + if (Result.isErr(matchesRes)) { + return matchesRes; + } + const matches = Result.unwrap(matchesRes); + + const res = await this.mainMatchRepository.createBulk(matches); + if (Result.isErr(res)) { + return res; + } + + return Result.ok(matches); + } + + /** + * トーナメントの初戦で対戦するチームのペアを生成する + * + * @param teams 予選順位でソートされたチームID + * @returns 生成されたペアのリスト + */ + private generateMatchPair( + teams: TeamID[] + ): Result.Result { + if (teams.length === 2) { + return Result.ok([[teams[0], teams[1]]] as [TeamID | undefined, TeamID | undefined][]); + } + + // 1. 4つのグループに分ける + const splited: [TeamID[], TeamID[], TeamID[], TeamID[]] = [[], [], [], []]; + const chunkSize = Math.floor(teams.length / 4); + let start = 0; + for (let i = 0; i < 4; i++) { + const size = chunkSize + (i < teams.length % 4 ? 1 : 0); + splited[i] = teams.slice(start, start + size); + start += size; + } + /** + * 2. 4つのグループを以下のように組み合わせる + * グループ1-グループ4 + * グループ2-グループ3 + */ + const pairLeft: TeamID[] = [...splited[0], ...splited[1]]; + const pairRight: TeamID[] = [...splited[3], ...splited[2]]; + const ungroupedPair: [TeamID, TeamID][] = []; + for (let i = 0; i < pairLeft.length; i++) { + ungroupedPair.push([pairLeft[i], pairRight[i]]); + } + /* + * 3. 順位で並べてグルーピングする + * グループ数: n/4 + * ToDo: n=4のときは2、n=2のときは1として扱うようにする + */ + const groupNum = ((n: number) => { + if (n === 4) return 2; + if (n === 2) return 1; + return n / 4; + })(teams.length); + + const groupedPair: Map = new Map(); + for (let i = 0; i < ungroupedPair.length; i++) { + const groupIndex = i % groupNum; + if (!groupedPair.has(groupIndex)) { + groupedPair.set(groupIndex, []); + } + groupedPair.get(groupIndex)!.push(ungroupedPair[i]); + } + + return Result.ok([...groupedPair.values()].flat()); + } + + /** + * トーナメントを生成する + */ + private generateTournament( + firstRoundMatches: [TeamID | undefined, TeamID | undefined][] + ): Result.Result { + // K: トーナメントのラウンド数(0が決勝) / V: そのラウンドの試合 + const matches: Map = new Map(); + // 現在のラウンド数(0だとlog_2が0になるため1からスタート) + let round = 1; + /* + 試合を生成 + + 終了条件: + - チーム数-1に達した(試合を生成しきった) + ラウンド終了条件: + - log_2(これまでの生成数)が変わった + + NOTE: 注意 **ラウンドはトーナメント実行順とは逆方向に数えています(例(n=8):初戦: 2, 準決勝: 1, 決勝: 0)** + */ + for (let i = 1; i < firstRoundMatches.length * 2; i++) { + if (Math.floor(Math.log2(i)) != round) { + round = Math.floor(Math.log2(i)); + } + if (!matches.has(round)) { + matches.set(round, []); + } + + // 試合を生成する + // ToDo: 試合番号、部門を正しく埋める + const res = this.generateMainMatch( + 'elementary', + [undefined, undefined], + undefined, + undefined + ); + if (Result.isErr(res)) { + return res; + } + matches.get(round)!.push(Result.unwrap(res)); + } + + // 最初の試合のチームを埋める + for (const v of matches.get([...matches].length - 1)!) { + const pair = firstRoundMatches.shift(); + if (!pair) { + return Result.err(new Error('ペアがありません')); + } + if (pair[0]) v.setTeamID1(pair[0]); + if (pair[1]) v.setTeamID2(pair[1]); + } + + for (let i = [...matches].length - 1; 0 <= i; i--) { + /** 生成手順 + * 1. 自分のIDをparentに持つ試合を、前のラウンドのIDリストから取りして、childにセットする + * - i=0のときはなにもしない + * - 2個にならない場合は終了する + * 2. 左から2こずつ試合を取り出して、上のラウンドのIDリストから1つとってくる(parentIDにセット) + * - 最終ラウンド(決勝)なら、parentはundefinedにする + * 3. 1-2を繰り返す + */ + + const currentRoundMatches = matches.get(i); + if (!currentRoundMatches) { + return Result.err(new Error('試合がありません')); + } + const previousRoundMatches = matches.get(i + 1); + if (i !== [...matches].length - 1 && !previousRoundMatches) { + return Result.err(new Error('前のラウンドの試合がありません')); + } + + for (const match of currentRoundMatches) { + if (i !== [...matches].length - 1) { + // 自分のIDをparentに持つ試合を前のラウンドのidリストから取りだす + const child = previousRoundMatches!.filter((v) => v.getParentID() === match.getID()); + if (child.length !== 2) return Result.err(new Error('前のラウンドの試合が2つありません')); + match.setChildMatches({ + match1: child[0], + match2: child[1], + }); + } + } + + const nextRoundMatchID = matches.get(i - 1)?.map((v) => v.getID()); + if (i !== 0 && !nextRoundMatchID) { + return Result.err(new Error('次のラウンドの試合がありません')); + } + // 全部読み切ったら抜ける + if (!nextRoundMatchID) { + break; + } + + // 左から2こずつ試合を取り出して、上のラウンドのIDリストから1つとってくる(parentIDにセット) + for (let j = 0; j < currentRoundMatches.length; j += 2) { + const pair = currentRoundMatches.slice(j, j + 2); + const parent = nextRoundMatchID!.shift(); + + if (!parent) return Result.err(new Error('次のラウンドのIDを読み切りました')); + pair[0].setParentID(parent); + pair[1].setParentID(parent); + } + } + return Result.ok([...matches.entries()].map((v) => v[1]).flat()); + } + + // ファクトリー関数 + private generateMainMatch( + departmentType: DepartmentType, + pair: [TeamID | undefined, TeamID | undefined], + parent: MainMatchID | undefined, + child: ChildMatches | undefined + ): Result.Result { + const id = this.idGenerator.generate(); + if (Result.isErr(id)) { + return id; + } + + return Result.ok( + MainMatch.new({ + id: Result.unwrap(id), + courseIndex: 1, + matchIndex: 1, + departmentType: departmentType, + teamID1: pair[0], + teamID2: pair[1], + runResults: [], + winnerID: undefined, + parentMatchID: parent, + childMatches: child, + }) + ); } }