Capstone: o jogo, a API, o banco e o site num só fluxo, e a retrospectiva do curso

Capstone: o jogo, a API, o banco e o site num só fluxo, e a retrospectiva do curso

O capstone: a Biblioteca em Apuros envia partidas que a API valida com as mesmas regras do jogo, a partir de uma semente emitida pelo servidor e de um gerador PCG32 próprio, grava com outbox e mostra o ranking ao vivo no site. Depois, a retrospectiva das onze fases do curso.
Linguagem C#

• • 36 min de leitura

Este último artigo junta as pontas. A "Biblioteca em Apuros" passa a enviar suas partidas para a API da Livraria. A API as valida com as mesmas regras do jogo, grava o resultado no banco e publica um evento, e o site mostra o ranking atualizado no momento em que alguém bate um recorde. O caminho de uma pontuação, do polegar de quem joga até a tela de quem acompanha, passa por quase tudo que o curso construiu. No fim, olhamos para trás e percorremos o caminho inteiro, fechando o círculo com o mapa do artigo zero.

O fluxo completo

Biblioteca em Apuros (Godot)                    Livraria.Api                       Banco / Redis / RabbitMQ          Livraria.Site
────────────────────────────                    ────────────                       ────────────────────────          ─────────────
1. login por código (RFC 8628) ───────────────► Keycloak
2. POST /v1/partidas ─────────────────────────► emite partida: id, semente ──────► grava Partida (Aberta)
3. joga: gerador determinístico + gravação
4. POST /v1/partidas/{id}/resultado ──────────► valida com as Regras do jogo
                                                grava resultado + outbox ────────► Partida (Validada) + caixa de saída
                                                                                   retransmissor → partida.validada
                                                                                   consumidor → ZADD GT no ranking
                                                                                   hub → RankingAtualizado ─────────────► /ranking atualiza sozinho
5. mostra posição oficial ◄──────────────────── 200 { pontos, posicao }

Cada seta é um artigo do curso. O login é o de "Performance e distribuição", a partida é gravada nos repositórios da Fase 5, o resultado usa o outbox de "Worker Services e mensageria com RabbitMQ", o ranking é o sorted set de "Cache com Redis, HybridCache e output caching", o aviso ao vivo é o hub de "Tempo real com SignalR", e a página é uma Razor Page com o aprimoramento progressivo de "Razor Pages". A única peça realmente nova é a do meio: como a API decide se acredita numa pontuação.

O problema da confiança

"Áudio, interface e save game" terminou com uma observação: um recorde num arquivo local pode ser editado por qualquer um. Para um ranking público, o problema é o mesmo, e maior. A versão ingênua do envio seria:

POST /v1/pontuacoes
{ "pontos": 48210 }

Qualquer pessoa com um cliente HTTP pode enviar { "pontos": 99999999 }, e o ranking vira uma disputa de quem digita o maior número. É o mesmo princípio que atravessou toda a Fase 6: o servidor não confia no que o cliente afirma, ele verifica. A API recusa um pedido de um livro inexistente, recalcula o preço em vez de aceitar o que veio do carrinho, e confere o dono de cada pedido. Com pontuações não pode ser diferente.

Não dá para impedir toda trapaça num jogo que roda no computador de quem joga. Um programa que joga sozinho, perfeitamente, dentro das regras, produz partidas legítimas do ponto de vista do servidor. O objetivo realista é outro: tornar a trapaça tão cara quanto jogar bem. Para isso, o servidor precisa de três coisas:

  1. Controlar o início: a partida é emitida pelo servidor, com uma semente que o jogador não escolhe, e só pode ser enviada uma vez.
  2. Poder reproduzir a partida: o que caiu, onde e quando, precisa ser recalculável no servidor a partir da semente.
  3. Receber o que o jogador fez, e não o resultado, e recalcular o resultado com as mesmas regras.

A partida é emitida pelo servidor

public sealed class Partida
{
    public Guid Id { get; private set; }
    public string ClienteId { get; private set; } = "";
    public string Apelido { get; private set; } = "";
    public ulong Semente { get; private set; }
    public string VersaoDasRegras { get; private set; } = "";
    public DateTimeOffset IniciadaEm { get; private set; }
    public DateTimeOffset ExpiraEm { get; private set; }
    public StatusDaPartida Status { get; private set; }
    public int? Pontos { get; private set; }
    public string? MotivoDaRejeicao { get; private set; }

    public static Partida Iniciar(string clienteId, string apelido, string versaoDasRegras, DateTimeOffset agora) => new()
    {
        Id = Guid.CreateVersion7(agora),
        ClienteId = clienteId,
        Apelido = apelido,
        Semente = BitConverter.ToUInt64(RandomNumberGenerator.GetBytes(8)),
        VersaoDasRegras = versaoDasRegras,
        IniciadaEm = agora,
        ExpiraEm = agora.AddHours(24),
        Status = StatusDaPartida.Aberta,
    };

    // Validar(...) e Rejeitar(...) mudam o status, com as regras de transição
}
var partidas = app.MapGroup("/v1/partidas").WithTags("Jogo").RequireAuthorization("Jogar");

partidas.MapPost("/", async (ClaimsPrincipal usuario, IRepositorioPartidas repo,
                             TimeProvider relogio, CancellationToken ct) =>
{
    var partida = Partida.Iniciar(
        usuario.FindFirstValue("sub")!,
        usuario.FindFirstValue("preferred_username") ?? "Jogador",
        RegrasDoJogo.Versao,
        relogio.GetUtcNow());

    await repo.AdicionarAsync(partida, ct);
    return TypedResults.Created($"/v1/partidas/{partida.Id}",
        new PartidaEmitida(partida.Id, partida.Semente, partida.VersaoDasRegras));
}).RequireRateLimiting("Partidas");

Alguns detalhes vêm de artigos anteriores:

  • Guid.CreateVersion7, disponível desde o .NET 9, gera identificadores ordenados pelo tempo. No PostgreSQL, isso mantém o índice da chave primária inserindo sempre no fim, sem a fragmentação que GUIDs aleatórios causam.
  • RandomNumberGenerator gera a semente com um gerador criptográfico. Um Random comum, semeado pelo relógio, permitiria a alguém prever sementes futuras e treinar um robô para cada uma.
  • O apelido, e não o e-mail, é o que aparece no ranking público. É o cuidado com dados pessoais de "Configuração, logging e testes de integração" num lugar em que ele é fácil de esquecer.
  • TimeProvider, em vez de DateTimeOffset.UtcNow, deixa os testes controlarem o relógio, como no exercício 1 de "Validação, ProblemDetails e OpenAPI".
  • A tabela partidas ganha uma migration nos dois provedores relacionais e uma coleção no Mongo, com os repositórios da Fase 5.

Tornar a partida reproduzível

Para o servidor recalcular uma partida, a sequência de livros e traças precisa ser função da semente, e só dela. O loop de tempo fixo de "C# nos jogos" deu a primeira metade: a simulação avança sempre em passos de 1/60 s, e cada evento pode ser identificado pelo número do passo (o tick). Falta a segunda metade: a geração dos livros precisa sair do nó Principal do Godot e ir para o projeto Regras, que o servidor também pode usar.

Um gerador de números aleatórios que não muda

A documentação do .NET avisa que o algoritmo por trás de new Random(semente) não é garantido entre versões do runtime. Hoje, a mesma semente produz a mesma sequência. Nada impede uma versão futura de mudar isso, e se o jogo e o servidor usarem runtimes diferentes, as partidas deixam de conferir. Para reprodutibilidade de longo prazo, o gerador precisa ser nosso. O PCG32, publicado por Melissa O'Neill em 2014, é pequeno, rápido, estatisticamente bom e fácil de implementar:

namespace BibliotecaEmApuros.Regras;

public struct Pcg32
{
    private ulong estado;
    private readonly ulong incremento;

    public Pcg32(ulong semente, ulong sequencia = 54)
    {
        estado = 0;
        incremento = (sequencia << 1) | 1;
        Proximo();
        estado += semente;
        Proximo();
    }

    public uint Proximo()
    {
        var antigo = estado;
        estado = unchecked(antigo * 6364136223846793005UL + incremento);
        var misturado = (uint)(((antigo >> 18) ^ antigo) >> 27);
        var rotacao = (int)(antigo >> 59);
        return (misturado >> rotacao) | (misturado << (-rotacao & 31));
    }

    public int Entre(int minimo, int maximoExclusivo) =>
        minimo + (int)(Proximo() % (uint)(maximoExclusivo - minimo));

    public bool Chance(int porMil) => Proximo() % 1000 < porMil;
}

É uma struct com dois campos, sem nenhuma alocação, pronta para o loop do jogo. A sequência que ela produz depende só da aritmética de inteiros de 64 bits, que é idêntica em qualquer processador e em qualquer versão do .NET. O teste que garante isso é simples e importante: os primeiros valores para uma semente fixa, gravados uma vez, conferidos para sempre.

Inteiros onde a precisão importa

O mesmo cuidado vale para a aritmética da própria partida. Operações básicas de ponto flutuante são reproduzíveis na prática, mas funções como MathF.Sin podem dar resultados ligeiramente diferentes em processadores e sistemas diferentes. Um jogo compilado para ARM num celular e um servidor x64 podem discordar na sétima casa decimal, e uma validação exata falharia por nada. A saída é usar inteiros para tudo o que o servidor precisa conferir com exatidão, e tolerâncias para o que é inevitavelmente aproximado:

public readonly record struct LivroGerado(int Indice, int TickDeNascimento, int X, int VelocidadePxS, bool Raro)
{
    public const int Largura = 12, Altura = 16;

    // posição vertical em pixels, sem ponto flutuante: y = -16 + v·(t − t0) / 60
    public int YNoTick(int tick) => -Altura + VelocidadePxS * (tick - TickDeNascimento) / 60;
}

public static class GeradorDeEventos
{
    public static void GerarLivros(ulong semente, TabelaDeDificuldade tabela, int ateOTick, List<LivroGerado> destino)
    {
        var gerador = new Pcg32(semente, sequencia: 1);
        var tick = 60;
        var indice = 0;

        while (tick < ateOTick)
        {
            var nivel = tabela.NoTick(tick);
            var raro = gerador.Chance(nivel.ChanceDeRaroPorMil);
            destino.Add(new LivroGerado(
                indice++, tick,
                X: gerador.Entre(8, 460),
                VelocidadePxS: raro ? nivel.VelocidadePxS * 3 / 2 : nivel.VelocidadePxS,
                Raro: raro));
            tick += Math.Max(15, nivel.IntervaloEmTicks * gerador.Entre(70, 131) / 100);
        }
    }
}

As traças têm o seu próprio gerador, com outra sequencia do PCG, para que acrescentar um tipo de evento no futuro não altere a sequência de livros das partidas antigas.

A TabelaDeDificuldade é a versão "congelada" das curvas de "Projeto: a Biblioteca em Apuros completa": os valores das curvas do Godot amostrados em pontos fixos, guardados no projeto Regras como uma tabela de inteiros, com uma versão (RegrasDoJogo.Versao = "2026.09-1"). O editor continua servindo para experimentar. Quando o ajuste está bom, a tabela é regenerada e a versão muda. Partidas iniciadas numa versão são validadas com a tabela daquela versão. Mudar a dificuldade sem mudar a versão faria o servidor rejeitar partidas legítimas.

No jogo, o nó que solta livros deixa de sortear e passa a seguir o gerador: a cada passo físico, solta os livros cujo TickDeNascimento chegou. A chuva que o jogador vê e a que o servidor recalcula são a mesma, livro por livro.

As regras que migraram

Para o servidor recalcular vidas e fim de jogo, uma regra que morava no Godot precisou mudar de lugar: a invencibilidade depois de um golpe, que estava no ComponenteDeVida de "Arquitetura de jogo". No servidor não existe componente nem nó. A regra ("golpes nos 90 ticks seguintes a outro golpe não contam") passou para RegrasDaPartida, e o componente passou a consultá-la. É o critério daquele artigo aplicado com um motivo concreto: regras que definem o resultado do jogo moram fora do motor, e ter um segundo consumidor (o servidor) revelou uma que ainda estava no lugar errado.

O que o jogo envia

O jogo não envia a pontuação. Envia o que o jogador fez:

public sealed record RegistroDaPartida(
    string VersaoDasRegras,
    int DuracaoEmTicks,
    int PontosInformados,
    short[] AmostrasX,              // posição da bibliotecária a cada 6 ticks (10 por segundo)
    short[] AmostrasY,
    int[] ApanhadosTick,            // em que tick cada livro foi apanhado
    int[] ApanhadosLivro,           // índice do livro apanhado
    int[] MarcadoresTick);

Os golpes de traça e os livros perdidos não são enviados. O servidor os deduz da trajetória e dos livros gerados. Se o jogo enviasse os golpes, um cliente modificado simplesmente omitiria todos e jogaria para sempre com três vidas.

A gravação no jogo segue as regras de "Arquitetura de jogo e por que não alocar dentro do loop": arranjos pré-alocados para dez minutos de partida (6.000 amostras), preenchidos por índice, sem nenhuma alocação durante o jogo. Três minutos de partida geram cerca de 1.800 amostras de posição, alguns kilobytes em JSON, que caem a uma fração disso com compressão. A API aceita corpos comprimidos com o middleware de descompressão de requisições do ASP.NET Core (AddRequestDecompression e UseRequestDecompression), e o jogo envia com Content-Encoding: gzip.

O validador

O validador mora no projeto Regras, compartilhado com o jogo, e não depende nem do Godot nem do ASP.NET Core:

public sealed class ValidadorDePartida(TabelaDeDificuldade tabela, ParametrosFisicos fisica)
{
    private const int TicksPorAmostra = 6;

    public ResultadoDaValidacao Validar(ulong semente, RegistroDaPartida r)
    {
        // 1. Forma
        if (r.VersaoDasRegras != RegrasDoJogo.Versao)
            return ResultadoDaValidacao.Rejeitada("versão de regras diferente da partida");
        if (r.AmostrasX.Length != r.AmostrasY.Length ||
            r.AmostrasX.Length != r.DuracaoEmTicks / TicksPorAmostra + 1 ||
            r.ApanhadosTick.Length != r.ApanhadosLivro.Length)
            return ResultadoDaValidacao.Rejeitada("registro malformado");

        // 2. Trajetória possível: ninguém anda mais rápido que a velocidade máxima
        var passoMaximoX = fisica.VelocidadeMaximaPxS * TicksPorAmostra / 60 + fisica.ToleranciaPx;
        for (var i = 1; i < r.AmostrasX.Length; i++)
            if (Math.Abs(r.AmostrasX[i] - r.AmostrasX[i - 1]) > passoMaximoX)
                return ResultadoDaValidacao.Rejeitada($"deslocamento impossível na amostra {i}");

        // 3. Livros apanhados existem, são únicos e estavam nas mãos naquele tick
        var livros = new List<LivroGerado>();
        GeradorDeEventos.GerarLivros(semente, tabela, r.DuracaoEmTicks, livros);
        var apanhado = new bool[livros.Count];

        for (var i = 0; i < r.ApanhadosTick.Length; i++)
        {
            var (tick, indice) = (r.ApanhadosTick[i], r.ApanhadosLivro[i]);
            if ((uint)indice >= (uint)livros.Count || apanhado[indice])
                return ResultadoDaValidacao.Rejeitada($"livro {indice} inexistente ou apanhado duas vezes");

            var livro = livros[indice];
            var amostra = Math.Clamp((tick + TicksPorAmostra / 2) / TicksPorAmostra, 0, r.AmostrasX.Length - 1);
            if (!fisica.MaosAlcancam(r.AmostrasX[amostra], r.AmostrasY[amostra], livro.X, livro.YNoTick(tick)))
                return ResultadoDaValidacao.Rejeitada($"livro {indice} fora do alcance no tick {tick}");

            apanhado[indice] = true;
        }

        // 4. Monta a linha do tempo: apanhados, perdidos (deduzidos), golpes (deduzidos), marcadores
        var eventos = new List<EventoDaPartida>();
        for (var i = 0; i < r.ApanhadosTick.Length; i++)
            eventos.Add(EventoDaPartida.Apanhado(r.ApanhadosTick[i], livros[r.ApanhadosLivro[i]].Raro));
        foreach (var livro in livros)
            if (!apanhado[livro.Indice] && fisica.TickEmQueTocaOChao(livro) is int tickNoChao && tickNoChao <= r.DuracaoEmTicks)
                eventos.Add(EventoDaPartida.Perdido(tickNoChao));
        eventos.AddRange(DetectorDeGolpes.Detectar(semente, tabela, fisica, r));
        foreach (var t in r.MarcadoresTick) eventos.Add(EventoDaPartida.Marcador(t));
        eventos.Sort(static (a, b) => a.Tick.CompareTo(b.Tick));

        // 5. Recalcula com as MESMAS regras do jogo
        var regras = new RegrasDaPartida();
        int? tickDoFim = null;
        foreach (var e in eventos)
        {
            e.AplicarEm(regras);
            if (regras.Terminou) { tickDoFim = e.Tick; break; }
        }

        // 6. Coerência do fim e da pontuação
        if (tickDoFim is null || Math.Abs(tickDoFim.Value - r.DuracaoEmTicks) > fisica.ToleranciaDeFimEmTicks)
            return ResultadoDaValidacao.Rejeitada("a partida não termina onde o registro diz");
        if (regras.Pontos != r.PontosInformados)
            return ResultadoDaValidacao.Rejeitada($"pontuação informada {r.PontosInformados}, recalculada {regras.Pontos}");

        return ResultadoDaValidacao.Validada(regras.Pontos);
    }
}

O passo 5 é o centro do capstone. RegrasDaPartida é a mesma classe, do mesmo assembly, que o jogo usa durante a partida, criada em "Arquitetura de jogo e por que não alocar dentro do loop" para ser testável sem o motor. Ela agora tem um segundo uso, que justifica o esforço daquela separação: a pontuação oficial não vem do jogo, vem do servidor rodando as regras do jogo sobre a partida reconstruída. Se as regras mudarem, mudam nos dois lugares ao mesmo tempo, porque são o mesmo código.

O DetectorDeGolpes regenera as traças, calcula a trajetória de cada uma e procura, nas amostras de posição, os momentos em que a caixa da bibliotecária e a da traça se sobrepunham, aplicando as hitboxes reduzidas de "Input, física e colisões" e uma tolerância para as diferenças de ponto flutuante do seno. O ParametrosFisicos concentra os números que precisam coincidir com o jogo (velocidade máxima, tamanho das mãos, altura do chão, tolerâncias), com um teste que compara esses valores com os exportados nas cenas.

Nenhuma das rejeições diz ao cliente exatamente o que falhou. O motivo detalhado vai para o banco e para o log, para revisão, e a resposta HTTP diz só que a partida não foi aceita. Um trapaceiro que recebesse "livro 37 fora do alcance no tick 4120" teria um depurador gratuito para ajustar o robô.

O envio e a gravação

partidas.MapPost("/{id:guid}/resultado", async (
    Guid id, RegistroDaPartida registro, ClaimsPrincipal usuario,
    IRepositorioPartidas repo, ValidadorDePartida validador,
    TimeProvider relogio, ILogger<Partida> log, CancellationToken ct) =>
{
    var partida = await repo.ObterAsync(id, ct);
    if (partida is null || partida.ClienteId != usuario.FindFirstValue("sub"))
        return Results.NotFound();

    var agora = relogio.GetUtcNow();
    if (partida.Status != StatusDaPartida.Aberta)
        return Results.Conflict();
    if (agora > partida.ExpiraEm)
        return Results.Problem(statusCode: 410, title: "Partida expirada");

    // não se joga 3 minutos em 30 segundos de relógio
    var duracaoReal = agora - partida.IniciadaEm;
    if (duracaoReal < TimeSpan.FromSeconds(registro.DuracaoEmTicks / 60.0 * 0.95))
        return Rejeitar(partida, "duração maior que o tempo decorrido");

    var resultado = validador.Validar(partida.Semente, registro);
    if (resultado.Aceita) partida.Validar(resultado.Pontos, agora);
    else partida.Rejeitar(resultado.Motivo, agora);

    if (!await repo.ConcluirAsync(partida, ct))          // UPDATE ... WHERE status = 'Aberta'
        return Results.Conflict();                       // outra requisição concluiu antes

    if (!resultado.Aceita)
    {
        log.PartidaRejeitada(partida.Id, partida.ClienteId, resultado.Motivo);
        return Results.UnprocessableEntity();
    }

    var posicao = await repo.PosicaoNoRankingAsync(resultado.Pontos, ct);
    return Results.Ok(new ResultadoOficial(resultado.Pontos, posicao));
}).RequireRateLimiting("Partidas");

A sequência de verificações repete padrões conhecidos. Dono da partida, com 404 para partidas de outros, é o BOLA de "Autenticação e autorização com JWT". Uso único, com um UPDATE condicional ao status, é o mesmo mecanismo que impede vender o último exemplar duas vezes em "ADO.NET: Conexões, Comandos Parametrizados e o Perigo do SQL Injection": se o jogo reenviar o resultado por uma falha de rede, a segunda requisição encontra a partida concluída e recebe 409, sem gravar nada de novo. O relógio do servidor como limite inferior da duração impede que um robô "jogue" três minutos em trinta segundos.

Partida.Validar registra o evento de domínio PartidaFoiValidada, e o SaveChangesAsync do LivrariaContext o transforma numa linha da caixa de saída na mesma transação, como em "Worker Services e mensageria com RabbitMQ". Nenhuma pontuação aceita fica fora do ranking por uma queda entre o commit e a publicação.

O ranking

O banco é a fonte da verdade. A consulta do melhor resultado de cada jogador na semana, no PostgreSQL, usa DISTINCT ON:

SELECT DISTINCT ON (cliente_id) cliente_id, apelido, pontos, finalizada_em
FROM partidas
WHERE status = 'Validada' AND finalizada_em >= date_trunc('week', now())
ORDER BY cliente_id, pontos DESC, finalizada_em;

Consultar isso a cada visita ao site seria desperdício. O consumidor de partida.validada, na fila livraria.api.efeitos, mantém uma projeção no Redis:

private async Task AoValidarPartida(PartidaValidada e, CancellationToken ct)
{
    var db = redis.GetDatabase();
    var chave = $"livraria:ranking:{SemanaIso(e.FinalizadaEm)}";

    var melhorou = await db.SortedSetAddAsync(chave, e.ClienteId, e.Pontos, SortedSetWhen.GreaterThan);
    await db.HashSetAsync("livraria:ranking:apelidos", e.ClienteId, e.Apelido);
    await db.KeyExpireAsync(chave, TimeSpan.FromDays(21));

    if (!melhorou) return;

    var top = await LerTopAsync(db, chave, 10);
    await hub.Clients.Group(HubDaLivraria.GrupoRanking).RankingAtualizado(top);
}

SortedSetWhen.GreaterThan corresponde à opção GT do comando ZADD do Redis: a pontuação do jogador só é atualizada se a nova for maior. Uma partida pior não rebaixa o recorde da semana, e o consumidor é idempotente sem nenhuma tabela extra: processar o mesmo evento duas vezes tenta gravar o mesmo valor, que não é maior, e nada muda. É a solução mais elegante para o problema dos consumidores idempotentes de "Worker Services e mensageria com RabbitMQ" que o curso encontrou, porque vem da própria semântica da operação.

A chave por semana ISO expira sozinha depois de três semanas. Se o Redis for perdido, o ranking é reconstruído a partir da consulta SQL acima, por um comando da ferramenta administrativa de "Performance e distribuição": livraria ranking reconstruir --semana 2026-W39. A mesma relação entre fonte da verdade e projeção descartável do ranking de mais vendidos em "Cache com Redis, HybridCache e output caching".

O aviso ao vivo só é enviado quando o top 10 muda de verdade, o que evita mensagens inúteis a cada partida medíocre. A mensagem leva os dez primeiros, e não "algo mudou". Aqui a escolha entre notificação fina e estado embutido, discutida em "Tempo real com SignalR", pende para o estado: a lista é pequena, pública, igual para todos, e mil visitantes relendo a API ao mesmo tempo seria um custo sem ganho.

O ranking no site

A página segue o padrão de "Razor Pages": HTML completo montado no servidor, indexável e funcional sem JavaScript, com uma camada de tempo real por cima.

@page "/ranking"
@model RankingModel
@{ ViewData["Title"] = "Ranking da semana"; }

<h1>Biblioteca em Apuros: ranking da semana</h1>

<ol id="ranking" class="ranking" aria-live="polite">
    <partial name="_LinhasDoRanking" model="Model.Top" />
</ol>

<p class="convite">Jogue e apareça aqui: <a href="/jogo">baixe a Biblioteca em Apuros</a>.</p>

@section Scripts {
    <script src="~/lib/signalr/signalr.min.js"></script>
    <script src="~/js/ranking.js" asp-append-version="true"></script>
}
// wwwroot/js/ranking.js
const lista = document.getElementById("ranking");

const conexao = new signalR.HubConnectionBuilder()
  .withUrl("/api/hubs/livraria")
  .withAutomaticReconnect()
  .build();

conexao.on("RankingAtualizado", async () => {
  const resposta = await fetch("/ranking?handler=Linhas");
  if (resposta.ok) lista.innerHTML = await resposta.text();
});

conexao.onreconnected(() => conexao.invoke("AcompanharRanking"));
conexao.start().then(() => conexao.invoke("AcompanharRanking")).catch(() => {});

A mensagem traz o top 10, mas a página ainda busca o fragmento pelo handler Linhas. Assim o HTML é gerado sempre pela mesma parcial no servidor, com a codificação do Razor protegendo os apelidos. Um apelido como <script> aparece como texto, e não executa, que é a lição de XSS de "Razor Pages" aplicada a dados que vêm de jogadores. A requisição passa pelo proxy YARP de "O site consumindo a API", pela mesma origem, e a resposta da API vem do output cache com tag ranking, invalidada pelo consumidor. Mil pessoas olhando o ranking quando um recorde cai fazem uma leitura ao Redis.

O jogo falando com a API

Login pelo código

Um jogo, principalmente num console, numa TV ou no Steam Deck, é o caso de uso original do Device Authorization Grant (RFC 8628), o mesmo fluxo da ferramenta administrativa no artigo anterior. No menu, "Entrar no ranking" mostra um endereço, um código e um QR code:

public async Task<bool> EntrarAsync(CancellationToken ct)
{
    var disco = await http.GetDiscoveryDocumentAsync(Configuracao.Autoridade, ct);
    var pedido = await http.RequestDeviceAuthorizationAsync(new DeviceAuthorizationRequest
    {
        Address = disco.DeviceAuthorizationEndpoint,
        ClientId = "biblioteca-em-apuros",
        Scope = "openid offline_access livraria-api"
    }, ct);

    telaDeLogin.Mostrar(pedido.VerificationUri!, pedido.UserCode!, pedido.VerificationUriComplete);

    var intervalo = TimeSpan.FromSeconds(pedido.Interval);
    while (!ct.IsCancellationRequested)
    {
        await Task.Delay(intervalo, ct);
        var token = await http.RequestDeviceTokenAsync(new DeviceTokenRequest
        {
            Address = disco.TokenEndpoint,
            ClientId = "biblioteca-em-apuros",
            DeviceCode = pedido.DeviceCode!
        }, ct);

        if (!token.IsError) { sessao.Guardar(token); return true; }
        if (token.Error == "slow_down") { intervalo += TimeSpan.FromSeconds(5); continue; }
        if (token.Error != "authorization_pending") return false;   // negado ou expirado
    }
    return false;
}

O QR code, gerado a partir do VerificationUriComplete, leva o jogador direto à tela de aprovação no celular, com o código já preenchido. O jogo nunca vê uma senha, e o Keycloak aplica as mesmas regras de segurança do site.

O await dentro de um nó do Godot volta à thread principal do motor, porque o Godot instala seu próprio contexto de sincronização para C#. É o mesmo mecanismo que devolve a execução à thread de interface no Avalonia e no MAUI, e é o que permite atualizar a tela de login depois do await sem nenhum cuidado extra. As chamadas HTTP nunca acontecem dentro do _PhysicsProcess: elas começam em resposta a eventos de interface e ao fim da partida, fora do loop de simulação, como "C# nos jogos" recomendou.

Fila local

Se a API estiver fora do ar quando a partida termina, o registro vai para uma fila em user://, com a gravação atômica de "Áudio, interface e save game", e é enviado quando a conexão voltar. É o outbox do caixa de "O caixa da Livraria em Avalonia", com o mesmo raciocínio: gravar primeiro de forma durável, enviar depois, e deixar o servidor garantir o uso único. A validade de 24 horas da partida limita quanto tempo um registro pode esperar.

O jogo também continua funcionando sem login. Os recordes locais são guardados como sempre, e o ranking público é um convite, e não uma exigência.

O teste de ponta a ponta

O teste que fecha o capstone usa a FabricaDaApi de "Configuração, logging e testes de integração" e um jogador sintético: um algoritmo que, a partir da semente, gera um registro de partida legítimo, movendo-se dentro da velocidade máxima até cada livro que consegue alcançar.

[Fact]
public async Task Partida_jogada_por_regras_validas_entra_no_ranking_com_a_pontuacao_recalculada()
{
    var jogador = Fabrica.ClienteComo("ana");

    var emitida = await (await jogador.PostAsync("/v1/partidas", null)).Content.ReadFromJsonAsync<PartidaEmitida>();
    Relogio.Avancar(TimeSpan.FromMinutes(4));

    var registro = JogadorSintetico.Jogar(emitida!.Semente, TabelaDeDificuldade.Atual, ParametrosFisicos.Atuais);
    var resposta = await jogador.PostAsJsonAsync($"/v1/partidas/{emitida.Id}/resultado", registro);

    Assert.Equal(HttpStatusCode.OK, resposta.StatusCode);
    var oficial = await resposta.Content.ReadFromJsonAsync<ResultadoOficial>();
    Assert.Equal(registro.PontosInformados, oficial!.Pontos);

    await Eventualmente(async () =>
    {
        var top = await Fabrica.CreateClient().GetFromJsonAsync<List<LinhaDoRanking>>("/v1/ranking");
        Assert.Contains(top!, l => l.Apelido == "ana" && l.Pontos == oficial.Pontos);
    });
}

[Theory]
[InlineData(nameof(Adulteracoes.LivroInexistente))]
[InlineData(nameof(Adulteracoes.Teletransporte))]
[InlineData(nameof(Adulteracoes.PontuacaoInflada))]
[InlineData(nameof(Adulteracoes.GolpeOmitido))]
public async Task Partidas_adulteradas_sao_rejeitadas(string adulteracao)
{
    // mesma emissão; o registro legítimo passa por uma adulteração antes do envio
    // Assert: 422, e o ranking não muda
}

O Relogio é um FakeTimeProvider registrado na fábrica, que permite simular os quatro minutos de partida sem esperar por eles. Eventualmente repete a verificação por alguns segundos, porque o ranking é atualizado de forma assíncrona, pelo retransmissor e pelo consumidor. Um teste que verificasse o ranking logo depois da resposta falharia de forma intermitente, e ensinaria a equipe a ignorar testes vermelhos.

A adulteração GolpeOmitido é a mais interessante: o jogador sintético passa por uma traça, e o registro é enviado normalmente, porque o jogo não envia golpes. O servidor deduz o golpe das amostras de posição, a vida perdida antecipa o fim da partida, e a duração informada deixa de conferir. O teste prova que omitir informação não ajuda o trapaceiro, porque o servidor não depende dela.

Os limites, com honestidade

O sistema acima não impede tudo, e é importante saber o que ele não impede:

  • Um robô que joga bem. Um programa que lê a tela e move a bibliotecária de forma perfeita, dentro da velocidade máxima, produz partidas válidas. A defesa aqui deixa de ser técnica e passa a ser estatística: pontuações muito acima da distribuição, tempos de reação sobre-humanos e padrões de movimento repetidos geram alertas para revisão humana, com a ferramenta administrativa listando partidas suspeitas.
  • Um jogo modificado que envia registros inventados mas coerentes. Com o validador no servidor, inventar um registro coerente é, na prática, escrever um jogador sintético, como o do teste. O custo da trapaça passou a ser o de escrever um robô que joga, e esse era o objetivo.
  • Divergências de ponto flutuante. As tolerâncias do detector de golpes podem, em casos raríssimos, rejeitar uma partida legítima. Toda rejeição é gravada com o motivo, e a taxa de rejeições é uma métrica no painel de "Produção", com alerta se subir. Uma mudança de versão do Godot que altere a física vai aparecer ali antes de aparecer nas reclamações.

Segurança, aqui como em todo o curso, não é uma propriedade que um sistema tem ou não tem. É uma relação entre o custo do ataque e o valor do que se protege. Para o ranking semanal de um jogo de livraria, essa relação ficou boa.

Retrospectiva: o caminho inteiro

O artigo zero apresentou o curso como um mapa de onze fases, com um projeto que cresceria de um programa de console até um sistema com banco, API, site, aplicativos e jogo. Com o capstone funcionando, o mapa pode ser percorrido de trás para frente, olhando o que cada fase deixou.

Fase 1, Fundamentos (01 a 08). A história da linguagem, de Anders Hejlsberg e da disputa com a Sun até o .NET aberto e multiplataforma. O caminho do código-fonte ao IL e ao JIT. Variáveis, tipos, decisões, laços, métodos e coleções. A Livraria nasceu como um programa de console que listava livros num arranjo. Tudo que veio depois foi construído sobre essa sintaxe.

Fase 2, Orientação a objetos (09 a 15). Classes e encapsulamento, herança e interfaces, class, struct e record, pattern matching, a organização em projetos. Livro, Autor e Pedido ganharam invariantes que nenhum código externo podia quebrar. A ideia de que um objeto protege suas regras voltou em cada fase, até os objetos de valor de "Arquitetura: Clean Architecture, DDD com moderação e os padrões que já usamos".

Fase 3, A linguagem em profundidade (16 a 23). Genéricos, delegates, lambdas e eventos, LINQ, tipos anuláveis, reflexão, atributos e source generators. Os eventos reapareceram como sinais do Godot e como EventCallback do Blazor. Os source generators tornaram possíveis o JSON sem reflexão, o logging de alto desempenho, a validação das Minimal APIs, o CommunityToolkit.Mvvm, os scripts C# do Godot e o Native AOT.

Fase 4, Runtime, assincronia e testes (24 a 31). O coletor de lixo e quanto ele custa, Span<T> e código sem alocação, arquivos e JSON, async/await, concorrência, P/Invoke e testes com xUnit. O que parecia teoria de runtime virou a diferença entre um jogo suave e um jogo que engasga, e o cancelamento cooperativo virou o CancellationToken que atravessa HTTP, gRPC e SQL.

Fase 5, Bancos de dados (32 a 42). SQL no SQLite, ADO.NET, Dapper e EF Core, e depois PostgreSQL, MySQL e MongoDB, cada um com suas particularidades. Transações e concorrência, e o update condicional que impede vender o último exemplar duas vezes, a regra mais cara do negócio, testada em todas as camadas até o fim. E a arquitetura que pôs as regras no centro e os bancos na borda, com um repositório e três implementações.

Fase 6, API e backend (43 a 52). ASP.NET Core do pipeline ao deploy. Contratos HTTP com DTOs, ETags e ProblemDetails; validação e OpenAPI; JWT e autorização por recurso; testes de integração com contêineres; cache em três níveis; tempo real; outbox e mensageria; gRPC; CORS, rate limiting, versões e OpenTelemetry. É onde a Livraria deixou de ser um programa e passou a ser um sistema.

Fase 7, Site web completo (53 a 59). Razor Pages para páginas públicas, formulários com validação e CSRF, Blazor com componentes e modos de renderização, o site consumindo a API por um BFF com YARP, login por OpenID Connect, a área administrativa, o upload seguro de capas, e o deploy com contêineres, nginx e Aspire.

Fase 8, Desktop e mobile (60 a 62). Vinte e cinco anos de interfaces no .NET, o MVVM, o caixa em Avalonia que vende sem internet, e o aplicativo em MAUI no bolso do cliente. A mesma API, os mesmos contratos, a mesma identidade, e interfaces que só diferem naquilo em que precisam diferir.

Fase 9, Programação de jogos (63 a 69). O game loop, o passo fixo e o acumulador, um protótipo em MonoGame, o Godot com nós, cenas e sinais, física e colisões justas, arquitetura sem alocação no loop, som, interface e save, e um jogo completo exportado para quatro plataformas.

Fase 10, C# profissional (70 a 72). Arquitetura com nomes: a regra de dependência, DDD com moderação, padrões e registros de decisão. Modelos de linguagem como infraestrutura, atrás de interfaces, com a autorização mantida no servidor. Medição rigorosa, Native AOT e ferramentas de linha de comando.

Fase 11, O encerramento (73). Este artigo, em que tudo isso vira um único fluxo.

O que atravessa as fases

Algumas ideias apareceram em quase todos os artigos, em formas diferentes. Elas são, talvez, o que vale levar além do C#:

  • Contratos nas fronteiras. DTOs na API, eventos na fila, .proto no gRPC, o registro da partida, o formato do save, o esquema do banco. Tudo o que atravessa uma fronteira é um contrato, e contratos evoluem com regras: acrescentar é seguro, remover e renomear quebra.
  • As regras longe dos detalhes. O domínio sem banco, as regras do jogo sem o motor, a IA atrás de uma interface. Separar o que o sistema faz de como ele faz foi o que permitiu trocar bancos, acrescentar interfaces e, no fim, reutilizar as regras do jogo no servidor.
  • O servidor verifica. Preço, dono do pedido, validade do ISBN, permissão do assistente de IA, pontuação da partida. O cliente pode ser qualquer coisa, e toda decisão que importa é tomada, de novo, do lado em que se tem controle.
  • Falhas são normais. Redes caem, processos morrem, mensagens chegam duas vezes, fornecedores ficam lentos. Idempotência, outbox, retentativas com critério, circuit breakers, filas locais e gravação atômica não são exageros: são o que faz um sistema continuar correto quando algo dá errado, e algo sempre dá.
  • Tempo e concorrência são difíceis. Duas vendas do último livro, duas renovações de token, duas abas, dois caixas offline, passos de simulação que precisam dar o mesmo resultado em máquinas diferentes. Quase todos os bugs sutis do curso vieram daqui, e quase todas as soluções foram tornar explícito o que antes era suposto.
  • Medir antes de acreditar. Alocações por quadro, latência p95, taxa de acerto do cache, rejeições de partidas, benchmarks com estatística. Intuição serve para formular hipóteses, e só medições decidem.
  • Proporção. Um script é um arquivo, as regras do jogo são uma classe, e o domínio de vendas tem objetos de valor, agregados e testes de arquitetura. A quantidade de estrutura acompanha o tamanho do problema e o tempo que o código vai viver.

A linguagem que mudou no caminho

O C# que o curso ensinou não é o C# 1.0 de 2002. É uma linguagem que ganhou genéricos, LINQ, async, tipos anuláveis por referência, records, pattern matching, Span, source generators, membros de extensão e muito mais, sempre mantendo o código antigo compilando. O .NET saiu do Windows, abriu o código, ganhou Native AOT e roda de servidores Linux a celulares e consoles. A lição prática é que o que se aprende hoje vai continuar mudando. As versões novas aparecem todo novembro, e acompanhar as notas de lançamento e os artigos anuais de desempenho da equipe do .NET faz parte do trabalho, tanto quanto conhecer o que já existe.

Para onde ir

Com o curso terminado, alguns caminhos naturais, conforme o interesse:

  • Sistemas distribuídos em profundidade: Designing Data-Intensive Applications, de Martin Kleppmann, explica os fundamentos de tudo o que a Fase 6 usou (replicação, consistência, filas, transações), independentemente de linguagem.
  • Contribuir com código aberto: o runtime, o ASP.NET Core, o EF Core, o Godot, o Avalonia e o MonoGame aceitam contribuições, e ler o código dessas bibliotecas é uma aula permanente.
  • Aprofundar uma área: jogos (com o Godot ou a Unity), aplicações móveis, dados e IA, ou infraestrutura com Aspire e Kubernetes. O que o curso deu é a base comum. A profundidade vem de escolher e insistir.
  • Construir algo seu: a Livraria foi o projeto do curso. O próximo projeto, com problemas que nenhum artigo antecipou, é onde o aprendizado vira habilidade.

Setenta e quatro artigos depois, a Livraria cabe numa frase: um jogo envia uma partida, uma API a valida com as mesmas regras do jogo, um banco a guarda, uma fila a espalha, e um site mostra, ao vivo, quem está no topo. Cada parte dessa frase foi construída, testada e explicada, e cada parte pode ser trocada sem que as outras percebam. O mapa do artigo zero prometia exatamente isso, e o círculo se fecha aqui. O que vem depois é com quem chegou até o fim: escolher o próximo problema e resolvê-lo com o que aprendeu.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um jogador descobre que, se não enviar o resultado de uma partida ruim, pode iniciar outra, e assim só envia as boas. Isso é trapaça? O sistema deveria impedir? Proponha como, se sim.

Ver resposta

✓ Resposta: Depende da regra do ranking, e ela deve ser decidida e escrita (um ADR, como no artigo de arquitetura). Se o ranking é "a melhor partida da semana", descartar partidas ruins é o comportamento normal de qualquer jogador que tenta de novo, e não há nada a impedir. As partidas não enviadas expiram em 24 horas e ficam como "abertas" sem efeito. Se o ranking fosse "média das partidas" ou houvesse um limite de tentativas por dia (um "desafio do dia" com uma única semente), aí sim seria preciso impedir: a API contaria as partidas emitidas, e não só as enviadas. Uma partida não enviada até a expiração contaria como zero, ou o número de partidas emitidas por dia teria um teto por jogador. O rate limiting "Partidas" já impede emissões em massa para "procurar" sementes fáceis, que seria a trapaça real nesse cenário.

Exercício 2

Por que o jogo usa um PCG32 próprio em vez de new Random(semente), se hoje os dois produzem sequências reproduzíveis? Descreva o incidente que a escolha evita.

Ver resposta

✓ Resposta: A documentação do .NET não garante que o algoritmo de Random com semente continue o mesmo entre versões do runtime. Cenário do incidente: o servidor é atualizado para uma versão nova do .NET, com uma implementação diferente de Random, enquanto os jogadores ainda usam o jogo compilado com a versão antiga (ou o contrário, o jogo é atualizado antes do servidor). A mesma semente passa a gerar chuvas de livros diferentes nos dois lados. O validador regenera livros que não existiram na tela do jogador, e todas as partidas passam a ser rejeitadas por "livro inexistente" ou "fora do alcance". O ranking para, os jogadores reclamam, e o motivo não aparece em nenhum teste que rode jogo e servidor na mesma versão. Com o PCG32 no projeto Regras, a sequência depende só de aritmética de inteiros definida no nosso código, e um teste com valores fixos garante que ela nunca muda sem que alguém perceba.

Exercício 3

A equipe quer mudar a pontuação do livro raro de 50 para 60 pontos. Liste tudo o que precisa acontecer para que partidas em andamento, partidas antigas e o ranking não sejam afetados de forma injusta.

Ver resposta

✓ Resposta: (1) Criar uma nova versão de regras (2026.10-1) com o valor novo, mantendo a versão anterior disponível no projeto Regras: RegrasDaPartida passa a receber os parâmetros da versão, em vez de constantes fixas. (2) O validador escolhe as regras pela VersaoDasRegras gravada na partida no momento da emissão, e não pela versão atual. Partidas emitidas antes da mudança são validadas com 50 pontos, mesmo que enviadas depois. (3) Publicar a versão nova do jogo. A API passa a emitir partidas com a versão nova só para clientes que a suportam (o jogo informa sua versão de regras ao pedir a partida), e jogos antigos continuam recebendo a versão antiga até uma data anunciada. (4) Decidir o ranking: misturar partidas com pontuações diferentes na mesma semana é injusto. A opção mais simples é a mudança entrar em vigor no início de uma semana, com as chaves semanais do Redis separando as épocas naturalmente. (5) Registrar a decisão num ADR, e ajustar os testes de ponta a ponta para cobrir as duas versões.

Exercício 4

O consumidor de partida.validada usa SortedSetWhen.GreaterThan e é idempotente "de graça". Descreva um cenário em que a projeção do Redis diverge do banco mesmo assim, e como detectá-lo e corrigi-lo.

Ver resposta

✓ Resposta: Cenários: (a) um administrador anula uma partida já validada, depois de descobrir uma trapaça. O banco muda o status para "Anulada", mas o Redis continua com a pontuação, porque GT nunca diminui um valor. (b) O Redis foi reiniciado sem persistência, e a chave da semana sumiu: novas partidas recriam a chave, mas os recordes anteriores da semana só voltam se o jogador jogar melhor. (c) Um evento foi perdido na fila, por uma mensagem que foi para a dead-letter e nunca foi reprocessada. Detecção: um job periódico (ou o comando livraria ranking verificar) compara o top 100 do Redis com a consulta SQL de referência e publica uma métrica de divergência, com alerta. Correção: a anulação publica um evento próprio, partida.anulada, cujo consumidor recalcula a entrada daquele jogador a partir do banco (a melhor partida válida restante), com SortedSetAddAsync sem a condição GT. Para os outros casos, o comando de reconstrução refaz a chave inteira a partir do banco. A projeção é descartável por projeto, e reconstruí-la é uma operação normal, não uma emergência.

Exercício 5

Olhando para o curso inteiro, escolha a decisão de arquitetura que mais facilitou o capstone e a que mais o dificultou, e justifique.

Ver resposta

✓ Resposta: Resposta pessoal, mas uma boa candidata para mais facilitou é a separação de RegrasDaPartida num projeto sem dependência do Godot, em "Arquitetura de jogo e por que não alocar dentro do loop". Na época, a justificativa era testar as regras sem o motor. No capstone, ela permitiu que o servidor validasse partidas com o mesmo código que o jogo usa, sem reimplementar nada e sem risco de divergência entre as duas versões das regras. Uma candidata para mais dificultou é a geração aleatória da chuva de livros com RandomNumberGenerator do Godot e intervalos em float diretamente no nó Principal, em "Godot com C#". Ela funcionava bem para o jogo, mas não era reproduzível fora do motor, e o capstone precisou mover a geração para o projeto Regras, trocar o gerador por um PCG próprio, converter posições e velocidades para inteiros e congelar as curvas de dificuldade numa tabela versionada. A lição comum às duas: a decisão que facilita o futuro não é a que prevê tudo, e sim a que mantém as regras separadas dos detalhes. Quando um uso novo aparece, fica claro o que precisa mudar de lugar.

Comentários

Mais em Linguagem C#

Memória no .NET: Pilha, Heap, Coletor de Lixo e IDisposable
Memória no .NET: Pilha, Heap, Coletor de Lixo e IDisposable

Memória no .NET: o que fica na pilha e no heap, por que alocar é barato e…

Arquitetura: Clean Architecture, DDD com moderação e os padrões que já usamos
Arquitetura: Clean Architecture, DDD com moderação e os padrões que já usamos

A Livraria olhada como um todo: a regra de dependência de Clean Architecture e…

LINQ, Parte II: Agrupar, Cruzar e Entender Quando a Consulta Executa
LINQ, Parte II: Agrupar, Cruzar e Entender Quando a Consulta Executa

LINQ avançado em C#: GroupBy e tipos anônimos, CountBy e ToLookup, Join e a…