Gerenciador das noites de custom game 5x5 de League of Legends de um grupo de amigos. Sorteia os times respeitando o pool de posições de cada um, controla o Fearless Draft ao longo da MD3 e guarda as estatísticas de todo mundo.
Nasceu de um problema chato de resolver na mão: com 10 pessoas e pools de role diferentes, montar dois times válidos (Top/Jungle/Mid/ADC/Support dos dois lados) sem ninguém ficar de fora da própria main é um quebra-cabeça — ainda mais quando metade do grupo é Fill.
Sorteio automático (Auto-Balance) — escolhe quem veio hoje e o sistema divide os 10 em dois times completos. O algoritmo resolve primeiro quem tem pool restrito (quem só joga Top/ADC entra antes) e usa quem marcou Fill por último, para tapar as lacunas. Nunca repete role no mesmo time.
Modo Capitães — escolhe os 2 capitães (maior winrate, sorteio entre quem
perdeu o último mapa, ou aleatório) e conduz o snake draft 1-2-2-2-1. No
fim, distribui as roles dentro de cada time.
Draft ao vivo — o mesmo draft, mas numa sala com link: todo mundo do grupo acompanha as escolhas acontecendo. Cada capitão clica "Sou o capitão" uma vez e só ele escolhe pelo lado dele; o resto assiste.
Fearless Draft — os campeões usados no Jogo 1 e no Jogo 2 ficam travados para o resto da MD3. A aba Série mostra os "queimados" com os ícones oficiais do Data Dragon.
Registro de partida — um agente local lê o histórico do cliente do LoL (que, ao contrário da API pública, inclui custom games) e manda o scoreboard completo. Também dá para colar o Match ID ou preencher o formulário manual. Ver Puxando os dados das partidas abaixo.
Estatísticas — classificação geral (pontos, winrate, KDA), perfil individual com dano por minuto, winrate por role e pódio dos 3 campeões mais jogados.
Destaques — 11 recordes de todos os tempos (mais abates, melhor KDA, mais dano, maior sequência… e "mais mortes", que é piada e não mérito), cada um apontando para a partida exata em que aconteceu. Mais uma linha do tempo de quadras, pentas e sequências longas.
Histórico com scoreboard completo — três níveis, cada um atrás de um clique:
série → placar e vencedor
jogo → os dois times, objetivos (dragão/barão/torre) e os bans do draft
jogador → build, feitiços, runa, quebra AD/AP/verdadeiro, farm de rota x selva,
visão, CC, abates
O nível do jogador existe para não depender do cliente do LoL aberto: tudo que a tela de fim de partida mostrava fica gravado e consultável. Cada número grande vem com uma barra comparando com o melhor da partida — "37k de dano" não diz nada sozinho, 37k sendo o maior do jogo diz tudo.
Ranking como imagem — botão que gera um PNG da classificação desenhado para ser lido no celular, para mandar no grupo. Copia, baixa ou compartilha.
Pontuação: +3 por mapa vencido, +1 de bônus para quem vence a MD3.
Detalhe que parece pequeno e não é. Em custom os times trocam de lado entre os jogos. Se o placar contasse vitórias por azul/vermelho, uma MD3 em que os mesmos 5 vencem os dois jogos de lados diferentes viraria 1-1 em vez de 2-0 — a série nunca fecharia e ninguém levaria o bônus.
Isso aconteceu de verdade aqui (MD3 de 07/09/2026) e deixou jogadores com o mesmo retrospecto 2-2 com pontuações diferentes.
A identidade de um time é o conjunto de jogadores. O sistema ancora no jogo 1 — quem estava de azul nele é o "Time 1" — e classifica os jogos seguintes por sobreposição de elenco (maioria de 5). Isso tolera até uma substituição.
Se a regra mudar, ou se você corrigir um jogo antigo, dá para reconstruir todos os placares a partir dos jogos:
npm run db:recompute -- --dry-run # mostra o que mudaria
npm run db:recompute # aplica| Camada | Tecnologia |
|---|---|
| Frontend | React 19 + Vite + TypeScript + Tailwind CSS v4 + Lucide Icons |
| Backend | Node.js + Express + TypeScript |
| Banco | SQLite via Prisma ORM — arquivo em dev, Turso em produção |
| Externo | Riot Games API · LCU (cliente do LoL) · Data Dragon (ícones) |
| Testes | Vitest |
Monorepo com npm workspaces: server/ e client/.
Documentação: ARCHITECTURE.md (por que o código é assim) · DEPLOY.md (como publicar) · CONTRIBUTING.md (fluxo de branches) · NOITE-DE-JOGOS.md (o passo a passo da noite). O backlog vive nas issues.
Precisa de Node.js 20+.
# 1. Dependências (a raiz instala os dois workspaces)
npm install
# 2. Variáveis de ambiente
cp .env.example .env
# 3. Cria o banco SQLite e gera o Prisma Client
npm run db:push
# 4. Popula com a base inicial de jogadores
npm run db:seed
# 5. Sobe API (:3333) e front (:5173) juntos
npm run devAbra http://localhost:5173.
A
RIOT_API_KEYé opcional e você provavelmente não precisa dela. O caminho principal de importação usa o cliente do LoL, não a API pública — e esse caminho dispensa chave. A chave só serve para importar por Match ID e para o "Usar ícone do LoL" da conta. Ela sai em https://developer.riotgames.com/ e a versão de desenvolvimento expira a cada 24 horas.
npm test # testes do algoritmo de draft e do parser do cliente
npm run typecheck # checagem de tipos dos dois workspaces
npm run db:studio # Prisma Studio, para olhar o banco
npm run db:recompute # recalcula os placares das MD3 a partir dos jogos
npm run build # build de produçãoEste é o problema central de um app de inhouse, e vale entender por quê.
A API pública da Riot não lista custom games. O endpoint
/lol/match/v5/matches/by-puuid/{puuid}/ids só devolve filas oficiais. Não
existe "buscar meus últimos inhouses" — a limitação é deliberada, não um bug.
Mas o cliente do LoL lista. Ele mantém o próprio histórico, e nele os
customs aparecem. O cliente expõe esse histórico numa API local (a LCU API)
em 127.0.0.1, numa porta aleatória protegida por senha — porta e senha ficam
num arquivo lockfile na pasta de instalação. É o mesmo mecanismo que Blitz,
Porofessor e OP.GG Desktop usam.
companion/inhouse-companion.mjs lê esse
histórico e manda para o InHouse. Zero dependências, roda com node. Uma
pessoa do grupo executa — de preferência quem hospeda o custom.
node companion/inhouse-companion.mjs --list # lista os customs recentes
node companion/inhouse-companion.mjs --last # manda o último custom
node companion/inhouse-companion.mjs --watch # manda sozinho ao fim de cada jogo
node companion/inhouse-companion.mjs --game 3280827724 # manda um gameId específicoOutros comandos:
| Comando | O que faz |
|---|---|
--games <id,id> |
importa várias de uma vez |
--who [ids] |
lista os Riot IDs de quem joga os customs |
--replays / --replay <id> |
importa de arquivo de replay |
--refresh-all |
atualiza a scoreboard de tudo que já está registrado |
Extras: --dry-run (confere sem gravar), --criar-faltantes (cadastra
desconhecidos com o nick, para renomear depois), --refresh, --api <url>,
--series <id>.
Dado de custom game só existe na máquina de quem jogou, e só enquanto o cliente ainda tem a partida em cache. Quando o projeto passa a guardar um dado novo (destaques, itens, objetivos), as partidas antigas ficam com zero em colunas que a origem sempre soube responder.
--refresh resolve isso reenviando o mesmo jogo: só a scoreboard muda —
placar da MD3, campeões queimados e número do jogo ficam intactos. Serve também
para corrigir role depois de a inferência melhorar. Ele recusa a atualização se
vencedor ou elenco não baterem, para não gravar os números de um jogo em cima de
outro.
--refresh-all faz isso para tudo de uma vez. É o comando para rodar depois de
um deploy que passou a guardar mais coisa:
node companion/inhouse-companion.mjs --refresh-all --api https://inhouse-lol.vercel.app/apiVarrer é seguro: nesse modo o servidor recusa criar partida nova, então um custom de um ano atrás que apareça no histórico é pulado em vez de entrar de carona na MD3 em andamento.
Requisitos: Node 18+. O cliente do LoL precisa estar aberto para os comandos de histórico; os de replay leem arquivo em disco e funcionam com o jogo fechado. Nada para instalar.
O LCU tem dois endpoints de histórico, e a diferença entre eles é grande — vale saber qual é qual antes de concluir que uma partida "sumiu":
| Endpoint | Retorno |
|---|---|
.../products/lol/current-summoner/matches |
20 jogos, e ignora begIndex — paginar devolve sempre os mesmos 20 |
.../products/lol/{puuid}/matches |
~100 jogos, cerca de 1 mês |
Medido numa conta real: o primeiro devolveu 20 partidas (2 customs); o segundo, 101 partidas (13 customs). O agente usa o segundo — o primeiro é uma armadilha que faz parecer que os inhouses de duas semanas atrás desapareceram.
O --list agrupa por noite, então uma MD3 aparece junta:
03/09/2026 (3 jogos)
3279275628 21:14 32min
3279294765 21:56 29min
3279316571 22:32 14min
Passou de ~1 mês, o jeito é o arquivo de replay — ele guarda o scoreboard para sempre e funciona com o jogo fechado:
node companion/inhouse-companion.mjs --replays # lista os .rofl salvos
node companion/inhouse-companion.mjs --replay 3280827724 # importa um delesPara o LoL salvar sozinho daqui pra frente: Configurações > Replays > "Gravar automaticamente as partidas". Sem isso ligado, partidas muito antigas só entram pelo formulário manual.
Segurança: o token do lockfile dá acesso total ao cliente do LoL. Ele fica só em memória e nunca é enviado ao servidor — só os dados da partida são.
Para o sistema saber quem é quem no scoreboard, cada jogador precisa do
Riot ID preenchido na tela Jogadores (formato Nick#TAG).
Isso não precisa da chave da Riot API. O PUUID vem junto com os dados do
jogo: na primeira importação o servidor casa riotId → PUUID sozinho e guarda.
Da segunda vez em diante o reconhecimento é automático.
Se alguém não estiver vinculado, a importação recusa a partida inteira e diz exatamente quais Riot IDs faltam — gravar 8 de 10 corromperia a classificação em silêncio.
| Caminho | Quando usar |
|---|---|
| Agente local (LCU) | Padrão. Scoreboard completo, automático. ~1 mês de histórico. |
| Replay .rofl | Mais antigo que isso. Precisa do replay salvo. |
| Match ID na API pública | Alguém anotou o ID. POST /api/riot/import. Precisa da chave. |
| Formulário manual | Sempre disponível, sem chave nenhuma. |
O formulário manual não é plano B improvisado: como a API pública não cobre o caso de uso, ele é um caminho de primeira classe. Se os times vieram do sorteio, os 10 lugares já chegam preenchidos — sobra digitar campeão e KDA.
Só 5x5 no Summoner's Rift. A verificação é por mapId e gameMode — não
por número de jogadores. Sem isso, um ARAM de 10 pessoas entraria e estragaria a
estatística: lá não existe lane, e dano/CS por minuto seguem outra escala, o que
tornaria o "winrate por role" ficção.
Valores observados em partidas reais do grupo:
| mapId | gameMode | queueId | ||
|---|---|---|---|---|
| Custom na Fenda, draft de torneio | 11 | CLASSIC | 3130 | conta |
| Abismo Uivante | 12 | KIWI | 3270 | não conta |
A recusa diz o motivo real ("foi no Abismo Uivante"), não um genérico.
Em custom game o cliente frequentemente não infere a posição de cada jogador
(lane vem vazia, ou dois jogadores marcados como "MID"). Quando isso acontece,
o sistema resolve as roles que sobraram usando o pool declarado de cada
jogador, e sinaliza rolesFullyInferred: false para a tela pedir conferência.
Em produção o Express serve também o frontend já buildado, então o deploy tem um alvo só: uma URL, sem CORS e sem domínio separado para a API.
npm run build # compila server + client
npm start # sobe tudo na :3333Instruções passo a passo em DEPLOY.md. Resumo:
| Camada | Onde | Custo |
|---|---|---|
| Banco | Turso (SQLite hospedado) | grátis |
| App | Vercel | grátis |
Por que não SQLite em arquivo na nuvem: Vercel e afins têm filesystem efêmero. O arquivo some a cada deploy e a escrita não dá erro — ela desaparece, que é pior. Por que Turso e não Postgres: mantém o mesmo motor em dev e em produção, evitando a divergência silenciosa que gera "na minha máquina funciona".
Fora da Vercel, o mesmo código roda em qualquer lugar com Node: npm run build
e npm start sobem API e front juntos. Na rede local, o pessoal acessa pelo
seu IP; o PC só precisa estar ligado.
Escrita protegida. Com a
GROUP_KEYdefinida, toda escrita exige a chave do grupo ou uma conta logada; leitura é pública. ComADMIN_PLAYER_IDS, o site fica só de leitura para todo mundo menos o admin, e a chave passa a servir só ao agente que importa os jogos. Detalhes em DEPLOY.md.
Detalhes e o porquê de cada decisão em ARCHITECTURE.md.
inhouse-lol/
├─ api/index.ts handler serverless (Vercel)
├─ companion/ agente que lê o cliente do LoL
├─ server/
│ ├─ prisma/
│ │ ├─ schema.prisma Player, Series, Match, MatchPlayerStat,
│ │ │ MatchTeamStat, MatchBan, BurnedChampion
│ │ └─ seed.ts base inicial (apelidos genéricos)
│ ├─ scripts/
│ │ ├─ recompute-series.ts recalcula os placares das MD3
│ │ └─ migrate-to-turso.ts aplica o schema no banco hospedado
│ └─ src/
│ ├─ app.ts monta o Express, sem escutar porta
│ ├─ index.ts escuta porta (local, VPS, Docker)
│ ├─ lib/ ← lógica pura, sem framework
│ │ ├─ autoBalance.ts o algoritmo de sorteio
│ │ ├─ captainsDraft.ts snake draft 1-2-2-2-1
│ │ ├─ lcu.ts histórico do cliente do LoL
│ │ ├─ rofl.ts arquivos de replay
│ │ ├─ riot.ts API pública da Riot
│ │ ├─ ddragon.ts ícones de campeão
│ │ ├─ ddragonBuild.ts itens, feitiços e runas
│ │ └─ roles.ts roles canônicas
│ ├─ services/ regras de negócio
│ │ ├─ series.ts MD3, Fearless, gravar e atualizar partida
│ │ ├─ stats.ts classificação e perfil
│ │ └─ highlights.ts recordes e momentos
│ ├─ routes/ endpoints Express
│ └─ __tests__/ 91 testes
└─ client/src/
├─ pages/ Ranking, Sorteio, Série, Destaques, Histórico,
│ Jogadores, Perfil
├─ components/ Select, ChampionPicker, MatchForm, TeamCard,
│ MatchPlayerDetail, MatchObjectives, BuildIcons...
├─ hooks/
├─ lib/ rankingImage.ts, lolTerms.ts, activeDraft.ts
├─ index.css tokens do sistema visual
├─ api/client.ts cliente HTTP tipado
└─ __tests__/ 23 testes (puros e de componente)
lib/ não importa framework nenhum. É a parte cara do projeto — o
algoritmo e os parsers descobertos por engenharia reversa — e portaria para
outra stack sem alteração.
O problema é um emparelhamento perfeito em grafo bipartido: 10 jogadores
para 10 vagas (2 lados × 5 roles), com aresta onde a role está no pool do
jogador. Em server/src/lib/autoBalance.ts:
- Viabilidade — o Teorema de Hall diz, em 32 verificações, se a composição é possível. Quando não é, o erro aponta o gargalo exato: "5 jogadores só jogam TOP, mas existem apenas 2 vagas", em vez de um "não foi possível sortear" inútil.
- Busca — backtracking com heurística MRV (Minimum Remaining Values): a cada passo aloca o jogador com menos vagas disponíveis. É o que implementa "gargalo primeiro, Fill por último" — e de forma dinâmica, reagindo ao que as escolhas anteriores já consumiram.
- Escolha — existem várias soluções válidas; o sistema avalia milhares com restarts independentes e fica com a de menor custo: diferença de rating + desconforto de role + concentração de autofill num lado só.
Cada sorteio devolve a seed usada. Passar a mesma seed de volta reproduz exatamente os mesmos times — útil quando alguém contesta o resultado.
| Método | Rota | Descrição |
|---|---|---|
GET |
/api/players |
Lista jogadores |
POST |
/api/players |
Cadastra (roles em ordem de preferência) |
PATCH |
/api/players/:id |
Atualiza nome, roles, Riot ID |
GET |
/api/players/:id/profile |
KDA, dano/min, winrate por role, pódio |
POST |
/api/draft/auto-balance |
Sortear times |
POST |
/api/draft/captains/start |
Inicia o modo capitães |
POST |
/api/draft/captains/pick |
Aplica uma escolha do snake draft |
GET |
/api/series/current |
MD3 em andamento |
POST |
/api/series |
Abre uma MD3 |
POST |
/api/series/:id/matches |
Registra jogo (manual) |
GET |
/api/series/:id/burned |
Campeões queimados (Fearless) |
GET |
/api/series/:id |
Detalhe: scoreboard, objetivos por time e bans |
GET |
/api/stats/leaderboard |
Classificação geral |
GET |
/api/stats/highlights |
Recordes e momentos (aba Destaques) |
POST |
/api/riot/import |
Importa partida por Match ID |
GET |
/api/riot/champions |
Manifesto de campeões do Data Dragon |
GET |
/api/riot/build |
Itens, feitiços e runas (só o histórico usa) |
POST |
/api/ingest/lcu |
Recebe um custom game do agente local |
POST |
/api/ingest/rofl |
Recebe uma partida vinda de replay |
GET |
/api/ingest/status |
Estado da MD3 e contagem de vínculos |
Todas respondem no envelope { success, data } ou { success, error, code }.
Este repositório é público. Nomes reais, Riot IDs e PUUIDs dos jogadores
vivem só no banco (dev.db, que está no .gitignore) — nunca no seed.ts.
O seed usa apelidos genéricos de propósito.
O CI tem um passo que falha o build se encontrar uma chave RGAPI- no código ou
se o .env for versionado. Detalhes em CONTRIBUTING.md.
O lockfile do cliente do LoL dá acesso total à conta de quem roda o agente.
Ele fica só em memória e nunca sai da máquina — só os dados da partida são
enviados.
- Sem enum no schema. O provider
sqlitedo Prisma não suportaenum; esses campos sãoString, validados na borda por zod. A fonte da verdade dos valores está emserver/src/lib/roles.ts. - Sem modelo
Team. Os times mudam a cada jogo, então a composição vive emMatchPlayerStat.teamSide. Persistir times geraria centenas de registros de uma partida só. PlayerRoleem vez de um campomainRoles. A ordem de preferência é informação que o algoritmo usa, e uma tabela normalizada permite consultar "quem joga Support?" semLIKEem string.- Placar da MD3 por elenco, não por cor. Os campos
blueScore/redScoreguardam Time 1 / Time 2, ancorados no jogo 1. Mantive os nomes das colunas para não migrar um banco com dados reais dentro. - Jogador é desativado, nunca apagado — deletar levaria junto todo o histórico de partidas dele e corromperia a classificação.
O seed cadastra os 10 jogadores do grupo. Ígor é jungler puro (pool de uma role só), o que faz dele o gargalo mais apertado do elenco — e por isso o algoritmo o aloca primeiro.
O sorteio recusa um elenco que não tenha exatamente 10, com mensagem explícita.