Skip to content

Multiplayer/Liveness: corrida nunca termina se um jogador presente parar de digitar — sala presa em "racing" e nada persiste #58

Description

@caioross

Sintoma

Basta um jogador presente parar de digitar (AFK, celular bloqueado, distraiu-se) para a
corrida nunca terminar. Todo mundo que já cruzou a linha fica preso na tela de corrida,
com o cronômetro rodando, sem Results, sem pódio e sem nada persistido em
matches/scores. A sala fica em status: "racing" indefinidamente no banco.

Causa (arquivo:linha, contra origin/main)

src/lib/useRoom.ts:330-336 — o líder só posta finish quando todos os presentes têm
finishedAt:

// Leader finalizes the match when everyone present has finished.
if (players.length === 0 || !players.every(p => p.finishedAt)) return;   // :334
finishPostedRef.current = true;
postAction("finish", { results: toResults(players) });

Não existe nenhum critério alternativo de encerramento — sem tempo máximo de corrida,
sem timeout por inatividade, sem "todos os que ainda progridem já terminaram". Quem não
digita nunca ganha finishedAt (ele só é setado em broadcastProgress, useRoom.ts:373-375).

Agravantes:

  • Não há escape pela UI. Durante racing, Race.tsx só oferece onAbandon (Race.tsx:126)
    para quem ainda não terminou. Quem já terminou não tem botão de sair nem de forçar o fim;
    resetToLobby só aparece na tela de Results, que nunca chega.
  • Líder AFK trava mais ainda: o efeito de finish só roda para isLeader. Se o próprio líder
    travou, ninguém posta o finish, mesmo com todos os outros terminados.
  • Fechar a aba funciona (sai da presence → some de players → o gate passa), mas deixar a
    aba aberta parada, não. O resultado depende de o jogador AFK fechar o navegador — o pior
    contrato possível.

Acceptance criteria

  1. Uma corrida com um jogador parado termina sozinha: existe um encerramento por tempo
    (ex.: RACE_TIMEOUT_MS a partir de start_at, dimensionado pelo tamanho do snippet) e/ou
    por inatividade (jogador sem novo progress há N segundos deixa de bloquear o fim).
  2. Quem não terminou entra nos results como abandono — sem place, sem entrar no
    leaderboard —, mantendo a regra atual de que abandono não ranqueia
    (useRoom.ts:293-296 e toResults, useRoom.ts:317-320).
  3. O encerramento continua idempotente: a transição racing→finished segue condicional
    (route.ts.eq("status","racing")), então corridas encerradas por timeout não
    duplicam matches/scores se dois clientes dispararem juntos.
  4. Não há regressão de anti-cheat: o timeout não vira caminho novo para results forjados —
    passa pelo mesmo sanitizeResults. Coordenar com a Anti-cheat: finish sem coerência temporal — leaderboard forjável em 1 request e corrida alheia encerrável #34 (coerência temporal do finish),
    que endurece a validação; esta issue trata da liveness (a corrida acabar), são
    complementares e tocam a mesma action.
  5. node scripts/validate-persistence.mjs verde. Se o encerramento virar função pura
    (ex.: shouldFinish(players, startMs, now)), cobrir com Vitest.

Dica de abordagem

Manter a autoridade no líder (menor mudança) e adicionar ao efeito de useRoom.ts:330 um
segundo gatilho: um setTimeout/tick que dispara quando Date.now() - startMs > limite ou
quando todos os jogadores ativos terminaram (último progress de cada não-finalizado mais
velho que N s). Extrair a decisão para uma função pura em src/lib/room.ts é o que torna isso
testável sem sala real. Cuidado com a área sagrada (HANDBOOK §2): o tick de decisão é do
líder e roda em segundos, não pode virar mais um timer de 100ms competindo com a textarea.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Alto valor — próximo da filaarea:multiplayerSalas, Realtime, presença, leaderboard, persistência

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions