Refactor series handling in backend to include player context in Toss, Ban, Pick, and Record actions. Update service methods to accept player parameters, enhancing authorization checks and ensuring proper team actions. Modify integration tests to reflect new method signatures and improve tournament hydration logic in the store. Enhance frontend components to support new series features and improve user experience with live match navigation.
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit is contained in:
2026-07-19 03:22:32 +03:00
parent 4b8218ec14
commit 5d3e66d2f1
16 changed files with 449 additions and 62 deletions

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { adaptEvent, adaptPlayer, adaptSeries, api, type RawEvent, type RawPlayer, type RawSeries } from './client'
import { adaptEvent, adaptPlayer, adaptSeries, adaptTournament, api, type RawEvent, type RawPlayer, type RawSeries } from './client'
const player: RawPlayer = {
id: 'player-1',
@@ -79,6 +79,21 @@ describe('backend DTO adapters', () => {
}
expect(adaptSeries(series).score).toEqual({ alpha: 0, beta: 1 })
expect(adaptSeries({ ...series, phase: 'CoinTossPending', results: null, maps: null, audit: null }).score).toEqual({ alpha: 0, beta: 0 })
expect(adaptTournament({ id: 't-1', eventId: 'event-1', name: 'Cup', winnerTeamId: '', teamIds: ['a', 'b'], rounds: [[{ ...series, phase: 'MapBan' }]] }).matches[0].status).toBe('live')
const heroDraft = adaptSeries({
...series,
phase: 'HeroBan',
seriesBans: { a: ['Ana'], b: ['Tracer'] },
heroDraft: {
heroes: [{ name: 'Ana', role: 'Support' }, { name: 'Kiriko', role: 'Support' }],
teamIds: ['a', 'b'],
firstTeamId: 'a',
bansPerTeam: 2,
currentBans: [],
},
})
expect(heroDraft.heroBanHistory).toEqual({ alpha: ['Ana'], beta: ['Tracer'] })
expect(heroDraft.options.find((option) => option.name === 'Ana')).toMatchObject({ disabled: true })
})
})

View File

@@ -82,6 +82,7 @@ export interface DraftAction {
}
export interface Series {
id: string
eventId: string
tournamentId?: string
status: 'scheduled' | 'coin_toss' | 'map_draft' | 'hero_draft' | 'playing' | 'completed'
roundLabel: string
@@ -97,6 +98,7 @@ export interface Series {
version: number
}
options: DraftOption[]
heroBanHistory: Record<TeamSide, string[]>
maps: Array<{
number: number
name: string
@@ -109,7 +111,7 @@ export interface Series {
}
export interface BracketMatch {
id: string; round: number; slot: number; label: string
teamAlpha?: string; teamBeta?: string; scoreAlpha?: number; scoreBeta?: number
teamAlpha?: string; teamBeta?: string; teamAlphaId?: string; teamBetaId?: string; scoreAlpha?: number; scoreBeta?: number
winner?: TeamSide; seriesId?: string; status: 'pending' | 'ready' | 'live' | 'completed'
}
export interface Bracket { id: string; title: string; rounds: string[]; matches: BracketMatch[] }
@@ -164,6 +166,7 @@ export interface RawSeries {
mapDraft?: { pool: string[] | null; banned: string[] | null; firstTeamId: string; teamIds: [string, string]; actions: RawDraftAction[] | null }
heroDraft?: { heroes: Array<{ name: string; role: 'Tank' | 'Damage' | 'Support' }> | null; teamIds: [string, string]; firstTeamId: string; bansPerTeam: number; currentBans: RawDraftAction[] | null }
currentMap: string; playedMaps: string[] | null; nextMapPickerId: string; availableMaps: string[] | null
seriesBans?: Record<string, string[]> | null
maps: Array<{ number: number; name: string; heroBans: RawDraftAction[] | null; result?: RawMapResult }> | null
audit: Array<{ kind: DraftAction['kind']; summary: string; teamId?: string; actorAccountId: string; at: string }> | null
results: RawMapResult[] | null; version: number
@@ -345,10 +348,14 @@ export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
: phase === 'MapPick'
? (raw.availableMaps ?? []).map((name) => ({ id: name, name }))
: phase === 'HeroBan' && raw.heroDraft
? (raw.heroDraft.heroes ?? []).filter((hero) => !(raw.heroDraft?.currentBans ?? []).some((ban) => ban.value === hero.name)).map((hero) => ({ id: hero.name, name: hero.name, role: roleFromRaw(hero.role) }))
? (raw.heroDraft.heroes ?? []).filter((hero) => !(raw.heroDraft?.currentBans ?? []).some((ban) => ban.value === hero.name)).map((hero) => {
const repeated = (raw.seriesBans?.[nextHeroTeam ?? ''] ?? []).includes(hero.name)
return { id: hero.name, name: hero.name, role: roleFromRaw(hero.role), disabled: repeated, disabledReason: repeated ? 'Already banned by this team in this Bo3' : undefined }
})
: []
return {
id: raw.id,
eventId: raw.eventId,
tournamentId: raw.tournamentId,
status,
roundLabel: 'Tournament series',
@@ -364,13 +371,20 @@ export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
version: raw.version,
},
options,
heroBanHistory: {
alpha: raw.seriesBans?.[raw.teamAId] ?? [],
beta: raw.seriesBans?.[raw.teamBId] ?? [],
},
maps: (raw.maps ?? activeResults.map((result, index) => ({ number: index + 1, name: result.mapName, heroBans: [], result }))).map((map) => ({
number: map.number,
name: map.name,
mode: 'Map',
status: map.result ? 'completed' : phase === 'Playing' ? 'playing' : phase === 'HeroBan' ? 'ready' : 'drafting',
winner: map.result ? (map.result.outcome === 'TeamAWin' ? 'alpha' : map.result.outcome === 'TeamBWin' ? 'beta' : 'draw') : undefined,
heroBans: (map.heroBans ?? []).map((ban) => ({ hero: ban.value, team: ban.teamId === raw.teamAId ? 'alpha' : 'beta', role: 'damage' as PlayerRole })),
heroBans: (map.heroBans ?? []).map((ban) => {
const heroRole = raw.heroDraft?.heroes?.find((hero) => hero.name === ban.value)?.role
return { hero: ban.value, team: ban.teamId === raw.teamAId ? 'alpha' : 'beta', role: heroRole ? roleFromRaw(heroRole) : 'damage' as PlayerRole }
}),
})),
audit: (raw.audit ?? []).map((action, index) => ({ id: `action-${index}`, kind: action.kind, summary: action.summary, actorName: action.actorAccountId, createdAt: action.at })),
}
@@ -390,11 +404,13 @@ export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket
label: `Match ${slot + 1}`,
teamAlpha: names.get(series.teamAId) ?? (series.teamAId || undefined),
teamBeta: names.get(series.teamBId) ?? (series.teamBId || undefined),
teamAlphaId: series.teamAId || undefined,
teamBetaId: series.teamBId || undefined,
scoreAlpha: adapted.score.alpha,
scoreBeta: adapted.score.beta,
winner: series.winnerTeamId ? (series.winnerTeamId === series.teamAId ? 'alpha' : 'beta') : undefined,
seriesId: series.id,
status: series.winnerTeamId ? 'completed' : series.teamAId && series.teamBId ? 'ready' : 'pending',
status: series.winnerTeamId ? 'completed' : series.teamAId && series.teamBId && series.phase ? 'live' : series.teamAId && series.teamBId ? 'ready' : 'pending',
} satisfies BracketMatch
})),
}

View File

@@ -102,6 +102,7 @@ export const demoEvents: MixEvent[] = [
version: 6,
rulesetId: 'standard-control-hybrid-control',
activeSeriesId: 'series-1',
tournamentId: 'bracket-1',
},
{
id: 'event-4',
@@ -224,6 +225,8 @@ export const demoBalanceCandidates: BalanceCandidate[] = [
export const demoSeries: Series = {
id: 'series-1',
eventId: 'event-3',
tournamentId: 'bracket-1',
status: 'hero_draft',
roundLabel: 'Semifinal 1',
teamAlpha: alpha,
@@ -245,6 +248,10 @@ export const demoSeries: Series = {
{ id: 'hero-5', name: 'Winston', role: 'tank', disabled: true, disabledReason: 'Already banned by this team' },
{ id: 'hero-6', name: 'Sigma', role: 'tank' },
],
heroBanHistory: {
alpha: ['Winston', 'D.Va'],
beta: ['Ana', 'Tracer'],
},
maps: [
{
number: 1,
@@ -282,8 +289,8 @@ export const demoBracket: Bracket = {
title: 'Summer Clash #4',
rounds: ['Semifinals', 'Grand Final'],
matches: [
{ id: 'm1', round: 0, slot: 0, label: 'Semifinal 1', teamAlpha: 'Ember Wolves', teamBeta: 'Azure Phantoms', scoreAlpha: 1, scoreBeta: 0, status: 'live', seriesId: 'series-1' },
{ id: 'm2', round: 0, slot: 1, label: 'Semifinal 2', teamAlpha: 'Neon Foxes', teamBeta: 'Void Runners', scoreAlpha: 2, scoreBeta: 1, winner: 'alpha', status: 'completed', seriesId: 'series-2' },
{ id: 'm1', round: 0, slot: 0, label: 'Semifinal 1', teamAlpha: 'Ember Wolves', teamBeta: 'Azure Phantoms', teamAlphaId: 'team-alpha', teamBetaId: 'team-beta', scoreAlpha: 1, scoreBeta: 0, status: 'live', seriesId: 'series-1' },
{ id: 'm2', round: 0, slot: 1, label: 'Semifinal 2', teamAlpha: 'Neon Foxes', teamBeta: 'Void Runners', teamAlphaId: 'team-neon', teamBetaId: 'team-void', scoreAlpha: 0, scoreBeta: 1, status: 'live', seriesId: 'series-2' },
{ id: 'm3', round: 1, slot: 0, label: 'Grand Final', teamBeta: 'Neon Foxes', status: 'pending' },
],
}