From 13bf0f16a0fdce6ba6cdf55022eb3f87e5151c15 Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 23 Sep 2026 10:22:52 -0300 Subject: [PATCH 001/139] docs: record the 64 hz tick and four-player room decision --- docs/adr/0008-tick-alto-e-sala-de-quatro.md | 113 ++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/adr/0008-tick-alto-e-sala-de-quatro.md diff --git a/docs/adr/0008-tick-alto-e-sala-de-quatro.md b/docs/adr/0008-tick-alto-e-sala-de-quatro.md new file mode 100644 index 00000000..e93e106e --- /dev/null +++ b/docs/adr/0008-tick-alto-e-sala-de-quatro.md @@ -0,0 +1,113 @@ +# ADR 0008 — tick de 64 hz e sala de quatro, sobre websocket + +- **status**: proposta. substitui, se aceita, a taxa de snapshot e o tamanho de sala da + [adr 0002](0002-transporte-de-rede.md). o transporte websocket, o colyseus pinado em + `0.18.2` e o núcleo de movimento próprio da 0002 continuam valendo, e esta adr os reafirma + depois de reavaliar udp. +- **data**: 2026-09-22 +- **decide**: renato + +## contexto + +a pergunta foi se um transporte com semântica de udp tiraria latência do jogo. o navegador não +abre socket udp; os caminhos que existem são webtransport (quic) e webrtc datachannel. os dois +dependem de udp, e **a rede da feira bloqueia udp**. + +o que tira latência de verdade, independente do transporte, está no tick e no tamanho da sala: +a 30 hz o tick sozinho soma até 33 ms, e oito jogadores custam histórico de rewind e cpu por +sala. + +restrições: servidor autoritativo obrigatório (adr 0002), cerca de 8 semanas até 19/11, dois +engenheiros. + +## opções consideradas + +### opção a — websocket pelo colyseus (adr 0002) + +tcp direto entre cliente e servidor autoritativo. + +- a favor: passa por qualquer rede, é estável, mantém predição, reconciliação e rewind do + colyseus. sobre tcp é o caminho com menos camadas e menos saltos. +- contra: com perda de pacote, o head-of-line blocking do tcp segura os pacotes seguintes por + pelo menos um rtt. + +### opção b — webtransport (quic) pelo `@colyseus/h3-transport` + +datagrama não confiável para input, sem sair do colyseus. + +- contra: **não conecta com udp bloqueado**, e webtransport não cai sozinho para tcp. exige + certificado tls válido, e o roadmap do colyseus declara o transporte não estável. + +### opção c — webrtc datachannel com sfu (livekit) + +- contra: sem udp, o ice cai para turn sobre tcp, e o datachannel não confiável passa a viajar + dentro de tcp. o head-of-line blocking volta inteiro, com mais camadas que o websocket. o sfu + não entende o jogo: para ter autoridade, o servidor entra como participante e o caminho vira + cliente → sfu → servidor → sfu → cliente. e sai do colyseus, então netcode volta a ser escrito + à mão. + +### opção d — p2p + +um trecho a menos, mas ninguém tem autoridade sobre o acerto: ou um jogador vira host, com +vantagem e possibilidade de trapaça, ou o cliente afirma acerto, que a adr 0002 proíbe. com +quatro jogadores são três conexões por cliente, e onde o nat falha precisa de turn, que é um +servidor no meio de novo. "p2p com sfu" tem a latência do cliente-servidor e a falta de +autoridade do p2p. + +### opção e — mqtt + +protocolo de pub/sub para iot, não transporte. no navegador roda sobre websocket, adiciona o +salto do broker e não traz sala, estado, predição nem rewind. + +## decisão + +**usamos websocket pelo colyseus, com simulação, patches de estado e input a 64 hz e salas de +quatro jogadores.** + +o critério: com udp bloqueado, todo transporte do navegador cai para tcp, e sobre tcp o +websocket direto é o caminho mais curto. o ganho de latência que sobra está no tick e na sala. +os patches acompanham o tick de simulação: um patch por passo, sem estado velho à espera e com +rewind na resolução de um passo. o tick sai de 60 para 64 hz porque 1/64 s é 2⁻⁶, exato em +ponto flutuante binário, e 1/60 s não é. somar passos, converter tick em tempo e voltar dá o +mesmo número no cliente e no servidor sem arredondamento acumulado: uma hora de passos de 1/60 +soma 3600,0000000182 s, e de 1/64 soma 3600 exatos. o ganho numérico é pequeno, e o custo +também: 6,7% a mais de banda e de cpu que a 60 hz. 128 hz foi descartado porque dobra o custo do +servidor e do replay de reconciliação. a sala de quatro compensa a taxa de patches mais alta, +com menos histórico de rewind e menos cpu por sala. + +## consequências + +- **patches de estado, simulação e input a 64 hz.** a espera pelo próximo passo cai de até + 33 ms para até 15,6 ms. nada disso é padrão da `0.18.2`: a simulação padrão é 60 hz e os + patches 20 hz (`DEFAULT_PATCH_RATE = 1000 / 20`), então a sala chama + `setFixedTimestep(passo, 64)` e `patchRate = 1000 / 64`. +- **o tick de 64 hz muda a [adr 0003](0003-fisica-e-controlador.md), o + [simulation-model.md](../simulation-model.md) e o `simulation.tickHz` de + `config/gameplay.json`**, que dizem 60. os valores de gameplay estão em unidades do si, não + por tick, então a mudança não reajusta o movimento. +- **quatro jogadores por sala.** o [gdd](../gdd.md) e o [nfr.md](../nfr.md) dizem 8 e mudam + junto com esta adr. o item 4 da fila de corte da adr 0002 deixa de existir, porque já foi + aplicado. o level design pensado para 8 precisa ser revisto para não deixar o mapa vazio. +- **o que tira latência do websocket:** + - nagle e `permessage-deflate` desligados. conferido no código da `0.18.2`: a lib `ws` chama + `socket.setNoDelay()` em todo socket, e o `WebSocketTransport` põe + `perMessageDeflate: false` quando a opção não é passada. + - patch pequeno. é delta do `@colyseus/schema`, e com quatro jogadores cabe folgado num + segmento tcp, então uma perda custa uma retransmissão, não várias. + - **servidor na lan do estande, com roteador próprio**, servidor no cabo e wi-fi de 5 ghz só + para o jogo. perda de pacote é o único ponto fraco do tcp, e rede própria é o que a reduz. + rtt local fica na casa de 1 a 5 ms. + - página em `https` e servidor em `wss`, com certificado. página em `https` não abre `ws://` + (conteúdo misto), e o microfone do chat de voz exige `https` + ([adr 0009](0009-chat-de-voz.md)), então `http` local não é opção. +- servidor no wsl2 funciona na lan pelo `netsh interface portproxy`, porque ele encaminha tcp. + isso só é verdade enquanto o transporte for tcp. +- o transporte continua atrás de uma interface do projeto. se a feira um dia liberar udp, a + opção b volta a ser candidata sem reescrever o jogo. + +## revisão + +reabrir se, medido com perda de pacote simulada (`tc netem`, 2%) na rede do estande, o p99 do +erro posicional passar de 0,10 m, que é o critério do [nfr.md](../nfr.md). reabrir também se o +p99 do tick de servidor a 64 hz estourar com quatro jogadores, ou se a rede da feira passar a +liberar udp. From e31eebe3a96b8b6e414fa1f08782f7a0f5bff4b7 Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 23 Sep 2026 10:22:53 -0300 Subject: [PATCH 002/139] docs: record livekit as the voice chat server --- docs/adr/0009-chat-de-voz.md | 84 ++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/adr/0009-chat-de-voz.md diff --git a/docs/adr/0009-chat-de-voz.md b/docs/adr/0009-chat-de-voz.md new file mode 100644 index 00000000..ca8fbcb6 --- /dev/null +++ b/docs/adr/0009-chat-de-voz.md @@ -0,0 +1,84 @@ +# ADR 0009 — chat de voz por livekit, ao lado do colyseus + +- **status**: proposta +- **data**: 2026-09-23 +- **decide**: renato + +## contexto + +o jogo passa a ter chat de voz entre os jogadores da sala. voz tem requisitos que o canal do +jogo não cobre: cancelamento de eco (o som do jogo e a voz dos outros voltam pelo microfone), +supressão de ruído, codec de voz, jitter buffer e ocultação de perda. e tolera o que o tiro não +tolera: uns 150 ms de boca a ouvido e pacote perdido disfarçado. + +o multiplayer do jogo é colyseus sobre websocket com servidor autoritativo +([adr 0002](0002-transporte-de-rede.md), [adr 0008](0008-tick-alto-e-sala-de-quatro.md)), e +isso não muda. + +## opções consideradas + +### opção a — voz pelo colyseus + +capturar, codificar com webcodecs e mandar os pedaços pelo websocket do jogo. + +- contra: jitter buffer, ocultação de perda e controle de congestionamento escritos à mão. sobre + tcp, um pacote perdido congela o áudio em vez de ser disfarçado. e o áudio divide o socket com + o input e o snapshot, disputando banda com o tiro. + +### opção b — webrtc p2p em malha + +- contra: sinalização, ice e turn escritos à mão. com quatro jogadores, cada um manda três + cópias da própria voz. + +### opção c — livekit (sfu webrtc) self-hosted + +o webrtc do navegador traz eco, ruído, opus, jitter buffer e ocultação de perda. o sfu recebe +um fluxo por jogador e repassa sem decodificar. o livekit traz servidor, sdk, detecção de quem +fala e fallback para tcp prontos. + +- a favor: é o que a indústria usa para voz; nada de mídia escrito à mão. +- contra: mais um processo para operar no estande, e mais uma dependência. + +## decisão + +**usamos livekit self-hosted para voz, em paralelo ao colyseus. o colyseus continua sendo o +único dono do jogo; o livekit só carrega áudio.** + +o critério: cada ferramenta resolve o problema para o qual foi feita. o colyseus não foi feito +para mídia, e o livekit não entende o jogo. nenhum dado de jogo passa pelo livekit. + +## consequências + +- **o colyseus é a fonte de verdade de quem está em qual sala.** quando o jogador entra na sala + do colyseus, o servidor do jogo emite o token de acesso do livekit para a sala de voz + correspondente. ninguém entra na voz sem estar na partida, e sair da partida derruba a voz. +- **o livekit roda na lan do estande**, junto do servidor do jogo, e não no livekit cloud: a + feira não pode depender de internet. porta http/websocket de sinalização (7880 por padrão), + udp num mux de porta única (`rtc.udp_port`) em vez da faixa 50000–60000, e fallback ice-tcp + ligado (`rtc.allow_tcp_fallback`, porta 7881) para quando o udp não passar. o modo `development` e a + chave `devkey` não vão para o estande. +- **o microfone exige contexto seguro.** `getUserMedia` só funciona em `https` ou `localhost`. + a página passa a ser servida em `https`, então o colyseus passa a ser `wss`, e a opção de + `http` local da [adr 0008](0008-tick-alto-e-sala-de-quatro.md) deixa de valer. certificado + válido na lan vira item obrigatório de deploy: domínio do projeto apontando para o ip local + com certificado emitido por desafio dns, ou uma ca local instalada nas máquinas do estande. +- **o livekit fica atrás de uma interface do cliente** (entrar, sair, mutar, quem está + falando). o `@shobu/core` não o importa, e o jogo roda inteiro com a voz desligada. +- **a voz está fora do caminho crítico.** se a data apertar, ela é o primeiro corte, antes de + qualquer item da fila da [adr 0002](0002-transporte-de-rede.md). ela também não pode custar + cpu de quadro: se o áudio derrubar o fps do cliente, desliga. +- **o áudio é publicado com o preset `speech` (24 kbps).** o padrão do `livekit-client` é + `music` (48 kbps), com red e dtx ligados. o red (rfc 2198) repete o quadro anterior em cada + pacote e cobre perda isolada sem retransmissão, então fica ligado. com `speech`, cada jogador + falando gera um fluxo de cerca de 77 kbps no fio; com `music`, cerca de 125 kbps. +- em estande barulhento, microfone aberto capta o salão. **push-to-talk** é o padrão. +- **voz por proximidade é feita no cliente.** com quatro jogadores, cada um recebe as outras três + vozes e ajusta volume e posição 3D de cada uma pela distância (web audio), usando as posições + que o colyseus já entrega. o livekit continua só carregando áudio. o custo é que um cliente + adulterado pode aumentar o volume de quem está longe. se isso importar, a gameroom passa a + decidir quem ouve quem, mudando as assinaturas de áudio no livekit pela distância. + +## revisão + +reabrir se o livekit na lan não passar pela rede do estande, se o áudio pesar no quadro ou no +tick de servidor, ou se jogar lado a lado no estande tornar a voz desnecessária na feira. From 6236779ea7b100da569b73a3dc5391643da91210 Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 23 Sep 2026 10:23:13 -0300 Subject: [PATCH 003/139] docs: describe the multiplayer network architecture --- ...arquitetura-de-rede-1-topologia.drawio.svg | 4 + ...rquitetura-de-rede-2-protocolos.drawio.svg | 4 + .../arquitetura-de-rede-3-tiro.drawio.svg | 4 + .../arquitetura-de-rede-4-entrada.drawio.svg | 4 + docs/rede/arquitetura-de-rede.drawio | 1 + docs/rede/arquitetura-de-rede.md | 132 ++++++++++++++++++ 6 files changed, 149 insertions(+) create mode 100644 docs/rede/arquitetura-de-rede-1-topologia.drawio.svg create mode 100644 docs/rede/arquitetura-de-rede-2-protocolos.drawio.svg create mode 100644 docs/rede/arquitetura-de-rede-3-tiro.drawio.svg create mode 100644 docs/rede/arquitetura-de-rede-4-entrada.drawio.svg create mode 100644 docs/rede/arquitetura-de-rede.drawio create mode 100644 docs/rede/arquitetura-de-rede.md diff --git a/docs/rede/arquitetura-de-rede-1-topologia.drawio.svg b/docs/rede/arquitetura-de-rede-1-topologia.drawio.svg new file mode 100644 index 00000000..c60e4975 --- /dev/null +++ b/docs/rede/arquitetura-de-rede-1-topologia.drawio.svg @@ -0,0 +1,4 @@ + + + +Figura 1. Topologia de rede do multiplayer no estandeRede local do estandeNavegador [Wi-Fi 5 GHz]Cliente de jogo[@colyseus/sdk 0.18.2]Cliente de voz[livekit-client]Servidor [Ethernet]Servidor de jogo[Node.js, Colyseus 0.18.2]Servidor de mídia[LiveKit, SFU]Twirp sobre HTTP, TCP 7880HTTPS e WSS, TCP 2567↑ input, 64 Hz↓ patches de estado, 64 Hzsinalização: WSS, TCP 7880Opus em RTP/SRTP, UDP 7882fallback ICE-TCP, TCP 7881jogomídia de vozsinalização e controleFigura 1. Topologia de rede do multiplayer no estande. Portas padrão de Colyseus e LiveKit, exceto UDP 7882, que é o mux UDP configurado. \ No newline at end of file diff --git a/docs/rede/arquitetura-de-rede-2-protocolos.drawio.svg b/docs/rede/arquitetura-de-rede-2-protocolos.drawio.svg new file mode 100644 index 00000000..402b697f --- /dev/null +++ b/docs/rede/arquitetura-de-rede-2-protocolos.drawio.svg @@ -0,0 +1,4 @@ + + + +Figura 2. Pilha de protocolos dos canais de jogo e de mídia de vozCanal de jogoMídia de vozCarga útilpatches de estado (@colyseus/schema)mensagens (msgpack)áudio OpusEnquadramentoWebSocketRTP e RTCPSegurançaTLSSRTP, chaves negociadas por DTLSTransporteTCP, sem NagleUDP, ou TCP por ICE-TCPRedeIP sobre Wi-Fi ou EthernetFigura 2. Pilha de protocolos dos canais de jogo e de mídia de voz. A sinalização da voz usa a pilha do canal de jogo (WSS). \ No newline at end of file diff --git a/docs/rede/arquitetura-de-rede-3-tiro.drawio.svg b/docs/rede/arquitetura-de-rede-3-tiro.drawio.svg new file mode 100644 index 00000000..39fd4438 --- /dev/null +++ b/docs/rede/arquitetura-de-rede-3-tiro.drawio.svg @@ -0,0 +1,4 @@ + + + +Figura 3. Diagrama de sequência de um disparo validado pelo servidoratirador : Cliente: GameRoomalvo : Cliente1: aplicarInput(n){predição local}2: input(n, mira, disparo, renderTime)3: passoFixo(n)4: rewind.lastSeenBy(atirador); raycast()5: aplicarKill(alvo)6a: evento kill; patch de estado6b: evento kill; patch de estado7: reconciliar(n)8: interpolar(renderTime)renderTime: instante exibido pelocliente, no relógio do servidor(room.clock), limitado ao histórico;rewind interpolado entre ticksFigura 3. Diagrama de sequência (UML) de um disparo validado pelo servidor. \ No newline at end of file diff --git a/docs/rede/arquitetura-de-rede-4-entrada.drawio.svg b/docs/rede/arquitetura-de-rede-4-entrada.drawio.svg new file mode 100644 index 00000000..f88d2740 --- /dev/null +++ b/docs/rede/arquitetura-de-rede-4-entrada.drawio.svg @@ -0,0 +1,4 @@ + + + +Figura 4. Diagrama de sequência da entrada na partida e na sala de voz: Navegador: Servidor de jogo: Servidor de mídia1: GET / (HTTPS)2: joinOrCreate() (HTTPS)reserva de vaga3: conectar(reserva) (WSS)4: onJoin(); assinarJWT()5: token de voz (WSS)6: conectar(token) (WSS, TCP 7880)7: ICE e handshake DTLS (UDP 7882, fallback ICE-TCP 7881)opt[push-to-talk pressionado]8: áudio Opus (RTP/SRTP)9: desconectar()10: RemoveParticipant(roomId, sessionId) (Twirp/HTTP)Figura 4. Diagrama de sequência (UML) da entrada na partida e na sala de voz, e da saída. \ No newline at end of file diff --git a/docs/rede/arquitetura-de-rede.drawio b/docs/rede/arquitetura-de-rede.drawio new file mode 100644 index 00000000..3d3e2c94 --- /dev/null +++ b/docs/rede/arquitetura-de-rede.drawio @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/rede/arquitetura-de-rede.md b/docs/rede/arquitetura-de-rede.md new file mode 100644 index 00000000..a7d261ca --- /dev/null +++ b/docs/rede/arquitetura-de-rede.md @@ -0,0 +1,132 @@ +# Arquitetura de rede do multiplayer + +Arquitetura de rede do shōbu na feira. As decisões estão nas ADRs +[0002](../adr/0002-transporte-de-rede.md), [0008](../adr/0008-tick-alto-e-sala-de-quatro.md) +e [0009](../adr/0009-chat-de-voz.md). N é o número de jogadores na sala. Nenhum valor abaixo +depende de N ser fixo. + +Cada número traz a origem: **padrão** da biblioteca, **configuração** nossa ou **estimativa** +com a premissa declarada. + +| Parâmetro | Valor | Origem | +|---|---|---| +| Passo de simulação | 64 Hz (15,625 ms) | configuração, `setFixedTimestep(passo, 64)`; o padrão é 60 Hz | +| Envio de patches de estado | 64 Hz | configuração, `patchRate = 1000 / 64`; o padrão é 20 Hz | +| Envio de input | 64 Hz | configuração | +| Heartbeat do WebSocket | ping a cada 3 s, desconecta após 2 sem resposta | padrão do `WebSocketTransport` | +| Tamanho máximo de mensagem do cliente | 4 KiB | padrão do `WebSocketTransport` (`maxPayload`) | +| Áudio | Opus, preset `speech` (24 kbps), quadro de 20 ms, RED e DTX | configuração; o padrão do `livekit-client` é o preset `music`, 48 kbps | + +Simulação e patches rodam em dois `setInterval` independentes. A simulação usa acumulador +(`setFixedTimestep`) e faz exatamente 64 passos por segundo em média. Os patches saem no ritmo +do próprio timer, que no Node 22 mediu 15,24 ms de período médio para 15,625 ms pedidos +(65,6 Hz). Como os dois timers não têm fase comum, um patch às vezes não traz passo novo e o +seguinte traz dois. O intervalo entre patches com passo novo varia de cerca de 15,6 a 31,3 ms, e o +buffer de interpolação de 2 a 3 intervalos absorve essa variação. O rewind não é afetado, +porque o Colyseus grava o histórico em cada patch enviado, exatamente o que o cliente recebeu. + +## Topologia + +![Figura 1](arquitetura-de-rede-1-topologia.drawio.svg) + +| Origem | Destino | Protocolo | Porta | Conteúdo | +|---|---|---|---|---| +| Cliente de jogo | Servidor de jogo | HTTPS | TCP 2567 | página, matchmaking | +| Cliente de jogo | Servidor de jogo | WSS | TCP 2567 | input, patches de estado, eventos, token de voz | +| Cliente de voz | Servidor de mídia | WSS | TCP 7880 | sinalização | +| Cliente de voz | Servidor de mídia | RTP/SRTP | UDP 7882, fallback ICE-TCP 7881 | áudio Opus | +| Servidor de jogo | Servidor de mídia | Twirp sobre HTTP, loopback | TCP 7880 | `RemoveParticipant` | + +*Tabela 1. Conexões de rede. As portas são padrões de Colyseus e LiveKit, exceto UDP 7882, +que é o mux UDP configurado em `rtc.udp_port`. Sem ele, o LiveKit usa a faixa UDP 50000 a +60000.* + +Toda conexão que sai do navegador é cifrada: TLS no jogo e na sinalização, DTLS-SRTP na mídia. +O navegador só libera o microfone em contexto seguro, e uma página HTTPS não abre `ws://`. O +`WebSocketTransport` do Colyseus cria um servidor HTTP sem TLS, então o servidor de jogo +recebe um `https.Server` com o certificado da LAN, ou fica atrás de um proxy que termina TLS. + +O servidor roda em Linux nativo ou Windows. No WSL2 em modo NAT, o encaminhamento de portas do +Windows (`netsh interface portproxy`) só cobre TCP, e a voz ficaria restrita ao ICE-TCP. + +## Pilha de protocolos + +![Figura 2](arquitetura-de-rede-2-protocolos.drawio.svg) + +O canal de jogo usa TCP porque a rede da feira bloqueia UDP. Sem UDP, todo transporte do +navegador termina em TCP, e o WebSocket direto ao servidor tem menos camadas e menos saltos. A +lib `ws`, usada pelo Colyseus, desativa o algoritmo de Nagle em cada socket +(`socket.setNoDelay()`), e o `WebSocketTransport` desativa `permessage-deflate`. Cada patch sai +no instante em que é gerado, sem agregação nem compressão. + +A fragilidade do TCP é a perda de pacote: um segmento perdido retém os seguintes até a +retransmissão (head-of-line blocking). Num fluxo de 64 pacotes por segundo, o segmento seguinte +chega 15,6 ms depois, o receptor responde com SACK, e o RACK-TLP do Linux (RFC 8985) marca a +perda e retransmite. A recuperação típica leva um intervalo entre pacotes mais um RTT, cerca de +20 ms na LAN. No pior caso, com perdas seguidas, ela espera o RTO mínimo do Linux, 200 ms. É +esse caso que o critério de aceite mede com perda simulada. + +A mídia de voz usa WebRTC porque o áudio tolera atraso e perda. O RED (RFC 2198) repete o quadro +anterior em cada pacote, e uma perda isolada é reconstruída sem retransmissão. + +## Orçamento de banda + +Por pacote, o canal de jogo soma IPv4 (20 B), TCP com timestamps (32 B), registro TLS 1.3 +(22 B: cabeçalho de 5, tipo de 1, tag AEAD de 16) e cabeçalho WebSocket (6 B do cliente, com +máscara; 4 B do servidor para cargas de 126 B a 64 KiB). Premissas: input de 32 B e patch de +60 B por jogador, a ordem de grandeza do estado quantizado em [nfr.md](../nfr.md). + +A mídia de voz soma IPv4 (20 B), UDP (8 B), RTP com extensões (cerca de 24 B) e tag SRTP (16 B +com AES-GCM, 10 B com HMAC-SHA1-80). Um quadro Opus de 20 ms a 24 kbps tem 60 B, e o RED leva a +carga a 125 B. + +| Fluxo | Bytes por pacote | Pacotes/s | Banda | +|---|---|---|---| +| Input, cliente → servidor | 32 + 80 = 112 | 64 | 57,3 kbps | +| Patches, servidor → cliente | 60N + 78 | 64 | 512·(60N + 78) bps; 162,8 kbps com N = 4 | +| Áudio de um jogador falando (um fluxo RTP) | 125 + 68 = 193 | 50 | 77,2 kbps | + +*Tabela 2. Banda por cliente. Camada de enlace, ACKs puros do TCP e RTCP ficam fora da conta.* + +Cada jogador que fala gera um fluxo de áudio, e com push-to-talk o fluxo só existe enquanto a +tecla está pressionada. Um cliente recebe no máximo N − 1 fluxos, um por jogador falando ao mesmo +tempo, e o servidor de mídia emite no máximo N(N − 1). Com o preset padrão `music`, cada fluxo +passaria de 77 para 125 kbps. + +No Wi-Fi, o limite prático é a taxa de pacotes, não a banda: cada quadro paga contenção e +preâmbulo independentemente do tamanho. Um cliente envia até 114 pacotes/s e recebe até +64 + 50(N − 1). + +## Sequência de um disparo + +![Figura 3](arquitetura-de-rede-3-tiro.drawio.svg) + +O cliente aplica o próprio disparo sem esperar a rede (mensagem 1), e o servidor decide o +acerto (mensagens 3 a 5). Cada input leva um `renderTime`: o instante que o cliente exibia, no +relógio do servidor, sincronizado por `room.clock`. Na mensagem 4, `rewind.lastSeenBy` limita +esse instante à janela de histórico e rebobina as cápsulas até ele, interpolando entre ticks. O +servidor não estima o atraso do atirador: o cliente informa o que viu, e o servidor só aceita +instantes dentro da janela. + +| Componente | Latência | Origem | +|---|---|---| +| Movimento e disparo do próprio jogador | 0 ms | predição local | +| Ida e volta no Wi-Fi da LAN | 2 a 10 ms | estimativa, a medir | +| Espera até o próximo passo do servidor | até 15,6 ms | 64 Hz | +| Buffer de interpolação dos outros jogadores | 31,3 a 46,9 ms | 2 a 3 intervalos de patch | +| Recuperação de um segmento TCP perdido | cerca de 20 ms; até 200 ms | RACK-TLP; RTO mínimo do Linux | + +*Tabela 3. Orçamento de latência na LAN.* + +O critério de aceite é o p99 do erro posicional abaixo de 0,10 m ([nfr.md](../nfr.md)), medido +com 2% de perda simulada por `tc netem`. + +## Entrada na partida e na voz + +![Figura 4](arquitetura-de-rede-4-entrada.drawio.svg) + +O servidor de jogo decide quem está em cada sala. O servidor de mídia só aceita quem traz um +token assinado por ele (mensagem 4), com sala igual ao `roomId`, identidade igual ao `sessionId` +e permissão apenas de áudio. Antes do áudio, o cliente faz as verificações de conectividade do +ICE e o handshake DTLS que deriva as chaves do SRTP (mensagem 7). Quando o jogador sai, o +servidor de jogo o remove da sala de voz (mensagem 10). From ecae674d3e3ef5f59a4f1c2b2f358036e0fd1be9 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 19:32:20 -0300 Subject: [PATCH 004/139] docs: add the domain glossary --- CONTEXT.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 CONTEXT.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..ad11f45d --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,81 @@ +# shōbu + +FPS de sniper PvP no navegador, com servidor autoritativo. Este glossário fixa os nomes que o +código, os documentos e as conversas usam para as mesmas coisas. + +## Partida + +**Sala**: +O grupo de jogadores conectados juntos ao servidor de jogo. Dura várias partidas seguidas, e +quem chega entra na sala mesmo com uma partida em andamento. +_Evitar_: room, lobby + +**Endereço da sala**: +O identificador de uma sala, escrito como um endereço IPv4 fictício no caminho da URL +(`/203.0.113.7`). Não tem relação com o endereço real de nenhuma máquina. Os gerados evitam as +faixas privadas e reservadas; um endereço digitado vale em qualquer faixa, e abrir um endereço +sem sala cria a sala. Cada octeto vai de 0 a 255, sem zeros à esquerda: `10.0.0.07` é a sala +`10.0.0.7`. +_Evitar_: código da sala, IP da sala, room id + +**Fila da sala**: +Os jogadores que abriram o endereço de uma sala cheia e esperam vaga nela, por ordem de chegada. +Quem está na fila joga numa sala provisória até a vaga abrir. +_Evitar_: lobby, espera + +**Sala provisória**: +Uma sala comum com vaga, onde um jogador da fila joga enquanto espera. A URL mostra o endereço +dela, não o da sala pedida. Quem espera pela mesma sala pode estar em salas provisórias +diferentes. Quando abre vaga na sala pedida, o jogador é levado para lá, mesmo no meio de uma +partida, e a URL muda. +_Evitar_: sala temporária, overflow + +**Partida**: +Uma rodada de 5 minutos dentro de uma sala. Quem entra na sala durante a partida joga o tempo +que resta dela. +_Evitar_: match, round, sala + +**Sala de voz**: +O canal de voz de uma sala. Existe enquanto a sala existe e atravessa as partidas. +_Evitar_: room de voz + +## Simulação + +**Tick**: +A unidade fixa de tempo em que o estado da partida avança, igual no cliente e no servidor. +_Evitar_: passo, frame, quadro + +**Snapshot**: +O estado completo da partida num tick. +_Evitar_: estado, frame + +**Patch de estado**: +A diferença entre dois snapshots, que é o que o servidor de jogo envia aos clientes. +_Evitar_: delta snapshot, update + +**Input**: +O que um jogador comanda num tick: movimento, mira e botões. +_Evitar_: comando + +## Rede + +**Servidor de jogo**: +Única autoridade da partida: simula, valida cada disparo e decide quem está em cada sala. +_Evitar_: servidor Colyseus, host + +**Servidor de mídia**: +Repassa o áudio de voz entre os jogadores de uma mesma sala, sem entender o jogo. +_Evitar_: SFU de voz, servidor de voz + +**Cliente de jogo**: +A parte do navegador que troca input e patches de estado com o servidor de jogo. + +**Cliente de voz**: +A parte do navegador que troca sinalização e áudio com o servidor de mídia. + +**Sinalização**: +Troca de controle que abre, mantém e fecha a conexão de voz, separada do áudio. + +**Fluxo de áudio**: +O áudio de um jogador enquanto ele fala. Existe um por jogador falando. +_Evitar_: locutor From e7d332418329fb4c296bf7fe04dbc836c43983c8 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 19:32:21 -0300 Subject: [PATCH 005/139] refactor: rename snapshotHz to patchHz --- config/gameplay.json | 2 +- packages/core/src/config/gameplayConfig.ts | 2 +- packages/core/src/config/gameplayConfigSpec.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/gameplay.json b/config/gameplay.json index dca726e7..604410ba 100644 --- a/config/gameplay.json +++ b/config/gameplay.json @@ -1,7 +1,7 @@ { "simulation": { "tickHz": 60, - "snapshotHz": 30, + "patchHz": 30, "inputSendHz": 60 }, "movement": { diff --git a/packages/core/src/config/gameplayConfig.ts b/packages/core/src/config/gameplayConfig.ts index 9f9f2411..5cf8562e 100644 --- a/packages/core/src/config/gameplayConfig.ts +++ b/packages/core/src/config/gameplayConfig.ts @@ -13,7 +13,7 @@ /** Taxas fixadas pela ADR 0002, repetidas aqui porque o núcleo as consome. */ export interface SimulationConfig { readonly tickHz: number - readonly snapshotHz: number + readonly patchHz: number readonly inputSendHz: number } diff --git a/packages/core/src/config/gameplayConfigSpec.ts b/packages/core/src/config/gameplayConfigSpec.ts index 9395dbec..8650200e 100644 --- a/packages/core/src/config/gameplayConfigSpec.ts +++ b/packages/core/src/config/gameplayConfigSpec.ts @@ -17,7 +17,7 @@ export interface NumericFieldSpec { export const GAMEPLAY_CONFIG_SPEC: readonly NumericFieldSpec[] = [ { section: 'simulation', key: 'tickHz', minInclusive: 20, maxInclusive: 240 }, - { section: 'simulation', key: 'snapshotHz', minInclusive: 10, maxInclusive: 120 }, + { section: 'simulation', key: 'patchHz', minInclusive: 10, maxInclusive: 120 }, { section: 'simulation', key: 'inputSendHz', minInclusive: 20, maxInclusive: 240 }, { section: 'movement', key: 'gravityMps2', minInclusive: -60, maxInclusive: -1 }, From 586df006eb4418f851b098e4d69f755fbb1aeed1 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 19:32:21 -0300 Subject: [PATCH 006/139] docs: call the simulation unit a tick in the network architecture --- docs/rede/arquitetura-de-rede-3-tiro.drawio.svg | 2 +- docs/rede/arquitetura-de-rede.drawio | 2 +- docs/rede/arquitetura-de-rede.md | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/rede/arquitetura-de-rede-3-tiro.drawio.svg b/docs/rede/arquitetura-de-rede-3-tiro.drawio.svg index 39fd4438..dcf3c2d2 100644 --- a/docs/rede/arquitetura-de-rede-3-tiro.drawio.svg +++ b/docs/rede/arquitetura-de-rede-3-tiro.drawio.svg @@ -1,4 +1,4 @@ -Figura 3. Diagrama de sequência de um disparo validado pelo servidoratirador : Cliente: GameRoomalvo : Cliente1: aplicarInput(n){predição local}2: input(n, mira, disparo, renderTime)3: passoFixo(n)4: rewind.lastSeenBy(atirador); raycast()5: aplicarKill(alvo)6a: evento kill; patch de estado6b: evento kill; patch de estado7: reconciliar(n)8: interpolar(renderTime)renderTime: instante exibido pelocliente, no relógio do servidor(room.clock), limitado ao histórico;rewind interpolado entre ticksFigura 3. Diagrama de sequência (UML) de um disparo validado pelo servidor. \ No newline at end of file +Figura 3. Diagrama de sequência de um disparo validado pelo servidoratirador : Cliente: GameRoomalvo : Cliente1: aplicarInput(n){predição local}2: input(n, mira, disparo, renderTime)3: simularTick(n)4: rewind.lastSeenBy(atirador); raycast()5: aplicarKill(alvo)6a: evento kill; patch de estado6b: evento kill; patch de estado7: reconciliar(n)8: interpolar(renderTime)renderTime: instante exibido pelocliente, no relógio do servidor(room.clock), limitado ao histórico;rewind interpolado entre ticksFigura 3. Diagrama de sequência (UML) de um disparo validado pelo servidor. \ No newline at end of file diff --git a/docs/rede/arquitetura-de-rede.drawio b/docs/rede/arquitetura-de-rede.drawio index 3d3e2c94..7a421b45 100644 --- a/docs/rede/arquitetura-de-rede.drawio +++ b/docs/rede/arquitetura-de-rede.drawio @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/docs/rede/arquitetura-de-rede.md b/docs/rede/arquitetura-de-rede.md index a7d261ca..bf4479e0 100644 --- a/docs/rede/arquitetura-de-rede.md +++ b/docs/rede/arquitetura-de-rede.md @@ -10,7 +10,7 @@ com a premissa declarada. | Parâmetro | Valor | Origem | |---|---|---| -| Passo de simulação | 64 Hz (15,625 ms) | configuração, `setFixedTimestep(passo, 64)`; o padrão é 60 Hz | +| Tick | 64 Hz (15,625 ms) | configuração, `setFixedTimestep(simular, 64)`; o padrão é 60 Hz | | Envio de patches de estado | 64 Hz | configuração, `patchRate = 1000 / 64`; o padrão é 20 Hz | | Envio de input | 64 Hz | configuração | | Heartbeat do WebSocket | ping a cada 3 s, desconecta após 2 sem resposta | padrão do `WebSocketTransport` | @@ -18,10 +18,10 @@ com a premissa declarada. | Áudio | Opus, preset `speech` (24 kbps), quadro de 20 ms, RED e DTX | configuração; o padrão do `livekit-client` é o preset `music`, 48 kbps | Simulação e patches rodam em dois `setInterval` independentes. A simulação usa acumulador -(`setFixedTimestep`) e faz exatamente 64 passos por segundo em média. Os patches saem no ritmo +(`setFixedTimestep`) e faz exatamente 64 ticks por segundo em média. Os patches saem no ritmo do próprio timer, que no Node 22 mediu 15,24 ms de período médio para 15,625 ms pedidos -(65,6 Hz). Como os dois timers não têm fase comum, um patch às vezes não traz passo novo e o -seguinte traz dois. O intervalo entre patches com passo novo varia de cerca de 15,6 a 31,3 ms, e o +(65,6 Hz). Como os dois timers não têm fase comum, um patch às vezes não traz tick novo e o +seguinte traz dois. O intervalo entre patches com tick novo varia de cerca de 15,6 a 31,3 ms, e o buffer de interpolação de 2 a 3 intervalos absorve essa variação. O rewind não é afetado, porque o Colyseus grava o histórico em cada patch enviado, exatamente o que o cliente recebeu. @@ -112,7 +112,7 @@ instantes dentro da janela. |---|---|---| | Movimento e disparo do próprio jogador | 0 ms | predição local | | Ida e volta no Wi-Fi da LAN | 2 a 10 ms | estimativa, a medir | -| Espera até o próximo passo do servidor | até 15,6 ms | 64 Hz | +| Espera até o próximo tick do servidor | até 15,6 ms | 64 Hz | | Buffer de interpolação dos outros jogadores | 31,3 a 46,9 ms | 2 a 3 intervalos de patch | | Recuperação de um segmento TCP perdido | cerca de 20 ms; até 200 ms | RACK-TLP; RTO mínimo do Linux | From 6b998025ae991f1c4ae04f85d47a5151b9208c4b Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 19:36:44 -0300 Subject: [PATCH 007/139] docs: record the four-player room decision --- docs/adr/0010-sala-de-quatro.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/adr/0010-sala-de-quatro.md diff --git a/docs/adr/0010-sala-de-quatro.md b/docs/adr/0010-sala-de-quatro.md new file mode 100644 index 00000000..9a68e801 --- /dev/null +++ b/docs/adr/0010-sala-de-quatro.md @@ -0,0 +1,18 @@ +# ADR 0010 — sala de quatro jogadores + +- **status**: proposta +- **data**: 2026-09-28 +- **decide**: renato + +o [gdd](../gdd.md) previa oito jogadores por sala. reduzimos para quatro. com patches de estado a +64 hz, cada jogador a mais aumenta o histórico de rewind, a cpu de cada tick e a banda de descida +de todos os clientes, porque o patch cresce com o número de jogadores. a arquitetura de rede não +depende desse número: o orçamento em [docs/rede](../rede/arquitetura-de-rede.md) está em função +de N. + +## consequências + +- o gdd e o [nfr.md](../nfr.md) mudam de oito para quatro. +- o item 4 da fila de corte da [adr 0002](0002-transporte-de-rede.md), reduzir para quatro + jogadores, deixa de existir porque já foi aplicado. +- o level design, pensado para oito, precisa ser revisto para o mapa não ficar vazio. From ed8a816141adab8de3ed78be47ee74a0c761b061 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 19:36:44 -0300 Subject: [PATCH 008/139] docs: record ipv4-style room addresses with a queue --- docs/adr/0011-endereco-da-sala.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 docs/adr/0011-endereco-da-sala.md diff --git a/docs/adr/0011-endereco-da-sala.md b/docs/adr/0011-endereco-da-sala.md new file mode 100644 index 00000000..e1577b39 --- /dev/null +++ b/docs/adr/0011-endereco-da-sala.md @@ -0,0 +1,28 @@ +# ADR 0011 — endereço da sala em formato ipv4, com fila e sala provisória + +- **status**: proposta +- **data**: 2026-09-28 +- **decide**: renato + +cada sala é identificada por um endereço ipv4 fictício no caminho da url, como +`shobu.com/203.0.113.7`. abrir um endereço sem sala cria a sala, e os endereços gerados evitam as +faixas privadas e reservadas para não se confundir com os ips reais da rede do estande. quem abre +o endereço de uma sala cheia entra na fila dela e joga numa sala provisória até a vaga abrir. os +termos estão definidos no [CONTEXT.md](../../CONTEXT.md). + +## opções descartadas + +- **código curto alfanumérico**: funciona igual, mas perde a identidade de rede do jogo. +- **esperar vaga parado**: contradiz o pilar 2 ([pillars.md](../pillars.md)), que exige controle + do personagem em menos de cinco segundos depois do clique no link. +- **mandar para outra sala sem fila**: cumpre o pilar, mas separa quem abriu o link de quem o + mandou. + +## consequências + +- o id de sala do colyseus só aceita letras, números, `_` e `-`, então o servidor de jogo mantém + um mapa de endereço para sala. +- um caminho com ponto, como `/203.0.113.7`, costuma ser tratado como arquivo pelo servidor de + página. a rota precisa devolver a página para esses caminhos. +- ir da sala provisória para a pedida é sair e entrar de novo no servidor de jogo e no servidor + de mídia, no meio de uma partida. From f663750d41052353cd471cd4fb10e843c61a4ca9 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 19:36:45 -0300 Subject: [PATCH 009/139] docs: narrow adr 0008 to the transport decision --- docs/adr/0008-tick-alto-e-sala-de-quatro.md | 113 -------------------- docs/adr/0008-websocket-sem-udp.md | 28 +++++ docs/rede/arquitetura-de-rede.md | 15 ++- 3 files changed, 40 insertions(+), 116 deletions(-) delete mode 100644 docs/adr/0008-tick-alto-e-sala-de-quatro.md create mode 100644 docs/adr/0008-websocket-sem-udp.md diff --git a/docs/adr/0008-tick-alto-e-sala-de-quatro.md b/docs/adr/0008-tick-alto-e-sala-de-quatro.md deleted file mode 100644 index e93e106e..00000000 --- a/docs/adr/0008-tick-alto-e-sala-de-quatro.md +++ /dev/null @@ -1,113 +0,0 @@ -# ADR 0008 — tick de 64 hz e sala de quatro, sobre websocket - -- **status**: proposta. substitui, se aceita, a taxa de snapshot e o tamanho de sala da - [adr 0002](0002-transporte-de-rede.md). o transporte websocket, o colyseus pinado em - `0.18.2` e o núcleo de movimento próprio da 0002 continuam valendo, e esta adr os reafirma - depois de reavaliar udp. -- **data**: 2026-09-22 -- **decide**: renato - -## contexto - -a pergunta foi se um transporte com semântica de udp tiraria latência do jogo. o navegador não -abre socket udp; os caminhos que existem são webtransport (quic) e webrtc datachannel. os dois -dependem de udp, e **a rede da feira bloqueia udp**. - -o que tira latência de verdade, independente do transporte, está no tick e no tamanho da sala: -a 30 hz o tick sozinho soma até 33 ms, e oito jogadores custam histórico de rewind e cpu por -sala. - -restrições: servidor autoritativo obrigatório (adr 0002), cerca de 8 semanas até 19/11, dois -engenheiros. - -## opções consideradas - -### opção a — websocket pelo colyseus (adr 0002) - -tcp direto entre cliente e servidor autoritativo. - -- a favor: passa por qualquer rede, é estável, mantém predição, reconciliação e rewind do - colyseus. sobre tcp é o caminho com menos camadas e menos saltos. -- contra: com perda de pacote, o head-of-line blocking do tcp segura os pacotes seguintes por - pelo menos um rtt. - -### opção b — webtransport (quic) pelo `@colyseus/h3-transport` - -datagrama não confiável para input, sem sair do colyseus. - -- contra: **não conecta com udp bloqueado**, e webtransport não cai sozinho para tcp. exige - certificado tls válido, e o roadmap do colyseus declara o transporte não estável. - -### opção c — webrtc datachannel com sfu (livekit) - -- contra: sem udp, o ice cai para turn sobre tcp, e o datachannel não confiável passa a viajar - dentro de tcp. o head-of-line blocking volta inteiro, com mais camadas que o websocket. o sfu - não entende o jogo: para ter autoridade, o servidor entra como participante e o caminho vira - cliente → sfu → servidor → sfu → cliente. e sai do colyseus, então netcode volta a ser escrito - à mão. - -### opção d — p2p - -um trecho a menos, mas ninguém tem autoridade sobre o acerto: ou um jogador vira host, com -vantagem e possibilidade de trapaça, ou o cliente afirma acerto, que a adr 0002 proíbe. com -quatro jogadores são três conexões por cliente, e onde o nat falha precisa de turn, que é um -servidor no meio de novo. "p2p com sfu" tem a latência do cliente-servidor e a falta de -autoridade do p2p. - -### opção e — mqtt - -protocolo de pub/sub para iot, não transporte. no navegador roda sobre websocket, adiciona o -salto do broker e não traz sala, estado, predição nem rewind. - -## decisão - -**usamos websocket pelo colyseus, com simulação, patches de estado e input a 64 hz e salas de -quatro jogadores.** - -o critério: com udp bloqueado, todo transporte do navegador cai para tcp, e sobre tcp o -websocket direto é o caminho mais curto. o ganho de latência que sobra está no tick e na sala. -os patches acompanham o tick de simulação: um patch por passo, sem estado velho à espera e com -rewind na resolução de um passo. o tick sai de 60 para 64 hz porque 1/64 s é 2⁻⁶, exato em -ponto flutuante binário, e 1/60 s não é. somar passos, converter tick em tempo e voltar dá o -mesmo número no cliente e no servidor sem arredondamento acumulado: uma hora de passos de 1/60 -soma 3600,0000000182 s, e de 1/64 soma 3600 exatos. o ganho numérico é pequeno, e o custo -também: 6,7% a mais de banda e de cpu que a 60 hz. 128 hz foi descartado porque dobra o custo do -servidor e do replay de reconciliação. a sala de quatro compensa a taxa de patches mais alta, -com menos histórico de rewind e menos cpu por sala. - -## consequências - -- **patches de estado, simulação e input a 64 hz.** a espera pelo próximo passo cai de até - 33 ms para até 15,6 ms. nada disso é padrão da `0.18.2`: a simulação padrão é 60 hz e os - patches 20 hz (`DEFAULT_PATCH_RATE = 1000 / 20`), então a sala chama - `setFixedTimestep(passo, 64)` e `patchRate = 1000 / 64`. -- **o tick de 64 hz muda a [adr 0003](0003-fisica-e-controlador.md), o - [simulation-model.md](../simulation-model.md) e o `simulation.tickHz` de - `config/gameplay.json`**, que dizem 60. os valores de gameplay estão em unidades do si, não - por tick, então a mudança não reajusta o movimento. -- **quatro jogadores por sala.** o [gdd](../gdd.md) e o [nfr.md](../nfr.md) dizem 8 e mudam - junto com esta adr. o item 4 da fila de corte da adr 0002 deixa de existir, porque já foi - aplicado. o level design pensado para 8 precisa ser revisto para não deixar o mapa vazio. -- **o que tira latência do websocket:** - - nagle e `permessage-deflate` desligados. conferido no código da `0.18.2`: a lib `ws` chama - `socket.setNoDelay()` em todo socket, e o `WebSocketTransport` põe - `perMessageDeflate: false` quando a opção não é passada. - - patch pequeno. é delta do `@colyseus/schema`, e com quatro jogadores cabe folgado num - segmento tcp, então uma perda custa uma retransmissão, não várias. - - **servidor na lan do estande, com roteador próprio**, servidor no cabo e wi-fi de 5 ghz só - para o jogo. perda de pacote é o único ponto fraco do tcp, e rede própria é o que a reduz. - rtt local fica na casa de 1 a 5 ms. - - página em `https` e servidor em `wss`, com certificado. página em `https` não abre `ws://` - (conteúdo misto), e o microfone do chat de voz exige `https` - ([adr 0009](0009-chat-de-voz.md)), então `http` local não é opção. -- servidor no wsl2 funciona na lan pelo `netsh interface portproxy`, porque ele encaminha tcp. - isso só é verdade enquanto o transporte for tcp. -- o transporte continua atrás de uma interface do projeto. se a feira um dia liberar udp, a - opção b volta a ser candidata sem reescrever o jogo. - -## revisão - -reabrir se, medido com perda de pacote simulada (`tc netem`, 2%) na rede do estande, o p99 do -erro posicional passar de 0,10 m, que é o critério do [nfr.md](../nfr.md). reabrir também se o -p99 do tick de servidor a 64 hz estourar com quatro jogadores, ou se a rede da feira passar a -liberar udp. diff --git a/docs/adr/0008-websocket-sem-udp.md b/docs/adr/0008-websocket-sem-udp.md new file mode 100644 index 00000000..0f1c0e9b --- /dev/null +++ b/docs/adr/0008-websocket-sem-udp.md @@ -0,0 +1,28 @@ +# ADR 0008 — websocket mantido, sem udp + +- **status**: proposta +- **data**: 2026-09-22 +- **decide**: renato + +a rede da feira bloqueia udp, e no navegador todo transporte com datagrama depende dele: +webtransport não conecta, e webrtc cai para tcp. reavaliamos a [adr 0002](0002-transporte-de-rede.md) +e mantemos websocket direto entre cliente de jogo e servidor de jogo, porque sobre tcp é o caminho +com menos camadas e menos saltos. o ponto fraco do tcp é a perda de pacote, e por isso o estande +tem rede própria. o detalhe de rede está em [docs/rede](../rede/arquitetura-de-rede.md). + +## opções descartadas + +- **quic, pelo `@colyseus/h3-transport`**: não conecta sem udp e não cai sozinho para tcp. o + próprio colyseus declara esse transporte instável. +- **webrtc com sfu (livekit)**: sem udp, a mídia vai por ice-tcp e o head-of-line blocking volta, + com mais camadas. o sfu não entende o jogo, então o servidor de jogo entra como participante e + cada mensagem ganha um salto. e sai do colyseus, com o netcode escrito à mão. +- **p2p**: ninguém tem autoridade sobre o acerto. um jogador vira host, com vantagem e chance de + trapaça, ou o cliente afirma o acerto, que a adr 0002 proíbe. +- **mqtt**: pub/sub de iot sobre websocket, com um broker no meio e sem sala, estado nem rewind. + +## revisão + +o transporte fica atrás de uma interface do projeto. se a rede da feira liberar udp, o quic volta +a ser candidato sem reescrever o jogo. reabrir também se o critério do [nfr.md](../nfr.md) falhar +com perda de pacote simulada. diff --git a/docs/rede/arquitetura-de-rede.md b/docs/rede/arquitetura-de-rede.md index bf4479e0..553a0183 100644 --- a/docs/rede/arquitetura-de-rede.md +++ b/docs/rede/arquitetura-de-rede.md @@ -1,8 +1,9 @@ # Arquitetura de rede do multiplayer Arquitetura de rede do shōbu na feira. As decisões estão nas ADRs -[0002](../adr/0002-transporte-de-rede.md), [0008](../adr/0008-tick-alto-e-sala-de-quatro.md) -e [0009](../adr/0009-chat-de-voz.md). N é o número de jogadores na sala. Nenhum valor abaixo +[0002](../adr/0002-transporte-de-rede.md), [0008](../adr/0008-websocket-sem-udp.md), +[0009](../adr/0009-chat-de-voz.md), [0010](../adr/0010-sala-de-quatro.md) e +[0011](../adr/0011-endereco-da-sala.md). N é o número de jogadores na sala. Nenhum valor abaixo depende de N ser fixo. Cada número traz a origem: **padrão** da biblioteca, **configuração** nossa ou **estimativa** @@ -17,6 +18,10 @@ com a premissa declarada. | Tamanho máximo de mensagem do cliente | 4 KiB | padrão do `WebSocketTransport` (`maxPayload`) | | Áudio | Opus, preset `speech` (24 kbps), quadro de 20 ms, RED e DTX | configuração; o padrão do `livekit-client` é o preset `music`, 48 kbps | +O tick é de 64 Hz, e não 60, porque 1/64 s é 2⁻⁶, exato em ponto flutuante binário. Somar +ticks e converter tick em tempo nunca arredonda: uma hora de ticks de 1/60 s soma +3600,0000000182 s, e de 1/64 s soma 3600 s exatos. O custo é 6,7% a mais de banda e de CPU. + Simulação e patches rodam em dois `setInterval` independentes. A simulação usa acumulador (`setFixedTimestep`) e faz exatamente 64 ticks por segundo em média. Os patches saem no ritmo do próprio timer, que no Node 22 mediu 15,24 ms de período médio para 15,625 ms pedidos @@ -46,6 +51,9 @@ O navegador só libera o microfone em contexto seguro, e uma página HTTPS não `WebSocketTransport` do Colyseus cria um servidor HTTP sem TLS, então o servidor de jogo recebe um `https.Server` com o certificado da LAN, ou fica atrás de um proxy que termina TLS. +O servidor de mídia roda fora do modo `development` e com chaves próprias: as chaves desse +modo são públicas. + O servidor roda em Linux nativo ou Windows. No WSL2 em modo NAT, o encaminhamento de portas do Windows (`netsh interface portproxy`) só cobre TCP, e a voz ficaria restrita ao ICE-TCP. @@ -64,7 +72,8 @@ retransmissão (head-of-line blocking). Num fluxo de 64 pacotes por segundo, o s chega 15,6 ms depois, o receptor responde com SACK, e o RACK-TLP do Linux (RFC 8985) marca a perda e retransmite. A recuperação típica leva um intervalo entre pacotes mais um RTT, cerca de 20 ms na LAN. No pior caso, com perdas seguidas, ela espera o RTO mínimo do Linux, 200 ms. É -esse caso que o critério de aceite mede com perda simulada. +esse caso que o critério de aceite mede com perda simulada. A defesa contra a perda é a rede +própria do estande: Wi-Fi de 5 GHz só para o jogo e o servidor no cabo. A mídia de voz usa WebRTC porque o áudio tolera atraso e perda. O RED (RFC 2198) repete o quadro anterior em cada pacote, e uma perda isolada é reconstruída sem retransmissão. From 9cb423587c2b32f5301e79466f449e0fbc806e35 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 19:36:45 -0300 Subject: [PATCH 010/139] docs: narrow adr 0009 to the voice decision --- docs/adr/0009-chat-de-voz.md | 90 ++++++++---------------------------- 1 file changed, 20 insertions(+), 70 deletions(-) diff --git a/docs/adr/0009-chat-de-voz.md b/docs/adr/0009-chat-de-voz.md index ca8fbcb6..d709c38a 100644 --- a/docs/adr/0009-chat-de-voz.md +++ b/docs/adr/0009-chat-de-voz.md @@ -4,81 +4,31 @@ - **data**: 2026-09-23 - **decide**: renato -## contexto +voz precisa de cancelamento de eco, supressão de ruído, codec de voz, jitter buffer e ocultação +de perda, e aceita atraso e perda que o tiro não aceita. usamos um servidor de mídia livekit na +rede do estande, ao lado do servidor de jogo. o webrtc do navegador já faz o tratamento de áudio, +e o livekit só repassa os fluxos de áudio, sem entender o jogo. nenhum dado de jogo passa por +ele. o detalhe de rede está em [docs/rede](../rede/arquitetura-de-rede.md). -o jogo passa a ter chat de voz entre os jogadores da sala. voz tem requisitos que o canal do -jogo não cobre: cancelamento de eco (o som do jogo e a voz dos outros voltam pelo microfone), -supressão de ruído, codec de voz, jitter buffer e ocultação de perda. e tolera o que o tiro não -tolera: uns 150 ms de boca a ouvido e pacote perdido disfarçado. +## opções descartadas -o multiplayer do jogo é colyseus sobre websocket com servidor autoritativo -([adr 0002](0002-transporte-de-rede.md), [adr 0008](0008-tick-alto-e-sala-de-quatro.md)), e -isso não muda. - -## opções consideradas - -### opção a — voz pelo colyseus - -capturar, codificar com webcodecs e mandar os pedaços pelo websocket do jogo. - -- contra: jitter buffer, ocultação de perda e controle de congestionamento escritos à mão. sobre - tcp, um pacote perdido congela o áudio em vez de ser disfarçado. e o áudio divide o socket com - o input e o snapshot, disputando banda com o tiro. - -### opção b — webrtc p2p em malha - -- contra: sinalização, ice e turn escritos à mão. com quatro jogadores, cada um manda três - cópias da própria voz. - -### opção c — livekit (sfu webrtc) self-hosted - -o webrtc do navegador traz eco, ruído, opus, jitter buffer e ocultação de perda. o sfu recebe -um fluxo por jogador e repassa sem decodificar. o livekit traz servidor, sdk, detecção de quem -fala e fallback para tcp prontos. - -- a favor: é o que a indústria usa para voz; nada de mídia escrito à mão. -- contra: mais um processo para operar no estande, e mais uma dependência. - -## decisão - -**usamos livekit self-hosted para voz, em paralelo ao colyseus. o colyseus continua sendo o -único dono do jogo; o livekit só carrega áudio.** - -o critério: cada ferramenta resolve o problema para o qual foi feita. o colyseus não foi feito -para mídia, e o livekit não entende o jogo. nenhum dado de jogo passa pelo livekit. +- **voz pelo websocket do jogo**: tratamento de áudio escrito à mão, e sobre tcp um pacote + perdido congela o áudio em vez de ser disfarçado. o áudio ainda disputaria banda com o jogo. +- **webrtc p2p em malha**: sinalização e ice escritos à mão, e cada jogador envia uma cópia da + voz para cada outro. ## consequências -- **o colyseus é a fonte de verdade de quem está em qual sala.** quando o jogador entra na sala - do colyseus, o servidor do jogo emite o token de acesso do livekit para a sala de voz - correspondente. ninguém entra na voz sem estar na partida, e sair da partida derruba a voz. -- **o livekit roda na lan do estande**, junto do servidor do jogo, e não no livekit cloud: a - feira não pode depender de internet. porta http/websocket de sinalização (7880 por padrão), - udp num mux de porta única (`rtc.udp_port`) em vez da faixa 50000–60000, e fallback ice-tcp - ligado (`rtc.allow_tcp_fallback`, porta 7881) para quando o udp não passar. o modo `development` e a - chave `devkey` não vão para o estande. -- **o microfone exige contexto seguro.** `getUserMedia` só funciona em `https` ou `localhost`. - a página passa a ser servida em `https`, então o colyseus passa a ser `wss`, e a opção de - `http` local da [adr 0008](0008-tick-alto-e-sala-de-quatro.md) deixa de valer. certificado - válido na lan vira item obrigatório de deploy: domínio do projeto apontando para o ip local - com certificado emitido por desafio dns, ou uma ca local instalada nas máquinas do estande. -- **o livekit fica atrás de uma interface do cliente** (entrar, sair, mutar, quem está - falando). o `@shobu/core` não o importa, e o jogo roda inteiro com a voz desligada. -- **a voz está fora do caminho crítico.** se a data apertar, ela é o primeiro corte, antes de - qualquer item da fila da [adr 0002](0002-transporte-de-rede.md). ela também não pode custar - cpu de quadro: se o áudio derrubar o fps do cliente, desliga. -- **o áudio é publicado com o preset `speech` (24 kbps).** o padrão do `livekit-client` é - `music` (48 kbps), com red e dtx ligados. o red (rfc 2198) repete o quadro anterior em cada - pacote e cobre perda isolada sem retransmissão, então fica ligado. com `speech`, cada jogador - falando gera um fluxo de cerca de 77 kbps no fio; com `music`, cerca de 125 kbps. -- em estande barulhento, microfone aberto capta o salão. **push-to-talk** é o padrão. -- **voz por proximidade é feita no cliente.** com quatro jogadores, cada um recebe as outras três - vozes e ajusta volume e posição 3D de cada uma pela distância (web audio), usando as posições - que o colyseus já entrega. o livekit continua só carregando áudio. o custo é que um cliente - adulterado pode aumentar o volume de quem está longe. se isso importar, a gameroom passa a - decidir quem ouve quem, mudando as assinaturas de áudio no livekit pela distância. +- o servidor de jogo decide quem está na sala de voz: ele emite o token de acesso quando o + jogador entra na sala e o remove quando ele sai. +- o navegador só libera o microfone em https, então a página e o canal de jogo também passam a + exigir tls, com certificado válido na rede do estande. +- a voz por proximidade é calculada no cliente, com as posições que o servidor de jogo já envia. + um cliente adulterado pode ouvir quem está longe. se isso importar, o servidor de jogo passa a + decidir quem ouve quem. +- a voz é o primeiro corte se a data apertar, e o jogo roda inteiro com ela desligada. ## revisão -reabrir se o livekit na lan não passar pela rede do estande, se o áudio pesar no quadro ou no -tick de servidor, ou se jogar lado a lado no estande tornar a voz desnecessária na feira. +reabrir se o servidor de mídia não passar pela rede do estande, se o áudio pesar no quadro, ou +se jogar lado a lado no estande tornar a voz desnecessária. From d94d0c46b404c96b1ad9edac718c75db19bd1ded Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 19:36:46 -0300 Subject: [PATCH 011/139] docs: use the glossary terms for tick and state patch --- docs/nfr.md | 14 +++++++------- docs/simulation-model.md | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/nfr.md b/docs/nfr.md index 0473b4a2..6255aad4 100644 --- a/docs/nfr.md +++ b/docs/nfr.md @@ -60,12 +60,12 @@ dependem delas. | grandeza | valor | onde foi decidido | |---|---|---| | tick de simulação | 60 Hz | [adr 0002](adr/0002-transporte-de-rede.md) | -| taxa de snapshot do servidor | 30 Hz | [adr 0002](adr/0002-transporte-de-rede.md) | +| taxa de patches de estado do servidor | 30 Hz | [adr 0002](adr/0002-transporte-de-rede.md) | | taxa de envio de input do cliente | 60 Hz | [adr 0002](adr/0002-transporte-de-rede.md) | **a janela máxima de rewind não tem valor ainda, tem fórmula.** ela cobre o quanto o cliente -enxerga o mundo atrás do servidor, que é *meio rtt* mais o *buffer de interpolação de snapshot* -(um a dois intervalos de snapshot), mais folga para jitter e um snapshot perdido. o rtt é a +enxerga o mundo atrás do servidor, que é *meio rtt* mais o *buffer de interpolação* +(um a dois intervalos de patch), mais folga para jitter e um patch perdido. o rtt é a incógnita da seção anterior, então o número sai no dia em que a rede for conhecida. janela maior custa histórico por sala; menor começa a descartar disparo legítimo de quem joga com rtt alto. @@ -80,10 +80,10 @@ estas não são alvos, são decisões de implementação, e valem antes de qualq - **zero alocação no caminho quente.** a causa mais provável de engasgo em javascript é o coletor de lixo, e engasgo é pior de jogar que fps médio baixo. - **o critério de fluidez é o percentil 1, não a média.** e ele é medido nos quadros que carregam - passo de simulação, porque um quadro que só desenha é barato e esconde o problema. + tick, porque um quadro que só desenha é barato e esconde o problema. - **estado de rede quantizado**, posição e ângulo em inteiro, não float de 32 bits cru do schema - do colyseus. 30 snapshots por segundo para 8 jogadores é da ordem de 60 bytes por jogador por - snapshot, e float cru não cabe nessa ordem de grandeza. + do colyseus. 30 patches de estado por segundo para 8 jogadores é da ordem de 60 bytes por jogador por + patch, e float cru não cabe nessa ordem de grandeza. - **para chegar a qualquer número com babylon**: congelar matriz de mundo da geometria estática, congelar material, instanciar prop repetido, manter contagem de draw call baixa, e nada de pbr ou sombra dinâmica ([adr 0004](adr/0004-pipeline-de-assets.md)). @@ -99,7 +99,7 @@ e a primeira rodada é entregável da semana 1. | download inicial total (js, mapa, textura, áudio) | o resto do peso da página, que compete com o bundle pelos mesmos 5 s | semana 1 | | tempo até a tela de boot | carregamento não bloqueante é o que faz os 5 s parecerem 5 s | semana 1 | | percentil 1 de fps nas máquinas da equipe | é o critério de fluidez | com o greybox navegável | -| custo do passo de simulação no cliente | quanto do orçamento de quadro o tick come | com o controlador | +| custo do tick no cliente | quanto do orçamento de quadro o tick come | com o controlador | | custo do replay de reconciliação | idem, e é o que cresce com rtt | com o protótipo de netcode | | memória da aba | o navegador mata a aba antes de avisar | com o greybox | | p99 do tick de servidor | é o que limita jogadores por sala | com o servidor | diff --git a/docs/simulation-model.md b/docs/simulation-model.md index 16d70343..f9f23e43 100644 --- a/docs/simulation-model.md +++ b/docs/simulation-model.md @@ -16,9 +16,9 @@ confundir as três é o erro mais fácil de cometer aqui, então elas ficam nome | taxa | valor | o que é | |---|---|---| -| **tick de simulação** | 60 Hz ([adr 0002](adr/0002-transporte-de-rede.md)) | passo **fixo** em que o estado do jogo avança. idêntico no cliente e no servidor, e é o que torna a predição reproduzível. | +| **tick** | 60 Hz ([adr 0002](adr/0002-transporte-de-rede.md)) | unidade **fixa** de tempo em que o estado do jogo avança. idêntico no cliente e no servidor, e é o que torna a predição reproduzível. | | **taxa de render** | livre | quantos quadros são desenhados. limitada pela taxa do monitor, não pelo tick. o render **interpola** entre os dois últimos estados de simulação. | -| **taxa de snapshot** | 30 Hz ([adr 0002](adr/0002-transporte-de-rede.md)) | quantas vezes por segundo o servidor transmite estado. | +| **taxa de patches de estado** | 30 Hz ([adr 0002](adr/0002-transporte-de-rede.md)) | quantas vezes por segundo o servidor envia a diferença do estado aos clientes. | consequência que precisa estar clara: **simulação a 60 Hz não limita o jogo a 60 fps.** num monitor de 240 Hz o jogo desenha 240 quadros interpolando entre os estados de 60 Hz, e o ângulo From 0741e0b71ca8dbd49f029b433144c5d0887c19a6 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 19:51:06 -0300 Subject: [PATCH 012/139] test: derive the tick from the config instead of assuming 60 hz --- .../client/src/controller/arenaSession.test.ts | 5 +++-- .../client/src/controller/localCharacter.test.ts | 9 +++++---- .../core/src/collision/sweepCharacter.test.ts | 16 +++++++++++----- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/client/src/controller/arenaSession.test.ts b/packages/client/src/controller/arenaSession.test.ts index 608be7ec..50e384f0 100644 --- a/packages/client/src/controller/arenaSession.test.ts +++ b/packages/client/src/controller/arenaSession.test.ts @@ -14,6 +14,7 @@ const SPAWN_FEET = { x: 0, y: 0, z: 0 } /** Um boneco a 20 m na frente, na convenção de olho dos spawns. */ const DUMMY_POST: readonly [number, number, number] = [0, 1.8, 20] const FRAME_S = 1 / 60 +const TICK_S = 1 / config.simulation.tickHz interface Rig { readonly session: ArenaSession @@ -226,9 +227,9 @@ describe('createArenaSession', () => { it('a arma e o personagem andam no mesmo relógio', () => { const rig = mountSession() - const ticks = rig.frame({ elapsedS: 5 / 60 }) + const ticks = rig.frame({ elapsedS: 5 * TICK_S }) expect(ticks).toBe(5) - expect(rig.session.matchTimeS).toBeCloseTo(5 / 60) + expect(rig.session.matchTimeS).toBeCloseTo(5 * TICK_S) }) it('o pente esvazia a cada tiro e recarrega sozinho no último', () => { diff --git a/packages/client/src/controller/localCharacter.test.ts b/packages/client/src/controller/localCharacter.test.ts index 00c1e9e2..3bbb786e 100644 --- a/packages/client/src/controller/localCharacter.test.ts +++ b/packages/client/src/controller/localCharacter.test.ts @@ -10,12 +10,13 @@ const config = parseGameplayConfig(JSON.parse(readFileSync(SHIPPED_CONFIG_URL, ' const boxes = blockoutToStaticBoxes(GREYBOX_BLOCKOUT) const SPAWN_FEET = { x: 26, y: 0, z: 26 } const FORWARD = { ...IDLE_INPUT, wishZ: -1 } +const TICK_S = 1 / config.simulation.tickHz describe('createLocalCharacter', () => { it('converte tempo de quadro em ticks inteiros e guarda o resto', () => { const character = createLocalCharacter(config, SPAWN_FEET, boxes) - expect(character.advance(1 / 60 + 0.004, IDLE_INPUT)).toBe(1) - expect(character.interpolationAlpha()).toBeCloseTo(0.004 * 60) + expect(character.advance(TICK_S + 0.004, IDLE_INPUT)).toBe(1) + expect(character.interpolationAlpha()).toBeCloseTo(0.004 / TICK_S) }) /** Quadro longo não vira espiral: o excedente é descartado, o jogo atrasa. */ @@ -27,14 +28,14 @@ describe('createLocalCharacter', () => { it('guarda o tick anterior para interpolar', () => { const character = createLocalCharacter(config, SPAWN_FEET, boxes) character.advance(0.5, FORWARD) - character.advance(1 / 60, FORWARD) + character.advance(TICK_S, FORWARD) expect(character.previous.position.z).toBeGreaterThan(character.current.position.z) }) it('o olho fica a 92 % da cápsula acima do pé, e entre os dois últimos ticks', () => { const character = createLocalCharacter(config, SPAWN_FEET, boxes) character.advance(0.5, FORWARD) - character.advance(1 / 60 + 1 / 120, FORWARD) + character.advance(TICK_S + TICK_S / 2, FORWARD) const eye = character.eyePosition({ x: 0, y: 0, z: 0 }) expect(eye.y).toBeCloseTo(config.collision.capsuleHeightM * EYE_HEIGHT_RATIO) expect(eye.z).toBeLessThan(character.previous.position.z) diff --git a/packages/core/src/collision/sweepCharacter.test.ts b/packages/core/src/collision/sweepCharacter.test.ts index 6b085ff8..61af2ef4 100644 --- a/packages/core/src/collision/sweepCharacter.test.ts +++ b/packages/core/src/collision/sweepCharacter.test.ts @@ -61,12 +61,15 @@ describe('sweepCharacter', () => { /** * O caso que justifica o sub-passo (ADR 0003): a 100 m/s o deslocamento por - * tick é 1,67 m, dezesseis vezes a espessura da parede. Sem dividir o - * passo, o jogador apareceria do outro lado. + * tick passa de 1,5 m, mais de quinze vezes a espessura da parede. Sem + * dividir o tick, o jogador apareceria do outro lado. O início sai do tick + * para o deslocamento sem colisão terminar sempre além da parede: com x fixo, + * um tick mais curto parava antes dela e o teste deixava de testar colisão. */ it('não atravessa parede fina em alta velocidade', () => { const thin = boxFromCenterSize([5.05, 5, 0], [0.1, 10, 20]) - const state = createCharacterState({ x: 3, y: 0, z: 0 }, config) + const beyondWallX = 5.1 + radius + 0.1 + const state = createCharacterState({ x: beyondWallX - 100 * dtS, y: 0, z: 0 }, config) state.velocity.x = 100 sweepCharacter(state, [FLOOR, thin], config, dtS) expect(state.position.x).toBe(5 - radius) @@ -160,7 +163,10 @@ describe('subStepCount', () => { it('divide pelo deslocamento máximo, arredondando para cima', () => { const state = createCharacterState({ x: 0, y: 0, z: 0 }, config) state.velocity.x = 100 - // 100 / 60 = 1,67 m por tick, sobre 0,2 m por sub-passo - expect(subStepCount(state, config, dtS)).toBe(9) + const displacementM = 100 * dtS + expect(subStepCount(state, config, dtS)).toBe( + Math.ceil(displacementM / config.collision.subStepMaxDisplacementM), + ) + expect(subStepCount(state, config, dtS)).toBeGreaterThan(1) }) }) From 1eb2a781036bd1d1c37d8eef892942970c6d1432 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 19:51:06 -0300 Subject: [PATCH 013/139] feat: raise tick, patch and input rates to 64 hz --- config/gameplay.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/gameplay.json b/config/gameplay.json index 604410ba..a5c68e0f 100644 --- a/config/gameplay.json +++ b/config/gameplay.json @@ -1,8 +1,8 @@ { "simulation": { - "tickHz": 60, - "patchHz": 30, - "inputSendHz": 60 + "tickHz": 64, + "patchHz": 64, + "inputSendHz": 64 }, "movement": { "gravityMps2": -24, From 7425c7795a4c20610b0c452afaa808a2ecfc8414 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 19:51:07 -0300 Subject: [PATCH 014/139] feat: cap rooms at four players --- config/gameplay.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/gameplay.json b/config/gameplay.json index a5c68e0f..12d013ca 100644 --- a/config/gameplay.json +++ b/config/gameplay.json @@ -51,7 +51,7 @@ "match": { "durationS": 300, "respawnDelayS": 1.5, - "playersPerRoom": 8, + "playersPerRoom": 4, "pointsPerKill": 100, "trainingDummies": 6 }, From 0461492bdf1c8f07924bfa753023e168baab17b7 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 19:51:07 -0300 Subject: [PATCH 015/139] docs: accept adrs 0008 to 0011 --- docs/adr/0008-websocket-sem-udp.md | 2 +- docs/adr/0009-chat-de-voz.md | 2 +- docs/adr/0010-sala-de-quatro.md | 2 +- docs/adr/0011-endereco-da-sala.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/adr/0008-websocket-sem-udp.md b/docs/adr/0008-websocket-sem-udp.md index 0f1c0e9b..f7bfcb3f 100644 --- a/docs/adr/0008-websocket-sem-udp.md +++ b/docs/adr/0008-websocket-sem-udp.md @@ -1,6 +1,6 @@ # ADR 0008 — websocket mantido, sem udp -- **status**: proposta +- **status**: aceita - **data**: 2026-09-22 - **decide**: renato diff --git a/docs/adr/0009-chat-de-voz.md b/docs/adr/0009-chat-de-voz.md index d709c38a..f0d51460 100644 --- a/docs/adr/0009-chat-de-voz.md +++ b/docs/adr/0009-chat-de-voz.md @@ -1,6 +1,6 @@ # ADR 0009 — chat de voz por livekit, ao lado do colyseus -- **status**: proposta +- **status**: aceita - **data**: 2026-09-23 - **decide**: renato diff --git a/docs/adr/0010-sala-de-quatro.md b/docs/adr/0010-sala-de-quatro.md index 9a68e801..aec2c9dc 100644 --- a/docs/adr/0010-sala-de-quatro.md +++ b/docs/adr/0010-sala-de-quatro.md @@ -1,6 +1,6 @@ # ADR 0010 — sala de quatro jogadores -- **status**: proposta +- **status**: aceita - **data**: 2026-09-28 - **decide**: renato diff --git a/docs/adr/0011-endereco-da-sala.md b/docs/adr/0011-endereco-da-sala.md index e1577b39..8f1c4d23 100644 --- a/docs/adr/0011-endereco-da-sala.md +++ b/docs/adr/0011-endereco-da-sala.md @@ -1,6 +1,6 @@ # ADR 0011 — endereço da sala em formato ipv4, com fila e sala provisória -- **status**: proposta +- **status**: aceita - **data**: 2026-09-28 - **decide**: renato From 738659b02f9159f3a04bad17441c245551dae148 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 19:51:08 -0300 Subject: [PATCH 016/139] docs: carry the accepted network decisions into the design docs --- docs/adr/0002-transporte-de-rede.md | 4 +++- docs/adr/0003-fisica-e-controlador.md | 2 +- docs/gdd.md | 10 +++++----- docs/nfr.md | 10 +++++----- docs/simulation-model.md | 20 ++++++++++---------- 5 files changed, 24 insertions(+), 22 deletions(-) diff --git a/docs/adr/0002-transporte-de-rede.md b/docs/adr/0002-transporte-de-rede.md index 44d51852..15d4b64f 100644 --- a/docs/adr/0002-transporte-de-rede.md +++ b/docs/adr/0002-transporte-de-rede.md @@ -1,6 +1,8 @@ # ADR 0002 — transporte de rede e framework de sincronização -- **status**: aceita +- **status**: aceita. o transporte foi reavaliado e mantido na [adr 0008](0008-websocket-sem-udp.md), + o tamanho de sala foi revisto na [adr 0010](0010-sala-de-quatro.md), e as taxas de rede + atuais estão em [docs/rede](../rede/arquitetura-de-rede.md). - **data**: 2026-08-18 - **decidem**: renato, nicolas diff --git a/docs/adr/0003-fisica-e-controlador.md b/docs/adr/0003-fisica-e-controlador.md index b1f14149..911ee978 100644 --- a/docs/adr/0003-fisica-e-controlador.md +++ b/docs/adr/0003-fisica-e-controlador.md @@ -60,7 +60,7 @@ controlador de personagem baseado em havok, já pronto na engine. com **sub-passo por deslocamento**: quando o movimento no tick passa de uma fração do raio da cápsula, o passo é dividido, e o número de sub-passos é o deslocamento dividido por esse limite. as mecânicas rápidas (puxão do gancho, queda perto da terminal) caem nesse caso a - 60 Hz, e é isso, e não tick rate maior, que impede atravessar parede fina. + 64 Hz, e é isso, e não tick rate maior, que impede atravessar parede fina. - **o havok fica reservado** a prop, ragdoll, destroço e qualquer coisa que não afete a autoridade nem a predição. - **hitscan é raycast no servidor**, contra as cápsulas dos jogadores rebobinadas para o diff --git a/docs/gdd.md b/docs/gdd.md index dfdbcbf6..e4fe49fe 100644 --- a/docs/gdd.md +++ b/docs/gdd.md @@ -11,7 +11,7 @@ Revision: 0.0.1 ## Theme / Setting / Genre -fps pvp de sniper em arena cyberpunk. free-for-all livre de cinco minutos, até oito jogadores, sem times e sem progressão. +fps pvp de sniper em arena cyberpunk. free-for-all livre de cinco minutos, até quatro jogadores, sem times e sem progressão. ## Core Gameplay Mechanics Brief @@ -97,7 +97,7 @@ o loop é: nascer, buscar linha de tiro, matar ou morrer, voltar. kill vale 100 a arena é única, fechada por geometria e tem três camadas: becos embaixo (distância curta), passarelas no meio (rota principal de trânsito, uso mais frequente do gancho), telhados em cima (linhas de tiro longas, jogador exposto). cada camada tem pelo menos duas rotas de subida que não exigem gancho, e toda linha de tiro longa tem cobertura intermediária. doze pontos de spawn distribuídos pelas três camadas. -o escopo entregável é uma arena, um personagem, um sniper, uma faca, um gancho e um modo, para até oito jogadores. o jogo tem um único mapa. fora do escopo entregável: voip, times, mapas adicionais, progressão, loja funcional, matchmaking por habilidade e anti-cheat. voip entra como expansão, depois da base jogável. +o escopo entregável é uma arena, um personagem, um sniper, uma faca, um gancho e um modo, para até quatro jogadores. o jogo tem um único mapa. fora do escopo entregável: voip, times, mapas adicionais, progressão, loja funcional, matchmaking por habilidade e anti-cheat. voip entra como expansão, depois da base jogável. # What sets this project apart? - roda no navegador, sem instalação e sem conta obrigatória @@ -141,10 +141,10 @@ a narrativa é ambiental: outdoor, pichação e letreiro. não há campanha, npc ## Gameplay (Brief) -até oito jogadores em free-for-all livre de cinco minutos numa arena vertical de três camadas. sniper, pulo duplo e gancho, mais uma faca para o corpo a corpo. respawn rápido, sem invulnerabilidade. kill vale 100 pontos, mais o bônus da medalha que ela ganhar. +até quatro jogadores em free-for-all livre de cinco minutos numa arena vertical de três camadas. sniper, pulo duplo e gancho, mais uma faca para o corpo a corpo. respawn rápido, sem invulnerabilidade. kill vale 100 pontos, mais o bônus da medalha que ela ganhar. ## Gameplay (Detailed) - - entrada: link, campo de apelido com a lista de controles ao lado, botão de jogar. alocação em sala de até oito jogadores, entrando na partida em andamento. + - entrada: link, campo de apelido com a lista de controles ao lado, botão de jogar. alocação em sala de até quatro jogadores, entrando na partida em andamento. - controles: wasd move, mouse mira, botão esquerdo atira, botão direito liga e desliga a mira telescópica, espaço pula (duas vezes), tecla dedicada para gancho, tecla dedicada para faca, tab mostra o placar. - loop: cronômetro de cinco minutos, placar por tab, killfeed no canto, feed de medalhas no topo central. kill vale 100 pontos. sem dano de queda e sem limite de mapa a respeitar, porque a arena é fechada por geometria. - arena: três camadas (becos, passarelas, telhados), pelo menos duas rotas de subida por camada sem gancho, cobertura intermediária em toda linha de tiro longa, doze spawns distribuídos. @@ -213,7 +213,7 @@ a equipe não tem especialista em arte. a maior parte dos assets vem de pacotes - Ambient Scripts (Runs in the background) - servidor autoritativo: estado da sala, validação de disparo, respawn, cronômetro, placar - sincronização de estado entre servidor e clientes - - lobby: entrada por link, apelido, alocação em sala de até oito jogadores + - lobby: entrada por link, apelido, alocação em sala de até quatro jogadores - killfeed, placar e transição de fim de partida - NPC Scripts - não se aplica: pvp, sem npcs. bot apenas como ferramenta de teste. diff --git a/docs/nfr.md b/docs/nfr.md index 6255aad4..7110ee03 100644 --- a/docs/nfr.md +++ b/docs/nfr.md @@ -12,7 +12,7 @@ o dono deste documento é quem estiver com o protótipo da semana 1 na mão. | requisito | valor | de onde sai | |---|---|---| | tempo do clique no link ao controle do personagem | **≤ 5 s** | [pilar 2](pillars.md), literal | -| jogadores por sala | **8** | [gdd](gdd.md) | +| jogadores por sala | **4** | [adr 0010](adr/0010-sala-de-quatro.md) | | p99 do erro posicional de validação | **< 0.10 m** | cerca de ¼ do raio de uma cápsula de escala humana | estes três são critério de aceite. os outros números do projeto ou são conta derivada deles, ou @@ -59,9 +59,9 @@ dependem delas. | grandeza | valor | onde foi decidido | |---|---|---| -| tick de simulação | 60 Hz | [adr 0002](adr/0002-transporte-de-rede.md) | -| taxa de patches de estado do servidor | 30 Hz | [adr 0002](adr/0002-transporte-de-rede.md) | -| taxa de envio de input do cliente | 60 Hz | [adr 0002](adr/0002-transporte-de-rede.md) | +| tick | 64 Hz | [docs/rede](rede/arquitetura-de-rede.md) | +| taxa de patches de estado do servidor | 64 Hz | [docs/rede](rede/arquitetura-de-rede.md) | +| taxa de envio de input do cliente | 64 Hz | [docs/rede](rede/arquitetura-de-rede.md) | **a janela máxima de rewind não tem valor ainda, tem fórmula.** ela cobre o quanto o cliente enxerga o mundo atrás do servidor, que é *meio rtt* mais o *buffer de interpolação* @@ -82,7 +82,7 @@ estas não são alvos, são decisões de implementação, e valem antes de qualq - **o critério de fluidez é o percentil 1, não a média.** e ele é medido nos quadros que carregam tick, porque um quadro que só desenha é barato e esconde o problema. - **estado de rede quantizado**, posição e ângulo em inteiro, não float de 32 bits cru do schema - do colyseus. 30 patches de estado por segundo para 8 jogadores é da ordem de 60 bytes por jogador por + do colyseus. 64 patches de estado por segundo para 4 jogadores é da ordem de 60 bytes por jogador por patch, e float cru não cabe nessa ordem de grandeza. - **para chegar a qualquer número com babylon**: congelar matriz de mundo da geometria estática, congelar material, instanciar prop repetido, manter contagem de draw call baixa, e nada de pbr diff --git a/docs/simulation-model.md b/docs/simulation-model.md index f9f23e43..e7fa13af 100644 --- a/docs/simulation-model.md +++ b/docs/simulation-model.md @@ -16,26 +16,26 @@ confundir as três é o erro mais fácil de cometer aqui, então elas ficam nome | taxa | valor | o que é | |---|---|---| -| **tick** | 60 Hz ([adr 0002](adr/0002-transporte-de-rede.md)) | unidade **fixa** de tempo em que o estado do jogo avança. idêntico no cliente e no servidor, e é o que torna a predição reproduzível. | +| **tick** | 64 Hz ([docs/rede](docs/rede/arquitetura-de-rede.md)) | unidade **fixa** de tempo em que o estado do jogo avança. idêntico no cliente e no servidor, e é o que torna a predição reproduzível. | | **taxa de render** | livre | quantos quadros são desenhados. limitada pela taxa do monitor, não pelo tick. o render **interpola** entre os dois últimos estados de simulação. | -| **taxa de patches de estado** | 30 Hz ([adr 0002](adr/0002-transporte-de-rede.md)) | quantas vezes por segundo o servidor envia a diferença do estado aos clientes. | +| **taxa de patches de estado** | 64 Hz ([docs/rede](docs/rede/arquitetura-de-rede.md)) | quantas vezes por segundo o servidor envia a diferença do estado aos clientes. | -consequência que precisa estar clara: **simulação a 60 Hz não limita o jogo a 60 fps.** num -monitor de 240 Hz o jogo desenha 240 quadros interpolando entre os estados de 60 Hz, e o ângulo +consequência que precisa estar clara: **simulação a 64 Hz não limita o jogo a 64 fps.** num +monitor de 240 Hz o jogo desenha 240 quadros interpolando entre os estados de 64 Hz, e o ângulo de visão é atualizado na taxa de render, então a mira responde a 240 Hz mesmo com a movimentação -simulando a 60. +simulando a 64. -### por que 60 Hz e não 120 +### por que 64 Hz e não 128 -a 60 Hz o deslocamento por tick de uma mecânica rápida (gancho, queda) passa do raio da cápsula, +a 64 Hz o deslocamento por tick de uma mecânica rápida (gancho, queda) passa do raio da cápsula, o que de fato causaria atravessamento de parede fina. mas a correção certa é **sub-passo de colisão por deslocamento**, não dobrar o tick: dobrar reduz o erro pela metade e ainda deixa erro, enquanto o sub-passo o elimina. e o que decide "acertei na minha tela e não morreu" é rebobinar para o **instante** do disparo interpolando entre ticks. encaixar no tick mais próximo erra meio tick, que a velocidade de -gancho é da ordem de meio raio de cápsula. **rewind interpolado a 60 Hz é melhor que rewind -grosseiro a 120 Hz, e é de graça.** +gancho é da ordem de meio raio de cápsula. **rewind interpolado a 64 Hz é melhor que rewind +grosseiro a 128 Hz, e é de graça.** ## convenções do contrato @@ -201,7 +201,7 @@ antes de qualquer número: telhado a partir do beco exige alcance ≥ √(altura² + afastamento²), e ignorar isso produz uma arena em que a rota principal de subida não alcança. - **pegada da arena sai do tempo de travessia**: diagonal / velocidade de corrida. arena grande - demais faz o respawn rápido perder sentido, e fica vazia para oito jogadores. + demais faz o respawn rápido perder sentido, e fica vazia para quatro jogadores. - **passagem baixa atravessável só deslizando** fica entre a altura da cápsula em slide e a cápsula em pé, e existe pelo menos uma por camada como atalho de quem domina o slide. - **altura livre mínima** em corredor e passagem é maior que a cápsula em pé. From 404d7d6a539accf08626a28bfb842f626ef32648 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 20:04:26 -0300 Subject: [PATCH 017/139] docs: record the network condition for the acceptance test --- docs/adr/0006-condicao-de-rede-do-aceite.md | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/adr/0006-condicao-de-rede-do-aceite.md diff --git a/docs/adr/0006-condicao-de-rede-do-aceite.md b/docs/adr/0006-condicao-de-rede-do-aceite.md new file mode 100644 index 00000000..6787a88d --- /dev/null +++ b/docs/adr/0006-condicao-de-rede-do-aceite.md @@ -0,0 +1,24 @@ +# ADR 0006 — condição de rede em que o aceite é medido + +- **status**: aceita +- **data**: 2026-09-28 +- **decide**: renato + +o critério de aceite do [nfr.md](../nfr.md), p99 do erro posicional abaixo de 0,10 m, não dizia +em que rede é medido, e a rede da mesa da equipe é otimista. medimos em três perfis +emulados no servidor, e o aceite vale no perfil **wi-fi congestionado**. os outros dois são +relatados junto, para mostrar como o jogo degrada. + +| perfil | rtt | perda, por direção | +|---|---|---| +| lan ideal | o da rede do estande, sem emulação | a da rede | +| **wi-fi congestionado** | 10 ± 5 ms | 1%, aleatória | +| pior caso | 30 ± 10 ms | 3%, em rajadas de 3 pacotes em média | + +## consequências + +- a emulação roda no servidor, nas duas direções, com `tc netem` na saída e uma interface `ifb` + na entrada. os comandos, o modelo de perda em rajada e as grandezas registradas em cada rodada + estão em [docs/rede](../rede/arquitetura-de-rede.md). +- o jitter do netem reordena pacotes. o tcp absorve a reordenação, mas ela pesa na medição, e o + relatório declara isso. From 2697e92e206fca274df7eb5dd96b1eace151eb37 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 20:04:26 -0300 Subject: [PATCH 018/139] docs: size the network parameters, messages and stand network --- docs/rede/arquitetura-de-rede.md | 89 +++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/docs/rede/arquitetura-de-rede.md b/docs/rede/arquitetura-de-rede.md index 553a0183..8d742c32 100644 --- a/docs/rede/arquitetura-de-rede.md +++ b/docs/rede/arquitetura-de-rede.md @@ -1,7 +1,7 @@ # Arquitetura de rede do multiplayer Arquitetura de rede do shōbu na feira. As decisões estão nas ADRs -[0002](../adr/0002-transporte-de-rede.md), [0008](../adr/0008-websocket-sem-udp.md), +[0002](../adr/0002-transporte-de-rede.md), [0006](../adr/0006-condicao-de-rede-do-aceite.md), [0008](../adr/0008-websocket-sem-udp.md), [0009](../adr/0009-chat-de-voz.md), [0010](../adr/0010-sala-de-quatro.md) e [0011](../adr/0011-endereco-da-sala.md). N é o número de jogadores na sala. Nenhum valor abaixo depende de N ser fixo. @@ -14,7 +14,10 @@ com a premissa declarada. | Tick | 64 Hz (15,625 ms) | configuração, `setFixedTimestep(simular, 64)`; o padrão é 60 Hz | | Envio de patches de estado | 64 Hz | configuração, `patchRate = 1000 / 64`; o padrão é 20 Hz | | Envio de input | 64 Hz | configuração | -| Heartbeat do WebSocket | ping a cada 3 s, desconecta após 2 sem resposta | padrão do `WebSocketTransport` | +| Buffer de interpolação | 3 intervalos de patch, 46,9 ms | configuração (`delay` do `Predict`); o padrão é 100 ms | +| Janela máxima de rewind | 150 ms | configuração (`maxRewindMs`); o padrão é 500 ms | +| Heartbeat do WebSocket | ping a cada 1 s, desconecta após 2 sem resposta | configuração (`pingInterval: 1000`); o padrão é 3 s | +| Reconexão | nenhuma | configuração: o servidor não guarda a vaga, e o cliente desliga `reconnection.enabled`, ligado por padrão no SDK | | Tamanho máximo de mensagem do cliente | 4 KiB | padrão do `WebSocketTransport` (`maxPayload`) | | Áudio | Opus, preset `speech` (24 kbps), quadro de 20 ms, RED e DTX | configuração; o padrão do `livekit-client` é o preset `music`, 48 kbps | @@ -26,8 +29,10 @@ Simulação e patches rodam em dois `setInterval` independentes. A simulação u (`setFixedTimestep`) e faz exatamente 64 ticks por segundo em média. Os patches saem no ritmo do próprio timer, que no Node 22 mediu 15,24 ms de período médio para 15,625 ms pedidos (65,6 Hz). Como os dois timers não têm fase comum, um patch às vezes não traz tick novo e o -seguinte traz dois. O intervalo entre patches com tick novo varia de cerca de 15,6 a 31,3 ms, e o -buffer de interpolação de 2 a 3 intervalos absorve essa variação. O rewind não é afetado, +seguinte traz dois. O intervalo entre patches com tick novo varia de cerca de 15,6 a 31,3 ms. O buffer de +interpolação de 3 intervalos (46,9 ms) cobre esse pior intervalo mais o jitter do perfil +congestionado da ADR 0006, de até 10 ms. Uma retransmissão TCP somada ao pior intervalo passa +do buffer, e o cliente extrapola por alguns milissegundos. O rewind não é afetado, porque o Colyseus grava o histórico em cada patch enviado, exatamente o que o cliente recebeu. ## Topologia @@ -117,18 +122,23 @@ esse instante à janela de histórico e rebobina as cápsulas até ele, interpol servidor não estima o atraso do atirador: o cliente informa o que viu, e o servidor só aceita instantes dentro da janela. +A janela de 150 ms cobre o buffer de interpolação (46,9 ms), meio RTT do pior perfil da ADR 0006 +(até 20 ms), um intervalo de patch (15,6 ms) e uma retransmissão TCP (cerca de 35 ms nesse +perfil), somando 117,5 ms. Como o `renderTime` vem do cliente, a janela também limita quanto um +cliente adulterado pode voltar no tempo para acertar quem já saiu da linha de tiro. + | Componente | Latência | Origem | |---|---|---| | Movimento e disparo do próprio jogador | 0 ms | predição local | | Ida e volta no Wi-Fi da LAN | 2 a 10 ms | estimativa, a medir | | Espera até o próximo tick do servidor | até 15,6 ms | 64 Hz | -| Buffer de interpolação dos outros jogadores | 31,3 a 46,9 ms | 2 a 3 intervalos de patch | +| Buffer de interpolação dos outros jogadores | 46,9 ms | 3 intervalos de patch | | Recuperação de um segmento TCP perdido | cerca de 20 ms; até 200 ms | RACK-TLP; RTO mínimo do Linux | *Tabela 3. Orçamento de latência na LAN.* O critério de aceite é o p99 do erro posicional abaixo de 0,10 m ([nfr.md](../nfr.md)), medido -com 2% de perda simulada por `tc netem`. +no perfil Wi-Fi congestionado da [ADR 0006](../adr/0006-condicao-de-rede-do-aceite.md). ## Entrada na partida e na voz @@ -139,3 +149,70 @@ token assinado por ele (mensagem 4), com sala igual ao `roomId`, identidade igua e permissão apenas de áudio. Antes do áudio, o cliente faz as verificações de conectividade do ICE e o handshake DTLS que deriva as chaves do SRTP (mensagem 7). Quando o jogador sai, o servidor de jogo o remove da sala de voz (mensagem 10). + +Não há reconexão: quando a conexão cai, o jogador sai da sala. Numa queda limpa, o TCP fecha na +hora. Quando o Wi-Fi some sem aviso, nada chega ao servidor, e o heartbeat detecta a queda em 2 +a 3 s. Até lá, o personagem continua no mundo, parado. + +## Formato das mensagens + +Estado e input são quantizados em inteiros ([nfr.md](../nfr.md)). A resolução de posição fica +200 vezes abaixo do critério de aceite. + +| Mensagem | Campo | Tipo | Resolução | +|---|---|---|---| +| Input | direção desejada, x e z | `int8` cada | 1/127 | +| Input | corrida, pulo, agachar, mira, disparo, recarga | bits de um `uint8` | | +| Input e snapshot | yaw | `uint16` | 360°/65536 = 0,0055° | +| Input e snapshot | pitch | `int16` | 90°/32767 = 0,0027° | +| Snapshot | posição, x, y e z | `int16` cada | 1 mm; cobre ±32,767 m | +| Snapshot | postura: em pé, agachado, slide, no ar | `uint8` | | + +*Tabela 4. Campos quantizados. Com a arena de ±32 m, a margem de posição é de 0,77 m; se a arena +crescer, a resolução passa a 2 mm e cobre ±65 m.* + +O estado completo de simulação de um jogador, com velocidade, pulos restantes e tempos de slide, +vai só para o próprio jogador, porque a reconciliação precisa dele e os outros clientes só +interpolam posição e mira. + +O SDK envia cada input confiável com 1 B de código de protocolo e o carimbo de tempo codificado +como diferença do anterior (1 B a 15,6 ms). O corpo, serializado pelo `@colyseus/schema`, soma +um índice de 1 B por campo, e o input fica em cerca de 14 B. Um jogador em movimento ocupa cerca +de 18 B por patch. As premissas da Tabela 2, 32 B e 60 B, são conservadoras. + +## Rede do estande + +| Item | Configuração | Motivo | +|---|---|---| +| Roteador | dedicado, rede só do jogo, sem isolamento de clientes | isolamento de clientes impede o navegador de alcançar o servidor | +| Wi-Fi | 5 GHz, canal fixo entre 36 e 48 | fora da faixa DFS: um canal DFS que detecta radar é abandonado, e o novo canal DFS exige 60 s de escuta antes de transmitir | +| Servidor | Ethernet, IP fixo por reserva de DHCP | o domínio aponta para um endereço que não muda | +| DNS | o roteador resolve o domínio do projeto para o IP do servidor | a feira não depende de internet, e muitos roteadores descartam respostas públicas com IP privado, como proteção contra DNS rebinding | +| Certificado | Let's Encrypt, desafio DNS-01, emitido antes da feira | o desafio DNS não exige o servidor exposto à internet; a validade do perfil padrão é de 90 dias | +| Firewall do servidor | entrada em TCP 2567, 7880 e 7881, e UDP 7882 | as portas da Tabela 1 | + +## Medição + +A emulação da [ADR 0006](../adr/0006-condicao-de-rede-do-aceite.md) roda no servidor. A saída +passa por um `netem`, e a entrada é redirecionada para uma interface `ifb` com outro `netem`. +Cada direção recebe metade do atraso, e a perda vale por direção. Para o perfil Wi-Fi +congestionado, com `eth0` como interface do servidor: + +```sh +tc qdisc add dev eth0 root netem delay 5ms 2.5ms loss 1% +modprobe ifb && ip link set ifb0 up +tc qdisc add dev eth0 ingress +tc filter add dev eth0 parent ffff: matchall action mirred egress redirect dev ifb0 +tc qdisc add dev ifb0 root netem delay 5ms 2.5ms loss 1% +``` + +O pior caso troca os dois `netem` por `delay 15ms 5ms loss gemodel 1% 33%`: 1% de chance de +entrar em rajada e 33% de sair dela, cerca de 3% de perda média em rajadas de 3 pacotes. + +| Grandeza | Onde se mede | +|---|---| +| Erro posicional p99 | servidor: posição rebobinada contra a exibida pelo cliente | +| RTT p50 e p99 | cliente: `room.clock` | +| Intervalo de chegada dos patches p99 | cliente | +| Retransmissões TCP | servidor: `ss -ti` | +| Quadros com a interpolação sem patch | cliente | From 9c855f6c4acf65b037287af3d44f9538761bbe9a Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 20:04:26 -0300 Subject: [PATCH 019/139] docs: bring proximity voice into the fair scope --- docs/gdd.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/gdd.md b/docs/gdd.md index e4fe49fe..b4810ca9 100644 --- a/docs/gdd.md +++ b/docs/gdd.md @@ -97,7 +97,7 @@ o loop é: nascer, buscar linha de tiro, matar ou morrer, voltar. kill vale 100 a arena é única, fechada por geometria e tem três camadas: becos embaixo (distância curta), passarelas no meio (rota principal de trânsito, uso mais frequente do gancho), telhados em cima (linhas de tiro longas, jogador exposto). cada camada tem pelo menos duas rotas de subida que não exigem gancho, e toda linha de tiro longa tem cobertura intermediária. doze pontos de spawn distribuídos pelas três camadas. -o escopo entregável é uma arena, um personagem, um sniper, uma faca, um gancho e um modo, para até quatro jogadores. o jogo tem um único mapa. fora do escopo entregável: voip, times, mapas adicionais, progressão, loja funcional, matchmaking por habilidade e anti-cheat. voip entra como expansão, depois da base jogável. +o escopo entregável é uma arena, um personagem, um sniper, uma faca, um gancho e um modo, para até quatro jogadores. o jogo tem um único mapa. a voz por proximidade entre os jogadores da sala também entra, construída depois da base jogável e primeira a ser cortada se a data apertar ([adr 0009](adr/0009-chat-de-voz.md)). fora do escopo entregável: times, mapas adicionais, progressão, loja funcional, matchmaking por habilidade e anti-cheat. # What sets this project apart? - roda no navegador, sem instalação e sem conta obrigatória @@ -151,7 +151,7 @@ até quatro jogadores em free-for-all livre de cinco minutos numa arena vertical - hud: retículo, indicador de recarga, cronômetro, killfeed. - feedback de tiro: hitmarker no acerto, zunido de bala no quase-acerto. - fim de partida: dez segundos de placar final e início da partida seguinte com o mesmo grupo. saída fechando a aba. - - fora do escopo entregável: voip, times, modos alternativos, mapas adicionais, progressão, loja funcional, matchmaking por habilidade, anti-cheat. + - fora do escopo entregável: times, modos alternativos, mapas adicionais, progressão, loja funcional, matchmaking por habilidade, anti-cheat. # Assets Needed @@ -248,7 +248,7 @@ a equipe não tem especialista em arte. a maior parte dos assets vem de pacotes - implementação do cliente, 13/10 - expansões e feira - Time Scale: 13/10 a 19/11 - - expansão 1, voip aberto, 20/10 e 27/10 + - expansão 1, voz por proximidade, 20/10 e 27/10 - expansão 2, a definir, 03/11 e 10/11 - integração com a feira de jogos, 17/11 - feira de jogos, 19/11 From 1b7cfba300b36df73799761ae25f8df75f995088 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 20:07:20 -0300 Subject: [PATCH 020/139] docs: define matchmaking as pairing by room address --- CONTEXT.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CONTEXT.md b/CONTEXT.md index ad11f45d..b207b199 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -10,6 +10,12 @@ O grupo de jogadores conectados juntos ao servidor de jogo. Dura várias partida quem chega entra na sala mesmo com uma partida em andamento. _Evitar_: room, lobby +**Matchmaking**: +O pareamento de jogadores pela URL: quem abre o mesmo endereço da sala joga junto. Põe o jogador +na sala do endereço, cria a sala se ela não existe, ou o põe na fila da sala se ela está cheia. +Não pareia por habilidade. +_Evitar_: lobby, alocação de sala + **Endereço da sala**: O identificador de uma sala, escrito como um endereço IPv4 fictício no caminho da URL (`/203.0.113.7`). Não tem relação com o endereço real de nenhuma máquina. Os gerados evitam as From e7fb6a0a8e296bc09aac3a844596be1d50fc1274 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 20:07:20 -0300 Subject: [PATCH 021/139] docs: call the entry system matchmaking in the gdd --- docs/gdd.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gdd.md b/docs/gdd.md index b4810ca9..bdfaf65c 100644 --- a/docs/gdd.md +++ b/docs/gdd.md @@ -213,7 +213,7 @@ a equipe não tem especialista em arte. a maior parte dos assets vem de pacotes - Ambient Scripts (Runs in the background) - servidor autoritativo: estado da sala, validação de disparo, respawn, cronômetro, placar - sincronização de estado entre servidor e clientes - - lobby: entrada por link, apelido, alocação em sala de até quatro jogadores + - matchmaking: pareamento pelo endereço da sala na url, apelido, salas de até quatro jogadores - killfeed, placar e transição de fim de partida - NPC Scripts - não se aplica: pvp, sem npcs. bot apenas como ferramenta de teste. From 6ef3c7a633377810b5db1deb42a2e07c9f33dc4c Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 20:09:56 -0300 Subject: [PATCH 022/139] docs: point the nfr at the resolved network conditions --- docs/nfr.md | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/docs/nfr.md b/docs/nfr.md index 7110ee03..92a6c69a 100644 --- a/docs/nfr.md +++ b/docs/nfr.md @@ -33,24 +33,15 @@ personagem que este jogo vá usar. [adr 0002](adr/0002-transporte-de-rede.md). se ele estourar, o problema é o rewind, não o tick rate. -## em que rede os três são medidos: ninguém sabe +## em que rede os três são medidos -**esta é a lacuna mais grave do projeto, e não é uma medição de código.** os três requisitos acima -só significam algo contra uma rede, e a rede que importa é a do local da feira, que ninguém mediu. -rtt, perda de pacote e banda disponível ali são desconhecidos. - -qualquer número que a gente escrevesse aqui (150 ms de rtt, 2% de perda, 20 Mbps de descida) seria -plausível e inventado, e pior que inventado: a janela de rewind e o orçamento de banda **derivam** -dele, então o chute se propagaria para decisões de arquitetura disfarçado de conta. - -o que dá para afirmar hoje: +o jogo roda num servidor local, na rede própria do estande, e não depende da rede do local da +feira ([adr 0008](adr/0008-websocket-sem-udp.md)). as condições são conhecidas porque são as +nossas, e a [adr 0006](adr/0006-condicao-de-rede-do-aceite.md) fixa os três perfis emulados em +que os requisitos são medidos. - **a máquina de teste é uma das duas da equipe**, porque não há orçamento para outra. isso é fato, não alvo. -- **o cenário que mais provavelmente quebra a demo não é o jogo, é a rede do local.** -- **medir a rede da feira, ou decidir não depender dela**, é o que destrava esta seção. as duas - saídas vão para a adr 0006: ou alguém mede o local antes de novembro, ou o plano passa a ser - servidor local na mesma lan, e aí as condições são conhecidas porque são as nossas. ## números de arquitetura já fixados @@ -63,11 +54,11 @@ dependem delas. | taxa de patches de estado do servidor | 64 Hz | [docs/rede](rede/arquitetura-de-rede.md) | | taxa de envio de input do cliente | 64 Hz | [docs/rede](rede/arquitetura-de-rede.md) | -**a janela máxima de rewind não tem valor ainda, tem fórmula.** ela cobre o quanto o cliente -enxerga o mundo atrás do servidor, que é *meio rtt* mais o *buffer de interpolação* -(um a dois intervalos de patch), mais folga para jitter e um patch perdido. o rtt é a -incógnita da seção anterior, então o número sai no dia em que a rede for conhecida. janela maior -custa histórico por sala; menor começa a descartar disparo legítimo de quem joga com rtt alto. +**a janela máxima de rewind é de 150 ms.** ela cobre o quanto o cliente enxerga o mundo atrás do +servidor: o buffer de interpolação, meio rtt, um intervalo de patch e uma retransmissão, no pior +perfil da [adr 0006](adr/0006-condicao-de-rede-do-aceite.md). a conta está em +[docs/rede](rede/arquitetura-de-rede.md). janela maior custa histórico por sala e deixa um cliente adulterado voltar +mais no tempo; menor começa a descartar disparo legítimo. **orçamento de quadro** é aritmética da taxa do monitor, não requisito: é 1/taxa, uns 6,9 ms a 144 Hz e 4,2 ms a 240 Hz. serve para saber quanto de um quadro o tick e o replay de reconciliação @@ -111,7 +102,6 @@ e a primeira rodada é entregável da semana 1. - **quantos jogadores simultâneos a feira precisa atender.** não é medição, é decisão: depende de quanta fila vocês aceitam no estande. ela é que define quantas salas o servidor tem que sustentar, e sem ela "cpu por sala" não tem alvo contra o que ser comparado. -- **hospedagem e o fallback de lan**, que vai para a adr 0006. ## o que não é requisito From c271276f631055f393aa0662cace6f2b857d1adc Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 20:10:02 -0300 Subject: [PATCH 023/139] docs: note adr 0006 in the status of adr 0002 --- docs/adr/0002-transporte-de-rede.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/adr/0002-transporte-de-rede.md b/docs/adr/0002-transporte-de-rede.md index 15d4b64f..85245394 100644 --- a/docs/adr/0002-transporte-de-rede.md +++ b/docs/adr/0002-transporte-de-rede.md @@ -1,8 +1,9 @@ # ADR 0002 — transporte de rede e framework de sincronização - **status**: aceita. o transporte foi reavaliado e mantido na [adr 0008](0008-websocket-sem-udp.md), - o tamanho de sala foi revisto na [adr 0010](0010-sala-de-quatro.md), e as taxas de rede - atuais estão em [docs/rede](../rede/arquitetura-de-rede.md). + o tamanho de sala foi revisto na [adr 0010](0010-sala-de-quatro.md), a condição de medição + foi fixada na [adr 0006](0006-condicao-de-rede-do-aceite.md), e as taxas de rede atuais estão + em [docs/rede](../rede/arquitetura-de-rede.md). - **data**: 2026-08-18 - **decidem**: renato, nicolas From c3150902a31bd171c4f5d13d37e737d08fc3e5c5 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 20:10:05 -0300 Subject: [PATCH 024/139] docs: mark the message table as temporary until the schema exists --- docs/rede/arquitetura-de-rede.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/rede/arquitetura-de-rede.md b/docs/rede/arquitetura-de-rede.md index 8d742c32..6a30e9a7 100644 --- a/docs/rede/arquitetura-de-rede.md +++ b/docs/rede/arquitetura-de-rede.md @@ -168,8 +168,9 @@ Estado e input são quantizados em inteiros ([nfr.md](../nfr.md)). A resolução | Snapshot | posição, x, y e z | `int16` cada | 1 mm; cobre ±32,767 m | | Snapshot | postura: em pé, agachado, slide, no ar | `uint8` | | -*Tabela 4. Campos quantizados. Com a arena de ±32 m, a margem de posição é de 0,77 m; se a arena -crescer, a resolução passa a 2 mm e cobre ±65 m.* +*Tabela 4. Campos quantizados. Com a arena de ±32 m, a margem de posição é de 0,77 m; se a +arena crescer, a resolução passa a 2 mm e cobre ±65 m. A tabela vale até o schema existir no +código; a partir daí o schema é a fonte, e ela sai ([planejamento.md](../planejamento.md)).* O estado completo de simulação de um jogador, com velocidade, pulos restantes e tempos de slide, vai só para o próprio jogador, porque a reconciliação precisa dele e os outros clientes só From 93381ca7579c221bb64c607b34b236126fde7547 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:05:44 -0300 Subject: [PATCH 025/139] docs: pin the colyseus packages individually in adr 0002 --- docs/adr/0002-transporte-de-rede.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/adr/0002-transporte-de-rede.md b/docs/adr/0002-transporte-de-rede.md index 85245394..2ef317f9 100644 --- a/docs/adr/0002-transporte-de-rede.md +++ b/docs/adr/0002-transporte-de-rede.md @@ -4,6 +4,10 @@ o tamanho de sala foi revisto na [adr 0010](0010-sala-de-quatro.md), a condição de medição foi fixada na [adr 0006](0006-condicao-de-rede-do-aceite.md), e as taxas de rede atuais estão em [docs/rede](../rede/arquitetura-de-rede.md). + a versão pinada também foi revista em 2026-09-28: a linha 0.18 virou a estável, e os pacotes do + colyseus têm versões independentes, então `0.18.2` em todos não forma um conjunto válido. o + pino exato passou a ser `@colyseus/core` 0.18.17, `@colyseus/ws-transport` 0.18.4, + `@colyseus/sdk` 0.18.4 e `@colyseus/schema` 5.0.34, sem o meta-pacote `colyseus`. - **data**: 2026-08-18 - **decidem**: renato, nicolas From 23a90b410c8a2acbf307a3dc212c8f9de936846d Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:05:44 -0300 Subject: [PATCH 026/139] docs: show the pinned colyseus versions in the topology figure --- docs/rede/arquitetura-de-rede-1-topologia.drawio.svg | 2 +- docs/rede/arquitetura-de-rede.drawio | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/rede/arquitetura-de-rede-1-topologia.drawio.svg b/docs/rede/arquitetura-de-rede-1-topologia.drawio.svg index c60e4975..3ec4c991 100644 --- a/docs/rede/arquitetura-de-rede-1-topologia.drawio.svg +++ b/docs/rede/arquitetura-de-rede-1-topologia.drawio.svg @@ -1,4 +1,4 @@ -Figura 1. Topologia de rede do multiplayer no estandeRede local do estandeNavegador [Wi-Fi 5 GHz]Cliente de jogo[@colyseus/sdk 0.18.2]Cliente de voz[livekit-client]Servidor [Ethernet]Servidor de jogo[Node.js, Colyseus 0.18.2]Servidor de mídia[LiveKit, SFU]Twirp sobre HTTP, TCP 7880HTTPS e WSS, TCP 2567↑ input, 64 Hz↓ patches de estado, 64 Hzsinalização: WSS, TCP 7880Opus em RTP/SRTP, UDP 7882fallback ICE-TCP, TCP 7881jogomídia de vozsinalização e controleFigura 1. Topologia de rede do multiplayer no estande. Portas padrão de Colyseus e LiveKit, exceto UDP 7882, que é o mux UDP configurado. \ No newline at end of file +Figura 1. Topologia de rede do multiplayer no estandeRede local do estandeNavegador [Wi-Fi 5 GHz]Cliente de jogo[@colyseus/sdk 0.18.4]Cliente de voz[livekit-client]Servidor [Ethernet]Servidor de jogo[Node.js, @colyseus/core 0.18.17]Servidor de mídia[LiveKit, SFU]Twirp sobre HTTP, TCP 7880HTTPS e WSS, TCP 2567↑ input, 64 Hz↓ patches de estado, 64 Hzsinalização: WSS, TCP 7880Opus em RTP/SRTP, UDP 7882fallback ICE-TCP, TCP 7881jogomídia de vozsinalização e controleFigura 1. Topologia de rede do multiplayer no estande. Portas padrão de Colyseus e LiveKit, exceto UDP 7882, que é o mux UDP configurado. \ No newline at end of file diff --git a/docs/rede/arquitetura-de-rede.drawio b/docs/rede/arquitetura-de-rede.drawio index 7a421b45..fae857e0 100644 --- a/docs/rede/arquitetura-de-rede.drawio +++ b/docs/rede/arquitetura-de-rede.drawio @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From d22ceeeb072e2521ebec7ddf061be6a2035eda77 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:07:42 -0300 Subject: [PATCH 027/139] refactor: move the greybox arena into the core --- packages/client/src/arena/buildGreyboxArena.ts | 2 +- packages/client/src/arena/trainingDummyPosts.test.ts | 9 ++++++--- packages/client/src/arena/trainingDummyPosts.ts | 2 +- packages/client/src/character/competitorAvatar.test.ts | 3 +-- packages/client/src/controller/localCharacter.test.ts | 9 ++++++--- packages/client/src/main.ts | 8 ++++++-- packages/client/src/renderer/arenaRenderer.ts | 3 +-- packages/client/src/renderer/babylonArenaRenderer.ts | 2 +- packages/client/src/renderer/greyboxMaterials.ts | 2 +- .../{client => core}/src/arena/collisionBoxes.test.ts | 2 +- packages/{client => core}/src/arena/collisionBoxes.ts | 2 +- .../{client => core}/src/arena/greyboxBlockout.test.ts | 3 ++- packages/{client => core}/src/arena/greyboxBlockout.ts | 0 packages/core/src/index.ts | 3 +++ 14 files changed, 31 insertions(+), 19 deletions(-) rename packages/{client => core}/src/arena/collisionBoxes.test.ts (96%) rename packages/{client => core}/src/arena/collisionBoxes.ts (90%) rename packages/{client => core}/src/arena/greyboxBlockout.test.ts (95%) rename packages/{client => core}/src/arena/greyboxBlockout.ts (100%) diff --git a/packages/client/src/arena/buildGreyboxArena.ts b/packages/client/src/arena/buildGreyboxArena.ts index 5d3b1eb3..f6dd7dbd 100644 --- a/packages/client/src/arena/buildGreyboxArena.ts +++ b/packages/client/src/arena/buildGreyboxArena.ts @@ -1,8 +1,8 @@ import { CreateBox } from '@babylonjs/core/Meshes/Builders/boxBuilder' import type { Mesh } from '@babylonjs/core/Meshes/mesh' import type { Scene } from '@babylonjs/core/scene' +import type { GreyboxBlock } from '@shobu/core' import { createLayerMaterials } from '../renderer/greyboxMaterials.ts' -import type { GreyboxBlock } from './greyboxBlockout.ts' /** * Instancia o blockout como caixas na cena. Toda malha tem matriz de mundo diff --git a/packages/client/src/arena/trainingDummyPosts.test.ts b/packages/client/src/arena/trainingDummyPosts.test.ts index 760adf40..aebbf0e1 100644 --- a/packages/client/src/arena/trainingDummyPosts.test.ts +++ b/packages/client/src/arena/trainingDummyPosts.test.ts @@ -1,7 +1,10 @@ -import { raycastBoxes } from '@shobu/core' +import { + blockoutToStaticBoxes, + GREYBOX_BLOCKOUT, + GREYBOX_SPAWN_POINTS_M, + raycastBoxes, +} from '@shobu/core' import { describe, expect, it } from 'vitest' -import { blockoutToStaticBoxes } from './collisionBoxes.ts' -import { GREYBOX_BLOCKOUT, GREYBOX_SPAWN_POINTS_M } from './greyboxBlockout.ts' import { TRAINING_DUMMY_SPAWN_INDEXES, trainingDummyPostsM } from './trainingDummyPosts.ts' const boxes = blockoutToStaticBoxes(GREYBOX_BLOCKOUT) diff --git a/packages/client/src/arena/trainingDummyPosts.ts b/packages/client/src/arena/trainingDummyPosts.ts index c2f11c9d..cbea2b3d 100644 --- a/packages/client/src/arena/trainingDummyPosts.ts +++ b/packages/client/src/arena/trainingDummyPosts.ts @@ -1,4 +1,4 @@ -import { GREYBOX_SPAWN_POINTS_M } from './greyboxBlockout.ts' +import { GREYBOX_SPAWN_POINTS_M } from '@shobu/core' /** * Onde os bonecos de treino ficam, na convenção de **olho** dos diff --git a/packages/client/src/character/competitorAvatar.test.ts b/packages/client/src/character/competitorAvatar.test.ts index d9d88afb..69056a8d 100644 --- a/packages/client/src/character/competitorAvatar.test.ts +++ b/packages/client/src/character/competitorAvatar.test.ts @@ -1,7 +1,6 @@ import { readFileSync } from 'node:fs' -import { parseGameplayConfig } from '@shobu/core' +import { GREYBOX_SPAWN_POINTS_M, parseGameplayConfig } from '@shobu/core' import { describe, expect, it } from 'vitest' -import { GREYBOX_SPAWN_POINTS_M } from '../arena/greyboxBlockout.ts' import { COMPETITOR_MODEL_HEIGHT_M, competitorAvatarScale, diff --git a/packages/client/src/controller/localCharacter.test.ts b/packages/client/src/controller/localCharacter.test.ts index 3bbb786e..92e91678 100644 --- a/packages/client/src/controller/localCharacter.test.ts +++ b/packages/client/src/controller/localCharacter.test.ts @@ -1,8 +1,11 @@ import { readFileSync } from 'node:fs' -import { IDLE_INPUT, parseGameplayConfig } from '@shobu/core' +import { + blockoutToStaticBoxes, + GREYBOX_BLOCKOUT, + IDLE_INPUT, + parseGameplayConfig, +} from '@shobu/core' import { describe, expect, it } from 'vitest' -import { blockoutToStaticBoxes } from '../arena/collisionBoxes.ts' -import { GREYBOX_BLOCKOUT } from '../arena/greyboxBlockout.ts' import { createLocalCharacter, EYE_HEIGHT_RATIO } from './localCharacter.ts' const SHIPPED_CONFIG_URL = new URL('../../../../config/gameplay.json', import.meta.url) diff --git a/packages/client/src/main.ts b/packages/client/src/main.ts index 884481b5..944a47c5 100644 --- a/packages/client/src/main.ts +++ b/packages/client/src/main.ts @@ -1,5 +1,9 @@ -import { type GameplayConfig, MEDAL_CATALOG } from '@shobu/core' -import { GREYBOX_BLOCKOUT, GREYBOX_SPAWN_POINTS_M } from './arena/greyboxBlockout.ts' +import { + type GameplayConfig, + GREYBOX_BLOCKOUT, + GREYBOX_SPAWN_POINTS_M, + MEDAL_CATALOG, +} from '@shobu/core' import { createGameAudio, type GameAudio } from './audio/gameAudio.ts' import { createNarrator } from './audio/narrator.ts' import { createWebAudioMixer } from './audio/webAudioMixer.ts' diff --git a/packages/client/src/renderer/arenaRenderer.ts b/packages/client/src/renderer/arenaRenderer.ts index 85264aa1..a33d66fd 100644 --- a/packages/client/src/renderer/arenaRenderer.ts +++ b/packages/client/src/renderer/arenaRenderer.ts @@ -1,5 +1,4 @@ -import type { GameplayConfig } from '@shobu/core' -import type { GreyboxBlock } from '../arena/greyboxBlockout.ts' +import type { GameplayConfig, GreyboxBlock } from '@shobu/core' import type { GameAudio } from '../audio/gameAudio.ts' import type { ArenaHud } from '../hud/arenaHud.ts' import type { SessionMode } from '../hud/matchClock.ts' diff --git a/packages/client/src/renderer/babylonArenaRenderer.ts b/packages/client/src/renderer/babylonArenaRenderer.ts index 26e75182..3783a2ee 100644 --- a/packages/client/src/renderer/babylonArenaRenderer.ts +++ b/packages/client/src/renderer/babylonArenaRenderer.ts @@ -3,8 +3,8 @@ import { Engine } from '@babylonjs/core/Engines/engine' import { Vector3 } from '@babylonjs/core/Maths/math.vector' import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh' import { Scene } from '@babylonjs/core/scene' +import { blockoutToStaticBoxes } from '@shobu/core' import { buildGreyboxArena } from '../arena/buildGreyboxArena.ts' -import { blockoutToStaticBoxes } from '../arena/collisionBoxes.ts' import { trainingDummyPostsM } from '../arena/trainingDummyPosts.ts' import { driveArenaAudio } from '../audio/driveArenaAudio.ts' import { bodyYawTargetRad, followBodyYawRad } from '../character/bodyYaw.ts' diff --git a/packages/client/src/renderer/greyboxMaterials.ts b/packages/client/src/renderer/greyboxMaterials.ts index 94f61f76..59a03b30 100644 --- a/packages/client/src/renderer/greyboxMaterials.ts +++ b/packages/client/src/renderer/greyboxMaterials.ts @@ -6,7 +6,7 @@ import '@babylonjs/core/Shaders/default.vertex' import '@babylonjs/core/Shaders/default.fragment' import { Color3 } from '@babylonjs/core/Maths/math.color' import type { Scene } from '@babylonjs/core/scene' -import type { ArenaLayer } from '../arena/greyboxBlockout.ts' +import type { ArenaLayer } from '@shobu/core' /** * Um material por camada, congelado. `StandardMaterial` e não PBR por decisão diff --git a/packages/client/src/arena/collisionBoxes.test.ts b/packages/core/src/arena/collisionBoxes.test.ts similarity index 96% rename from packages/client/src/arena/collisionBoxes.test.ts rename to packages/core/src/arena/collisionBoxes.test.ts index 99ff533a..d5c4b4e3 100644 --- a/packages/client/src/arena/collisionBoxes.test.ts +++ b/packages/core/src/arena/collisionBoxes.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' -import { parseGameplayConfig } from '@shobu/core' import { describe, expect, it } from 'vitest' +import { parseGameplayConfig } from '../config/parseGameplayConfig.ts' import { blockoutToStaticBoxes } from './collisionBoxes.ts' import { GREYBOX_BLOCKOUT, GREYBOX_SPAWN_POINTS_M } from './greyboxBlockout.ts' diff --git a/packages/client/src/arena/collisionBoxes.ts b/packages/core/src/arena/collisionBoxes.ts similarity index 90% rename from packages/client/src/arena/collisionBoxes.ts rename to packages/core/src/arena/collisionBoxes.ts index 37958734..de3cc381 100644 --- a/packages/client/src/arena/collisionBoxes.ts +++ b/packages/core/src/arena/collisionBoxes.ts @@ -1,4 +1,4 @@ -import { boxFromCenterSize, type StaticBox } from '@shobu/core' +import { boxFromCenterSize, type StaticBox } from '../collision/staticBox.ts' import type { GreyboxBlock } from './greyboxBlockout.ts' /** diff --git a/packages/client/src/arena/greyboxBlockout.test.ts b/packages/core/src/arena/greyboxBlockout.test.ts similarity index 95% rename from packages/client/src/arena/greyboxBlockout.test.ts rename to packages/core/src/arena/greyboxBlockout.test.ts index 94e866e7..9b90ecc1 100644 --- a/packages/client/src/arena/greyboxBlockout.test.ts +++ b/packages/core/src/arena/greyboxBlockout.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' -import { maxDoubleJumpHeightM, maxJumpHeightM, parseGameplayConfig } from '@shobu/core' import { describe, expect, it } from 'vitest' +import { parseGameplayConfig } from '../config/parseGameplayConfig.ts' +import { maxDoubleJumpHeightM, maxJumpHeightM } from '../movement/jump.ts' import { GREYBOX_BLOCKOUT, GREYBOX_SPAWN_POINTS_M } from './greyboxBlockout.ts' const SHIPPED_CONFIG_URL = new URL('../../../../config/gameplay.json', import.meta.url) diff --git a/packages/client/src/arena/greyboxBlockout.ts b/packages/core/src/arena/greyboxBlockout.ts similarity index 100% rename from packages/client/src/arena/greyboxBlockout.ts rename to packages/core/src/arena/greyboxBlockout.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e029ce62..36d65e8f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,6 @@ +export { blockoutToStaticBoxes } from './arena/collisionBoxes.ts' +export type { ArenaLayer, GreyboxBlock } from './arena/greyboxBlockout.ts' +export { GREYBOX_BLOCKOUT, GREYBOX_SPAWN_POINTS_M } from './arena/greyboxBlockout.ts' export type { BoxHit } from './collision/raycastBoxes.ts' export { createBoxHit, nearestBoxHit, raycastBoxes } from './collision/raycastBoxes.ts' export { nearestTargetHit } from './collision/raycastTargets.ts' From 8f77f04d0cdd25d7e0ae754c30d6a15262bdb330 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:08:54 -0300 Subject: [PATCH 028/139] feat: simulate a multiplayer match in the core --- packages/core/src/index.ts | 2 + packages/core/src/match/match.test.ts | 91 +++++++++++++++++++++++++++ packages/core/src/match/match.ts | 81 ++++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 packages/core/src/match/match.test.ts create mode 100644 packages/core/src/match/match.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 36d65e8f..1c8e162c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,8 @@ export type { } from './config/gameplayConfig.ts' export { GAMEPLAY_CONFIG_SPEC } from './config/gameplayConfigSpec.ts' export { parseGameplayConfig } from './config/parseGameplayConfig.ts' +export type { Match, MatchPlayer } from './match/match.ts' +export { createMatch, joinMatch, leaveMatch, setPlayerInput, stepMatch } from './match/match.ts' export type { SeededRandom } from './math/seededRandom.ts' export { createSeededRandom, nextUnit } from './math/seededRandom.ts' export type { Vector3 } from './math/vector3.ts' diff --git a/packages/core/src/match/match.test.ts b/packages/core/src/match/match.test.ts new file mode 100644 index 00000000..397ad84d --- /dev/null +++ b/packages/core/src/match/match.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { copyCharacterState, createCharacterState } from '../movement/characterState.ts' +import { IDLE_INPUT } from '../movement/movementInput.ts' +import { FLOOR, FORWARD, shippedConfig, tickDurationS } from '../movement/movementTestKit.ts' +import { stepCharacter } from '../movement/stepCharacter.ts' +import { createMatch, joinMatch, leaveMatch, setPlayerInput, stepMatch } from './match.ts' + +const config = shippedConfig() +const BOXES = [FLOOR] +const ORIGIN = { x: 0, y: 0, z: 0 } +const FAR = { x: 10, y: 0, z: 10 } + +describe('joinMatch', () => { + it('põe o jogador nos pés indicados, parado', () => { + const match = createMatch() + const player = joinMatch(match, 'ana', FAR, config) + expect(player.character.position).toEqual(FAR) + expect(player.input).toEqual(IDLE_INPUT) + expect(match.players.get('ana')).toBe(player) + }) + + it('recusa o mesmo jogador duas vezes, dizendo quem', () => { + const match = createMatch() + joinMatch(match, 'ana', ORIGIN, config) + expect(() => joinMatch(match, 'ana', FAR, config)).toThrow(/'ana'/) + }) +}) + +describe('leaveMatch', () => { + it('tira o jogador da partida', () => { + const match = createMatch() + joinMatch(match, 'ana', ORIGIN, config) + leaveMatch(match, 'ana') + expect(match.players.has('ana')).toBe(false) + }) + + /** A queda da conexão e a saída pela aba podem chegar as duas; a segunda não é erro. */ + it('ignora quem já saiu', () => { + const match = createMatch() + expect(() => leaveMatch(match, 'ninguém')).not.toThrow() + }) +}) + +describe('setPlayerInput', () => { + it('recusa input de quem não está na partida, dizendo quem', () => { + const match = createMatch() + expect(() => setPlayerInput(match, 'bia', FORWARD)).toThrow(/'bia'/) + }) +}) + +describe('stepMatch', () => { + it('avança o tick da partida', () => { + const match = createMatch() + stepMatch(match, BOXES, config) + stepMatch(match, BOXES, config) + expect(match.tick).toBe(2) + }) + + /** + * O servidor e a predição do cliente têm que chegar no mesmo estado: cada + * jogador avança exatamente como `stepCharacter` sozinho, com o próprio input. + */ + it('avança cada jogador com o próprio input, igual ao stepCharacter', () => { + const match = createMatch() + joinMatch(match, 'ana', ORIGIN, config) + joinMatch(match, 'bia', FAR, config) + setPlayerInput(match, 'ana', FORWARD) + const expectedAna = createCharacterState(ORIGIN, config) + const expectedBia = createCharacterState(FAR, config) + const dtS = tickDurationS(config) + + for (let tick = 0; tick < 10; tick += 1) { + stepMatch(match, BOXES, config) + stepCharacter(expectedAna, FORWARD, BOXES, config, dtS) + stepCharacter(expectedBia, IDLE_INPUT, BOXES, config, dtS) + } + + expect(match.players.get('ana')?.character).toEqual(expectedAna) + expect(match.players.get('bia')?.character).toEqual(expectedBia) + }) + + it('mantém o último input até chegar outro', () => { + const match = createMatch() + const player = joinMatch(match, 'ana', ORIGIN, config) + setPlayerInput(match, 'ana', FORWARD) + stepMatch(match, BOXES, config) + const afterOne = copyCharacterState(player.character, createCharacterState(ORIGIN, config)) + stepMatch(match, BOXES, config) + expect(player.character.position.z).toBeGreaterThan(afterOne.position.z) + }) +}) diff --git a/packages/core/src/match/match.ts b/packages/core/src/match/match.ts new file mode 100644 index 00000000..5094dead --- /dev/null +++ b/packages/core/src/match/match.ts @@ -0,0 +1,81 @@ +import type { StaticBox } from '../collision/staticBox.ts' +import type { GameplayConfig } from '../config/gameplayConfig.ts' +import type { Vector3 } from '../math/vector3.ts' +import { type CharacterState, createCharacterState } from '../movement/characterState.ts' +import { IDLE_INPUT, type MovementInput } from '../movement/movementInput.ts' +import { stepCharacter } from '../movement/stepCharacter.ts' + +/** + * Um jogador numa partida de vários: o personagem e o último input que chegou + * dele. O último input vale até chegar outro, porque um input atrasado na rede + * não pode fazer o personagem parar no servidor enquanto segue andando na + * predição do cliente. + */ +export interface MatchPlayer { + readonly playerId: string + readonly character: CharacterState + input: MovementInput +} + +/** + * A partida no núcleo, sem rede: é o que o servidor de jogo autoriza e o que a + * predição do cliente repete. Ordem de inserção no mapa é ordem de entrada. + */ +export interface Match { + readonly players: Map + tick: number +} + +export function createMatch(): Match { + return { players: new Map(), tick: 0 } +} + +/** + * ```ts + * joinMatch(match, sessionId, { x: 26, y: 0.2, z: 26 }, config) + * ``` + */ +export function joinMatch( + match: Match, + playerId: string, + feetM: Readonly, + config: GameplayConfig, +): MatchPlayer { + if (match.players.has(playerId)) { + throw new Error(`joinMatch recebeu '${playerId}', que já está na partida; esperado id novo`) + } + const player: MatchPlayer = { + playerId, + character: createCharacterState(feetM, config), + input: IDLE_INPUT, + } + match.players.set(playerId, player) + return player +} + +export function leaveMatch(match: Match, playerId: string): void { + match.players.delete(playerId) +} + +export function setPlayerInput(match: Match, playerId: string, input: MovementInput): void { + const player = match.players.get(playerId) + if (!player) { + throw new Error(`setPlayerInput recebeu '${playerId}', que não está na partida`) + } + player.input = input +} + +/** + * Um tick da partida: cada jogador avança com o próprio input. + * + * ```ts + * stepMatch(match, arenaBoxes, config) + * ``` + */ +export function stepMatch(match: Match, boxes: readonly StaticBox[], config: GameplayConfig): void { + const dtS = 1 / config.simulation.tickHz + for (const player of match.players.values()) { + stepCharacter(player.character, player.input, boxes, config, dtS) + } + match.tick += 1 +} From 0db4b3ee92db48433baf4bf1f788140b6261ee19 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:11:29 -0300 Subject: [PATCH 029/139] feat: add the quantized wire format to the core --- packages/core/src/index.ts | 13 ++ packages/core/src/wire/wireFormat.test.ts | 121 +++++++++++++++ packages/core/src/wire/wireFormat.ts | 175 ++++++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 packages/core/src/wire/wireFormat.test.ts create mode 100644 packages/core/src/wire/wireFormat.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1c8e162c..4e0cd579 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -89,3 +89,16 @@ export { isReloading, weaponPhase, } from './weapon/weaponState.ts' +export type { WireAim, WireButtons, WireInput } from './wire/wireFormat.ts' +export { + decodeButtons, + decodeMovementInput, + decodeStance, + dequantizeAim, + dequantizePositionM, + encodeButtons, + encodeStance, + quantizeAim, + quantizePositionM, + quantizeWish, +} from './wire/wireFormat.ts' diff --git a/packages/core/src/wire/wireFormat.test.ts b/packages/core/src/wire/wireFormat.test.ts new file mode 100644 index 00000000..c2180a59 --- /dev/null +++ b/packages/core/src/wire/wireFormat.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { dot, normalizeInPlace } from '../math/vector3.ts' +import { + decodeButtons, + decodeMovementInput, + decodeStance, + dequantizeAim, + dequantizePositionM, + encodeButtons, + encodeStance, + quantizeAim, + quantizePositionM, + quantizeWish, + type WireButtons, + type WireInput, +} from './wireFormat.ts' + +const NO_BUTTONS: WireButtons = { + sprint: false, + jump: false, + crouch: false, + fire: false, + scope: false, + reload: false, +} + +describe('posição', () => { + /** A resolução é o que a docs/rede promete: 200 vezes abaixo do critério de 0,10 m. */ + it('volta com erro de no máximo meio milímetro', () => { + for (const meters of [0, 0.0004, -12.3456, 31.9999, 14.5]) { + expect(Math.abs(dequantizePositionM(quantizePositionM(meters)) - meters)).toBeLessThanOrEqual( + 0.0005, + ) + } + }) + + it('cabe em int16 e satura fora de ±32,767 m em vez de dar a volta', () => { + expect(quantizePositionM(40)).toBe(32767) + expect(quantizePositionM(-40)).toBe(-32768) + }) + + it('recusa posição que não é número finito, dizendo qual', () => { + expect(() => quantizePositionM(Number.NaN)).toThrow(/NaN/) + }) +}) + +describe('mira', () => { + it('volta unitária e a menos de 0,002° da original', () => { + const aim = normalizeInPlace({ x: 0.3, y: -0.2, z: -0.93 }) + const back = dequantizeAim(quantizeAim(aim), { x: 0, y: 0, z: 0 }) + expect(Math.hypot(back.x, back.y, back.z)).toBeCloseTo(1, 12) + const angleDeg = (Math.acos(Math.min(1, dot(aim, back))) * 180) / Math.PI + expect(angleDeg).toBeLessThan(0.002) + }) + + it('não precisa vir unitária', () => { + expect(quantizeAim({ x: 0, y: 0, z: -5 })).toEqual({ aimX: 0, aimY: 0, aimZ: -32767 }) + }) + + it('recusa mira nula, dizendo qual', () => { + expect(() => quantizeAim({ x: 0, y: 0, z: 0 })).toThrow(/0, 0, 0/) + }) +}) + +describe('direção desejada', () => { + it('ocupa int8 e satura acima de 1', () => { + expect(quantizeWish(1)).toBe(127) + expect(quantizeWish(-3)).toBe(-127) + expect(quantizeWish(0.5)).toBe(64) + }) +}) + +describe('botões', () => { + it('cabem num byte e voltam iguais, um a um', () => { + for (const name of Object.keys(NO_BUTTONS) as (keyof WireButtons)[]) { + const pressed = { ...NO_BUTTONS, [name]: true } + const bits = encodeButtons(pressed) + expect(bits).toBeLessThan(256) + expect(decodeButtons(bits)).toEqual(pressed) + } + }) +}) + +describe('postura', () => { + it.each([ + ['standing', true], + ['crouching', false], + ['sliding', true], + ] as const)('%s, no chão %s, volta igual', (stance, grounded) => { + expect(decodeStance(encodeStance(stance, grounded))).toEqual({ stance, grounded }) + }) + + it('recusa byte que não é postura, dizendo qual', () => { + expect(() => decodeStance(3)).toThrow(/3/) + }) +}) + +describe('decodeMovementInput', () => { + const wire: WireInput = { + wishX: 127, + wishZ: -64, + buttons: encodeButtons({ ...NO_BUTTONS, sprint: true, jump: true }), + aimX: 0, + aimY: 0, + aimZ: -32767, + } + + it('devolve a direção desejada em -1..1 e os botões de movimento', () => { + const input = decodeMovementInput(wire, false) + expect(input.wishX).toBe(1) + expect(input.wishZ).toBeCloseTo(-64 / 127, 12) + expect(input).toMatchObject({ sprint: true, jump: true, crouch: false, scoped: false }) + }) + + /** A mira aberta vem do estado da arma, não do botão cru (ver `MovementInput.scoped`). */ + it('usa o scoped que recebe, não o botão de mira', () => { + const scopeHeld = { ...wire, buttons: encodeButtons({ ...NO_BUTTONS, scope: true }) } + expect(decodeMovementInput(scopeHeld, false).scoped).toBe(false) + expect(decodeMovementInput(wire, true).scoped).toBe(true) + }) +}) diff --git a/packages/core/src/wire/wireFormat.ts b/packages/core/src/wire/wireFormat.ts new file mode 100644 index 00000000..3c2c3ae4 --- /dev/null +++ b/packages/core/src/wire/wireFormat.ts @@ -0,0 +1,175 @@ +import { lengthSquared, normalizeInPlace, type Vector3 } from '../math/vector3.ts' +import type { Stance } from '../movement/characterState.ts' +import type { MovementInput } from '../movement/movementInput.ts' + +/** + * O formato de fio do canal de jogo: os inteiros que o servidor e o cliente + * trocam no lugar de float cru (nfr.md, "estado de rede quantizado"). Fica no + * núcleo porque os dois lados precisam do mesmo arredondamento, e a resolução + * de cada campo está na Tabela 4 de `docs/rede/arquitetura-de-rede.md`. + * + * A mira viaja como direção quantizada, não como yaw e pitch: voltar de ângulo + * para vetor exige seno e cosseno, que o núcleo proíbe (`coreIsDeterministic`), + * e a raiz quadrada da normalização é exata no IEEE. + */ + +/** O input de um tick, como o schema do colyseus o transporta. */ +export interface WireInput { + readonly wishX: number + readonly wishZ: number + readonly buttons: number + readonly aimX: number + readonly aimY: number + readonly aimZ: number +} + +export interface WireAim { + readonly aimX: number + readonly aimY: number + readonly aimZ: number +} + +export interface WireButtons { + readonly sprint: boolean + readonly jump: boolean + readonly crouch: boolean + readonly fire: boolean + readonly scope: boolean + readonly reload: boolean +} + +const INT16_MIN = -32768 +const INT16_MAX = 32767 +const WISH_STEPS = 127 +const AIM_STEPS = 32767 +const MM_PER_M = 1000 + +const BUTTON_BITS: readonly (keyof WireButtons)[] = [ + 'sprint', + 'jump', + 'crouch', + 'fire', + 'scope', + 'reload', +] + +const STANCES: readonly Stance[] = ['standing', 'crouching', 'sliding'] +const GROUNDED_BIT = 0b100 + +function assertFinite(value: number, what: string): void { + if (!Number.isFinite(value)) { + throw new RangeError(`${what} recebeu ${value}; esperado número finito`) + } +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +/** + * Metros para milímetros em `int16`. Satura em ±32,767 m em vez de dar a + * volta: um valor fora da arena é defeito, mas derrubar a sala na feira por + * causa dele é pior que desenhar o jogador na borda. + * + * ```ts + * quantizePositionM(12.3456) // 12346 + * ``` + */ +export function quantizePositionM(meters: number): number { + assertFinite(meters, 'quantizePositionM') + return clamp(Math.round(meters * MM_PER_M), INT16_MIN, INT16_MAX) +} + +export function dequantizePositionM(millimeters: number): number { + return millimeters / MM_PER_M +} + +/** + * Direção de mira em três `int16`. Não precisa vir unitária. + * + * ```ts + * quantizeAim(cameraForward) // { aimX, aimY, aimZ } + * ``` + */ +export function quantizeAim(aim: Readonly): WireAim { + const unit = normalizeInPlace({ x: aim.x, y: aim.y, z: aim.z }) + if (lengthSquared(unit) === 0) { + throw new RangeError( + `quantizeAim recebeu (${aim.x}, ${aim.y}, ${aim.z}); esperado vetor não nulo`, + ) + } + return { + aimX: Math.round(unit.x * AIM_STEPS), + aimY: Math.round(unit.y * AIM_STEPS), + aimZ: Math.round(unit.z * AIM_STEPS), + } +} + +/** Escreve em `out` a direção unitária, sem alocar. */ +export function dequantizeAim(wire: WireAim, out: Vector3): Vector3 { + out.x = wire.aimX + out.y = wire.aimY + out.z = wire.aimZ + return normalizeInPlace(out) +} + +/** Um eixo da direção desejada em `int8`. O núcleo normaliza a direção depois. */ +export function quantizeWish(axis: number): number { + assertFinite(axis, 'quantizeWish') + return clamp(Math.round(axis * WISH_STEPS), -WISH_STEPS, WISH_STEPS) +} + +export function encodeButtons(buttons: WireButtons): number { + let bits = 0 + BUTTON_BITS.forEach((name, bit) => { + if (buttons[name]) bits |= 1 << bit + }) + return bits +} + +export function decodeButtons(bits: number): WireButtons { + const has = (name: keyof WireButtons): boolean => (bits & (1 << BUTTON_BITS.indexOf(name))) !== 0 + return { + sprint: has('sprint'), + jump: has('jump'), + crouch: has('crouch'), + fire: has('fire'), + scope: has('scope'), + reload: has('reload'), + } +} + +/** Postura nos dois bits de baixo e o `grounded` no terceiro: um `uint8`. */ +export function encodeStance(stance: Stance, grounded: boolean): number { + return STANCES.indexOf(stance) | (grounded ? GROUNDED_BIT : 0) +} + +export function decodeStance(bits: number): { stance: Stance; grounded: boolean } { + const stance = STANCES[bits & 0b11] + if (stance === undefined) { + throw new RangeError( + `decodeStance recebeu ${bits}; esperado postura de 0 a ${STANCES.length - 1}`, + ) + } + return { stance, grounded: (bits & GROUNDED_BIT) !== 0 } +} + +/** + * O input de movimento de um tick, a partir do fio. `scoped` vem do estado da + * arma no servidor, não do botão de mira: é a mesma regra do `MovementInput`. + * + * ```ts + * setPlayerInput(match, sessionId, decodeMovementInput(wire, weapon.scoped)) + * ``` + */ +export function decodeMovementInput(wire: WireInput, scoped: boolean): MovementInput { + const buttons = decodeButtons(wire.buttons) + return { + wishX: wire.wishX / WISH_STEPS, + wishZ: wire.wishZ / WISH_STEPS, + sprint: buttons.sprint, + jump: buttons.jump, + crouch: buttons.crouch, + scoped, + } +} From 154c3b4c01c8dd1948fd5a6e009bd5b5a0af1f8a Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:11:30 -0300 Subject: [PATCH 030/139] docs: send the aim as a quantized direction instead of yaw and pitch --- docs/rede/arquitetura-de-rede.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/rede/arquitetura-de-rede.md b/docs/rede/arquitetura-de-rede.md index 6a30e9a7..a09f4419 100644 --- a/docs/rede/arquitetura-de-rede.md +++ b/docs/rede/arquitetura-de-rede.md @@ -157,14 +157,15 @@ a 3 s. Até lá, o personagem continua no mundo, parado. ## Formato das mensagens Estado e input são quantizados em inteiros ([nfr.md](../nfr.md)). A resolução de posição fica -200 vezes abaixo do critério de aceite. +200 vezes abaixo do critério de aceite. A mira viaja como direção, e não como yaw e pitch: voltar +de ângulo para vetor exige seno e cosseno, que o núcleo proíbe porque o IEEE não fixa o último bit +deles, e a raiz quadrada da normalização é exata. O formato está em `wireFormat.ts`, no núcleo. | Mensagem | Campo | Tipo | Resolução | |---|---|---|---| | Input | direção desejada, x e z | `int8` cada | 1/127 | | Input | corrida, pulo, agachar, mira, disparo, recarga | bits de um `uint8` | | -| Input e snapshot | yaw | `uint16` | 360°/65536 = 0,0055° | -| Input e snapshot | pitch | `int16` | 90°/32767 = 0,0027° | +| Input e snapshot | direção de mira, x, y e z | `int16` cada | 1/32767 por componente, cerca de 0,0017° | | Snapshot | posição, x, y e z | `int16` cada | 1 mm; cobre ±32,767 m | | Snapshot | postura: em pé, agachado, slide, no ar | `uint8` | | @@ -178,8 +179,8 @@ interpolam posição e mira. O SDK envia cada input confiável com 1 B de código de protocolo e o carimbo de tempo codificado como diferença do anterior (1 B a 15,6 ms). O corpo, serializado pelo `@colyseus/schema`, soma -um índice de 1 B por campo, e o input fica em cerca de 14 B. Um jogador em movimento ocupa cerca -de 18 B por patch. As premissas da Tabela 2, 32 B e 60 B, são conservadoras. +um índice de 1 B por campo, e o input fica em cerca de 17 B. Um jogador em movimento ocupa cerca +de 22 B por patch. As premissas da Tabela 2, 32 B e 60 B, são conservadoras. ## Rede do estande From 12d8e77ad2c6fbe6ca65d774f93b74546bf9262d Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:12:46 -0300 Subject: [PATCH 031/139] feat: parse and generate ipv4-style room addresses --- packages/core/src/index.ts | 1 + packages/core/src/room/roomAddress.test.ts | 87 +++++++++++++++++++++ packages/core/src/room/roomAddress.ts | 90 ++++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 packages/core/src/room/roomAddress.test.ts create mode 100644 packages/core/src/room/roomAddress.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4e0cd579..801ffa13 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -51,6 +51,7 @@ export type { MovementInput } from './movement/movementInput.ts' export { hasWish, IDLE_INPUT, wishLength, wishUnit } from './movement/movementInput.ts' export { SLIDE_CAPSULE_RATIO, SLIDE_ENTRY_SPEED_RATIO } from './movement/slide.ts' export { stepCharacter } from './movement/stepCharacter.ts' +export { generateRoomAddress, isReservedAddress, parseRoomAddress } from './room/roomAddress.ts' export type { KillEvent, KillWeapon, MutableKillEvent } from './scoring/killEvent.ts' export { createKillEvent } from './scoring/killEvent.ts' export type { PlayerScore, Scoreboard } from './scoring/scoreboard.ts' diff --git a/packages/core/src/room/roomAddress.test.ts b/packages/core/src/room/roomAddress.test.ts new file mode 100644 index 00000000..7c199fc1 --- /dev/null +++ b/packages/core/src/room/roomAddress.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { generateRoomAddress, isReservedAddress, parseRoomAddress } from './roomAddress.ts' + +describe('parseRoomAddress', () => { + it.each([ + ['203.0.113.7', '203.0.113.7'], + ['10.0.0.07', '10.0.0.7'], + ['0.0.0.0', '0.0.0.0'], + ['255.255.255.255', '255.255.255.255'], + ])('%s é a sala %s', (text, canonical) => { + expect(parseRoomAddress(text)).toBe(canonical) + }) + + /** O `inet_aton` leria 010 como octal, 8. O glossário decide decimal. */ + it('lê zero à esquerda como decimal, não octal', () => { + expect(parseRoomAddress('010.0.0.1')).toBe('10.0.0.1') + }) + + it.each([ + '10.0.0.256', + '10.0.0', + '10.0.0.1.1', + '10..0.1', + '10.0.0.1 ', + '10.0.0.+1', + '10.0.0.0001', + 'a.b.c.d', + '', + ])('%j não é endereço de sala', (text) => { + expect(parseRoomAddress(text)).toBeUndefined() + }) +}) + +describe('isReservedAddress', () => { + it.each([ + '0.1.2.3', + '10.20.30.40', + '100.64.0.1', + '127.0.0.1', + '169.254.1.1', + '172.16.0.1', + '172.31.255.255', + '192.0.0.1', + '192.0.2.1', + '192.88.99.1', + '192.168.0.1', + '198.18.0.1', + '198.19.255.255', + '198.51.100.1', + '203.0.113.7', + '224.0.0.1', + '240.0.0.1', + '255.255.255.255', + ])('%s é reservado', (address) => { + expect(isReservedAddress(address)).toBe(true) + }) + + it.each(['8.8.8.8', '172.15.255.255', '172.32.0.0', '100.63.255.255', '198.20.0.0'])( + '%s não é reservado', + (address) => { + expect(isReservedAddress(address)).toBe(false) + }, + ) + + it('recusa texto que não é endereço, dizendo qual', () => { + expect(() => isReservedAddress('10.0.0.256')).toThrow(/'10\.0\.0\.256'/) + }) +}) + +describe('generateRoomAddress', () => { + /** Sequência fixa de uint32: o gerador é determinístico dado o sorteio. */ + function sequence(...values: number[]): () => number { + return () => { + const next = values.shift() + if (next === undefined) throw new Error('a sequência de teste acabou') + return next + } + } + + it('monta o endereço com os quatro bytes do sorteio', () => { + expect(generateRoomAddress(sequence(0x08080808))).toBe('8.8.8.8') + }) + + it('sorteia de novo quando cai em faixa reservada', () => { + expect(generateRoomAddress(sequence(0xc0a80001, 0x0a000001, 0x5db8d822))).toBe('93.184.216.34') + }) +}) diff --git a/packages/core/src/room/roomAddress.ts b/packages/core/src/room/roomAddress.ts new file mode 100644 index 00000000..97ebc730 --- /dev/null +++ b/packages/core/src/room/roomAddress.ts @@ -0,0 +1,90 @@ +/** + * O endereço da sala: um ipv4 fictício no caminho da url (ADR 0011). Não é o + * endereço de máquina nenhuma, é o nome da sala com cara de ip. + * + * O texto aceito é o dotted-quad decimal de quatro octetos de 0 a 255, com até + * três dígitos cada. Zero à esquerda é decimal, ao contrário do `inet_aton`, + * que leria `010` como octal: o glossário decide que `10.0.0.07` é `10.0.0.7`. + */ + +const OCTET = /^\d{1,3}$/ + +/** + * As faixas de uso especial do ipv4 (registro da iana, RFC 6890): privadas, + * loopback, link-local, documentação, benchmark, multicast e reservadas. O + * gerador as evita para um endereço de sala não se confundir com o ip real de + * uma máquina da rede do estande. + */ +const RESERVED_RANGES: readonly (readonly [readonly number[], number])[] = [ + [[0, 0, 0, 0], 8], + [[10, 0, 0, 0], 8], + [[100, 64, 0, 0], 10], + [[127, 0, 0, 0], 8], + [[169, 254, 0, 0], 16], + [[172, 16, 0, 0], 12], + [[192, 0, 0, 0], 24], + [[192, 0, 2, 0], 24], + [[192, 88, 99, 0], 24], + [[192, 168, 0, 0], 16], + [[198, 18, 0, 0], 15], + [[198, 51, 100, 0], 24], + [[203, 0, 113, 0], 24], + [[224, 0, 0, 0], 4], + [[240, 0, 0, 0], 4], +] + +function octetsOf(text: string): readonly number[] | undefined { + const parts = text.split('.') + if (parts.length !== 4 || !parts.every((part) => OCTET.test(part))) return undefined + const octets = parts.map(Number) + return octets.every((octet) => octet <= 255) ? octets : undefined +} + +/** Os quatro octetos como inteiro de 32 bits sem sinal; sem operador de bit, que teria sinal. */ +function toUint32(octets: readonly number[]): number { + return octets.reduce((value, octet) => value * 256 + octet, 0) +} + +/** + * A forma canônica do endereço, ou `undefined` se o texto não é endereço de sala. + * + * ```ts + * parseRoomAddress('10.0.0.07') // '10.0.0.7' + * ``` + */ +export function parseRoomAddress(text: string): string | undefined { + return octetsOf(text)?.join('.') +} + +/** + * ```ts + * isReservedAddress('192.168.0.1') // true + * ``` + */ +export function isReservedAddress(address: string): boolean { + const octets = octetsOf(address) + if (!octets) { + throw new RangeError(`isReservedAddress recebeu '${address}'; esperado endereço ipv4 de sala`) + } + const value = toUint32(octets) + return RESERVED_RANGES.some(([base, prefix]) => { + const blockSize = 2 ** (32 - prefix) + return Math.floor(value / blockSize) === Math.floor(toUint32(base) / blockSize) + }) +} + +/** + * Um endereço de sala novo, fora das faixas reservadas. O sorteio é injetado: + * o servidor passa um uint32 criptográfico, e o teste passa uma sequência fixa. + * + * ```ts + * generateRoomAddress(() => crypto.getRandomValues(new Uint32Array(1))[0] ?? 0) + * ``` + */ +export function generateRoomAddress(nextUint32: () => number): string { + for (;;) { + const value = nextUint32() + const address = [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255].join('.') + if (!isReservedAddress(address)) return address + } +} From e04575d50faac3c2ab1c3c70bda6d9d9ca04d557 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:15:41 -0300 Subject: [PATCH 032/139] refactor: move the spawn feet conversion into the core --- .../src/character/competitorAvatar.test.ts | 28 +------------- .../client/src/character/competitorAvatar.ts | 17 --------- .../src/renderer/babylonArenaRenderer.ts | 5 +-- .../src/renderer/loadCompetitorAvatar.ts | 9 ++--- packages/core/src/arena/greyboxBlockout.ts | 2 +- packages/core/src/arena/spawnFeet.test.ts | 37 +++++++++++++++++++ packages/core/src/arena/spawnFeet.ts | 17 +++++++++ packages/core/src/index.ts | 1 + 8 files changed, 63 insertions(+), 53 deletions(-) create mode 100644 packages/core/src/arena/spawnFeet.test.ts create mode 100644 packages/core/src/arena/spawnFeet.ts diff --git a/packages/client/src/character/competitorAvatar.test.ts b/packages/client/src/character/competitorAvatar.test.ts index 69056a8d..f31311ce 100644 --- a/packages/client/src/character/competitorAvatar.test.ts +++ b/packages/client/src/character/competitorAvatar.test.ts @@ -1,11 +1,7 @@ import { readFileSync } from 'node:fs' -import { GREYBOX_SPAWN_POINTS_M, parseGameplayConfig } from '@shobu/core' +import { parseGameplayConfig } from '@shobu/core' import { describe, expect, it } from 'vitest' -import { - COMPETITOR_MODEL_HEIGHT_M, - competitorAvatarScale, - competitorFeetM, -} from './competitorAvatar.ts' +import { COMPETITOR_MODEL_HEIGHT_M, competitorAvatarScale } from './competitorAvatar.ts' /** * A altura **versionada**, não uma constante local. @@ -30,23 +26,3 @@ describe('competitorAvatarScale', () => { expect(() => competitorAvatarScale(Number.NaN)).toThrow(/recebeu NaN/) }) }) - -describe('competitorFeetM', () => { - it('desce uma cápsula e preserva o plano horizontal', () => { - expect(competitorFeetM([26, CAPSULE_HEIGHT_M, 26], CAPSULE_HEIGHT_M)).toEqual([26, 0, 26]) - }) - - /** - * A regressão que importa: se o spawn deixar de ser "uma cápsula acima da - * superfície", o avatar flutua ou afunda, e nenhum teste de unidade do - * renderer pega isso — só esta relação entre o blockout e a cápsula. - */ - it('apoia todo spawn do greybox em uma superfície do blockout', () => { - const surfaceY = [0, 7.5, 14.5] - for (const spawn of GREYBOX_SPAWN_POINTS_M) { - const [, feetY] = competitorFeetM(spawn, CAPSULE_HEIGHT_M) - const gap = Math.min(...surfaceY.map((y) => Math.abs(y - feetY))) - expect(gap, `spawn ${JSON.stringify(spawn)} apoia em ${feetY}`).toBeLessThan(1e-9) - } - }) -}) diff --git a/packages/client/src/character/competitorAvatar.ts b/packages/client/src/character/competitorAvatar.ts index 0d99c773..03aae809 100644 --- a/packages/client/src/character/competitorAvatar.ts +++ b/packages/client/src/character/competitorAvatar.ts @@ -35,20 +35,3 @@ export function competitorAvatarScale(capsuleHeightM: number): number { } return capsuleHeightM / COMPETITOR_MODEL_HEIGHT_M } - -/** - * Todo ponto de `GREYBOX_SPAWN_POINTS_M` está **uma altura de cápsula acima da - * superfície que o sustenta** — 1,8 sobre o piso que termina em y 0, e 9,3 - * sobre o deck que termina em 7,5. A malha do glTF tem origem no pé, então - * descer uma cápsula é o que apoia o avatar no chão em vez de pendurá-lo. - * - * ```ts - * competitorFeetM([26, 2, 26], 2) // → [26, 0, 26] - * ``` - */ -export function competitorFeetM( - eyeM: readonly [number, number, number], - capsuleHeightM: number, -): readonly [number, number, number] { - return [eyeM[0], eyeM[1] - capsuleHeightM, eyeM[2]] -} diff --git a/packages/client/src/renderer/babylonArenaRenderer.ts b/packages/client/src/renderer/babylonArenaRenderer.ts index 3783a2ee..bc17edb7 100644 --- a/packages/client/src/renderer/babylonArenaRenderer.ts +++ b/packages/client/src/renderer/babylonArenaRenderer.ts @@ -3,12 +3,11 @@ import { Engine } from '@babylonjs/core/Engines/engine' import { Vector3 } from '@babylonjs/core/Maths/math.vector' import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh' import { Scene } from '@babylonjs/core/scene' -import { blockoutToStaticBoxes } from '@shobu/core' +import { blockoutToStaticBoxes, spawnFeetM } from '@shobu/core' import { buildGreyboxArena } from '../arena/buildGreyboxArena.ts' import { trainingDummyPostsM } from '../arena/trainingDummyPosts.ts' import { driveArenaAudio } from '../audio/driveArenaAudio.ts' import { bodyYawTargetRad, followBodyYawRad } from '../character/bodyYaw.ts' -import { competitorFeetM } from '../character/competitorAvatar.ts' import { accentForIndex } from '../character/competitorPalette.ts' import { createLocomotionPose, @@ -303,7 +302,7 @@ function attachLocalCharacter( } function feetOfSpawn(options: ArenaRendererOptions): { x: number; y: number; z: number } { - const [x, y, z] = competitorFeetM(options.spawnPointM, options.config.collision.capsuleHeightM) + const [x, y, z] = spawnFeetM(options.spawnPointM, options.config.collision.capsuleHeightM) return { x, y, z } } diff --git a/packages/client/src/renderer/loadCompetitorAvatar.ts b/packages/client/src/renderer/loadCompetitorAvatar.ts index fe528e01..7863334d 100644 --- a/packages/client/src/renderer/loadCompetitorAvatar.ts +++ b/packages/client/src/renderer/loadCompetitorAvatar.ts @@ -1,11 +1,8 @@ import { ImportMeshAsync } from '@babylonjs/core/Loading/sceneLoader' import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh' import type { Scene } from '@babylonjs/core/scene' -import { - COMPETITOR_IDLE_CLIP, - competitorAvatarScale, - competitorFeetM, -} from '../character/competitorAvatar.ts' +import { spawnFeetM } from '@shobu/core' +import { COMPETITOR_IDLE_CLIP, competitorAvatarScale } from '../character/competitorAvatar.ts' import type { CompetitorAccent } from '../character/competitorPalette.ts' import { type CompetitorAnimator, createCompetitorAnimator } from './competitorAnimator.ts' import { flattenPbrMaterial } from './flattenPbrMaterial.ts' @@ -48,7 +45,7 @@ export async function loadCompetitorAvatar( if (!root) { throw new Error(`${COMPETITOR_MODEL_URL} carregou 0 malhas; esperado o root do glb`) } - root.position.set(...competitorFeetM(options.eyeM, options.capsuleHeightM)) + root.position.set(...spawnFeetM(options.eyeM, options.capsuleHeightM)) root.scaling.setAll(competitorAvatarScale(options.capsuleHeightM)) for (const mesh of loaded.meshes) flattenPbrMaterial(mesh, scene, 'competitor') paintCompetitor(loaded.meshes, options.accent) diff --git a/packages/core/src/arena/greyboxBlockout.ts b/packages/core/src/arena/greyboxBlockout.ts index d670bef3..58b883e9 100644 --- a/packages/core/src/arena/greyboxBlockout.ts +++ b/packages/core/src/arena/greyboxBlockout.ts @@ -49,7 +49,7 @@ export const GREYBOX_BLOCKOUT: readonly GreyboxBlock[] = [ * pilar 1 proíbe. * * Estão na **convenção de altura de olho**: o y é o piso da camada mais - * `collision.capsuleHeightM`, e quem converte para pé é `competitorFeetM`. + * `collision.capsuleHeightM`, e quem converte para pé é `spawnFeetM`. * Mexer na altura da cápsula **obriga** a mexer aqui junto, senão o jogador * nasce enterrado ou boiando — é o que o teste de `competitorAvatar` vigia. */ diff --git a/packages/core/src/arena/spawnFeet.test.ts b/packages/core/src/arena/spawnFeet.test.ts new file mode 100644 index 00000000..3340c8b4 --- /dev/null +++ b/packages/core/src/arena/spawnFeet.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parseGameplayConfig } from '../config/parseGameplayConfig.ts' +import { GREYBOX_SPAWN_POINTS_M } from './greyboxBlockout.ts' +import { spawnFeetM } from './spawnFeet.ts' + +/** + * A altura **versionada**, não uma constante local. + * + * Com o número na mão aqui, mexer na cápsula e esquecer os spawns passava no + * teste e enterrava o jogador no chão — foi o que aconteceu ao subir a cápsula + * para 2 m. Lendo a config, a relação "spawn = superfície + cápsula" é vigiada + * contra o número que o jogo de fato usa. + */ +const SHIPPED_CONFIG_URL = new URL('../../../../config/gameplay.json', import.meta.url) +const CAPSULE_HEIGHT_M = parseGameplayConfig(JSON.parse(readFileSync(SHIPPED_CONFIG_URL, 'utf8'))) + .collision.capsuleHeightM + +describe('spawnFeetM', () => { + it('desce uma cápsula e preserva o plano horizontal', () => { + expect(spawnFeetM([26, CAPSULE_HEIGHT_M, 26], CAPSULE_HEIGHT_M)).toEqual([26, 0, 26]) + }) + + /** + * A regressão que importa: se o spawn deixar de ser "uma cápsula acima da + * superfície", o avatar flutua ou afunda, e nenhum teste de unidade do + * renderer pega isso — só esta relação entre o blockout e a cápsula. + */ + it('apoia todo spawn do greybox em uma superfície do blockout', () => { + const surfaceY = [0, 7.5, 14.5] + for (const spawn of GREYBOX_SPAWN_POINTS_M) { + const [, feetY] = spawnFeetM(spawn, CAPSULE_HEIGHT_M) + const gap = Math.min(...surfaceY.map((y) => Math.abs(y - feetY))) + expect(gap, `spawn ${JSON.stringify(spawn)} apoia em ${feetY}`).toBeLessThan(1e-9) + } + }) +}) diff --git a/packages/core/src/arena/spawnFeet.ts b/packages/core/src/arena/spawnFeet.ts new file mode 100644 index 00000000..bac17c03 --- /dev/null +++ b/packages/core/src/arena/spawnFeet.ts @@ -0,0 +1,17 @@ +/** + * Todo ponto de `GREYBOX_SPAWN_POINTS_M` está **uma altura de cápsula acima da + * superfície que o sustenta** — 1,8 sobre o piso que termina em y 0, e 9,3 + * sobre o deck que termina em 7,5. Descer uma cápsula dá o pé: é onde o + * servidor põe o personagem e onde o cliente apoia o avatar, que tem a origem + * no pé. + * + * ```ts + * spawnFeetM([26, 2, 26], 2) // → [26, 0, 26] + * ``` + */ +export function spawnFeetM( + eyeM: readonly [number, number, number], + capsuleHeightM: number, +): readonly [number, number, number] { + return [eyeM[0], eyeM[1] - capsuleHeightM, eyeM[2]] +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 801ffa13..8998b2bb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export { blockoutToStaticBoxes } from './arena/collisionBoxes.ts' export type { ArenaLayer, GreyboxBlock } from './arena/greyboxBlockout.ts' export { GREYBOX_BLOCKOUT, GREYBOX_SPAWN_POINTS_M } from './arena/greyboxBlockout.ts' +export { spawnFeetM } from './arena/spawnFeet.ts' export type { BoxHit } from './collision/raycastBoxes.ts' export { createBoxHit, nearestBoxHit, raycastBoxes } from './collision/raycastBoxes.ts' export { nearestTargetHit } from './collision/raycastTargets.ts' From 8a2c0ec0be4900332ce64052a4a33c2f1241189d Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:22:17 -0300 Subject: [PATCH 033/139] feat: run the authoritative game room on colyseus --- package-lock.json | 1287 ++++++++++++++++- package.json | 2 +- .../client/src/coverageExclusions.test.ts | 1 + packages/server/package.json | 15 + packages/server/src/gameRoom.ts | 66 + packages/server/src/gameRoomState.test.ts | 48 + packages/server/src/gameRoomState.ts | 37 + packages/server/src/main.ts | 43 + packages/server/src/roomSimulation.test.ts | 168 +++ packages/server/src/roomSimulation.ts | 145 ++ packages/server/tsconfig.json | 8 + vitest.config.ts | 2 + 12 files changed, 1816 insertions(+), 6 deletions(-) create mode 100644 packages/server/package.json create mode 100644 packages/server/src/gameRoom.ts create mode 100644 packages/server/src/gameRoomState.test.ts create mode 100644 packages/server/src/gameRoomState.ts create mode 100644 packages/server/src/main.ts create mode 100644 packages/server/src/roomSimulation.test.ts create mode 100644 packages/server/src/roomSimulation.ts create mode 100644 packages/server/tsconfig.json diff --git a/package-lock.json b/package-lock.json index e3fa7a04..a3ea1361 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,18 @@ "node": ">=18" } }, + "node_modules/@better-auth/utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.1.tgz", + "integrity": "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==", + "license": "MIT" + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.2.tgz", + "integrity": "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==", + "license": "MIT" + }, "node_modules/@biomejs/biome": { "version": "2.5.11", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.11.tgz", @@ -258,6 +270,148 @@ "node": ">=14.21.3" } }, + "node_modules/@colyseus/better-call": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@colyseus/better-call/-/better-call-1.3.3.tgz", + "integrity": "sha512-ISJtvMx6fV0ABPQt/ljzPc4O2+mFvyVKeHKX02qBSWKR0VXOXzniAC64POea4pp8DxZTbgHoSPpXjdnNpUkeLw==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.3.1", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@colyseus/clock": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@colyseus/clock/-/clock-2.0.3.tgz", + "integrity": "sha512-kziqG0dcGsvSfcerHLZTY1V2PmGnZAdG5DM0T5BKQDAiYBXYoMqfzA1Wxfe3KSnyeZuP99isffkUQLEwpFYF7A==", + "license": "MIT" + }, + "node_modules/@colyseus/core": { + "version": "0.18.17", + "resolved": "https://registry.npmjs.org/@colyseus/core/-/core-0.18.17.tgz", + "integrity": "sha512-SlWL+4Nf9Wqs7Ee5ApY6e6jhmvtBNArGrENr4WELPlPRZb0bIIud82O0RAfQY3Ns/iwmgev5heX0jhlqlgdXvQ==", + "license": "MIT", + "dependencies": { + "@colyseus/better-call": "^1.3.1", + "@colyseus/greeting-banner": "^4.0.3", + "@colyseus/shared-types": "^0.18.3", + "@colyseus/timer": "^2.0.0", + "@standard-schema/spec": "^1.0.0", + "debug": "^4.3.4", + "msgpackr": "^2.0.1", + "nanoid": "^3.3.11" + }, + "engines": { + "node": ">= 22.x" + }, + "funding": { + "url": "https://github.com/sponsors/endel" + }, + "peerDependencies": { + "@colyseus/auth": "^0.18.3", + "@colyseus/better-call": "^1.3.1", + "@colyseus/schema": "^5.0.8", + "@colyseus/ws-transport": "^0.18.4", + "@pm2/io": "^6.1.0", + "express": "^4.16.0 || ^5.0.0", + "zod": "^4.1.12" + }, + "peerDependenciesMeta": { + "@colyseus/auth": { + "optional": true + }, + "@colyseus/ws-transport": { + "optional": true + }, + "@pm2/io": { + "optional": true + }, + "express": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@colyseus/greeting-banner": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@colyseus/greeting-banner/-/greeting-banner-4.0.3.tgz", + "integrity": "sha512-2Ned3SnWS2XoxG9I7zy2qbKsaev0aE9+VZrBgg3NrNBVcxJjRba7jscqWs89k7D7tsGCDAa5d17ac0DYviUACA==", + "license": "MIT", + "engines": { + "node": ">= 22.x" + } + }, + "node_modules/@colyseus/schema": { + "version": "5.0.34", + "resolved": "https://registry.npmjs.org/@colyseus/schema/-/schema-5.0.34.tgz", + "integrity": "sha512-66YPLYAl7l+ezlymFtvYtfEpQasRUd+TjAprEph1ieDWC/FHolAnFsYhL4yESZf4r5qMIkaGTz+4Zz1Ex0LB4A==", + "license": "MIT", + "bin": { + "schema-codegen": "bin/schema-codegen", + "schema-debug": "bin/schema-debug" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@colyseus/shared-types": { + "version": "0.18.3", + "resolved": "https://registry.npmjs.org/@colyseus/shared-types/-/shared-types-0.18.3.tgz", + "integrity": "sha512-Kye6Lmpd4RXY52Ro8NawurUKMNagkNfAdV79Dx/cdWib1LrNW4e94JOtl9HIxy4QYVdHlkxc2m5ZjcwQXekWng==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0" + }, + "engines": { + "node": ">= 22.x" + }, + "funding": { + "url": "https://github.com/sponsors/endel" + } + }, + "node_modules/@colyseus/timer": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@colyseus/timer/-/timer-2.0.3.tgz", + "integrity": "sha512-75MI7LfXQncibesa7W6FKWUc8YBcVI6jwBrDXOq1TlgYQXSMRv7a5WFPBdDKkP5Pht9IWpE2hyUb8B1DPrLYqg==", + "license": "MIT", + "dependencies": { + "@colyseus/clock": "^2.0.0" + } + }, + "node_modules/@colyseus/ws-transport": { + "version": "0.18.4", + "resolved": "https://registry.npmjs.org/@colyseus/ws-transport/-/ws-transport-0.18.4.tgz", + "integrity": "sha512-gSPEkhV9cX8QjJWJiXfkRF38ZIiWq7DbDvzkgGqa1zmdOSZBP1aaM9tnZyvcjc1OpG+AFggeehrs/QMDBEzz2g==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.14", + "ws": "^8.19.0" + }, + "engines": { + "node": ">= 22.x" + }, + "peerDependencies": { + "@colyseus/core": "^0.18.5", + "express": "^4.16.0 || ^5.0.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -1311,6 +1465,84 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -1686,6 +1918,16 @@ "resolved": "packages/core", "link": true }, + "node_modules/@shobu/server": { + "resolved": "packages/server", + "link": true + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1722,12 +1964,20 @@ "version": "26.4.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/coverage-v8": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz", @@ -1818,6 +2068,20 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1847,6 +2111,86 @@ "license": "Apache-2.0", "peer": true }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1857,6 +2201,50 @@ "node": ">=18" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/cwise-compiler": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz", @@ -1867,16 +2255,95 @@ "uniq": "^1.0.0" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT", + "peer": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -1884,6 +2351,19 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -1926,6 +2406,13 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT", + "peer": true + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1936,6 +2423,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1946,6 +2443,50 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1964,6 +2505,48 @@ } } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1979,6 +2562,139 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "peer": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "peer": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "peer": true + }, "node_modules/iota-array": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", @@ -1986,6 +2702,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -1993,6 +2719,13 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT", + "peer": true + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -2029,11 +2762,111 @@ "source-map-js": "^1.2.1" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, "funding": [ { "type": "github", @@ -2093,6 +2926,65 @@ "sharp": "^0.35.0" } }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -2107,6 +2999,50 @@ "node": ">=12.20.0" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2163,6 +3099,71 @@ "dev": true, "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -2209,6 +3210,36 @@ "fsevents": "~2.3.2" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -2222,6 +3253,66 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC", + "peer": true + }, "node_modules/sharp": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", @@ -2272,6 +3363,82 @@ } } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2296,6 +3463,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -2350,6 +3527,16 @@ "node": ">=14.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2358,11 +3545,44 @@ "license": "0BSD", "optional": true }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -2376,7 +3596,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, "license": "MIT" }, "node_modules/uniq": { @@ -2386,6 +3605,26 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -2561,6 +3800,34 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "peer": true + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/client": { "name": "@shobu/client", "version": "0.0.0", @@ -2576,6 +3843,16 @@ "packages/core": { "name": "@shobu/core", "version": "0.0.0" + }, + "packages/server": { + "name": "@shobu/server", + "version": "0.0.0", + "dependencies": { + "@colyseus/core": "0.18.17", + "@colyseus/schema": "5.0.34", + "@colyseus/ws-transport": "0.18.4", + "@shobu/core": "*" + } } } } diff --git a/package.json b/package.json index 309702ff..a76d4c30 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "verify": "npm run lint && npm run typecheck && npm test", "lint": "biome check .", "format": "biome format --write .", - "typecheck": "tsc -p packages/core && tsc -p packages/client", + "typecheck": "tsc -p packages/core && tsc -p packages/client && tsc -p packages/server", "test": "vitest run --coverage", "test:watch": "vitest", "convert:viewmodel": "node scripts/convert-viewmodel.mjs", diff --git a/packages/client/src/coverageExclusions.test.ts b/packages/client/src/coverageExclusions.test.ts index 5986566c..25f8117d 100644 --- a/packages/client/src/coverageExclusions.test.ts +++ b/packages/client/src/coverageExclusions.test.ts @@ -20,6 +20,7 @@ const ADAPTER_MARKS: readonly RegExp[] = [ /HTML[A-Za-z]*Element/, /\bfetch\(/, /\bperformance\./, + /@colyseus\//, ] function sourceOf(file: string): string { diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 00000000..8088a8c0 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "@shobu/server", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node --experimental-strip-types src/main.ts" + }, + "dependencies": { + "@colyseus/core": "0.18.17", + "@colyseus/schema": "5.0.34", + "@colyseus/ws-transport": "0.18.4", + "@shobu/core": "*" + } +} diff --git a/packages/server/src/gameRoom.ts b/packages/server/src/gameRoom.ts new file mode 100644 index 00000000..2c53f276 --- /dev/null +++ b/packages/server/src/gameRoom.ts @@ -0,0 +1,66 @@ +import { type Client, Room } from '@colyseus/core' +import type { GameplayConfig, StaticBox } from '@shobu/core' +import { GameRoomState, PlayerInput, PlayerState } from './gameRoomState.ts' +import { + assertCanonicalAddress, + createRoomSimulation, + joinRoom, + leaveRoom, + type SpawnPoints, + stepRoom, + writeRoomState, +} from './roomSimulation.ts' + +/** + * O que a sala recebe por injeção. Não vem pelas opções do `defineRoom`, + * porque o colyseus mistura essas opções com as que o cliente manda, e um + * cliente adulterado sobrescreveria a configuração do servidor. + */ +export interface GameRoomDeps { + readonly config: GameplayConfig + readonly boxes: readonly StaticBox[] + readonly spawns: SpawnPoints +} + +/** O que o cliente manda no matchmaking. */ +export interface GameRoomOptions { + readonly address?: string +} + +/** + * A sala do servidor de jogo, ligada ao colyseus. Adapter de rede: a lógica + * está em `roomSimulation.ts`, e o que fica aqui é só a cola. + * + * ```ts + * defineRoom(createGameRoom({ config, boxes, spawns })).filterBy(['address']) + * ``` + */ +export function createGameRoom(deps: GameRoomDeps) { + return class GameRoom extends Room { + override state = new GameRoomState() + override readonly inputs = this.defineInput(PlayerInput, { bufferMaxSize: 64 }) + readonly #simulation = createRoomSimulation() + + override onCreate(options: GameRoomOptions): void { + assertCanonicalAddress(options.address) + this.maxClients = deps.config.match.playersPerRoom + this.patchRate = 1000 / deps.config.simulation.patchHz + this.setFixedTimestep(() => this.#step(), deps.config.simulation.tickHz) + } + + override onJoin(client: Client): void { + joinRoom(this.#simulation, client.sessionId, deps.spawns, deps.config) + this.state.players.set(client.sessionId, new PlayerState()) + } + + override onLeave(client: Client): void { + leaveRoom(this.#simulation, client.sessionId) + this.state.players.delete(client.sessionId) + } + + #step(): void { + stepRoom(this.#simulation, (id) => this.inputs.get(id).next(), deps.boxes, deps.config) + writeRoomState(this.#simulation, (id) => this.state.players.get(id)) + } + } +} diff --git a/packages/server/src/gameRoomState.test.ts b/packages/server/src/gameRoomState.test.ts new file mode 100644 index 00000000..0ac06173 --- /dev/null +++ b/packages/server/src/gameRoomState.test.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs' +import { Encoder } from '@colyseus/schema' +import { + decodeMovementInput, + GREYBOX_SPAWN_POINTS_M, + parseGameplayConfig, + quantizePositionM, +} from '@shobu/core' +import { describe, expect, it } from 'vitest' +import { GameRoomState, PlayerInput, PlayerState } from './gameRoomState.ts' +import { createRoomSimulation, joinRoom, writeRoomState } from './roomSimulation.ts' + +const SHIPPED_CONFIG_URL = new URL('../../../config/gameplay.json', import.meta.url) +const config = parseGameplayConfig(JSON.parse(readFileSync(SHIPPED_CONFIG_URL, 'utf8'))) + +describe('PlayerState', () => { + /** O snapshot do jogador é o que `writeRoomState` escreve: nenhum campo pode sumir no caminho. */ + it('guarda o que writeRoomState escreve, nos limites de int16', () => { + const room = createRoomSimulation() + const player = joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) + player.character.position.x = -32.767 + const state = new PlayerState() + + writeRoomState(room, () => state) + + expect(state.x).toBe(quantizePositionM(-32.767)) + expect(state.x).toBe(-32767) + expect(state.aimZ).toBe(-32767) + }) + + it('codifica um estado com jogador sem erro de tipo no schema', () => { + const room = createRoomSimulation() + joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) + const roomState = new GameRoomState() + roomState.players.set('ana', new PlayerState()) + writeRoomState(room, (id) => roomState.players.get(id)) + expect(new Encoder(roomState).encodeAll().byteLength).toBeGreaterThan(0) + }) +}) + +describe('PlayerInput', () => { + it('é lido como o WireInput do núcleo', () => { + const input = new PlayerInput() + input.wishX = 127 + input.wishZ = -127 + expect(decodeMovementInput(input, false)).toMatchObject({ wishX: 1, wishZ: -1 }) + }) +}) diff --git a/packages/server/src/gameRoomState.ts b/packages/server/src/gameRoomState.ts new file mode 100644 index 00000000..998ec9bb --- /dev/null +++ b/packages/server/src/gameRoomState.ts @@ -0,0 +1,37 @@ +import { schema, t } from '@colyseus/schema' + +/** + * O estado e o input que o colyseus sincroniza, com as larguras da Tabela 4 de + * `docs/rede/arquitetura-de-rede.md`. Os campos são os inteiros do + * `wireFormat` do núcleo: quem arredonda é ele, não o schema, para o cliente e + * o servidor arredondarem com o mesmo código. + */ + +/** O snapshot de um jogador que os outros clientes interpolam. */ +export const PlayerState = schema( + { + x: t.int16(), + y: t.int16(), + z: t.int16(), + aimX: t.int16(), + aimY: t.int16(), + aimZ: t.int16(), + stance: t.uint8(), + }, + 'PlayerState', +) + +export const GameRoomState = schema({ players: t.map(PlayerState) }, 'GameRoomState') + +/** O input de um tick. O colyseus exige schema plano, só primitivos. */ +export const PlayerInput = schema( + { + wishX: t.int8(), + wishZ: t.int8(), + buttons: t.uint8(), + aimX: t.int16(), + aimY: t.int16(), + aimZ: t.int16(), + }, + 'PlayerInput', +) diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts new file mode 100644 index 00000000..b8dccf3d --- /dev/null +++ b/packages/server/src/main.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs' +import { defineRoom, defineServer } from '@colyseus/core' +import { WebSocketTransport } from '@colyseus/ws-transport' +import { + blockoutToStaticBoxes, + GREYBOX_BLOCKOUT, + GREYBOX_SPAWN_POINTS_M, + parseGameplayConfig, +} from '@shobu/core' +import { createGameRoom } from './gameRoom.ts' + +/** A porta padrão do colyseus, a da Tabela 1 de `docs/rede/arquitetura-de-rede.md`. */ +const PORT = 2567 + +/** + * Sem reconexão, a queda silenciosa do wi-fi só é detectada pelo heartbeat: + * ping a cada 1 s e desconexão após 2 sem resposta dão 2 a 3 s, contra 6 a 9 s + * do padrão de 3 s (docs/rede, Tabela de parâmetros). + */ +const HEARTBEAT_INTERVAL_MS = 1000 +const HEARTBEAT_MISSES = 2 + +const CONFIG_URL = new URL('../../../config/gameplay.json', import.meta.url) +const config = parseGameplayConfig(JSON.parse(readFileSync(CONFIG_URL, 'utf8'))) + +const server = defineServer({ + transport: new WebSocketTransport({ + pingInterval: HEARTBEAT_INTERVAL_MS, + pingMaxRetries: HEARTBEAT_MISSES, + }), + rooms: { + arena: defineRoom( + createGameRoom({ + config, + boxes: blockoutToStaticBoxes(GREYBOX_BLOCKOUT), + spawns: GREYBOX_SPAWN_POINTS_M, + }), + ).filterBy(['address']), + }, +}) + +await server.listen(PORT) +console.log(JSON.stringify({ event: 'server.listening', port: PORT })) diff --git a/packages/server/src/roomSimulation.test.ts b/packages/server/src/roomSimulation.test.ts new file mode 100644 index 00000000..2bd66237 --- /dev/null +++ b/packages/server/src/roomSimulation.test.ts @@ -0,0 +1,168 @@ +import { readFileSync } from 'node:fs' +import { + blockoutToStaticBoxes, + decodeStance, + dequantizePositionM, + encodeButtons, + GREYBOX_BLOCKOUT, + GREYBOX_SPAWN_POINTS_M, + parseGameplayConfig, + quantizePositionM, + spawnFeetM, + type WireInput, +} from '@shobu/core' +import { describe, expect, it } from 'vitest' +import { + assertCanonicalAddress, + createRoomSimulation, + FORWARD_AIM, + joinRoom, + leaveRoom, + type PlayerStateFields, + stepRoom, + writeRoomState, +} from './roomSimulation.ts' + +const SHIPPED_CONFIG_URL = new URL('../../../config/gameplay.json', import.meta.url) +const config = parseGameplayConfig(JSON.parse(readFileSync(SHIPPED_CONFIG_URL, 'utf8'))) +const boxes = blockoutToStaticBoxes(GREYBOX_BLOCKOUT) + +const NO_BUTTONS = encodeButtons({ + sprint: false, + jump: false, + crouch: false, + fire: false, + scope: false, + reload: false, +}) +const FORWARD_WIRE: WireInput = { wishX: 0, wishZ: -127, buttons: NO_BUTTONS, ...FORWARD_AIM } + +/** A fila de input por jogador que o colyseus guarda; aqui, em memória. */ +class FakeInputQueues { + readonly #queues = new Map() + + push(playerId: string, input: WireInput): void { + const queue = this.#queues.get(playerId) ?? [] + queue.push(input) + this.#queues.set(playerId, queue) + } + + readonly next = (playerId: string): WireInput | undefined => this.#queues.get(playerId)?.shift() +} + +function emptyState(): PlayerStateFields { + return { x: 0, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: 0, stance: 0 } +} + +describe('joinRoom', () => { + it('põe cada jogador que entra no spawn seguinte, com os pés no chão do spawn', () => { + const room = createRoomSimulation() + const first = joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) + const second = joinRoom(room, 'bia', GREYBOX_SPAWN_POINTS_M, config) + const [x0, y0, z0] = spawnFeetM( + GREYBOX_SPAWN_POINTS_M[0] ?? [0, 0, 0], + config.collision.capsuleHeightM, + ) + expect(first.character.position).toEqual({ x: x0, y: y0, z: z0 }) + const [x1] = spawnFeetM(GREYBOX_SPAWN_POINTS_M[1] ?? [0, 0, 0], config.collision.capsuleHeightM) + expect(second.character.position.x).toBe(x1) + }) + + /** Treze entradas numa sala que gira jogadores não podem estourar os doze spawns. */ + it('volta ao primeiro spawn depois do último', () => { + const room = createRoomSimulation() + for (let index = 0; index < GREYBOX_SPAWN_POINTS_M.length; index += 1) { + joinRoom(room, `p${index}`, GREYBOX_SPAWN_POINTS_M, config) + leaveRoom(room, `p${index}`) + } + const again = joinRoom(room, 'volta', GREYBOX_SPAWN_POINTS_M, config) + const [x0] = spawnFeetM(GREYBOX_SPAWN_POINTS_M[0] ?? [0, 0, 0], config.collision.capsuleHeightM) + expect(again.character.position.x).toBe(x0) + }) +}) + +describe('joinRoom sem spawn', () => { + it('recusa lista de spawn vazia, dizendo o tamanho', () => { + expect(() => joinRoom(createRoomSimulation(), 'ana', [], config)).toThrow(/recebeu 0/) + }) +}) + +describe('leaveRoom', () => { + it('tira o jogador da partida e da sala', () => { + const room = createRoomSimulation() + joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) + leaveRoom(room, 'ana') + expect(room.match.players.size).toBe(0) + expect(room.seats.size).toBe(0) + }) +}) + +describe('stepRoom', () => { + it('consome um input por jogador por tick e anda com ele', () => { + const room = createRoomSimulation() + const player = joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) + const queues = new FakeInputQueues() + queues.push('ana', FORWARD_WIRE) + queues.push('ana', FORWARD_WIRE) + const startZ = player.character.position.z + + stepRoom(room, queues.next, boxes, config) + + expect(player.character.position.z).toBeLessThan(startZ) + expect(queues.next('ana')).toEqual(FORWARD_WIRE) + expect(room.match.tick).toBe(1) + }) + + /** Input atrasado na rede não pode parar o jogador no servidor enquanto ele anda na tela dele. */ + it('repete o último input quando não chegou nenhum no tick', () => { + const room = createRoomSimulation() + const player = joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) + const queues = new FakeInputQueues() + queues.push('ana', FORWARD_WIRE) + stepRoom(room, queues.next, boxes, config) + const afterFirst = player.character.position.z + stepRoom(room, queues.next, boxes, config) + expect(player.character.position.z).toBeLessThan(afterFirst) + }) + + it('guarda a mira do último input', () => { + const room = createRoomSimulation() + joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) + const queues = new FakeInputQueues() + queues.push('ana', { ...FORWARD_WIRE, aimX: 32767, aimZ: 0 }) + stepRoom(room, queues.next, boxes, config) + expect(room.seats.get('ana')?.aim).toEqual({ aimX: 32767, aimY: 0, aimZ: 0 }) + }) +}) + +describe('writeRoomState', () => { + it('escreve posição quantizada, mira e postura de cada jogador', () => { + const room = createRoomSimulation() + const player = joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) + const states = new Map([['ana', emptyState()]]) + + writeRoomState(room, (playerId) => states.get(playerId)) + + const written = states.get('ana') ?? emptyState() + expect(written.x).toBe(quantizePositionM(player.character.position.x)) + expect(dequantizePositionM(written.y)).toBeCloseTo(player.character.position.y, 3) + expect(written).toMatchObject(FORWARD_AIM) + expect(decodeStance(written.stance)).toEqual({ stance: 'standing', grounded: false }) + }) + + it('pula jogador sem estado ainda criado', () => { + const room = createRoomSimulation() + joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) + expect(() => writeRoomState(room, () => undefined)).not.toThrow() + }) +}) + +describe('assertCanonicalAddress', () => { + it('aceita o endereço canônico', () => { + expect(() => assertCanonicalAddress('203.0.113.7')).not.toThrow() + }) + + it.each(['10.0.0.07', 'sala', undefined])('recusa %j, dizendo qual', (address) => { + expect(() => assertCanonicalAddress(address)).toThrow(new RegExp(`'${address}'`)) + }) +}) diff --git a/packages/server/src/roomSimulation.ts b/packages/server/src/roomSimulation.ts new file mode 100644 index 00000000..a248979f --- /dev/null +++ b/packages/server/src/roomSimulation.ts @@ -0,0 +1,145 @@ +import { + createMatch, + decodeMovementInput, + encodeStance, + type GameplayConfig, + joinMatch, + leaveMatch, + type Match, + type MatchPlayer, + parseRoomAddress, + quantizePositionM, + type StaticBox, + setPlayerInput, + spawnFeetM, + stepMatch, + type WireAim, + type WireInput, +} from '@shobu/core' + +/** + * A sala do servidor de jogo sem o colyseus: a partida do núcleo, a mira de + * cada jogador e a conta de entradas que escolhe o spawn. A `gameRoom.ts` só + * liga isto ao colyseus; tudo que decide alguma coisa mora aqui, testado. + */ +export interface RoomSimulation { + readonly match: Match + readonly seats: Map + joins: number +} + +/** Um jogador da sala: o personagem da partida e a última mira que ele mandou. */ +export interface RoomSeat { + readonly player: MatchPlayer + /** A mira viaja já quantizada e volta igual para os outros clientes. */ + aim: WireAim +} + +/** Os pontos de spawn, na convenção de altura de olho de `GREYBOX_SPAWN_POINTS_M`. */ +export type SpawnPoints = readonly (readonly [number, number, number])[] + +/** Os campos que o `PlayerState` do schema tem; o schema os satisfaz por estrutura. */ +export interface PlayerStateFields { + x: number + y: number + z: number + aimX: number + aimY: number + aimZ: number + stance: number +} + +/** Olhando para -z até o primeiro input dizer outra coisa. */ +export const FORWARD_AIM: WireAim = { aimX: 0, aimY: 0, aimZ: -32767 } + +export function createRoomSimulation(): RoomSimulation { + return { match: createMatch(), seats: new Map(), joins: 0 } +} + +/** + * Entra no spawn seguinte, na ordem da lista, e volta ao primeiro depois do + * último. A escolha de spawn por distância aos outros jogadores é da fatia do + * respawn; aqui basta não empilhar quem entra. + * + * ```ts + * joinRoom(room, client.sessionId, GREYBOX_SPAWN_POINTS_M, config) + * ``` + */ +export function joinRoom( + room: RoomSimulation, + playerId: string, + spawns: SpawnPoints, + config: GameplayConfig, +): MatchPlayer { + const eyeM = spawns[room.joins % spawns.length] + if (!eyeM) + throw new Error(`joinRoom recebeu ${spawns.length} pontos de spawn; esperado ao menos 1`) + room.joins += 1 + const [x, y, z] = spawnFeetM(eyeM, config.collision.capsuleHeightM) + const player = joinMatch(room.match, playerId, { x, y, z }, config) + room.seats.set(playerId, { player, aim: FORWARD_AIM }) + return player +} + +export function leaveRoom(room: RoomSimulation, playerId: string): void { + leaveMatch(room.match, playerId) + room.seats.delete(playerId) +} + +/** + * Um tick: consome **um** input de cada jogador e avança a partida. Sem input + * no tick, o jogador repete o último (ver `MatchPlayer`). + * + * ```ts + * stepRoom(room, (id) => this.inputs.get(id).next(), boxes, config) + * ``` + */ +export function stepRoom( + room: RoomSimulation, + nextInput: (playerId: string) => WireInput | undefined, + boxes: readonly StaticBox[], + config: GameplayConfig, +): void { + for (const [playerId, seat] of room.seats) { + const wire = nextInput(playerId) + if (!wire) continue + setPlayerInput(room.match, playerId, decodeMovementInput(wire, false)) + seat.aim = { aimX: wire.aimX, aimY: wire.aimY, aimZ: wire.aimZ } + } + stepMatch(room.match, boxes, config) +} + +/** + * Escreve o snapshot quantizado de cada jogador no estado que o colyseus + * sincroniza. Jogador sem estado ainda criado fica para o próximo tick. + */ +export function writeRoomState( + room: RoomSimulation, + stateOf: (playerId: string) => PlayerStateFields | undefined, +): void { + for (const [playerId, { player, aim }] of room.seats) { + const out = stateOf(playerId) + if (!out) continue + const { position, stance, grounded } = player.character + out.x = quantizePositionM(position.x) + out.y = quantizePositionM(position.y) + out.z = quantizePositionM(position.z) + out.aimX = aim.aimX + out.aimY = aim.aimY + out.aimZ = aim.aimZ + out.stance = encodeStance(stance, grounded) + } +} + +/** + * O `filterBy` do colyseus compara o texto cru. Um endereço não canônico, + * como `10.0.0.07`, abriria uma segunda sala para o mesmo endereço, então o + * servidor recusa e o cliente normaliza antes de pedir. + */ +export function assertCanonicalAddress(address: string | undefined): void { + if (address === undefined || parseRoomAddress(address) !== address) { + throw new Error( + `endereço de sala '${address}' não é canônico; esperado ipv4 como '203.0.113.7'`, + ) + } +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 00000000..2c226e87 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts index b9170cb6..b30d4168 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -39,6 +39,8 @@ export const IO_ADAPTERS: readonly string[] = [ 'packages/client/src/renderer/loadSniperViewmodel.ts', 'packages/client/src/renderer/paintCompetitor.ts', 'packages/client/src/renderer/tracerBeams.ts', + 'packages/server/src/gameRoom.ts', + 'packages/server/src/main.ts', ] // headless por decisão: nenhum teste abre navegador (adr 0001). From 0871cd4d08c7bbdd05d2cfed1bf0b4b4bbbce768 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:29:00 -0300 Subject: [PATCH 034/139] perf: encode the aim and buttons without allocating --- packages/core/src/index.ts | 2 +- packages/core/src/wire/wireFormat.test.ts | 26 +++++++++++++++-- packages/core/src/wire/wireFormat.ts | 34 +++++++++++++---------- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8998b2bb..e2251b2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -91,7 +91,7 @@ export { isReloading, weaponPhase, } from './weapon/weaponState.ts' -export type { WireAim, WireButtons, WireInput } from './wire/wireFormat.ts' +export type { MutableWireAim, WireAim, WireButtons, WireInput } from './wire/wireFormat.ts' export { decodeButtons, decodeMovementInput, diff --git a/packages/core/src/wire/wireFormat.test.ts b/packages/core/src/wire/wireFormat.test.ts index c2180a59..190707c9 100644 --- a/packages/core/src/wire/wireFormat.test.ts +++ b/packages/core/src/wire/wireFormat.test.ts @@ -8,6 +8,7 @@ import { dequantizePositionM, encodeButtons, encodeStance, + type MutableWireAim, quantizeAim, quantizePositionM, quantizeWish, @@ -15,6 +16,10 @@ import { type WireInput, } from './wireFormat.ts' +function emptyAim(): MutableWireAim { + return { aimX: 0, aimY: 0, aimZ: 0 } +} + const NO_BUTTONS: WireButtons = { sprint: false, jump: false, @@ -47,18 +52,33 @@ describe('posição', () => { describe('mira', () => { it('volta unitária e a menos de 0,002° da original', () => { const aim = normalizeInPlace({ x: 0.3, y: -0.2, z: -0.93 }) - const back = dequantizeAim(quantizeAim(aim), { x: 0, y: 0, z: 0 }) + const back = dequantizeAim(quantizeAim(aim, emptyAim()), { x: 0, y: 0, z: 0 }) expect(Math.hypot(back.x, back.y, back.z)).toBeCloseTo(1, 12) const angleDeg = (Math.acos(Math.min(1, dot(aim, back))) * 180) / Math.PI expect(angleDeg).toBeLessThan(0.002) }) it('não precisa vir unitária', () => { - expect(quantizeAim({ x: 0, y: 0, z: -5 })).toEqual({ aimX: 0, aimY: 0, aimZ: -32767 }) + expect(quantizeAim({ x: 0, y: 0, z: -5 }, emptyAim())).toEqual({ + aimX: 0, + aimY: 0, + aimZ: -32767, + }) + }) + + it('recusa mira infinita, dizendo qual', () => { + expect(() => quantizeAim({ x: Number.POSITIVE_INFINITY, y: 0, z: 0 }, emptyAim())).toThrow( + /Infinity/, + ) + }) + + it('escreve no objeto que recebe, sem alocar outro', () => { + const out = emptyAim() + expect(quantizeAim({ x: 1, y: 0, z: 0 }, out)).toBe(out) }) it('recusa mira nula, dizendo qual', () => { - expect(() => quantizeAim({ x: 0, y: 0, z: 0 })).toThrow(/0, 0, 0/) + expect(() => quantizeAim({ x: 0, y: 0, z: 0 }, emptyAim())).toThrow(/0, 0, 0/) }) }) diff --git a/packages/core/src/wire/wireFormat.ts b/packages/core/src/wire/wireFormat.ts index 3c2c3ae4..4e26acf2 100644 --- a/packages/core/src/wire/wireFormat.ts +++ b/packages/core/src/wire/wireFormat.ts @@ -84,25 +84,29 @@ export function dequantizePositionM(millimeters: number): number { return millimeters / MM_PER_M } +/** A mira que `quantizeAim` escreve, reaproveitada a cada tick. */ +export type MutableWireAim = { -readonly [Key in keyof WireAim]: WireAim[Key] } + /** - * Direção de mira em três `int16`. Não precisa vir unitária. + * Direção de mira em três `int16`, escrita em `out` sem alocar: roda a cada + * tick de input, e o nfr.md proíbe alocação no caminho quente. Não precisa vir + * unitária. * * ```ts - * quantizeAim(cameraForward) // { aimX, aimY, aimZ } + * quantizeAim(cameraForward, wireInput) * ``` */ -export function quantizeAim(aim: Readonly): WireAim { - const unit = normalizeInPlace({ x: aim.x, y: aim.y, z: aim.z }) - if (lengthSquared(unit) === 0) { +export function quantizeAim(aim: Readonly, out: Out): Out { + const length = Math.sqrt(lengthSquared(aim)) + if (!(length > 0) || !Number.isFinite(length)) { throw new RangeError( - `quantizeAim recebeu (${aim.x}, ${aim.y}, ${aim.z}); esperado vetor não nulo`, + `quantizeAim recebeu (${aim.x}, ${aim.y}, ${aim.z}); esperado vetor não nulo e finito`, ) } - return { - aimX: Math.round(unit.x * AIM_STEPS), - aimY: Math.round(unit.y * AIM_STEPS), - aimZ: Math.round(unit.z * AIM_STEPS), - } + out.aimX = Math.round((aim.x / length) * AIM_STEPS) + out.aimY = Math.round((aim.y / length) * AIM_STEPS) + out.aimZ = Math.round((aim.z / length) * AIM_STEPS) + return out } /** Escreve em `out` a direção unitária, sem alocar. */ @@ -119,11 +123,13 @@ export function quantizeWish(axis: number): number { return clamp(Math.round(axis * WISH_STEPS), -WISH_STEPS, WISH_STEPS) } +/** Sem closure nem objeto novo: roda a cada tick de input. */ export function encodeButtons(buttons: WireButtons): number { let bits = 0 - BUTTON_BITS.forEach((name, bit) => { - if (buttons[name]) bits |= 1 << bit - }) + for (let bit = 0; bit < BUTTON_BITS.length; bit += 1) { + const name = BUTTON_BITS[bit] + if (name && buttons[name]) bits |= 1 << bit + } return bits } From 5f4614c31a045c6d0c0bbebf9245ba94f66ee4bd Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:29:01 -0300 Subject: [PATCH 035/139] feat: hand each simulated tick's input to the network --- .../src/controller/arenaSession.test.ts | 28 ++++++++++ .../client/src/controller/arenaSession.ts | 14 +++++ .../client/src/net/encodeTickInput.test.ts | 38 +++++++++++++ packages/client/src/net/encodeTickInput.ts | 56 +++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 packages/client/src/net/encodeTickInput.test.ts create mode 100644 packages/client/src/net/encodeTickInput.ts diff --git a/packages/client/src/controller/arenaSession.test.ts b/packages/client/src/controller/arenaSession.test.ts index 50e384f0..2b8c168a 100644 --- a/packages/client/src/controller/arenaSession.test.ts +++ b/packages/client/src/controller/arenaSession.test.ts @@ -225,6 +225,34 @@ describe('createArenaSession', () => { expect(rig.movementInput.scoped).toBe(false) }) + it('entrega o input de cada tick depois de a predição usá-lo', () => { + const ticks: { wishZ: number; fire: boolean; aimZ: number }[] = [] + const movementInput = createMovementInput() + const weaponInput = createWeaponInput() + const session = createArenaSession({ + config, + character: createLocalCharacter(config, SPAWN_FEET, [FLOOR]), + boxes: [FLOOR], + dummyPostsM: [DUMMY_POST], + movementInput, + weaponInput, + onTick: (movement, weapon, aimM) => + ticks.push({ wishZ: movement.wishZ, fire: weapon.fire, aimZ: aimM.z }), + }) + movementInput.wishZ = 1 + const ran = session.advance({ + elapsedS: 3 * TICK_S, + eyeM: SPAWN_FEET, + aimM: { x: 0, y: 0, z: 1 }, + }) + expect(ran).toBe(3) + expect(ticks).toEqual([ + { wishZ: 1, fire: false, aimZ: 1 }, + { wishZ: 1, fire: false, aimZ: 1 }, + { wishZ: 1, fire: false, aimZ: 1 }, + ]) + }) + it('a arma e o personagem andam no mesmo relógio', () => { const rig = mountSession() const ticks = rig.frame({ elapsedS: 5 * TICK_S }) diff --git a/packages/client/src/controller/arenaSession.ts b/packages/client/src/controller/arenaSession.ts index 3db37ffc..0eda7439 100644 --- a/packages/client/src/controller/arenaSession.ts +++ b/packages/client/src/controller/arenaSession.ts @@ -14,6 +14,7 @@ import { hitHeightRatio, killTrainingDummy, type MedalAward, + type MovementInput, type MutableKillEvent, resolveShot, type Scoreboard, @@ -25,6 +26,7 @@ import { type TargetList, type TrainingDummy, type Vector3, + type WeaponInput, type WeaponState, } from '@shobu/core' import type { LocalCharacter } from './localCharacter.ts' @@ -85,6 +87,17 @@ export interface ArenaSessionOptions { * da mensagem de entrada na sala e mais nada aqui muda. */ readonly seedBase?: number + /** + * Chamado no fim de cada tick com o input que a predição acabou de usar. É + * por aqui que o input sai para o servidor de jogo, sem a sessão conhecer a + * rede: o servidor precisa exatamente do input de cada tick que o cliente + * simulou, e é só aqui dentro que tick e input andam juntos. + */ + readonly onTick?: ( + movement: Readonly, + weapon: Readonly, + aimM: Readonly, + ) => void } /** O id do jogador local no placar. Opaco: amanhã é o sessionId do colyseus. */ @@ -182,6 +195,7 @@ function advanceArena( } movementInput.scoped = parts.weapon.scoped character.stepOnce(movementInput) + options.onTick?.(movementInput, weaponInput, frame.aimM) for (const dummy of parts.dummies) stepTrainingDummy(dummy, character.tickDurationS) session.matchTimeS += character.tickDurationS } diff --git a/packages/client/src/net/encodeTickInput.test.ts b/packages/client/src/net/encodeTickInput.test.ts new file mode 100644 index 00000000..c23098ac --- /dev/null +++ b/packages/client/src/net/encodeTickInput.test.ts @@ -0,0 +1,38 @@ +import { decodeButtons, decodeMovementInput, IDLE_INPUT, IDLE_WEAPON_INPUT } from '@shobu/core' +import { describe, expect, it } from 'vitest' +import { createWireInput, encodeTickInput } from './encodeTickInput.ts' + +const LOOKING_EAST = { x: 1, y: 0, z: 0 } + +describe('encodeTickInput', () => { + it('leva direção, botões e mira do tick para o fio', () => { + const wire = encodeTickInput( + { ...IDLE_INPUT, wishX: 0.5, wishZ: -1, sprint: true, crouch: true }, + { ...IDLE_WEAPON_INPUT, fire: true, reload: true }, + LOOKING_EAST, + createWireInput(), + ) + expect(decodeMovementInput(wire, false)).toMatchObject({ + wishX: 64 / 127, + wishZ: -1, + sprint: true, + jump: false, + crouch: true, + }) + expect(decodeButtons(wire.buttons)).toMatchObject({ fire: true, scope: false, reload: true }) + expect(wire).toMatchObject({ aimX: 32767, aimY: 0, aimZ: 0 }) + }) + + /** O servidor lê o input pelo mesmo `wireFormat`: o que sai daqui tem que voltar igual. */ + it('não deixa botão do tick anterior no rascunho', () => { + const out = createWireInput() + encodeTickInput({ ...IDLE_INPUT, jump: true }, IDLE_WEAPON_INPUT, LOOKING_EAST, out) + encodeTickInput(IDLE_INPUT, IDLE_WEAPON_INPUT, LOOKING_EAST, out) + expect(out.buttons).toBe(0) + }) + + it('escreve no objeto que recebe', () => { + const out = createWireInput() + expect(encodeTickInput(IDLE_INPUT, IDLE_WEAPON_INPUT, LOOKING_EAST, out)).toBe(out) + }) +}) diff --git a/packages/client/src/net/encodeTickInput.ts b/packages/client/src/net/encodeTickInput.ts new file mode 100644 index 00000000..91d4c6b9 --- /dev/null +++ b/packages/client/src/net/encodeTickInput.ts @@ -0,0 +1,56 @@ +import { + encodeButtons, + type MovementInput, + quantizeAim, + quantizeWish, + type Vector3, + type WeaponInput, + type WireButtons, + type WireInput, +} from '@shobu/core' + +/** O input de fio que o cliente preenche por tick e reaproveita, sem alocar. */ +export type MutableWireInput = { -readonly [Key in keyof WireInput]: WireInput[Key] } + +/** + * Rascunho dos botões, reaproveitado: montar um objeto por tick seria alocação + * no caminho quente. A função é síncrona, então dividir o rascunho é seguro. + */ +const buttonsScratch: { -readonly [Key in keyof WireButtons]: boolean } = { + sprint: false, + jump: false, + crouch: false, + fire: false, + scope: false, + reload: false, +} + +export function createWireInput(): MutableWireInput { + return { wishX: 0, wishZ: 0, buttons: 0, aimX: 0, aimY: 0, aimZ: -32767 } +} + +/** + * O input de um tick, do jeito que a predição acabou de usá-lo, no formato de + * fio do núcleo. Roda 64 vezes por segundo, então escreve em `out`. + * + * ```ts + * encodeTickInput(movementInput, weaponInput, frame.aimM, wireInput) + * ``` + */ +export function encodeTickInput( + movement: Readonly, + weapon: Readonly, + aimM: Readonly, + out: MutableWireInput, +): MutableWireInput { + out.wishX = quantizeWish(movement.wishX) + out.wishZ = quantizeWish(movement.wishZ) + buttonsScratch.sprint = movement.sprint + buttonsScratch.jump = movement.jump + buttonsScratch.crouch = movement.crouch + buttonsScratch.fire = weapon.fire + buttonsScratch.scope = weapon.scope + buttonsScratch.reload = weapon.reload + out.buttons = encodeButtons(buttonsScratch) + return quantizeAim(aimM, out) +} From 7e32dd4626bd27c9c388dfece998ae3edf163fae Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:29:39 -0300 Subject: [PATCH 036/139] feat: resolve the room address and game server url from the page --- packages/client/src/net/roomLocation.test.ts | 43 ++++++++++++++++++++ packages/client/src/net/roomLocation.ts | 38 +++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 packages/client/src/net/roomLocation.test.ts create mode 100644 packages/client/src/net/roomLocation.ts diff --git a/packages/client/src/net/roomLocation.test.ts b/packages/client/src/net/roomLocation.test.ts new file mode 100644 index 00000000..1921a9fc --- /dev/null +++ b/packages/client/src/net/roomLocation.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { gameServerUrl, resolveRoomAddress } from './roomLocation.ts' + +/** Sorteio fixo: o primeiro uint32 dá 93.184.216.34, fora das faixas reservadas. */ +const DRAW = (): number => 0x5db8d822 + +describe('resolveRoomAddress', () => { + it('usa o endereço do caminho', () => { + expect(resolveRoomAddress('/203.0.113.7', DRAW)).toEqual({ + address: '203.0.113.7', + path: '/203.0.113.7', + }) + }) + + /** Dois amigos com `10.0.0.07` e `10.0.0.7` têm que cair na mesma sala. */ + it('normaliza o caminho para a forma canônica', () => { + expect(resolveRoomAddress('/10.0.0.07/', DRAW)).toEqual({ + address: '10.0.0.7', + path: '/10.0.0.7', + }) + }) + + it.each(['/', '', '/sala', '/10.0.0.256'])('sorteia um endereço novo para %j', (pathname) => { + expect(resolveRoomAddress(pathname, DRAW)).toEqual({ + address: '93.184.216.34', + path: '/93.184.216.34', + }) + }) +}) + +describe('gameServerUrl', () => { + it('usa wss quando a página vem por https, porque página https não abre ws://', () => { + expect(gameServerUrl({ protocol: 'https:', hostname: 'shobu.example' }, 2567)).toBe( + 'wss://shobu.example:2567', + ) + }) + + it('usa ws quando a página vem por http', () => { + expect(gameServerUrl({ protocol: 'http:', hostname: 'localhost' }, 2567)).toBe( + 'ws://localhost:2567', + ) + }) +}) diff --git a/packages/client/src/net/roomLocation.ts b/packages/client/src/net/roomLocation.ts new file mode 100644 index 00000000..953e9707 --- /dev/null +++ b/packages/client/src/net/roomLocation.ts @@ -0,0 +1,38 @@ +import { generateRoomAddress, parseRoomAddress } from '@shobu/core' + +/** A sala que o jogador vai pedir e o caminho que a url passa a mostrar. */ +export interface RoomLocation { + readonly address: string + readonly path: string +} + +/** + * O endereço da sala sai do caminho da url (ADR 0011). Endereço válido volta + * na forma canônica, para `10.0.0.07` e `10.0.0.7` caírem na mesma sala; sem + * endereço, ou com um inválido, sorteia um novo fora das faixas reservadas. + * + * ```ts + * resolveRoomAddress(location.pathname, () => crypto.getRandomValues(new Uint32Array(1))[0] ?? 0) + * ``` + */ +export function resolveRoomAddress(pathname: string, nextUint32: () => number): RoomLocation { + const typed = parseRoomAddress(pathname.replaceAll('/', '')) + const address = typed ?? generateRoomAddress(nextUint32) + return { address, path: `/${address}` } +} + +/** + * O servidor de jogo no mesmo host da página. Página em https exige `wss`: o + * navegador bloqueia `ws://` numa página segura como conteúdo misto. + * + * ```ts + * gameServerUrl(window.location, 2567) // 'wss://host:2567' + * ``` + */ +export function gameServerUrl( + location: { readonly protocol: string; readonly hostname: string }, + port: number, +): string { + const scheme = location.protocol === 'https:' ? 'wss' : 'ws' + return `${scheme}://${location.hostname}:${port}` +} From e6a18be2d492ec427103321594038d21f0b82ba7 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:32:54 -0300 Subject: [PATCH 037/139] fix: write a joining player's state before the first patch --- packages/server/src/gameRoom.ts | 6 ++++-- packages/server/src/roomSimulation.test.ts | 21 +++++++++++++++++++++ packages/server/src/roomSimulation.ts | 22 ++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/server/src/gameRoom.ts b/packages/server/src/gameRoom.ts index 2c53f276..b31e93ef 100644 --- a/packages/server/src/gameRoom.ts +++ b/packages/server/src/gameRoom.ts @@ -2,9 +2,9 @@ import { type Client, Room } from '@colyseus/core' import type { GameplayConfig, StaticBox } from '@shobu/core' import { GameRoomState, PlayerInput, PlayerState } from './gameRoomState.ts' import { + admitPlayer, assertCanonicalAddress, createRoomSimulation, - joinRoom, leaveRoom, type SpawnPoints, stepRoom, @@ -49,8 +49,10 @@ export function createGameRoom(deps: GameRoomDeps) { } override onJoin(client: Client): void { - joinRoom(this.#simulation, client.sessionId, deps.spawns, deps.config) this.state.players.set(client.sessionId, new PlayerState()) + admitPlayer(this.#simulation, client.sessionId, deps.spawns, deps.config, (id) => + this.state.players.get(id), + ) } override onLeave(client: Client): void { diff --git a/packages/server/src/roomSimulation.test.ts b/packages/server/src/roomSimulation.test.ts index 2bd66237..c4a03954 100644 --- a/packages/server/src/roomSimulation.test.ts +++ b/packages/server/src/roomSimulation.test.ts @@ -13,6 +13,7 @@ import { } from '@shobu/core' import { describe, expect, it } from 'vitest' import { + admitPlayer, assertCanonicalAddress, createRoomSimulation, FORWARD_AIM, @@ -87,6 +88,26 @@ describe('joinRoom sem spawn', () => { }) }) +describe('admitPlayer', () => { + /** + * Regressão: o estado nascia zerado e só era preenchido no tick seguinte. Se + * o patch saísse antes, os outros viam o jogador no centro da arena e depois + * saltando 26 m para o spawn. + */ + it('escreve o estado de quem entra antes do primeiro tick', () => { + const room = createRoomSimulation() + const state = emptyState() + admitPlayer(room, 'ana', GREYBOX_SPAWN_POINTS_M, config, () => state) + const [x0, , z0] = spawnFeetM( + GREYBOX_SPAWN_POINTS_M[0] ?? [0, 0, 0], + config.collision.capsuleHeightM, + ) + expect(state.x).toBe(quantizePositionM(x0)) + expect(state.z).toBe(quantizePositionM(z0)) + expect(room.match.tick).toBe(0) + }) +}) + describe('leaveRoom', () => { it('tira o jogador da partida e da sala', () => { const room = createRoomSimulation() diff --git a/packages/server/src/roomSimulation.ts b/packages/server/src/roomSimulation.ts index a248979f..cb7d1adc 100644 --- a/packages/server/src/roomSimulation.ts +++ b/packages/server/src/roomSimulation.ts @@ -81,6 +81,28 @@ export function joinRoom( return player } +/** + * Entra e já escreve o estado de todos. O primeiro patch com o jogador novo + * pode sair antes do próximo tick; sem esta escrita ele levaria zeros, e os + * outros clientes veriam o jogador no centro da arena saltando para o spawn. + * + * ```ts + * state.players.set(sessionId, new PlayerState()) + * admitPlayer(room, sessionId, spawns, config, (id) => state.players.get(id)) + * ``` + */ +export function admitPlayer( + room: RoomSimulation, + playerId: string, + spawns: SpawnPoints, + config: GameplayConfig, + stateOf: (playerId: string) => PlayerStateFields | undefined, +): MatchPlayer { + const player = joinRoom(room, playerId, spawns, config) + writeRoomState(room, stateOf) + return player +} + export function leaveRoom(room: RoomSimulation, playerId: string): void { leaveMatch(room.match, playerId) room.seats.delete(playerId) From 170e445d7943a475f1165d386eac8fbe38406699 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:32:55 -0300 Subject: [PATCH 038/139] feat: link the client to the game server with interpolated remotes --- package-lock.json | 36 +++++++- packages/client/package.json | 1 + packages/client/src/net/gameLink.ts | 87 +++++++++++++++++++ .../client/src/net/remotePlayerView.test.ts | 46 ++++++++++ packages/client/src/net/remotePlayerView.ts | 77 ++++++++++++++++ vitest.config.ts | 1 + 6 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 packages/client/src/net/gameLink.ts create mode 100644 packages/client/src/net/remotePlayerView.test.ts create mode 100644 packages/client/src/net/remotePlayerView.ts diff --git a/package-lock.json b/package-lock.json index a3ea1361..b228afee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -371,6 +371,37 @@ } } }, + "node_modules/@colyseus/sdk": { + "version": "0.18.4", + "resolved": "https://registry.npmjs.org/@colyseus/sdk/-/sdk-0.18.4.tgz", + "integrity": "sha512-abCF8The7tACU5IM3yNKDnLfhqZQRBgw00OXxSHS0LuXfHJWFSc8mpDUJ40e0TEQPFPpKsZKzT+cnd5jQCQXvA==", + "license": "MIT", + "dependencies": { + "@colyseus/better-call": "^1.3.1", + "@colyseus/schema": "^5.0.8", + "@colyseus/shared-types": "^0.18.3", + "msgpackr": "^2.0.1", + "tslib": "^2.1.0", + "ws": "^8.13.0" + }, + "engines": { + "node": ">= 12.x" + }, + "funding": { + "url": "https://github.com/sponsors/endel" + }, + "peerDependencies": { + "@colyseus/core": "0.18.x" + }, + "peerDependenciesMeta": { + "@colyseus/better-call": { + "optional": true + }, + "@colyseus/core": { + "optional": true + } + } + }, "node_modules/@colyseus/shared-types": { "version": "0.18.3", "resolved": "https://registry.npmjs.org/@colyseus/shared-types/-/shared-types-0.18.3.tgz", @@ -3541,9 +3572,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-is": { "version": "2.1.0", @@ -3834,6 +3863,7 @@ "dependencies": { "@babylonjs/core": "9.23.0", "@babylonjs/loaders": "9.23.0", + "@colyseus/sdk": "0.18.4", "@shobu/core": "*" }, "devDependencies": { diff --git a/packages/client/package.json b/packages/client/package.json index 5af83c92..9893f133 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -11,6 +11,7 @@ "dependencies": { "@babylonjs/core": "9.23.0", "@babylonjs/loaders": "9.23.0", + "@colyseus/sdk": "0.18.4", "@shobu/core": "*" }, "devDependencies": { diff --git a/packages/client/src/net/gameLink.ts b/packages/client/src/net/gameLink.ts new file mode 100644 index 00000000..4169ff70 --- /dev/null +++ b/packages/client/src/net/gameLink.ts @@ -0,0 +1,87 @@ +import { Client, type Room } from '@colyseus/sdk' +import { Predict } from '@colyseus/sdk/predict' +import type { WireInput } from '@shobu/core' +import type { MutableWireInput } from './encodeTickInput.ts' +import type { PlayerSnapshotFields } from './remotePlayerView.ts' + +/** + * O canal de jogo, do lado do cliente: a interface do projeto sobre o + * `@colyseus/sdk`. Adapter de rede; quem decide alguma coisa com o que chega + * daqui está em `remotePlayerView.ts` e no controller. + */ +export interface GameLink { + readonly sessionId: string + /** O input de um tick, na ordem em que a predição o usou. */ + sendTickInput(input: WireInput): void + /** Avança a interpolação até `nowMs` e visita cada outro jogador com o valor interpolado. */ + forEachRemote( + nowMs: number, + visit: (playerId: string, fields: PlayerSnapshotFields) => void, + ): void + leave(): Promise +} + +const INTERPOLATED_FIELDS = ['x', 'y', 'z', 'aimX', 'aimY', 'aimZ'] as const + +/** + * Entra na sala do endereço. Sem reconexão: o sdk vem com ela ligada, e a sala + * não guarda a vaga de quem cai (docs/rede, "Reconexão"). + * + * ```ts + * const link = await openGameLink(gameServerUrl(location, 2567), address, interpolationDelayMs(config)) + * ``` + */ +export async function openGameLink( + serverUrl: string, + address: string, + interpolationDelayMs: number, +): Promise { + const room = await new Client(serverUrl).joinOrCreate('arena', { address }) + room.reconnection.enabled = false + return { + sessionId: room.sessionId, + sendTickInput: createInputSender(room), + forEachRemote: createRemoteReader(room, interpolationDelayMs), + leave: async () => { + await room.leave() + }, + } +} + +/** Copia campo a campo no mesmo objeto do sdk: nada é alocado por tick. */ +function createInputSender(room: Room): GameLink['sendTickInput'] { + const input = room.input() + const data = input.data as MutableWireInput + return (wire) => { + data.wishX = wire.wishX + data.wishZ = wire.wishZ + data.buttons = wire.buttons + data.aimX = wire.aimX + data.aimY = wire.aimY + data.aimZ = wire.aimZ + input.send() + } +} + +/** + * O `Predict` da 0.18 interpola cada jogador no relógio do servidor, com o + * atraso do buffer de interpolação. A postura não se interpola: é um byte de + * estado, e o meio do caminho entre "em pé" e "no slide" não existe. + */ +function createRemoteReader(room: Room, delayMs: number): GameLink['forEachRemote'] { + const predict = Predict.get(room, { mode: 'lerp', delay: delayMs }) + predict.attachAll('players', { fields: [...INTERPOLATED_FIELDS] }) + // o cliente não importa o schema do servidor: o estado chega descrito no + // handshake, e é só aqui que ele ganha o formato de `gameRoomState.ts`. + const players = (): Map => room.state.players + const scratch = { x: 0, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: 0, stance: 0 } + return (nowMs, visit) => { + predict.tick(nowMs) + for (const [playerId, player] of players()) { + if (playerId === room.sessionId) continue + for (const field of INTERPOLATED_FIELDS) scratch[field] = predict.value(player, field) + scratch.stance = player.stance + visit(playerId, scratch) + } + } +} diff --git a/packages/client/src/net/remotePlayerView.test.ts b/packages/client/src/net/remotePlayerView.test.ts new file mode 100644 index 00000000..6e20be83 --- /dev/null +++ b/packages/client/src/net/remotePlayerView.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs' +import { encodeStance, parseGameplayConfig } from '@shobu/core' +import { describe, expect, it } from 'vitest' +import { + createRemotePlayerView, + interpolationDelayMs, + readRemotePlayer, +} from './remotePlayerView.ts' + +const SHIPPED_CONFIG_URL = new URL('../../../../config/gameplay.json', import.meta.url) +const config = parseGameplayConfig(JSON.parse(readFileSync(SHIPPED_CONFIG_URL, 'utf8'))) + +describe('readRemotePlayer', () => { + it('converte o snapshot em metros, mira unitária e postura', () => { + const view = readRemotePlayer( + { + x: 26000, + y: 7500, + z: -12345, + aimX: 32767, + aimY: 0, + aimZ: 0, + stance: encodeStance('sliding', true), + }, + createRemotePlayerView(), + ) + expect(view.feetM).toEqual({ x: 26, y: 7.5, z: -12.345 }) + expect(view.aim).toEqual({ x: 1, y: 0, z: 0 }) + expect(view).toMatchObject({ stance: 'sliding', grounded: true }) + }) + + /** Entre dois patches o `Predict` devolve o meio do caminho; a conversão não pode arredondar. */ + it('aceita posição fracionária da interpolação', () => { + const view = readRemotePlayer( + { x: 1000.5, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: -32767, stance: 0 }, + createRemotePlayerView(), + ) + expect(view.feetM.x).toBeCloseTo(1.0005, 9) + }) +}) + +describe('interpolationDelayMs', () => { + it('é três intervalos de patch', () => { + expect(interpolationDelayMs(config)).toBe((3 * 1000) / config.simulation.patchHz) + }) +}) diff --git a/packages/client/src/net/remotePlayerView.ts b/packages/client/src/net/remotePlayerView.ts new file mode 100644 index 00000000..cded7753 --- /dev/null +++ b/packages/client/src/net/remotePlayerView.ts @@ -0,0 +1,77 @@ +import { + decodeStance, + dequantizeAim, + dequantizePositionM, + type GameplayConfig, + type Stance, + type Vector3, + type WireAim, +} from '@shobu/core' + +/** + * Os campos do snapshot de um jogador, já interpolados pelo `Predict` do sdk. + * São os inteiros do `wireFormat` misturados entre dois patches, então podem + * vir fracionários; a conversão para metros não se importa. + */ +export interface PlayerSnapshotFields extends WireAim { + readonly x: number + readonly y: number + readonly z: number + readonly stance: number +} + +/** Como o render enxerga outro jogador, em metros e direção unitária. */ +export interface RemotePlayerView { + readonly feetM: Vector3 + readonly aim: Vector3 + stance: Stance + grounded: boolean +} + +export function createRemotePlayerView(): RemotePlayerView { + return { + feetM: { x: 0, y: 0, z: 0 }, + aim: { x: 0, y: 0, z: -1 }, + stance: 'standing', + grounded: true, + } +} + +/** + * Converte o snapshot interpolado em pé, mira e postura, escrevendo em `out`: + * roda por jogador remoto em todo quadro. + * + * ```ts + * readRemotePlayer(fields, view) + * avatar.root.position.set(view.feetM.x, view.feetM.y, view.feetM.z) + * ``` + */ +export function readRemotePlayer( + fields: PlayerSnapshotFields, + out: RemotePlayerView, +): RemotePlayerView { + out.feetM.x = dequantizePositionM(fields.x) + out.feetM.y = dequantizePositionM(fields.y) + out.feetM.z = dequantizePositionM(fields.z) + dequantizeAim(fields, out.aim) + const { stance, grounded } = decodeStance(fields.stance) + out.stance = stance + out.grounded = grounded + return out +} + +/** + * Quantos patches o cliente desenha atrasado: três intervalos. O pior + * intervalo entre patches com tick novo é de dois, e o terceiro cobre o jitter + * do perfil congestionado da ADR 0006 (docs/rede, "Buffer de interpolação"). + */ +const INTERPOLATION_PATCH_INTERVALS = 3 + +/** + * ```ts + * interpolationDelayMs(config) // 46,875 a 64 Hz + * ``` + */ +export function interpolationDelayMs(config: GameplayConfig): number { + return (INTERPOLATION_PATCH_INTERVALS * 1000) / config.simulation.patchHz +} diff --git a/vitest.config.ts b/vitest.config.ts index b30d4168..7f34566f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,7 @@ import { defineConfig } from 'vitest/config' */ export const IO_ADAPTERS: readonly string[] = [ 'packages/client/src/main.ts', + 'packages/client/src/net/gameLink.ts', 'packages/client/src/arena/buildGreyboxArena.ts', 'packages/client/src/audio/driveArenaAudio.ts', 'packages/client/src/audio/musicStream.ts', From dd4ba3675da14b2abc863ad93483c59fc703494a Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:34:14 -0300 Subject: [PATCH 039/139] feat: add a late-bound network port and a remote player roster --- packages/client/src/net/networkPort.test.ts | 58 ++++++++++++++++++++ packages/client/src/net/networkPort.ts | 38 +++++++++++++ packages/client/src/net/remoteRoster.test.ts | 36 ++++++++++++ packages/client/src/net/remoteRoster.ts | 42 ++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 packages/client/src/net/networkPort.test.ts create mode 100644 packages/client/src/net/networkPort.ts create mode 100644 packages/client/src/net/remoteRoster.test.ts create mode 100644 packages/client/src/net/remoteRoster.ts diff --git a/packages/client/src/net/networkPort.test.ts b/packages/client/src/net/networkPort.test.ts new file mode 100644 index 00000000..29145c7f --- /dev/null +++ b/packages/client/src/net/networkPort.test.ts @@ -0,0 +1,58 @@ +import type { WireInput } from '@shobu/core' +import { describe, expect, it } from 'vitest' +import type { GameLink } from './gameLink.ts' +import { createNetworkPort } from './networkPort.ts' +import type { PlayerSnapshotFields } from './remotePlayerView.ts' + +const WALK: WireInput = { wishX: 0, wishZ: -127, buttons: 0, aimX: 0, aimY: 0, aimZ: -32767 } +const AT_SPAWN: PlayerSnapshotFields = { + x: 26000, + y: 0, + z: 26000, + aimX: 0, + aimY: 0, + aimZ: -32767, + stance: 4, +} + +/** A sala do colyseus, em memória: guarda o que recebeu e devolve um jogador remoto. */ +class FakeGameLink implements GameLink { + readonly sessionId = 'eu' + readonly sent: WireInput[] = [] + + sendTickInput(input: WireInput): void { + this.sent.push({ ...input }) + } + + forEachRemote( + _nowMs: number, + visit: (playerId: string, fields: PlayerSnapshotFields) => void, + ): void { + visit('outro', AT_SPAWN) + } + + async leave(): Promise {} +} + +describe('createNetworkPort', () => { + it('roda sozinho antes da conexão, sem erro e sem remoto', () => { + const port = createNetworkPort() + const visited: string[] = [] + port.sendTickInput(WALK) + port.forEachRemote(0, (id) => visited.push(id)) + expect(visited).toEqual([]) + expect(port.connected).toBe(false) + }) + + it('passa a falar com a sala depois do attach', () => { + const port = createNetworkPort() + const link = new FakeGameLink() + port.attach(link) + port.sendTickInput(WALK) + const visited: string[] = [] + port.forEachRemote(0, (id) => visited.push(id)) + expect(link.sent).toEqual([WALK]) + expect(visited).toEqual(['outro']) + expect(port.connected).toBe(true) + }) +}) diff --git a/packages/client/src/net/networkPort.ts b/packages/client/src/net/networkPort.ts new file mode 100644 index 00000000..2cbd8060 --- /dev/null +++ b/packages/client/src/net/networkPort.ts @@ -0,0 +1,38 @@ +import type { WireInput } from '@shobu/core' +import type { GameLink } from './gameLink.ts' +import type { PlayerSnapshotFields } from './remotePlayerView.ts' + +/** + * O que o renderer usa da rede. A cena começa antes da conexão, porque entrar + * na sala é assíncrono e o pilar 2 não espera por ela: até o `attach`, o jogo + * roda sozinho, e depois os mesmos métodos passam a falar com a sala. + */ +export interface NetworkPort { + sendTickInput(input: WireInput): void + forEachRemote( + nowMs: number, + visit: (playerId: string, fields: PlayerSnapshotFields) => void, + ): void + attach(link: GameLink): void + readonly connected: boolean +} + +/** + * ```ts + * const network = createNetworkPort() + * openGameLink(url, address, delayMs).then((link) => network.attach(link)) + * ``` + */ +export function createNetworkPort(): NetworkPort { + let link: GameLink | undefined + return { + sendTickInput: (input) => link?.sendTickInput(input), + forEachRemote: (nowMs, visit) => link?.forEachRemote(nowMs, visit), + attach: (next) => { + link = next + }, + get connected() { + return link !== undefined + }, + } +} diff --git a/packages/client/src/net/remoteRoster.test.ts b/packages/client/src/net/remoteRoster.test.ts new file mode 100644 index 00000000..d57d2c78 --- /dev/null +++ b/packages/client/src/net/remoteRoster.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { createRemoteRoster } from './remoteRoster.ts' + +describe('createRemoteRoster', () => { + it('avisa só na primeira vez que o jogador aparece', () => { + const roster = createRemoteRoster() + roster.beginFrame() + expect(roster.visit('ana')).toBe(true) + roster.endFrame() + roster.beginFrame() + expect(roster.visit('ana')).toBe(false) + }) + + it('devolve quem não apareceu no quadro', () => { + const roster = createRemoteRoster() + roster.beginFrame() + roster.visit('ana') + roster.visit('bia') + roster.endFrame() + roster.beginFrame() + roster.visit('ana') + expect(roster.endFrame()).toEqual(['bia']) + }) + + /** Quem sai e volta com o mesmo id é jogador novo: o avatar antigo já foi removido. */ + it('trata como novo quem saiu e voltou', () => { + const roster = createRemoteRoster() + roster.beginFrame() + roster.visit('ana') + roster.endFrame() + roster.beginFrame() + roster.endFrame() + roster.beginFrame() + expect(roster.visit('ana')).toBe(true) + }) +}) diff --git a/packages/client/src/net/remoteRoster.ts b/packages/client/src/net/remoteRoster.ts new file mode 100644 index 00000000..921d7d2c --- /dev/null +++ b/packages/client/src/net/remoteRoster.ts @@ -0,0 +1,42 @@ +/** + * Quem entrou e quem saiu entre dois quadros. O renderer visita os jogadores + * remotos a cada quadro; o que for novo ganha avatar, e quem não apareceu no + * quadro saiu da sala e perde o dele. + */ +export interface RemoteRoster { + beginFrame(): void + /** `true` na primeira vez que o jogador aparece. */ + visit(playerId: string): boolean + /** Quem estava no quadro anterior e não foi visitado neste. */ + endFrame(): readonly string[] +} + +/** + * ```ts + * roster.beginFrame() + * network.forEachRemote(now, (id) => { if (roster.visit(id)) spawnAvatar(id) }) + * for (const id of roster.endFrame()) removeAvatar(id) + * ``` + */ +export function createRemoteRoster(): RemoteRoster { + const present = new Set() + const visited = new Set() + const gone: string[] = [] + return { + beginFrame: () => visited.clear(), + visit: (playerId) => { + visited.add(playerId) + if (present.has(playerId)) return false + present.add(playerId) + return true + }, + endFrame: () => { + gone.length = 0 + for (const playerId of present) { + if (!visited.has(playerId)) gone.push(playerId) + } + for (const playerId of gone) present.delete(playerId) + return gone + }, + } +} From 424b831947b333622788d95213ad5e84619b1375 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:43:14 -0300 Subject: [PATCH 040/139] feat: adopt the spawn the game server chose --- .../src/controller/localCharacter.test.ts | 10 +++++++++ .../client/src/controller/localCharacter.ts | 21 +++++++++++++++++++ packages/client/src/net/gameLink.ts | 4 ++++ packages/client/src/net/networkPort.test.ts | 17 +++++++++++++++ packages/client/src/net/networkPort.ts | 19 ++++++++++++++++- 5 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/client/src/controller/localCharacter.test.ts b/packages/client/src/controller/localCharacter.test.ts index 92e91678..4577e81d 100644 --- a/packages/client/src/controller/localCharacter.test.ts +++ b/packages/client/src/controller/localCharacter.test.ts @@ -76,4 +76,14 @@ describe('pendingTicks e stepOnce', () => { const character = createLocalCharacter(config, SPAWN_FEET, boxes) expect(character.tickDurationS).toBeCloseTo(1 / config.simulation.tickHz) }) + + /** O spawn é do servidor: o cliente adota sem desenhar a viagem até lá. */ + it('teleporta parado, sem interpolar a viagem', () => { + const character = createLocalCharacter(config, SPAWN_FEET, boxes) + character.advance(0.5, FORWARD) + character.teleport({ x: -26, y: 0, z: -26 }) + expect(character.current.position).toEqual({ x: -26, y: 0, z: -26 }) + expect(character.current.velocity).toEqual({ x: 0, y: 0, z: 0 }) + expect(character.previous.position).toEqual({ x: -26, y: 0, z: -26 }) + }) }) diff --git a/packages/client/src/controller/localCharacter.ts b/packages/client/src/controller/localCharacter.ts index c004d74b..5eb24f1f 100644 --- a/packages/client/src/controller/localCharacter.ts +++ b/packages/client/src/controller/localCharacter.ts @@ -49,6 +49,11 @@ export interface LocalCharacter { interpolationAlpha(): number /** Onde o olho está neste quadro, interpolado. Escreve em `out` e o devolve. */ eyePosition(out: Vector3): Vector3 + /** + * Põe o personagem parado em outro lugar, sem interpolar a viagem: é como o + * cliente adota o spawn que o servidor de jogo escolheu. + */ + teleport(feetM: Readonly): void } /** @@ -99,9 +104,25 @@ export function createLocalCharacter( }, interpolationAlpha: () => clock.pendingS / clock.tickDurationS, eyePosition: (out) => eyePosition(current, previous, clock, out), + teleport: (feetM) => teleport(current, previous, feetM), } } +/** Parado no pé novo, e o tick anterior igual a ele: a interpolação não desenha a viagem. */ +function teleport( + current: CharacterState, + previous: CharacterState, + feetM: Readonly, +): void { + current.position.x = feetM.x + current.position.y = feetM.y + current.position.z = feetM.z + current.velocity.x = 0 + current.velocity.y = 0 + current.velocity.z = 0 + copyCharacterState(current, previous) +} + /** * O olho fica a `EYE_HEIGHT_RATIO` da cápsula — e a cápsula encolhe agachado * e no slide, então a altura do olho também interpola: é o que faz a câmera diff --git a/packages/client/src/net/gameLink.ts b/packages/client/src/net/gameLink.ts index 4169ff70..a816187a 100644 --- a/packages/client/src/net/gameLink.ts +++ b/packages/client/src/net/gameLink.ts @@ -18,6 +18,8 @@ export interface GameLink { nowMs: number, visit: (playerId: string, fields: PlayerSnapshotFields) => void, ): void + /** O snapshot do próprio jogador, cru, ou `undefined` antes do primeiro estado. */ + ownSnapshot(): PlayerSnapshotFields | undefined leave(): Promise } @@ -42,6 +44,8 @@ export async function openGameLink( sessionId: room.sessionId, sendTickInput: createInputSender(room), forEachRemote: createRemoteReader(room, interpolationDelayMs), + ownSnapshot: () => + (room.state.players as Map).get(room.sessionId), leave: async () => { await room.leave() }, diff --git a/packages/client/src/net/networkPort.test.ts b/packages/client/src/net/networkPort.test.ts index 29145c7f..6889bc7c 100644 --- a/packages/client/src/net/networkPort.test.ts +++ b/packages/client/src/net/networkPort.test.ts @@ -31,6 +31,12 @@ class FakeGameLink implements GameLink { visit('outro', AT_SPAWN) } + own: PlayerSnapshotFields | undefined = undefined + + ownSnapshot(): PlayerSnapshotFields | undefined { + return this.own + } + async leave(): Promise {} } @@ -55,4 +61,15 @@ describe('createNetworkPort', () => { expect(visited).toEqual(['outro']) expect(port.connected).toBe(true) }) + + it('entrega o spawn do servidor uma vez, quando o próprio estado chega', () => { + const port = createNetworkPort() + const link = new FakeGameLink() + expect(port.takeOwnSpawn()).toBeUndefined() + port.attach(link) + expect(port.takeOwnSpawn()).toBeUndefined() + link.own = AT_SPAWN + expect(port.takeOwnSpawn()).toEqual({ x: 26, y: 0, z: 26 }) + expect(port.takeOwnSpawn()).toBeUndefined() + }) }) diff --git a/packages/client/src/net/networkPort.ts b/packages/client/src/net/networkPort.ts index 2cbd8060..4a9c1f1a 100644 --- a/packages/client/src/net/networkPort.ts +++ b/packages/client/src/net/networkPort.ts @@ -1,4 +1,4 @@ -import type { WireInput } from '@shobu/core' +import { dequantizePositionM, type Vector3, type WireInput } from '@shobu/core' import type { GameLink } from './gameLink.ts' import type { PlayerSnapshotFields } from './remotePlayerView.ts' @@ -15,6 +15,11 @@ export interface NetworkPort { ): void attach(link: GameLink): void readonly connected: boolean + /** + * O spawn que o servidor escolheu, uma vez só, assim que o primeiro estado do + * próprio jogador chega. `undefined` antes disso e depois de entregue. + */ + takeOwnSpawn(): Vector3 | undefined } /** @@ -25,11 +30,23 @@ export interface NetworkPort { */ export function createNetworkPort(): NetworkPort { let link: GameLink | undefined + let spawnPending = false return { sendTickInput: (input) => link?.sendTickInput(input), forEachRemote: (nowMs, visit) => link?.forEachRemote(nowMs, visit), attach: (next) => { link = next + spawnPending = true + }, + takeOwnSpawn: () => { + const own = spawnPending ? link?.ownSnapshot() : undefined + if (!own) return undefined + spawnPending = false + return { + x: dequantizePositionM(own.x), + y: dequantizePositionM(own.y), + z: dequantizePositionM(own.z), + } }, get connected() { return link !== undefined From caea48092ba7e7f6a7267a5fd2dfceb833853071 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:43:15 -0300 Subject: [PATCH 041/139] feat: join the room from the url and show the other players --- packages/client/src/main.ts | 43 ++++++++++- packages/client/src/renderer/arenaRenderer.ts | 7 ++ .../src/renderer/babylonArenaRenderer.ts | 41 ++++++++++- .../client/src/renderer/showRemotePlayers.ts | 72 +++++++++++++++++++ vitest.config.ts | 1 + 5 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 packages/client/src/renderer/showRemotePlayers.ts diff --git a/packages/client/src/main.ts b/packages/client/src/main.ts index 944a47c5..1eb0c107 100644 --- a/packages/client/src/main.ts +++ b/packages/client/src/main.ts @@ -20,6 +20,10 @@ import { buildTagline } from './hud/tagline.ts' import { createTerminalPrinter, type TerminalPrinter } from './hud/terminalPrinter.ts' import { describeTimeToControl, timeToControlMs } from './instrumentation/timeToPlayerControl.ts' import { createMenuState, type MenuCommand, selectRow, stepMenu } from './menu/mainMenuModel.ts' +import { openGameLink } from './net/gameLink.ts' +import { createNetworkPort, type NetworkPort } from './net/networkPort.ts' +import { interpolationDelayMs } from './net/remotePlayerView.ts' +import { gameServerUrl, resolveRoomAddress } from './net/roomLocation.ts' import type { ArenaRenderer } from './renderer/arenaRenderer.ts' import { createBabylonArenaRenderer } from './renderer/babylonArenaRenderer.ts' import { createLivePlayerSettings, type LivePlayerSettings } from './settings/livePlayerSettings.ts' @@ -33,6 +37,9 @@ import { /** Servido pelo `gameplayConfigPlugin` a partir de `config/gameplay.json`. */ const GAMEPLAY_CONFIG_URL = '/gameplay.json' +/** A porta do servidor de jogo, a da Tabela 1 de `docs/rede/arquitetura-de-rede.md`. */ +const GAME_SERVER_PORT = 2567 + const waitMs = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) interface Intro { @@ -51,7 +58,9 @@ async function boot(): Promise { const store = createPlayerSettingsStore(settingsStorage()) const settings = createLivePlayerSettings(config.camera, store.read()) const audio = mountAudio(store.read()) - const renderer = createArenaRenderer(config, hud, settings, audio) + const network = createNetworkPort() + const renderer = createArenaRenderer(config, hud, settings, audio, network) + connectToRoom(config, network) reportControlTiming(renderer, overlay) preloadMedalIcons(renderer) renderer.start() @@ -126,6 +135,7 @@ function createArenaRenderer( hud: ArenaHud, settings: LivePlayerSettings, audio: GameAudio, + network: NetworkPort, ): ArenaRenderer { const canvas = document.querySelector('#arena-canvas') if (!canvas) throw new Error("querySelector('#arena-canvas') não achou o canvas da arena") @@ -139,9 +149,40 @@ function createArenaRenderer( hud, settings, audio, + network, }) } +/** + * Entra na sala do endereço da url, sem segurar o boot: a arena roda sozinha + * até a conexão chegar, e falha de conexão vira log e jogo sem sala, não tela + * de erro. A url passa a mostrar o endereço canônico, que é o link para chamar + * alguém para a mesma sala. + */ +function connectToRoom(config: GameplayConfig, network: NetworkPort): void { + const room = resolveRoomAddress(window.location.pathname, randomUint32) + window.history.replaceState(null, '', room.path) + const url = gameServerUrl(window.location, GAME_SERVER_PORT) + openGameLink(url, room.address, interpolationDelayMs(config)) + .then((link) => { + network.attach(link) + window.addEventListener('pagehide', () => void link.leave()) + console.info( + JSON.stringify({ event: 'room.joined', address: room.address, sessionId: link.sessionId }), + ) + }) + .catch((reason: unknown) => { + const message = reason instanceof Error ? reason.message : String(reason) + console.error( + JSON.stringify({ event: 'room.join-failed', address: room.address, url, message }), + ) + }) +} + +function randomUint32(): number { + return crypto.getRandomValues(new Uint32Array(1))[0] ?? 0 +} + interface MenuWiring { readonly overlay: BootOverlay readonly renderer: ArenaRenderer diff --git a/packages/client/src/renderer/arenaRenderer.ts b/packages/client/src/renderer/arenaRenderer.ts index a33d66fd..96f6589d 100644 --- a/packages/client/src/renderer/arenaRenderer.ts +++ b/packages/client/src/renderer/arenaRenderer.ts @@ -2,6 +2,7 @@ import type { GameplayConfig, GreyboxBlock } from '@shobu/core' import type { GameAudio } from '../audio/gameAudio.ts' import type { ArenaHud } from '../hud/arenaHud.ts' import type { SessionMode } from '../hud/matchClock.ts' +import type { NetworkPort } from '../net/networkPort.ts' import type { LivePlayerSettings } from '../settings/livePlayerSettings.ts' /** @@ -53,4 +54,10 @@ export interface ArenaRendererOptions { * a mesma sessão que o visor. */ readonly audio?: GameAudio + /** + * O canal de jogo. Opcional pela mesma razão do `hud`: sem ele o jogo roda + * sozinho, contra os bonecos. Com ele, o input de cada tick vai para a sala + * e os outros jogadores aparecem na arena. + */ + readonly network?: NetworkPort } diff --git a/packages/client/src/renderer/babylonArenaRenderer.ts b/packages/client/src/renderer/babylonArenaRenderer.ts index bc17edb7..04d41040 100644 --- a/packages/client/src/renderer/babylonArenaRenderer.ts +++ b/packages/client/src/renderer/babylonArenaRenderer.ts @@ -14,7 +14,11 @@ import { poseOfLocalCharacter, thirdPersonClipFor, } from '../character/thirdPersonClips.ts' -import { type ArenaSession, createArenaSession } from '../controller/arenaSession.ts' +import { + type ArenaSession, + type ArenaSessionOptions, + createArenaSession, +} from '../controller/arenaSession.ts' import { releaseAllButtons, trackHeldButtons } from '../controller/heldButtons.ts' import { type HeldKeys, @@ -27,6 +31,8 @@ import { createWeaponInput } from '../controller/weaponInputFrom.ts' import { createMovementInput } from '../controller/wishDirection.ts' import { type ArenaHud, createSilentHud } from '../hud/arenaHud.ts' import type { SessionMode } from '../hud/matchClock.ts' +import { createWireInput, encodeTickInput } from '../net/encodeTickInput.ts' +import type { NetworkPort } from '../net/networkPort.ts' import { lightArena } from './arenaLighting.ts' import { arenaLightingSpec } from './arenaLightingSpec.ts' import type { ArenaRenderer, ArenaRendererOptions } from './arenaRenderer.ts' @@ -46,6 +52,7 @@ import { type SniperViewmodel, } from './loadSniperViewmodel.ts' import { createScopeZoom, scopeFovDeg } from './scopeZoom.ts' +import { showRemotePlayers } from './showRemotePlayers.ts' import { createTracerBeams } from './tracerBeams.ts' import { createViewBob, type ViewBob } from './viewBob.ts' import type { ClipTempo } from './viewmodelAnimator.ts' @@ -92,6 +99,7 @@ export function createBabylonArenaRenderer(options: ArenaRendererOptions): Arena dummyPostsM: trainingDummyPostsM(options.config.match.trainingDummies), movementInput, weaponInput, + onTick: sendTickInput(options), }) const { keyboard, viewBob } = attachLocalCharacter(engine, scene, camera, options, { character, @@ -100,6 +108,7 @@ export function createBabylonArenaRenderer(options: ArenaRendererOptions): Arena }) mirrorLocalCharacter(scene, options, character, keyboard.keys, () => engine.getDeltaTime()) showTrainingDummies(scene, options, session) + if (options.network) followNetwork(scene, options, options.network, character) const viewmodel = attachSniperViewmodel(scene, camera, options.config.weapon) const hud: ArenaHud = options.hud ?? createSilentHud() const zoom = createScopeZoom(!prefersReducedMotion()) @@ -301,6 +310,36 @@ function attachLocalCharacter( return { keyboard, viewBob } } +/** + * Os outros jogadores na arena e o spawn que o servidor escolheu para este: + * o spawn é autoridade do servidor, e o personagem local o adota no primeiro + * estado que chega. + */ +function followNetwork( + scene: Scene, + options: ArenaRendererOptions, + network: NetworkPort, + character: LocalCharacter, +): void { + showRemotePlayers(scene, options, network) + scene.onBeforeRenderObservable.add(() => { + const spawn = network.takeOwnSpawn() + if (spawn) character.teleport(spawn) + }) +} + +/** + * O input que a predição acabou de usar, no formato de fio, para a sala. O + * objeto de fio é um só, reaproveitado a cada tick. + */ +function sendTickInput(options: ArenaRendererOptions): NonNullable { + const wire = createWireInput() + return (movement, weapon, aimM) => { + if (!options.network) return + options.network.sendTickInput(encodeTickInput(movement, weapon, aimM, wire)) + } +} + function feetOfSpawn(options: ArenaRendererOptions): { x: number; y: number; z: number } { const [x, y, z] = spawnFeetM(options.spawnPointM, options.config.collision.capsuleHeightM) return { x, y, z } diff --git a/packages/client/src/renderer/showRemotePlayers.ts b/packages/client/src/renderer/showRemotePlayers.ts new file mode 100644 index 00000000..56fc0506 --- /dev/null +++ b/packages/client/src/renderer/showRemotePlayers.ts @@ -0,0 +1,72 @@ +import { Vector3 } from '@babylonjs/core/Maths/math.vector' +import type { Scene } from '@babylonjs/core/scene' +import { accentForIndex } from '../character/competitorPalette.ts' +import type { NetworkPort } from '../net/networkPort.ts' +import { + createRemotePlayerView, + type PlayerSnapshotFields, + readRemotePlayer, +} from '../net/remotePlayerView.ts' +import { createRemoteRoster } from '../net/remoteRoster.ts' +import type { ArenaRendererOptions } from './arenaRenderer.ts' +import { type CompetitorAvatar, loadCompetitorAvatar } from './loadCompetitorAvatar.ts' + +/** + * Os outros jogadores da sala, desenhados na posição que o `Predict` do sdk + * interpola. Adapter de babylon: quem decide quem entrou, quem saiu e onde + * cada um está são `remoteRoster.ts` e `remotePlayerView.ts`. + * + * Um avatar que termina de carregar depois de o jogador sair é descartado na + * chegada, e falha de carregamento vira log estruturado, como os bonecos. + */ +export function showRemotePlayers( + scene: Scene, + options: ArenaRendererOptions, + network: NetworkPort, +): void { + const roster = createRemoteRoster() + const avatars = new Map() + const view = createRemotePlayerView() + const facing = new Vector3() + let arrivals = 0 + const place = (playerId: string, fields: PlayerSnapshotFields): void => { + if (roster.visit(playerId)) loadRemoteAvatar(scene, options, playerId, avatars, arrivals++) + const avatar = avatars.get(playerId) + if (!avatar) return + readRemotePlayer(fields, view) + avatar.root.position.set(view.feetM.x, view.feetM.y, view.feetM.z) + facing.set(view.feetM.x + view.aim.x, view.feetM.y, view.feetM.z + view.aim.z) + avatar.root.lookAt(facing) + } + scene.onBeforeRenderObservable.add(() => { + roster.beginFrame() + network.forEachRemote(performance.now(), place) + for (const playerId of roster.endFrame()) { + avatars.get(playerId)?.root.dispose() + avatars.delete(playerId) + } + }) +} + +function loadRemoteAvatar( + scene: Scene, + options: ArenaRendererOptions, + playerId: string, + avatars: Map, + arrival: number, +): void { + avatars.set(playerId, undefined) + loadCompetitorAvatar(scene, { + eyeM: [0, options.config.collision.capsuleHeightM, 0], + capsuleHeightM: options.config.collision.capsuleHeightM, + accent: accentForIndex(arrival), + }) + .then((avatar) => { + if (avatars.has(playerId)) avatars.set(playerId, avatar) + else avatar.root.dispose() + }) + .catch((reason: unknown) => { + const message = reason instanceof Error ? reason.message : String(reason) + console.error(JSON.stringify({ event: 'remote-avatar-load-failed', playerId, message })) + }) +} diff --git a/vitest.config.ts b/vitest.config.ts index 7f34566f..0016b7e2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -39,6 +39,7 @@ export const IO_ADAPTERS: readonly string[] = [ 'packages/client/src/renderer/loadCompetitorAvatar.ts', 'packages/client/src/renderer/loadSniperViewmodel.ts', 'packages/client/src/renderer/paintCompetitor.ts', + 'packages/client/src/renderer/showRemotePlayers.ts', 'packages/client/src/renderer/tracerBeams.ts', 'packages/server/src/gameRoom.ts', 'packages/server/src/main.ts', From 5df3d28083d0da1f71d33b96ecf582ef4fe5c3ef Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:47:06 -0300 Subject: [PATCH 042/139] feat: drive each player by its own inputs with a credit bucket --- packages/core/src/index.ts | 2 +- packages/core/src/match/match.test.ts | 47 +++++-------------- packages/core/src/match/match.ts | 52 +++++++++------------ packages/core/src/wire/wireFormat.ts | 2 +- packages/server/src/roomSimulation.test.ts | 54 ++++++++++++++++++---- packages/server/src/roomSimulation.ts | 40 +++++++++++----- 6 files changed, 110 insertions(+), 87 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e2251b2c..f96922d8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,7 +20,7 @@ export type { export { GAMEPLAY_CONFIG_SPEC } from './config/gameplayConfigSpec.ts' export { parseGameplayConfig } from './config/parseGameplayConfig.ts' export type { Match, MatchPlayer } from './match/match.ts' -export { createMatch, joinMatch, leaveMatch, setPlayerInput, stepMatch } from './match/match.ts' +export { createMatch, joinMatch, leaveMatch, stepMatchPlayer } from './match/match.ts' export type { SeededRandom } from './math/seededRandom.ts' export { createSeededRandom, nextUnit } from './math/seededRandom.ts' export type { Vector3 } from './math/vector3.ts' diff --git a/packages/core/src/match/match.test.ts b/packages/core/src/match/match.test.ts index 397ad84d..865e722d 100644 --- a/packages/core/src/match/match.test.ts +++ b/packages/core/src/match/match.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' -import { copyCharacterState, createCharacterState } from '../movement/characterState.ts' +import { createCharacterState } from '../movement/characterState.ts' import { IDLE_INPUT } from '../movement/movementInput.ts' import { FLOOR, FORWARD, shippedConfig, tickDurationS } from '../movement/movementTestKit.ts' import { stepCharacter } from '../movement/stepCharacter.ts' -import { createMatch, joinMatch, leaveMatch, setPlayerInput, stepMatch } from './match.ts' +import { createMatch, joinMatch, leaveMatch, stepMatchPlayer } from './match.ts' const config = shippedConfig() const BOXES = [FLOOR] @@ -11,11 +11,10 @@ const ORIGIN = { x: 0, y: 0, z: 0 } const FAR = { x: 10, y: 0, z: 10 } describe('joinMatch', () => { - it('põe o jogador nos pés indicados, parado', () => { + it('põe o jogador nos pés indicados', () => { const match = createMatch() const player = joinMatch(match, 'ana', FAR, config) expect(player.character.position).toEqual(FAR) - expect(player.input).toEqual(IDLE_INPUT) expect(match.players.get('ana')).toBe(player) }) @@ -41,51 +40,29 @@ describe('leaveMatch', () => { }) }) -describe('setPlayerInput', () => { - it('recusa input de quem não está na partida, dizendo quem', () => { - const match = createMatch() - expect(() => setPlayerInput(match, 'bia', FORWARD)).toThrow(/'bia'/) - }) -}) - -describe('stepMatch', () => { - it('avança o tick da partida', () => { - const match = createMatch() - stepMatch(match, BOXES, config) - stepMatch(match, BOXES, config) - expect(match.tick).toBe(2) - }) - +describe('stepMatchPlayer', () => { /** - * O servidor e a predição do cliente têm que chegar no mesmo estado: cada - * jogador avança exatamente como `stepCharacter` sozinho, com o próprio input. + * A predição do cliente só reconcilia sem erro se N inputs aplicados dão o + * mesmo estado nos dois lados: um input é um tick, igual ao `stepCharacter`. */ - it('avança cada jogador com o próprio input, igual ao stepCharacter', () => { + it('avança só o jogador do input, igual ao stepCharacter', () => { const match = createMatch() joinMatch(match, 'ana', ORIGIN, config) - joinMatch(match, 'bia', FAR, config) - setPlayerInput(match, 'ana', FORWARD) + const bia = joinMatch(match, 'bia', FAR, config) const expectedAna = createCharacterState(ORIGIN, config) - const expectedBia = createCharacterState(FAR, config) const dtS = tickDurationS(config) for (let tick = 0; tick < 10; tick += 1) { - stepMatch(match, BOXES, config) + stepMatchPlayer(match, 'ana', FORWARD, BOXES, config) stepCharacter(expectedAna, FORWARD, BOXES, config, dtS) - stepCharacter(expectedBia, IDLE_INPUT, BOXES, config, dtS) } expect(match.players.get('ana')?.character).toEqual(expectedAna) - expect(match.players.get('bia')?.character).toEqual(expectedBia) + expect(bia.character).toEqual(createCharacterState(FAR, config)) }) - it('mantém o último input até chegar outro', () => { + it('recusa input de quem não está na partida, dizendo quem', () => { const match = createMatch() - const player = joinMatch(match, 'ana', ORIGIN, config) - setPlayerInput(match, 'ana', FORWARD) - stepMatch(match, BOXES, config) - const afterOne = copyCharacterState(player.character, createCharacterState(ORIGIN, config)) - stepMatch(match, BOXES, config) - expect(player.character.position.z).toBeGreaterThan(afterOne.position.z) + expect(() => stepMatchPlayer(match, 'bia', IDLE_INPUT, BOXES, config)).toThrow(/'bia'/) }) }) diff --git a/packages/core/src/match/match.ts b/packages/core/src/match/match.ts index 5094dead..f231f19e 100644 --- a/packages/core/src/match/match.ts +++ b/packages/core/src/match/match.ts @@ -2,32 +2,30 @@ import type { StaticBox } from '../collision/staticBox.ts' import type { GameplayConfig } from '../config/gameplayConfig.ts' import type { Vector3 } from '../math/vector3.ts' import { type CharacterState, createCharacterState } from '../movement/characterState.ts' -import { IDLE_INPUT, type MovementInput } from '../movement/movementInput.ts' +import type { MovementInput } from '../movement/movementInput.ts' import { stepCharacter } from '../movement/stepCharacter.ts' -/** - * Um jogador numa partida de vários: o personagem e o último input que chegou - * dele. O último input vale até chegar outro, porque um input atrasado na rede - * não pode fazer o personagem parar no servidor enquanto segue andando na - * predição do cliente. - */ +/** Um jogador numa partida de vários. */ export interface MatchPlayer { readonly playerId: string readonly character: CharacterState - input: MovementInput } /** * A partida no núcleo, sem rede: é o que o servidor de jogo autoriza e o que a * predição do cliente repete. Ordem de inserção no mapa é ordem de entrada. + * + * A partida é guiada por input, não por relógio: cada input de um jogador é um + * tick daquele jogador. É o que faz "o servidor aplicou N inputs" e "o cliente + * simulou N inputs" darem o mesmo estado, e a reconciliação do cliente depende + * disso. Quem decide quantos inputs cabem num tick do servidor é a sala. */ export interface Match { readonly players: Map - tick: number } export function createMatch(): Match { - return { players: new Map(), tick: 0 } + return { players: new Map() } } /** @@ -44,11 +42,7 @@ export function joinMatch( if (match.players.has(playerId)) { throw new Error(`joinMatch recebeu '${playerId}', que já está na partida; esperado id novo`) } - const player: MatchPlayer = { - playerId, - character: createCharacterState(feetM, config), - input: IDLE_INPUT, - } + const player: MatchPlayer = { playerId, character: createCharacterState(feetM, config) } match.players.set(playerId, player) return player } @@ -57,25 +51,23 @@ export function leaveMatch(match: Match, playerId: string): void { match.players.delete(playerId) } -export function setPlayerInput(match: Match, playerId: string, input: MovementInput): void { - const player = match.players.get(playerId) - if (!player) { - throw new Error(`setPlayerInput recebeu '${playerId}', que não está na partida`) - } - player.input = input -} - /** - * Um tick da partida: cada jogador avança com o próprio input. + * Um input de um jogador: um tick do personagem dele. * * ```ts - * stepMatch(match, arenaBoxes, config) + * stepMatchPlayer(match, sessionId, decodeMovementInput(wire, false), arenaBoxes, config) * ``` */ -export function stepMatch(match: Match, boxes: readonly StaticBox[], config: GameplayConfig): void { - const dtS = 1 / config.simulation.tickHz - for (const player of match.players.values()) { - stepCharacter(player.character, player.input, boxes, config, dtS) +export function stepMatchPlayer( + match: Match, + playerId: string, + input: MovementInput, + boxes: readonly StaticBox[], + config: GameplayConfig, +): void { + const player = match.players.get(playerId) + if (!player) { + throw new Error(`stepMatchPlayer recebeu '${playerId}', que não está na partida`) } - match.tick += 1 + stepCharacter(player.character, input, boxes, config, 1 / config.simulation.tickHz) } diff --git a/packages/core/src/wire/wireFormat.ts b/packages/core/src/wire/wireFormat.ts index 4e26acf2..77e472e1 100644 --- a/packages/core/src/wire/wireFormat.ts +++ b/packages/core/src/wire/wireFormat.ts @@ -165,7 +165,7 @@ export function decodeStance(bits: number): { stance: Stance; grounded: boolean * arma no servidor, não do botão de mira: é a mesma regra do `MovementInput`. * * ```ts - * setPlayerInput(match, sessionId, decodeMovementInput(wire, weapon.scoped)) + * stepMatchPlayer(match, sessionId, decodeMovementInput(wire, weapon.scoped), boxes, config) * ``` */ export function decodeMovementInput(wire: WireInput, scoped: boolean): MovementInput { diff --git a/packages/server/src/roomSimulation.test.ts b/packages/server/src/roomSimulation.test.ts index c4a03954..3b2f96a3 100644 --- a/packages/server/src/roomSimulation.test.ts +++ b/packages/server/src/roomSimulation.test.ts @@ -17,6 +17,7 @@ import { assertCanonicalAddress, createRoomSimulation, FORWARD_AIM, + INPUT_CREDIT_CAP, joinRoom, leaveRoom, type PlayerStateFields, @@ -49,6 +50,10 @@ class FakeInputQueues { } readonly next = (playerId: string): WireInput | undefined => this.#queues.get(playerId)?.shift() + + remaining(playerId: string): number { + return this.#queues.get(playerId)?.length ?? 0 + } } function emptyState(): PlayerStateFields { @@ -104,7 +109,6 @@ describe('admitPlayer', () => { ) expect(state.x).toBe(quantizePositionM(x0)) expect(state.z).toBe(quantizePositionM(z0)) - expect(room.match.tick).toBe(0) }) }) @@ -119,7 +123,7 @@ describe('leaveRoom', () => { }) describe('stepRoom', () => { - it('consome um input por jogador por tick e anda com ele', () => { + it('aplica um input no primeiro tick, com um crédito só', () => { const room = createRoomSimulation() const player = joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) const queues = new FakeInputQueues() @@ -130,20 +134,52 @@ describe('stepRoom', () => { stepRoom(room, queues.next, boxes, config) expect(player.character.position.z).toBeLessThan(startZ) - expect(queues.next('ana')).toEqual(FORWARD_WIRE) - expect(room.match.tick).toBe(1) + expect(queues.remaining('ana')).toBe(1) }) - /** Input atrasado na rede não pode parar o jogador no servidor enquanto ele anda na tela dele. */ - it('repete o último input quando não chegou nenhum no tick', () => { + /** Sem input, o servidor não inventa tick: é o que deixa a reconciliação do cliente fechar. */ + it('deixa parado quem não mandou input', () => { const room = createRoomSimulation() const player = joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) + const before = { ...player.character.position } + stepRoom(room, new FakeInputQueues().next, boxes, config) + expect(player.character.position).toEqual(before) + }) + + it('recupera o atraso de um soluço de rede com o crédito guardado', () => { + const room = createRoomSimulation() + joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) const queues = new FakeInputQueues() - queues.push('ana', FORWARD_WIRE) + for (let tick = 0; tick < 3; tick += 1) stepRoom(room, queues.next, boxes, config) + for (let input = 0; input < 5; input += 1) queues.push('ana', FORWARD_WIRE) + stepRoom(room, queues.next, boxes, config) - const afterFirst = player.character.position.z + + expect(queues.remaining('ana')).toBe(1) + }) + + it('guarda no máximo INPUT_CREDIT_CAP créditos', () => { + const room = createRoomSimulation() + joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) + const queues = new FakeInputQueues() + for (let tick = 0; tick < 20; tick += 1) stepRoom(room, queues.next, boxes, config) + for (let input = 0; input < 20; input += 1) queues.push('ana', FORWARD_WIRE) + stepRoom(room, queues.next, boxes, config) - expect(player.character.position.z).toBeLessThan(afterFirst) + + expect(queues.remaining('ana')).toBe(20 - INPUT_CREDIT_CAP) + }) + + /** Um cliente adulterado que manda três inputs por tick não anda três vezes mais rápido. */ + it('não aplica mais de um input por tick na média', () => { + const room = createRoomSimulation() + joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) + const queues = new FakeInputQueues() + for (let tick = 0; tick < 10; tick += 1) { + for (let input = 0; input < 3; input += 1) queues.push('ana', FORWARD_WIRE) + stepRoom(room, queues.next, boxes, config) + } + expect(30 - queues.remaining('ana')).toBe(10) }) it('guarda a mira do último input', () => { diff --git a/packages/server/src/roomSimulation.ts b/packages/server/src/roomSimulation.ts index cb7d1adc..022d73c6 100644 --- a/packages/server/src/roomSimulation.ts +++ b/packages/server/src/roomSimulation.ts @@ -10,9 +10,8 @@ import { parseRoomAddress, quantizePositionM, type StaticBox, - setPlayerInput, spawnFeetM, - stepMatch, + stepMatchPlayer, type WireAim, type WireInput, } from '@shobu/core' @@ -28,13 +27,27 @@ export interface RoomSimulation { joins: number } -/** Um jogador da sala: o personagem da partida e a última mira que ele mandou. */ +/** Um jogador da sala: o personagem da partida, a última mira e o crédito de input. */ export interface RoomSeat { readonly player: MatchPlayer /** A mira viaja já quantizada e volta igual para os outros clientes. */ aim: WireAim + /** Quantos inputs o jogador ainda pode aplicar; ver `INPUT_CREDIT_CAP`. */ + inputCredits: number } +/** + * O teto do balde de crédito de input, em ticks: 125 ms a 64 Hz. + * + * Cada tick do servidor dá um crédito ao jogador, e cada input aplicado gasta + * um. Sem balde, consumir um input por tick nunca esvazia o atraso que um + * soluço de rede acumula, e o jogador ficaria para sempre esse tanto atrás; + * consumir tudo o que chegou deixaria um cliente adulterado mandar inputs mais + * rápido e andar mais rápido. Com o balde, ninguém passa de um input por tick + * na média, e o atraso de um soluço de até 125 ms é recuperado. + */ +export const INPUT_CREDIT_CAP = 8 + /** Os pontos de spawn, na convenção de altura de olho de `GREYBOX_SPAWN_POINTS_M`. */ export type SpawnPoints = readonly (readonly [number, number, number])[] @@ -77,7 +90,7 @@ export function joinRoom( room.joins += 1 const [x, y, z] = spawnFeetM(eyeM, config.collision.capsuleHeightM) const player = joinMatch(room.match, playerId, { x, y, z }, config) - room.seats.set(playerId, { player, aim: FORWARD_AIM }) + room.seats.set(playerId, { player, aim: FORWARD_AIM, inputCredits: 0 }) return player } @@ -109,8 +122,10 @@ export function leaveRoom(room: RoomSimulation, playerId: string): void { } /** - * Um tick: consome **um** input de cada jogador e avança a partida. Sem input - * no tick, o jogador repete o último (ver `MatchPlayer`). + * Um tick do servidor: cada jogador ganha um crédito e aplica os inputs que + * chegaram enquanto houver crédito, um tick de personagem por input. Sem input, + * o jogador fica parado: repetir o último input faria o servidor simular ticks + * que o cliente nunca simulou, e a reconciliação não fecharia. * * ```ts * stepRoom(room, (id) => this.inputs.get(id).next(), boxes, config) @@ -123,12 +138,15 @@ export function stepRoom( config: GameplayConfig, ): void { for (const [playerId, seat] of room.seats) { - const wire = nextInput(playerId) - if (!wire) continue - setPlayerInput(room.match, playerId, decodeMovementInput(wire, false)) - seat.aim = { aimX: wire.aimX, aimY: wire.aimY, aimZ: wire.aimZ } + seat.inputCredits = Math.min(INPUT_CREDIT_CAP, seat.inputCredits + 1) + while (seat.inputCredits >= 1) { + const wire = nextInput(playerId) + if (!wire) break + seat.inputCredits -= 1 + stepMatchPlayer(room.match, playerId, decodeMovementInput(wire, false), boxes, config) + seat.aim = { aimX: wire.aimX, aimY: wire.aimY, aimZ: wire.aimZ } + } } - stepMatch(room.match, boxes, config) } /** From 0fe3e040ded652446ad43c05b79ec92c5d48db69 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:47:06 -0300 Subject: [PATCH 043/139] docs: describe input-driven stepping and the credit bucket --- docs/rede/arquitetura-de-rede.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/rede/arquitetura-de-rede.md b/docs/rede/arquitetura-de-rede.md index a09f4419..6d76e4e1 100644 --- a/docs/rede/arquitetura-de-rede.md +++ b/docs/rede/arquitetura-de-rede.md @@ -14,6 +14,7 @@ com a premissa declarada. | Tick | 64 Hz (15,625 ms) | configuração, `setFixedTimestep(simular, 64)`; o padrão é 60 Hz | | Envio de patches de estado | 64 Hz | configuração, `patchRate = 1000 / 64`; o padrão é 20 Hz | | Envio de input | 64 Hz | configuração | +| Consumo de input no servidor | um tick de personagem por input, com balde de 8 créditos (125 ms) | configuração (`INPUT_CREDIT_CAP`) | | Buffer de interpolação | 3 intervalos de patch, 46,9 ms | configuração (`delay` do `Predict`); o padrão é 100 ms | | Janela máxima de rewind | 150 ms | configuração (`maxRewindMs`); o padrão é 500 ms | | Heartbeat do WebSocket | ping a cada 1 s, desconecta após 2 sem resposta | configuração (`pingInterval: 1000`); o padrão é 3 s | @@ -25,6 +26,13 @@ O tick é de 64 Hz, e não 60, porque 1/64 s é 2⁻⁶, exato em ponto flutuant ticks e converter tick em tempo nunca arredonda: uma hora de ticks de 1/60 s soma 3600,0000000182 s, e de 1/64 s soma 3600 s exatos. O custo é 6,7% a mais de banda e de CPU. +O servidor avança cada jogador pelos inputs dele, e não pelo relógio: cada input aplicado é um +tick daquele personagem, e sem input o personagem fica parado, inclusive no ar. É o que faz "o +servidor aplicou N inputs" e "o cliente simulou N inputs" darem o mesmo estado, e a reconciliação +do cliente depende disso. Cada tick do servidor dá um crédito ao jogador e cada input gasta um, +com teto de 8. Um soluço de rede de até 125 ms é recuperado aplicando os inputs acumulados, e um +cliente adulterado que mande inputs mais rápido não passa de um por tick na média. + Simulação e patches rodam em dois `setInterval` independentes. A simulação usa acumulador (`setFixedTimestep`) e faz exatamente 64 ticks por segundo em média. Os patches saem no ritmo do próprio timer, que no Node 22 mediu 15,24 ms de período médio para 15,625 ms pedidos From e05d4913904fcf68cc118b425263b0a74ebb22c5 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:50:14 -0300 Subject: [PATCH 044/139] feat: reconcile the local prediction against the server --- .../src/controller/localCharacter.test.ts | 10 ++ .../client/src/controller/localCharacter.ts | 19 +++ .../src/controller/predictionHistory.test.ts | 107 ++++++++++++ .../src/controller/predictionHistory.ts | 161 ++++++++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 packages/client/src/controller/predictionHistory.test.ts create mode 100644 packages/client/src/controller/predictionHistory.ts diff --git a/packages/client/src/controller/localCharacter.test.ts b/packages/client/src/controller/localCharacter.test.ts index 4577e81d..5c710045 100644 --- a/packages/client/src/controller/localCharacter.test.ts +++ b/packages/client/src/controller/localCharacter.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { blockoutToStaticBoxes, + createCharacterState, GREYBOX_BLOCKOUT, IDLE_INPUT, parseGameplayConfig, @@ -86,4 +87,13 @@ describe('pendingTicks e stepOnce', () => { expect(character.current.velocity).toEqual({ x: 0, y: 0, z: 0 }) expect(character.previous.position).toEqual({ x: -26, y: 0, z: -26 }) }) + + it('adota o tick atual e o anterior que a reconciliação recalculou', () => { + const character = createLocalCharacter(config, SPAWN_FEET, boxes) + const current = createCharacterState({ x: 1, y: 0, z: 2 }, config) + const previous = createCharacterState({ x: 1, y: 0, z: 1 }, config) + character.adopt(current, previous) + expect(character.current.position).toEqual({ x: 1, y: 0, z: 2 }) + expect(character.previous.position).toEqual({ x: 1, y: 0, z: 1 }) + }) }) diff --git a/packages/client/src/controller/localCharacter.ts b/packages/client/src/controller/localCharacter.ts index 5eb24f1f..5d84f0c4 100644 --- a/packages/client/src/controller/localCharacter.ts +++ b/packages/client/src/controller/localCharacter.ts @@ -54,6 +54,11 @@ export interface LocalCharacter { * cliente adota o spawn que o servidor de jogo escolheu. */ teleport(feetM: Readonly): void + /** + * Adota o estado que a reconciliação recalculou: o tick atual e o anterior, + * para a interpolação do quadro continuar entre dois ticks verdadeiros. + */ + adopt(current: Readonly, previous: Readonly): void } /** @@ -104,7 +109,21 @@ export function createLocalCharacter( }, interpolationAlpha: () => clock.pendingS / clock.tickDurationS, eyePosition: (out) => eyePosition(current, previous, clock, out), + ...stateReplacements(current, previous), + } +} + +/** As duas formas de trocar o estado por fora da simulação: o spawn e a reconciliação. */ +function stateReplacements( + current: CharacterState, + previous: CharacterState, +): Pick { + return { teleport: (feetM) => teleport(current, previous, feetM), + adopt: (nextCurrent, nextPrevious) => { + copyCharacterState(nextCurrent, current) + copyCharacterState(nextPrevious, previous) + }, } } diff --git a/packages/client/src/controller/predictionHistory.test.ts b/packages/client/src/controller/predictionHistory.test.ts new file mode 100644 index 00000000..d1c023e9 --- /dev/null +++ b/packages/client/src/controller/predictionHistory.test.ts @@ -0,0 +1,107 @@ +import { readFileSync } from 'node:fs' +import { + boxFromCenterSize, + IDLE_INPUT, + type MovementInput, + parseGameplayConfig, + quantizePositionM, + type StaticBox, +} from '@shobu/core' +import { describe, expect, it } from 'vitest' +import { createLocalCharacter, type LocalCharacter } from './localCharacter.ts' +import { createPredictionHistory, type OwnSnapshot } from './predictionHistory.ts' + +const SHIPPED_CONFIG_URL = new URL('../../../../config/gameplay.json', import.meta.url) +const config = parseGameplayConfig(JSON.parse(readFileSync(SHIPPED_CONFIG_URL, 'utf8'))) +const FLOOR: StaticBox = boxFromCenterSize([0, -0.5, 0], [64, 1, 64]) +const BOXES = [FLOOR] +const FORWARD: MovementInput = { ...IDLE_INPUT, wishZ: -1 } + +/** Anda N ticks gravando cada um no histórico, como o renderer faz a cada input enviado. */ +function walk( + character: LocalCharacter, + history: ReturnType, + firstSeq: number, + ticks: number, +): void { + for (let index = 0; index < ticks; index += 1) { + character.stepOnce(FORWARD) + history.record(firstSeq + index, FORWARD, character.current) + } +} + +function snapshotOf(feetM: { x: number; y: number; z: number }): OwnSnapshot { + return { + x: quantizePositionM(feetM.x), + y: quantizePositionM(feetM.y), + z: quantizePositionM(feetM.z), + } +} + +function setup(): { + character: LocalCharacter + history: ReturnType +} { + const character = createLocalCharacter(config, { x: 0, y: 0, z: 0 }, BOXES) + character.stepOnce(IDLE_INPUT) + return { character, history: createPredictionHistory(config, BOXES) } +} + +describe('createPredictionHistory', () => { + /** O caso normal: servidor e cliente simularam os mesmos inputs e chegaram ao mesmo lugar. */ + it('não mexe no personagem quando o servidor confirma a predição', () => { + const { character, history } = setup() + walk(character, history, 1, 5) + const predicted = { ...character.current.position } + + expect(history.reconcile(5, snapshotOf(predicted), character)).toBe('matched') + expect(character.current.position).toEqual(predicted) + }) + + it('corrige para a posição do servidor e reaplica os inputs ainda não confirmados', () => { + const { character, history } = setup() + walk(character, history, 1, 3) + const predictedAtThree = { ...character.current.position } + walk(character, history, 4, 4) + const predictedAtSeven = { ...character.current.position } + const serverAtThree = snapshotOf({ ...predictedAtThree, x: predictedAtThree.x + 0.5 }) + + expect(history.reconcile(3, serverAtThree, character)).toBe('corrected') + + expect(character.current.position.x).toBeCloseTo(predictedAtSeven.x + 0.5, 3) + expect(character.current.position.z).toBeCloseTo(predictedAtSeven.z, 3) + }) + + it('ignora confirmação que não é mais nova que a última', () => { + const { character, history } = setup() + walk(character, history, 1, 3) + const server = snapshotOf(character.current.position) + history.reconcile(3, server, character) + expect(history.reconcile(2, snapshotOf({ x: 9, y: 0, z: 9 }), character)).toBe('stale') + }) + + it('não corrige a partir de input que já saiu do histórico', () => { + const { character, history } = setup() + walk(character, history, 1, 100) + expect(history.reconcile(2, snapshotOf({ x: 9, y: 0, z: 9 }), character)).toBe('unknown') + }) + + it('recusa seq que não é inteiro positivo, dizendo qual', () => { + const { character, history } = setup() + expect(() => history.record(0, FORWARD, character.current)).toThrow(/seq 0/) + expect(() => history.record(1.5, FORWARD, character.current)).toThrow(/seq 1.5/) + }) + + /** Um input que nunca foi gravado interrompe a reaplicação ali, em vez de pular o buraco. */ + it('para a reaplicação no primeiro input que falta', () => { + const { character, history } = setup() + walk(character, history, 1, 2) + character.stepOnce(FORWARD) + walk(character, history, 4, 3) + const server = snapshotOf({ x: 5, y: 0, z: 0 }) + + expect(history.reconcile(1, server, character)).toBe('corrected') + + expect(character.current.position.x).toBeCloseTo(5, 3) + }) +}) diff --git a/packages/client/src/controller/predictionHistory.ts b/packages/client/src/controller/predictionHistory.ts new file mode 100644 index 00000000..e4c84e55 --- /dev/null +++ b/packages/client/src/controller/predictionHistory.ts @@ -0,0 +1,161 @@ +import { + type CharacterState, + copyCharacterState, + createCharacterState, + dequantizePositionM, + type GameplayConfig, + type MovementInput, + quantizePositionM, + type StaticBox, + stepCharacter, +} from '@shobu/core' +import type { LocalCharacter } from './localCharacter.ts' +import type { MutableMovementInput } from './wishDirection.ts' + +/** A posição do próprio jogador que o servidor mandou, em milímetros (`wireFormat`). */ +export interface OwnSnapshot { + readonly x: number + readonly y: number + readonly z: number +} + +/** + * O que a reconciliação fez: confirmou a predição, corrigiu, ignorou uma + * confirmação antiga, ou não achou mais o input confirmado no histórico. + */ +export type ReconcileOutcome = 'matched' | 'corrected' | 'stale' | 'unknown' + +export interface PredictionHistory { + /** Guarda o input que acabou de ser enviado como `seq` e o estado que ele produziu. */ + record(seq: number, input: Readonly, state: Readonly): void + /** + * Compara a predição no input `ackSeq` com a posição que o servidor mandou. + * Se diferem, adota a do servidor e reaplica os inputs ainda não confirmados. + */ + reconcile(ackSeq: number, own: OwnSnapshot, character: LocalCharacter): ReconcileOutcome +} + +/** + * Quantos inputs o histórico lembra: 1 s a 64 Hz. Cobre com folga a ida e volta + * de um input mais o balde de crédito do servidor (docs/rede). + */ +const HISTORY_CAPACITY = 64 + +interface HistoryEntry { + seq: number + readonly input: MutableMovementInput + readonly state: CharacterState +} + +/** + * A reconciliação do jogador local. O servidor avança cada jogador pelos + * inputs dele (`stepRoom`), então aplicar os mesmos N inputs dá o mesmo estado + * nos dois lados: o caso normal é `matched`, sem mexer em nada. A correção só + * acontece quando o servidor decide diferente, e ela corrige a **posição**; o + * resto do estado (velocidade, pulos, slide) é o da predição, que é o mesmo + * código do servidor. + * + * Tudo pré-alocado: `record` roda a cada tick. + * + * ```ts + * const history = createPredictionHistory(config, boxes) + * history.record(seq, movementInput, character.current) + * history.reconcile(link.lastProcessed(), own, character) + * ``` + */ +export function createPredictionHistory( + config: GameplayConfig, + boxes: readonly StaticBox[], +): PredictionHistory { + const entries = createEntries(config) + const origin = { x: 0, y: 0, z: 0 } + const replay = { + current: createCharacterState(origin, config), + previous: createCharacterState(origin, config), + } + const cursor = { latestSeq: 0, lastAck: 0 } + const entryAt = (seq: number): HistoryEntry | undefined => { + const entry = entries[seq % HISTORY_CAPACITY] + return entry?.seq === seq ? entry : undefined + } + const context = { entryAt, cursor, replay, config, boxes } + return { + record: (seq, input, state) => recordEntry(entries, cursor, seq, input, state), + reconcile: (ackSeq, own, character) => reconcileAt(context, ackSeq, own, character), + } +} + +function createEntries(config: GameplayConfig): readonly HistoryEntry[] { + return Array.from({ length: HISTORY_CAPACITY }, () => ({ + seq: -1, + input: { wishX: 0, wishZ: 0, sprint: false, jump: false, crouch: false, scoped: false }, + state: createCharacterState({ x: 0, y: 0, z: 0 }, config), + })) +} + +function reconcileAt( + context: ReplayContext, + ackSeq: number, + own: OwnSnapshot, + character: LocalCharacter, +): ReconcileOutcome { + if (ackSeq <= context.cursor.lastAck) return 'stale' + context.cursor.lastAck = ackSeq + const confirmed = context.entryAt(ackSeq) + if (!confirmed) return 'unknown' + if (agrees(confirmed.state, own)) return 'matched' + replayFrom(confirmed, own, context) + character.adopt(context.replay.current, context.replay.previous) + return 'corrected' +} + +function recordEntry( + entries: readonly HistoryEntry[], + cursor: { latestSeq: number }, + seq: number, + input: Readonly, + state: Readonly, +): void { + const entry = Number.isInteger(seq) && seq >= 1 ? entries[seq % HISTORY_CAPACITY] : undefined + if (!entry) throw new RangeError(`record recebeu seq ${seq}; esperado inteiro >= 1`) + entry.seq = seq + Object.assign(entry.input, input) + copyCharacterState(state, entry.state) + cursor.latestSeq = Math.max(cursor.latestSeq, seq) +} + +/** Compara na resolução do fio: a predição arredondada como o servidor arredonda. */ +function agrees(predicted: Readonly, own: OwnSnapshot): boolean { + return ( + quantizePositionM(predicted.position.x) === own.x && + quantizePositionM(predicted.position.y) === own.y && + quantizePositionM(predicted.position.z) === own.z + ) +} + +interface ReplayContext { + readonly entryAt: (seq: number) => HistoryEntry | undefined + readonly cursor: { latestSeq: number; lastAck: number } + readonly replay: { readonly current: CharacterState; readonly previous: CharacterState } + readonly config: GameplayConfig + readonly boxes: readonly StaticBox[] +} + +/** Parte do estado confirmado com a posição do servidor e reaplica o que veio depois. */ +function replayFrom(confirmed: HistoryEntry, own: OwnSnapshot, context: ReplayContext): void { + const { replay, config, boxes } = context + copyCharacterState(confirmed.state, replay.current) + replay.current.position.x = dequantizePositionM(own.x) + replay.current.position.y = dequantizePositionM(own.y) + replay.current.position.z = dequantizePositionM(own.z) + copyCharacterState(replay.current, confirmed.state) + copyCharacterState(replay.current, replay.previous) + const dtS = 1 / config.simulation.tickHz + for (let seq = confirmed.seq + 1; seq <= context.cursor.latestSeq; seq += 1) { + const entry = context.entryAt(seq) + if (!entry) break + copyCharacterState(replay.current, replay.previous) + stepCharacter(replay.current, entry.input, boxes, config, dtS) + copyCharacterState(replay.current, entry.state) + } +} From 01c166a3153eece46dfb4ce5e095141e3ccb2a10 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:55:32 -0300 Subject: [PATCH 045/139] fix: predict with the movement direction the server receives --- .../src/controller/arenaSession.test.ts | 30 ++++++++++++++++++- .../client/src/controller/arenaSession.ts | 5 ++++ packages/core/src/index.ts | 1 + packages/core/src/wire/wireFormat.test.ts | 17 +++++++++++ packages/core/src/wire/wireFormat.ts | 15 ++++++++++ 5 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/client/src/controller/arenaSession.test.ts b/packages/client/src/controller/arenaSession.test.ts index 2b8c168a..cca58777 100644 --- a/packages/client/src/controller/arenaSession.test.ts +++ b/packages/client/src/controller/arenaSession.test.ts @@ -1,5 +1,11 @@ import { readFileSync } from 'node:fs' -import { boxFromCenterSize, parseGameplayConfig, type ShotHit, type StaticBox } from '@shobu/core' +import { + boxFromCenterSize, + parseGameplayConfig, + type ShotHit, + type StaticBox, + wishOnWire, +} from '@shobu/core' import { describe, expect, it } from 'vitest' import { type ArenaFrame, type ArenaSession, createArenaSession } from './arenaSession.ts' import { createLocalCharacter } from './localCharacter.ts' @@ -253,6 +259,28 @@ describe('createArenaSession', () => { ]) }) + /** + * Regressão: a predição andava com a direção crua do teclado e da câmera, e + * o servidor com a direção arredondada do fio. Medido contra o servidor real, + * metade das confirmações virava correção; com a direção do fio, nenhuma. + */ + it('prediz com a direção arredondada como o servidor a recebe', () => { + const rig = mountSession() + rig.movementInput.wishX = Math.sin(1) + rig.movementInput.wishZ = -Math.cos(1) + const expected = createLocalCharacter(config, SPAWN_FEET, [FLOOR]) + const onWire = { + ...rig.movementInput, + wishX: wishOnWire(Math.sin(1)), + wishZ: wishOnWire(-Math.cos(1)), + } + + rig.frame({ elapsedS: TICK_S }) + expected.stepOnce(onWire) + + expect(rig.session.character.current.position).toEqual(expected.current.position) + }) + it('a arma e o personagem andam no mesmo relógio', () => { const rig = mountSession() const ticks = rig.frame({ elapsedS: 5 * TICK_S }) diff --git a/packages/client/src/controller/arenaSession.ts b/packages/client/src/controller/arenaSession.ts index 0eda7439..442cd08b 100644 --- a/packages/client/src/controller/arenaSession.ts +++ b/packages/client/src/controller/arenaSession.ts @@ -28,6 +28,7 @@ import { type Vector3, type WeaponInput, type WeaponState, + wishOnWire, } from '@shobu/core' import type { LocalCharacter } from './localCharacter.ts' import type { MutableWeaponInput } from './weaponInputFrom.ts' @@ -194,6 +195,10 @@ function advanceArena( session.lastShot = parts.shot } movementInput.scoped = parts.weapon.scoped + // o personagem anda com a direção que o servidor vai receber, não com a do + // teclado: é o que deixa a predição igual ao servidor até o último bit. + movementInput.wishX = wishOnWire(movementInput.wishX) + movementInput.wishZ = wishOnWire(movementInput.wishZ) character.stepOnce(movementInput) options.onTick?.(movementInput, weaponInput, frame.aimM) for (const dummy of parts.dummies) stepTrainingDummy(dummy, character.tickDurationS) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f96922d8..2466e6e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -103,4 +103,5 @@ export { quantizeAim, quantizePositionM, quantizeWish, + wishOnWire, } from './wire/wireFormat.ts' diff --git a/packages/core/src/wire/wireFormat.test.ts b/packages/core/src/wire/wireFormat.test.ts index 190707c9..8584cdda 100644 --- a/packages/core/src/wire/wireFormat.test.ts +++ b/packages/core/src/wire/wireFormat.test.ts @@ -14,6 +14,7 @@ import { quantizeWish, type WireButtons, type WireInput, + wishOnWire, } from './wireFormat.ts' function emptyAim(): MutableWireAim { @@ -90,6 +91,22 @@ describe('direção desejada', () => { }) }) +describe('wishOnWire', () => { + it('é o valor que o servidor decodifica do fio', () => { + for (const axis of [0, 1, -1, Math.SQRT1_2, 0.3141592, -0.9999]) { + const wire: WireInput = { + wishX: quantizeWish(axis), + wishZ: 0, + buttons: 0, + aimX: 0, + aimY: 0, + aimZ: -32767, + } + expect(wishOnWire(axis)).toBe(decodeMovementInput(wire, false).wishX) + } + }) +}) + describe('botões', () => { it('cabem num byte e voltam iguais, um a um', () => { for (const name of Object.keys(NO_BUTTONS) as (keyof WireButtons)[]) { diff --git a/packages/core/src/wire/wireFormat.ts b/packages/core/src/wire/wireFormat.ts index 77e472e1..14bc931e 100644 --- a/packages/core/src/wire/wireFormat.ts +++ b/packages/core/src/wire/wireFormat.ts @@ -123,6 +123,21 @@ export function quantizeWish(axis: number): number { return clamp(Math.round(axis * WISH_STEPS), -WISH_STEPS, WISH_STEPS) } +/** + * O eixo da direção desejada exatamente como o servidor vai lê-lo: arredondado + * a 1/127, a resolução do `int8` do fio. A predição do cliente anda com este + * valor, e não com o do teclado e da câmera; com o valor cru, cliente e + * servidor simulavam direções diferentes no último dígito, e metade das + * confirmações do servidor virava correção. + * + * ```ts + * movementInput.wishX = wishOnWire(movementInput.wishX) + * ``` + */ +export function wishOnWire(axis: number): number { + return quantizeWish(axis) / WISH_STEPS +} + /** Sem closure nem objeto novo: roda a cada tick de input. */ export function encodeButtons(buttons: WireButtons): number { let bits = 0 From 91bf4dc5fb6f04b29f4c7f66891252f932cba13e Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:55:32 -0300 Subject: [PATCH 046/139] fix: read the room players only after the state arrives --- .../client/src/net/remotePlayerView.test.ts | 14 ++++++++++++++ packages/client/src/net/remotePlayerView.ts | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/client/src/net/remotePlayerView.test.ts b/packages/client/src/net/remotePlayerView.test.ts index 6e20be83..ace006f2 100644 --- a/packages/client/src/net/remotePlayerView.test.ts +++ b/packages/client/src/net/remotePlayerView.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { createRemotePlayerView, interpolationDelayMs, + playersOf, readRemotePlayer, } from './remotePlayerView.ts' @@ -44,3 +45,16 @@ describe('interpolationDelayMs', () => { expect(interpolationDelayMs(config)).toBe((3 * 1000) / config.simulation.patchHz) }) }) + +describe('playersOf', () => { + /** Regressão: nos primeiros quadros depois de entrar, o estado ainda não chegou. */ + it('não tem jogador nenhum antes de o estado chegar', () => { + expect(playersOf(undefined).size).toBe(0) + expect(playersOf({}).size).toBe(0) + }) + + it('devolve o mapa de jogadores quando o estado chegou', () => { + const players = new Map([['ana', { x: 0, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: -1, stance: 0 }]]) + expect(playersOf({ players })).toBe(players) + }) +}) diff --git a/packages/client/src/net/remotePlayerView.ts b/packages/client/src/net/remotePlayerView.ts index cded7753..1a3a1115 100644 --- a/packages/client/src/net/remotePlayerView.ts +++ b/packages/client/src/net/remotePlayerView.ts @@ -75,3 +75,20 @@ const INTERPOLATION_PATCH_INTERVALS = 3 export function interpolationDelayMs(config: GameplayConfig): number { return (INTERPOLATION_PATCH_INTERVALS * 1000) / config.simulation.patchHz } + +const NO_PLAYERS: ReadonlyMap = new Map() + +/** + * Os jogadores da sala, no formato de `gameRoomState.ts` do servidor. O estado + * chega descrito no handshake e **depois** do `joinOrCreate`: nos primeiros + * quadros ele ainda não existe, e ler o mapa direto derrubava o laço de render. + * + * ```ts + * for (const [id, fields] of playersOf(room.state)) { ... } + * ``` + */ +export function playersOf( + state: { readonly players?: ReadonlyMap } | undefined, +): ReadonlyMap { + return state?.players ?? NO_PLAYERS +} From 1b94be29b8dff55e04a41863818bfb16630a186d Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:55:32 -0300 Subject: [PATCH 047/139] feat: reconcile the local player every frame --- packages/client/src/net/gameLink.ts | 46 +++++++------ packages/client/src/net/networkPort.test.ts | 22 +++++- packages/client/src/net/networkPort.ts | 68 ++++++++++++++----- .../src/renderer/babylonArenaRenderer.ts | 28 ++++++-- 4 files changed, 117 insertions(+), 47 deletions(-) diff --git a/packages/client/src/net/gameLink.ts b/packages/client/src/net/gameLink.ts index a816187a..4b8f5c13 100644 --- a/packages/client/src/net/gameLink.ts +++ b/packages/client/src/net/gameLink.ts @@ -2,7 +2,7 @@ import { Client, type Room } from '@colyseus/sdk' import { Predict } from '@colyseus/sdk/predict' import type { WireInput } from '@shobu/core' import type { MutableWireInput } from './encodeTickInput.ts' -import type { PlayerSnapshotFields } from './remotePlayerView.ts' +import { type PlayerSnapshotFields, playersOf } from './remotePlayerView.ts' /** * O canal de jogo, do lado do cliente: a interface do projeto sobre o @@ -11,8 +11,10 @@ import type { PlayerSnapshotFields } from './remotePlayerView.ts' */ export interface GameLink { readonly sessionId: string - /** O input de um tick, na ordem em que a predição o usou. */ - sendTickInput(input: WireInput): void + /** O input de um tick, na ordem em que a predição o usou. Devolve o número de sequência dele. */ + sendTickInput(input: WireInput): number + /** O último número de sequência que o servidor confirmou ter aplicado (`input.lastProcessed`). */ + lastProcessed(): number /** Avança a interpolação até `nowMs` e visita cada outro jogador com o valor interpolado. */ forEachRemote( nowMs: number, @@ -42,28 +44,35 @@ export async function openGameLink( room.reconnection.enabled = false return { sessionId: room.sessionId, - sendTickInput: createInputSender(room), + ...createInputChannel(room), forEachRemote: createRemoteReader(room, interpolationDelayMs), - ownSnapshot: () => - (room.state.players as Map).get(room.sessionId), + ownSnapshot: () => playersOf(room.state).get(room.sessionId), leave: async () => { await room.leave() }, } } -/** Copia campo a campo no mesmo objeto do sdk: nada é alocado por tick. */ -function createInputSender(room: Room): GameLink['sendTickInput'] { +/** + * O canal de input confiável do sdk. Copia campo a campo no mesmo objeto, sem + * alocar por tick; o `sentCount` depois do envio é o número de sequência que o + * servidor confirma de volta em `lastProcessed`. + */ +function createInputChannel(room: Room): Pick { const input = room.input() const data = input.data as MutableWireInput - return (wire) => { - data.wishX = wire.wishX - data.wishZ = wire.wishZ - data.buttons = wire.buttons - data.aimX = wire.aimX - data.aimY = wire.aimY - data.aimZ = wire.aimZ - input.send() + return { + sendTickInput: (wire) => { + data.wishX = wire.wishX + data.wishZ = wire.wishZ + data.buttons = wire.buttons + data.aimX = wire.aimX + data.aimY = wire.aimY + data.aimZ = wire.aimZ + input.send() + return input.sentCount + }, + lastProcessed: () => input.lastProcessed, } } @@ -75,13 +84,10 @@ function createInputSender(room: Room): GameLink['sendTickInput'] { function createRemoteReader(room: Room, delayMs: number): GameLink['forEachRemote'] { const predict = Predict.get(room, { mode: 'lerp', delay: delayMs }) predict.attachAll('players', { fields: [...INTERPOLATED_FIELDS] }) - // o cliente não importa o schema do servidor: o estado chega descrito no - // handshake, e é só aqui que ele ganha o formato de `gameRoomState.ts`. - const players = (): Map => room.state.players const scratch = { x: 0, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: 0, stance: 0 } return (nowMs, visit) => { predict.tick(nowMs) - for (const [playerId, player] of players()) { + for (const [playerId, player] of playersOf(room.state)) { if (playerId === room.sessionId) continue for (const field of INTERPOLATED_FIELDS) scratch[field] = predict.value(player, field) scratch.stance = player.stance diff --git a/packages/client/src/net/networkPort.test.ts b/packages/client/src/net/networkPort.test.ts index 6889bc7c..909903b0 100644 --- a/packages/client/src/net/networkPort.test.ts +++ b/packages/client/src/net/networkPort.test.ts @@ -20,8 +20,15 @@ class FakeGameLink implements GameLink { readonly sessionId = 'eu' readonly sent: WireInput[] = [] - sendTickInput(input: WireInput): void { + sendTickInput(input: WireInput): number { this.sent.push({ ...input }) + return this.sent.length + } + + acked = 0 + + lastProcessed(): number { + return this.acked } forEachRemote( @@ -54,7 +61,7 @@ describe('createNetworkPort', () => { const port = createNetworkPort() const link = new FakeGameLink() port.attach(link) - port.sendTickInput(WALK) + expect(port.sendTickInput(WALK)).toBe(1) const visited: string[] = [] port.forEachRemote(0, (id) => visited.push(id)) expect(link.sent).toEqual([WALK]) @@ -72,4 +79,15 @@ describe('createNetworkPort', () => { expect(port.takeOwnSpawn()).toEqual({ x: 26, y: 0, z: 26 }) expect(port.takeOwnSpawn()).toBeUndefined() }) + + it('entrega a confirmação do servidor só com sala e com o próprio estado', () => { + const port = createNetworkPort() + expect(port.confirmation()).toBeUndefined() + const link = new FakeGameLink() + port.attach(link) + expect(port.confirmation()).toBeUndefined() + link.own = AT_SPAWN + link.acked = 7 + expect(port.confirmation()).toEqual({ ackSeq: 7, own: AT_SPAWN }) + }) }) diff --git a/packages/client/src/net/networkPort.ts b/packages/client/src/net/networkPort.ts index 4a9c1f1a..23a941cf 100644 --- a/packages/client/src/net/networkPort.ts +++ b/packages/client/src/net/networkPort.ts @@ -7,8 +7,15 @@ import type { PlayerSnapshotFields } from './remotePlayerView.ts' * na sala é assíncrono e o pilar 2 não espera por ela: até o `attach`, o jogo * roda sozinho, e depois os mesmos métodos passam a falar com a sala. */ +/** A confirmação do servidor: o último input aplicado e a posição que ele produziu. */ +export interface OwnConfirmation { + readonly ackSeq: number + readonly own: PlayerSnapshotFields +} + export interface NetworkPort { - sendTickInput(input: WireInput): void + /** Devolve o número de sequência do input, ou `undefined` sem sala. */ + sendTickInput(input: WireInput): number | undefined forEachRemote( nowMs: number, visit: (playerId: string, fields: PlayerSnapshotFields) => void, @@ -20,6 +27,8 @@ export interface NetworkPort { * próprio jogador chega. `undefined` antes disso e depois de entregue. */ takeOwnSpawn(): Vector3 | undefined + /** O que a reconciliação precisa, ou `undefined` sem sala ou antes do primeiro estado. */ + confirmation(): OwnConfirmation | undefined } /** @@ -29,27 +38,50 @@ export interface NetworkPort { * ``` */ export function createNetworkPort(): NetworkPort { - let link: GameLink | undefined - let spawnPending = false + const slot: LinkSlot = { link: undefined, spawnPending: false } return { - sendTickInput: (input) => link?.sendTickInput(input), - forEachRemote: (nowMs, visit) => link?.forEachRemote(nowMs, visit), + sendTickInput: (input) => slot.link?.sendTickInput(input), + forEachRemote: (nowMs, visit) => slot.link?.forEachRemote(nowMs, visit), attach: (next) => { - link = next - spawnPending = true - }, - takeOwnSpawn: () => { - const own = spawnPending ? link?.ownSnapshot() : undefined - if (!own) return undefined - spawnPending = false - return { - x: dequantizePositionM(own.x), - y: dequantizePositionM(own.y), - z: dequantizePositionM(own.z), - } + slot.link = next + slot.spawnPending = true }, + confirmation: readConfirmation(slot), + takeOwnSpawn: () => takeSpawn(slot), get connected() { - return link !== undefined + return slot.link !== undefined }, } } + +/** A sala, quando chegar, e se o spawn dela ainda não foi entregue. */ +interface LinkSlot { + link: GameLink | undefined + spawnPending: boolean +} + +/** A confirmação é lida a cada quadro, então o objeto devolvido é sempre o mesmo. */ +function readConfirmation(slot: LinkSlot): () => OwnConfirmation | undefined { + const scratch: { ackSeq: number; own: PlayerSnapshotFields } = { + ackSeq: 0, + own: { x: 0, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: -32767, stance: 0 }, + } + return () => { + const own = slot.link?.ownSnapshot() + if (!slot.link || !own) return undefined + scratch.ackSeq = slot.link.lastProcessed() + scratch.own = own + return scratch + } +} + +function takeSpawn(slot: LinkSlot): Vector3 | undefined { + const own = slot.spawnPending ? slot.link?.ownSnapshot() : undefined + if (!own) return undefined + slot.spawnPending = false + return { + x: dequantizePositionM(own.x), + y: dequantizePositionM(own.y), + z: dequantizePositionM(own.z), + } +} diff --git a/packages/client/src/renderer/babylonArenaRenderer.ts b/packages/client/src/renderer/babylonArenaRenderer.ts index 04d41040..586bf6ce 100644 --- a/packages/client/src/renderer/babylonArenaRenderer.ts +++ b/packages/client/src/renderer/babylonArenaRenderer.ts @@ -27,6 +27,7 @@ import { trackHeldKeys, } from '../controller/heldKeys.ts' import { createLocalCharacter, type LocalCharacter } from '../controller/localCharacter.ts' +import { createPredictionHistory, type PredictionHistory } from '../controller/predictionHistory.ts' import { createWeaponInput } from '../controller/weaponInputFrom.ts' import { createMovementInput } from '../controller/wishDirection.ts' import { type ArenaHud, createSilentHud } from '../hud/arenaHud.ts' @@ -92,6 +93,7 @@ export function createBabylonArenaRenderer(options: ArenaRendererOptions): Arena const weaponInput = createWeaponInput() const boxes = blockoutToStaticBoxes(options.blockout) const character = createLocalCharacter(options.config, feetOfSpawn(options), boxes) + const history = createPredictionHistory(options.config, boxes) const session = createArenaSession({ config: options.config, character, @@ -99,7 +101,7 @@ export function createBabylonArenaRenderer(options: ArenaRendererOptions): Arena dummyPostsM: trainingDummyPostsM(options.config.match.trainingDummies), movementInput, weaponInput, - onTick: sendTickInput(options), + onTick: sendTickInput(options, character, history), }) const { keyboard, viewBob } = attachLocalCharacter(engine, scene, camera, options, { character, @@ -108,7 +110,7 @@ export function createBabylonArenaRenderer(options: ArenaRendererOptions): Arena }) mirrorLocalCharacter(scene, options, character, keyboard.keys, () => engine.getDeltaTime()) showTrainingDummies(scene, options, session) - if (options.network) followNetwork(scene, options, options.network, character) + if (options.network) followNetwork(scene, options, options.network, { character, history }) const viewmodel = attachSniperViewmodel(scene, camera, options.config.weapon) const hud: ArenaHud = options.hud ?? createSilentHud() const zoom = createScopeZoom(!prefersReducedMotion()) @@ -319,12 +321,20 @@ function followNetwork( scene: Scene, options: ArenaRendererOptions, network: NetworkPort, - character: LocalCharacter, + local: { readonly character: LocalCharacter; readonly history: PredictionHistory }, ): void { showRemotePlayers(scene, options, network) scene.onBeforeRenderObservable.add(() => { const spawn = network.takeOwnSpawn() - if (spawn) character.teleport(spawn) + if (spawn) local.character.teleport(spawn) + const confirmation = network.confirmation() + if (!confirmation) return + const outcome = local.history.reconcile(confirmation.ackSeq, confirmation.own, local.character) + // a correção é o sinal de que predição e servidor discordaram; no caso + // normal ela não acontece, então o log é a métrica, não ruído. + if (outcome === 'corrected') { + console.info(JSON.stringify({ event: 'prediction.corrected', ackSeq: confirmation.ackSeq })) + } }) } @@ -332,11 +342,15 @@ function followNetwork( * O input que a predição acabou de usar, no formato de fio, para a sala. O * objeto de fio é um só, reaproveitado a cada tick. */ -function sendTickInput(options: ArenaRendererOptions): NonNullable { +function sendTickInput( + options: ArenaRendererOptions, + character: LocalCharacter, + history: PredictionHistory, +): NonNullable { const wire = createWireInput() return (movement, weapon, aimM) => { - if (!options.network) return - options.network.sendTickInput(encodeTickInput(movement, weapon, aimM, wire)) + const seq = options.network?.sendTickInput(encodeTickInput(movement, weapon, aimM, wire)) + if (seq !== undefined) history.record(seq, movement, character.current) } } From 412893a364b0d5c2cc48c6d462610f0eacfc7180 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 21:55:46 -0300 Subject: [PATCH 048/139] docs: describe the client reconciliation and its measurement --- docs/rede/arquitetura-de-rede.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/rede/arquitetura-de-rede.md b/docs/rede/arquitetura-de-rede.md index 6d76e4e1..2d70dbca 100644 --- a/docs/rede/arquitetura-de-rede.md +++ b/docs/rede/arquitetura-de-rede.md @@ -33,6 +33,15 @@ do cliente depende disso. Cada tick do servidor dá um crédito ao jogador e cad com teto de 8. Um soluço de rede de até 125 ms é recuperado aplicando os inputs acumulados, e um cliente adulterado que mande inputs mais rápido não passa de um por tick na média. +A reconciliação do cliente guarda, por número de sequência do input, o input e o estado que a +predição produziu, até 64 inputs (1 s). Quando o servidor confirma o input N +(`input.lastProcessed` do sdk), o cliente compara a posição prevista em N com a do servidor, na +resolução de 1 mm do fio. Se batem, nada muda; se diferem, adota a posição do servidor e reaplica +os inputs ainda não confirmados. A predição anda com a direção arredondada a 1/127, a do fio, e +não com a do teclado: com a direção crua, medido contra o servidor real, 258 de 506 confirmações +viravam correção; com a do fio, nenhuma de 460, andando, virando, correndo, pulando e agachando +por 8 s. + Simulação e patches rodam em dois `setInterval` independentes. A simulação usa acumulador (`setFixedTimestep`) e faz exatamente 64 ticks por segundo em média. Os patches saem no ritmo do próprio timer, que no Node 22 mediu 15,24 ms de período médio para 15,625 ms pedidos From ed91f919fb659a2b82b9355adbfe63726e30f355 Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 22:09:13 -0300 Subject: [PATCH 049/139] feat: derive a remote player's locomotion pose from its motion --- .../client/src/character/remotePose.test.ts | 72 +++++++++++++++++++ packages/client/src/character/remotePose.ts | 46 ++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 packages/client/src/character/remotePose.test.ts create mode 100644 packages/client/src/character/remotePose.ts diff --git a/packages/client/src/character/remotePose.test.ts b/packages/client/src/character/remotePose.test.ts new file mode 100644 index 00000000..b7db6e7b --- /dev/null +++ b/packages/client/src/character/remotePose.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { createRemotePlayerView } from '../net/remotePlayerView.ts' +import { poseOfRemotePlayer } from './remotePose.ts' +import { createLocomotionPose } from './thirdPersonClips.ts' + +const LOOKING_NORTH = { x: 0, y: 0, z: 1 } + +function viewAt(x: number, z: number): ReturnType { + const view = createRemotePlayerView() + view.feetM.x = x + view.feetM.z = z + view.aim.x = LOOKING_NORTH.x + view.aim.z = LOOKING_NORTH.z + return view +} + +describe('poseOfRemotePlayer', () => { + it('mede a velocidade pelo deslocamento entre dois quadros', () => { + const pose = poseOfRemotePlayer( + viewAt(0, 0.1), + { x: 0, y: 0, z: 0 }, + 0.02, + createLocomotionPose(), + ) + expect(pose.horizontalSpeedMps).toBeCloseTo(5, 9) + }) + + it.each([ + ['para a frente', 0, 1, 1, 0], + ['para trás', 0, -1, -1, 0], + ['para a direita', 1, 0, 0, 1], + ['para a esquerda', -1, 0, 0, -1], + ['na diagonal frente-direita', 1, 1, 1, 1], + ] as const)('andando %s, relativo à mira', (_label, dx, dz, ahead, side) => { + const pose = poseOfRemotePlayer( + viewAt(dx * 0.1, dz * 0.1), + { x: 0, y: 0, z: 0 }, + 0.02, + createLocomotionPose(), + ) + expect(pose).toMatchObject({ ahead, side }) + }) + + it('fica parado sem deslocamento e em quadro de duração zero', () => { + const still = poseOfRemotePlayer( + viewAt(0, 0), + { x: 0, y: 0, z: 0 }, + 0.02, + createLocomotionPose(), + ) + expect(still).toMatchObject({ horizontalSpeedMps: 0, ahead: 0, side: 0 }) + const zeroFrame = poseOfRemotePlayer( + viewAt(1, 1), + { x: 0, y: 0, z: 0 }, + 0, + createLocomotionPose(), + ) + expect(zeroFrame.horizontalSpeedMps).toBe(0) + }) + + it('leva a postura e o chão do snapshot', () => { + const view = viewAt(0, 0) + view.stance = 'sliding' + view.grounded = true + expect( + poseOfRemotePlayer(view, { x: 0, y: 0, z: 0 }, 0.02, createLocomotionPose()), + ).toMatchObject({ + stance: 'sliding', + grounded: true, + }) + }) +}) diff --git a/packages/client/src/character/remotePose.ts b/packages/client/src/character/remotePose.ts new file mode 100644 index 00000000..353f2224 --- /dev/null +++ b/packages/client/src/character/remotePose.ts @@ -0,0 +1,46 @@ +import type { Vector3 } from '@shobu/core' +import { planarUnit } from '../controller/wishDirection.ts' +import type { RemotePlayerView } from '../net/remotePlayerView.ts' +import type { MutableLocomotionPose } from './thirdPersonClips.ts' + +/** + * Seno de 22,5°: a velocidade conta como "para a frente" ou "de lado" quando a + * componente passa desta fração dela. Divide o círculo em oito setores, como os + * clipes de corrida, e a diagonal pega as duas direções. + */ +const DIRECTION_SHARE = 0.383 + +/** + * A pose de um jogador remoto para o animador. O local lê a direção do teclado; + * o remoto não tem teclado, então a direção sai do movimento: a posição + * interpolada deste quadro menos a do anterior, projetada na mira. "Direita" é + * o eixo X local da câmera no plano do chão, `(frente.z, -frente.x)`, a mesma + * base que `driveCameraFromCharacter` usa. + * + * ```ts + * poseOfRemotePlayer(view, previousFeetM, frameS, pose) + * avatar.animator.play(thirdPersonClipFor(pose, config.movement), frameS) + * ``` + */ +export function poseOfRemotePlayer( + view: Readonly, + previousFeetM: Readonly, + frameS: number, + into: MutableLocomotionPose, +): MutableLocomotionPose { + into.stance = view.stance + into.grounded = view.grounded + const velocityX = frameS > 0 ? (view.feetM.x - previousFeetM.x) / frameS : 0 + const velocityZ = frameS > 0 ? (view.feetM.z - previousFeetM.z) / frameS : 0 + into.horizontalSpeedMps = Math.sqrt(velocityX * velocityX + velocityZ * velocityZ) + const [forwardX, forwardZ] = planarUnit(view.aim.x, view.aim.y, view.aim.z) + into.ahead = direction(velocityX * forwardX + velocityZ * forwardZ, into.horizontalSpeedMps) + into.side = direction(velocityX * forwardZ - velocityZ * forwardX, into.horizontalSpeedMps) + return into +} + +function direction(component: number, speed: number): -1 | 0 | 1 { + if (component > DIRECTION_SHARE * speed) return 1 + if (component < -DIRECTION_SHARE * speed) return -1 + return 0 +} From 9a4717fb4c529511c98378d21980141118c3b01f Mon Sep 17 00:00:00 2001 From: thnbi Date: Mon, 28 Sep 2026 22:09:14 -0300 Subject: [PATCH 050/139] feat: animate the other players by their motion --- .../client/src/renderer/showRemotePlayers.ts | 71 +++++++++++++++---- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/client/src/renderer/showRemotePlayers.ts b/packages/client/src/renderer/showRemotePlayers.ts index 56fc0506..e5074a5c 100644 --- a/packages/client/src/renderer/showRemotePlayers.ts +++ b/packages/client/src/renderer/showRemotePlayers.ts @@ -1,10 +1,17 @@ import { Vector3 } from '@babylonjs/core/Maths/math.vector' import type { Scene } from '@babylonjs/core/scene' import { accentForIndex } from '../character/competitorPalette.ts' +import { poseOfRemotePlayer } from '../character/remotePose.ts' +import { + createLocomotionPose, + type MutableLocomotionPose, + thirdPersonClipFor, +} from '../character/thirdPersonClips.ts' import type { NetworkPort } from '../net/networkPort.ts' import { createRemotePlayerView, type PlayerSnapshotFields, + type RemotePlayerView, readRemotePlayer, } from '../net/remotePlayerView.ts' import { createRemoteRoster } from '../net/remoteRoster.ts' @@ -25,34 +32,61 @@ export function showRemotePlayers( network: NetworkPort, ): void { const roster = createRemoteRoster() - const avatars = new Map() + const avatars = new Map() const view = createRemotePlayerView() - const facing = new Vector3() - let arrivals = 0 + const clock = { nowMs: performance.now(), frameS: 0, arrivals: 0 } const place = (playerId: string, fields: PlayerSnapshotFields): void => { - if (roster.visit(playerId)) loadRemoteAvatar(scene, options, playerId, avatars, arrivals++) - const avatar = avatars.get(playerId) - if (!avatar) return - readRemotePlayer(fields, view) - avatar.root.position.set(view.feetM.x, view.feetM.y, view.feetM.z) - facing.set(view.feetM.x + view.aim.x, view.feetM.y, view.feetM.z + view.aim.z) - avatar.root.lookAt(facing) + if (roster.visit(playerId)) + loadRemoteAvatar(scene, options, playerId, avatars, clock.arrivals++) + const remote = avatars.get(playerId) + if (remote) poseRemote(remote, readRemotePlayer(fields, view), clock.frameS, options) } scene.onBeforeRenderObservable.add(() => { + const nowMs = performance.now() + clock.frameS = (nowMs - clock.nowMs) / 1000 + clock.nowMs = nowMs roster.beginFrame() - network.forEachRemote(performance.now(), place) + network.forEachRemote(nowMs, place) for (const playerId of roster.endFrame()) { - avatars.get(playerId)?.root.dispose() + avatars.get(playerId)?.avatar.root.dispose() avatars.delete(playerId) } }) } +/** O avatar de um jogador remoto e o que a animação dele lembra entre quadros. */ +interface RemoteAvatar { + readonly avatar: CompetitorAvatar + readonly previousFeetM: { x: number; y: number; z: number } + readonly pose: MutableLocomotionPose + readonly facing: Vector3 + /** Até o primeiro quadro, não há posição anterior para medir velocidade. */ + seen: boolean +} + +/** Posição, direção do corpo e clipe de um quadro. */ +function poseRemote( + remote: RemoteAvatar, + view: RemotePlayerView, + frameS: number, + options: ArenaRendererOptions, +): void { + const { avatar, previousFeetM, pose, facing } = remote + if (!remote.seen) Object.assign(previousFeetM, view.feetM) + remote.seen = true + avatar.root.position.set(view.feetM.x, view.feetM.y, view.feetM.z) + facing.set(view.feetM.x + view.aim.x, view.feetM.y, view.feetM.z + view.aim.z) + avatar.root.lookAt(facing) + poseOfRemotePlayer(view, previousFeetM, frameS, pose) + avatar.animator.play(thirdPersonClipFor(pose, options.config.movement), frameS) + Object.assign(previousFeetM, view.feetM) +} + function loadRemoteAvatar( scene: Scene, options: ArenaRendererOptions, playerId: string, - avatars: Map, + avatars: Map, arrival: number, ): void { avatars.set(playerId, undefined) @@ -62,8 +96,15 @@ function loadRemoteAvatar( accent: accentForIndex(arrival), }) .then((avatar) => { - if (avatars.has(playerId)) avatars.set(playerId, avatar) - else avatar.root.dispose() + if (!avatars.has(playerId)) return avatar.root.dispose() + const remote: RemoteAvatar = { + avatar, + previousFeetM: { x: 0, y: 0, z: 0 }, + pose: createLocomotionPose(), + facing: new Vector3(), + seen: false, + } + avatars.set(playerId, remote) }) .catch((reason: unknown) => { const message = reason instanceof Error ? reason.message : String(reason) From 7f3df9ade1ce82ed2396ba2ea04b045234cc6880 Mon Sep 17 00:00:00 2001 From: thnbi Date: Tue, 29 Sep 2026 09:06:03 -0300 Subject: [PATCH 051/139] fix: make the dev server revalidate pre-bundled dependencies --- packages/client/vite.config.ts | 6 +++- .../client/vite/revalidateOptimizedDeps.ts | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 packages/client/vite/revalidateOptimizedDeps.ts diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 8f2345d3..d32bbb18 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -1,10 +1,14 @@ import { defineConfig } from 'vite' import { gameplayConfigPlugin } from './vite/gameplayConfigPlugin.ts' +import { revalidateOptimizedDeps } from './vite/revalidateOptimizedDeps.ts' // o tamanho do bundle é a primeira medição do projeto (nfr.md). `npm run // measure:bundle` lê o manifest que esta configuração produz. export default defineConfig({ - plugins: [gameplayConfigPlugin(new URL('../../config/gameplay.json', import.meta.url))], + plugins: [ + gameplayConfigPlugin(new URL('../../config/gameplay.json', import.meta.url)), + revalidateOptimizedDeps(), + ], /** * O que `gltfPipeline.ts` carrega por **import dinâmico**, pré-empacotado * junto com o resto. diff --git a/packages/client/vite/revalidateOptimizedDeps.ts b/packages/client/vite/revalidateOptimizedDeps.ts new file mode 100644 index 00000000..f87c477b --- /dev/null +++ b/packages/client/vite/revalidateOptimizedDeps.ts @@ -0,0 +1,35 @@ +import type { Plugin } from 'vite' + +const OPTIMIZED_DEPS_PATH = '/node_modules/.vite/deps/' + +/** + * Em dev, o vite serve as dependências pré-empacotadas com + * `Cache-Control: max-age=31536000,immutable`, e os imports dinâmicos de dentro + * delas (os `*.pure-*.js` do loader de glTF do babylon) nem levam o `?v=`. + * Quando o vite refaz o pré-empacotamento, a url de um arquivo pode sair igual + * com conteúdo diferente, e o navegador, que não revalida o que é imutável, + * mistura cópias velhas com novas: um chunk velho pede chunks que não existem + * mais (504 "Outdated Optimize Dep"), o loader de glTF morre, e avatar, bonecos + * e arma somem, com `Cannot set properties of undefined (setting 'weight')` ou + * `Failed to fetch dynamically imported module` no console. + * + * Com `no-cache`, o navegador revalida cada arquivo pelo ETag que o vite já + * manda, e em localhost a resposta 304 custa quase nada. Só vale no servidor + * de dev: o build de produção empacota tudo num passe e não tem este cache. + */ +export function revalidateOptimizedDeps(): Plugin { + return { + name: 'shobu:revalidate-optimized-deps', + apply: 'serve', + configureServer(server) { + server.middlewares.use((request, response, next) => { + if (request.url?.startsWith(OPTIMIZED_DEPS_PATH)) { + const setHeader = response.setHeader.bind(response) + response.setHeader = (name, value) => + setHeader(name, name.toLowerCase() === 'cache-control' ? 'no-cache' : value) + } + next() + }) + }, + } +} From 68f7d33ef194353aeb784ecacfcdaba08c1184fa Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 18:57:03 -0300 Subject: [PATCH 052/139] feat: share the shot eye, aim and seed between client and server --- packages/core/src/index.ts | 3 +++ packages/core/src/movement/eye.test.ts | 24 ++++++++++++++++++++ packages/core/src/movement/eye.ts | 25 +++++++++++++++++++++ packages/core/src/weapon/shotRandom.test.ts | 17 ++++++++++++++ packages/core/src/weapon/shotRandom.ts | 17 ++++++++++++++ packages/core/src/wire/wireFormat.test.ts | 9 ++++++++ packages/core/src/wire/wireFormat.ts | 16 +++++++++++++ 7 files changed, 111 insertions(+) create mode 100644 packages/core/src/movement/eye.test.ts create mode 100644 packages/core/src/movement/eye.ts create mode 100644 packages/core/src/weapon/shotRandom.test.ts create mode 100644 packages/core/src/weapon/shotRandom.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2466e6e9..cf38947c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -47,6 +47,7 @@ export { JUMPS_PER_FLIGHT, } from './movement/characterState.ts' export { CROUCH_CAPSULE_RATIO } from './movement/crouch.ts' +export { EYE_HEIGHT_RATIO, eyeOf } from './movement/eye.ts' export { maxDoubleJumpHeightM, maxJumpHeightM } from './movement/jump.ts' export type { MovementInput } from './movement/movementInput.ts' export { hasWish, IDLE_INPUT, wishLength, wishUnit } from './movement/movementInput.ts' @@ -78,6 +79,7 @@ export { applySpread } from './weapon/aimSpread.ts' export { hitHeightRatio } from './weapon/hitHeight.ts' export type { ShotHit, ShotOrigin } from './weapon/resolveShot.ts' export { createShotHit, resolveShot } from './weapon/resolveShot.ts' +export { SHOT_SEED_BASE, shotRandom } from './weapon/shotRandom.ts' export { spreadTangent } from './weapon/spreadTangent.ts' export { stepWeapon } from './weapon/stepWeapon.ts' export type { WeaponInput } from './weapon/weaponInput.ts' @@ -93,6 +95,7 @@ export { } from './weapon/weaponState.ts' export type { MutableWireAim, WireAim, WireButtons, WireInput } from './wire/wireFormat.ts' export { + aimOnWire, decodeButtons, decodeMovementInput, decodeStance, diff --git a/packages/core/src/movement/eye.test.ts b/packages/core/src/movement/eye.test.ts new file mode 100644 index 00000000..db96ca1b --- /dev/null +++ b/packages/core/src/movement/eye.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { createCharacterState } from './characterState.ts' +import { EYE_HEIGHT_RATIO, eyeOf } from './eye.ts' +import { shippedConfig } from './movementTestKit.ts' + +const config = shippedConfig() + +describe('eyeOf', () => { + it('fica a EYE_HEIGHT_RATIO da cápsula acima do pé', () => { + const state = createCharacterState({ x: 3, y: 7.5, z: -4 }, config) + expect(eyeOf(state, { x: 0, y: 0, z: 0 })).toEqual({ + x: 3, + y: 7.5 + config.collision.capsuleHeightM * EYE_HEIGHT_RATIO, + z: -4, + }) + }) + + /** Agachado a cápsula encolhe, e o olho desce com ela: o tiro sai de onde o jogador está. */ + it('desce com a cápsula agachada', () => { + const state = createCharacterState({ x: 0, y: 0, z: 0 }, config) + state.capsuleHeightM = 1 + expect(eyeOf(state, { x: 0, y: 0, z: 0 }).y).toBeCloseTo(EYE_HEIGHT_RATIO, 12) + }) +}) diff --git a/packages/core/src/movement/eye.ts b/packages/core/src/movement/eye.ts new file mode 100644 index 00000000..093c616f --- /dev/null +++ b/packages/core/src/movement/eye.ts @@ -0,0 +1,25 @@ +import type { Vector3 } from '../math/vector3.ts' +import type { CharacterState } from './characterState.ts' + +/** + * Onde o olho fica na cápsula: 92 % da altura, como em gente de verdade. No + * topo (100 %) o jogador olhava de cima para o avatar de 1,8 m e o achava + * baixo — o olho de um corpo de 1,8 m está a uns 1,66 m, não a 1,8. + */ +export const EYE_HEIGHT_RATIO = 0.92 + +/** + * O olho de um tick, de onde o tiro sai. Cliente e servidor usam o mesmo: o + * servidor não tem o olho interpolado da câmera, e um tiro que sai de pontos + * diferentes nos dois lados pode acertar num e errar no outro. + * + * ```ts + * eyeOf(character.current, shotOrigin) + * ``` + */ +export function eyeOf(state: Readonly, out: Vector3): Vector3 { + out.x = state.position.x + out.y = state.position.y + state.capsuleHeightM * EYE_HEIGHT_RATIO + out.z = state.position.z + return out +} diff --git a/packages/core/src/weapon/shotRandom.test.ts b/packages/core/src/weapon/shotRandom.test.ts new file mode 100644 index 00000000..c26d4f5a --- /dev/null +++ b/packages/core/src/weapon/shotRandom.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { nextUnit } from '../math/seededRandom.ts' +import { SHOT_SEED_BASE, shotRandom } from './shotRandom.ts' + +describe('shotRandom', () => { + it('sorteia a mesma sequência para o mesmo tiro, nos dois lados da rede', () => { + expect(nextUnit(shotRandom(7))).toBe(nextUnit(shotRandom(7))) + }) + + it('sorteia diferente em tiros diferentes', () => { + expect(nextUnit(shotRandom(7))).not.toBe(nextUnit(shotRandom(8))) + }) + + it('usa a base compartilhada quando não recebe outra', () => { + expect(nextUnit(shotRandom(3))).toBe(nextUnit(shotRandom(3, SHOT_SEED_BASE))) + }) +}) diff --git a/packages/core/src/weapon/shotRandom.ts b/packages/core/src/weapon/shotRandom.ts new file mode 100644 index 00000000..e19bb0c8 --- /dev/null +++ b/packages/core/src/weapon/shotRandom.ts @@ -0,0 +1,17 @@ +import { createSeededRandom, type SeededRandom } from '../math/seededRandom.ts' + +/** + * A semente base da dispersão. O cone do tiro sem mira é sorteado, e cliente e + * servidor precisam sortear a mesma direção: a semente de cada tiro é esta base + * combinada com o contador de tiros da arma, que anda igual nos dois lados. + */ +export const SHOT_SEED_BASE = 20_251_119 + +/** + * ```ts + * resolveShot(origin, boxes, targets, shotRandom(weapon.shotsFired), hit) + * ``` + */ +export function shotRandom(shotsFired: number, seedBase: number = SHOT_SEED_BASE): SeededRandom { + return createSeededRandom(seedBase ^ shotsFired) +} diff --git a/packages/core/src/wire/wireFormat.test.ts b/packages/core/src/wire/wireFormat.test.ts index 8584cdda..cb5cff49 100644 --- a/packages/core/src/wire/wireFormat.test.ts +++ b/packages/core/src/wire/wireFormat.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { dot, normalizeInPlace } from '../math/vector3.ts' import { + aimOnWire, decodeButtons, decodeMovementInput, decodeStance, @@ -83,6 +84,14 @@ describe('mira', () => { }) }) +describe('aimOnWire', () => { + it('é a mira que o servidor decodifica do fio', () => { + const aim = { x: 0.3, y: -0.2, z: -0.93 } + const expected = dequantizeAim(quantizeAim(aim, emptyAim()), { x: 0, y: 0, z: 0 }) + expect(aimOnWire(aim, { x: 0, y: 0, z: 0 })).toEqual(expected) + }) +}) + describe('direção desejada', () => { it('ocupa int8 e satura acima de 1', () => { expect(quantizeWish(1)).toBe(127) diff --git a/packages/core/src/wire/wireFormat.ts b/packages/core/src/wire/wireFormat.ts index 14bc931e..72410a31 100644 --- a/packages/core/src/wire/wireFormat.ts +++ b/packages/core/src/wire/wireFormat.ts @@ -109,6 +109,22 @@ export function quantizeAim(aim: Readonly, return out } +const aimScratch: MutableWireAim = { aimX: 0, aimY: 0, aimZ: 0 } + +/** + * A mira exatamente como o servidor vai lê-la, escrita em `out`: quantizada e + * de volta a vetor unitário. O tiro previsto no cliente sai nesta direção, e + * não na da câmera, pelo mesmo motivo de `wishOnWire`: o servidor resolve o tiro + * com a mira do fio, e um raio diferente no último dígito erra onde o outro acerta. + * + * ```ts + * aimOnWire(frame.aimM, shotAim) + * ``` + */ +export function aimOnWire(aim: Readonly, out: Vector3): Vector3 { + return dequantizeAim(quantizeAim(aim, aimScratch), out) +} + /** Escreve em `out` a direção unitária, sem alocar. */ export function dequantizeAim(wire: WireAim, out: Vector3): Vector3 { out.x = wire.aimX From 2222f141e464377f2fcc9ec5f38c08d197e39d4e Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 18:57:04 -0300 Subject: [PATCH 053/139] fix: fire the predicted shot from the tick eye with the wire aim --- .../src/controller/arenaSession.test.ts | 12 ++++---- .../client/src/controller/arenaSession.ts | 28 +++++++++++-------- .../src/controller/localCharacter.test.ts | 3 +- .../client/src/controller/localCharacter.ts | 8 +----- .../src/renderer/driveCameraFromCharacter.ts | 2 +- 5 files changed, 27 insertions(+), 26 deletions(-) diff --git a/packages/client/src/controller/arenaSession.test.ts b/packages/client/src/controller/arenaSession.test.ts index cca58777..dd0db2c5 100644 --- a/packages/client/src/controller/arenaSession.test.ts +++ b/packages/client/src/controller/arenaSession.test.ts @@ -17,8 +17,13 @@ const config = parseGameplayConfig(JSON.parse(readFileSync(SHIPPED_CONFIG_URL, ' const FLOOR: StaticBox = boxFromCenterSize([0, -0.5, 0], [200, 1, 200]) const SPAWN_FEET = { x: 0, y: 0, z: 0 } -/** Um boneco a 20 m na frente, na convenção de olho dos spawns. */ -const DUMMY_POST: readonly [number, number, number] = [0, 1.8, 20] +/** + * Um boneco a 20 m na frente, com o pé no mesmo chão do jogador. A convenção + * dos postes é a de olho dos spawns, uma cápsula acima do pé: com a altura fixa + * em 1,8, o boneco afundava 20 cm quando a cápsula passou a 2 m, e o tiro só o + * acertava porque saía de um olho inventado, e não do olho do personagem. + */ +const DUMMY_POST: readonly [number, number, number] = [0, config.collision.capsuleHeightM, 20] const FRAME_S = 1 / 60 const TICK_S = 1 / config.simulation.tickHz @@ -47,8 +52,6 @@ function mountSession(boxes: readonly StaticBox[] = [FLOOR]): Rig { frame: (over = {}) => session.advance({ elapsedS: FRAME_S, - // o olho do jogador, mirando o tronco do boneco lá na frente. - eyeM: { x: 0, y: 1.656, z: 0 }, aimM: { x: 0, y: 0, z: 1 }, ...over, }), @@ -248,7 +251,6 @@ describe('createArenaSession', () => { movementInput.wishZ = 1 const ran = session.advance({ elapsedS: 3 * TICK_S, - eyeM: SPAWN_FEET, aimM: { x: 0, y: 0, z: 1 }, }) expect(ran).toBe(3) diff --git a/packages/client/src/controller/arenaSession.ts b/packages/client/src/controller/arenaSession.ts index 442cd08b..b52caabb 100644 --- a/packages/client/src/controller/arenaSession.ts +++ b/packages/client/src/controller/arenaSession.ts @@ -1,15 +1,16 @@ import { + aimOnWire, applyKill, awardMedals, collectLiveTargets, createKillEvent, createMedalAwards, createScoreboard, - createSeededRandom, createShotHit, createTargetList, createTrainingDummy, createWeaponState, + eyeOf, type GameplayConfig, hitHeightRatio, killTrainingDummy, @@ -18,8 +19,10 @@ import { type MutableKillEvent, resolveShot, type Scoreboard, + SHOT_SEED_BASE, type ShotHit, type StaticBox, + shotRandom, spreadTangent, stepTrainingDummy, stepWeapon, @@ -37,11 +40,6 @@ import type { MutableMovementInput } from './wishDirection.ts' /** O que o quadro traz do render para a simulação. */ export interface ArenaFrame { readonly elapsedS: number - /** - * O olho interpolado **sem balanço de câmera**: é de onde o tiro sai - * (`viewBob.ts`), e é o que o servidor rebobina (ADR 0002). - */ - readonly eyeM: Readonly /** A frente da câmera, em mundo. Não precisa vir unitária. */ readonly aimM: Readonly } @@ -103,7 +101,6 @@ export interface ArenaSessionOptions { /** O id do jogador local no placar. Opaco: amanhã é o sessionId do colyseus. */ export const LOCAL_PLAYER_ID = 'local' -const DEFAULT_SEED_BASE = 20_251_119 /** * O dono do passo de tick fixo da arena: arma, corpo, bonecos e placar, **um @@ -118,7 +115,7 @@ const DEFAULT_SEED_BASE = 20_251_119 * * ```ts * const session = createArenaSession({ config, character, boxes, dummyPostsM, ... }) - * session.advance({ elapsedS, eyeM, aimM }) + * session.advance({ elapsedS, aimM }) * ``` */ export function createArenaSession(options: ArenaSessionOptions): ArenaSession { @@ -155,6 +152,9 @@ interface ArenaParts { /** A tangente do cone sai uma vez por carga de config, nunca por tiro. */ readonly coneTangent: number readonly seedBase: number + /** Rascunhos do tiro: origem e mira, reaproveitados a cada disparo. */ + readonly shotOrigin: Vector3 + readonly shotAim: Vector3 } function createArenaParts(options: ArenaSessionOptions): ArenaParts { @@ -170,7 +170,9 @@ function createArenaParts(options: ArenaSessionOptions): ArenaParts { kill: createKillEvent(), medals: createMedalAwards(), coneTangent: spreadTangent(options.config.weapon.noScopeSpreadDeg), - seedBase: options.seedBase ?? DEFAULT_SEED_BASE, + seedBase: options.seedBase ?? SHOT_SEED_BASE, + shotOrigin: { x: 0, y: 0, z: 0 }, + shotAim: { x: 0, y: 0, z: -1 }, } } @@ -222,15 +224,17 @@ function fireAndScore( collectLiveTargets(parts.dummies, config, parts.targets) resolveShot( { - originM: frame.eyeM, - aimM: frame.aimM, + // o olho do tick e a mira do fio, não os da câmera: são os que o servidor + // de jogo tem, e o tiro previsto aqui tem que ser o mesmo raio que ele valida. + originM: eyeOf(options.character.current, parts.shotOrigin), + aimM: aimOnWire(frame.aimM, parts.shotAim), exact: parts.weapon.firedExact, spreadTangent: parts.coneTangent, maxDistanceM: config.weapon.hitscanRangeM, }, options.boxes, parts.targets, - createSeededRandom(parts.seedBase ^ parts.weapon.shotsFired), + shotRandom(parts.weapon.shotsFired, parts.seedBase), parts.shot, ) // `find` e não indexar por número: o tiro na parede traz -1, e nenhuma posição diff --git a/packages/client/src/controller/localCharacter.test.ts b/packages/client/src/controller/localCharacter.test.ts index 5c710045..4012699a 100644 --- a/packages/client/src/controller/localCharacter.test.ts +++ b/packages/client/src/controller/localCharacter.test.ts @@ -2,12 +2,13 @@ import { readFileSync } from 'node:fs' import { blockoutToStaticBoxes, createCharacterState, + EYE_HEIGHT_RATIO, GREYBOX_BLOCKOUT, IDLE_INPUT, parseGameplayConfig, } from '@shobu/core' import { describe, expect, it } from 'vitest' -import { createLocalCharacter, EYE_HEIGHT_RATIO } from './localCharacter.ts' +import { createLocalCharacter } from './localCharacter.ts' const SHIPPED_CONFIG_URL = new URL('../../../../config/gameplay.json', import.meta.url) const config = parseGameplayConfig(JSON.parse(readFileSync(SHIPPED_CONFIG_URL, 'utf8'))) diff --git a/packages/client/src/controller/localCharacter.ts b/packages/client/src/controller/localCharacter.ts index 5d84f0c4..9b9da650 100644 --- a/packages/client/src/controller/localCharacter.ts +++ b/packages/client/src/controller/localCharacter.ts @@ -4,6 +4,7 @@ import { copyCharacterState, createCharacterState, createFixedTickAccumulator, + EYE_HEIGHT_RATIO, type FixedTickAccumulator, type GameplayConfig, type MovementInput, @@ -61,13 +62,6 @@ export interface LocalCharacter { adopt(current: Readonly, previous: Readonly): void } -/** - * Onde o olho fica na cápsula: 92 % da altura, como em gente de verdade. No - * topo (100 %) o jogador olhava de cima para o avatar de 1,8 m e o achava - * baixo — o olho de um corpo de 1,8 m está a uns 1,66 m, não a 1,8. - */ -export const EYE_HEIGHT_RATIO = 0.92 - /** * Teto de ticks por quadro. Um quadro de 100 ms pede seis ticks; acima disso * o jogo atrasa em vez de entrar na espiral em que cada quadro pede mais que diff --git a/packages/client/src/renderer/driveCameraFromCharacter.ts b/packages/client/src/renderer/driveCameraFromCharacter.ts index 104b4cd1..596f36a6 100644 --- a/packages/client/src/renderer/driveCameraFromCharacter.ts +++ b/packages/client/src/renderer/driveCameraFromCharacter.ts @@ -64,7 +64,7 @@ export function driveCameraFromCharacter(scene: Scene, options: CameraDriverOpti const input = options.movementInput const eye: Vector3 = { x: 0, y: 0, z: 0 } const bob: ViewBobOffset = { up: 0, right: 0 } - const frame = { elapsedS: 0, eyeM: eye, aimM: { x: 0, y: 0, z: 1 } } + const frame = { elapsedS: 0, aimM: { x: 0, y: 0, z: 1 } } scene.onBeforeRenderObservable.add(() => { camera.rotation.x = clampPitchRad(camera.rotation.x) const basis = planarBasisOf(camera) From 9fdbf9e70209532b45070bd27c9c497bf9be84d2 Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 19:16:48 -0300 Subject: [PATCH 054/139] feat: decode the weapon input from the wire buttons --- packages/core/src/index.ts | 1 + packages/core/src/wire/wireFormat.test.ts | 15 +++++++++++++++ packages/core/src/wire/wireFormat.ts | 14 ++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cf38947c..c87cf4c0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -99,6 +99,7 @@ export { decodeButtons, decodeMovementInput, decodeStance, + decodeWeaponInput, dequantizeAim, dequantizePositionM, encodeButtons, diff --git a/packages/core/src/wire/wireFormat.test.ts b/packages/core/src/wire/wireFormat.test.ts index cb5cff49..494bf634 100644 --- a/packages/core/src/wire/wireFormat.test.ts +++ b/packages/core/src/wire/wireFormat.test.ts @@ -5,6 +5,7 @@ import { decodeButtons, decodeMovementInput, decodeStance, + decodeWeaponInput, dequantizeAim, dequantizePositionM, encodeButtons, @@ -165,3 +166,17 @@ describe('decodeMovementInput', () => { expect(decodeMovementInput(wire, true).scoped).toBe(true) }) }) + +describe('decodeWeaponInput', () => { + it('devolve disparo, mira e recarga dos botões do fio', () => { + const wire: WireInput = { + wishX: 0, + wishZ: 0, + buttons: encodeButtons({ ...NO_BUTTONS, fire: true, reload: true, sprint: true }), + aimX: 0, + aimY: 0, + aimZ: -32767, + } + expect(decodeWeaponInput(wire)).toEqual({ fire: true, scope: false, reload: true }) + }) +}) diff --git a/packages/core/src/wire/wireFormat.ts b/packages/core/src/wire/wireFormat.ts index 72410a31..af5b6b50 100644 --- a/packages/core/src/wire/wireFormat.ts +++ b/packages/core/src/wire/wireFormat.ts @@ -1,6 +1,7 @@ import { lengthSquared, normalizeInPlace, type Vector3 } from '../math/vector3.ts' import type { Stance } from '../movement/characterState.ts' import type { MovementInput } from '../movement/movementInput.ts' +import type { WeaponInput } from '../weapon/weaponInput.ts' /** * O formato de fio do canal de jogo: os inteiros que o servidor e o cliente @@ -210,3 +211,16 @@ export function decodeMovementInput(wire: WireInput, scoped: boolean): MovementI scoped, } } + +/** + * O input da arma de um tick, a partir do fio: os botões presos de disparo, + * mira e recarga, que o servidor passa ao mesmo `stepWeapon` do cliente. + * + * ```ts + * stepWeapon(seat.weapon, decodeWeaponInput(wire), config, dtS) + * ``` + */ +export function decodeWeaponInput(wire: WireInput): WeaponInput { + const buttons = decodeButtons(wire.buttons) + return { fire: buttons.fire, scope: buttons.scope, reload: buttons.reload } +} From 14d3e77f8157b5b896d9ea7d51349ca4a5045d03 Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 19:16:49 -0300 Subject: [PATCH 055/139] feat: validate the shot on the server against rewound positions --- packages/server/src/gameRoom.ts | 68 +++++-- packages/server/src/gameRoomState.test.ts | 5 +- packages/server/src/gameRoomState.ts | 1 + packages/server/src/roomShot.ts | 114 +++++++++++ packages/server/src/roomSimulation.test.ts | 216 ++++++++++++++++++--- packages/server/src/roomSimulation.ts | 125 +++++++++--- 6 files changed, 457 insertions(+), 72 deletions(-) create mode 100644 packages/server/src/roomShot.ts diff --git a/packages/server/src/gameRoom.ts b/packages/server/src/gameRoom.ts index b31e93ef..9349713e 100644 --- a/packages/server/src/gameRoom.ts +++ b/packages/server/src/gameRoom.ts @@ -1,12 +1,13 @@ -import { type Client, Room } from '@colyseus/core' -import type { GameplayConfig, StaticBox } from '@shobu/core' +import { type Client, type Rewind, Room } from '@colyseus/core' +import { dequantizePositionM, type WireInput } from '@shobu/core' import { GameRoomState, PlayerInput, PlayerState } from './gameRoomState.ts' import { admitPlayer, assertCanonicalAddress, createRoomSimulation, leaveRoom, - type SpawnPoints, + type RoomIo, + type RoomRules, stepRoom, writeRoomState, } from './roomSimulation.ts' @@ -16,17 +17,22 @@ import { * porque o colyseus mistura essas opções com as que o cliente manda, e um * cliente adulterado sobrescreveria a configuração do servidor. */ -export interface GameRoomDeps { - readonly config: GameplayConfig - readonly boxes: readonly StaticBox[] - readonly spawns: SpawnPoints -} +export type GameRoomDeps = RoomRules /** O que o cliente manda no matchmaking. */ export interface GameRoomOptions { readonly address?: string } +/** + * A janela do rewind, em ms. Cobre o buffer de interpolação (46,9 ms) mais meio + * RTT do pior perfil do ADR 0006, com folga; acima dela o servidor lê a posição + * mais antiga da janela, e um cliente não rebobina mais longe que isso. + */ +const MAX_REWIND_MS = 150 + +type PlayerStates = InstanceType['players'] + /** * A sala do servidor de jogo, ligada ao colyseus. Adapter de rede: a lógica * está em `roomSimulation.ts`, e o que fica aqui é só a cola. @@ -39,30 +45,58 @@ export function createGameRoom(deps: GameRoomDeps) { return class GameRoom extends Room { override state = new GameRoomState() override readonly inputs = this.defineInput(PlayerInput, { bufferMaxSize: 64 }) - readonly #simulation = createRoomSimulation() + readonly #simulation = createRoomSimulation(deps.config) + readonly #stateOf = (id: string) => this.state.players.get(id) override onCreate(options: GameRoomOptions): void { assertCanonicalAddress(options.address) this.maxClients = deps.config.match.playersPerRoom this.patchRate = 1000 / deps.config.simulation.patchHz - this.setFixedTimestep(() => this.#step(), deps.config.simulation.tickHz) + const rewind = this.allowRewindState({ maxRewindMs: MAX_REWIND_MS }) + const io = createRoomIo(this.state.players, this.inputs, rewind, options.address) + this.setFixedTimestep(() => { + stepRoom(this.#simulation, io, deps) + writeRoomState(this.#simulation, this.#stateOf) + }, deps.config.simulation.tickHz) } override onJoin(client: Client): void { this.state.players.set(client.sessionId, new PlayerState()) - admitPlayer(this.#simulation, client.sessionId, deps.spawns, deps.config, (id) => - this.state.players.get(id), - ) + admitPlayer(this.#simulation, client.sessionId, deps.spawns, deps.config, this.#stateOf) } override onLeave(client: Client): void { leaveRoom(this.#simulation, client.sessionId) this.state.players.delete(client.sessionId) } + } +} - #step(): void { - stepRoom(this.#simulation, (id) => this.inputs.get(id).next(), deps.boxes, deps.config) - writeRoomState(this.#simulation, (id) => this.state.players.get(id)) - } +/** + * O que a simulação lê do colyseus a cada tick. O rewind grava só a posição + * dos jogadores, a cada patch. `lastSeenBy` rebobina ao + * instante de tela do último input consumido do atirador, e o tiro é resolvido + * logo depois de consumir o input que disparou: é o instante daquele tiro. + */ +function createRoomIo( + players: PlayerStates, + inputs: { get(id: string): { next(): WireInput | undefined } }, + rewind: Rewind, + address: string | undefined, +): RoomIo { + rewind.attachAll(players, { fields: ['x', 'y', 'z'] }) + return { + nextInput: (id) => inputs.get(id).next(), + feetSeenBy: (shooterId, targetId, out) => { + const target = players.get(targetId) + if (!target) throw new Error(`alvo '${targetId}' sem estado; esperado jogador da sala`) + const seen = rewind.lastSeenBy(shooterId) + out.x = dequantizePositionM(seen.value(target, 'x')) + out.y = dequantizePositionM(seen.value(target, 'y')) + out.z = dequantizePositionM(seen.value(target, 'z')) + return out + }, + onKill: (shooterId, victimId) => + console.log(JSON.stringify({ event: 'match.kill', room: address, shooterId, victimId })), } } diff --git a/packages/server/src/gameRoomState.test.ts b/packages/server/src/gameRoomState.test.ts index 0ac06173..35044ebd 100644 --- a/packages/server/src/gameRoomState.test.ts +++ b/packages/server/src/gameRoomState.test.ts @@ -16,7 +16,7 @@ const config = parseGameplayConfig(JSON.parse(readFileSync(SHIPPED_CONFIG_URL, ' describe('PlayerState', () => { /** O snapshot do jogador é o que `writeRoomState` escreve: nenhum campo pode sumir no caminho. */ it('guarda o que writeRoomState escreve, nos limites de int16', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) const player = joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) player.character.position.x = -32.767 const state = new PlayerState() @@ -26,10 +26,11 @@ describe('PlayerState', () => { expect(state.x).toBe(quantizePositionM(-32.767)) expect(state.x).toBe(-32767) expect(state.aimZ).toBe(-32767) + expect(state.alive).toBe(true) }) it('codifica um estado com jogador sem erro de tipo no schema', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) const roomState = new GameRoomState() roomState.players.set('ana', new PlayerState()) diff --git a/packages/server/src/gameRoomState.ts b/packages/server/src/gameRoomState.ts index 998ec9bb..34789f68 100644 --- a/packages/server/src/gameRoomState.ts +++ b/packages/server/src/gameRoomState.ts @@ -17,6 +17,7 @@ export const PlayerState = schema( aimY: t.int16(), aimZ: t.int16(), stance: t.uint8(), + alive: t.boolean(), }, 'PlayerState', ) diff --git a/packages/server/src/roomShot.ts b/packages/server/src/roomShot.ts new file mode 100644 index 00000000..f1a2dd8e --- /dev/null +++ b/packages/server/src/roomShot.ts @@ -0,0 +1,114 @@ +import { + createShotHit, + createTargetList, + dequantizeAim, + eyeOf, + type GameplayConfig, + pushTarget, + resetTargetList, + resolveShot, + type ShotHit, + type StaticBox, + shotRandom, + spreadTangent, + type TargetList, + type Vector3, +} from '@shobu/core' +import type { RoomSeat } from './roomSimulation.ts' + +/** + * Onde o atirador viu o alvo: os pés do alvo no instante que a tela do + * atirador mostrava quando ele atirou, em metros. No servidor é o rewind do + * colyseus lido pelo último input consumido do atirador; nos testes, um fake. + */ +export type FeetSeenBy = (shooterId: string, targetId: string, out: Vector3) => Vector3 + +/** Os rascunhos do tiro da sala, reaproveitados: nada aloca por disparo. */ +export interface RoomShot { + readonly targets: TargetList + /** O assento de cada alvo, na posição do `sourceIndex` dele. */ + readonly targetSeats: RoomSeat[] + readonly hit: ShotHit + readonly origin: Vector3 + readonly aim: Vector3 + readonly seenFeet: Vector3 + readonly coneTangent: number +} + +export function createRoomShot(config: GameplayConfig): RoomShot { + return { + targets: createTargetList(config.match.playersPerRoom), + targetSeats: [], + hit: createShotHit(), + origin: { x: 0, y: 0, z: 0 }, + aim: { x: 0, y: 0, z: -1 }, + seenFeet: { x: 0, y: 0, z: 0 }, + coneTangent: spreadTangent(config.weapon.noScopeSpreadDeg), + } +} + +/** + * O tiro que saiu neste input, validado pelo servidor. Sai do olho do tick, + * com a mira do fio e a semente do contador de tiros: o mesmo raio que o + * cliente previu (`arenaSession.ts`). Os alvos são os outros jogadores vivos, + * nas posições rebobinadas para o que o atirador via. Devolve o assento + * atingido, ou `undefined` quando o tiro achou parede ou nada. + * + * ```ts + * const victim = resolveRoomShot(room.shot, room.seats, seat, io.feetSeenBy, boxes, config) + * ``` + */ +export function resolveRoomShot( + shot: RoomShot, + seats: ReadonlyMap, + shooter: RoomSeat, + feetSeenBy: FeetSeenBy, + boxes: readonly StaticBox[], + config: GameplayConfig, +): RoomSeat | undefined { + collectSeenTargets(shot, seats, shooter, feetSeenBy, config) + resolveShot( + { + originM: eyeOf(shooter.player.character, shot.origin), + aimM: dequantizeAim(shooter.aim, shot.aim), + exact: shooter.weapon.firedExact, + spreadTangent: shot.coneTangent, + maxDistanceM: config.weapon.hitscanRangeM, + }, + boxes, + shot.targets, + shotRandom(shooter.weapon.shotsFired), + shot.hit, + ) + // o tiro na parede traz -1, que não é posição de array: uma guarda só. + return shot.targetSeats[shot.hit.targetIndex] +} + +/** + * A altura do alvo é a de agora, e não a rebobinada: o rewind só grava a + * posição (`gameRoom.ts`). ponytail: quem agacha no instante do tiro tem a + * cápsula de agora; rebobinar a postura entra se a diferença aparecer em jogo. + */ +function collectSeenTargets( + shot: RoomShot, + seats: ReadonlyMap, + shooter: RoomSeat, + feetSeenBy: FeetSeenBy, + config: GameplayConfig, +): void { + resetTargetList(shot.targets) + shot.targetSeats.length = 0 + const shooterId = shooter.player.playerId + for (const [targetId, target] of seats) { + if (target === shooter || !target.alive) continue + const feetM = feetSeenBy(shooterId, targetId, shot.seenFeet) + pushTarget( + shot.targets, + shot.targetSeats.length, + feetM, + config.collision.capsuleRadiusM, + target.player.character.capsuleHeightM, + ) + shot.targetSeats.push(target) + } +} diff --git a/packages/server/src/roomSimulation.test.ts b/packages/server/src/roomSimulation.test.ts index 3b2f96a3..b2063ebd 100644 --- a/packages/server/src/roomSimulation.test.ts +++ b/packages/server/src/roomSimulation.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { blockoutToStaticBoxes, + boxFromCenterSize, decodeStance, dequantizePositionM, encodeButtons, @@ -9,6 +10,7 @@ import { parseGameplayConfig, quantizePositionM, spawnFeetM, + type Vector3, type WireInput, } from '@shobu/core' import { describe, expect, it } from 'vitest' @@ -21,6 +23,9 @@ import { joinRoom, leaveRoom, type PlayerStateFields, + type RoomIo, + type RoomRules, + type RoomSimulation, stepRoom, writeRoomState, } from './roomSimulation.ts' @@ -39,9 +44,19 @@ const NO_BUTTONS = encodeButtons({ }) const FORWARD_WIRE: WireInput = { wishX: 0, wishZ: -127, buttons: NO_BUTTONS, ...FORWARD_AIM } -/** A fila de input por jogador que o colyseus guarda; aqui, em memória. */ -class FakeInputQueues { +const rules: RoomRules = { config, boxes, spawns: GREYBOX_SPAWN_POINTS_M } + +/** + * O colyseus do lado da sala, em memória: a fila de input por jogador, o + * rewind e o log de kills. Sem posição rebobinada marcada, o atirador vê o + * alvo onde o alvo está agora. + */ +class FakeRoomIo implements RoomIo { readonly #queues = new Map() + readonly seenFeet = new Map() + readonly kills: string[] = [] + + constructor(readonly room: RoomSimulation) {} push(playerId: string, input: WireInput): void { const queue = this.#queues.get(playerId) ?? [] @@ -49,20 +64,36 @@ class FakeInputQueues { this.#queues.set(playerId, queue) } - readonly next = (playerId: string): WireInput | undefined => this.#queues.get(playerId)?.shift() - remaining(playerId: string): number { return this.#queues.get(playerId)?.length ?? 0 } + + nextInput(playerId: string): WireInput | undefined { + return this.#queues.get(playerId)?.shift() + } + + readonly feetSeenBy = (_shooterId: string, targetId: string, out: Vector3): Vector3 => { + const feetM = + this.seenFeet.get(targetId) ?? this.room.seats.get(targetId)?.player.character.position + if (!feetM) throw new Error(`alvo '${targetId}' fora da sala`) + out.x = feetM.x + out.y = feetM.y + out.z = feetM.z + return out + } + + readonly onKill = (shooterId: string, victimId: string): void => { + this.kills.push(`${shooterId}>${victimId}`) + } } function emptyState(): PlayerStateFields { - return { x: 0, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: 0, stance: 0 } + return { x: 0, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: 0, stance: 0, alive: false } } describe('joinRoom', () => { it('põe cada jogador que entra no spawn seguinte, com os pés no chão do spawn', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) const first = joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) const second = joinRoom(room, 'bia', GREYBOX_SPAWN_POINTS_M, config) const [x0, y0, z0] = spawnFeetM( @@ -76,7 +107,7 @@ describe('joinRoom', () => { /** Treze entradas numa sala que gira jogadores não podem estourar os doze spawns. */ it('volta ao primeiro spawn depois do último', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) for (let index = 0; index < GREYBOX_SPAWN_POINTS_M.length; index += 1) { joinRoom(room, `p${index}`, GREYBOX_SPAWN_POINTS_M, config) leaveRoom(room, `p${index}`) @@ -89,7 +120,7 @@ describe('joinRoom', () => { describe('joinRoom sem spawn', () => { it('recusa lista de spawn vazia, dizendo o tamanho', () => { - expect(() => joinRoom(createRoomSimulation(), 'ana', [], config)).toThrow(/recebeu 0/) + expect(() => joinRoom(createRoomSimulation(config), 'ana', [], config)).toThrow(/recebeu 0/) }) }) @@ -100,7 +131,7 @@ describe('admitPlayer', () => { * saltando 26 m para o spawn. */ it('escreve o estado de quem entra antes do primeiro tick', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) const state = emptyState() admitPlayer(room, 'ana', GREYBOX_SPAWN_POINTS_M, config, () => state) const [x0, , z0] = spawnFeetM( @@ -114,7 +145,7 @@ describe('admitPlayer', () => { describe('leaveRoom', () => { it('tira o jogador da partida e da sala', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) leaveRoom(room, 'ana') expect(room.match.players.size).toBe(0) @@ -124,14 +155,14 @@ describe('leaveRoom', () => { describe('stepRoom', () => { it('aplica um input no primeiro tick, com um crédito só', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) const player = joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) - const queues = new FakeInputQueues() + const queues = new FakeRoomIo(room) queues.push('ana', FORWARD_WIRE) queues.push('ana', FORWARD_WIRE) const startZ = player.character.position.z - stepRoom(room, queues.next, boxes, config) + stepRoom(room, queues, rules) expect(player.character.position.z).toBeLessThan(startZ) expect(queues.remaining('ana')).toBe(1) @@ -139,62 +170,184 @@ describe('stepRoom', () => { /** Sem input, o servidor não inventa tick: é o que deixa a reconciliação do cliente fechar. */ it('deixa parado quem não mandou input', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) const player = joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) const before = { ...player.character.position } - stepRoom(room, new FakeInputQueues().next, boxes, config) + stepRoom(room, new FakeRoomIo(room), rules) expect(player.character.position).toEqual(before) }) it('recupera o atraso de um soluço de rede com o crédito guardado', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) - const queues = new FakeInputQueues() - for (let tick = 0; tick < 3; tick += 1) stepRoom(room, queues.next, boxes, config) + const queues = new FakeRoomIo(room) + for (let tick = 0; tick < 3; tick += 1) stepRoom(room, queues, rules) for (let input = 0; input < 5; input += 1) queues.push('ana', FORWARD_WIRE) - stepRoom(room, queues.next, boxes, config) + stepRoom(room, queues, rules) expect(queues.remaining('ana')).toBe(1) }) it('guarda no máximo INPUT_CREDIT_CAP créditos', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) - const queues = new FakeInputQueues() - for (let tick = 0; tick < 20; tick += 1) stepRoom(room, queues.next, boxes, config) + const queues = new FakeRoomIo(room) + for (let tick = 0; tick < 20; tick += 1) stepRoom(room, queues, rules) for (let input = 0; input < 20; input += 1) queues.push('ana', FORWARD_WIRE) - stepRoom(room, queues.next, boxes, config) + stepRoom(room, queues, rules) expect(queues.remaining('ana')).toBe(20 - INPUT_CREDIT_CAP) }) /** Um cliente adulterado que manda três inputs por tick não anda três vezes mais rápido. */ it('não aplica mais de um input por tick na média', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) - const queues = new FakeInputQueues() + const queues = new FakeRoomIo(room) for (let tick = 0; tick < 10; tick += 1) { for (let input = 0; input < 3; input += 1) queues.push('ana', FORWARD_WIRE) - stepRoom(room, queues.next, boxes, config) + stepRoom(room, queues, rules) } expect(30 - queues.remaining('ana')).toBe(10) }) it('guarda a mira do último input', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) - const queues = new FakeInputQueues() + const queues = new FakeRoomIo(room) queues.push('ana', { ...FORWARD_WIRE, aimX: 32767, aimZ: 0 }) - stepRoom(room, queues.next, boxes, config) + stepRoom(room, queues, rules) expect(room.seats.get('ana')?.aim).toEqual({ aimX: 32767, aimY: 0, aimZ: 0 }) }) }) +/** + * Um campo aberto, só chão, com dois spawns frente a frente a 3 m: a mira + * padrão, para -z, cruza o alvo. A 3 m o cone sem luneta tem raio menor que o + * da cápsula, e o tiro acerta sem depender de sorte. + */ +const open: RoomRules = { + config, + boxes: [boxFromCenterSize([0, -0.5, 0], [200, 1, 200])], + spawns: [ + [0, config.collision.capsuleHeightM, 0], + [0, config.collision.capsuleHeightM, -3], + ], +} + +const FIRE_WIRE: WireInput = { + wishX: 0, + wishZ: 0, + buttons: encodeButtons({ + sprint: false, + jump: false, + crouch: false, + fire: true, + scope: false, + reload: false, + }), + ...FORWARD_AIM, +} + +interface Duel { + readonly room: RoomSimulation + readonly io: FakeRoomIo +} + +/** Ana no primeiro spawn, Bia no segundo, bem na mira de Ana. */ +function duel(): Duel { + const room = createRoomSimulation(config) + joinRoom(room, 'ana', open.spawns, config) + joinRoom(room, 'bia', open.spawns, config) + return { room, io: new FakeRoomIo(room) } +} + +function seat(room: RoomSimulation, playerId: string) { + const found = room.seats.get(playerId) + if (!found) throw new Error(`'${playerId}' fora da sala`) + return found +} + +describe('stepRoom, o tiro', () => { + it('mata o alvo na mira e avisa quem matou quem', () => { + const { room, io } = duel() + io.push('ana', FIRE_WIRE) + stepRoom(room, io, open) + expect(seat(room, 'bia').alive).toBe(false) + expect(io.kills).toEqual(['ana>bia']) + }) + + /** O rewind: o acerto vale contra o que a tela do atirador mostrava, não contra o agora. */ + it('acerta o alvo onde o atirador o viu, mesmo que ele já tenha saído dali', () => { + const { room, io } = duel() + seat(room, 'bia').player.character.position.x = 5 + io.seenFeet.set('bia', { x: 0, y: 0, z: -3 }) + io.push('ana', FIRE_WIRE) + stepRoom(room, io, open) + expect(io.kills).toEqual(['ana>bia']) + }) + + it('erra o alvo que o atirador viu fora da mira, mesmo que ele esteja nela agora', () => { + const { room, io } = duel() + io.seenFeet.set('bia', { x: 5, y: 0, z: -3 }) + io.push('ana', FIRE_WIRE) + stepRoom(room, io, open) + expect(seat(room, 'bia').alive).toBe(true) + }) + + it('não mira em quem já morreu', () => { + const { room, io } = duel() + seat(room, 'bia').alive = false + io.push('ana', FIRE_WIRE) + stepRoom(room, io, open) + expect(io.kills).toEqual([]) + }) + + /** O morto não atira nem anda, e a confirmação dele segue andando. */ + it('consome o input do morto sem mover nem atirar', () => { + const { room, io } = duel() + const bia = seat(room, 'bia') + bia.alive = false + bia.respawnLeftS = config.match.respawnDelayS + const before = { ...bia.player.character.position } + io.push('bia', { ...FIRE_WIRE, wishZ: 127, aimZ: 32767 }) + stepRoom(room, io, open) + expect(io.remaining('bia')).toBe(0) + expect(bia.player.character.position).toEqual(before) + expect(seat(room, 'ana').alive).toBe(true) + }) + + /** O cliente não sabe que morreu e segue contando tiros; o contador semeia a dispersão. */ + it('a arma do morto segue contando tiros', () => { + const { room, io } = duel() + seat(room, 'bia').alive = false + seat(room, 'bia').respawnLeftS = config.match.respawnDelayS + io.push('bia', FIRE_WIRE) + stepRoom(room, io, open) + expect(seat(room, 'bia').weapon.shotsFired).toBe(1) + }) + + it('o morto volta no spawn seguinte depois do atraso de respawn', () => { + const { room, io } = duel() + io.push('ana', FIRE_WIRE) + stepRoom(room, io, open) + const bia = seat(room, 'bia') + bia.player.character.position.y = 7 + const ticks = Math.ceil(config.match.respawnDelayS * config.simulation.tickHz) + // o tick da kill já conta: a vaga de Bia vem depois da de Ana no mesmo tick. + for (let tick = 2; tick < ticks; tick += 1) stepRoom(room, io, open) + expect(bia.alive).toBe(false) + stepRoom(room, io, open) + expect(bia.alive).toBe(true) + expect(bia.player.character.position).toEqual({ x: 0, y: 0, z: 0 }) + }) +}) + describe('writeRoomState', () => { it('escreve posição quantizada, mira e postura de cada jogador', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) const player = joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) const states = new Map([['ana', emptyState()]]) @@ -205,10 +358,11 @@ describe('writeRoomState', () => { expect(dequantizePositionM(written.y)).toBeCloseTo(player.character.position.y, 3) expect(written).toMatchObject(FORWARD_AIM) expect(decodeStance(written.stance)).toEqual({ stance: 'standing', grounded: false }) + expect(written.alive).toBe(true) }) it('pula jogador sem estado ainda criado', () => { - const room = createRoomSimulation() + const room = createRoomSimulation(config) joinRoom(room, 'ana', GREYBOX_SPAWN_POINTS_M, config) expect(() => writeRoomState(room, () => undefined)).not.toThrow() }) diff --git a/packages/server/src/roomSimulation.ts b/packages/server/src/roomSimulation.ts index 022d73c6..5400decb 100644 --- a/packages/server/src/roomSimulation.ts +++ b/packages/server/src/roomSimulation.ts @@ -1,6 +1,10 @@ import { + copyCharacterState, + createCharacterState, createMatch, + createWeaponState, decodeMovementInput, + decodeWeaponInput, encodeStance, type GameplayConfig, joinMatch, @@ -12,9 +16,13 @@ import { type StaticBox, spawnFeetM, stepMatchPlayer, + stepWeapon, + type Vector3, + type WeaponState, type WireAim, type WireInput, } from '@shobu/core' +import { createRoomShot, type FeetSeenBy, type RoomShot, resolveRoomShot } from './roomShot.ts' /** * A sala do servidor de jogo sem o colyseus: a partida do núcleo, a mira de @@ -24,16 +32,35 @@ import { export interface RoomSimulation { readonly match: Match readonly seats: Map - joins: number + /** Quantos spawns já foram dados, entre entradas e respawns: escolhe o próximo. */ + spawnsGiven: number + readonly shot: RoomShot } -/** Um jogador da sala: o personagem da partida, a última mira e o crédito de input. */ +/** Um jogador da sala: o personagem, a arma, a última mira e o crédito de input. */ export interface RoomSeat { readonly player: MatchPlayer + readonly weapon: WeaponState /** A mira viaja já quantizada e volta igual para os outros clientes. */ aim: WireAim /** Quantos inputs o jogador ainda pode aplicar; ver `INPUT_CREDIT_CAP`. */ inputCredits: number + alive: boolean + respawnLeftS: number +} + +/** O mundo fixo da sala: a configuração, a colisão e os spawns. */ +export interface RoomRules { + readonly config: GameplayConfig + readonly boxes: readonly StaticBox[] + readonly spawns: SpawnPoints +} + +/** O que a sala lê e avisa a cada tick; o colyseus do lado do servidor, fakes nos testes. */ +export interface RoomIo { + nextInput(playerId: string): WireInput | undefined + readonly feetSeenBy: FeetSeenBy + onKill(shooterId: string, victimId: string): void } /** @@ -60,13 +87,14 @@ export interface PlayerStateFields { aimY: number aimZ: number stance: number + alive: boolean } /** Olhando para -z até o primeiro input dizer outra coisa. */ export const FORWARD_AIM: WireAim = { aimX: 0, aimY: 0, aimZ: -32767 } -export function createRoomSimulation(): RoomSimulation { - return { match: createMatch(), seats: new Map(), joins: 0 } +export function createRoomSimulation(config: GameplayConfig): RoomSimulation { + return { match: createMatch(), seats: new Map(), spawnsGiven: 0, shot: createRoomShot(config) } } /** @@ -84,16 +112,26 @@ export function joinRoom( spawns: SpawnPoints, config: GameplayConfig, ): MatchPlayer { - const eyeM = spawns[room.joins % spawns.length] - if (!eyeM) - throw new Error(`joinRoom recebeu ${spawns.length} pontos de spawn; esperado ao menos 1`) - room.joins += 1 - const [x, y, z] = spawnFeetM(eyeM, config.collision.capsuleHeightM) - const player = joinMatch(room.match, playerId, { x, y, z }, config) - room.seats.set(playerId, { player, aim: FORWARD_AIM, inputCredits: 0 }) + const player = joinMatch(room.match, playerId, nextSpawnFeet(room, spawns, config), config) + room.seats.set(playerId, { + player, + weapon: createWeaponState(config), + aim: FORWARD_AIM, + inputCredits: 0, + alive: true, + respawnLeftS: 0, + }) return player } +function nextSpawnFeet(room: RoomSimulation, spawns: SpawnPoints, config: GameplayConfig): Vector3 { + const eyeM = spawns[room.spawnsGiven % spawns.length] + if (!eyeM) throw new Error(`a sala recebeu ${spawns.length} pontos de spawn; esperado ao menos 1`) + room.spawnsGiven += 1 + const [x, y, z] = spawnFeetM(eyeM, config.collision.capsuleHeightM) + return { x, y, z } +} + /** * Entra e já escreve o estado de todos. O primeiro patch com o jogador novo * pode sair antes do próximo tick; sem esta escrita ele levaria zeros, e os @@ -128,27 +166,69 @@ export function leaveRoom(room: RoomSimulation, playerId: string): void { * que o cliente nunca simulou, e a reconciliação não fecharia. * * ```ts - * stepRoom(room, (id) => this.inputs.get(id).next(), boxes, config) + * stepRoom(room, { nextInput, feetSeenBy, onKill }, { config, boxes, spawns }) * ``` */ -export function stepRoom( - room: RoomSimulation, - nextInput: (playerId: string) => WireInput | undefined, - boxes: readonly StaticBox[], - config: GameplayConfig, -): void { +export function stepRoom(room: RoomSimulation, io: RoomIo, rules: RoomRules): void { for (const [playerId, seat] of room.seats) { + tickRespawn(room, seat, rules) seat.inputCredits = Math.min(INPUT_CREDIT_CAP, seat.inputCredits + 1) while (seat.inputCredits >= 1) { - const wire = nextInput(playerId) + const wire = io.nextInput(playerId) if (!wire) break seat.inputCredits -= 1 - stepMatchPlayer(room.match, playerId, decodeMovementInput(wire, false), boxes, config) - seat.aim = { aimX: wire.aimX, aimY: wire.aimY, aimZ: wire.aimZ } + applyInput(room, seat, wire, io, rules) } } } +/** + * Um input, na ordem do cliente (`arenaSession.ts`): a arma antes do corpo, + * e o tiro do olho do tick, antes de o corpo andar. + * + * A arma anda mesmo com o jogador morto: o cliente não sabe que morreu e segue + * contando tiros, e o contador semeia a dispersão. Parar aqui faria os dois + * sortearem cones diferentes depois do respawn. O morto não atira nem anda, e + * o input é consumido assim mesmo, para a confirmação seguir andando. + */ +function applyInput( + room: RoomSimulation, + seat: RoomSeat, + wire: WireInput, + io: RoomIo, + rules: RoomRules, +): void { + const { config, boxes } = rules + seat.aim = { aimX: wire.aimX, aimY: wire.aimY, aimZ: wire.aimZ } + stepWeapon(seat.weapon, decodeWeaponInput(wire), config, 1 / config.simulation.tickHz) + if (!seat.alive) return + if (seat.weapon.firedThisTick) fireRoomShot(room, seat, io, rules) + const movement = decodeMovementInput(wire, seat.weapon.scoped) + stepMatchPlayer(room.match, seat.player.playerId, movement, boxes, config) +} + +function fireRoomShot(room: RoomSimulation, seat: RoomSeat, io: RoomIo, rules: RoomRules): void { + const { config, boxes } = rules + const victim = resolveRoomShot(room.shot, room.seats, seat, io.feetSeenBy, boxes, config) + if (!victim) return + victim.alive = false + victim.respawnLeftS = config.match.respawnDelayS + io.onKill(seat.player.playerId, victim.player.playerId) +} + +/** + * O morto fica parado onde caiu e volta, depois do atraso, no próximo spawn + * da lista, com o corpo zerado. A arma não é zerada: ver `applyInput`. + */ +function tickRespawn(room: RoomSimulation, seat: RoomSeat, rules: RoomRules): void { + if (seat.alive) return + seat.respawnLeftS -= 1 / rules.config.simulation.tickHz + if (seat.respawnLeftS > 0) return + seat.alive = true + const feetM = nextSpawnFeet(room, rules.spawns, rules.config) + copyCharacterState(createCharacterState(feetM, rules.config), seat.player.character) +} + /** * Escreve o snapshot quantizado de cada jogador no estado que o colyseus * sincroniza. Jogador sem estado ainda criado fica para o próximo tick. @@ -157,7 +237,7 @@ export function writeRoomState( room: RoomSimulation, stateOf: (playerId: string) => PlayerStateFields | undefined, ): void { - for (const [playerId, { player, aim }] of room.seats) { + for (const [playerId, { player, aim, alive }] of room.seats) { const out = stateOf(playerId) if (!out) continue const { position, stance, grounded } = player.character @@ -168,6 +248,7 @@ export function writeRoomState( out.aimY = aim.aimY out.aimZ = aim.aimZ out.stance = encodeStance(stance, grounded) + out.alive = alive } } From fabf34c611b9274bc787971b8e276207c24cea9b Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 19:16:58 -0300 Subject: [PATCH 056/139] fix: stamp each input with the interpolation delay for the rewind --- packages/client/src/net/gameLink.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/client/src/net/gameLink.ts b/packages/client/src/net/gameLink.ts index 4b8f5c13..dd3e9de6 100644 --- a/packages/client/src/net/gameLink.ts +++ b/packages/client/src/net/gameLink.ts @@ -44,7 +44,7 @@ export async function openGameLink( room.reconnection.enabled = false return { sessionId: room.sessionId, - ...createInputChannel(room), + ...createInputChannel(room, interpolationDelayMs), forEachRemote: createRemoteReader(room, interpolationDelayMs), ownSnapshot: () => playersOf(room.state).get(room.sessionId), leave: async () => { @@ -57,9 +57,17 @@ export async function openGameLink( * O canal de input confiável do sdk. Copia campo a campo no mesmo objeto, sem * alocar por tick; o `sentCount` depois do envio é o número de sequência que o * servidor confirma de volta em `lastProcessed`. + * + * O `renderDelay` é o atraso de interpolação do `Predict`: o sdk carimba cada + * input com o instante do servidor que a tela mostrava, e o rewind do servidor + * rebobina até ele. Sem ele o carimbo sai sem o buffer, e o servidor validaria + * o tiro contra posições 46,9 ms mais novas que as da tela. */ -function createInputChannel(room: Room): Pick { - const input = room.input() +function createInputChannel( + room: Room, + renderDelayMs: number, +): Pick { + const input = room.input({ renderDelay: renderDelayMs }) const data = input.data as MutableWireInput return { sendTickInput: (wire) => { From c7c3f951736556287b624ed10f7f71ccc106e042 Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 19:16:58 -0300 Subject: [PATCH 057/139] feat: hide the other players while they are dead --- packages/client/src/net/gameLink.ts | 7 ++++--- packages/client/src/net/networkPort.test.ts | 1 + packages/client/src/net/networkPort.ts | 2 +- packages/client/src/net/remotePlayerView.test.ts | 9 ++++++--- packages/client/src/net/remotePlayerView.ts | 4 ++++ packages/client/src/renderer/showRemotePlayers.ts | 9 +++++++++ 6 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/client/src/net/gameLink.ts b/packages/client/src/net/gameLink.ts index dd3e9de6..cd87a519 100644 --- a/packages/client/src/net/gameLink.ts +++ b/packages/client/src/net/gameLink.ts @@ -86,19 +86,20 @@ function createInputChannel( /** * O `Predict` da 0.18 interpola cada jogador no relógio do servidor, com o - * atraso do buffer de interpolação. A postura não se interpola: é um byte de - * estado, e o meio do caminho entre "em pé" e "no slide" não existe. + * atraso do buffer de interpolação. A postura e o `alive` não se interpolam: + * são estado discreto, e o meio do caminho entre "em pé" e "no slide" não existe. */ function createRemoteReader(room: Room, delayMs: number): GameLink['forEachRemote'] { const predict = Predict.get(room, { mode: 'lerp', delay: delayMs }) predict.attachAll('players', { fields: [...INTERPOLATED_FIELDS] }) - const scratch = { x: 0, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: 0, stance: 0 } + const scratch = { x: 0, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: 0, stance: 0, alive: true } return (nowMs, visit) => { predict.tick(nowMs) for (const [playerId, player] of playersOf(room.state)) { if (playerId === room.sessionId) continue for (const field of INTERPOLATED_FIELDS) scratch[field] = predict.value(player, field) scratch.stance = player.stance + scratch.alive = player.alive visit(playerId, scratch) } } diff --git a/packages/client/src/net/networkPort.test.ts b/packages/client/src/net/networkPort.test.ts index 909903b0..63fcefd6 100644 --- a/packages/client/src/net/networkPort.test.ts +++ b/packages/client/src/net/networkPort.test.ts @@ -13,6 +13,7 @@ const AT_SPAWN: PlayerSnapshotFields = { aimY: 0, aimZ: -32767, stance: 4, + alive: true, } /** A sala do colyseus, em memória: guarda o que recebeu e devolve um jogador remoto. */ diff --git a/packages/client/src/net/networkPort.ts b/packages/client/src/net/networkPort.ts index 23a941cf..a31ed38c 100644 --- a/packages/client/src/net/networkPort.ts +++ b/packages/client/src/net/networkPort.ts @@ -64,7 +64,7 @@ interface LinkSlot { function readConfirmation(slot: LinkSlot): () => OwnConfirmation | undefined { const scratch: { ackSeq: number; own: PlayerSnapshotFields } = { ackSeq: 0, - own: { x: 0, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: -32767, stance: 0 }, + own: { x: 0, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: -32767, stance: 0, alive: true }, } return () => { const own = slot.link?.ownSnapshot() diff --git a/packages/client/src/net/remotePlayerView.test.ts b/packages/client/src/net/remotePlayerView.test.ts index ace006f2..f8a095b4 100644 --- a/packages/client/src/net/remotePlayerView.test.ts +++ b/packages/client/src/net/remotePlayerView.test.ts @@ -22,18 +22,19 @@ describe('readRemotePlayer', () => { aimY: 0, aimZ: 0, stance: encodeStance('sliding', true), + alive: false, }, createRemotePlayerView(), ) expect(view.feetM).toEqual({ x: 26, y: 7.5, z: -12.345 }) expect(view.aim).toEqual({ x: 1, y: 0, z: 0 }) - expect(view).toMatchObject({ stance: 'sliding', grounded: true }) + expect(view).toMatchObject({ stance: 'sliding', grounded: true, alive: false }) }) /** Entre dois patches o `Predict` devolve o meio do caminho; a conversão não pode arredondar. */ it('aceita posição fracionária da interpolação', () => { const view = readRemotePlayer( - { x: 1000.5, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: -32767, stance: 0 }, + { x: 1000.5, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: -32767, stance: 0, alive: true }, createRemotePlayerView(), ) expect(view.feetM.x).toBeCloseTo(1.0005, 9) @@ -54,7 +55,9 @@ describe('playersOf', () => { }) it('devolve o mapa de jogadores quando o estado chegou', () => { - const players = new Map([['ana', { x: 0, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: -1, stance: 0 }]]) + const players = new Map([ + ['ana', { x: 0, y: 0, z: 0, aimX: 0, aimY: 0, aimZ: -1, stance: 0, alive: true }], + ]) expect(playersOf({ players })).toBe(players) }) }) diff --git a/packages/client/src/net/remotePlayerView.ts b/packages/client/src/net/remotePlayerView.ts index 1a3a1115..e67eaa15 100644 --- a/packages/client/src/net/remotePlayerView.ts +++ b/packages/client/src/net/remotePlayerView.ts @@ -18,6 +18,7 @@ export interface PlayerSnapshotFields extends WireAim { readonly y: number readonly z: number readonly stance: number + readonly alive: boolean } /** Como o render enxerga outro jogador, em metros e direção unitária. */ @@ -26,6 +27,7 @@ export interface RemotePlayerView { readonly aim: Vector3 stance: Stance grounded: boolean + alive: boolean } export function createRemotePlayerView(): RemotePlayerView { @@ -34,6 +36,7 @@ export function createRemotePlayerView(): RemotePlayerView { aim: { x: 0, y: 0, z: -1 }, stance: 'standing', grounded: true, + alive: true, } } @@ -57,6 +60,7 @@ export function readRemotePlayer( const { stance, grounded } = decodeStance(fields.stance) out.stance = stance out.grounded = grounded + out.alive = fields.alive return out } diff --git a/packages/client/src/renderer/showRemotePlayers.ts b/packages/client/src/renderer/showRemotePlayers.ts index e5074a5c..55ddd5f0 100644 --- a/packages/client/src/renderer/showRemotePlayers.ts +++ b/packages/client/src/renderer/showRemotePlayers.ts @@ -72,6 +72,15 @@ function poseRemote( options: ArenaRendererOptions, ): void { const { avatar, previousFeetM, pose, facing } = remote + avatar.root.setEnabled(view.alive) + // o morto some; ao voltar no spawn, o salto até lá não pode virar velocidade. + // ponytail: o `alive` chega cru e a posição com 46,9 ms de atraso, então o + // avatar reaparece por uns quadros onde morreu; interpolar o `alive` junto + // entra se isso aparecer em jogo. + if (!view.alive) { + remote.seen = false + return + } if (!remote.seen) Object.assign(previousFeetM, view.feetM) remote.seen = true avatar.root.position.set(view.feetM.x, view.feetM.y, view.feetM.z) From b1b2b1e39b7b724f3b849e171cd635350915f660 Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 19:17:09 -0300 Subject: [PATCH 058/139] docs: describe the rewound shot and its measurement --- .../rede/arquitetura-de-rede-3-tiro.drawio.svg | 2 +- docs/rede/arquitetura-de-rede.drawio | 2 +- docs/rede/arquitetura-de-rede.md | 18 ++++++++++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/rede/arquitetura-de-rede-3-tiro.drawio.svg b/docs/rede/arquitetura-de-rede-3-tiro.drawio.svg index dcf3c2d2..05336849 100644 --- a/docs/rede/arquitetura-de-rede-3-tiro.drawio.svg +++ b/docs/rede/arquitetura-de-rede-3-tiro.drawio.svg @@ -1,4 +1,4 @@ -Figura 3. Diagrama de sequência de um disparo validado pelo servidoratirador : Cliente: GameRoomalvo : Cliente1: aplicarInput(n){predição local}2: input(n, mira, disparo, renderTime)3: simularTick(n)4: rewind.lastSeenBy(atirador); raycast()5: aplicarKill(alvo)6a: evento kill; patch de estado6b: evento kill; patch de estado7: reconciliar(n)8: interpolar(renderTime)renderTime: instante exibido pelocliente, no relógio do servidor(room.clock), limitado ao histórico;rewind interpolado entre ticksFigura 3. Diagrama de sequência (UML) de um disparo validado pelo servidor. \ No newline at end of file +Figura 3. Diagrama de sequência de um disparo validado pelo servidoratirador : Cliente: GameRoomalvo : Cliente1: aplicarInput(n){predição local}2: input(n, mira, disparo, renderTime)3: simularTick(n)4: rewind.lastSeenBy(atirador); raycast()5: aplicarKill(alvo)6a: patch de estado (alvo morto)6b: patch de estado (alvo morto)7: reconciliar(n)8: interpolar(renderTime)renderTime: instante exibido pelocliente, no relógio do servidor(room.clock), limitado ao histórico;rewind interpolado entre patchesFigura 3. Diagrama de sequência (UML) de um disparo validado pelo servidor. \ No newline at end of file diff --git a/docs/rede/arquitetura-de-rede.drawio b/docs/rede/arquitetura-de-rede.drawio index fae857e0..3ce8633e 100644 --- a/docs/rede/arquitetura-de-rede.drawio +++ b/docs/rede/arquitetura-de-rede.drawio @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/docs/rede/arquitetura-de-rede.md b/docs/rede/arquitetura-de-rede.md index 2d70dbca..427c5133 100644 --- a/docs/rede/arquitetura-de-rede.md +++ b/docs/rede/arquitetura-de-rede.md @@ -134,11 +134,24 @@ preâmbulo independentemente do tamanho. Um cliente envia até 114 pacotes/s e r O cliente aplica o próprio disparo sem esperar a rede (mensagem 1), e o servidor decide o acerto (mensagens 3 a 5). Cada input leva um `renderTime`: o instante que o cliente exibia, no -relógio do servidor, sincronizado por `room.clock`. Na mensagem 4, `rewind.lastSeenBy` limita -esse instante à janela de histórico e rebobina as cápsulas até ele, interpolando entre ticks. O +relógio do servidor, sincronizado por `room.clock`. O SDK o calcula como o relógio do servidor +menos o buffer de interpolação e meio RTT, e o buffer só entra se o cliente o declarar em +`room.input({ renderDelay })`. Na mensagem 4, `rewind.lastSeenBy` limita esse instante à janela +de histórico e rebobina a posição dos outros jogadores até ele, interpolando entre patches. O servidor não estima o atraso do atirador: o cliente informa o que viu, e o servidor só aceita instantes dentro da janela. +O tiro do servidor é o mesmo raio que o cliente previu: sai do olho do personagem no tick do +input, com a mira quantizada do fio, e a dispersão sem luneta é sorteada com a semente do contador +de tiros. O jogador atingido morre, e o campo `vivo` do patch leva a morte aos outros clientes +(mensagens 6a e 6b). Morto, o input dele é consumido sem mover nem atirar, e a arma segue contando +tiros para a semente não divergir da do cliente. + +Medido contra o servidor real, com o alvo correndo a 7,5 m/s perpendicular à linha de tiro, a +52 m, e o atirador mirando no centro interpolado com buffer de 100 ms (0,75 m atrás da posição +real, mais que o raio de 0,4 m da cápsula): 20 acertos em 20 disparos com o `renderDelay` +declarado, e 2 em 20 sem ele. + A janela de 150 ms cobre o buffer de interpolação (46,9 ms), meio RTT do pior perfil da ADR 0006 (até 20 ms), um intervalo de patch (15,6 ms) e uma retransmissão TCP (cerca de 35 ms nesse perfil), somando 117,5 ms. Como o `renderTime` vem do cliente, a janela também limita quanto um @@ -185,6 +198,7 @@ deles, e a raiz quadrada da normalização é exata. O formato está em `wireFor | Input e snapshot | direção de mira, x, y e z | `int16` cada | 1/32767 por componente, cerca de 0,0017° | | Snapshot | posição, x, y e z | `int16` cada | 1 mm; cobre ±32,767 m | | Snapshot | postura: em pé, agachado, slide, no ar | `uint8` | | +| Snapshot | vivo | `boolean` | | *Tabela 4. Campos quantizados. Com a arena de ±32 m, a margem de posição é de 0,77 m; se a arena crescer, a resolução passa a 2 mm e cobre ±65 m. A tabela vale até o schema existir no From 86959962d839d31468a5d6601cd1c5b9221e5345 Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 20:49:31 -0300 Subject: [PATCH 059/139] feat: define the kill confirmation message in the wire format --- packages/core/src/index.ts | 9 ++++++++- packages/core/src/wire/wireFormat.ts | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c87cf4c0..55ef979a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -93,7 +93,13 @@ export { isReloading, weaponPhase, } from './weapon/weaponState.ts' -export type { MutableWireAim, WireAim, WireButtons, WireInput } from './wire/wireFormat.ts' +export type { + KillMessage, + MutableWireAim, + WireAim, + WireButtons, + WireInput, +} from './wire/wireFormat.ts' export { aimOnWire, decodeButtons, @@ -104,6 +110,7 @@ export { dequantizePositionM, encodeButtons, encodeStance, + KILL_MESSAGE, quantizeAim, quantizePositionM, quantizeWish, diff --git a/packages/core/src/wire/wireFormat.ts b/packages/core/src/wire/wireFormat.ts index af5b6b50..87fb1b69 100644 --- a/packages/core/src/wire/wireFormat.ts +++ b/packages/core/src/wire/wireFormat.ts @@ -224,3 +224,23 @@ export function decodeWeaponInput(wire: WireInput): WeaponInput { const buttons = decodeButtons(wire.buttons) return { fire: buttons.fire, scope: buttons.scope, reload: buttons.reload } } + +/** + * A confirmação da kill, do servidor só para o atirador. O cliente não prevê + * acerto em jogador (o acerto é do servidor), então o hitmarker espera por ela: + * chega meio RTT depois do input que disparou. + */ +export const KILL_MESSAGE = 'shot.kill' + +/** + * O que o atirador precisa para pontuar a kill como a de um boneco: as + * medalhas leem distância, altura do acerto, luneta e se ele estava no ar, e + * só o servidor tem o tiro validado. + */ +export interface KillMessage { + readonly victimId: string + readonly distanceM: number + readonly hitHeightRatio: number + readonly scoped: boolean + readonly shooterAirborne: boolean +} From 0e352576e643421043a98209d760ca28ef5e5f50 Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 20:49:31 -0300 Subject: [PATCH 060/139] feat: confirm each kill to the shooter with what the medals read --- packages/server/src/gameRoom.ts | 38 ++++++++++++++-------- packages/server/src/roomShot.ts | 32 ++++++++++++++++++ packages/server/src/roomSimulation.test.ts | 26 +++++++++++++-- packages/server/src/roomSimulation.ts | 13 ++++++-- 4 files changed, 91 insertions(+), 18 deletions(-) diff --git a/packages/server/src/gameRoom.ts b/packages/server/src/gameRoom.ts index 9349713e..1eeac8a2 100644 --- a/packages/server/src/gameRoom.ts +++ b/packages/server/src/gameRoom.ts @@ -1,5 +1,5 @@ import { type Client, type Rewind, Room } from '@colyseus/core' -import { dequantizePositionM, type WireInput } from '@shobu/core' +import { dequantizePositionM, KILL_MESSAGE, type WireInput } from '@shobu/core' import { GameRoomState, PlayerInput, PlayerState } from './gameRoomState.ts' import { admitPlayer, @@ -31,7 +31,13 @@ export interface GameRoomOptions { */ const MAX_REWIND_MS = 150 -type PlayerStates = InstanceType['players'] +/** O pedaço da sala do colyseus que a simulação usa. */ +interface RoomSources { + readonly state: InstanceType + readonly inputs: { get(id: string): { next(): WireInput | undefined } } + readonly clients: { getById(sessionId: string): Pick | undefined } + allowRewindState(options: { maxRewindMs: number }): Rewind +} /** * A sala do servidor de jogo, ligada ao colyseus. Adapter de rede: a lógica @@ -52,8 +58,7 @@ export function createGameRoom(deps: GameRoomDeps) { assertCanonicalAddress(options.address) this.maxClients = deps.config.match.playersPerRoom this.patchRate = 1000 / deps.config.simulation.patchHz - const rewind = this.allowRewindState({ maxRewindMs: MAX_REWIND_MS }) - const io = createRoomIo(this.state.players, this.inputs, rewind, options.address) + const io = createRoomIo(this, options.address) this.setFixedTimestep(() => { stepRoom(this.#simulation, io, deps) writeRoomState(this.#simulation, this.#stateOf) @@ -78,15 +83,12 @@ export function createGameRoom(deps: GameRoomDeps) { * instante de tela do último input consumido do atirador, e o tiro é resolvido * logo depois de consumir o input que disparou: é o instante daquele tiro. */ -function createRoomIo( - players: PlayerStates, - inputs: { get(id: string): { next(): WireInput | undefined } }, - rewind: Rewind, - address: string | undefined, -): RoomIo { +function createRoomIo(room: RoomSources, address: string | undefined): RoomIo { + const { players } = room.state + const rewind = room.allowRewindState({ maxRewindMs: MAX_REWIND_MS }) rewind.attachAll(players, { fields: ['x', 'y', 'z'] }) return { - nextInput: (id) => inputs.get(id).next(), + nextInput: (id) => room.inputs.get(id).next(), feetSeenBy: (shooterId, targetId, out) => { const target = players.get(targetId) if (!target) throw new Error(`alvo '${targetId}' sem estado; esperado jogador da sala`) @@ -96,7 +98,17 @@ function createRoomIo( out.z = dequantizePositionM(seen.value(target, 'z')) return out }, - onKill: (shooterId, victimId) => - console.log(JSON.stringify({ event: 'match.kill', room: address, shooterId, victimId })), + onKill: announceKill(room.clients, address), + } +} + +/** O log da kill, e a confirmação para o atirador, se ele ainda está na sala. */ +function announceKill( + clients: { getById(sessionId: string): Pick | undefined }, + address: string | undefined, +): RoomIo['onKill'] { + return (shooterId, kill) => { + console.log(JSON.stringify({ event: 'match.kill', room: address, shooterId, ...kill })) + clients.getById(shooterId)?.send(KILL_MESSAGE, kill) } } diff --git a/packages/server/src/roomShot.ts b/packages/server/src/roomShot.ts index f1a2dd8e..8297d7be 100644 --- a/packages/server/src/roomShot.ts +++ b/packages/server/src/roomShot.ts @@ -4,6 +4,8 @@ import { dequantizeAim, eyeOf, type GameplayConfig, + hitHeightRatio, + type KillMessage, pushTarget, resetTargetList, resolveShot, @@ -112,3 +114,33 @@ function collectSeenTargets( shot.targetSeats.push(target) } } + +/** + * A kill como o atirador a pontua, tirada do tiro que acabou de ser resolvido: + * a altura do acerto é contra a cápsula rebobinada, a mesma em que o raio bateu. + * + * ```ts + * io.onKill(shooterId, describeRoomKill(room.shot, seat, victim)) + * ``` + */ +export function describeRoomKill(shot: RoomShot, shooter: RoomSeat, victim: RoomSeat): KillMessage { + // o `sourceIndex` de cada caixa é a posição dela, então só a do alvo + // atingido bate, inclusive entre as sobras de ticks anteriores. + for (const box of shot.targets.boxes) { + if (box.sourceIndex !== shot.hit.targetIndex) continue + shot.seenFeet.x = box.feetX + shot.seenFeet.y = box.feetY + shot.seenFeet.z = box.feetZ + } + return { + victimId: victim.player.playerId, + distanceM: shot.hit.distanceM, + hitHeightRatio: hitHeightRatio( + shot.hit.endpointM, + shot.seenFeet, + victim.player.character.capsuleHeightM, + ), + scoped: shooter.weapon.firedScoped, + shooterAirborne: !shooter.player.character.grounded, + } +} diff --git a/packages/server/src/roomSimulation.test.ts b/packages/server/src/roomSimulation.test.ts index b2063ebd..823cd93a 100644 --- a/packages/server/src/roomSimulation.test.ts +++ b/packages/server/src/roomSimulation.test.ts @@ -4,9 +4,11 @@ import { boxFromCenterSize, decodeStance, dequantizePositionM, + EYE_HEIGHT_RATIO, encodeButtons, GREYBOX_BLOCKOUT, GREYBOX_SPAWN_POINTS_M, + type KillMessage, parseGameplayConfig, quantizePositionM, spawnFeetM, @@ -82,8 +84,11 @@ class FakeRoomIo implements RoomIo { return out } - readonly onKill = (shooterId: string, victimId: string): void => { - this.kills.push(`${shooterId}>${victimId}`) + readonly killMessages: KillMessage[] = [] + + readonly onKill = (shooterId: string, kill: KillMessage): void => { + this.kills.push(`${shooterId}>${kill.victimId}`) + this.killMessages.push(kill) } } @@ -279,6 +284,23 @@ describe('stepRoom, o tiro', () => { expect(io.kills).toEqual(['ana>bia']) }) + /** + * O atirador pontua a kill como a de um boneco, e as medalhas leem estes + * campos. Mira reta a partir do olho: o acerto é na altura do olho, na frente + * da cápsula a 3 m. + */ + it('descreve a kill com distância, altura do acerto, luneta e chão', () => { + const { room, io } = duel() + seat(room, 'ana').player.character.grounded = true + io.push('ana', FIRE_WIRE) + stepRoom(room, io, open) + const [kill] = io.killMessages + // sem luneta o tiro sai no cone, então os números são aproximados + expect(kill?.distanceM).toBeCloseTo(3 - config.collision.capsuleRadiusM, 1) + expect(kill?.hitHeightRatio).toBeCloseTo(EYE_HEIGHT_RATIO, 1) + expect(kill).toMatchObject({ victimId: 'bia', scoped: false, shooterAirborne: false }) + }) + /** O rewind: o acerto vale contra o que a tela do atirador mostrava, não contra o agora. */ it('acerta o alvo onde o atirador o viu, mesmo que ele já tenha saído dali', () => { const { room, io } = duel() diff --git a/packages/server/src/roomSimulation.ts b/packages/server/src/roomSimulation.ts index 5400decb..a94a897e 100644 --- a/packages/server/src/roomSimulation.ts +++ b/packages/server/src/roomSimulation.ts @@ -8,6 +8,7 @@ import { encodeStance, type GameplayConfig, joinMatch, + type KillMessage, leaveMatch, type Match, type MatchPlayer, @@ -22,7 +23,13 @@ import { type WireAim, type WireInput, } from '@shobu/core' -import { createRoomShot, type FeetSeenBy, type RoomShot, resolveRoomShot } from './roomShot.ts' +import { + createRoomShot, + describeRoomKill, + type FeetSeenBy, + type RoomShot, + resolveRoomShot, +} from './roomShot.ts' /** * A sala do servidor de jogo sem o colyseus: a partida do núcleo, a mira de @@ -60,7 +67,7 @@ export interface RoomRules { export interface RoomIo { nextInput(playerId: string): WireInput | undefined readonly feetSeenBy: FeetSeenBy - onKill(shooterId: string, victimId: string): void + onKill(shooterId: string, kill: KillMessage): void } /** @@ -213,7 +220,7 @@ function fireRoomShot(room: RoomSimulation, seat: RoomSeat, io: RoomIo, rules: R if (!victim) return victim.alive = false victim.respawnLeftS = config.match.respawnDelayS - io.onKill(seat.player.playerId, victim.player.playerId) + io.onKill(seat.player.playerId, describeRoomKill(room.shot, seat, victim)) } /** From dce248da1f0ad15a55f06b9660f3cae275c9737e Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 20:49:32 -0300 Subject: [PATCH 061/139] feat: score confirmed kills on other players like dummy kills --- .../src/controller/arenaSession.test.ts | 26 +++++++++ .../client/src/controller/arenaSession.ts | 57 ++++++++++++++++--- packages/client/src/net/gameLink.ts | 7 ++- packages/client/src/net/networkPort.test.ts | 35 +++++++++++- packages/client/src/net/networkPort.ts | 15 ++++- .../src/renderer/babylonArenaRenderer.ts | 15 ++++- .../client/src/renderer/driveArenaReadouts.ts | 6 +- 7 files changed, 144 insertions(+), 17 deletions(-) diff --git a/packages/client/src/controller/arenaSession.test.ts b/packages/client/src/controller/arenaSession.test.ts index dd0db2c5..4dad2d49 100644 --- a/packages/client/src/controller/arenaSession.test.ts +++ b/packages/client/src/controller/arenaSession.test.ts @@ -301,6 +301,32 @@ describe('createArenaSession', () => { expect(rig.session.weapon.reloadLeftS).toBeGreaterThan(0) }) + it('a kill no boneco diz quem morreu, para o killfeed', () => { + const rig = mountSession() + settleScope(rig) + pullTrigger(rig) + expect(rig.session.lastVictimId).toBe(rig.session.dummies[0]?.id) + }) + + /** + * A kill num jogador é do servidor: o cliente pontua a confirmação pelo mesmo + * placar do boneco, com as medalhas lendo o que o servidor mandou do tiro. + */ + it('a kill confirmada pelo servidor pontua como a de um boneco', () => { + const rig = mountSession() + rig.frame() + rig.session.scoreConfirmedKill({ + victimId: 'bia', + distanceM: 40, + hitHeightRatio: 0.95, + scoped: true, + shooterAirborne: false, + }) + expect(rig.session.scoreboard.kills).toBe(1) + expect(rig.session.lastVictimId).toBe('bia') + expect(rig.session.lastMedals.map((award) => award.medal.slug)).toContain('headshot') + }) + /** A reprodução que o servidor vai precisar: mesma entrada, mesmo tiro. */ it('o mesmo quadro com a mesma semente produz o mesmo tiro', () => { const first = mountSession() diff --git a/packages/client/src/controller/arenaSession.ts b/packages/client/src/controller/arenaSession.ts index b52caabb..ce337c89 100644 --- a/packages/client/src/controller/arenaSession.ts +++ b/packages/client/src/controller/arenaSession.ts @@ -13,6 +13,7 @@ import { eyeOf, type GameplayConfig, hitHeightRatio, + type KillMessage, killTrainingDummy, type MedalAward, type MovementInput, @@ -59,6 +60,8 @@ export interface ArenaSession { * **Reaproveitado**, com o mesmo contrato do `lastShot`. */ readonly lastMedals: readonly MedalAward[] + /** Quem morreu na última kill, boneco ou jogador. É o nome da linha do killfeed. */ + readonly lastVictimId: string | undefined /** Segundos de partida já simulados. É o `atS` do evento de kill. */ readonly matchTimeS: number /** @@ -72,6 +75,12 @@ export interface ArenaSession { */ restartMatch(): void advance(frame: Readonly): number + /** + * Pontua a kill num outro jogador, que o servidor confirmou, pelo mesmo + * caminho da kill num boneco: placar, medalhas e o visor que os lê. Chamar + * depois do `advance` do quadro, que esvazia as medalhas do quadro anterior. + */ + scoreConfirmedKill(kill: Readonly): void } export interface ArenaSessionOptions { @@ -127,11 +136,17 @@ export function createArenaSession(options: ArenaSessionOptions): ArenaSession { scoreboard: parts.scoreboard, lastShot: undefined as Readonly | undefined, lastMedals: parts.medals as readonly MedalAward[], + lastVictimId: undefined as string | undefined, matchTimeS: 0, restartMatch: (): void => { session.matchTimeS = 0 }, advance: (frame: Readonly): number => advanceArena(session, options, parts, frame), + scoreConfirmedKill: (kill: Readonly): void => { + describeConfirmedKill(parts.kill, kill, session.matchTimeS) + scoreKill(parts, options.config) + session.lastVictimId = kill.victimId + }, } return session } @@ -181,7 +196,11 @@ function createArenaParts(options: ArenaSessionOptions): ArenaParts { * corpo**. Ver o docblock de `createArenaSession`. */ function advanceArena( - session: { lastShot: Readonly | undefined; matchTimeS: number }, + session: { + lastShot: Readonly | undefined + lastVictimId: string | undefined + matchTimeS: number + }, options: ArenaSessionOptions, parts: ArenaParts, frame: Readonly, @@ -193,7 +212,8 @@ function advanceArena( for (let tick = 0; tick < ticks; tick += 1) { stepWeapon(parts.weapon, weaponInput, config, character.tickDurationS) if (parts.weapon.firedThisTick) { - fireAndScore(options, parts, frame, session.matchTimeS) + const victimId = fireAndScore(options, parts, frame, session.matchTimeS) + if (victimId) session.lastVictimId = victimId session.lastShot = parts.shot } movementInput.scoped = parts.weapon.scoped @@ -219,7 +239,7 @@ function fireAndScore( parts: ArenaParts, frame: Readonly, atS: number, -): void { +): string | undefined { const { config } = options collectLiveTargets(parts.dummies, config, parts.targets) resolveShot( @@ -245,13 +265,36 @@ function fireAndScore( // Sem checar `alive` de novo: `collectLiveTargets` só enfileira boneco vivo, // e o tiro é resolvido no mesmo tick em que a lista foi montada. const victim = parts.dummies.find((_dummy, index) => index === parts.shot.targetIndex) - if (!victim) return + if (!victim) return undefined killTrainingDummy(victim, config.match.respawnDelayS) - const kill = describeKill(parts, options.character, victim, atS, config.collision.capsuleHeightM) + describeKill(parts, options.character, victim, atS, config.collision.capsuleHeightM) + scoreKill(parts, config) + return victim.id +} + +/** A kill já descrita em `parts.kill`, no placar. Vale para boneco e para jogador. */ +function scoreKill(parts: ArenaParts, config: GameplayConfig): void { // as medalhas antes da kill: as condições leem a sequência da vítima, a // contagem da partida e a janela de multikill, e `applyKill` muda as três. - awardMedals(parts.scoreboard, kill, config.medals, parts.medals) - applyKill(parts.scoreboard, kill, config.match.pointsPerKill) + awardMedals(parts.scoreboard, parts.kill, config.medals, parts.medals) + applyKill(parts.scoreboard, parts.kill, config.match.pointsPerKill) +} + +/** A kill confirmada pelo servidor, que mandou o que as medalhas leem do tiro. */ +function describeConfirmedKill( + into: MutableKillEvent, + kill: Readonly, + atS: number, +): void { + into.shooterId = LOCAL_PLAYER_ID + into.victimId = kill.victimId + into.atS = atS + into.weapon = 'sniper' + into.scoped = kill.scoped + into.distanceM = kill.distanceM + into.shooterAirborne = kill.shooterAirborne + into.hitHeightRatio = kill.hitHeightRatio + describeMissingMechanics(into) } function describeKill( diff --git a/packages/client/src/net/gameLink.ts b/packages/client/src/net/gameLink.ts index cd87a519..ed4891ff 100644 --- a/packages/client/src/net/gameLink.ts +++ b/packages/client/src/net/gameLink.ts @@ -1,6 +1,6 @@ import { Client, type Room } from '@colyseus/sdk' import { Predict } from '@colyseus/sdk/predict' -import type { WireInput } from '@shobu/core' +import { KILL_MESSAGE, type KillMessage, type WireInput } from '@shobu/core' import type { MutableWireInput } from './encodeTickInput.ts' import { type PlayerSnapshotFields, playersOf } from './remotePlayerView.ts' @@ -22,6 +22,8 @@ export interface GameLink { ): void /** O snapshot do próprio jogador, cru, ou `undefined` antes do primeiro estado. */ ownSnapshot(): PlayerSnapshotFields | undefined + /** Cada kill que o servidor confirmou para este jogador. */ + onKill(listener: (kill: KillMessage) => void): void leave(): Promise } @@ -47,6 +49,9 @@ export async function openGameLink( ...createInputChannel(room, interpolationDelayMs), forEachRemote: createRemoteReader(room, interpolationDelayMs), ownSnapshot: () => playersOf(room.state).get(room.sessionId), + onKill: (listener) => { + room.onMessage(KILL_MESSAGE, listener) + }, leave: async () => { await room.leave() }, diff --git a/packages/client/src/net/networkPort.test.ts b/packages/client/src/net/networkPort.test.ts index 63fcefd6..006fb3ad 100644 --- a/packages/client/src/net/networkPort.test.ts +++ b/packages/client/src/net/networkPort.test.ts @@ -1,4 +1,4 @@ -import type { WireInput } from '@shobu/core' +import type { KillMessage, WireInput } from '@shobu/core' import { describe, expect, it } from 'vitest' import type { GameLink } from './gameLink.ts' import { createNetworkPort } from './networkPort.ts' @@ -45,9 +45,42 @@ class FakeGameLink implements GameLink { return this.own } + #killListener: (kill: KillMessage) => void = () => {} + + onKill(listener: (kill: KillMessage) => void): void { + this.#killListener = listener + } + + /** O servidor confirmando uma kill deste jogador. */ + confirmKill(victimId: string): void { + this.#killListener({ + victimId, + distanceM: 30, + hitHeightRatio: 0.9, + scoped: true, + shooterAirborne: false, + }) + } + async leave(): Promise {} } +describe('drainKills', () => { + it('entrega cada kill confirmada uma vez, na ordem', () => { + const port = createNetworkPort() + const link = new FakeGameLink() + port.attach(link) + link.confirmKill('bia') + link.confirmKill('caio') + const first: string[] = [] + port.drainKills((kill) => first.push(kill.victimId)) + const second: string[] = [] + port.drainKills((kill) => second.push(kill.victimId)) + expect(first).toEqual(['bia', 'caio']) + expect(second).toEqual([]) + }) +}) + describe('createNetworkPort', () => { it('roda sozinho antes da conexão, sem erro e sem remoto', () => { const port = createNetworkPort() diff --git a/packages/client/src/net/networkPort.ts b/packages/client/src/net/networkPort.ts index a31ed38c..f54a40b6 100644 --- a/packages/client/src/net/networkPort.ts +++ b/packages/client/src/net/networkPort.ts @@ -1,4 +1,4 @@ -import { dequantizePositionM, type Vector3, type WireInput } from '@shobu/core' +import { dequantizePositionM, type KillMessage, type Vector3, type WireInput } from '@shobu/core' import type { GameLink } from './gameLink.ts' import type { PlayerSnapshotFields } from './remotePlayerView.ts' @@ -29,6 +29,11 @@ export interface NetworkPort { takeOwnSpawn(): Vector3 | undefined /** O que a reconciliação precisa, ou `undefined` sem sala ou antes do primeiro estado. */ confirmation(): OwnConfirmation | undefined + /** + * Visita as kills confirmadas desde a última chamada, uma vez cada. A + * mensagem chega fora do quadro; o render as lê no quadro seguinte. + */ + drainKills(visit: (kill: KillMessage) => void): void } /** @@ -38,13 +43,18 @@ export interface NetworkPort { * ``` */ export function createNetworkPort(): NetworkPort { - const slot: LinkSlot = { link: undefined, spawnPending: false } + const slot: LinkSlot = { link: undefined, spawnPending: false, kills: [] } return { sendTickInput: (input) => slot.link?.sendTickInput(input), forEachRemote: (nowMs, visit) => slot.link?.forEachRemote(nowMs, visit), attach: (next) => { slot.link = next slot.spawnPending = true + next.onKill((kill) => slot.kills.push(kill)) + }, + drainKills: (visit) => { + for (const kill of slot.kills) visit(kill) + slot.kills.length = 0 }, confirmation: readConfirmation(slot), takeOwnSpawn: () => takeSpawn(slot), @@ -58,6 +68,7 @@ export function createNetworkPort(): NetworkPort { interface LinkSlot { link: GameLink | undefined spawnPending: boolean + readonly kills: KillMessage[] } /** A confirmação é lida a cada quadro, então o objeto devolvido é sempre o mesmo. */ diff --git a/packages/client/src/renderer/babylonArenaRenderer.ts b/packages/client/src/renderer/babylonArenaRenderer.ts index 586bf6ce..16d814c7 100644 --- a/packages/client/src/renderer/babylonArenaRenderer.ts +++ b/packages/client/src/renderer/babylonArenaRenderer.ts @@ -3,7 +3,7 @@ import { Engine } from '@babylonjs/core/Engines/engine' import { Vector3 } from '@babylonjs/core/Maths/math.vector' import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh' import { Scene } from '@babylonjs/core/scene' -import { blockoutToStaticBoxes, spawnFeetM } from '@shobu/core' +import { blockoutToStaticBoxes, type KillMessage, spawnFeetM } from '@shobu/core' import { buildGreyboxArena } from '../arena/buildGreyboxArena.ts' import { trainingDummyPostsM } from '../arena/trainingDummyPosts.ts' import { driveArenaAudio } from '../audio/driveArenaAudio.ts' @@ -110,7 +110,8 @@ export function createBabylonArenaRenderer(options: ArenaRendererOptions): Arena }) mirrorLocalCharacter(scene, options, character, keyboard.keys, () => engine.getDeltaTime()) showTrainingDummies(scene, options, session) - if (options.network) followNetwork(scene, options, options.network, { character, history }) + if (options.network) + followNetwork(scene, options, options.network, { character, history, session }) const viewmodel = attachSniperViewmodel(scene, camera, options.config.weapon) const hud: ArenaHud = options.hud ?? createSilentHud() const zoom = createScopeZoom(!prefersReducedMotion()) @@ -321,10 +322,18 @@ function followNetwork( scene: Scene, options: ArenaRendererOptions, network: NetworkPort, - local: { readonly character: LocalCharacter; readonly history: PredictionHistory }, + local: { + readonly character: LocalCharacter + readonly history: PredictionHistory + readonly session: ArenaSession + }, ): void { showRemotePlayers(scene, options, network) + // registrado depois do `advance` e antes dos readouts: a kill confirmada + // entra no placar no mesmo quadro em que o visor o lê. + const scoreKill = (kill: KillMessage): void => local.session.scoreConfirmedKill(kill) scene.onBeforeRenderObservable.add(() => { + network.drainKills(scoreKill) const spawn = network.takeOwnSpawn() if (spawn) local.character.teleport(spawn) const confirmation = network.confirmation() diff --git a/packages/client/src/renderer/driveArenaReadouts.ts b/packages/client/src/renderer/driveArenaReadouts.ts index 1d0a617b..34852047 100644 --- a/packages/client/src/renderer/driveArenaReadouts.ts +++ b/packages/client/src/renderer/driveArenaReadouts.ts @@ -67,7 +67,8 @@ function writeScore(hud: ArenaHud, session: ArenaSession, last: LastReadouts): v } /** - * Todo alvo que morre hoje morreu para o jogador local: não há outro atirador. + * Toda kill que chega aqui é do jogador local: num boneco, pelo tiro previsto, + * ou num jogador, pela confirmação do servidor. * * Os pontos vêm da **diferença no placar**, e não de `pointsPerKill`. Era uma * aposta quando foi escrito, e o dia chegou: as medalhas somam por cima da @@ -80,10 +81,9 @@ function announceKill(hud: ArenaHud, session: ArenaSession, kills: number, point hud.showHitmarker() hud.showKillPoints(points, medalLabels(session.lastMedals)) hud.pushMedals(session.lastMedals) - const victim = session.dummies.find((dummy) => !dummy.alive) hud.pushKill({ killer: 'VOCÊ', - victim: (victim?.id ?? `ALVO-${kills}`).toUpperCase(), + victim: (session.lastVictimId ?? `ALVO-${kills}`).toUpperCase(), weapon: 'SNIPER', mine: true, }) From 6fc6fc0825c66e17831cc1c962ba4c3469618c6d Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 20:49:32 -0300 Subject: [PATCH 062/139] feat: log the close code when a player leaves the room --- packages/client/src/net/gameLink.ts | 3 +++ packages/server/src/gameRoom.ts | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/client/src/net/gameLink.ts b/packages/client/src/net/gameLink.ts index ed4891ff..9c8424f1 100644 --- a/packages/client/src/net/gameLink.ts +++ b/packages/client/src/net/gameLink.ts @@ -44,6 +44,9 @@ export async function openGameLink( ): Promise { const room = await new Client(serverUrl).joinOrCreate('arena', { address }) room.reconnection.enabled = false + // sem reconexão, sair da sala é silencioso: o jogo segue sozinho. o log com + // o código é o que diz se a sala caiu, e por quê. + room.onLeave((code, reason) => console.warn(JSON.stringify({ event: 'room.left', code, reason }))) return { sessionId: room.sessionId, ...createInputChannel(room, interpolationDelayMs), diff --git a/packages/server/src/gameRoom.ts b/packages/server/src/gameRoom.ts index 1eeac8a2..7049f496 100644 --- a/packages/server/src/gameRoom.ts +++ b/packages/server/src/gameRoom.ts @@ -70,7 +70,10 @@ export function createGameRoom(deps: GameRoomDeps) { admitPlayer(this.#simulation, client.sessionId, deps.spawns, deps.config, this.#stateOf) } - override onLeave(client: Client): void { + override onLeave(client: Client, code?: number): void { + // o código de fechamento diz quem fechou e por quê: 1000/4000 é saída + // limpa, 1006 é queda sem aviso (o heartbeat derrubou). + console.log(JSON.stringify({ event: 'room.left', sessionId: client.sessionId, code })) leaveRoom(this.#simulation, client.sessionId) this.state.players.delete(client.sessionId) } From d0dde4de0e7291dc37b8a58aa8b2d95e50b13c13 Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 20:49:41 -0300 Subject: [PATCH 063/139] docs: describe the kill confirmation sent to the shooter --- docs/rede/arquitetura-de-rede.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/rede/arquitetura-de-rede.md b/docs/rede/arquitetura-de-rede.md index 427c5133..281d13fb 100644 --- a/docs/rede/arquitetura-de-rede.md +++ b/docs/rede/arquitetura-de-rede.md @@ -145,7 +145,10 @@ O tiro do servidor é o mesmo raio que o cliente previu: sai do olho do personag input, com a mira quantizada do fio, e a dispersão sem luneta é sorteada com a semente do contador de tiros. O jogador atingido morre, e o campo `vivo` do patch leva a morte aos outros clientes (mensagens 6a e 6b). Morto, o input dele é consumido sem mover nem atirar, e a arma segue contando -tiros para a semente não divergir da do cliente. +tiros para a semente não divergir da do cliente. O atirador recebe ainda a mensagem `shot.kill`, só para ele, +com a distância, a altura do acerto na cápsula rebobinada, a luneta e se estava no ar: o cliente +não prevê acerto em jogador, então hitmarker, placar e medalhas esperam essa confirmação, meio RTT +depois do disparo. Medido contra o servidor real, com o alvo correndo a 7,5 m/s perpendicular à linha de tiro, a 52 m, e o atirador mirando no centro interpolado com buffer de 100 ms (0,75 m atrás da posição From 0796a05a33251a4dddc764ac6cc290ec01b6427f Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 20:58:31 -0300 Subject: [PATCH 064/139] feat: name the room entry route and the queue call in the wire protocol --- packages/core/src/index.ts | 2 ++ packages/core/src/room/roomEntry.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 packages/core/src/room/roomEntry.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 55ef979a..771e3cf4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -54,6 +54,8 @@ export { hasWish, IDLE_INPUT, wishLength, wishUnit } from './movement/movementIn export { SLIDE_CAPSULE_RATIO, SLIDE_ENTRY_SPEED_RATIO } from './movement/slide.ts' export { stepCharacter } from './movement/stepCharacter.ts' export { generateRoomAddress, isReservedAddress, parseRoomAddress } from './room/roomAddress.ts' +export type { SeatOffer } from './room/roomEntry.ts' +export { ROOM_ENTRY_PATH, ROOM_MOVE_MESSAGE } from './room/roomEntry.ts' export type { KillEvent, KillWeapon, MutableKillEvent } from './scoring/killEvent.ts' export { createKillEvent } from './scoring/killEvent.ts' export type { PlayerScore, Scoreboard } from './scoring/scoreboard.ts' diff --git a/packages/core/src/room/roomEntry.ts b/packages/core/src/room/roomEntry.ts new file mode 100644 index 00000000..7178b921 --- /dev/null +++ b/packages/core/src/room/roomEntry.ts @@ -0,0 +1,20 @@ +/** + * O matchmaking pela URL, no fio: o cliente pede a entrada pelo endereço numa + * rota HTTP do servidor de jogo, e recebe o assento reservado na sala em que + * vai jogar. Com a sala pedida cheia, essa é a sala provisória, e o servidor + * manda outro assento, na sala pedida, quando a vaga abre (CONTEXT.md, "Fila + * da sala"). + */ +export const ROOM_ENTRY_PATH = '/rooms/enter' + +/** Do servidor para quem está na fila: a vaga abriu, e o assento já está reservado. */ +export const ROOM_MOVE_MESSAGE = 'room.move' + +/** + * Um assento reservado e o endereço da sala dele, que é o que a URL passa a + * mostrar. A reserva é a do colyseus; o núcleo não a conhece por dentro. + */ +export interface SeatOffer { + readonly reservation: Reservation + readonly address: string +} From 299830eb381049037f1e48cd9530eecf2bb6ff33 Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 20:58:32 -0300 Subject: [PATCH 065/139] feat: add the room directory with a queue per address --- packages/server/src/roomDirectory.test.ts | 124 ++++++++++++++++++++++ packages/server/src/roomDirectory.ts | 116 ++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 packages/server/src/roomDirectory.test.ts create mode 100644 packages/server/src/roomDirectory.ts diff --git a/packages/server/src/roomDirectory.test.ts b/packages/server/src/roomDirectory.test.ts new file mode 100644 index 00000000..08f0cf04 --- /dev/null +++ b/packages/server/src/roomDirectory.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + createRoomDirectory, + enqueueWaiter, + forgetWaiter, + planEntry, + registerRoom, + takeNextWaiter, + unregisterRoom, +} from './roomDirectory.ts' + +const REQUESTED = '203.0.113.7' +const OTHER = '198.51.100.9' + +/** As vagas de cada sala, como o colyseus as conta; aqui, um conjunto de endereços. */ +class FakeVacancies { + readonly #open = new Set() + + open(address: string): this { + this.#open.add(address) + return this + } + + readonly has = (address: string): boolean => this.#open.has(address) +} + +describe('planEntry', () => { + it('cria a sala do endereço que ainda não tem sala', () => { + const directory = createRoomDirectory() + expect(planEntry(directory, REQUESTED, new FakeVacancies().has)).toEqual({ + kind: 'create', + address: REQUESTED, + }) + }) + + it('põe na sala do endereço quando há vaga e ninguém espera', () => { + const directory = createRoomDirectory() + registerRoom(directory, REQUESTED, 'sala-a') + expect(planEntry(directory, REQUESTED, new FakeVacancies().open(REQUESTED).has)).toEqual({ + kind: 'join', + address: REQUESTED, + }) + }) + + /** Regressão: o `joinOrCreate` do colyseus abria uma segunda sala com o mesmo endereço. */ + it('com a sala cheia, põe na fila e numa sala provisória com vaga', () => { + const directory = createRoomDirectory() + registerRoom(directory, REQUESTED, 'sala-a') + registerRoom(directory, OTHER, 'sala-b') + expect(planEntry(directory, REQUESTED, new FakeVacancies().open(OTHER).has)).toEqual({ + kind: 'wait', + address: REQUESTED, + provisional: OTHER, + }) + }) + + it('sem sala nenhuma com vaga, a provisória é uma sala nova', () => { + const directory = createRoomDirectory() + registerRoom(directory, REQUESTED, 'sala-a') + registerRoom(directory, OTHER, 'sala-b') + expect(planEntry(directory, REQUESTED, new FakeVacancies().has)).toMatchObject({ + kind: 'wait', + provisional: undefined, + }) + }) + + /** A vaga que abriu é do primeiro da fila, não de quem acabou de chegar. */ + it('com gente na fila, quem chega espera mesmo que haja vaga', () => { + const directory = createRoomDirectory() + registerRoom(directory, REQUESTED, 'sala-a') + enqueueWaiter(directory, REQUESTED, { sessionId: 'ana', roomId: 'sala-b' }) + expect(planEntry(directory, REQUESTED, new FakeVacancies().open(REQUESTED).has)).toMatchObject({ + kind: 'wait', + }) + }) + + it('não usa como provisória uma sala que tem fila própria', () => { + const directory = createRoomDirectory() + registerRoom(directory, REQUESTED, 'sala-a') + registerRoom(directory, OTHER, 'sala-b') + enqueueWaiter(directory, OTHER, { sessionId: 'ana', roomId: 'sala-c' }) + const vacancies = new FakeVacancies().open(OTHER) + expect(planEntry(directory, REQUESTED, vacancies.has)).toMatchObject({ provisional: undefined }) + }) +}) + +describe('registerRoom', () => { + it('recusa um segundo roomId para o mesmo endereço, dizendo os dois', () => { + const directory = createRoomDirectory() + registerRoom(directory, REQUESTED, 'sala-a') + expect(() => registerRoom(directory, REQUESTED, 'sala-b')).toThrow(/'sala-a'.*'sala-b'/) + }) + + it('libera o endereço e descarta a fila quando a sala fecha', () => { + const directory = createRoomDirectory() + registerRoom(directory, REQUESTED, 'sala-a') + enqueueWaiter(directory, REQUESTED, { sessionId: 'ana', roomId: 'sala-b' }) + unregisterRoom(directory, REQUESTED) + expect(directory.rooms.has(REQUESTED)).toBe(false) + expect(directory.queues.has(REQUESTED)).toBe(false) + }) +}) + +describe('fila', () => { + it('chama por ordem de chegada e apaga a fila vazia', () => { + const directory = createRoomDirectory() + enqueueWaiter(directory, REQUESTED, { sessionId: 'ana', roomId: 'sala-b' }) + enqueueWaiter(directory, REQUESTED, { sessionId: 'bia', roomId: 'sala-c' }) + expect(takeNextWaiter(directory, REQUESTED)?.sessionId).toBe('ana') + expect(takeNextWaiter(directory, REQUESTED)?.sessionId).toBe('bia') + expect(takeNextWaiter(directory, REQUESTED)).toBeUndefined() + expect(directory.queues.has(REQUESTED)).toBe(false) + }) + + it('tira de toda fila quem saiu do jogo', () => { + const directory = createRoomDirectory() + enqueueWaiter(directory, REQUESTED, { sessionId: 'ana', roomId: 'sala-b' }) + enqueueWaiter(directory, REQUESTED, { sessionId: 'bia', roomId: 'sala-c' }) + enqueueWaiter(directory, OTHER, { sessionId: 'ana', roomId: 'sala-b' }) + forgetWaiter(directory, 'ana') + expect(directory.queues.get(REQUESTED)).toEqual([{ sessionId: 'bia', roomId: 'sala-c' }]) + expect(directory.queues.has(OTHER)).toBe(false) + }) +}) diff --git a/packages/server/src/roomDirectory.ts b/packages/server/src/roomDirectory.ts new file mode 100644 index 00000000..7ab326b4 --- /dev/null +++ b/packages/server/src/roomDirectory.ts @@ -0,0 +1,116 @@ +/** + * O matchmaking pela URL, sem o colyseus: qual sala tem qual endereço, e a + * fila de cada sala cheia. O `filterBy` do colyseus não serve para isto: com a + * sala cheia, o `joinOrCreate` dele cria uma segunda sala com o mesmo + * endereço, e quem abriu o link cairia longe de quem o mandou (ADR 0011). + * + * ponytail: o diretório vive na memória de um processo só. Com mais de um + * processo de servidor de jogo, ele vira um registro compartilhado (a + * `presence` do colyseus). + */ +export interface RoomDirectory { + /** O endereço de cada sala aberta, e o `roomId` dela no colyseus. */ + readonly rooms: Map + /** Quem espera vaga em cada endereço, por ordem de chegada. */ + readonly queues: Map +} + +/** Um jogador da fila e a sala provisória em que ele joga enquanto espera. */ +export interface Waiter { + readonly sessionId: string + readonly roomId: string +} + +/** + * O que fazer com quem abriu um endereço: + * - `create`: não há sala nele, e ela nasce agora; + * - `join`: a sala tem vaga e ninguém espera por ela; + * - `wait`: a sala está cheia, ou a vaga já é de alguém da fila. O jogador + * entra na fila e joga na sala provisória `provisional`; sem nenhuma sala + * com vaga, uma sala nova é aberta para ele. + */ +export type EntryPlan = + | { readonly kind: 'create'; readonly address: string } + | { readonly kind: 'join'; readonly address: string } + | { readonly kind: 'wait'; readonly address: string; readonly provisional: string | undefined } + +export function createRoomDirectory(): RoomDirectory { + return { rooms: new Map(), queues: new Map() } +} + +/** + * ```ts + * const plan = planEntry(directory, '203.0.113.7', (address) => vacancyOf(address)) + * ``` + */ +export function planEntry( + directory: RoomDirectory, + address: string, + hasVacancy: (address: string) => boolean, +): EntryPlan { + if (!directory.rooms.has(address)) return { kind: 'create', address } + if (hasVacancy(address) && !directory.queues.has(address)) return { kind: 'join', address } + return { kind: 'wait', address, provisional: pickProvisional(directory, address, hasVacancy) } +} + +/** + * Uma sala com vaga que não seja a pedida. Sala com fila própria fica de fora: + * a vaga dela é de quem já espera por ela. + */ +function pickProvisional( + directory: RoomDirectory, + requested: string, + hasVacancy: (address: string) => boolean, +): string | undefined { + for (const address of directory.rooms.keys()) { + if (address === requested || directory.queues.has(address)) continue + if (hasVacancy(address)) return address + } + return undefined +} + +/** + * Dois `roomId` para o mesmo endereço é exatamente o defeito que o diretório + * existe para impedir, então é erro, e não sobrescrita. + */ +export function registerRoom(directory: RoomDirectory, address: string, roomId: string): void { + const existing = directory.rooms.get(address) + if (existing !== undefined) { + throw new Error( + `o endereço '${address}' já é da sala '${existing}'; esperado endereço livre para '${roomId}'`, + ) + } + directory.rooms.set(address, roomId) +} + +/** + * A sala fechou. Quem ainda esperava por ela segue na sala provisória: a sala + * só fecha vazia, e vazia ela já teria chamado a fila inteira. + */ +export function unregisterRoom(directory: RoomDirectory, address: string): void { + directory.rooms.delete(address) + directory.queues.delete(address) +} + +export function enqueueWaiter(directory: RoomDirectory, address: string, waiter: Waiter): void { + const queue = directory.queues.get(address) + if (queue) queue.push(waiter) + else directory.queues.set(address, [waiter]) +} + +/** O primeiro da fila, que sai dela. A fila vazia deixa de existir. */ +export function takeNextWaiter(directory: RoomDirectory, address: string): Waiter | undefined { + const queue = directory.queues.get(address) + const waiter = queue?.shift() + if (queue?.length === 0) directory.queues.delete(address) + return waiter +} + +/** Quem saiu do jogo enquanto esperava sai de toda fila. */ +export function forgetWaiter(directory: RoomDirectory, sessionId: string): void { + for (const [address, queue] of directory.queues) { + const kept = queue.filter((waiter) => waiter.sessionId !== sessionId) + if (kept.length === 0) directory.queues.delete(address) + else directory.queues.set(address, kept) + } +} From bee82af676f1628a40126a3e0cd20b16636a5491 Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 20:58:32 -0300 Subject: [PATCH 066/139] feat: enter rooms by address through a queueing matchmaking route --- packages/server/src/gameRoom.ts | 47 +++++--- packages/server/src/main.ts | 13 ++- packages/server/src/matchmaking.ts | 158 ++++++++++++++++++++++++++ packages/server/src/roomSimulation.ts | 2 +- vitest.config.ts | 1 + 5 files changed, 204 insertions(+), 17 deletions(-) create mode 100644 packages/server/src/matchmaking.ts diff --git a/packages/server/src/gameRoom.ts b/packages/server/src/gameRoom.ts index 7049f496..7d3a7ba4 100644 --- a/packages/server/src/gameRoom.ts +++ b/packages/server/src/gameRoom.ts @@ -1,6 +1,7 @@ import { type Client, type Rewind, Room } from '@colyseus/core' -import { dequantizePositionM, KILL_MESSAGE, type WireInput } from '@shobu/core' +import { dequantizePositionM, type GameplayConfig, KILL_MESSAGE, type WireInput } from '@shobu/core' import { GameRoomState, PlayerInput, PlayerState } from './gameRoomState.ts' +import type { RoomLifecycle } from './matchmaking.ts' import { admitPlayer, assertCanonicalAddress, @@ -8,6 +9,7 @@ import { leaveRoom, type RoomIo, type RoomRules, + type RoomSimulation, stepRoom, writeRoomState, } from './roomSimulation.ts' @@ -17,7 +19,9 @@ import { * porque o colyseus mistura essas opções com as que o cliente manda, e um * cliente adulterado sobrescreveria a configuração do servidor. */ -export type GameRoomDeps = RoomRules +export interface GameRoomDeps extends RoomRules { + readonly lifecycle: RoomLifecycle +} /** O que o cliente manda no matchmaking. */ export interface GameRoomOptions { @@ -44,7 +48,7 @@ interface RoomSources { * está em `roomSimulation.ts`, e o que fica aqui é só a cola. * * ```ts - * defineRoom(createGameRoom({ config, boxes, spawns })).filterBy(['address']) + * defineRoom(createGameRoom({ config, boxes, spawns, lifecycle: matchmaking })) * ``` */ export function createGameRoom(deps: GameRoomDeps) { @@ -53,16 +57,13 @@ export function createGameRoom(deps: GameRoomDeps) { override readonly inputs = this.defineInput(PlayerInput, { bufferMaxSize: 64 }) readonly #simulation = createRoomSimulation(deps.config) readonly #stateOf = (id: string) => this.state.players.get(id) + #address = '' override onCreate(options: GameRoomOptions): void { assertCanonicalAddress(options.address) - this.maxClients = deps.config.match.playersPerRoom - this.patchRate = 1000 / deps.config.simulation.patchHz - const io = createRoomIo(this, options.address) - this.setFixedTimestep(() => { - stepRoom(this.#simulation, io, deps) - writeRoomState(this.#simulation, this.#stateOf) - }, deps.config.simulation.tickHz) + this.#address = options.address + deps.lifecycle.roomCreated(options.address, this.roomId) + runRoom(this, deps, this.#simulation, options.address) } override onJoin(client: Client): void { @@ -71,15 +72,35 @@ export function createGameRoom(deps: GameRoomDeps) { } override onLeave(client: Client, code?: number): void { - // o código de fechamento diz quem fechou e por quê: 1000/4000 é saída - // limpa, 1006 é queda sem aviso (o heartbeat derrubou). - console.log(JSON.stringify({ event: 'room.left', sessionId: client.sessionId, code })) leaveRoom(this.#simulation, client.sessionId) this.state.players.delete(client.sessionId) + deps.lifecycle.playerLeft(this.#address, client.sessionId, code) + } + + override onDispose(): void { + deps.lifecycle.roomDisposed(this.#address) } } } +/** Capacidade, ritmo de patch e o passo fixo da simulação, da configuração. */ +function runRoom( + room: Room & RoomSources, + deps: GameRoomDeps, + simulation: RoomSimulation, + address: string, +): void { + const { config } = deps + room.maxClients = config.match.playersPerRoom + room.patchRate = 1000 / config.simulation.patchHz + const io = createRoomIo(room, address) + const stateOf = (id: string) => room.state.players.get(id) + room.setFixedTimestep(() => { + stepRoom(simulation, io, deps) + writeRoomState(simulation, stateOf) + }, config.simulation.tickHz) +} + /** * O que a simulação lê do colyseus a cada tick. O rewind grava só a posição * dos jogadores, a cada patch. `lastSeenBy` rebobina ao diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index b8dccf3d..1f5c749a 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs' -import { defineRoom, defineServer } from '@colyseus/core' +import { createRouter, defineRoom, defineServer } from '@colyseus/core' import { WebSocketTransport } from '@colyseus/ws-transport' import { blockoutToStaticBoxes, @@ -8,6 +8,7 @@ import { parseGameplayConfig, } from '@shobu/core' import { createGameRoom } from './gameRoom.ts' +import { createMatchmaking, ROOM_NAME } from './matchmaking.ts' /** A porta padrão do colyseus, a da Tabela 1 de `docs/rede/arquitetura-de-rede.md`. */ const PORT = 2567 @@ -23,20 +24,26 @@ const HEARTBEAT_MISSES = 2 const CONFIG_URL = new URL('../../../config/gameplay.json', import.meta.url) const config = parseGameplayConfig(JSON.parse(readFileSync(CONFIG_URL, 'utf8'))) +const matchmaking = createMatchmaking() + const server = defineServer({ transport: new WebSocketTransport({ pingInterval: HEARTBEAT_INTERVAL_MS, pingMaxRetries: HEARTBEAT_MISSES, }), rooms: { - arena: defineRoom( + [ROOM_NAME]: defineRoom( createGameRoom({ config, boxes: blockoutToStaticBoxes(GREYBOX_BLOCKOUT), spawns: GREYBOX_SPAWN_POINTS_M, + lifecycle: matchmaking, }), - ).filterBy(['address']), + ), }, + // o matchmaking pela URL troca o `joinOrCreate` do colyseus, que abria uma + // segunda sala com o mesmo endereço quando a primeira enchia. + routes: createRouter({ enterRoom: matchmaking.enterEndpoint }), }) await server.listen(PORT) diff --git a/packages/server/src/matchmaking.ts b/packages/server/src/matchmaking.ts new file mode 100644 index 00000000..fccf5c2f --- /dev/null +++ b/packages/server/src/matchmaking.ts @@ -0,0 +1,158 @@ +import { randomInt } from 'node:crypto' +import { createEndpoint, matchMaker } from '@colyseus/core' +import { + generateRoomAddress, + parseRoomAddress, + ROOM_ENTRY_PATH, + ROOM_MOVE_MESSAGE, + type SeatOffer, +} from '@shobu/core' +import { + createRoomDirectory, + enqueueWaiter, + forgetWaiter, + planEntry, + type RoomDirectory, + registerRoom, + takeNextWaiter, + unregisterRoom, +} from './roomDirectory.ts' + +/** O nome do tipo de sala no `defineServer`. */ +export const ROOM_NAME = 'arena' + +/** O que a sala avisa ao matchmaking sobre a própria vida. */ +export interface RoomLifecycle { + roomCreated(address: string, roomId: string): void + roomDisposed(address: string): void + playerLeft(address: string, sessionId: string, code: number | undefined): void +} + +export interface Matchmaking extends RoomLifecycle { + /** A rota `ROOM_ENTRY_PATH`, para o `createRouter` do `defineServer`. */ + readonly enterEndpoint: ReturnType +} + +type ISeatReservation = Awaited> +type Offer = SeatOffer + +/** + * O matchmaking pela URL, ligado ao colyseus. Adapter: a decisão de onde cada + * um joga está em `roomDirectory.ts`. + * + * Entrada e chamada da fila passam uma de cada vez. Reservar assento é + * assíncrono, e sem a fila única uma entrada nova veria a vaga de quem foi + * chamado antes de a reserva dele contar, e a tomaria. + * ponytail: uma fila para o servidor inteiro; por endereço, se a entrada virar + * gargalo. + * + * ```ts + * const matchmaking = createMatchmaking() + * defineServer({ rooms: { arena: defineRoom(createGameRoom({ ..., lifecycle: matchmaking })) }, + * routes: createRouter({ enter: matchmaking.enterEndpoint }) }) + * ``` + */ +export function createMatchmaking(): Matchmaking { + const directory = createRoomDirectory() + const serial = createSerialQueue() + return { + enterEndpoint: createEndpoint(ROOM_ENTRY_PATH, { method: 'POST' }, async (ctx) => { + const address = addressOf(ctx.body) + if (!address) + throw ctx.error(400, { + error: `endereço ${JSON.stringify(ctx.body)} inválido; esperado { address: '203.0.113.7' }`, + }) + const offer = await serial.run(() => enter(directory, address)) + return Response.json(offer) + }), + roomCreated: (address, roomId) => registerRoom(directory, address, roomId), + roomDisposed: (address) => unregisterRoom(directory, address), + playerLeft: (address, sessionId, code) => { + // o código de fechamento diz quem fechou e por quê: 1000/4000 é saída + // limpa, 1006 é queda sem aviso (o heartbeat derrubou). + console.log(JSON.stringify({ event: 'room.left', address, sessionId, code })) + void serial.run(async () => { + forgetWaiter(directory, sessionId) + await callNextWaiter(directory, address) + }) + }, + } +} + +function addressOf(body: unknown): string | undefined { + const text = (body as { address?: unknown } | undefined)?.address + return typeof text === 'string' ? parseRoomAddress(text) : undefined +} + +async function enter(directory: RoomDirectory, address: string): Promise { + const plan = planEntry(directory, address, (candidate) => hasVacancy(directory, candidate)) + if (plan.kind === 'create') return { reservation: await createAndReserve(address), address } + if (plan.kind === 'join') return { reservation: await reserveIn(directory, address), address } + const provisional = plan.provisional ?? freeAddress(directory) + const reservation = plan.provisional + ? await reserveIn(directory, provisional) + : await createAndReserve(provisional) + enqueueWaiter(directory, address, { + sessionId: reservation.sessionId, + roomId: reservation.roomId, + }) + return { reservation, address: provisional } +} + +/** + * Chama o primeiro da fila que ainda está no jogo, com o assento já reservado + * na sala pedida. Quem saiu enquanto esperava é pulado. + */ +async function callNextWaiter(directory: RoomDirectory, address: string): Promise { + while (hasVacancy(directory, address)) { + const waiter = takeNextWaiter(directory, address) + if (!waiter) return + const room = matchMaker.getLocalRoomById(waiter.roomId) + const client = room?.clients.getById(waiter.sessionId) + if (!client) continue + const offer: Offer = { reservation: await reserveIn(directory, address), address } + client.send(ROOM_MOVE_MESSAGE, offer) + console.log( + JSON.stringify({ event: 'room.queue-called', address, sessionId: waiter.sessionId }), + ) + return + } +} + +/** A vaga conta os assentos reservados: é o que impede duas entradas na mesma vaga. */ +function hasVacancy(directory: RoomDirectory, address: string): boolean { + const roomId = directory.rooms.get(address) + const room = roomId === undefined ? undefined : matchMaker.getLocalRoomById(roomId) + return room !== undefined && !room.hasReachedMaxClients() +} + +async function createAndReserve(address: string): Promise { + const room = await matchMaker.createRoom(ROOM_NAME, { address }) + return matchMaker.reserveSeatFor(room, { address }) +} + +async function reserveIn(directory: RoomDirectory, address: string): Promise { + const roomId = directory.rooms.get(address) + if (roomId === undefined) + throw new Error(`sem sala no endereço '${address}'; esperado sala aberta`) + return matchMaker.reserveSeatFor(await matchMaker.getRoomById(roomId), { address }) +} + +function freeAddress(directory: RoomDirectory): string { + for (;;) { + const address = generateRoomAddress(() => randomInt(0, 2 ** 32)) + if (!directory.rooms.has(address)) return address + } +} + +/** Promessas encadeadas: cada tarefa começa quando a anterior termina, com erro ou não. */ +function createSerialQueue(): { run(task: () => Promise): Promise } { + let tail: Promise = Promise.resolve() + return { + run: (task) => { + const next = tail.then(task) + tail = next.catch(() => undefined) + return next + }, + } +} diff --git a/packages/server/src/roomSimulation.ts b/packages/server/src/roomSimulation.ts index a94a897e..eef68c58 100644 --- a/packages/server/src/roomSimulation.ts +++ b/packages/server/src/roomSimulation.ts @@ -264,7 +264,7 @@ export function writeRoomState( * como `10.0.0.07`, abriria uma segunda sala para o mesmo endereço, então o * servidor recusa e o cliente normaliza antes de pedir. */ -export function assertCanonicalAddress(address: string | undefined): void { +export function assertCanonicalAddress(address: string | undefined): asserts address is string { if (address === undefined || parseRoomAddress(address) !== address) { throw new Error( `endereço de sala '${address}' não é canônico; esperado ipv4 como '203.0.113.7'`, diff --git a/vitest.config.ts b/vitest.config.ts index 0016b7e2..4145dc0d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -43,6 +43,7 @@ export const IO_ADAPTERS: readonly string[] = [ 'packages/client/src/renderer/tracerBeams.ts', 'packages/server/src/gameRoom.ts', 'packages/server/src/main.ts', + 'packages/server/src/matchmaking.ts', ] // headless por decisão: nenhum teste abre navegador (adr 0001). From f2e8f4c70cd925f649a7a65ecf69971487f950cf Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 20:58:33 -0300 Subject: [PATCH 067/139] feat: enter through the matchmaking route and follow the queue call --- packages/client/src/main.ts | 45 +++++++++++++-------- packages/client/src/net/gameLink.ts | 45 +++++++++++++++++++-- packages/client/src/net/networkPort.test.ts | 3 ++ 3 files changed, 73 insertions(+), 20 deletions(-) diff --git a/packages/client/src/main.ts b/packages/client/src/main.ts index 1eb0c107..4acce486 100644 --- a/packages/client/src/main.ts +++ b/packages/client/src/main.ts @@ -20,7 +20,7 @@ import { buildTagline } from './hud/tagline.ts' import { createTerminalPrinter, type TerminalPrinter } from './hud/terminalPrinter.ts' import { describeTimeToControl, timeToControlMs } from './instrumentation/timeToPlayerControl.ts' import { createMenuState, type MenuCommand, selectRow, stepMenu } from './menu/mainMenuModel.ts' -import { openGameLink } from './net/gameLink.ts' +import { type GameLink, openGameLink } from './net/gameLink.ts' import { createNetworkPort, type NetworkPort } from './net/networkPort.ts' import { interpolationDelayMs } from './net/remotePlayerView.ts' import { gameServerUrl, resolveRoomAddress } from './net/roomLocation.ts' @@ -156,27 +156,40 @@ function createArenaRenderer( /** * Entra na sala do endereço da url, sem segurar o boot: a arena roda sozinha * até a conexão chegar, e falha de conexão vira log e jogo sem sala, não tela - * de erro. A url passa a mostrar o endereço canônico, que é o link para chamar - * alguém para a mesma sala. + * de erro. A url mostra o endereço da sala em que se joga, que é o link para + * chamar alguém: com a sala pedida cheia, é o da sala provisória, e muda de + * novo quando o servidor chama da fila (CONTEXT.md, "Sala provisória"). */ function connectToRoom(config: GameplayConfig, network: NetworkPort): void { const room = resolveRoomAddress(window.location.pathname, randomUint32) window.history.replaceState(null, '', room.path) const url = gameServerUrl(window.location, GAME_SERVER_PORT) + const current: { link: GameLink | undefined } = { link: undefined } + window.addEventListener('pagehide', () => void current.link?.leave()) + const follow = (link: GameLink): void => { + current.link = link + network.attach(link) + window.history.replaceState(null, '', `/${link.address}`) + link.onMove((next) => void next().then(follow).catch(logJoinFailure(room.address, url))) + console.info( + JSON.stringify({ + event: 'room.joined', + address: link.address, + requested: room.address, + sessionId: link.sessionId, + }), + ) + } openGameLink(url, room.address, interpolationDelayMs(config)) - .then((link) => { - network.attach(link) - window.addEventListener('pagehide', () => void link.leave()) - console.info( - JSON.stringify({ event: 'room.joined', address: room.address, sessionId: link.sessionId }), - ) - }) - .catch((reason: unknown) => { - const message = reason instanceof Error ? reason.message : String(reason) - console.error( - JSON.stringify({ event: 'room.join-failed', address: room.address, url, message }), - ) - }) + .then(follow) + .catch(logJoinFailure(room.address, url)) +} + +function logJoinFailure(address: string, url: string): (reason: unknown) => void { + return (reason) => { + const message = reason instanceof Error ? reason.message : String(reason) + console.error(JSON.stringify({ event: 'room.join-failed', address, url, message })) + } } function randomUint32(): number { diff --git a/packages/client/src/net/gameLink.ts b/packages/client/src/net/gameLink.ts index 9c8424f1..3aae0cdb 100644 --- a/packages/client/src/net/gameLink.ts +++ b/packages/client/src/net/gameLink.ts @@ -1,6 +1,13 @@ import { Client, type Room } from '@colyseus/sdk' import { Predict } from '@colyseus/sdk/predict' -import { KILL_MESSAGE, type KillMessage, type WireInput } from '@shobu/core' +import { + KILL_MESSAGE, + type KillMessage, + ROOM_ENTRY_PATH, + ROOM_MOVE_MESSAGE, + type SeatOffer, + type WireInput, +} from '@shobu/core' import type { MutableWireInput } from './encodeTickInput.ts' import { type PlayerSnapshotFields, playersOf } from './remotePlayerView.ts' @@ -11,6 +18,8 @@ import { type PlayerSnapshotFields, playersOf } from './remotePlayerView.ts' */ export interface GameLink { readonly sessionId: string + /** O endereço da sala em que se joga; na fila, é o da sala provisória. */ + readonly address: string /** O input de um tick, na ordem em que a predição o usou. Devolve o número de sequência dele. */ sendTickInput(input: WireInput): number /** O último número de sequência que o servidor confirmou ter aplicado (`input.lastProcessed`). */ @@ -24,14 +33,23 @@ export interface GameLink { ownSnapshot(): PlayerSnapshotFields | undefined /** Cada kill que o servidor confirmou para este jogador. */ onKill(listener: (kill: KillMessage) => void): void + /** + * A vaga abriu na sala pedida. `follow` sai desta sala e entra na pedida, + * com o assento que o servidor já reservou. + */ + onMove(listener: (follow: () => Promise) => void): void leave(): Promise } +type SeatReservation = Parameters[0] + const INTERPOLATED_FIELDS = ['x', 'y', 'z', 'aimX', 'aimY', 'aimZ'] as const /** - * Entra na sala do endereço. Sem reconexão: o sdk vem com ela ligada, e a sala - * não guarda a vaga de quem cai (docs/rede, "Reconexão"). + * Pede a entrada pelo endereço na rota de matchmaking do servidor de jogo e + * entra na sala que ele reservou: a do endereço, ou uma provisória, com a sala + * pedida cheia. Sem reconexão: o sdk vem com ela ligada, e a sala não guarda a + * vaga de quem cai (docs/rede, "Reconexão"). * * ```ts * const link = await openGameLink(gameServerUrl(location, 2567), address, interpolationDelayMs(config)) @@ -42,19 +60,38 @@ export async function openGameLink( address: string, interpolationDelayMs: number, ): Promise { - const room = await new Client(serverUrl).joinOrCreate('arena', { address }) + const client = new Client(serverUrl) + const { data } = await client.http.post(ROOM_ENTRY_PATH, { body: { address } }) + return takeSeat(client, data as SeatOffer, interpolationDelayMs) +} + +async function takeSeat( + client: Client, + offer: SeatOffer, + interpolationDelayMs: number, +): Promise { + const room: Room = await client.consumeSeatReservation(offer.reservation) room.reconnection.enabled = false // sem reconexão, sair da sala é silencioso: o jogo segue sozinho. o log com // o código é o que diz se a sala caiu, e por quê. room.onLeave((code, reason) => console.warn(JSON.stringify({ event: 'room.left', code, reason }))) return { sessionId: room.sessionId, + address: offer.address, ...createInputChannel(room, interpolationDelayMs), forEachRemote: createRemoteReader(room, interpolationDelayMs), ownSnapshot: () => playersOf(room.state).get(room.sessionId), onKill: (listener) => { room.onMessage(KILL_MESSAGE, listener) }, + onMove: (listener) => { + room.onMessage(ROOM_MOVE_MESSAGE, (next: SeatOffer) => + listener(async () => { + await room.leave() + return takeSeat(client, next, interpolationDelayMs) + }), + ) + }, leave: async () => { await room.leave() }, diff --git a/packages/client/src/net/networkPort.test.ts b/packages/client/src/net/networkPort.test.ts index 006fb3ad..b29744d0 100644 --- a/packages/client/src/net/networkPort.test.ts +++ b/packages/client/src/net/networkPort.test.ts @@ -19,6 +19,7 @@ const AT_SPAWN: PlayerSnapshotFields = { /** A sala do colyseus, em memória: guarda o que recebeu e devolve um jogador remoto. */ class FakeGameLink implements GameLink { readonly sessionId = 'eu' + readonly address = '203.0.113.7' readonly sent: WireInput[] = [] sendTickInput(input: WireInput): number { @@ -62,6 +63,8 @@ class FakeGameLink implements GameLink { }) } + onMove(): void {} + async leave(): Promise {} } From 1c58a901256ca5276fc70c1b0f6d5ae5e1d1e61f Mon Sep 17 00:00:00 2001 From: thnbi Date: Wed, 30 Sep 2026 20:58:33 -0300 Subject: [PATCH 068/139] docs: describe the room entry route and the queue --- docs/rede/arquitetura-de-rede-4-entrada.drawio.svg | 2 +- docs/rede/arquitetura-de-rede.drawio | 2 +- docs/rede/arquitetura-de-rede.md | 13 ++++++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/rede/arquitetura-de-rede-4-entrada.drawio.svg b/docs/rede/arquitetura-de-rede-4-entrada.drawio.svg index f88d2740..c1eb4609 100644 --- a/docs/rede/arquitetura-de-rede-4-entrada.drawio.svg +++ b/docs/rede/arquitetura-de-rede-4-entrada.drawio.svg @@ -1,4 +1,4 @@ -Figura 4. Diagrama de sequência da entrada na partida e na sala de voz: Navegador: Servidor de jogo: Servidor de mídia1: GET / (HTTPS)2: joinOrCreate() (HTTPS)reserva de vaga3: conectar(reserva) (WSS)4: onJoin(); assinarJWT()5: token de voz (WSS)6: conectar(token) (WSS, TCP 7880)7: ICE e handshake DTLS (UDP 7882, fallback ICE-TCP 7881)opt[push-to-talk pressionado]8: áudio Opus (RTP/SRTP)9: desconectar()10: RemoveParticipant(roomId, sessionId) (Twirp/HTTP)Figura 4. Diagrama de sequência (UML) da entrada na partida e na sala de voz, e da saída. \ No newline at end of file +Figura 4. Diagrama de sequência da entrada na partida e na sala de voz: Navegador: Servidor de jogo: Servidor de mídia1: GET / (HTTPS)2: POST /rooms/enter (HTTPS)reserva de vaga3: conectar(reserva) (WSS)4: onJoin(); assinarJWT()5: token de voz (WSS)6: conectar(token) (WSS, TCP 7880)7: ICE e handshake DTLS (UDP 7882, fallback ICE-TCP 7881)opt[push-to-talk pressionado]8: áudio Opus (RTP/SRTP)9: desconectar()10: RemoveParticipant(roomId, sessionId) (Twirp/HTTP)Figura 4. Diagrama de sequência (UML) da entrada na partida e na sala de voz, e da saída. \ No newline at end of file diff --git a/docs/rede/arquitetura-de-rede.drawio b/docs/rede/arquitetura-de-rede.drawio index 3ce8633e..39661b58 100644 --- a/docs/rede/arquitetura-de-rede.drawio +++ b/docs/rede/arquitetura-de-rede.drawio @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/docs/rede/arquitetura-de-rede.md b/docs/rede/arquitetura-de-rede.md index 281d13fb..f265e8b3 100644 --- a/docs/rede/arquitetura-de-rede.md +++ b/docs/rede/arquitetura-de-rede.md @@ -177,7 +177,18 @@ no perfil Wi-Fi congestionado da [ADR 0006](../adr/0006-condicao-de-rede-do-acei ![Figura 4](arquitetura-de-rede-4-entrada.drawio.svg) -O servidor de jogo decide quem está em cada sala. O servidor de mídia só aceita quem traz um +O servidor de jogo decide quem está em cada sala. O cliente pede a entrada pelo endereço da URL +em `POST /rooms/enter` (mensagem 2), e a resposta é um assento já reservado numa sala, que o +cliente ocupa pelo WebSocket (mensagem 3). O servidor mantém um diretório de endereço para sala e +serializa as entradas: o `joinOrCreate` do Colyseus foi descartado porque, com a sala cheia, ele +cria uma segunda sala com o mesmo endereço. Com a sala pedida cheia, o jogador entra na fila dela +e recebe o assento numa sala provisória, com vaga ou nova. Quando abre vaga na sala pedida, o +servidor reserva o assento para o primeiro da fila e o envia pela mensagem `room.move` da sala +provisória. O cliente sai dela e ocupa o assento, e a URL passa a mostrar o endereço da sala em +que ele joga. A reserva conta como ocupação da vaga, e é isso que impede quem chega depois de +tomar a vaga de quem foi chamado. + +O servidor de mídia só aceita quem traz um token assinado por ele (mensagem 4), com sala igual ao `roomId`, identidade igual ao `sessionId` e permissão apenas de áudio. Antes do áudio, o cliente faz as verificações de conectividade do ICE e o handshake DTLS que deriva as chaves do SRTP (mensagem 7). Quando o jogador sai, o From d76bd7e9996e4d1b1f4af02e50fd3f1f2e634ddc Mon Sep 17 00:00:00 2001 From: thnbi Date: Tue, 13 Oct 2026 19:42:11 -0300 Subject: [PATCH 069/139] feat: serve the client build from the game server, room addresses included --- package-lock.json | 174 ++++++++++++++++++++--------------- packages/server/package.json | 6 +- packages/server/src/main.ts | 23 ++++- 3 files changed, 127 insertions(+), 76 deletions(-) diff --git a/package-lock.json b/package-lock.json index b228afee..73541552 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1959,6 +1959,17 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1970,6 +1981,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1984,6 +2005,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ndarray": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@types/ndarray/-/ndarray-1.1.0.tgz", @@ -2000,6 +2053,41 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2104,7 +2192,6 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", - "peer": true, "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -2147,7 +2234,6 @@ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", - "peer": true, "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", @@ -2172,7 +2258,6 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2186,7 +2271,6 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -2196,7 +2280,6 @@ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -2210,7 +2293,6 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -2237,7 +2319,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2251,7 +2332,6 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -2261,7 +2341,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -2271,7 +2350,6 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.6.0" } @@ -2308,7 +2386,6 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -2328,7 +2405,6 @@ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -2342,15 +2418,13 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -2360,7 +2434,6 @@ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -2370,7 +2443,6 @@ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -2387,7 +2459,6 @@ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -2441,8 +2512,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/estree-walker": { "version": "3.0.3", @@ -2459,7 +2529,6 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -2479,7 +2548,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2541,7 +2609,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", @@ -2563,7 +2630,6 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -2573,7 +2639,6 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -2598,7 +2663,6 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2608,7 +2672,6 @@ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -2633,7 +2696,6 @@ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", - "peer": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -2647,7 +2709,6 @@ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -2660,7 +2721,6 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -2673,7 +2733,6 @@ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", - "peer": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -2686,7 +2745,6 @@ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", - "peer": true, "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", @@ -2707,7 +2765,6 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -2723,8 +2780,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/iota-array": { "version": "1.0.0", @@ -2738,7 +2794,6 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.10" } @@ -2754,8 +2809,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/js-tokens": { "version": "10.0.0", @@ -2798,7 +2852,6 @@ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -2808,7 +2861,6 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" }, @@ -2822,7 +2874,6 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2835,7 +2886,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -2845,7 +2895,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "^1.54.0" }, @@ -2962,7 +3011,6 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", "license": "MIT", - "peer": true, "dependencies": { "content-type": "^2.1.0" }, @@ -2979,7 +3027,6 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3008,7 +3055,6 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -3035,7 +3081,6 @@ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", - "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -3048,7 +3093,6 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", - "peer": true, "dependencies": { "wrappy": "1" } @@ -3058,7 +3102,6 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -3068,7 +3111,6 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -3135,7 +3177,6 @@ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", - "peer": true, "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -3153,7 +3194,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" @@ -3170,7 +3210,6 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" }, @@ -3184,7 +3223,6 @@ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", - "peer": true, "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", @@ -3252,7 +3290,6 @@ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", @@ -3268,8 +3305,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.8.5", @@ -3289,7 +3325,6 @@ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", @@ -3316,7 +3351,6 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", - "peer": true, "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", @@ -3341,8 +3375,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/sharp": { "version": "0.35.4", @@ -3399,7 +3432,6 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", @@ -3419,7 +3451,6 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" @@ -3436,7 +3467,6 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -3455,7 +3485,6 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -3499,7 +3528,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -3563,7 +3591,6 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.6" } @@ -3579,7 +3606,6 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", - "peer": true, "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", @@ -3598,7 +3624,6 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3639,7 +3664,6 @@ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -3649,7 +3673,6 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -3833,8 +3856,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/ws": { "version": "8.22.0", @@ -3881,7 +3903,11 @@ "@colyseus/core": "0.18.17", "@colyseus/schema": "5.0.34", "@colyseus/ws-transport": "0.18.4", - "@shobu/core": "*" + "@shobu/core": "*", + "express": "5.2.1" + }, + "devDependencies": { + "@types/express": "5.0.6" } } } diff --git a/packages/server/package.json b/packages/server/package.json index 8088a8c0..fb88c1d9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -10,6 +10,10 @@ "@colyseus/core": "0.18.17", "@colyseus/schema": "5.0.34", "@colyseus/ws-transport": "0.18.4", - "@shobu/core": "*" + "@shobu/core": "*", + "express": "5.2.1" + }, + "devDependencies": { + "@types/express": "5.0.6" } } diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index 1f5c749a..75f5ee8e 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -1,4 +1,5 @@ -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' import { createRouter, defineRoom, defineServer } from '@colyseus/core' import { WebSocketTransport } from '@colyseus/ws-transport' import { @@ -6,7 +7,9 @@ import { GREYBOX_BLOCKOUT, GREYBOX_SPAWN_POINTS_M, parseGameplayConfig, + parseRoomAddress, } from '@shobu/core' +import express from 'express' import { createGameRoom } from './gameRoom.ts' import { createMatchmaking, ROOM_NAME } from './matchmaking.ts' @@ -24,6 +27,23 @@ const HEARTBEAT_MISSES = 2 const CONFIG_URL = new URL('../../../config/gameplay.json', import.meta.url) const config = parseGameplayConfig(JSON.parse(readFileSync(CONFIG_URL, 'utf8'))) +/** O build do cliente (`npm run build`). Sem ele, a página vem do vite em dev. */ +const CLIENT_DIST = fileURLToPath(new URL('../../client/dist/', import.meta.url)) + +/** + * A página e os assets do build. O caminho da sala tem ponto, como + * `/203.0.113.7`, e o servidor estático o trataria como arquivo inexistente: + * todo caminho que é endereço de sala devolve o `index.html` (ADR 0011). + */ +function servePage(app: express.Application): void { + if (!existsSync(CLIENT_DIST)) return + app.use(express.static(CLIENT_DIST)) + app.get('/:address', (req, res, next) => { + if (parseRoomAddress(req.params.address) === undefined) return next() + res.sendFile('index.html', { root: CLIENT_DIST }) + }) +} + const matchmaking = createMatchmaking() const server = defineServer({ @@ -44,6 +64,7 @@ const server = defineServer({ // o matchmaking pela URL troca o `joinOrCreate` do colyseus, que abria uma // segunda sala com o mesmo endereço quando a primeira enchia. routes: createRouter({ enterRoom: matchmaking.enterEndpoint }), + express: servePage, }) await server.listen(PORT) From a7c221c3bce3ac24d951732c4e6a120260ccef14 Mon Sep 17 00:00:00 2001 From: thnbi Date: Tue, 13 Oct 2026 19:42:11 -0300 Subject: [PATCH 070/139] feat: speak https and wss when a certificate is configured --- packages/server/src/main.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index 75f5ee8e..a1a28fb8 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from 'node:fs' +import { createServer as createHttpsServer, type Server as HttpsServer } from 'node:https' import { fileURLToPath } from 'node:url' import { createRouter, defineRoom, defineServer } from '@colyseus/core' import { WebSocketTransport } from '@colyseus/ws-transport' @@ -30,6 +31,23 @@ const config = parseGameplayConfig(JSON.parse(readFileSync(CONFIG_URL, 'utf8'))) /** O build do cliente (`npm run build`). Sem ele, a página vem do vite em dev. */ const CLIENT_DIST = fileURLToPath(new URL('../../client/dist/', import.meta.url)) +/** + * Com `SHOBU_TLS_CERT` e `SHOBU_TLS_KEY` (caminhos em PEM), o servidor fala + * HTTPS e WSS na mesma porta; sem os dois, HTTP puro, para o dev em localhost. + * Página HTTPS não abre `ws://`, e o navegador só libera o microfone em + * contexto seguro (docs/rede, Topologia). + */ +function createTlsServer(env: NodeJS.ProcessEnv): HttpsServer | undefined { + const { SHOBU_TLS_CERT: certPath, SHOBU_TLS_KEY: keyPath } = env + if (!certPath && !keyPath) return undefined + if (!certPath || !keyPath) { + throw new Error( + `tls pela metade: SHOBU_TLS_CERT='${certPath}', SHOBU_TLS_KEY='${keyPath}'; esperado os dois caminhos ou nenhum`, + ) + } + return createHttpsServer({ cert: readFileSync(certPath), key: readFileSync(keyPath) }) +} + /** * A página e os assets do build. O caminho da sala tem ponto, como * `/203.0.113.7`, e o servidor estático o trataria como arquivo inexistente: @@ -44,10 +62,12 @@ function servePage(app: express.Application): void { }) } +const tlsServer = createTlsServer(process.env) const matchmaking = createMatchmaking() const server = defineServer({ transport: new WebSocketTransport({ + server: tlsServer, pingInterval: HEARTBEAT_INTERVAL_MS, pingMaxRetries: HEARTBEAT_MISSES, }), @@ -68,4 +88,4 @@ const server = defineServer({ }) await server.listen(PORT) -console.log(JSON.stringify({ event: 'server.listening', port: PORT })) +console.log(JSON.stringify({ event: 'server.listening', port: PORT, tls: tlsServer !== undefined })) From 8f241a30f375a495e3249435f5f6d59ade74fa5b Mon Sep 17 00:00:00 2001 From: thnbi Date: Tue, 13 Oct 2026 19:42:13 -0300 Subject: [PATCH 071/139] refactor: drop an unused import from the game room --- packages/server/src/gameRoom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/gameRoom.ts b/packages/server/src/gameRoom.ts index 7d3a7ba4..b05a6f3b 100644 --- a/packages/server/src/gameRoom.ts +++ b/packages/server/src/gameRoom.ts @@ -1,5 +1,5 @@ import { type Client, type Rewind, Room } from '@colyseus/core' -import { dequantizePositionM, type GameplayConfig, KILL_MESSAGE, type WireInput } from '@shobu/core' +import { dequantizePositionM, KILL_MESSAGE, type WireInput } from '@shobu/core' import { GameRoomState, PlayerInput, PlayerState } from './gameRoomState.ts' import type { RoomLifecycle } from './matchmaking.ts' import { From 438cab6acadabaaed40af9bf8ac7c4d6d8aae8bd Mon Sep 17 00:00:00 2001 From: thnbi Date: Tue, 13 Oct 2026 19:42:22 -0300 Subject: [PATCH 072/139] docs: describe tls on the game server and the page route --- docs/rede/arquitetura-de-rede.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/rede/arquitetura-de-rede.md b/docs/rede/arquitetura-de-rede.md index f265e8b3..b5831afd 100644 --- a/docs/rede/arquitetura-de-rede.md +++ b/docs/rede/arquitetura-de-rede.md @@ -71,7 +71,14 @@ que é o mux UDP configurado em `rtc.udp_port`. Sem ele, o LiveKit usa a faixa U Toda conexão que sai do navegador é cifrada: TLS no jogo e na sinalização, DTLS-SRTP na mídia. O navegador só libera o microfone em contexto seguro, e uma página HTTPS não abre `ws://`. O `WebSocketTransport` do Colyseus cria um servidor HTTP sem TLS, então o servidor de jogo -recebe um `https.Server` com o certificado da LAN, ou fica atrás de um proxy que termina TLS. +recebe um `https.Server` com o certificado e a chave em PEM, indicados por `SHOBU_TLS_CERT` e +`SHOBU_TLS_KEY`. HTTPS e WSS dividem a porta TCP 2567: o WebSocket começa como requisição HTTP +com `Upgrade` sobre a mesma conexão TLS. Sem as duas variáveis, o servidor fala HTTP puro, para +o desenvolvimento em `localhost`. + +O mesmo servidor entrega a página. O caminho da sala tem pontos, como `/203.0.113.7`, e um +servidor estático o procuraria como arquivo. Todo caminho que é endereço de sala devolve o +`index.html`, e o cliente lê o endereço do caminho. O servidor de mídia roda fora do modo `development` e com chaves próprias: as chaves desse modo são públicas. From e7294f0a561d9248297ab551c039b39d61cc6a41 Mon Sep 17 00:00:00 2001 From: thnbi Date: Tue, 13 Oct 2026 19:50:00 -0300 Subject: [PATCH 073/139] feat: name the voice ticket message in the wire protocol --- packages/core/src/index.ts | 4 ++-- packages/core/src/room/roomEntry.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 771e3cf4..fc84e573 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -54,8 +54,8 @@ export { hasWish, IDLE_INPUT, wishLength, wishUnit } from './movement/movementIn export { SLIDE_CAPSULE_RATIO, SLIDE_ENTRY_SPEED_RATIO } from './movement/slide.ts' export { stepCharacter } from './movement/stepCharacter.ts' export { generateRoomAddress, isReservedAddress, parseRoomAddress } from './room/roomAddress.ts' -export type { SeatOffer } from './room/roomEntry.ts' -export { ROOM_ENTRY_PATH, ROOM_MOVE_MESSAGE } from './room/roomEntry.ts' +export type { SeatOffer, VoiceTicket } from './room/roomEntry.ts' +export { ROOM_ENTRY_PATH, ROOM_MOVE_MESSAGE, VOICE_TICKET_MESSAGE } from './room/roomEntry.ts' export type { KillEvent, KillWeapon, MutableKillEvent } from './scoring/killEvent.ts' export { createKillEvent } from './scoring/killEvent.ts' export type { PlayerScore, Scoreboard } from './scoring/scoreboard.ts' diff --git a/packages/core/src/room/roomEntry.ts b/packages/core/src/room/roomEntry.ts index 7178b921..190e0981 100644 --- a/packages/core/src/room/roomEntry.ts +++ b/packages/core/src/room/roomEntry.ts @@ -18,3 +18,14 @@ export interface SeatOffer { readonly reservation: Reservation readonly address: string } + +/** + * Do servidor para quem acabou de entrar: o token de acesso à sala de voz, assinado + * com a chave do servidor de mídia. Só o servidor de jogo emite, então só quem joga + * na sala fala nela (ADR 0009). Sem servidor de mídia configurado, não chega. + */ +export const VOICE_TICKET_MESSAGE = 'voice.ticket' + +export interface VoiceTicket { + readonly token: string +} From 1d56328618f11f782d658cbc07b6abd7b1992f08 Mon Sep 17 00:00:00 2001 From: thnbi Date: Tue, 13 Oct 2026 19:50:00 -0300 Subject: [PATCH 074/139] feat: issue voice tickets and remove leavers from the voice room --- package-lock.json | 174 +++++++++++++++++- .../client/src/coverageExclusions.test.ts | 1 + packages/server/package.json | 3 +- packages/server/src/gameRoom.ts | 45 ++++- packages/server/src/main.ts | 12 +- packages/server/src/voiceAccess.ts | 73 ++++++++ vitest.config.ts | 1 + 7 files changed, 300 insertions(+), 9 deletions(-) create mode 100644 packages/server/src/voiceAccess.ts diff --git a/package-lock.json b/package-lock.json index 73541552..ca38b1f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -270,6 +270,12 @@ "node": ">=14.21.3" } }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", + "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@colyseus/better-call": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@colyseus/better-call/-/better-call-1.3.3.tgz", @@ -1496,6 +1502,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@livekit/mutex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", + "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", + "license": "Apache-2.0" + }, + "node_modules/@livekit/protocol": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.51.0.tgz", + "integrity": "sha512-kEDEDaFvIcp5VlYBromdSKMJMEJKfPvXPfEaJPdEz1Qgo84gZAoLF2g58CcMlVYGsjrwE5XYgQxRS5B7KuEj3w==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", @@ -1998,6 +2019,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dom-mediacapture-record": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", + "integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==", + "license": "MIT", + "peer": true + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2533,6 +2561,15 @@ "node": ">= 0.6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2811,6 +2848,15 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -2825,6 +2871,81 @@ "dev": true, "license": "MIT" }, + "node_modules/livekit-client": { + "version": "2.22.3", + "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.22.3.tgz", + "integrity": "sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==", + "license": "Apache-2.0", + "dependencies": { + "@livekit/mutex": "1.1.1", + "@livekit/protocol": "1.50.4", + "events": "^3.3.0", + "jose": "^6.1.0", + "loglevel": "^1.9.2", + "machina": "^7.0.0", + "sdp-transform": "^2.15.0", + "tslib": "2.8.1", + "typed-emitter": "^2.1.0", + "webrtc-adapter": "9.0.6" + }, + "peerDependencies": { + "@types/dom-mediacapture-record": "^1" + } + }, + "node_modules/livekit-client/node_modules/@livekit/protocol": { + "version": "1.50.4", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz", + "integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, + "node_modules/livekit-client/node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/livekit-server-sdk": { + "version": "2.19.1", + "resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.19.1.tgz", + "integrity": "sha512-tqjM5oPDDNBrkb/64YReta9UWP32l1a2m0HHXq8WOuEzwxh4zsaXjeZZtPG4Er4eVAk4kjR+EMxH7lPKzg1g6g==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.1", + "@livekit/protocol": "1.51.0", + "jose": "^5.1.2" + }, + "engines": { + "node": ">=19" + } + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/machina": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/machina/-/machina-7.0.1.tgz", + "integrity": "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==", + "license": "MIT", + "engines": { + "node": ">=22.22" + } + }, "node_modules/magic-string": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", @@ -3301,12 +3422,37 @@ "node": ">= 18" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "license": "MIT" + }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -3632,6 +3778,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3835,6 +3990,19 @@ } } }, + "node_modules/webrtc-adapter": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", + "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3886,7 +4054,8 @@ "@babylonjs/core": "9.23.0", "@babylonjs/loaders": "9.23.0", "@colyseus/sdk": "0.18.4", - "@shobu/core": "*" + "@shobu/core": "*", + "livekit-client": "2.22.3" }, "devDependencies": { "vite": "^7.1.14" @@ -3904,7 +4073,8 @@ "@colyseus/schema": "5.0.34", "@colyseus/ws-transport": "0.18.4", "@shobu/core": "*", - "express": "5.2.1" + "express": "5.2.1", + "livekit-server-sdk": "2.19.1" }, "devDependencies": { "@types/express": "5.0.6" diff --git a/packages/client/src/coverageExclusions.test.ts b/packages/client/src/coverageExclusions.test.ts index 25f8117d..2c75f245 100644 --- a/packages/client/src/coverageExclusions.test.ts +++ b/packages/client/src/coverageExclusions.test.ts @@ -21,6 +21,7 @@ const ADAPTER_MARKS: readonly RegExp[] = [ /\bfetch\(/, /\bperformance\./, /@colyseus\//, + /'livekit-(client|server-sdk)'/, ] function sourceOf(file: string): string { diff --git a/packages/server/package.json b/packages/server/package.json index fb88c1d9..bf9d1146 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -11,7 +11,8 @@ "@colyseus/schema": "5.0.34", "@colyseus/ws-transport": "0.18.4", "@shobu/core": "*", - "express": "5.2.1" + "express": "5.2.1", + "livekit-server-sdk": "2.19.1" }, "devDependencies": { "@types/express": "5.0.6" diff --git a/packages/server/src/gameRoom.ts b/packages/server/src/gameRoom.ts index b05a6f3b..a41f402c 100644 --- a/packages/server/src/gameRoom.ts +++ b/packages/server/src/gameRoom.ts @@ -1,5 +1,11 @@ import { type Client, type Rewind, Room } from '@colyseus/core' -import { dequantizePositionM, KILL_MESSAGE, type WireInput } from '@shobu/core' +import { + dequantizePositionM, + KILL_MESSAGE, + VOICE_TICKET_MESSAGE, + type VoiceTicket, + type WireInput, +} from '@shobu/core' import { GameRoomState, PlayerInput, PlayerState } from './gameRoomState.ts' import type { RoomLifecycle } from './matchmaking.ts' import { @@ -13,6 +19,7 @@ import { stepRoom, writeRoomState, } from './roomSimulation.ts' +import type { VoiceAccess } from './voiceAccess.ts' /** * O que a sala recebe por injeção. Não vem pelas opções do `defineRoom`, @@ -21,6 +28,8 @@ import { */ export interface GameRoomDeps extends RoomRules { readonly lifecycle: RoomLifecycle + /** O servidor de mídia; `undefined` roda o jogo sem voz. */ + readonly voice: VoiceAccess | undefined } /** O que o cliente manda no matchmaking. */ @@ -48,7 +57,7 @@ interface RoomSources { * está em `roomSimulation.ts`, e o que fica aqui é só a cola. * * ```ts - * defineRoom(createGameRoom({ config, boxes, spawns, lifecycle: matchmaking })) + * defineRoom(createGameRoom({ config, boxes, spawns, lifecycle: matchmaking, voice })) * ``` */ export function createGameRoom(deps: GameRoomDeps) { @@ -56,7 +65,6 @@ export function createGameRoom(deps: GameRoomDeps) { override state = new GameRoomState() override readonly inputs = this.defineInput(PlayerInput, { bufferMaxSize: 64 }) readonly #simulation = createRoomSimulation(deps.config) - readonly #stateOf = (id: string) => this.state.players.get(id) #address = '' override onCreate(options: GameRoomOptions): void { @@ -67,14 +75,14 @@ export function createGameRoom(deps: GameRoomDeps) { } override onJoin(client: Client): void { - this.state.players.set(client.sessionId, new PlayerState()) - admitPlayer(this.#simulation, client.sessionId, deps.spawns, deps.config, this.#stateOf) + seatPlayer(this, deps, this.#simulation, client) } override onLeave(client: Client, code?: number): void { leaveRoom(this.#simulation, client.sessionId) this.state.players.delete(client.sessionId) deps.lifecycle.playerLeft(this.#address, client.sessionId, code) + deps.voice?.remove(this.roomId, client.sessionId) } override onDispose(): void { @@ -83,6 +91,19 @@ export function createGameRoom(deps: GameRoomDeps) { } } +/** O estado do jogador no patch, o personagem na simulação e o token da voz. */ +function seatPlayer( + room: Room & RoomSources, + deps: GameRoomDeps, + simulation: RoomSimulation, + client: Client, +): void { + const { players } = room.state + players.set(client.sessionId, new PlayerState()) + admitPlayer(simulation, client.sessionId, deps.spawns, deps.config, (id) => players.get(id)) + sendVoiceTicket(deps.voice, room.roomId, client) +} + /** Capacidade, ritmo de patch e o passo fixo da simulação, da configuração. */ function runRoom( room: Room & RoomSources, @@ -136,3 +157,17 @@ function announceKill( clients.getById(shooterId)?.send(KILL_MESSAGE, kill) } } + +/** A voz é opcional: sem token, o jogador joga sem ela, e o jogo não para. */ +function sendVoiceTicket( + voice: VoiceAccess | undefined, + roomId: string, + client: Pick, +): void { + voice + ?.ticketFor(roomId, client.sessionId) + .then((token) => client.send(VOICE_TICKET_MESSAGE, { token } satisfies VoiceTicket)) + .catch((error: unknown) => { + console.log(JSON.stringify({ event: 'voice.ticket-failed', roomId, error: String(error) })) + }) +} diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index a1a28fb8..fb923c00 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -13,6 +13,7 @@ import { import express from 'express' import { createGameRoom } from './gameRoom.ts' import { createMatchmaking, ROOM_NAME } from './matchmaking.ts' +import { createVoiceAccess } from './voiceAccess.ts' /** A porta padrão do colyseus, a da Tabela 1 de `docs/rede/arquitetura-de-rede.md`. */ const PORT = 2567 @@ -64,6 +65,7 @@ function servePage(app: express.Application): void { const tlsServer = createTlsServer(process.env) const matchmaking = createMatchmaking() +const voice = createVoiceAccess(process.env) const server = defineServer({ transport: new WebSocketTransport({ @@ -78,6 +80,7 @@ const server = defineServer({ boxes: blockoutToStaticBoxes(GREYBOX_BLOCKOUT), spawns: GREYBOX_SPAWN_POINTS_M, lifecycle: matchmaking, + voice, }), ), }, @@ -88,4 +91,11 @@ const server = defineServer({ }) await server.listen(PORT) -console.log(JSON.stringify({ event: 'server.listening', port: PORT, tls: tlsServer !== undefined })) +console.log( + JSON.stringify({ + event: 'server.listening', + port: PORT, + tls: tlsServer !== undefined, + voice: voice !== undefined, + }), +) diff --git a/packages/server/src/voiceAccess.ts b/packages/server/src/voiceAccess.ts new file mode 100644 index 00000000..3c45d6ff --- /dev/null +++ b/packages/server/src/voiceAccess.ts @@ -0,0 +1,73 @@ +import { AccessToken, RoomServiceClient, TrackSource } from 'livekit-server-sdk' + +/** + * Quem fala em cada sala de voz, decidido pelo servidor de jogo (ADR 0009). + * A sala de voz tem o `roomId` da sala de jogo, e o participante, o `sessionId`. + */ +export interface VoiceAccess { + /** O token de entrada na sala de voz: só microfone, sem vídeo nem dados. */ + ticketFor(roomId: string, sessionId: string): Promise + /** Tira o jogador da sala de voz quando ele sai da sala de jogo. */ + remove(roomId: string, sessionId: string): void +} + +/** + * O servidor de mídia, no loopback: a API dele é Twirp sobre HTTP na mesma + * porta da sinalização (docs/rede, Tabela 1). + */ +const MEDIA_SERVER_API = 'http://127.0.0.1:7880' + +/** + * O token só serve para entrar: depois da conexão, o servidor de mídia o + * renova sozinho. Um minuto cobre a ida do token ao cliente e a conexão. + */ +const TICKET_TTL_S = 60 + +/** + * Lê `LIVEKIT_API_KEY` e `LIVEKIT_API_SECRET`, as do `keys` do servidor de + * mídia. Sem as duas, devolve `undefined` e o jogo roda sem voz. + * + * ```ts + * const voice = createVoiceAccess(process.env) + * ``` + */ +export function createVoiceAccess(env: NodeJS.ProcessEnv): VoiceAccess | undefined { + const { LIVEKIT_API_KEY: apiKey, LIVEKIT_API_SECRET: apiSecret } = env + if (!apiKey && !apiSecret) return undefined + if (!apiKey || !apiSecret) { + throw new Error( + `chave de voz pela metade: LIVEKIT_API_KEY='${apiKey}', LIVEKIT_API_SECRET ${apiSecret ? 'definida' : 'vazia'}; esperado as duas ou nenhuma`, + ) + } + const rooms = new RoomServiceClient(MEDIA_SERVER_API, apiKey, apiSecret) + return { + ticketFor: (roomId, sessionId) => signTicket(apiKey, apiSecret, roomId, sessionId), + remove: (roomId, sessionId) => { + // quem fechou a aba já saiu da voz sozinho, e o servidor de mídia responde + // que não o encontrou: só registra. + rooms.removeParticipant(roomId, sessionId).catch((error: unknown) => { + console.log( + JSON.stringify({ event: 'voice.remove-failed', roomId, sessionId, error: String(error) }), + ) + }) + }, + } +} + +function signTicket( + apiKey: string, + apiSecret: string, + roomId: string, + sessionId: string, +): Promise { + const token = new AccessToken(apiKey, apiSecret, { identity: sessionId, ttl: TICKET_TTL_S }) + token.addGrant({ + roomJoin: true, + room: roomId, + canPublish: true, + canPublishSources: [TrackSource.MICROPHONE], + canPublishData: false, + canSubscribe: true, + }) + return token.toJwt() +} diff --git a/vitest.config.ts b/vitest.config.ts index 4145dc0d..0e3dc6ed 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -44,6 +44,7 @@ export const IO_ADAPTERS: readonly string[] = [ 'packages/server/src/gameRoom.ts', 'packages/server/src/main.ts', 'packages/server/src/matchmaking.ts', + 'packages/server/src/voiceAccess.ts', ] // headless por decisão: nenhum teste abre navegador (adr 0001). From a010ef8b18fa93b97db367152f164a172d646f11 Mon Sep 17 00:00:00 2001 From: thnbi Date: Tue, 13 Oct 2026 19:50:17 -0300 Subject: [PATCH 075/139] feat: talk in the room's voice channel while V is held --- packages/client/package.json | 3 +- .../client/src/controller/pushToTalk.test.ts | 70 +++++++++++++++++++ packages/client/src/controller/pushToTalk.ts | 40 +++++++++++ packages/client/src/main.ts | 49 ++++++++++--- packages/client/src/net/gameLink.ts | 23 ++++++ packages/client/src/net/networkPort.test.ts | 1 + packages/client/src/net/voiceLink.ts | 45 ++++++++++++ vitest.config.ts | 1 + 8 files changed, 223 insertions(+), 9 deletions(-) create mode 100644 packages/client/src/controller/pushToTalk.test.ts create mode 100644 packages/client/src/controller/pushToTalk.ts create mode 100644 packages/client/src/net/voiceLink.ts diff --git a/packages/client/package.json b/packages/client/package.json index 9893f133..5caed8a7 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -12,7 +12,8 @@ "@babylonjs/core": "9.23.0", "@babylonjs/loaders": "9.23.0", "@colyseus/sdk": "0.18.4", - "@shobu/core": "*" + "@shobu/core": "*", + "livekit-client": "2.22.3" }, "devDependencies": { "vite": "^7.1.14" diff --git a/packages/client/src/controller/pushToTalk.test.ts b/packages/client/src/controller/pushToTalk.test.ts new file mode 100644 index 00000000..5218b19d --- /dev/null +++ b/packages/client/src/controller/pushToTalk.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { KeyEventSource } from './heldKeys.ts' +import { bindPushToTalk, TALK_KEY_CODE } from './pushToTalk.ts' + +type Listener = (event: KeyboardEvent) => void + +/** Um alvo de eventos de teclado de mentira: guarda os ouvintes e deixa o teste disparar. */ +class FakeWindow implements KeyEventSource { + readonly listeners = new Map void)>>() + addEventListener(type: string, listener: Listener | (() => void)): void { + const set = this.listeners.get(type) ?? new Set() + set.add(listener) + this.listeners.set(type, set) + } + removeEventListener(type: string, listener: Listener | (() => void)): void { + this.listeners.get(type)?.delete(listener) + } + fire(type: 'keydown' | 'keyup', code: string): void { + for (const listener of this.listeners.get(type) ?? []) { + ;(listener as Listener)({ code } as KeyboardEvent) + } + } + blur(): void { + for (const listener of this.listeners.get('blur') ?? []) (listener as () => void)() + } +} + +function bind(source: FakeWindow): { calls: boolean[]; dispose: () => void } { + const calls: boolean[] = [] + const dispose = bindPushToTalk(source, (on) => calls.push(on)) + return { calls, dispose } +} + +describe('bindPushToTalk', () => { + it('fala enquanto o V está preso', () => { + const source = new FakeWindow() + const { calls } = bind(source) + source.fire('keydown', TALK_KEY_CODE) + source.fire('keyup', TALK_KEY_CODE) + expect(calls).toEqual([true, false]) + }) + + it('ignora a repetição automática e as outras teclas', () => { + const source = new FakeWindow() + const { calls } = bind(source) + source.fire('keydown', TALK_KEY_CODE) + source.fire('keydown', TALK_KEY_CODE) + source.fire('keydown', 'KeyW') + source.fire('keyup', 'KeyW') + expect(calls).toEqual([true]) + }) + + it('fecha o microfone ao perder o foco com o V preso', () => { + const source = new FakeWindow() + const { calls } = bind(source) + source.fire('keydown', TALK_KEY_CODE) + source.blur() + source.blur() + expect(calls).toEqual([true, false]) + }) + + it('para de escutar depois do dispose', () => { + const source = new FakeWindow() + const { calls, dispose } = bind(source) + dispose() + source.fire('keydown', TALK_KEY_CODE) + expect(calls).toEqual([]) + expect([...source.listeners.values()].every((set) => set.size === 0)).toBe(true) + }) +}) diff --git a/packages/client/src/controller/pushToTalk.ts b/packages/client/src/controller/pushToTalk.ts new file mode 100644 index 00000000..62e0c22c --- /dev/null +++ b/packages/client/src/controller/pushToTalk.ts @@ -0,0 +1,40 @@ +import type { KeyEventSource } from './heldKeys.ts' + +/** + * Fala enquanto o V estiver preso. `code`, e não `key`, pelo mesmo motivo de + * `heldKeys.ts`: posição física, igual em qualquer layout. + */ +export const TALK_KEY_CODE = 'KeyV' + +/** + * Push-to-talk: `talk(true)` na descida do V, `talk(false)` na subida ou ao + * perder o foco, senão o microfone fica aberto com a tecla solta fora da + * janela. A repetição automática do teclado não chama de novo. + * + * ```ts + * const dispose = bindPushToTalk(window, (on) => voice?.talk(on)) + * ``` + */ +export function bindPushToTalk(source: KeyEventSource, talk: (on: boolean) => void): () => void { + let talking = false + const set = (on: boolean): void => { + if (on === talking) return + talking = on + talk(on) + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.code === TALK_KEY_CODE) set(true) + } + const onKeyUp = (event: KeyboardEvent): void => { + if (event.code === TALK_KEY_CODE) set(false) + } + const onBlur = (): void => set(false) + source.addEventListener('keydown', onKeyDown) + source.addEventListener('keyup', onKeyUp) + source.addEventListener('blur', onBlur) + return () => { + source.removeEventListener('keydown', onKeyDown) + source.removeEventListener('keyup', onKeyUp) + source.removeEventListener('blur', onBlur) + } +} diff --git a/packages/client/src/main.ts b/packages/client/src/main.ts index 4acce486..195a97db 100644 --- a/packages/client/src/main.ts +++ b/packages/client/src/main.ts @@ -8,6 +8,7 @@ import { createGameAudio, type GameAudio } from './audio/gameAudio.ts' import { createNarrator } from './audio/narrator.ts' import { createWebAudioMixer } from './audio/webAudioMixer.ts' import { fetchGameplayConfig } from './config/fetchGameplayConfig.ts' +import { bindPushToTalk } from './controller/pushToTalk.ts' import { type ArenaHud, createArenaHud } from './hud/arenaHud.ts' import { type BootMenu, createBootMenu } from './hud/bootMenu.ts' import { type BootOverlay, createBootOverlay } from './hud/bootOverlay.ts' @@ -24,6 +25,7 @@ import { type GameLink, openGameLink } from './net/gameLink.ts' import { createNetworkPort, type NetworkPort } from './net/networkPort.ts' import { interpolationDelayMs } from './net/remotePlayerView.ts' import { gameServerUrl, resolveRoomAddress } from './net/roomLocation.ts' +import { openVoiceLink, type VoiceLink } from './net/voiceLink.ts' import type { ArenaRenderer } from './renderer/arenaRenderer.ts' import { createBabylonArenaRenderer } from './renderer/babylonArenaRenderer.ts' import { createLivePlayerSettings, type LivePlayerSettings } from './settings/livePlayerSettings.ts' @@ -40,6 +42,9 @@ const GAMEPLAY_CONFIG_URL = '/gameplay.json' /** A porta do servidor de jogo, a da Tabela 1 de `docs/rede/arquitetura-de-rede.md`. */ const GAME_SERVER_PORT = 2567 +/** A sinalização do servidor de mídia, a da Tabela 1. */ +const MEDIA_SERVER_PORT = 7880 + const waitMs = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) interface Intro { @@ -165,26 +170,54 @@ function connectToRoom(config: GameplayConfig, network: NetworkPort): void { window.history.replaceState(null, '', room.path) const url = gameServerUrl(window.location, GAME_SERVER_PORT) const current: { link: GameLink | undefined } = { link: undefined } + const voice = createVoiceSeat() window.addEventListener('pagehide', () => void current.link?.leave()) const follow = (link: GameLink): void => { current.link = link network.attach(link) + voice.follow(link) window.history.replaceState(null, '', `/${link.address}`) link.onMove((next) => void next().then(follow).catch(logJoinFailure(room.address, url))) - console.info( - JSON.stringify({ - event: 'room.joined', - address: link.address, - requested: room.address, - sessionId: link.sessionId, - }), - ) + logJoined(link, room.address) } openGameLink(url, room.address, interpolationDelayMs(config)) .then(follow) .catch(logJoinFailure(room.address, url)) } +/** + * A voz acompanha a sala de jogo: cada sala tem a sua, e quem troca de sala + * sai da voz antiga. Sem token, ou com falha, o jogo segue sem voz (ADR 0009). + */ +function createVoiceSeat(): { follow(link: GameLink): void } { + const url = gameServerUrl(window.location, MEDIA_SERVER_PORT) + let voice: VoiceLink | undefined + bindPushToTalk(window, (on) => voice?.talk(on)) + window.addEventListener('pagehide', () => voice?.leave()) + return { + follow: (link) => { + voice?.leave() + voice = undefined + link.onVoiceTicket((ticket) => { + openVoiceLink(url, ticket.token) + .then((opened) => { + voice = opened + }) + .catch((reason: unknown) => { + console.warn( + JSON.stringify({ event: 'voice.join-failed', url, message: String(reason) }), + ) + }) + }) + }, + } +} + +function logJoined(link: GameLink, requested: string): void { + const { address, sessionId } = link + console.info(JSON.stringify({ event: 'room.joined', address, requested, sessionId })) +} + function logJoinFailure(address: string, url: string): (reason: unknown) => void { return (reason) => { const message = reason instanceof Error ? reason.message : String(reason) diff --git a/packages/client/src/net/gameLink.ts b/packages/client/src/net/gameLink.ts index 3aae0cdb..142bb2a6 100644 --- a/packages/client/src/net/gameLink.ts +++ b/packages/client/src/net/gameLink.ts @@ -6,6 +6,8 @@ import { ROOM_ENTRY_PATH, ROOM_MOVE_MESSAGE, type SeatOffer, + VOICE_TICKET_MESSAGE, + type VoiceTicket, type WireInput, } from '@shobu/core' import type { MutableWireInput } from './encodeTickInput.ts' @@ -38,6 +40,8 @@ export interface GameLink { * com o assento que o servidor já reservou. */ onMove(listener: (follow: () => Promise) => void): void + /** O token da sala de voz. Sem servidor de mídia, nunca chama. */ + onVoiceTicket(listener: (ticket: VoiceTicket) => void): void leave(): Promise } @@ -81,6 +85,7 @@ async function takeSeat( ...createInputChannel(room, interpolationDelayMs), forEachRemote: createRemoteReader(room, interpolationDelayMs), ownSnapshot: () => playersOf(room.state).get(room.sessionId), + onVoiceTicket: createVoiceTicketChannel(room), onKill: (listener) => { room.onMessage(KILL_MESSAGE, listener) }, @@ -98,6 +103,24 @@ async function takeSeat( } } +/** + * O servidor manda o token logo depois da entrada, e ele pode chegar antes de + * alguém escutar: o sdk descarta mensagem sem ouvinte, então o canal guarda o + * token até ser pedido. + */ +function createVoiceTicketChannel(room: Room): GameLink['onVoiceTicket'] { + let received: VoiceTicket | undefined + let listening: ((ticket: VoiceTicket) => void) | undefined + room.onMessage(VOICE_TICKET_MESSAGE, (ticket: VoiceTicket) => { + received = ticket + listening?.(ticket) + }) + return (listener) => { + listening = listener + if (received) listener(received) + } +} + /** * O canal de input confiável do sdk. Copia campo a campo no mesmo objeto, sem * alocar por tick; o `sentCount` depois do envio é o número de sequência que o diff --git a/packages/client/src/net/networkPort.test.ts b/packages/client/src/net/networkPort.test.ts index b29744d0..2d817983 100644 --- a/packages/client/src/net/networkPort.test.ts +++ b/packages/client/src/net/networkPort.test.ts @@ -64,6 +64,7 @@ class FakeGameLink implements GameLink { } onMove(): void {} + onVoiceTicket(): void {} async leave(): Promise {} } diff --git a/packages/client/src/net/voiceLink.ts b/packages/client/src/net/voiceLink.ts new file mode 100644 index 00000000..9ddd93a0 --- /dev/null +++ b/packages/client/src/net/voiceLink.ts @@ -0,0 +1,45 @@ +import { AudioPresets, type RemoteTrack, Room, RoomEvent, Track } from 'livekit-client' + +/** + * O canal de voz, do lado do cliente: a interface do projeto sobre o + * `livekit-client`. Adapter de rede; quem decide quando falar é + * `pushToTalk.ts`. + */ +export interface VoiceLink { + /** Abre ou fecha o microfone. O primeiro `talk(true)` pede a permissão. */ + talk(on: boolean): void + leave(): void +} + +/** + * Entra na sala de voz com o token que o servidor de jogo emitiu. Preset + * `speech` (Opus a 24 kbps): o padrão do sdk é `music`, 48 kbps, e voz não + * precisa dele. RED e DTX já vêm ligados no sdk (docs/rede, Tabela de + * parâmetros). + * + * ```ts + * const voice = await openVoiceLink(gameServerUrl(location, 7880), ticket.token) + * ``` + */ +export async function openVoiceLink(url: string, token: string): Promise { + const room = new Room({ publishDefaults: { audioPreset: AudioPresets.speech } }) + room.on(RoomEvent.TrackSubscribed, playRemoteAudio) + room.on(RoomEvent.TrackUnsubscribed, (track) => { + for (const element of track.detach()) element.remove() + }) + await room.connect(url, token) + return { + talk: (on) => { + room.localParticipant.setMicrophoneEnabled(on).catch((reason: unknown) => { + console.warn(JSON.stringify({ event: 'voice.mic-failed', message: String(reason) })) + }) + }, + leave: () => void room.disconnect(), + } +} + +/** O sdk não toca o áudio sozinho: cada faixa recebida ganha um `