import { criarDrone } from "./drones"; import { montarFila } from "./hordas"; import * as mapaModulo from "./mapa"; import { avancar, criarPartida } from "./partida"; import { pilotar } from "./piloto"; import { semear } from "./rng"; import type { Dificuldade, Entrada, Fase, Partida } from "./tipos"; import { ESCRITORIO, HORDAS, JOGADOR, MUNICAO, PASSO } from "./valores"; const MAPA = ["#########", "#N......#", "#.......#", "#......J#", "#########"]; const PARADO: Entrada = { mover: [0, 0], olhar: [0, 0], atirar: false, arma: 0, }; function nova(extra: Partial[0]> = {}) { return criarPartida({ semente: 1, dificuldade: "facil", problemaId: "site-lento", tetoAVista: 2, mapa: MAPA, ...extra, }); } function passo(p: Partida, parcial: Partial = {}) { avancar(p, { ...PARADO, ...parcial }); } function em(p: Partida, fase: Fase, extra: Partial = {}) { p.fase = fase; p.tempoFase = 0; p.jogador.x = 4.5; p.jogador.z = 2.5; Object.assign(p, extra); } const FILA_UM = [{ tipo: "comum" as const, problemaId: "site-lento" }]; describe("criarPartida", () => { it("monta o estado inicial no mapa do escritório por padrão", () => { const p = criarPartida({ semente: 7, dificuldade: "normal", problemaId: "site-lento", }); expect(p.fase).toBe("inicio"); expect(p.tetoAVista).toBe(16); expect(p.mapa.largura).toBe(ESCRITORIO[0].length); expect(p.jogador).toEqual({ x: p.mapa.inicio[0], z: p.mapa.inicio[1], rumo: 0, inclinacao: 0, vida: JOGADOR.vida, arma: 0, municao: MUNICAO.inicial, recarga: 0, }); expect(p.hordas).toHaveLength(4); expect(p.hordas[3]).toBe("site-lento"); expect(p).toMatchObject({ passo: 0, horda: 0, tempoFase: 0, zumbis: [], fila: [], drones: [], caixas: [], feed: [], resolvidos: [], placar: { jogador: 0, drones: 0 }, eventos: [], proximoId: 1, problemaId: "site-lento", dificuldade: "normal", }); }); it("usa o mapa e o teto informados e consome o sorteio da própria partida", () => { const p = nova({ tetoAVista: 3 }); expect(p.tetoAVista).toBe(3); expect(p.mapa.largura).toBe(9); expect(p.jogador.x).toBe(7.5); expect(p.rng).not.toBe(semear(1)); expect(nova().hordas).toEqual(p.hordas); }); }); describe("avancar: jogador", () => { it("anda para a frente em +z com rumo 0", () => { const p = nova(); em(p, "pausa"); passo(p, { mover: [0, 1] }); expect(p.jogador.x).toBeCloseTo(4.5); expect(p.jogador.z).toBeCloseTo(2.5 + JOGADOR.velocidade * PASSO); }); it("anda para a direita em −x com rumo 0", () => { const p = nova(); em(p, "pausa"); passo(p, { mover: [1, 0] }); expect(p.jogador.x).toBeCloseTo(4.5 - JOGADOR.velocidade * PASSO); expect(p.jogador.z).toBeCloseTo(2.5); }); it("gira o rumo e anda para a frente na direção nova", () => { const p = nova(); em(p, "pausa"); passo(p, { olhar: [Math.PI / 2, 0], mover: [0, 1] }); expect(p.jogador.rumo).toBeCloseTo(Math.PI / 2); expect(p.jogador.x).toBeCloseTo(4.5 + JOGADOR.velocidade * PASSO); expect(p.jogador.z).toBeCloseTo(2.5); }); it("limita o movimento diagonal a comprimento 1", () => { const p = nova(); em(p, "pausa"); passo(p, { mover: [1, 1] }); const andou = Math.hypot(p.jogador.x - 4.5, p.jogador.z - 2.5); expect(andou).toBeCloseTo(JOGADOR.velocidade * PASSO); }); it("não aumenta um movimento menor que 1", () => { const p = nova(); em(p, "pausa"); passo(p, { mover: [0, 0.5] }); expect(p.jogador.z).toBeCloseTo(2.5 + 0.5 * JOGADOR.velocidade * PASSO); }); it("soma a inclinação e a limita para cima e para baixo", () => { const p = nova(); em(p, "pausa"); passo(p, { olhar: [0, 0.1] }); expect(p.jogador.inclinacao).toBeCloseTo(0.1); passo(p, { olhar: [0, 5] }); expect(p.jogador.inclinacao).toBe(JOGADOR.inclinacaoMax); passo(p, { olhar: [0, -9] }); expect(p.jogador.inclinacao).toBe(-JOGADOR.inclinacaoMax); }); it("troca de arma, mas só atira se pedido", () => { const p = nova(); em(p, "pausa"); passo(p, { arma: 1 }); expect(p.jogador.arma).toBe(1); expect(p.eventos).toEqual([]); p.jogador.recarga = 0; passo(p, { arma: 1, atirar: true }); expect(p.eventos[0]).toMatchObject({ tipo: "tiro", autor: "jogador" }); }); it("recolhe as caixas ao alcance", () => { const p = nova(); em(p, "pausa", { caixas: [{ id: 99, x: 4.5, z: 2.5 }] }); passo(p); expect(p.caixas).toEqual([]); expect(p.jogador.municao).toBe(MUNICAO.inicial + MUNICAO.porCaixa); expect(p.eventos).toEqual([{ tipo: "caixa", balas: MUNICAO.porCaixa }]); }); }); describe("avancar: fases", () => { it("esvazia os eventos e conta passos", () => { const p = nova(); p.eventos.push({ tipo: "golpe", dano: 1, zumbiId: 0 }); passo(p); expect(p.eventos).toEqual([]); expect(p.passo).toBe(1); expect(p.tempoFase).toBe(1); }); it("fica em inicio até o preparo acabar", () => { const p = nova(); p.tempoFase = HORDAS.preparo - 2; passo(p); expect(p.fase).toBe("inicio"); expect(p.eventos).toEqual([]); }); it("começa a horda 0 ao fim do preparo", () => { const p = nova(); p.tempoFase = HORDAS.preparo - 1; passo(p); expect(p.fase).toBe("horda"); expect(p.tempoFase).toBe(0); expect(p.horda).toBe(0); expect(p.fila).toEqual(montarFila(0, p.hordas[0], "facil")); expect(p.eventos).toEqual([{ tipo: "fase", fase: "horda" }]); }); it("não termina a horda com zumbis na fila", () => { const p = nova(); em(p, "horda", { fila: [...FILA_UM], tetoAVista: 0 }); passo(p); expect(p.fase).toBe("horda"); expect(p.resolvidos).toEqual([]); }); it("não termina a horda com zumbis no mapa", () => { const p = nova(); em(p, "horda", { zumbis: [ { id: 50, tipo: "comum", problemaId: "site-lento", x: 1.5, z: 1.5, rumo: 0, vida: 100, estado: "andando", tempo: 0, }, ], }); passo(p); expect(p.fase).toBe("horda"); expect(p.resolvidos).toEqual([]); }); it("termina a horda: resolve, cria o drone, cura e vai para a pausa", () => { const p = nova(); em(p, "horda", { horda: 1 }); p.jogador.vida = 50; passo(p); const resolvido = p.hordas[1]; expect(p.resolvidos).toEqual([resolvido]); expect(p.drones).toHaveLength(1); expect(p.drones[0].problemaId).toBe(resolvido); expect(p.jogador.vida).toBe(50 + JOGADOR.curaPorHorda); expect(p.fase).toBe("pausa"); expect(p.tempoFase).toBe(0); expect(p.feed[0].texto).toMatch(/^Resolvido: /); expect(p.eventos).toEqual([ { tipo: "resolvido", problemaId: resolvido, droneId: p.drones[0].id }, { tipo: "fase", fase: "pausa" }, ]); }); it("a cura não passa da vida máxima", () => { const p = nova(); em(p, "horda"); p.jogador.vida = 90; passo(p); expect(p.jogador.vida).toBe(JOGADOR.vida); }); it("vai para a derrota com a vida zerada, antes de resolver a horda", () => { const p = nova(); em(p, "horda"); p.jogador.vida = 0; passo(p); expect(p.fase).toBe("derrota"); expect(p.resolvidos).toEqual([]); expect(p.drones).toEqual([]); expect(p.eventos).toEqual([{ tipo: "fase", fase: "derrota" }]); }); it("fica na pausa até o tempo acabar, e os drones seguem o jogador", () => { const p = nova(); em(p, "pausa", { horda: 0 }); const drone = criarDrone(p, "site-lento"); drone.x = 1.5; drone.z = 1.5; passo(p); expect(p.fase).toBe("pausa"); expect(p.horda).toBe(0); expect(drone.x).toBeGreaterThan(1.5); expect(p.eventos).toEqual([]); }); it("sai da pausa para a horda seguinte", () => { const p = nova(); em(p, "pausa", { horda: 0 }); p.tempoFase = HORDAS.pausa - 1; passo(p); expect(p.horda).toBe(1); expect(p.fase).toBe("horda"); expect(p.tempoFase).toBe(0); expect(p.fila).toEqual(montarFila(1, p.hordas[1], "facil")); expect(p.eventos).toEqual([{ tipo: "fase", fase: "horda" }]); }); it("sai da pausa da horda 2 para o chefão", () => { const p = nova(); em(p, "pausa", { horda: 2 }); p.tempoFase = HORDAS.pausa - 1; passo(p); expect(p.horda).toBe(3); expect(p.fase).toBe("chefao"); expect(p.fila).toEqual(montarFila(3, p.hordas[3], "facil")); expect(p.eventos).toEqual([{ tipo: "fase", fase: "chefao" }]); }); it("vence ao derrotar o chefão, sem criar drone", () => { const p = nova(); em(p, "chefao", { horda: 3 }); passo(p); expect(p.fase).toBe("vitoria"); expect(p.resolvidos).toEqual(["site-lento"]); expect(p.drones).toEqual([]); expect(p.eventos).toEqual([ { tipo: "resolvido", problemaId: "site-lento", droneId: -1 }, { tipo: "fase", fase: "vitoria" }, ]); }); it("perde no chefão com a vida zerada", () => { const p = nova(); em(p, "chefao", { horda: 3 }); p.jogador.vida = -5; passo(p); expect(p.fase).toBe("derrota"); }); it.each(["vitoria", "derrota"] as const)( "depois da %s nada mais muda, só os eventos esvaziam", (fase) => { const p = nova(); em(p, fase); p.eventos.push({ tipo: "golpe", dano: 1, zumbiId: 0 }); const antes = JSON.stringify({ ...p, eventos: [] }); passo(p, { mover: [1, 1], olhar: [1, 1], atirar: true, arma: 1 }); expect(p.eventos).toEqual([]); expect(JSON.stringify(p)).toBe(antes); }, ); }); describe("avancar: campo de fluxo", () => { it("só recalcula quando o jogador muda de célula", () => { const espiao = jest.spyOn(mapaModulo, "campoDeFluxo"); const p = nova(); em(p, "horda", { fila: [...FILA_UM], tetoAVista: 0 }); passo(p); passo(p); expect(espiao).toHaveBeenCalledTimes(1); p.jogador.x -= 1; passo(p); expect(espiao).toHaveBeenCalledTimes(2); passo(p); expect(espiao).toHaveBeenCalledTimes(2); espiao.mockRestore(); }); }); describe("partida inteira com o piloto", () => { const TETO_DE_PASSOS = 60_000; // regressão: na fácil, as sementes 4, 6, 13, 20, 23, 24 e 38 empacavam na horda 3 com "faltam 2" it.each(["facil", "normal", "dificil"])( "40 sementes na %s terminam em vitória ou derrota", (dificuldade) => { const fins: [number, Fase][] = []; for (let semente = 1; semente <= 40; semente++) { const p = criarPartida({ semente, dificuldade, problemaId: "site-lento", }); for ( let i = 0; i < TETO_DE_PASSOS && p.fase !== "vitoria" && p.fase !== "derrota"; i++ ) { avancar(p, pilotar(p)); } fins.push([semente, p.fase]); } const presas = fins.filter(([, f]) => f !== "vitoria" && f !== "derrota"); expect(presas).toEqual([]); }, ); });