Algoritmo de Alocação de Quadras
Sport Tech Club - Sistema Inteligente de Gestão de Quadras
Visão Geral
O algoritmo de alocação de quadras é o coração do Sport Tech Club, responsável por:
- Gestão dinâmica de filas de espera
- Sistema "winner stays" (vencedor fica)
- Balanceamento de jogadores por nível de habilidade
- Otimização de ocupação das quadras
1. Modelo de Domínio
1.1 Entidades Principais
typescript
// Quadra
interface Court {
id: string;
arenaId: string;
name: string;
sport: Sport;
surface: Surface;
status: CourtStatus;
currentSession?: GameSession;
queue: WaitingQueue;
config: CourtConfig;
}
// Sessão de Jogo
interface GameSession {
id: string;
courtId: string;
teams: Team[];
startTime: Date;
maxDuration: number; // minutos
score: Score;
status: SessionStatus;
winnerStays: boolean;
}
// Time
interface Team {
id: string;
players: Player[];
skillRating: number; // média do time
waitingSince?: Date;
gamesPlayed: number; // na sessão atual
}
// Jogador
interface Player {
id: string;
userId: string;
name: string;
skillLevel: SkillLevel;
skillRating: number; // 1-100
preferences: PlayerPreferences;
stats: PlayerStats;
}
// Fila de Espera
interface WaitingQueue {
id: string;
courtId: string;
entries: QueueEntry[];
config: QueueConfig;
estimatedWaitTime: number;
}
// Entrada na Fila
interface QueueEntry {
id: string;
team: Team;
joinedAt: Date;
priority: Priority;
status: QueueStatus;
estimatedPlayTime: Date;
}1.2 Enumerações
typescript
enum Sport {
BEACH_TENNIS = 'BEACH_TENNIS',
BEACH_VOLLEY = 'BEACH_VOLLEY',
FUTEVOLEI = 'FUTEVOLEI',
PADEL = 'PADEL'
}
enum CourtStatus {
AVAILABLE = 'AVAILABLE',
IN_USE = 'IN_USE',
MAINTENANCE = 'MAINTENANCE',
RESERVED = 'RESERVED'
}
enum SessionStatus {
WAITING_PLAYERS = 'WAITING_PLAYERS',
IN_PROGRESS = 'IN_PROGRESS',
PAUSED = 'PAUSED',
COMPLETED = 'COMPLETED'
}
enum SkillLevel {
BEGINNER = 'BEGINNER', // 1-25
INTERMEDIATE = 'INTERMEDIATE', // 26-50
ADVANCED = 'ADVANCED', // 51-75
PROFESSIONAL = 'PROFESSIONAL' // 76-100
}
enum Priority {
NORMAL = 0,
MEMBER = 1,
VIP = 2,
TOURNAMENT = 3
}2. Algoritmo de Fila de Espera
2.1 Priority Queue Implementation
typescript
class PriorityWaitingQueue {
private entries: QueueEntry[] = [];
private config: QueueConfig;
constructor(config: QueueConfig) {
this.config = config;
}
/**
* Adiciona um time à fila com prioridade calculada
*/
enqueue(team: Team, basePriority: Priority): QueueEntry {
const entry: QueueEntry = {
id: generateId(),
team,
joinedAt: new Date(),
priority: this.calculateEffectivePriority(team, basePriority),
status: QueueStatus.WAITING,
estimatedPlayTime: this.estimatePlayTime()
};
// Inserção ordenada por prioridade
const insertIndex = this.findInsertIndex(entry);
this.entries.splice(insertIndex, 0, entry);
this.recalculateEstimates();
return entry;
}
/**
* Remove e retorna o próximo time da fila
*/
dequeue(): QueueEntry | null {
if (this.entries.length === 0) return null;
const entry = this.entries.shift()!;
entry.status = QueueStatus.CALLED;
this.recalculateEstimates();
return entry;
}
/**
* Calcula prioridade efetiva considerando múltiplos fatores
*/
private calculateEffectivePriority(
team: Team,
basePriority: Priority
): number {
let score = basePriority * 1000; // Base priority weight
// Fator de tempo de espera (aging)
const waitingMinutes = team.waitingSince
? (Date.now() - team.waitingSince.getTime()) / 60000
: 0;
score += waitingMinutes * this.config.agingFactor;
// Fator de jogos jogados (fairness)
if (team.gamesPlayed > 0) {
score -= team.gamesPlayed * this.config.fairnessPenalty;
}
// Bonus para times completos
if (this.isTeamComplete(team)) {
score += this.config.completeTeamBonus;
}
return score;
}
/**
* Encontra posição de inserção mantendo ordem
*/
private findInsertIndex(entry: QueueEntry): number {
for (let i = 0; i < this.entries.length; i++) {
if (entry.priority > this.entries[i].priority) {
return i;
}
}
return this.entries.length;
}
/**
* Recalcula tempos estimados para toda a fila
*/
private recalculateEstimates(): void {
let accumulatedTime = 0;
for (const entry of this.entries) {
entry.estimatedPlayTime = new Date(
Date.now() + accumulatedTime + this.config.averageGameDuration
);
accumulatedTime += this.config.averageGameDuration;
}
}
}2.2 Configuração da Fila
typescript
interface QueueConfig {
// Tempo médio de jogo (ms)
averageGameDuration: number; // default: 15 * 60 * 1000 (15 min)
// Máximo de jogos consecutivos (winner stays)
maxConsecutiveGames: number; // default: 3
// Fator de envelhecimento (pontos por minuto de espera)
agingFactor: number; // default: 10
// Penalidade de fairness (pontos por jogo jogado)
fairnessPenalty: number; // default: 50
// Bonus para times completos
completeTeamBonus: number; // default: 100
// Máximo de entradas na fila
maxQueueSize: number; // default: 20
// Timeout para responder chamada (ms)
callTimeout: number; // default: 2 * 60 * 1000 (2 min)
}3. Sistema Winner Stays
3.1 Lógica Principal
typescript
class WinnerStaysManager {
constructor(
private courtService: CourtService,
private queueService: QueueService,
private notificationService: NotificationService
) {}
/**
* Processa fim de jogo e determina próxima partida
*/
async processGameEnd(session: GameSession, result: GameResult): Promise<void> {
const court = await this.courtService.findById(session.courtId);
const config = court.config;
// Determina vencedor e perdedor
const { winner, loser } = this.determineTeams(session, result);
// Verifica se vencedor pode ficar
const canWinnerStay = this.canTeamStay(winner, config);
if (canWinnerStay) {
// Vencedor fica, busca próximo desafiante
const challenger = await this.getNextChallenger(court, winner);
if (challenger) {
// Inicia nova partida
await this.startNewGame(court, winner, challenger.team);
// Perdedor vai para o fim da fila
await this.queueService.enqueue(court.queue.id, loser, Priority.NORMAL);
// Remove desafiante da fila
await this.queueService.remove(challenger.id);
} else {
// Sem desafiantes, vencedor continua ou libera quadra
await this.handleNoChallenger(court, winner, loser);
}
} else {
// Vencedor atingiu limite, ambos vão para fila
await this.handleWinnerLimitReached(court, winner, loser);
}
}
/**
* Verifica se time pode continuar na quadra
*/
private canTeamStay(team: Team, config: CourtConfig): boolean {
// Limite de jogos consecutivos
if (team.gamesPlayed >= config.maxConsecutiveGames) {
return false;
}
// Limite de tempo na quadra
const timeOnCourt = Date.now() - team.enteredCourtAt.getTime();
if (timeOnCourt >= config.maxTimeOnCourt) {
return false;
}
return true;
}
/**
* Busca próximo desafiante com skill matching
*/
private async getNextChallenger(
court: Court,
currentTeam: Team
): Promise<QueueEntry | null> {
const queue = await this.queueService.getQueue(court.queue.id);
if (queue.entries.length === 0) {
return null;
}
// Se skill matching está ativo
if (court.config.skillMatching) {
return this.findBestSkillMatch(queue.entries, currentTeam);
}
// Caso contrário, retorna primeiro da fila
return queue.entries[0];
}
/**
* Encontra melhor match por habilidade
*/
private findBestSkillMatch(
entries: QueueEntry[],
currentTeam: Team
): QueueEntry {
const targetRating = currentTeam.skillRating;
const tolerance = 15; // diferença máxima de rating
// Filtra times dentro da tolerância
const matchingEntries = entries.filter(e =>
Math.abs(e.team.skillRating - targetRating) <= tolerance
);
if (matchingEntries.length > 0) {
// Retorna o que está esperando há mais tempo
return matchingEntries.reduce((oldest, current) =>
current.joinedAt < oldest.joinedAt ? current : oldest
);
}
// Se não há match, retorna primeiro da fila
return entries[0];
}
}3.2 Fluxograma do Algoritmo
┌─────────────────┐
│ Fim do Jogo │
└────────┬────────┘
│
▼
┌─────────────────────────┐
│ Determinar Vencedor │
│ e Perdedor │
└────────┬────────────────┘
│
▼
┌─────────────────────────┐ Não
│ Vencedor pode ficar? │────────┐
│ (< max jogos e tempo) │ │
└────────┬────────────────┘ │
│ Sim │
▼ │
┌─────────────────────────┐ │
│ Há desafiantes na fila? │ │
└────────┬────────────────┘ │
│ │
┌────┴────┐ │
│ │ │
Sim Não │
│ │ │
▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌───────────────┐
│ Buscar │ │ Vencedor │ │ Ambos times │
│ próximo │ │ continua │ │ vão para fila │
│ na fila │ │ treinando │ └───────┬───────┘
└────┬────┘ └─────┬─────┘ │
│ │ │
▼ │ │
┌───────────────┐ │ │
│ Skill Match? │ │ │
└───────┬───────┘ │ │
│ │ │
┌────┴────┐ │ │
Sim Não │ │
│ │ │ │
▼ ▼ │ │
┌───────┐ ┌─────┐ │ │
│ Busca │ │FIFO │ │ │
│ match │ │ │ │ │
└───┬───┘ └──┬──┘ │ │
│ │ │ │
└───┬────┘ │ │
│ │ │
▼ │ │
┌───────────────┐ │ │
│ Nova partida: │ │ │
│ Vencedor vs │ │ │
│ Desafiante │ │ │
└───────┬───────┘ │ │
│ │ │
▼ ▼ │
┌───────────────────┐ │
│ Perdedor vai │ │
│ para fim da fila │◄────────────┘
└───────────────────┘4. Skill Matching
4.1 Cálculo de Skill Rating
typescript
class SkillRatingCalculator {
// Constantes do sistema ELO modificado
private readonly K_FACTOR = 32;
private readonly BASE_RATING = 1500;
/**
* Calcula novo rating após uma partida
*/
calculateNewRating(
player: Player,
opponent: Player,
result: MatchResult
): number {
const expectedScore = this.expectedScore(
player.skillRating,
opponent.skillRating
);
const actualScore = this.getActualScore(result);
const newRating = player.skillRating +
this.K_FACTOR * (actualScore - expectedScore);
// Limita entre 1 e 100 (escala normalizada)
return Math.max(1, Math.min(100, newRating));
}
/**
* Probabilidade esperada de vitória
*/
private expectedScore(ratingA: number, ratingB: number): number {
return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
}
/**
* Pontuação real do resultado
*/
private getActualScore(result: MatchResult): number {
switch (result) {
case MatchResult.WIN: return 1;
case MatchResult.DRAW: return 0.5;
case MatchResult.LOSS: return 0;
}
}
/**
* Calcula rating médio de um time
*/
calculateTeamRating(team: Team): number {
if (team.players.length === 0) return this.BASE_RATING;
const sum = team.players.reduce((acc, p) => acc + p.skillRating, 0);
return sum / team.players.length;
}
/**
* Determina nível de habilidade baseado no rating
*/
getSkillLevel(rating: number): SkillLevel {
if (rating <= 25) return SkillLevel.BEGINNER;
if (rating <= 50) return SkillLevel.INTERMEDIATE;
if (rating <= 75) return SkillLevel.ADVANCED;
return SkillLevel.PROFESSIONAL;
}
}4.2 Algoritmo de Matching
typescript
class SkillMatchingService {
constructor(private ratingCalculator: SkillRatingCalculator) {}
/**
* Encontra o melhor oponente na fila
*/
findBestMatch(
currentTeam: Team,
waitingTeams: Team[],
config: MatchingConfig
): Team | null {
if (waitingTeams.length === 0) return null;
const currentRating = this.ratingCalculator.calculateTeamRating(currentTeam);
// Calcula score de match para cada time
const scoredTeams = waitingTeams.map(team => ({
team,
score: this.calculateMatchScore(currentTeam, team, config)
}));
// Ordena por melhor score
scoredTeams.sort((a, b) => b.score - a.score);
// Retorna melhor match se score mínimo for atingido
const best = scoredTeams[0];
if (best.score >= config.minimumMatchScore) {
return best.team;
}
// Fallback para FIFO se não há bom match
return waitingTeams[0];
}
/**
* Calcula score de compatibilidade entre times
*/
private calculateMatchScore(
teamA: Team,
teamB: Team,
config: MatchingConfig
): number {
let score = 100;
// Penalidade por diferença de skill
const ratingDiff = Math.abs(
this.ratingCalculator.calculateTeamRating(teamA) -
this.ratingCalculator.calculateTeamRating(teamB)
);
score -= ratingDiff * config.skillDiffPenalty;
// Bonus por tempo de espera do oponente
const waitingMinutes = teamB.waitingSince
? (Date.now() - teamB.waitingSince.getTime()) / 60000
: 0;
score += waitingMinutes * config.waitingTimeBonus;
// Penalidade se já jogaram juntos recentemente
if (this.playedRecently(teamA, teamB)) {
score -= config.recentMatchPenalty;
}
return Math.max(0, score);
}
}
interface MatchingConfig {
skillDiffPenalty: number; // default: 2 (por ponto de diferença)
waitingTimeBonus: number; // default: 5 (por minuto)
recentMatchPenalty: number; // default: 30
minimumMatchScore: number; // default: 50
}5. Otimização de Ocupação
5.1 Court Allocation Optimizer
typescript
class CourtAllocationOptimizer {
/**
* Otimiza alocação de jogadores em múltiplas quadras
*/
async optimizeAllocation(
arena: Arena,
availableCourts: Court[],
waitingPlayers: Player[]
): Promise<AllocationPlan> {
const plan: AllocationPlan = {
allocations: [],
unallocatedPlayers: [],
metrics: {
courtUtilization: 0,
averageWaitTime: 0,
skillMatchQuality: 0
}
};
// Agrupa jogadores por esporte preferido
const playersBySport = this.groupBySport(waitingPlayers);
// Para cada esporte
for (const [sport, players] of playersBySport) {
const courtsForSport = availableCourts.filter(c => c.sport === sport);
// Forma times balanceados
const teams = this.formBalancedTeams(players, sport);
// Aloca times às quadras
const allocations = this.allocateTeamsToCourts(teams, courtsForSport);
plan.allocations.push(...allocations);
}
// Calcula métricas
plan.metrics = this.calculateMetrics(plan);
return plan;
}
/**
* Forma times balanceados por skill
*/
private formBalancedTeams(players: Player[], sport: Sport): Team[] {
const teamSize = this.getTeamSize(sport);
const teams: Team[] = [];
// Ordena por skill
const sortedPlayers = [...players].sort(
(a, b) => b.skillRating - a.skillRating
);
// Usa algoritmo serpentine para balanceamento
while (sortedPlayers.length >= teamSize) {
const team: Player[] = [];
for (let i = 0; i < teamSize; i++) {
// Alterna entre pegar do início e do fim
const player = i % 2 === 0
? sortedPlayers.shift()!
: sortedPlayers.pop()!;
team.push(player);
}
teams.push({
id: generateId(),
players: team,
skillRating: this.calculateTeamRating(team),
gamesPlayed: 0
});
}
return teams;
}
/**
* Aloca times às quadras disponíveis
*/
private allocateTeamsToCourts(
teams: Team[],
courts: Court[]
): CourtAllocation[] {
const allocations: CourtAllocation[] = [];
// Ordena quadras por prioridade (menos ocupadas primeiro)
const sortedCourts = [...courts].sort(
(a, b) => a.queue.entries.length - b.queue.entries.length
);
// Distribui times uniformemente
for (let i = 0; i < teams.length; i++) {
const court = sortedCourts[i % sortedCourts.length];
allocations.push({
teamId: teams[i].id,
courtId: court.id,
action: court.status === CourtStatus.AVAILABLE
? 'START_GAME'
: 'ADD_TO_QUEUE'
});
}
return allocations;
}
}5.2 Métricas de Eficiência
typescript
interface AllocationMetrics {
// Taxa de ocupação das quadras (0-100%)
courtUtilization: number;
// Tempo médio de espera (minutos)
averageWaitTime: number;
// Qualidade do skill matching (0-100)
skillMatchQuality: number;
// Jogadores não alocados
unallocatedCount: number;
// Eficiência geral (0-100)
overallEfficiency: number;
}
class MetricsCalculator {
calculateMetrics(
courts: Court[],
allocations: CourtAllocation[]
): AllocationMetrics {
const courtUtilization = this.calculateCourtUtilization(courts);
const averageWaitTime = this.calculateAverageWaitTime(courts);
const skillMatchQuality = this.calculateSkillMatchQuality(courts);
const overallEfficiency = (
courtUtilization * 0.4 +
(100 - Math.min(averageWaitTime, 60) / 60 * 100) * 0.3 +
skillMatchQuality * 0.3
);
return {
courtUtilization,
averageWaitTime,
skillMatchQuality,
unallocatedCount: 0, // calculado separadamente
overallEfficiency
};
}
private calculateCourtUtilization(courts: Court[]): number {
const inUse = courts.filter(c => c.status === CourtStatus.IN_USE).length;
return (inUse / courts.length) * 100;
}
private calculateAverageWaitTime(courts: Court[]): number {
let totalWait = 0;
let count = 0;
for (const court of courts) {
for (const entry of court.queue.entries) {
const waitTime = (Date.now() - entry.joinedAt.getTime()) / 60000;
totalWait += waitTime;
count++;
}
}
return count > 0 ? totalWait / count : 0;
}
}6. Integração com Eventos
6.1 Event-Driven Architecture
typescript
// Eventos emitidos pelo sistema de alocação
interface AllocationEvents {
'queue.player.joined': {
queueId: string;
playerId: string;
position: number;
estimatedWaitTime: number;
};
'queue.player.called': {
queueId: string;
playerId: string;
courtId: string;
timeToRespond: number;
};
'queue.player.timeout': {
queueId: string;
playerId: string;
reason: 'no_response' | 'declined';
};
'game.started': {
courtId: string;
sessionId: string;
teams: Team[];
isWinnerStays: boolean;
};
'game.ended': {
courtId: string;
sessionId: string;
winner: Team;
loser: Team;
duration: number;
score: Score;
};
'court.status.changed': {
courtId: string;
previousStatus: CourtStatus;
newStatus: CourtStatus;
};
}
// Handler de eventos
class AllocationEventHandler {
constructor(
private eventBus: EventBus,
private notificationService: NotificationService,
private analyticsService: AnalyticsService
) {
this.registerHandlers();
}
private registerHandlers(): void {
this.eventBus.on('queue.player.called', async (event) => {
// Notifica jogador via push notification
await this.notificationService.sendPush(event.playerId, {
title: 'Sua vez de jogar!',
body: `Dirija-se à quadra. Você tem ${event.timeToRespond / 60}min.`,
action: 'COURT_READY'
});
});
this.eventBus.on('game.ended', async (event) => {
// Registra analytics
await this.analyticsService.trackGameEnd({
courtId: event.courtId,
duration: event.duration,
...event
});
});
}
}7. Casos de Uso
7.1 Beach Tennis - Winner Stays
typescript
// Configuração típica para beach tennis
const beachTennisConfig: CourtConfig = {
sport: Sport.BEACH_TENNIS,
teamSize: 2,
maxConsecutiveGames: 3,
maxTimeOnCourt: 45 * 60 * 1000, // 45 minutos
winnerStays: true,
skillMatching: true,
skillMatchTolerance: 15,
averageGameDuration: 15 * 60 * 1000, // 15 minutos
};
// Exemplo de fluxo
async function beachTennisFlow(court: Court) {
// 1. Times A e B jogando
// 2. Time A vence (6x4)
// 3. Time A já jogou 2 partidas (pode ficar mais 1)
// 4. Time C é o próximo na fila (skill compatível)
// 5. Time B vai para o fim da fila
// 6. Time A vs Time C começa
}7.2 Futevôlei - Rotação
typescript
// Configuração para futevôlei com rotação
const futevoleiConfig: CourtConfig = {
sport: Sport.FUTEVOLEI,
teamSize: 2,
maxConsecutiveGames: 2, // menos jogos por ser mais cansativo
maxTimeOnCourt: 30 * 60 * 1000, // 30 minutos
winnerStays: false, // rotação após cada jogo
skillMatching: false, // mais casual
averageGameDuration: 12 * 60 * 1000, // 12 minutos
};8. API Reference
8.1 Endpoints
yaml
# Gestão de Filas
POST /api/v1/courts/{courtId}/queue # Entrar na fila
DELETE /api/v1/courts/{courtId}/queue/{entryId} # Sair da fila
GET /api/v1/courts/{courtId}/queue # Listar fila
POST /api/v1/courts/{courtId}/queue/call # Chamar próximo
# Sessões de Jogo
POST /api/v1/courts/{courtId}/sessions # Iniciar sessão
PUT /api/v1/courts/{courtId}/sessions/{id} # Atualizar placar
POST /api/v1/courts/{courtId}/sessions/{id}/end # Finalizar
# Alocação
POST /api/v1/arenas/{arenaId}/allocate # Auto-alocação
GET /api/v1/arenas/{arenaId}/allocation/metrics # Métricas8.2 WebSocket Events
typescript
// Cliente se conecta ao WebSocket da quadra
ws.connect(`/ws/courts/${courtId}`);
// Eventos recebidos pelo cliente
ws.on('queue:updated', (data: QueueUpdate) => {
// Atualiza UI da fila
});
ws.on('player:called', (data: PlayerCalled) => {
// Mostra notificação
});
ws.on('game:started', (data: GameStarted) => {
// Atualiza status da quadra
});
ws.on('score:updated', (data: ScoreUpdate) => {
// Atualiza placar em tempo real
});9. Performance Considerations
9.1 Otimizações
typescript
// Cache de filas em Redis
class CachedQueueService {
private readonly QUEUE_TTL = 3600; // 1 hora
async getQueue(courtId: string): Promise<WaitingQueue> {
const cached = await this.redis.get(`queue:${courtId}`);
if (cached) {
return JSON.parse(cached);
}
const queue = await this.db.queues.findByCourtId(courtId);
await this.redis.setex(
`queue:${courtId}`,
this.QUEUE_TTL,
JSON.stringify(queue)
);
return queue;
}
async invalidateCache(courtId: string): Promise<void> {
await this.redis.del(`queue:${courtId}`);
// Publica evento para outros pods
await this.redis.publish('queue:invalidated', courtId);
}
}9.2 Índices de Banco
sql
-- Índices para queries frequentes
CREATE INDEX idx_queue_entries_court_status
ON queue_entries(court_id, status)
WHERE status = 'WAITING';
CREATE INDEX idx_queue_entries_priority
ON queue_entries(court_id, priority DESC, joined_at ASC);
CREATE INDEX idx_game_sessions_court_active
ON game_sessions(court_id)
WHERE status = 'IN_PROGRESS';10. Testes
10.1 Cenários de Teste
gherkin
Feature: Winner Stays Algorithm
Scenario: Vencedor permanece após vitória
Given uma quadra com "winner stays" ativo
And Time A e Time B estão jogando
And Time C está na fila
When Time A vence a partida
And Time A jogou menos de 3 partidas
Then Time A deve permanecer na quadra
And Time B deve ir para o fim da fila
And Time C deve ser chamado para jogar
Scenario: Vencedor sai após limite de jogos
Given uma quadra com max_consecutive_games = 3
And Time A venceu 3 partidas consecutivas
When Time A vence mais uma partida
Then Time A deve ir para o fim da fila
And próximo time da fila deve iniciar novo cicloEste documento detalha o algoritmo de alocação de quadras do Sport Tech Club, cobrindo desde a estrutura de dados até a implementação do sistema winner-stays e otimização de ocupação.