Add player profiles and community settings
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 21:43:48 +03:00
parent 5e0825f43f
commit fa4edc0df5
23 changed files with 1280 additions and 57 deletions

View File

@@ -5,6 +5,7 @@ const player: RawPlayer = {
id: 'player-1',
accountId: 'account-1',
displayName: 'Nova',
battleTag: 'Nova#1234',
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['Tank'],
preferredPlayerIds: ['player-2'],
@@ -19,6 +20,7 @@ describe('backend DTO adapters', () => {
it('adapts backend role ratings without changing their values', () => {
expect(adaptPlayer(player)).toMatchObject({
displayName: 'Nova',
battleTag: 'Nova#1234',
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['tank'],
preferredPlayerIds: ['player-2'],
@@ -118,10 +120,38 @@ describe('actual backend routes', () => {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.updateRatings('Nova', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'], ['player-3'])
await api.updateRatings('Nova Prime', 'Nova#5678', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'], ['player-3'])
expect(fetchMock).toHaveBeenCalledWith('/api/me/player', expect.objectContaining({ method: 'PATCH' }))
expect(fetchMock.mock.calls[0][1]?.body).toContain('"preferredRoles":["Tank","Damage"]')
expect(fetchMock.mock.calls[0][1]?.body).toContain('"avoidedPlayerIds":["player-3"]')
expect(fetchMock.mock.calls[0][1]?.body).toContain('"displayName":"Nova Prime"')
expect(fetchMock.mock.calls[0][1]?.body).toContain('"battleTag":"Nova#5678"')
})
it('loads public profile history and updates community settings', async () => {
const publicProfile = {
player: {
id: player.id,
displayName: player.displayName,
battleTag: player.battleTag,
ratings: player.ratings,
isGuest: false,
},
summary: { seriesPlayed: 3, seriesWon: 2, seriesLost: 1, winRate: 2 / 3, mapsPlayed: 7, mapsWon: 4, mapsLost: 2, mapsDrawn: 1 },
recentMatches: [{ seriesId: 'series-1', eventId: 'event-1', eventName: 'Cup', eventDate: '2026-07-18T00:00:00Z', ownTeamId: 'a', ownTeamName: 'A', opponentTeamId: 'b', opponentTeamName: 'B', ownScore: 2, opponentScore: 1, outcome: 'win' }],
}
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response(JSON.stringify(publicProfile), { status: 200, headers: { 'Content-Type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({ discordInviteUrl: 'https://discord.gg/test' }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
const profile = await api.playerProfile(player.id)
expect(profile.stats.mapsDrawn).toBe(1)
expect(profile.recentMatches[0].outcome).toBe('win')
await api.updateCommunitySettings('https://discord.gg/test')
expect(fetchMock.mock.calls[0][0]).toBe('/api/players/player-1')
expect(fetchMock.mock.calls[1]).toEqual(['/api/community/settings', expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ discordInviteUrl: 'https://discord.gg/test' }),
})])
})
it('sends backend RSVP enum values to the player-specific route', async () => {

View File

@@ -18,6 +18,35 @@ export interface Player {
preferredPlayerIds: string[]
avoidedPlayerIds: string[]
}
export interface PlayerStats {
seriesPlayed: number
seriesWon: number
seriesLost: number
winRate: number
mapsPlayed: number
mapsWon: number
mapsLost: number
mapsDrawn: number
}
export interface RecentMatch {
seriesId: string
eventId: string
eventName: string
startedAt: string
teamId: string
teamName: string
opponentTeamId: string
opponentTeamName: string
scoreFor: number
scoreAgainst: number
outcome: 'win' | 'loss'
}
export interface PublicPlayerProfile extends Player {
isGuest: boolean
stats: PlayerStats
recentMatches: RecentMatch[]
}
export interface CommunitySettings { discordInviteUrl: string }
export interface Session {
account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole }
player: Player
@@ -154,13 +183,36 @@ export interface RawAccount {
}
export interface RawRatings { tank: number; damage: number; support: number }
export interface RawPlayer {
id: string; accountId: string; displayName: string; ratings: RawRatings
id: string; accountId: string; displayName: string; battleTag?: string; ratings: RawRatings
preferredRoles?: Array<'Tank' | 'Damage' | 'Support'>
preferredPlayerIds?: string[]
avoidedPlayerIds?: string[]
createdAt: string; updatedAt: string
}
export interface RawSession { account: RawAccount; player: RawPlayer }
export interface RawPlayerProfile {
player: {
id: string
displayName: string
battleTag: string
ratings: RawRatings
isGuest: boolean
}
summary: PlayerStats
recentMatches: Array<{
seriesId: string
eventId: string
eventName: string
eventDate: string
ownTeamId: string
ownTeamName: string
opponentTeamId: string
opponentTeamName: string
ownScore: number
opponentScore: number
outcome: 'win' | 'loss'
}>
}
export interface RawEvent {
id: string; name: string; description: string; startsAt: string; endsAt: string
registrationDeadline: string; createdBy: string; createdAt: string; updatedAt: string
@@ -223,6 +275,7 @@ export function adaptPlayer(raw: RawPlayer): Player {
id: raw.id,
accountId: raw.accountId,
displayName: raw.displayName,
battleTag: raw.battleTag || undefined,
ratings,
preferredRoles,
preferredPlayerIds: raw.preferredPlayerIds ?? [],
@@ -529,6 +582,7 @@ export const api = {
profile: async () => adaptPlayer((await raw.session()).player),
updateRatings: async (
displayName: string,
battleTag: string,
ratings: Pick<Ratings, PlayerRole>,
preferredRoles: PlayerRole[],
preferredPlayerIds: string[],
@@ -538,13 +592,50 @@ export const api = {
method: 'PATCH',
body: JSON.stringify({
displayName,
battleTag: battleTag.trim(),
ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support },
preferredRoles: preferredRoles.map((role) => role === 'tank' ? 'Tank' : role === 'damage' ? 'Damage' : 'Support'),
preferredPlayerIds,
avoidedPlayerIds,
}),
})),
playerProfile: async (playerId: string): Promise<PublicPlayerProfile> => {
const profile = await request<RawPlayerProfile>(`/players/${playerId}`)
return {
id: profile.player.id,
accountId: profile.player.isGuest ? undefined : 'linked',
displayName: profile.player.displayName,
battleTag: profile.player.battleTag || undefined,
ratings: {
tank: toRankOrdinal(profile.player.ratings.tank),
damage: toRankOrdinal(profile.player.ratings.damage),
support: toRankOrdinal(profile.player.ratings.support),
updatedAt: '',
},
preferredRoles: [],
preferredPlayerIds: [],
avoidedPlayerIds: [],
isGuest: profile.player.isGuest,
stats: profile.summary,
recentMatches: profile.recentMatches.map((match) => ({
seriesId: match.seriesId,
eventId: match.eventId,
eventName: match.eventName,
startedAt: match.eventDate,
teamId: match.ownTeamId,
teamName: match.ownTeamName,
opponentTeamId: match.opponentTeamId,
opponentTeamName: match.opponentTeamName,
scoreFor: match.ownScore,
scoreAgainst: match.opponentScore,
outcome: match.outcome,
})),
} satisfies PublicPlayerProfile
},
players: async () => (await raw.players()).map(adaptPlayer),
communitySettings: () => request<CommunitySettings>('/community/settings'),
updateCommunitySettings: (discordInviteUrl: string) =>
request<CommunitySettings>('/community/settings', { method: 'PUT', body: JSON.stringify({ discordInviteUrl }) }),
accounts: () => request<RawAccount[]>('/accounts'),
setModerator: (accountId: string, moderator: boolean) =>
request<RawAccount>(`/accounts/${accountId}/moderator`, { method: 'PATCH', body: JSON.stringify({ moderator }) }),

View File

@@ -4,6 +4,7 @@ import type {
BracketDraft,
MixEvent,
Player,
PublicPlayerProfile,
Registration,
Series,
Session,
@@ -307,3 +308,50 @@ export const demoBracketDraft: BracketDraft = {
{ id: 'draft-m3', round: 2, order: 0, slotA: { kind: 'Winner', matchId: 'draft-m1' }, slotB: { kind: 'Winner', matchId: 'draft-m2' } },
],
}
export const demoCommunitySettings = {
discordInviteUrl: 'https://discord.gg/mixmaker',
}
export const demoPlayerProfiles: PublicPlayerProfile[] = players.map((player, index) => ({
...player,
isGuest: !player.accountId,
stats: {
seriesPlayed: 18 + index,
seriesWon: 11 + (index % 4),
seriesLost: 7 + (index % 3),
winRate: Math.round(((11 + (index % 4)) / (18 + index)) * 100),
mapsPlayed: 42 + index * 2,
mapsWon: 25 + index,
mapsLost: 14 + index,
mapsDrawn: 3,
},
recentMatches: [
{
seriesId: 'series-1',
eventId: 'event-3',
eventName: 'Summer Clash #4',
startedAt: daysFromNow(-2, 20),
teamId: 'team-alpha',
teamName: 'Ember Wolves',
opponentTeamId: 'team-beta',
opponentTeamName: 'Azure Phantoms',
scoreFor: 2,
scoreAgainst: 1,
outcome: 'win',
},
{
seriesId: 'series-2',
eventId: 'event-5',
eventName: 'Completed Friday Mix',
startedAt: daysFromNow(-7, 20),
teamId: 'team-alpha',
teamName: 'Ember Wolves',
opponentTeamId: 'team-beta',
opponentTeamName: 'Neon Foxes',
scoreFor: 1,
scoreAgainst: 2,
outcome: 'loss',
},
],
}))