import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; const RAIZ = process.cwd(); const PASTA = join(RAIZ, "public/jogo"); const BLOCO = join(RAIZ, "public/3d/bloco.glb"); const MB = 1024 * 1024; function arquivos(pasta: string): string[] { if (!existsSync(pasta)) return []; return readdirSync(pasta, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? arquivos(join(pasta, e.name)) : [join(pasta, e.name)], ); } const nome = (caminho: string) => relative(RAIZ, caminho); const doJogo = arquivos(PASTA).filter((a) => !a.endsWith("CREDITOS.json")); const glbs = [...doJogo, BLOCO].filter((a) => a.endsWith(".glb")); /** Maior lado de um JPEG, PNG ou WebP, lido do cabeçalho; `null` se não for nenhum deles. */ function lado(b: Buffer): number | null { if ( b.length > 30 && b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 15) === "WEBPVP8" ) { const tipo = b.toString("latin1", 15, 16); if (tipo === "X") return Math.max(b.readUIntLE(24, 3), b.readUIntLE(27, 3)) + 1; if (tipo === " ") return Math.max(b.readUInt16LE(26) & 0x3fff, b.readUInt16LE(28) & 0x3fff); if (tipo === "L") { const v = b.readUInt32LE(21); return Math.max((v & 0x3fff) + 1, ((v >> 14) & 0x3fff) + 1); } } if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return Math.max(b.readUInt32BE(16), b.readUInt32BE(20)); if (b[0] === 0xff && b[1] === 0xd8) { let i = 2; while (i + 9 < b.length) { if (b[i] !== 0xff) { i += 1; continue; } const marcador = b[i + 1]; if (marcador === 0xff) { i += 1; continue; } if (marcador === 0xd8 || (marcador >= 0xd0 && marcador <= 0xd7)) { i += 2; continue; } const sof = marcador >= 0xc0 && marcador <= 0xcf && marcador !== 0xc4 && marcador !== 0xc8 && marcador !== 0xcc; if (sof) return Math.max(b.readUInt16BE(i + 5), b.readUInt16BE(i + 7)); i += 2 + b.readUInt16BE(i + 2); } } return null; } type Gltf = { accessors?: Array<{ count: number }>; meshes?: Array<{ primitives: Array<{ indices?: number }> }>; images?: Array<{ bufferView?: number; uri?: string }>; bufferViews?: Array<{ byteOffset?: number; byteLength: number }>; }; function lerGlb(caminho: string) { const b = readFileSync(caminho); const tamanhoJson = b.readUInt32LE(12); const json = JSON.parse( b.subarray(20, 20 + tamanhoJson).toString("utf8"), ) as Gltf; const fimJson = 20 + tamanhoJson; const bin = fimJson + 8 <= b.length ? b.subarray(fimJson + 8, fimJson + 8 + b.readUInt32LE(fimJson)) : Buffer.alloc(0); return { json, bin }; } describe("orçamento de peso do jogo", () => { it("public/jogo + bloco.glb somam até 6 MB", () => { const total = [...doJogo, BLOCO].reduce((s, a) => s + statSync(a).size, 0); expect({ total: `${(total / MB).toFixed(2)} MB`, ok: total <= 6 * MB, }).toEqual({ total: `${(total / MB).toFixed(2)} MB`, ok: true }); }); it.each(glbs.map((g) => [nome(g), g]))("%s tem até 1,5 MB", (n, g) => { const tamanho = statSync(g).size; expect({ arquivo: n, MB: +(tamanho / MB).toFixed(2), ok: tamanho <= 1.5 * MB, }).toEqual({ arquivo: n, MB: +(tamanho / MB).toFixed(2), ok: true }); }); it("zumbi.glb tem até 12.000 triângulos", () => { const { json } = lerGlb(join(PASTA, "zumbi.glb")); let triangulos = 0; for (const malha of json.meshes ?? []) for (const p of malha.primitives) if (p.indices !== undefined) triangulos += (json.accessors?.[p.indices]?.count ?? 0) / 3; expect({ arquivo: "public/jogo/zumbi.glb", triangulos }).toEqual({ arquivo: "public/jogo/zumbi.glb", triangulos: Math.min(triangulos, 12000), }); }); it("imagens de public/jogo têm lado até 1024 px", () => { for (const a of doJogo.filter( (f) => /\.(jpe?g|png|webp)$/i.test(f) && !f.endsWith("capa.jpg"), )) { const l = lado(readFileSync(a)); expect({ arquivo: nome(a), lado: l }).toEqual({ arquivo: nome(a), lado: Math.min(l ?? 0, 1024), }); } }); it("itens.webp tem 512 px de lado e até 120 KB", () => { const a = join(PASTA, "itens.webp"); const kb = statSync(a).size / 1024; expect({ arquivo: "public/jogo/itens.webp", lado: lado(readFileSync(a)), ok: kb <= 120, }).toEqual({ arquivo: "public/jogo/itens.webp", lado: 512, ok: true }); }); it("capa.jpg tem lado até 1600 px e até 220 KB", () => { const a = join(PASTA, "capa.jpg"); const l = lado(readFileSync(a)); const kb = statSync(a).size / 1024; expect({ arquivo: "public/jogo/capa.jpg", lado: l, KB: Math.round(kb), }).toEqual({ arquivo: "public/jogo/capa.jpg", lado: Math.min(l ?? 0, 1600), KB: Math.min(Math.round(kb), 220), }); }); it("imagens embutidas nos GLB têm lado até 1024 px", () => { for (const g of glbs) { const { json, bin } = lerGlb(g); (json.images ?? []).forEach((img, i) => { let dados: Buffer | null = null; if (img.bufferView !== undefined) { const v = json.bufferViews?.[img.bufferView]; if (v) dados = bin.subarray( v.byteOffset ?? 0, (v.byteOffset ?? 0) + v.byteLength, ); } else if (img.uri?.startsWith("data:")) dados = Buffer.from(img.uri.split(",")[1] ?? "", "base64"); const l = dados ? lado(dados) : null; const rotulo = `${nome(g)}#imagem${i}`; expect({ arquivo: rotulo, lado: l }).toEqual({ arquivo: rotulo, lado: Math.min(l ?? 0, 1024), }); }); } }); it("cada .mp3 de public/jogo/sons tem até 60 KB", () => { for (const a of arquivos(join(PASTA, "sons")).filter((f) => f.endsWith(".mp3"), )) { const kb = statSync(a).size / 1024; expect({ arquivo: nome(a), KB: Math.round(kb) }).toEqual({ arquivo: nome(a), KB: Math.min(Math.round(kb), 60), }); } }); });