diff --git a/ARCH.generated.md b/ARCH.generated.md index 725b84f0c..280f55cab 100644 --- a/ARCH.generated.md +++ b/ARCH.generated.md @@ -8,9 +8,9 @@ Números atuais das zonas, do quality gate e do `package.json`: | Zona | O que é | Tamanho medido | Regra | |---|---|---|---| -| `public/` | o **jogo** | 44 arquivos `.js`, 32.001 linhas · Three.js `r160` vendorizado | ES modules servidos crus, **zero build**, sem dependência de runtime | -| `src/` | o **site** | 18 páginas `.astro`, 21 rotas `/api` · Astro `^7.1.1` | framework é bem-vindo; `service_role` só no servidor | -| `tools/` | o **arnês** | 201 scripts em `tools/eval/`, 55 em `tools/` | node puro: sobe o jogo real sem browser | +| `public/` | o **jogo** | 46 arquivos `.js`, 32.430 linhas · Three.js `r160` vendorizado | ES modules servidos crus, **zero build**, sem dependência de runtime | +| `src/` | o **site** | 19 páginas `.astro`, 21 rotas `/api` · Astro `^7.1.1` | framework é bem-vindo; `service_role` só no servidor | +| `tools/` | o **arnês** | 204 scripts em `tools/eval/`, 55 em `tools/` | node puro: sobe o jogo real sem browser | **Não existe `public/index.html`.** O HTML do jogo é `src/pages/index.astro`, servido na rota `/`. Servir `public/` estaticamente entrega os arnêses visuais, **não o jogo** — é a pegadinha que custa a primeira hora de todo mundo. diff --git a/README.md b/README.md index 9d6f334c0..a4c0c089c 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ contra bots, direto na aba. Sem download, sem instalação, sem cadastro. | O que | Quanto | Onde confere | |---|---:|---| -| Código do jogo | 32.001 linhas em 44 arquivos | `git ls-files public/js/*.js \| xargs wc -l` | +| Código do jogo | 32.430 linhas em 46 arquivos | `git ls-files public/js/*.js \| xargs wc -l` | | `game.js` | **6.910** linhas | `wc -l public/js/game.js` | | `main.js` | 2.698 linhas | `wc -l public/js/main.js` | | Armas com GLB | 26 | `git ls-files 'public/models/weapons/*.glb' \| wc -l` | @@ -44,7 +44,7 @@ contra bots, direto na aba. Sem download, sem instalação, sem cadastro. | Personagens jogáveis | 44, em 5 facções | array `CHARACTERS` de `characters.js` | | Mapas no registro | 12 | objeto `MAPS` de `maps.js` | | Arnêses visuais em HTML | 15 | `git ls-files 'public/*.html' \| wc -l` | -| Scripts do arnês | 201 | `git ls-files 'tools/eval/*.mjs' 'tools/eval/*.py' \| wc -l` | +| Scripts do arnês | 204 | `git ls-files 'tools/eval/*.mjs' 'tools/eval/*.py' \| wc -l` | | Scripts de pipeline | 55 | `git ls-files 'tools/*.mjs' \| wc -l` | | Tarefas de entrada escritas | 26 | `git ls-files 'docs/issues/[0-9]*.md' \| wc -l` | | Versão | `2.0.0-alpha.184` | `public/js/version.js` e `package.json` (batem) | @@ -89,7 +89,7 @@ arquitetura): `cd docs && npm install && npm start` → Bloco gerado por `node tools/gen-docs.mjs`. Fonte: `dependencies/devDependencies do package.json · REVISION de public/vendor/three.module.js` diff --git a/SCRIPTS.md b/SCRIPTS.md index 74b2ebad2..6123025d3 100644 --- a/SCRIPTS.md +++ b/SCRIPTS.md @@ -370,6 +370,14 @@ Contrato de produção do BotBrain: coleta opt-in autenticada por UID, limites c npm run eval:botbrain ``` +## `eval:i18ntwins` + +Pares PT↔EN numa tabela só (`src/lib/i18n-pairs.ts`): hreflang, `html lang`, og:locale, sitemap e o parser único do changelog. A gêmea do `/changelog` é `/whats-new`; o cromo é EN e o corpo continua o `CHANGELOG.md`. Mutantes: sem-par|sem-redirect|chrome-pt|lang-pt|sem-sitemap|parser-dup. + +```bash +npm run eval:i18ntwins +``` + ## `eval:posters` O aspecto declarado de cada cartaz em POSTER_FILES (textures.js) bate com o pixel do arquivo em public/posters/? Erro >6% = arte esticada na parede (issue #79). `--mutate` estraga um número e prova que a régua morde. diff --git a/SESSAO-2026-08-20-issue-382-nick-cheat.md b/SESSAO-2026-08-20-issue-382-nick-cheat.md new file mode 100644 index 000000000..9f1018ea2 --- /dev/null +++ b/SESSAO-2026-08-20-issue-382-nick-cheat.md @@ -0,0 +1,54 @@ +# Sessão 2026-08-20 — issue #382: nick de quem tenta cheat no crash report + +## Estado ao desconectar + +- Branch: `main`, atualizada com `origin/main` (ff de 181 commits, até `ed59dfc`). +- Trabalho da branch `fix/perf-fps-medida-em-partida` está **guardado no `stash@{0}`** + (mensagem: "wip fix/perf-fps-medida-em-partida (stash p/ issue 382)"). + Para recuperar: `git checkout fix/perf-fps-medida-em-partida && git stash pop`. +- Correção da #382 **aplicada, verificada (54/54 no check:fast), mas NÃO commitada**. + Falta decidir: commit direto na `main` ou branch/PR. + +## O que a issue era + +Alguém colou no console o script de cheat `cheat-demo`, que faz +`console.error("ainda sem window.__game…")`. O hook de console do `index.astro` +reportou pro `/api/jserror` e o `crash-fix.yml` abriu a issue automática — mas +sem nick, sem saber QUEM. + +## Causa raiz + +`src/pages/index.astro` (`contexto()`) lia `g.nickname` — campo que o `game.js` +**nunca grava** na instância (o construtor só usa o parâmetro `nickname` inline +em `player.name`, game.js:629). `undefined` calado: todo erro em partida chegava +sem dono. + +## O que foi mudado (4 arquivos, +61/−4) + +1. `src/pages/index.astro` — `contexto()` lê o nick de `localStorage['awpbr_nick']` + (mesma chave `NICK_KEY` de `main.js:30`); fallback do `#nick-input` no menu + continua. Não mexeu em `game.js` (zona vermelha). +2. `src/pages/api/jserror.ts` — `nick` virou const; `client_payload` do dispatch + agora leva `nick` + `anonId` (anonId validado por UUID_RE). +3. `.github/workflows/crash-fix.yml` — corpo da issue ganha `**Nick:**` e `**Anon:**`. +4. `tools/eval/error-provenance-check.mjs` — invariante nova **EP14**: executa o + `contexto()` real com `__game` sem o campo e prova nick→payload→issue. + Mutantes novos (ambos vermelhos, como manda a lei 3): `nick-do-game` e + `nick-sem-issue`. + +## Verificação já feita + +- `node tools/eval/error-provenance-check.mjs` → verde (EP1–EP14). +- Mutantes `nick-do-game` e `nick-sem-issue` → vermelhos na EP14 (a régua morde). +- `syntax`, `error-console`, `telemetry` → verdes. +- `npm run check:fast` → **54/54** (50.6s). +- Sem migration: a tabela `js_error` já tinha a coluna `nick`. + +## Ao voltar (pendências) + +1. Decidir destino do diff: commit na `main` ou branch/PR (hook exige trailer + `Agent:` — automático; diff está dentro do limite de 15 arq/800 linhas). +2. Recuperar o stash da branch de perf se for continuar nela depois. +3. Opcional: comentar na issue #382 que a correção subiu (ela só passa a + mostrar nick nas **próximas** ocorrências; as linhas antigas da tabela + `js_error` seguem sem nick). diff --git a/STATUS.md b/STATUS.md index cdef2610e..f52dd7aa4 100644 --- a/STATUS.md +++ b/STATUS.md @@ -4,8 +4,8 @@ - **Versão:** `2.0.0-alpha.184` - **Conteúdo jogável:** 5 facções, 44 personagens, 12 mapas e 26 armas com GLB -- **Código do jogo:** 32.001 linhas em 44 módulos JavaScript -- **Automação:** 124 comandos npm, 201 scripts de avaliação e 55 scripts de pipeline +- **Código do jogo:** 32.430 linhas em 46 módulos JavaScript +- **Automação:** 124 comandos npm, 204 scripts de avaliação e 55 scripts de pipeline > Bloco gerado por `node tools/gen-docs.mjs`. Fonte: `package.json · CHARACTERS · MAPS · public/models/weapons · public/js · tools/` diff --git a/docs/docs/arquitetura.md b/docs/docs/arquitetura.md index be5416328..6c29aeb5a 100644 --- a/docs/docs/arquitetura.md +++ b/docs/docs/arquitetura.md @@ -70,7 +70,7 @@ Tamanho dos arquivos que o `gen-arch.mjs` indexa — bloco gerado, regenerado po | `public/js/weapons.js` | 346 | | `public/js/springs.js` | 260 | -Total de `public/js/`: **32.001 linhas em 44 arquivos**. O índice símbolo→linha, com a tabela de conflito, é outro bloco gerado: `tools/eval/ARCH.md` (`npm run arch`). +Total de `public/js/`: **32.430 linhas em 46 arquivos**. O índice símbolo→linha, com a tabela de conflito, é outro bloco gerado: `tools/eval/ARCH.md` (`npm run arch`). > Bloco gerado por `node tools/gen-docs.mjs`. Fonte: ``git ls-files public/js/*.js | xargs wc -l`` diff --git a/docs/docs/colaborar.md b/docs/docs/colaborar.md index 2d5b3e074..960b6cb7e 100644 --- a/docs/docs/colaborar.md +++ b/docs/docs/colaborar.md @@ -14,7 +14,7 @@ O número abaixo não é retórica, e não é escrito à mão: sai de `git short {/* BEGIN:GERADO:pessoas — não edite à mão, rode `npm run docs` */} -**11 identidades de autoria humana** assinam commit no histórico **desta branch**: `ruben-cytonic`, `Ruben`, `Emerson Garrido`, `rubenmarcus`, `Ruben Marcus`, `William Oliveira`, `Juan Versolato Lopes`, `daeeseD`, `matheusgb`, `Maná Soares`, `daltonfontes`. O resto dos commits é assinado por agentes de IA. Branch não é repositório: quem contribuiu num ramo que esta branch não contém **não aparece aqui**. +**11 identidades de autoria humana** assinam commit no histórico **desta branch**: `ruben-cytonic`, `Ruben`, `Emerson Garrido`, `rubenmarcus`, `Ruben Marcus`, `William Oliveira`, `Juan Versolato Lopes`, `Maná Soares`, `daeeseD`, `matheusgb`, `daltonfontes`. O resto dos commits é assinado por agentes de IA. Branch não é repositório: quem contribuiu num ramo que esta branch não contém **não aparece aqui**. > Bloco gerado por `node tools/gen-docs.mjs`. Fonte: `git shortlog -sn --no-merges (descontando autores que são agentes)` diff --git a/docs/docs/comecando.md b/docs/docs/comecando.md index 5781b68c4..cb14b1d04 100644 --- a/docs/docs/comecando.md +++ b/docs/docs/comecando.md @@ -39,7 +39,7 @@ esta página envelhecia no primeiro commit — ver | O que | Quanto | Onde confere | |---|---:|---| -| Código do jogo | 32.001 linhas em 44 arquivos | `git ls-files public/js/*.js \| xargs wc -l` | +| Código do jogo | 32.430 linhas em 46 arquivos | `git ls-files public/js/*.js \| xargs wc -l` | | `game.js` | **6.910** linhas | `wc -l public/js/game.js` | | `main.js` | 2.698 linhas | `wc -l public/js/main.js` | | Armas com GLB | 26 | `git ls-files 'public/models/weapons/*.glb' \| wc -l` | @@ -49,7 +49,7 @@ esta página envelhecia no primeiro commit — ver | Personagens jogáveis | 44, em 5 facções | array `CHARACTERS` de `characters.js` | | Mapas no registro | 12 | objeto `MAPS` de `maps.js` | | Arnêses visuais em HTML | 15 | `git ls-files 'public/*.html' \| wc -l` | -| Scripts do arnês | 201 | `git ls-files 'tools/eval/*.mjs' 'tools/eval/*.py' \| wc -l` | +| Scripts do arnês | 204 | `git ls-files 'tools/eval/*.mjs' 'tools/eval/*.py' \| wc -l` | | Scripts de pipeline | 55 | `git ls-files 'tools/*.mjs' \| wc -l` | | Tarefas de entrada escritas | 26 | `git ls-files 'docs/issues/[0-9]*.md' \| wc -l` | | Versão | `2.0.0-alpha.184` | `public/js/version.js` e `package.json` (batem) | diff --git a/docs/docs/quality-gates.md b/docs/docs/quality-gates.md index d802c0845..42f2be208 100644 --- a/docs/docs/quality-gates.md +++ b/docs/docs/quality-gates.md @@ -15,7 +15,7 @@ PR (`.github/workflows/ci.yml`). {/* BEGIN:GERADO:invariantes — não edite à mão, rode `npm run docs` */} - `tools/eval/invariants.mjs`: **2.275 linhas**, **65 identificadores de invariante declarados** (`put()`), dos quais **28** têm caminho de `skip()` declarado. -- O arnês inteiro são **201 scripts** em `tools/eval/` (`.mjs` + `.py`), mais **55 scripts** de pipeline em `tools/`. +- O arnês inteiro são **204 scripts** em `tools/eval/` (`.mjs` + `.py`), mais **55 scripts** de pipeline em `tools/`. - Quantas invariantes rodam como **críticas** numa execução **não é derivável do fonte**: depende de qual insumo existe na máquina (o JSON do auditor de viewmodel, um GLB, uma pasta de anims). Esse número só sai rodando o quality gate — e o lugar dele é o cabeçalho do `KNOWN-BUGS.md`, atualizado com saída real. Reproduza: diff --git a/docs/docs/stack.md b/docs/docs/stack.md index e5818daeb..d41e078d9 100644 --- a/docs/docs/stack.md +++ b/docs/docs/stack.md @@ -17,7 +17,7 @@ a partir do `package.json`, do `docs/package.json` e do próprio Three.js vendor | Camada | Ferramenta | Versão | |---|---|---| | Motor 3D (WebGL) | **Three.js**, vendorizado | `r160` | -| Jogo | ES modules vanilla, **zero build** | 44 arquivos | +| Jogo | ES modules vanilla, **zero build** | 46 arquivos | | Site | **Astro** com SSR | `^7.1.1` | | Hospedagem | adapter **Vercel** | `^11.0.6` | | Banco | **Postgres gerenciado** (RLS; schema privado, fora do repo) | `^2.110.7` | @@ -28,7 +28,7 @@ a partir do `package.json`, do `docs/package.json` e do próprio Three.js vendor | Esta documentação | **Docusaurus** | `3.6.3` | | Runtime de CI | **Node** | `22` | -Three.js sai de `public/vendor/three.module.js` (**sem CDN, sem npm no runtime**). Astro e Vercel de `package.json` + `astro.config.mjs` + `vercel.json`. Dos scripts de `tools/`, **110** importam Playwright, **39** importam gltf-transform e **4** importam meshoptimizer. +Three.js sai de `public/vendor/three.module.js` (**sem CDN, sem npm no runtime**). Astro e Vercel de `package.json` + `astro.config.mjs` + `vercel.json`. Dos scripts de `tools/`, **111** importam Playwright, **39** importam gltf-transform e **4** importam meshoptimizer. > Bloco gerado por `node tools/gen-docs.mjs`. Fonte: `dependencies/devDependencies do package.json · REVISION de public/vendor/three.module.js` diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/arquitetura.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/arquitetura.md index bf263a227..77a0ec245 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/arquitetura.md +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/arquitetura.md @@ -77,7 +77,7 @@ Size of the files `gen-arch.mjs` indexes — a generated block, regenerated by | `public/js/weapons.js` | 346 | | `public/js/springs.js` | 260 | -Total in `public/js/`: **32,001 lines in 44 files**. The symbol-to-line index lives in `tools/eval/ARCH.md`. +Total in `public/js/`: **32,430 lines in 46 files**. The symbol-to-line index lives in `tools/eval/ARCH.md`. > Block generated by `node tools/gen-docs.mjs`. Source: ``wc -l public/js/*.js`` diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/colaborar.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/colaborar.md index ad72a3a1d..609e0f910 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/colaborar.md +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/colaborar.md @@ -17,7 +17,7 @@ The number below is not rhetoric, and it is not hand-written: it comes from `git {/* BEGIN:GERADO:pessoas — não edite à mão, rode `npm run docs` */} -**11 human author identities** sign commits in this branch: `ruben-cytonic`, `Ruben`, `Emerson Garrido`, `rubenmarcus`, `Ruben Marcus`, `William Oliveira`, `Juan Versolato Lopes`, `daeeseD`, `matheusgb`, `Maná Soares`, `daltonfontes`. Automated identities are excluded. A Git author name is not necessarily one unique person. +**11 human author identities** sign commits in this branch: `ruben-cytonic`, `Ruben`, `Emerson Garrido`, `rubenmarcus`, `Ruben Marcus`, `William Oliveira`, `Juan Versolato Lopes`, `Maná Soares`, `daeeseD`, `matheusgb`, `daltonfontes`. Automated identities are excluded. A Git author name is not necessarily one unique person. > Block generated by `node tools/gen-docs.mjs`. Source: `git shortlog -sn --no-merges (descontando autores que são agentes)` diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/comecando.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/comecando.md index 05aebf9ed..e6929dcff 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/comecando.md +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/comecando.md @@ -41,7 +41,7 @@ this page was aging at the very first commit — see | What | How much | Where to check | |---|---:|---| -| Game code | 32,001 lines in 44 files | `git ls-files public/js/*.js \| xargs wc -l` | +| Game code | 32,430 lines in 46 files | `git ls-files public/js/*.js \| xargs wc -l` | | `game.js` | **6,910** lines | `wc -l public/js/game.js` | | `main.js` | 2,698 lines | `wc -l public/js/main.js` | | Weapons with GLB | 26 | `git ls-files 'public/models/weapons/*.glb' \| wc -l` | @@ -51,7 +51,7 @@ this page was aging at the very first commit — see | Playable characters | 44, in 5 factions | `CHARACTERS` array in `characters.js` | | Maps in the registry | 12 | `MAPS` object in `maps.js` | | Visual harnesses in HTML | 15 | `git ls-files 'public/*.html' \| wc -l` | -| Harness scripts | 201 | `git ls-files 'tools/eval/*.mjs' 'tools/eval/*.py' \| wc -l` | +| Harness scripts | 204 | `git ls-files 'tools/eval/*.mjs' 'tools/eval/*.py' \| wc -l` | | Pipeline scripts | 55 | `git ls-files 'tools/*.mjs' \| wc -l` | | Written entry tasks | 26 | `git ls-files 'docs/issues/[0-9]*.md' \| wc -l` | | Version | `2.0.0-alpha.184` | `public/js/version.js` and `package.json` (match) | diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/quality-gates.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/quality-gates.md index 2a6ea015a..f52637609 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/quality-gates.md +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/quality-gates.md @@ -18,7 +18,7 @@ PR (`.github/workflows/ci.yml`). {/* BEGIN:GERADO:invariantes — não edite à mão, rode `npm run docs` */} - `tools/eval/invariants.mjs`: **2,275 lines**, **65 declared invariant identifiers**, with **28** declared `skip()` paths. -- The harness contains **201 scripts** in `tools/eval/`, plus **55 pipeline scripts** in `tools/`. +- The harness contains **204 scripts** in `tools/eval/`, plus **55 pipeline scripts** in `tools/`. - The number of critical checks in one run depends on the inputs present on that machine; dated results belong in `KNOWN-BUGS.md`. ```bash diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/stack.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/stack.md index fa6e23626..697415fe5 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/stack.md +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/stack.md @@ -20,7 +20,7 @@ from `package.json`, `docs/package.json` and the vendored Three.js itself. | Layer | Tool | Version | |---|---|---| | 3D engine (WebGL) | **Three.js**, vendored | `r160` | -| Game | vanilla ES modules, **zero build** | 44 files | +| Game | vanilla ES modules, **zero build** | 46 files | | Site | **Astro** with SSR | `^7.1.1` | | Hosting | **Vercel** adapter | `^11.0.6` | | Database | **managed Postgres** (RLS; private schema) | `^2.110.7` | @@ -31,7 +31,7 @@ from `package.json`, `docs/package.json` and the vendored Three.js itself. | This documentation | **Docusaurus** | `3.6.3` | | CI runtime | **Node** | `22` | -Three.js comes from `public/vendor/three.module.js`. Of the scripts in `tools/`, **110** import Playwright, **39** import gltf-transform, and **4** import meshoptimizer. +Three.js comes from `public/vendor/three.module.js`. Of the scripts in `tools/`, **111** import Playwright, **39** import gltf-transform, and **4** import meshoptimizer. > Block generated by `node tools/gen-docs.mjs`. Source: `dependencies/devDependencies do package.json · REVISION de public/vendor/three.module.js` diff --git a/public/img/textures/sky_brasilia.webp b/public/img/textures/sky_brasilia.webp new file mode 100644 index 000000000..3518cd76d Binary files /dev/null and b/public/img/textures/sky_brasilia.webp differ diff --git a/public/img/textures/sky_ferrovelho.webp b/public/img/textures/sky_ferrovelho.webp new file mode 100644 index 000000000..0d9f5ee3d Binary files /dev/null and b/public/img/textures/sky_ferrovelho.webp differ diff --git a/public/img/textures/sky_havan.webp b/public/img/textures/sky_havan.webp new file mode 100644 index 000000000..e26a95f73 Binary files /dev/null and b/public/img/textures/sky_havan.webp differ diff --git a/public/img/textures/sky_pool.webp b/public/img/textures/sky_pool.webp new file mode 100644 index 000000000..5ba7ea3b6 Binary files /dev/null and b/public/img/textures/sky_pool.webp differ diff --git a/public/img/textures/sky_quebrada.webp b/public/img/textures/sky_quebrada.webp new file mode 100644 index 000000000..37c8409ab Binary files /dev/null and b/public/img/textures/sky_quebrada.webp differ diff --git a/public/js/ambientlife.js b/public/js/ambientlife.js new file mode 100644 index 000000000..1e0530f43 --- /dev/null +++ b/public/js/ambientlife.js @@ -0,0 +1,410 @@ +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js'; +import { VERSION } from './version.js'; + +const loader = new GLTFLoader(); +const templates = new Map(); +const ASSETS = Object.freeze({ + rat: 'models/ambient/rat_animated.glb', + pigeonGround: 'models/ambient/pigeon_ground.glb', + pigeonFlight: 'models/ambient/pigeon_flight.glb', + dog: 'models/ambient/dog_caramelo.glb', +}); +export const FAVELA_AMBIENCE_ASSETS = Object.freeze(Object.keys(ASSETS)); +const SHOT_REACTION_RADIUS = 13; +const DOG_WALK_SPEED = 1; +const DOG_IDLE_TIME = 3; + +const loadGLB = (url) => new Promise((resolve, reject) => loader.load(url, resolve, undefined, reject)); + +export async function preloadAmbientLife(ids = FAVELA_AMBIENCE_ASSETS) { + await Promise.all([...new Set(ids)].filter((id) => ASSETS[id] && !templates.has(id)).map(async (id) => { + try { + const gltf = await loadGLB(`${ASSETS[id]}?v=${VERSION}`); + let skinned = false; + gltf.scene.traverse((object) => { + if (!object.isMesh) return; + skinned ||= object.isSkinnedMesh; + object.material.metalness = 0; + object.material.roughness = Math.max(.72, object.material.roughness ?? .72); + }); + templates.set(id, { scene: gltf.scene, clips: gltf.animations, skinned }); + } catch (error) { + console.warn('[ambientlife] GLB não carregou', id, error); + } + })); +} + +function fallbackRat(index) { + const group = new THREE.Group(); + const fur = new THREE.MeshStandardMaterial({ color: index % 2 ? 0x5f5044 : 0x4b4a48, roughness: 1 }); + const skin = new THREE.MeshStandardMaterial({ color: 0xa27672, roughness: .95 }); + const body = new THREE.Mesh(new THREE.SphereGeometry(.065, 9, 6), fur); + body.scale.set(.5, .43, 1.12); body.position.y = .01; body.userData.faunaPart = 'body'; group.add(body); + const head = new THREE.Mesh(new THREE.ConeGeometry(.038, .085, 8), fur); + head.rotation.x = -Math.PI / 2; head.position.set(0, .01, .088); head.userData.faunaPart = 'head'; group.add(head); + for (const x of [-.026, .026]) { + const ear = new THREE.Mesh(new THREE.SphereGeometry(.021, 7, 5), skin); + ear.scale.set(1, .36, 1); ear.position.set(x, .052, .066); ear.userData.faunaPart = 'ear'; group.add(ear); + } + for (const [x, z] of [[-.035, -.04], [.035, -.04], [-.032, .045], [.032, .045]]) { + const leg = new THREE.Mesh(new THREE.CylinderGeometry(.0065, .010, .036, 6), fur); + leg.rotation.z = x < 0 ? .18 : -.18; leg.position.set(x, -.033, z); leg.userData.faunaPart = 'leg'; group.add(leg); + } + const points = [[0, -.01, -.06], [.035, -.018, -.11], [.06, -.01, -.16], [.025, 0, -.205]]; + for (let i = 0; i < 3; i++) { + const curve = new THREE.CatmullRomCurve3(points.slice(i, i + 2).map((point) => new THREE.Vector3(...point))); + const tail = new THREE.Mesh(new THREE.TubeGeometry(curve, 3, [.0065, .0045, .0025][i], 4), skin); + tail.userData.faunaPart = 'curved-tail'; group.add(tail); + } + for (const child of group.children) child.position.y += .052; + return group; +} + +function fallbackPigeon() { + const group = new THREE.Group(); + const feather = new THREE.MeshStandardMaterial({ color: 0x657078, roughness: .92 }); + const body = new THREE.Mesh(new THREE.SphereGeometry(.13, 9, 7), feather); + body.scale.set(.72, 1.1, 1.35); body.position.y = .17; group.add(body); + const head = new THREE.Mesh(new THREE.SphereGeometry(.075, 8, 6), feather); + head.position.set(0, .32, .11); group.add(head); + for (const side of [-1, 1]) { + const wing = new THREE.Mesh(new THREE.ConeGeometry(.11, .3, 5), feather); + wing.rotation.z = side * 1.05; wing.position.set(side * .15, .19, 0); group.add(wing); + } + return group; +} + +function fallbackDog() { + const group = new THREE.Group(); + const fur = new THREE.MeshStandardMaterial({ color: 0xc68642, roughness: .95 }); + const cream = new THREE.MeshStandardMaterial({ color: 0xe4c59a, roughness: .95 }); + const body = new THREE.Mesh(new THREE.BoxGeometry(.22, .26, .62), fur); + body.position.y = .46; group.add(body); + const head = new THREE.Mesh(new THREE.BoxGeometry(.2, .2, .22), fur); + head.position.set(0, .66, .4); group.add(head); + const snout = new THREE.Mesh(new THREE.BoxGeometry(.11, .1, .14), cream); + snout.position.set(0, .62, .55); group.add(snout); + for (const side of [-1, 1]) { + const ear = new THREE.Mesh(new THREE.ConeGeometry(.05, .11, 4), fur); + ear.position.set(side * .07, .8, .36); group.add(ear); + } + for (const [x, z] of [[-.08, .24], [.08, .24], [-.08, -.24], [.08, -.24]]) { + const leg = new THREE.Mesh(new THREE.CylinderGeometry(.028, .024, .34, 6), fur); + leg.position.set(x, .17, z); group.add(leg); + } + const tail = new THREE.Mesh(new THREE.CylinderGeometry(.02, .012, .3, 5), fur); + tail.rotation.x = -.8; tail.position.set(0, .62, -.38); group.add(tail); + return group; +} + +function cloneAsset(id) { + const template = templates.get(id); + if (!template) return null; + return { + model: template.skinned ? skeletonClone(template.scene) : template.scene.clone(true), + clips: template.clips, + }; +} + +function normalizeModel(id, model) { + model.updateMatrixWorld(true); + const box = new THREE.Box3().setFromObject(model); + const size = box.getSize(new THREE.Vector3()); + const target = id === 'rat' ? .36 : id === 'pigeonGround' ? .29 : id === 'dog' ? 1 : .62; + const dimension = id === 'pigeonGround' || id === 'dog' ? size.y : Math.max(size.x, size.z); + const scale = target / Math.max(.001, dimension); + // dog: altura 1 m => cernelha ~0,6 (ombro 1,83 de 3,09 de altura no GLB bruto) + const center = box.getCenter(new THREE.Vector3()); + model.scale.setScalar(scale); + model.position.set(-center.x * scale, -box.min.y * scale, -center.z * scale); + if (id === 'rat') model.rotation.y = -Math.PI / 2; +} + +function distanceToSegment(point, start, end) { + const segment = end.clone().sub(start); + const lengthSq = segment.lengthSq(); + if (lengthSq < 1e-6) return point.distanceTo(start); + const t = THREE.MathUtils.clamp(point.clone().sub(start).dot(segment) / lengthSq, 0, 1); + return point.distanceTo(start.clone().addScaledVector(segment, t)); +} + +class FavelaAmbience { + constructor(root, { map, low = false, rats = [], pigeons = [], dogs = [] }) { + this.map = map; + this.low = low; + this.time = 0; + this.paused = false; + this.ready = true; + this.group = new THREE.Group(); + this.group.name = `AMBIENT_LIFE_${map}`; + this.group.userData.ambientLife = true; + root.add(this.group); + const ratList = low ? rats.slice(0, 1) : rats; + const flight = pigeons.find((pigeon) => pigeon.mode === 'flight') || pigeons[0]; + const pigeonList = low ? (flight ? [flight] : []) : pigeons; + const dogList = low ? dogs.slice(0, 1) : dogs; + this.animals = []; + ratList.forEach((config, index) => this._add('rat', config, index)); + pigeonList.forEach((config, index) => this._add('pigeon', config, index)); + dogList.forEach((config, index) => this._add('dog', config, index)); + this.reset(); + } + + _add(type, config, index) { + const assetId = type === 'rat' ? 'rat' : type === 'dog' ? 'dog' : config.mode === 'flight' ? 'pigeonFlight' : 'pigeonGround'; + const loaded = cloneAsset(assetId); + const animalRoot = new THREE.Group(); + animalRoot.name = `${type}:${this.map}:${index}`; + animalRoot.userData.fauna = type === 'rat' ? 'rato' : type === 'dog' ? 'cachorro' : 'pomba'; + animalRoot.userData.nonCollider = true; + animalRoot.userData.motion = 'deterministic-run-idle'; + animalRoot.userData.bodyLength = type === 'rat' ? .142 : undefined; + animalRoot.userData.bodyAspect = type === 'rat' ? 2.05 : undefined; + animalRoot.userData.taperedTail = type === 'rat' || undefined; + animalRoot.userData.poseId = `${type}-${index % 2}`; + animalRoot.userData.albedoId = `${type}-${index % 2}`; + let model; + if (loaded) { + model = loaded.model; + normalizeModel(assetId, model); + animalRoot.add(model); + } else { + model = type === 'rat' ? fallbackRat(index) : type === 'dog' ? fallbackDog() : fallbackPigeon(); + while (model.children.length) animalRoot.add(model.children[0]); + model = animalRoot; + } + animalRoot.traverse((object) => { + if (!object.isMesh) return; + object.userData.nonSolidSurface = true; + object.castShadow = config.mode !== 'flight' && !this.low; + object.receiveShadow = true; + }); + this.group.add(animalRoot); + const clips = loaded?.clips || []; + let mixer = null; + let actions = null; + if (type === 'dog' && clips.length) { + // clipes Quaternius vêm como 'AnimalArmature|Idle'; casa por sufixo, cai no primeiro + mixer = new THREE.AnimationMixer(model); + actions = {}; + for (const [key, pattern] of [['idle', /(^|\|)Idle$/], ['walk', /(^|\|)Walk$/], ['run', /(^|\|)(Run|Gallop)$/]]) { + const clip = clips.find((item) => pattern.test(item.name)) || clips[0]; + actions[key] = mixer.clipAction(clip); + } + actions.idle.play(); + } else if (type !== 'dog') { + const clip = clips.find((item) => item.name === (type === 'rat' ? 'Run' : 'Animation')) || clips[0]; + if (clip) { + mixer = new THREE.AnimationMixer(model); + mixer.clipAction(clip).play(); + } + } + const origin = new THREE.Vector3(...config.pos); + const to = new THREE.Vector3(...(config.to || config.pos)); + this.animals.push({ + id: `${type}-${index}`, type, mode: config.mode || 'ground', root: animalRoot, model, + origin, to, phase: config.phase || 0, radius: config.radius || [2.4, 1.8], + source: loaded ? 'gltf' : 'fallback', mixer, actions, action: 'idle', state: 'idle', alertUntil: 0, + alertAt: 0, alertOrigin: origin.clone(), flee: new THREE.Vector3(1, 0, 0), + routine: origin.clone(), recoverAt: 0, recoverUntil: 0, recoverFrom: origin.clone(), + }); + } + + setPaused(value) { this.paused = !!value; } + + reset() { + this.time = 0; + for (const animal of this.animals) { + animal.alertUntil = 0; + animal.alertAt = 0; + animal.recoverAt = 0; + animal.recoverUntil = .8; + animal.recoverFrom.copy(animal.origin); + animal.state = animal.mode === 'flight' ? 'fly' : 'idle'; + animal.root.position.copy(animal.origin); + animal.root.rotation.set(0, animal.phase, 0); + if (animal.actions) { + animal.mixer.stopAllAction(); + animal.actions.idle.reset().play(); + animal.action = 'idle'; + } + animal.mixer?.setTime(0); + } + } + + onShot(start, end) { + let reacted = 0; + for (const animal of this.animals) { + const position = animal.root.getWorldPosition(new THREE.Vector3()); + if (distanceToSegment(position, start, end) > SHOT_REACTION_RADIUS) continue; + animal.alertAt = this.time; + animal.alertUntil = this.time + (animal.type === 'rat' ? 2.1 : animal.type === 'dog' ? 2.6 : 3.2); + animal.recoverAt = 0; + animal.recoverUntil = 0; + animal.alertOrigin.copy(animal.root.position); + animal.flee.copy(position).sub(start).setY(0); + if (animal.flee.lengthSq() < .01) animal.flee.set(Math.sin(animal.phase + 1), 0, Math.cos(animal.phase + 1)); + animal.flee.normalize(); + animal.state = animal.type === 'pigeon' ? 'takeoff' : 'flee'; + reacted++; + } + return reacted; + } + + update(dt, playerPosition) { + this.time += Math.max(0, Math.min(.05, dt)); + for (const animal of this.animals) { + if (playerPosition && this.time >= animal.alertUntil && animal.root.position.distanceToSquared(playerPosition) < 4.84) { + const from = new THREE.Vector3(playerPosition.x, animal.root.position.y, playerPosition.z); + this.onShot(from, animal.root.position.clone()); + } + if (animal.type === 'rat') this._updateRat(animal, dt); + else if (animal.type === 'dog') this._updateDog(animal, dt); + else this._updatePigeon(animal, dt); + animal.mixer?.update(dt); + } + } + + _updateRat(animal) { + if (this.time < animal.alertUntil) { + const elapsed = this.time - animal.alertAt; + animal.root.position.copy(animal.alertOrigin).addScaledVector(animal.flee, Math.min(2.7, elapsed * 2.05)); + animal.root.rotation.y = Math.atan2(animal.flee.x, animal.flee.z); + animal.state = 'flee'; + return; + } + const cycle = (this.time + animal.phase) % 5; + const moving = cycle < 3.2; + const t = moving ? .5 - .5 * Math.cos(cycle / 3.2 * Math.PI * 2) : 0; + animal.routine.lerpVectors(animal.origin, animal.to, t); + const recovering = this._recoverToRoute(animal, animal.routine, 1.6); + const direction = animal.to.clone().sub(animal.origin); + if (cycle > 1.6) direction.negate(); + if (direction.lengthSq() > .001) animal.root.rotation.y = Math.atan2(direction.x, direction.z); + animal.state = recovering ? 'recover' : moving ? 'run' : 'idle'; + } + + _dogAction(animal, name) { + if (!animal.actions || animal.action === name) return; + animal.actions[name].reset().crossFadeFrom(animal.actions[animal.action], .25, false).play(); + animal.action = name; + } + + _updateDog(animal) { + if (this.time < animal.alertUntil) { + const elapsed = this.time - animal.alertAt; + animal.root.position.copy(animal.alertOrigin).addScaledVector(animal.flee, Math.min(4.5, elapsed * 3.2)); + animal.root.rotation.y = Math.atan2(animal.flee.x, animal.flee.z); + animal.state = 'flee'; + this._dogAction(animal, 'run'); + return; + } + const span = animal.origin.distanceTo(animal.to); + const leg = span / DOG_WALK_SPEED; + const cycle = (this.time + animal.phase) % (2 * (DOG_IDLE_TIME + leg)); + let moving = false; + let direction = null; + if (cycle < DOG_IDLE_TIME) animal.routine.copy(animal.origin); + else if (cycle < DOG_IDLE_TIME + leg) { + animal.routine.lerpVectors(animal.origin, animal.to, (cycle - DOG_IDLE_TIME) / leg); + moving = span > .05; + direction = animal.to.clone().sub(animal.origin); + } else if (cycle < 2 * DOG_IDLE_TIME + leg) animal.routine.copy(animal.to); + else { + animal.routine.lerpVectors(animal.to, animal.origin, (cycle - 2 * DOG_IDLE_TIME - leg) / leg); + moving = span > .05; + direction = animal.origin.clone().sub(animal.to); + } + const recovering = this._recoverToRoute(animal, animal.routine, 2); + if (direction && direction.lengthSq() > .001) animal.root.rotation.y = Math.atan2(direction.x, direction.z); + animal.state = recovering ? 'recover' : moving ? 'walk' : 'idle'; + this._dogAction(animal, recovering || moving ? 'walk' : 'idle'); + } + + _updatePigeon(animal) { + const flight = animal.mode === 'flight' || this.time < animal.alertUntil; + if (!flight) { + const angle = (this.time * .38 + animal.phase) * Math.PI * 2; + animal.routine.set(animal.origin.x + Math.sin(angle) * .18, animal.origin.y, animal.origin.z + Math.cos(angle) * .12); + const recovering = this._recoverToRoute(animal, animal.routine, 2.2); + animal.root.rotation.y = angle + Math.PI / 2; + animal.state = recovering ? 'recover' : 'walk'; + return; + } + if (this.time < animal.alertUntil) { + const elapsed = this.time - animal.alertAt; + animal.root.position.copy(animal.alertOrigin).addScaledVector(animal.flee, elapsed * 3.1); + animal.root.position.y += Math.min(5, elapsed * 2.4); + animal.root.rotation.z = Math.sin(elapsed * 8) * .24; + animal.root.rotation.y = Math.atan2(animal.flee.x, animal.flee.z); + animal.state = 'takeoff'; + return; + } + const angle = this.time * .42 + animal.phase; + animal.routine.set( + animal.origin.x + Math.cos(angle) * animal.radius[0], + animal.origin.y + Math.sin(angle * 2.3) * .28, + animal.origin.z + Math.sin(angle) * animal.radius[1], + ); + const recovering = this._recoverToRoute(animal, animal.routine, 2.2); + const dx = -Math.sin(angle) * animal.radius[0]; + const dz = Math.cos(angle) * animal.radius[1]; + animal.root.rotation.set(.04, Math.atan2(dx, dz), THREE.MathUtils.clamp(-Math.sin(angle) * .28, -.3, .3)); + animal.state = recovering ? 'recover' : 'fly'; + } + + _recoverToRoute(animal, target, duration) { + if (animal.alertUntil > 0 && this.time >= animal.alertUntil && animal.recoverUntil === 0) { + animal.recoverAt = this.time; + animal.recoverUntil = this.time + duration; + animal.recoverFrom.copy(animal.root.position); + } + if (animal.recoverUntil > this.time) { + const progress = THREE.MathUtils.smoothstep(this.time, animal.recoverAt, animal.recoverUntil); + animal.root.position.lerpVectors(animal.recoverFrom, target, progress); + return true; + } + animal.root.position.copy(target); + if (animal.recoverUntil > 0) { + animal.alertUntil = 0; + animal.recoverUntil = 0; + } + return false; + } + + snapshot() { + return this.animals.map((animal) => ({ + id: animal.id, type: animal.type, state: animal.state, + x: +animal.root.position.x.toFixed(4), y: +animal.root.position.y.toFixed(4), z: +animal.root.position.z.toFixed(4), + clipTime: +(animal.mixer?.time || 0).toFixed(4), + })); + } + + report() { + let meshes = 0, triangles = 0; + for (const animal of this.animals) animal.root.traverse((object) => { + if (!object.isMesh || !object.geometry) return; + meshes++; + const geometry = object.geometry; + triangles += (geometry.index?.count || geometry.attributes.position?.count || 0) / 3; + }); + const rat = this.animals.filter((animal) => animal.type === 'rat').length; + const pigeon = this.animals.filter((animal) => animal.type === 'pigeon').length; + const dog = this.animals.filter((animal) => animal.type === 'dog').length; + return { + map: this.map, low: this.low, gltf: this.animals.length > 0 && this.animals.every((animal) => animal.source === 'gltf'), + counts: { rat, pigeon, dog, total: rat + pigeon + dog }, meshes, triangles: Math.round(triangles), + }; + } + + dispose() { + for (const animal of this.animals) animal.mixer?.stopAllAction(); + this.group.removeFromParent(); + } +} + +export function createFavelaAmbience(root, options) { + return new FavelaAmbience(root, options); +} diff --git a/public/js/i18n.js b/public/js/i18n.js index 3be827c3e..91ece5c77 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -8,7 +8,7 @@ 3. `translateDom(root)`: varre nós de texto e atributos (placeholder/title/aria) do menu ESTÁTICO uma vez no boot — zero mudança no index.astro; 4. `frase(id, ...args)`: os textos DINÂMICOS do game.js (banner, HUD) com template. - Páginas do site e docs em EN são outra frente (issue #54). */ + Páginas do site e docs em EN são outra frente (issue #54; changelog já tem /whats-new). */ let _lang = null; // ?lang=pt|en na URL vence tudo (teste/demonstração — ex.: a live mostra EN sem mexer em config) diff --git a/public/js/main.js b/public/js/main.js index 06dddff33..df35d0729 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -761,9 +761,9 @@ await resolveGeoLang(); // EN por camada: varre o menu estático UMA vez (PT é a fonte; i18n.js explica o desenho) translateDom(document.body); // links do rodapé por idioma: EN vai pras gêmeas que EXISTEM (characters, how-to-play, -// weapons, maps, about, docs/en); /changelog e /mapa continuam só PT (issue #54) +// weapons, maps, about, whats-new, docs/en); /mapa continua só PT (issue #54) if (LANG === 'en') for (const a of document.querySelectorAll('.menu-footer a')) { - const GEMEA = { '/personagens': '/characters', '/como-jogar': '/how-to-play', '/armas': '/weapons', '/mapas': '/maps', '/sobre': '/about', '/docs/': '/docs/en/' }; + const GEMEA = { '/personagens': '/characters', '/como-jogar': '/how-to-play', '/armas': '/weapons', '/mapas': '/maps', '/sobre': '/about', '/changelog': '/whats-new', '/docs/': '/docs/en/' }; const h = a.getAttribute('href'); if (GEMEA[h]) a.setAttribute('href', GEMEA[h]); } diff --git a/public/js/map_sky.js b/public/js/map_sky.js new file mode 100644 index 000000000..8228a500a --- /dev/null +++ b/public/js/map_sky.js @@ -0,0 +1,19 @@ +import * as THREE from 'three'; +import { VERSION } from './version.js'; + +// Único caminho para céu fotográfico dos mapas. Os assets são panoramas 2:1; +// sem mapping equiretangular o Three trata a imagem como wallpaper preso à câmera. +export function setMapSky(scene, T, url, fallback = 0x9fb8cc) { + const fb = (T && T.sky) || (fallback && fallback.isTexture ? fallback : new THREE.Color(fallback)); + if (typeof document === 'undefined') { scene.background = fb; return fb; } + const versionedUrl = `${url}${url.includes('?') ? '&' : '?'}v=${encodeURIComponent(VERSION)}`; + const tex = new THREE.TextureLoader().load(versionedUrl, undefined, undefined, () => { + if (scene.background === tex) scene.background = fb; + }); + tex.colorSpace = THREE.SRGBColorSpace; + tex.mapping = THREE.EquirectangularReflectionMapping; + tex.minFilter = THREE.LinearMipmapLinearFilter; + tex.magFilter = THREE.LinearFilter; + scene.background = tex; + return tex; +} diff --git a/public/models/ambient/FONTE.md b/public/models/ambient/FONTE.md new file mode 100644 index 000000000..93c0c0161 --- /dev/null +++ b/public/models/ambient/FONTE.md @@ -0,0 +1,22 @@ +# Fauna ambiente + +Derivados otimizados das referências locais; texturas WebP 256², sem quantização +de malha skinned. + +- `rat_animated.glb` — “Rat Animated”, Lobbyvictor, + [Sketchfab](https://sketchfab.com/3d-models/rat-animated-cba5c3b8a946499083b4adfbb6d568b8), + [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/). +- `pigeon_ground.glb` — “Pigeon”, kenchoo, + [Sketchfab](https://sketchfab.com/3d-models/pigeon-ddd5ef4a94eb4159937a9de25c45697c), + [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/). +- `pigeon_flight.glb` — “Pigeon In Flight”, restore50, + [Sketchfab](https://sketchfab.com/3d-models/pigeon-in-flight-d135106ba138411fbe8d779b2fb90599), + [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/). +- `dog_caramelo.glb` — “Shiba Inu” (Ultimate Animated Animals Pack), Quaternius, + [quaternius.com](https://quaternius.com/packs/ultimateanimatedanimals.html), + [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/). + Tingido para caramelo no pipeline (`Main` #C68642, `Main_Light` #E4C59A) — + o original é marrom escuro com marcações cinzas. `skin.skeleton` inválido do + export original foi removido (hint que o three.js ignora). + +Pipeline reproduzível: `node tools/optimize-ambient-fauna.mjs`. diff --git a/public/models/ambient/dog_caramelo.glb b/public/models/ambient/dog_caramelo.glb new file mode 100644 index 000000000..29479c2e3 Binary files /dev/null and b/public/models/ambient/dog_caramelo.glb differ diff --git a/public/models/ambient/pigeon_flight.glb b/public/models/ambient/pigeon_flight.glb new file mode 100644 index 000000000..e8ea5157a Binary files /dev/null and b/public/models/ambient/pigeon_flight.glb differ diff --git a/public/models/ambient/pigeon_ground.glb b/public/models/ambient/pigeon_ground.glb new file mode 100644 index 000000000..513364198 Binary files /dev/null and b/public/models/ambient/pigeon_ground.glb differ diff --git a/public/models/ambient/rat_animated.glb b/public/models/ambient/rat_animated.glb new file mode 100644 index 000000000..7a9c311d1 Binary files /dev/null and b/public/models/ambient/rat_animated.glb differ diff --git a/scratchpad/autoria.patch b/scratchpad/autoria.patch new file mode 100644 index 000000000..dc94f5318 --- /dev/null +++ b/scratchpad/autoria.patch @@ -0,0 +1,21 @@ +diff --git a/tools/eval/docs-autoria-check.mjs b/tools/eval/docs-autoria-check.mjs +index 361f3813..ba23805f 100644 +--- a/tools/eval/docs-autoria-check.mjs ++++ b/tools/eval/docs-autoria-check.mjs +@@ -29,6 +29,16 @@ const MUT = (process.argv.find((a) => a.startsWith('--mutante=')) || '').split(' + const ALVO = 'docs/docs/colaborar.md'; + const GERADOR = 'tools/gen-docs.mjs'; + ++/* Clone RASO (Vercel, CI com fetch-depth:1) não tem histórico, e o gen-docs - por decisão ++ já registrada nele - NÃO regenera o bloco `pessoas` ali: número que não existe no ++ ambiente não derruba portão. Sem regeneração, a mutação de autoria não tem como acender ++ nada, e a DOCSAUT2 acusa cegueira onde na verdade falta medida. Foi o que derrubou o ++ deploy da Vercel (e a produção) desde o #398. Onde há histórico, a régua segue mordendo. */ ++if (execFileSync('git', ['rev-parse', '--is-shallow-repository']).toString().trim() === 'true') { ++ console.log(' \x1b[33m⚠\x1b[0m DOCSAUT não medido: clone raso não tem o histórico de onde sai a autoria'); ++ process.exit(0); ++} ++ + const sujo = execFileSync('git', ['status', '--porcelain', '--', ALVO, GERADOR]).toString().trim(); + if (sujo) { + console.log(` \x1b[31m✗\x1b[0m DOCSAUT não dá para medir: ${ALVO} ou ${GERADOR} com mudança não commitada`); diff --git a/scratchpad/bounds.mjs b/scratchpad/bounds.mjs new file mode 100644 index 000000000..3c9ff7efa --- /dev/null +++ b/scratchpad/bounds.mjs @@ -0,0 +1,19 @@ +import { execSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +const gRoot = execSync('npm root -g').toString().trim(); +const _pw = await import(pathToFileURL(`${gRoot}/playwright/index.js`).href); +const chromium = _pw.chromium || _pw.default?.chromium; +const b = await chromium.launch({ executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + args:['--use-angle=swiftshader','--enable-unsafe-swiftshader','--headless=new','--mute-audio'] }); +const p = await b.newPage({ viewport:{width:900,height:900} }); +for (const mapa of process.argv.slice(2)) { + await p.goto(`http://127.0.0.1:8123/?debug=1&auto=P,mst&map=${mapa}`, { waitUntil:'domcontentloaded', timeout:60000 }); + await p.waitForFunction(() => window.__game && window.__game.state === 'live', null, { timeout:180000 }); + console.log(mapa, JSON.stringify(await p.evaluate(() => { + const g = window.__game; + const bb = g.world?.bounds; + return { bounds: bb ? { min: bb.min ? [bb.min.x, bb.min.y, bb.min.z] : null, max: bb.max ? [bb.max.x, bb.max.y, bb.max.z] : null } : null, + spawn: [Math.round(g.player.pos.x), Math.round(g.player.pos.y), Math.round(g.player.pos.z)] }; + }))); +} +await b.close(); diff --git a/scratchpad/contato.mjs b/scratchpad/contato.mjs new file mode 100644 index 000000000..3de6ec3ea --- /dev/null +++ b/scratchpad/contato.mjs @@ -0,0 +1,15 @@ +import sharp from 'sharp'; +const YAWS = ['0','0.8','1.57','2.4','3.14']; +const W = 300; +for (const mapa of process.argv.slice(2)) { + const tiles = []; + for (const y of YAWS) { + const buf = await sharp(`/tmp/gauntlet/g2ui-maps/${mapa}-p2-y${y}.png`).resize(W, W).toBuffer(); + tiles.push(buf); + } + await sharp({ create: { width: W * YAWS.length, height: W, channels: 3, background: '#000' } }) + .composite(tiles.map((input, i) => ({ input, left: i * W, top: 0 }))) + .jpeg({ quality: 82 }) + .toFile(`scratchpad/contato_${mapa}_p2.jpg`); + console.log('contato', mapa, YAWS.join(' | ')); +} diff --git a/scratchpad/contato_parque_treta.jpg b/scratchpad/contato_parque_treta.jpg new file mode 100644 index 000000000..b42e4dad6 Binary files /dev/null and b/scratchpad/contato_parque_treta.jpg differ diff --git a/scratchpad/contato_parque_treta_p1.jpg b/scratchpad/contato_parque_treta_p1.jpg new file mode 100644 index 000000000..d3df8482e Binary files /dev/null and b/scratchpad/contato_parque_treta_p1.jpg differ diff --git a/scratchpad/contato_parque_treta_p2.jpg b/scratchpad/contato_parque_treta_p2.jpg new file mode 100644 index 000000000..e71c35967 Binary files /dev/null and b/scratchpad/contato_parque_treta_p2.jpg differ diff --git a/scratchpad/contato_penitenciaria.jpg b/scratchpad/contato_penitenciaria.jpg new file mode 100644 index 000000000..307ae7748 Binary files /dev/null and b/scratchpad/contato_penitenciaria.jpg differ diff --git a/scratchpad/contato_penitenciaria_p1.jpg b/scratchpad/contato_penitenciaria_p1.jpg new file mode 100644 index 000000000..ba3409b0c Binary files /dev/null and b/scratchpad/contato_penitenciaria_p1.jpg differ diff --git a/scratchpad/contato_penitenciaria_p2.jpg b/scratchpad/contato_penitenciaria_p2.jpg new file mode 100644 index 000000000..dfe78a1a7 Binary files /dev/null and b/scratchpad/contato_penitenciaria_p2.jpg differ diff --git a/scratchpad/contato_velho_oeste.jpg b/scratchpad/contato_velho_oeste.jpg new file mode 100644 index 000000000..470b97a1f Binary files /dev/null and b/scratchpad/contato_velho_oeste.jpg differ diff --git a/scratchpad/contato_velho_oeste_p1.jpg b/scratchpad/contato_velho_oeste_p1.jpg new file mode 100644 index 000000000..d69fb0e1b Binary files /dev/null and b/scratchpad/contato_velho_oeste_p1.jpg differ diff --git a/scratchpad/contato_velho_oeste_p2.jpg b/scratchpad/contato_velho_oeste_p2.jpg new file mode 100644 index 000000000..b723586fe Binary files /dev/null and b/scratchpad/contato_velho_oeste_p2.jpg differ diff --git a/scratchpad/dev.log b/scratchpad/dev.log new file mode 100644 index 000000000..d0e4e05c3 --- /dev/null +++ b/scratchpad/dev.log @@ -0,0 +1,10 @@ + +> coro-solto@2.0.0-alpha.167 dev +> npm run copy-wasm && astro dev + + +> coro-solto@2.0.0-alpha.167 copy-wasm +> node scripts/copy-wasm.mjs + +[copy-wasm] ok: /Users/ruben/game4/node_modules/@resvg/resvg-wasm/index_bg.wasm -> public/wasm/resvg.wasm +{"message":"Dev server running at http://localhost:4323 (pid 73669)\n Stop: astro dev stop\n Status: astro dev status\n Logs: astro dev logs","label":"SKIP_FORMAT","level":"info"} diff --git a/scratchpad/dict-add.txt b/scratchpad/dict-add.txt new file mode 100644 index 000000000..48b2a48cd --- /dev/null +++ b/scratchpad/dict-add.txt @@ -0,0 +1,106 @@ + /* --- varredura de 21/08: as sobras PT que apareciam no meio do EN (tela 04 e vizinhas). + Personagem, facção e nome de mapa continuam PT nos dois idiomas — é sabor, decisão do dono. */ + // boot / erro + 'CARREGANDO MAPA': 'LOADING MAP', + 'A TRETA DEU UMA PAUSA': 'THE FIGHT TOOK A BREAK', + 'A ARENA NÃO ABRIU': 'THE ARENA DID NOT OPEN', + 'Alguma coisa travou durante o carregamento. O erro já foi registrado automaticamente, sem pedir senha.': + 'Something jammed while loading. The error was logged automatically, no password needed.', + 'RELATÓRIO LOCAL': 'LOCAL REPORT', + 'TENTAR DE NOVO': 'TRY AGAIN', + 'REPORTAR ERRO': 'REPORT ERROR', + 'Se quiser, confirme o envio pelo botão acima.': 'If you like, confirm the submission with the button above.', + 'DICA': 'TIP', 'CORRIDA': 'RUN', + // aviso de desktop + 'Este jogo foi feito para': 'This game was built for', + '(mouse + teclado).': '(mouse + keyboard).', + 'Jogar num PC ou notebook é': 'Playing on a PC or laptop is', + 'recomendado': 'recommended', + '. No celular a treta não flui.': '. On a phone the fight does not flow.', + // menu principal / perfil + 'RANKING GLOBAL': 'GLOBAL LEADERBOARD', + 'MAPA DA TRETA': 'THE MAP', + 'SOBRE': 'ABOUT', + 'ENVIE SEU FEEDBACK': 'SEND YOUR FEEDBACK', + 'EDITAR PERFIL': 'EDIT PROFILE', + 'NÍVEL 1': 'LEVEL 1', + '▶ JOGAR': '▶ PLAY', + 'CONFIG.': 'SETTINGS', + 'SEU NICK': 'YOUR NICK', + 'REDES SOCIAIS (OPCIONAL)': 'SOCIAL LINKS (OPTIONAL)', + '+ REDE SOCIAL': '+ SOCIAL LINK', + '+ FOTO DE PERFIL': '+ PROFILE PHOTO', + 'É esse nome que aparece no killfeed. Escolha com carinho - a treta é pública.': + 'This is the name that shows up in the killfeed. Choose it well — the fight is public.', + 'ex: Zé do AWP': 'e.g. AWP Joe', + // tela 04 · escolha do mapa + '04 · ESCOLHA DO MAPA': '04 · PICK THE MAP', + 'Categorias de mapa': 'Map categories', + 'Opções da partida': 'Match options', + 'Mapa anterior': 'Previous map', 'mapa anterior': 'previous map', + 'Próximo mapa': 'Next map', 'próximo mapa': 'next map', + 'Ver mapa em tela cheia': 'View map full screen', + 'Escolher o mapa da partida': 'Pick the match map', + 'Escolher as armas da partida': 'Pick the match weapons', + 'Clique pra trocar entre ROUNDS e CAPTURE THE FLAG': 'Click to switch between ROUNDS and CAPTURE THE FLAG', + // fichas dos mapas que faltavam (as antigas já estavam acima) + 'Galpão de atacado em guerra: gôndolas apertadas, caixas de cobertura e o estacionamento disputado carrinho por carrinho.': + 'A wholesale warehouse at war: tight aisles, crates for cover and a parking lot contested cart by cart.', + 'Um parque de diversões em guerra de confete: carrossel no centro, roda-gigante, castelo colorido e três rotas de ataque.': + 'An amusement park in a confetti war: carousel at the center, ferris wheel, colorful castle and three attack routes.', + 'Duelo na cidade empoeirada: saloon, banco, carroças e tumbleweeds cruzando três rotas entre casas de madeira.': + 'A duel in the dusty town: saloon, bank, wagons and tumbleweeds crossing three routes between wooden houses.', + 'Rebelião no pátio: celas abertas, concreto gasto, guaritas e barricadas policiais entre três rotas de confronto.': + 'Yard riot: open cells, worn concrete, watchtowers and police barricades across three routes.', + 'Pronto-socorro lotado: salas de verdade, corredor em cruz e treta no fluorescente — 100% interno.': + 'A packed emergency room: real wards, a cross-shaped corridor and a fight under fluorescent light — fully indoors.', + 'Canteiro de obra eterna: terreno ondulado, buracos de escavação, tapumes e a treta do desvio de verba.': + 'An eternal construction site: uneven ground, dig pits, hoardings and the fight over the missing budget.', + // personagem / config / como jogar + 'personagem anterior': 'previous character', 'próximo personagem': 'next character', + 'Categorias de configuração': 'Settings categories', + 'Configurações': 'Settings', + 'Escolha sua região': 'Choose your region', + 'Liga/desliga as falas (memes)': 'Toggle the meme voice lines', + 'FECHAR': 'CLOSE', 'JOGABILIDADE': 'GAMEPLAY', + 'QUALIDADE GRÁFICA': 'GRAPHICS QUALITY', + 'SENSIBILIDADE DO MOUSE': 'MOUSE SENSITIVITY', + 'LIGADO': 'ON', 'DESLIGADO': 'OFF', + 'VOLUME GERAL': 'MASTER VOLUME', + 'FALAS DOS MEMES': 'MEME VOICE LINES', + 'COR DA MIRA': 'CROSSHAIR COLOR', + 'AJUDAR A TREINAR OS BOTS': 'HELP TRAIN THE BOTS', + '“Padrão ouro” liga sombras e neblina. Em notebook de reunião, escolha “Batata”.': + '“Gold standard” turns on shadows and fog. On a meeting laptop, pick “Potato”.', + 'PRÉVIA · FERRO VELHO DO ZÉ · PADRÃO OURO': 'PREVIEW · FERRO VELHO DO ZÉ · GOLD STANDARD', + 'RESTAURAR PADRÃO': 'RESTORE DEFAULTS', + 'APLICAR': 'APPLY', 'SALVAR': 'SAVE', + 'Estes stats ficam': 'These stats live', 'neste navegador': 'in this browser', + '. O ranking global está desligado enquanto o jogo está em alpha - volta quando o placar for confiável.': + '. The global leaderboard is off while the game is in alpha — it returns when the scoreboard is trustworthy.', + 'O CORO SOLTO ainda está em alpha. Se você curte a ideia, qualquer apoio ajuda a pagar servidor, domínio e as próximas melhorias.': + 'CORO SOLTO is still in alpha. If you like the idea, any support helps pay for the server, the domain and the next improvements.', + '🇧🇷 SOU DO BRASIL': '🇧🇷 I AM IN BRAZIL', + '🌐 FORA DO BRASIL': '🌐 OUTSIDE BRAZIL', + 'ABRIR PÁGINA DE APOIO': 'OPEN THE SUPPORT PAGE', + 'AWP / Pistola / Faca': 'AWP / Pistol / Knife', + 'com respawn: cada round dura 1:39 ou fecha quando um time chega no alvo de abates; o time com mais kills leva o round. Por padrão, vence quem ganhar 3 rounds (melhor de 5); o teto pode ser escolhido na tela de mapas.': + 'with respawn: each round lasts 1:39 or ends when a team hits the kill target; the team with more kills takes the round. By default the first to 3 rounds wins (best of 5); the cap can be chosen on the map screen.', + 'não tem cronômetro de round: a rodada acaba quando um time captura 3 bandeiras (ou domina todas de uma vez). Vence quem ganhar 2 rodadas (melhor de 3). A partida tem um limite de 8 minutos, que só aparece no HUD no último minuto.': + 'has no round clock: the round ends when a team captures 3 flags (or holds them all at once). First to 2 rounds wins (best of 3). The match has an 8-minute cap that only shows on the HUD in the final minute.', + // HUD / placar + 'PROTEGIDO': 'PROTECTED', + 'RECARREGANDO…': 'RELOADING…', + 'Z/X/V RÁDIO': 'Z/X/V RADIO', 'TAB PLACAR': 'TAB SCOREBOARD', 'M TROCAR DE TIME': 'M SWITCH TEAM', + 'CLIQUE PARA ATIVAR A MIRA': 'CLICK TO LOCK THE CROSSHAIR', + 'Se o navegador bloquear, clique de novo. Em iframe/preview, abra o jogo em aba própria.': + 'If the browser blocks it, click again. Inside an iframe/preview, open the game in its own tab.', + 'ELIMINADO': 'ELIMINATED', + 'CORO SOLTO - PLACAR': 'CORO SOLTO — SCOREBOARD', + 'SEGURAR PARA VER': 'HOLD TO VIEW', 'ATIVAR CURSOR': 'ENABLE CURSOR', + 'Armas e utilitários': 'Weapons and utilities', + 'Fumaça (tecla 4) · Frag (tecla 5)': 'Smoke (key 4) · Frag (key 5)', + // rodapé / links + 'Links do jogo': 'Game links', 'Menu principal': 'Main menu', 'Abrir seu perfil': 'Open your profile', + 'Discord do CORO SOLTO': 'CORO SOLTO Discord', 'Telegram do CORO SOLTO': 'CORO SOLTO Telegram', + 'Código no GitHub': 'Source on GitHub', diff --git a/scratchpad/gauntlet-r1-mapas-fauna.html b/scratchpad/gauntlet-r1-mapas-fauna.html new file mode 100644 index 000000000..c2b4afd54 --- /dev/null +++ b/scratchpad/gauntlet-r1-mapas-fauna.html @@ -0,0 +1,33 @@ +Gauntlet r1 — mapas + fauna + +

GAUNTLET r1 — tela de mapas em grade + fauna em 12/12 mapas

+

Métricas: cards 98px→275px (3:2) e 50px→228px (960) · paginação removida · ↑↓ pulam linha · fauna: GLB ok, animação ok, reação a tiro ok em 12/12 · check:fast 53/54 (vermelho pré-existente: changelog alpha.151)

+

Tela de mapas — 3:2 (1536×1024) — ANTES

+

Tela de mapas — 3:2 (1536×1024) — DEPOIS (grade 3 colunas, cards 275px vs 98px)

+

Tela de mapas — 960×640 — ANTES não capturado; DEPOIS (cards 228px, antes 50px)

+

Fauna · atacadao_treta · 1 · parado (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · atacadao_treta · 2 · apos · tiro (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · ferro_velho · 1 · parado (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · ferro_velho · 2 · apos · tiro (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · loja_h · 1 · parado (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · loja_h · 2 · apos · tiro (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · obras_prefeitura · 1 · parado (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · obras_prefeitura · 2 · apos · tiro (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · parque_treta · 1 · parado (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · parque_treta · 2 · apos · tiro (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · penitenciaria · 1 · parado (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · penitenciaria · 2 · apos · tiro (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · piscina_treta · 1 · parado (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · piscina_treta · 2 · apos · tiro (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · posto_treta · 1 · parado (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · posto_treta · 2 · apos · tiro (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · praca_poderes · 1 · parado (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · praca_poderes · 2 · apos · tiro (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · quebrada · 1 · parado (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · quebrada · 2 · apos · tiro (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · upa_24h · 1 · parado (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · upa_24h · 2 · apos · tiro (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · velho_oeste · 1 · parado (1=parado, 2=após tiro real via _fireHitscan)

+

Fauna · velho_oeste · 2 · apos · tiro (1=parado, 2=após tiro real via _fireHitscan)

\ No newline at end of file diff --git a/scratchpad/i18n-audit.mjs b/scratchpad/i18n-audit.mjs new file mode 100644 index 000000000..03bce6b7b --- /dev/null +++ b/scratchpad/i18n-audit.mjs @@ -0,0 +1,17 @@ +import fs from 'node:fs'; +const astro = fs.readFileSync('src/pages/index.astro','utf8'); +const i18n = fs.readFileSync('public/js/i18n.js','utf8'); +// chaves do DICT (linhas 'X': 'Y') +const keys = new Set(); +for (const m of i18n.matchAll(/'((?:[^'\\]|\\.)*)'\s*:/g)) keys.add(m[1].replace(/\\'/g,"'")); +// texto visível: remove frontmatter, script/style, comentários +let html = astro.replace(/^---[\s\S]*?---/,'').replace(//g,'').replace(//g,'').replace(//g,''); +const texts = []; +for (const m of html.matchAll(/>([^<>{}]+)x.replace(/\s+/g,' ').trim(); +const nk=new Set([...keys].map(N)); +const faltando = [...new Set(texts)].filter(t => !nk.has(N(t)) && !/^[\s\d\W]*$/.test(t)); +console.log(faltando.map(t=>JSON.stringify(t)).join('\n')); +console.log('\n--- total sem tradução:', faltando.length); diff --git a/scratchpad/map-screen.css b/scratchpad/map-screen.css new file mode 100644 index 000000000..b1b330714 --- /dev/null +++ b/scratchpad/map-screen.css @@ -0,0 +1,98 @@ +/* ============ MAP SCREEN ============ */ +/* Redesign de 21/08: DUAS COLUNAS. À esquerda a ficha do mapa (texto menor e com mais + respiro do que o cartaz antigo — o dono reclamou que o nome de 72px comia a tela); + à direita o acervo inteiro rolando na VERTICAL, só miniaturas. A preview em tela + cheia saiu: o fundo agora é o preto do jogo. `.ms-bg` continua no DOM (o render + escreve o src e a sonda de tela lê) mas não pinta nada. */ +#map-screen{padding:0;justify-content:flex-start;align-items:stretch;gap:0;background:#08080a; + font-family:var(--aaa-font-body);display:grid;grid-template-rows:auto minmax(0,1fr)} +.ms-bg{display:none} +.ms-bg img{display:none} +.ms-scrim{position:absolute;inset:0;pointer-events:none;z-index:0; + background:radial-gradient(120% 90% at 12% 8%,rgba(180,217,46,.05),transparent 58%),repeating-linear-gradient(0deg,transparent 0 3px,rgba(0,0,0,.05) 3px 4px)} +.ms-topbar{position:relative;z-index:2;padding:24px 40px 14px; + display:flex;flex-direction:column;align-items:flex-start;gap:12px; + border-bottom:1px solid rgba(236,235,230,.08)} +.ms-kicker{font-family:var(--aaa-font-body);font-weight:600;font-size:12px;letter-spacing:4px; + color:var(--br-faixa);text-transform:uppercase} +.ms-tabs{display:flex;gap:24px;flex-wrap:wrap} +.ms-tab{padding:6px 2px;font:600 14px/1 var(--aaa-font-body);letter-spacing:3px;color:#5c5d63; + background:transparent;border:0;border-bottom:2px solid transparent;cursor:pointer} +.ms-tab:hover,.ms-tab:focus-visible{color:#ecebe6} +.ms-tab.on{font-weight:700;color:#b4d92e;border-bottom-color:#b4d92e} +.ms-cat-desc{max-width:640px;margin:0;font:400 13px/1.5 var(--aaa-font-body);letter-spacing:.4px;color:#8a8b91} +.ms-authors{display:flex;gap:8px;flex-wrap:wrap} +.ms-author{padding:5px 12px;font:600 11px/1 var(--aaa-font-body);letter-spacing:2px;color:#5c5d63;background:rgba(16,16,20,.7);border:1px solid #2a2b31;border-radius:999px;cursor:pointer;text-transform:uppercase} +.ms-author:hover,.ms-author:focus-visible{color:#ecebe6} +.ms-author.on{color:#2e9ed8;border-color:#2e9ed8} +/* as duas colunas: ficha fluida até 560px, acervo numa faixa fixa que rola */ +.ms-body{position:relative;z-index:2;min-height:0;display:grid; + grid-template-columns:minmax(0,1fr) minmax(300px,420px);gap:40px;padding:26px 40px 28px} +.ms-head{grid-column:1;min-width:0;max-width:560px; + display:flex;flex-direction:column;align-items:flex-start;gap:20px;text-align:left} +.ms-tagrow{display:flex;align-items:center;justify-content:flex-start;gap:10px} +.ms-cat{font-size:11px;font-weight:700;letter-spacing:3px;padding:3px 10px;border:1px solid} +.ms-cat[data-cat="FAVELA"]{color:#e0762a;border-color:rgba(224,118,42,.5)} +.ms-cat[data-cat="ARENA"]{color:#8b8c92;border-color:rgba(139,140,146,.5)} +.ms-cat[data-cat="CIDADES"]{color:#8258d8;border-color:rgba(130,88,216,.5)}.ms-cat[data-cat="COMUNIDADE"]{color:#2e9ed8;border-color:rgba(46,158,216,.5)} +.ms-cat[data-cat="AI"]{color:#d8c02e;border-color:rgba(216,192,46,.5)} +.ms-byline{margin:0;font:400 12px/1.4 var(--aaa-font-body);letter-spacing:.6px;color:#8a8b91} +.ms-byline strong{color:#b9babf;font-weight:600} +.ms-badge-oficial{display:inline-block;margin-left:8px;padding:2px 8px;font:700 10px/1 var(--aaa-font-body);letter-spacing:2px;color:#b4d92e;border:1px solid rgba(180,217,46,.5);border-radius:3px;text-transform:uppercase} +.ms-badge-comunidade{display:inline-block;margin-left:8px;padding:2px 8px;font:700 10px/1 var(--aaa-font-body);letter-spacing:2px;color:#2e9ed8;border:1px solid rgba(46,158,216,.5);border-radius:3px;text-transform:uppercase} +.ms-count{font-family:var(--font);font-weight:600;font-size:11px;letter-spacing:2px;color:#5c5d63} +.ms-name{margin:0;font-family:var(--aaa-font-display);font-weight:400;font-size:44px; + line-height:1;letter-spacing:1px;text-transform:uppercase;color:var(--ink-100); + text-shadow:2px 2px 0 rgba(0,0,0,.5)} +.ms-meta{margin:0;font-family:var(--font);font-weight:700;font-size:12px;letter-spacing:2.4px;color:var(--ink-100)} +.ms-meta .ms-sep{color:var(--br-faixa);margin:0 8px} +.ms-desc{margin:0;font-size:14px;font-weight:400;color:#b9b9be;max-width:46ch;line-height:1.7} +.ms-match-options{display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px;margin:0;padding:12px 14px; + background:rgba(16,16,20,.8);border-left:2px solid var(--br-faixa)} +.ms-match-options label{display:flex;flex-direction:column;gap:6px;min-width:112px; + font:700 10px/1 var(--aaa-font-body);letter-spacing:2px;color:#8b8c92;text-transform:uppercase} +.ms-match-options select{height:32px;padding:0 28px 0 9px;border:1px solid rgba(236,235,230,.2);border-radius:0; + background:#111216;color:#ecebe6;font:700 12px/1 var(--aaa-font-body);letter-spacing:1px;cursor:pointer} +.ms-match-options select:focus{outline:1px solid var(--br-faixa);border-color:var(--br-faixa)} +/* VOLTAR e CONTINUAR moram no pé da ficha — não são mais cantos soltos do palco */ +.ms-foot{display:flex;align-items:center;gap:18px;margin-top:auto;padding-top:6px} +/* ---- coluna 2: o acervo em pé ---- */ +.ms-carousel{grid-column:2;min-height:0;display:flex;flex-direction:column;gap:8px} +.ms-arrow{flex:0 0 auto;width:100%;height:26px;display:flex;align-items:center;justify-content:center; + background:rgba(13,14,17,.7);border:1px solid #26272d;color:#9a9ba1;font-size:12px;line-height:1;cursor:pointer} +.ms-arrow:hover,.ms-arrow:focus-visible{border-color:#b4d92e;color:#ecebe6;background:rgba(13,14,17,.9)} +.ms-viewport{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;scrollbar-width:thin;scrollbar-color:#33343b transparent} +.ms-viewport::-webkit-scrollbar{width:6px} +.ms-viewport::-webkit-scrollbar-thumb{background:#33343b} +.ms-strip{--map-count:1;display:grid;grid-template-columns:minmax(0,1fr);justify-content:stretch;gap:10px;width:100%;scroll-behavior:smooth} +.ms-thumb{position:relative;width:100%;min-width:0;padding:0;display:flex;flex-direction:column;cursor:pointer; + background:#101014;border:1px solid rgba(236,235,230,.12);opacity:.75; + font-family:var(--aaa-font-body);text-align:left;text-transform:uppercase; + transition:border-color var(--t) var(--ease),filter var(--t) var(--ease),opacity var(--t) var(--ease)} +.ms-thumb:hover,.ms-thumb:focus-visible{filter:brightness(1.15);opacity:1} +.ms-thumb.on{border:2px solid #b4d92e;opacity:1;box-shadow:0 0 26px rgba(180,217,46,.3)} +.ms-thumb-img{display:block;width:100%;height:96px;object-fit:cover} +.ms-thumb-copy{display:flex;flex-direction:column;gap:1px;padding:7px 10px 9px;background:rgba(10,10,12,.85)} +.ms-thumb-name{font-weight:700;font-size:12px;letter-spacing:2px;color:#c9c9cc; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ms-thumb.on .ms-thumb-name{color:#b4d92e} +.ms-thumb-cat{font-weight:600;font-size:9px;letter-spacing:2px} +.ms-thumb-cat[data-cat="FAVELA"]{color:#e0762a} +.ms-thumb-cat[data-cat="ARENA"]{color:#8b8c92} +.ms-thumb-cat[data-cat="CIDADES"]{color:#8258d8} +.ms-diamond{position:absolute;top:6px;right:6px;width:8px;height:8px;background:var(--br-faixa);transform:rotate(45deg)} +.ms-dashes{flex:0 0 auto;display:flex;justify-content:center;gap:6px} +.ms-dashes i{width:18px;height:3px;background:#33343b}.ms-dashes i.on{background:#b4d92e} +.ms-actions{position:static} +#map-screen .ms-actions .btn-big{transform:skewX(-8deg);background:linear-gradient(180deg,#cfe84a,#b4d92e);padding:12px 40px;border:0} +#map-screen .ms-actions .btn-big>*{display:block;transform:skewX(8deg);font:400 20px/1 var(--aaa-font-display);letter-spacing:3px;color:#131313;padding:0} +@media(max-height:720px){ + .ms-body{gap:28px;padding:18px 32px 20px} + .ms-head{gap:14px} + .ms-name{font-size:36px} + .ms-desc{font-size:13px;line-height:1.6} + .ms-match-options{padding:9px 11px}.ms-match-options select{height:28px} +} +@media(max-width:1040px){ + .ms-body{grid-template-columns:minmax(0,1fr) minmax(240px,300px);gap:24px} +} diff --git a/scratchpad/map-screen.html b/scratchpad/map-screen.html new file mode 100644 index 000000000..f535320b0 --- /dev/null +++ b/scratchpad/map-screen.html @@ -0,0 +1,65 @@ + diff --git a/scratchpad/map-screen2.css b/scratchpad/map-screen2.css new file mode 100644 index 000000000..1daecc1cc --- /dev/null +++ b/scratchpad/map-screen2.css @@ -0,0 +1,114 @@ +/* ============ MAP SCREEN ============ */ +/* Redesign de 21/08, 3ª rodada: a tela é CABEÇALHO + ACERVO + RODAPÉ, em faixas. + O cabeçalho carrega tudo que não é lista (abas, ficha do mapa em foco, opções da + partida) e o corpo inteiro fica para a grade de mapas — que é o que a tela existe + para mostrar. O palco é o WALLPAPER do setup: a foto do mapa em foco já está no card + selecionado, e repetida em tela cheia ela brigava com a grade. */ +#map-screen{padding:0;gap:0;background:#08080a;font-family:var(--aaa-font-body); + display:grid;grid-template-rows:auto minmax(0,1fr) auto;grid-template-columns:minmax(0,1fr); + justify-content:stretch;align-items:stretch;justify-items:stretch} +/* mesmo tratamento do .cs-wallpaper do menu: borrão cobrindo a tela + arte inteira por cima */ +.ms-bg{position:absolute;inset:0;overflow:hidden;border:0;background:var(--bg-900);z-index:0;isolation:isolate} +.ms-bg::before,.ms-bg::after{content:"";position:absolute;pointer-events:none; + background-image:var(--wall);background-position:center;background-repeat:no-repeat} +.ms-bg::before{inset:-24px;background-size:cover;filter:blur(20px) brightness(.4) saturate(.7)} +.ms-bg::after{inset:0;background-size:cover;opacity:.5} +.ms-bg img{display:none} /* o `src` serve à sonda de tela, não ao olho */ +.ms-scrim{position:absolute;inset:0;pointer-events:none;z-index:1; + background:linear-gradient(90deg,rgba(8,8,10,.9) 0%,rgba(8,8,10,.72) 46%,rgba(8,8,10,.66) 100%),linear-gradient(0deg,rgba(8,8,10,.92) 0%,rgba(8,8,10,.6) 30%,rgba(8,8,10,.88) 100%),repeating-linear-gradient(0deg,transparent 0 3px,rgba(0,0,0,.05) 3px 4px)} +/* ---- faixa 1: cabeçalho ---- */ +.ms-topbar{position:relative;z-index:2;padding:20px 40px 16px; + display:flex;flex-direction:column;align-items:stretch;gap:10px; + border-bottom:1px solid rgba(236,235,230,.1)} +.ms-kicker{font-family:var(--aaa-font-body);font-weight:600;font-size:12px;letter-spacing:4px; + color:var(--br-faixa);text-transform:uppercase} +.ms-tabs{display:flex;gap:24px;flex-wrap:wrap} +.ms-tab{padding:6px 2px;font:600 14px/1 var(--aaa-font-body);letter-spacing:3px;color:#5c5d63; + background:transparent;border:0;border-bottom:2px solid transparent;cursor:pointer} +.ms-tab:hover,.ms-tab:focus-visible{color:#ecebe6} +.ms-tab.on{font-weight:700;color:#b4d92e;border-bottom-color:#b4d92e} +.ms-cat-desc{max-width:640px;margin:0;font:400 13px/1.5 var(--aaa-font-body);letter-spacing:.4px;color:#8a8b91} +.ms-author{padding:5px 12px;font:600 11px/1 var(--aaa-font-body);letter-spacing:2px;color:#5c5d63;background:rgba(16,16,20,.7);border:1px solid #2a2b31;border-radius:999px;cursor:pointer;text-transform:uppercase} +.ms-author:hover,.ms-author:focus-visible{color:#ecebe6} +.ms-author.on{color:#2e9ed8;border-color:#2e9ed8} +/* ficha e opções dividem a linha do cabeçalho: nome/descrição à esquerda, controles à direita */ +.ms-head{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:20px 40px;margin-top:4px} +.ms-ficha{min-width:0;flex:1 1 460px;display:flex;flex-direction:column;align-items:flex-start;gap:8px;text-align:left} +.ms-tagrow{display:flex;align-items:center;justify-content:flex-start;gap:10px;flex-wrap:wrap} +.ms-cat{font-size:11px;font-weight:700;letter-spacing:3px;padding:3px 10px;border:1px solid} +.ms-cat[data-cat="FAVELA"]{color:#e0762a;border-color:rgba(224,118,42,.5)} +.ms-cat[data-cat="ARENA"]{color:#8b8c92;border-color:rgba(139,140,146,.5)} +.ms-cat[data-cat="CIDADES"]{color:#8258d8;border-color:rgba(130,88,216,.5)}.ms-cat[data-cat="COMUNIDADE"]{color:#2e9ed8;border-color:rgba(46,158,216,.5)} +.ms-cat[data-cat="AI"]{color:#d8c02e;border-color:rgba(216,192,46,.5)} +/* o crachá de partidas jogadas — só existe quando o contador respondeu */ +.ms-plays{font-family:var(--font);font-weight:700;font-size:11px;letter-spacing:2px;color:#b4d92e} +.ms-plays[hidden]{display:none} +.ms-byline{margin:0;font:400 12px/1.4 var(--aaa-font-body);letter-spacing:.6px;color:#8a8b91} +.ms-byline strong{color:#b9babf;font-weight:600} +.ms-badge-oficial{display:inline-block;margin-left:8px;padding:2px 8px;font:700 10px/1 var(--aaa-font-body);letter-spacing:2px;color:#b4d92e;border:1px solid rgba(180,217,46,.5);border-radius:3px;text-transform:uppercase} +.ms-badge-comunidade{display:inline-block;margin-left:8px;padding:2px 8px;font:700 10px/1 var(--aaa-font-body);letter-spacing:2px;color:#2e9ed8;border:1px solid rgba(46,158,216,.5);border-radius:3px;text-transform:uppercase} +.ms-count{font-family:var(--font);font-weight:600;font-size:11px;letter-spacing:2px;color:#5c5d63} +.ms-name{margin:0;font-family:var(--aaa-font-display);font-weight:400;font-size:40px; + line-height:1;letter-spacing:1px;text-transform:uppercase;color:var(--ink-100); + text-shadow:2px 2px 0 rgba(0,0,0,.5)} +.ms-meta{margin:0;font-family:var(--font);font-weight:700;font-size:12px;letter-spacing:2.4px;color:var(--ink-100)} +.ms-meta .ms-sep{color:var(--br-faixa);margin:0 8px} +.ms-desc{margin:0;font-size:13px;font-weight:400;color:#b9b9be;max-width:62ch;line-height:1.7} +.ms-match-options{display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px;margin:0;padding:12px 14px; + background:rgba(16,16,20,.8);border-left:2px solid var(--br-faixa)} +.ms-match-options label{display:flex;flex-direction:column;gap:6px;min-width:112px; + font:700 10px/1 var(--aaa-font-body);letter-spacing:2px;color:#8b8c92;text-transform:uppercase} +.ms-match-options select{height:32px;padding:0 28px 0 9px;border:1px solid rgba(236,235,230,.2);border-radius:0; + background:#111216;color:#ecebe6;font:700 12px/1 var(--aaa-font-body);letter-spacing:1px;cursor:pointer} +.ms-match-options select:focus{outline:1px solid var(--br-faixa);border-color:var(--br-faixa)} +/* ---- faixa 2: o acervo, dono do corpo ---- */ +.ms-body{position:relative;z-index:2;min-height:0;display:grid;padding:18px 40px 10px} +.ms-carousel{min-height:0;display:flex;flex-direction:column;gap:8px} +.ms-arrow{flex:0 0 auto;width:100%;height:26px;display:flex;align-items:center;justify-content:center; + background:rgba(13,14,17,.7);border:1px solid #26272d;color:#9a9ba1;font-size:12px;line-height:1;cursor:pointer} +.ms-arrow:hover,.ms-arrow:focus-visible{border-color:#b4d92e;color:#ecebe6;background:rgba(13,14,17,.9)} +.ms-viewport{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;scrollbar-width:thin;scrollbar-color:#33343b transparent} +.ms-viewport::-webkit-scrollbar{width:6px} +.ms-viewport::-webkit-scrollbar-thumb{background:#33343b} +/* a grade cresce com a tela: `auto-fill` decide o número de colunas pela largura real, + e o teto de 3 vem do max-width da faixa — card largo demais deixa de ser quadrado. */ +.ms-strip{--map-count:1;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));justify-content:stretch;gap:14px;width:100%;max-width:1320px;margin:0 auto;scroll-behavior:smooth} +.ms-thumb{position:relative;width:100%;min-width:0;padding:0;display:flex;flex-direction:column;cursor:pointer; + background:rgba(16,16,20,.86);border:1px solid rgba(236,235,230,.12);opacity:.8; + font-family:var(--aaa-font-body);text-align:left;text-transform:uppercase; + transition:border-color var(--t) var(--ease),filter var(--t) var(--ease),opacity var(--t) var(--ease)} +.ms-thumb:hover,.ms-thumb:focus-visible{filter:brightness(1.15);opacity:1} +.ms-thumb.on{border:2px solid #b4d92e;opacity:1;box-shadow:0 0 26px rgba(180,217,46,.3)} +.ms-thumb-img{display:block;width:100%;height:auto;aspect-ratio:4/3;object-fit:cover} +.ms-thumb-copy{display:flex;flex-direction:column;gap:3px;padding:8px 11px 10px;background:rgba(10,10,12,.85)} +.ms-thumb-name{font-weight:700;font-size:12px;letter-spacing:2px;color:#c9c9cc; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ms-thumb.on .ms-thumb-name{color:#b4d92e} +.ms-thumb-sub{display:flex;align-items:center;justify-content:space-between;gap:10px} +.ms-thumb-cat{font-weight:600;font-size:9px;letter-spacing:2px} +.ms-thumb-cat[data-cat="FAVELA"]{color:#e0762a} +.ms-thumb-cat[data-cat="ARENA"]{color:#8b8c92} +.ms-thumb-cat[data-cat="CIDADES"]{color:#8258d8} +/* partidas jogadas no card: número seco, sem rótulo — a coluna inteira é a mesma unidade */ +.ms-thumb-plays{font-family:var(--font);font-weight:700;font-size:10px;letter-spacing:1.5px;color:#7d8a52} +.ms-thumb.on .ms-thumb-plays{color:#b4d92e} +.ms-diamond{position:absolute;top:6px;right:6px;width:8px;height:8px;background:var(--br-faixa);transform:rotate(45deg)} +.ms-dashes{flex:0 0 auto;display:flex;justify-content:center;gap:6px} +.ms-dashes i{width:18px;height:3px;background:#33343b}.ms-dashes i.on{background:#b4d92e} +/* ---- faixa 3: rodapé, VOLTAR num canto e CONTINUAR no outro ---- */ +.ms-foot{position:relative;z-index:2;display:flex;align-items:center;justify-content:space-between; + gap:18px;padding:14px 40px 22px} +.ms-actions{position:static} +#map-screen .ms-actions .btn-big{transform:skewX(-8deg);background:linear-gradient(180deg,#cfe84a,#b4d92e);padding:12px 40px;border:0} +#map-screen .ms-actions .btn-big>*{display:block;transform:skewX(8deg);font:400 20px/1 var(--aaa-font-display);letter-spacing:3px;color:#131313;padding:0} +@media(max-height:780px){ + .ms-topbar{padding:14px 32px 12px;gap:8px} + .ms-body{padding:12px 32px 8px} + .ms-name{font-size:30px} + .ms-desc{display:none} + .ms-match-options{padding:9px 11px}.ms-match-options select{height:28px} + .ms-foot{padding:10px 32px 14px} +} +@media(max-width:820px){ + .ms-strip{grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px} +} diff --git a/scratchpad/map-screen2.html b/scratchpad/map-screen2.html new file mode 100644 index 000000000..70996f1c2 --- /dev/null +++ b/scratchpad/map-screen2.html @@ -0,0 +1,66 @@ + diff --git a/scratchpad/mapshot.mjs b/scratchpad/mapshot.mjs new file mode 100644 index 000000000..81515c2e6 --- /dev/null +++ b/scratchpad/mapshot.mjs @@ -0,0 +1,33 @@ +import { execSync } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +const OUT = process.env.OUT || 'scratchpad/shots'; +const BASE = process.env.BASE || 'http://127.0.0.1:4323'; +const gRoot = execSync('npm root -g').toString().trim(); +const _pw = await import(pathToFileURL(`${gRoot}/playwright/index.js`).href); +const chromium = _pw.chromium || _pw.default?.chromium; +mkdirSync(OUT, { recursive: true }); +const browser = await chromium.launch({ + executablePath: process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--headless=new', '--mute-audio'], +}); +for (const lang of ['pt', 'en']) { + const page = await browser.newPage({ viewport: { width: 1536, height: 864 } }); + page.on('pageerror', e => console.error('[pageerror]', lang, e.message.slice(0, 200))); + await page.addInitScript(() => localStorage.setItem('awpbr_nick', 'ZÉ DO AWP')); + await page.goto(`${BASE}/?debug=1&lang=${lang}`, { waitUntil: 'domcontentloaded' }); + await page.click('#boot-splash').catch(() => {}); + await page.waitForSelector('#main-menu:not(.hidden)', { timeout: 30000 }); + await page.waitForTimeout(1500); + await page.evaluate(() => document.getElementById('map-thumb')?.click()); + await page.waitForSelector('#map-screen:not(.hidden)', { timeout: 10000 }); + await page.waitForTimeout(1200); + await page.screenshot({ path: `${OUT}/map_${lang}.png` }); + // aba COMUNIDADE, que é a do print do dono + await page.click('.ms-tab[data-cat="COMUNIDADE"]'); + await page.waitForTimeout(900); + await page.screenshot({ path: `${OUT}/map_${lang}_comunidade.png` }); + console.log('shot', lang); + await page.close(); +} +await browser.close(); diff --git a/scratchpad/preview.log b/scratchpad/preview.log new file mode 100644 index 000000000..4342e150b --- /dev/null +++ b/scratchpad/preview.log @@ -0,0 +1,33 @@ + +> coro-solto@2.0.0-alpha.167 preview +> python3 -m http.server ${PORT:-4321} -d dist/client + +Traceback (most recent call last): + File "", line 198, in _run_module_as_main + File "", line 88, in _run_code + File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/http/server.py", line 1320, in + test( + ~~~~^ + HandlerClass=handler_class, + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ...<3 lines>... + protocol=args.protocol, + ^^^^^^^^^^^^^^^^^^^^^^^ + ) + ^ + File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/http/server.py", line 1267, in test + with ServerClass(addr, HandlerClass) as httpd: + ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/socketserver.py", line 457, in __init__ + self.server_bind() + ~~~~~~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/http/server.py", line 1314, in server_bind + return super().server_bind() + ~~~~~~~~~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/http/server.py", line 136, in server_bind + socketserver.TCPServer.server_bind(self) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/socketserver.py", line 473, in server_bind + self.socket.bind(self.server_address) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^ +OSError: [Errno 48] Address already in use diff --git a/scratchpad/probe.mjs b/scratchpad/probe.mjs new file mode 100644 index 000000000..ea1fea127 --- /dev/null +++ b/scratchpad/probe.mjs @@ -0,0 +1,19 @@ +import { execSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +const gRoot = execSync('npm root -g').toString().trim(); +const _pw = await import(pathToFileURL(`${gRoot}/playwright/index.js`).href); +const chromium = _pw.chromium || _pw.default?.chromium; +const b = await chromium.launch({ executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', args:['--headless=new','--use-angle=swiftshader','--enable-unsafe-swiftshader','--mute-audio'] }); +const p = await b.newPage({ viewport:{width:1536,height:864} }); +await p.goto('http://localhost:4323/?debug=1', { waitUntil:'domcontentloaded' }); +await p.click('#boot-splash').catch(()=>{}); +await p.waitForSelector('#main-menu:not(.hidden)', {timeout:30000}); +await p.evaluate(()=>document.getElementById('map-thumb')?.click()); +await p.waitForSelector('#map-screen:not(.hidden)'); +await p.waitForTimeout(800); +console.log(await p.evaluate(()=>{ + const el=document.elementFromPoint(768,858); + const path=[]; let n=el; while(n&&n!==document.body){path.push(n.tagName+(n.id?'#'+n.id:'')+(n.className&&typeof n.className==='string'?'.'+n.className.split(' ').join('.'):'')); n=n.parentElement;} + return path.join(' < '); +})); +await b.close(); diff --git a/scratchpad/shots/map_en.png b/scratchpad/shots/map_en.png new file mode 100644 index 000000000..e4c21fbc7 Binary files /dev/null and b/scratchpad/shots/map_en.png differ diff --git a/scratchpad/shots/map_en_comunidade.png b/scratchpad/shots/map_en_comunidade.png new file mode 100644 index 000000000..8bbc84f9a Binary files /dev/null and b/scratchpad/shots/map_en_comunidade.png differ diff --git a/scratchpad/shots/map_pt.png b/scratchpad/shots/map_pt.png new file mode 100644 index 000000000..8ca2c8375 Binary files /dev/null and b/scratchpad/shots/map_pt.png differ diff --git a/scratchpad/shots/map_pt_comunidade.png b/scratchpad/shots/map_pt_comunidade.png new file mode 100644 index 000000000..b8d55db67 Binary files /dev/null and b/scratchpad/shots/map_pt_comunidade.png differ diff --git a/scratchpad/shots/thumbs_fim.png b/scratchpad/shots/thumbs_fim.png new file mode 100644 index 000000000..832d568c3 Binary files /dev/null and b/scratchpad/shots/thumbs_fim.png differ diff --git a/scratchpad/thumbshot.mjs b/scratchpad/thumbshot.mjs new file mode 100644 index 000000000..9ef5f018d --- /dev/null +++ b/scratchpad/thumbshot.mjs @@ -0,0 +1,22 @@ +import { execSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +const gRoot = execSync('npm root -g').toString().trim(); +const _pw = await import(pathToFileURL(`${gRoot}/playwright/index.js`).href); +const chromium = _pw.chromium || _pw.default?.chromium; +const b = await chromium.launch({ executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + args:['--use-angle=swiftshader','--enable-unsafe-swiftshader','--headless=new','--mute-audio'] }); +const p = await b.newPage({ viewport:{width:1536,height:864} }); +await p.addInitScript(() => localStorage.setItem('awpbr_nick','ZÉ DO AWP')); +await p.goto('http://localhost:4323/?debug=1', { waitUntil:'domcontentloaded' }); +await p.click('#boot-splash').catch(()=>{}); +await p.waitForSelector('#main-menu:not(.hidden)', { timeout:30000 }); +await p.waitForTimeout(1200); +await p.evaluate(()=>document.getElementById('map-thumb')?.click()); +await p.waitForSelector('#map-screen:not(.hidden)'); +await p.click('.ms-tab[data-cat="COMUNIDADE"]'); +await p.waitForTimeout(800); +await p.evaluate(()=>{ const v=document.querySelector('.ms-viewport'); v.scrollTop = v.scrollHeight; }); +await p.waitForTimeout(1200); +await p.screenshot({ path:'scratchpad/shots/thumbs_fim.png' }); +console.log('ok'); +await b.close(); diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index a60f4026f..e045a4509 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -37,6 +37,7 @@ // plans/08 §1.1). O canarinho do topo sai pelo WebP animado de 96 KB, não pelo // GIF de 368 KB - mesmo conteúdo, 24 quadros, 26% do peso. import { SITE, BRAND, BRAND_FULL, BRAND_ALT, DESC_SHORT, ORG_ID, WEBSITE_ID, abs, RANKING_ON, DISCORD_URL, TELEGRAM_URL, GITHUB_URL } from '../lib/site'; +import { PT_EN_PAIRS } from '../lib/i18n-pairs'; import pkg from '../../package.json'; const V = pkg.version; @@ -105,20 +106,12 @@ const temSub = Astro.slots.has('sub'); const path = Astro.url.pathname.replace(/\/+$/, '') || '/'; const canonical = new URL(path, Astro.site ?? SITE).toString(); -// Tabela fixa porque cada rota EN é uma página própria, não um prefixo de idioma. -// Alimenta o hreflang recíproco e o link PT/EN estático do rodapé (a troca por JS -// não é descoberta por crawl). -const PT_EN_PAIRS: [string, string][] = [ - ['/como-jogar', '/how-to-play'], - ['/personagens', '/characters'], - ['/mapas', '/maps'], - ['/armas', '/weapons'], - ['/sobre', '/about'], -]; -const ptToEn = new Map(PT_EN_PAIRS); -const enToPt = new Map(PT_EN_PAIRS.map(([pt, en]) => [en, pt])); +const ptToEn = new Map(PT_EN_PAIRS.map(([pt, en]) => [pt, en])); +const enToPt = new Map(PT_EN_PAIRS.map(([pt, en]) => [en, pt])); const pairedEn = ptToEn.get(path); const pairedPt = enToPt.get(path); +const pageLang = pairedPt ? 'en' : 'pt-BR'; +const ogLocale = pageLang === 'en' ? 'en_US' : 'pt_BR'; const hreflangPt = pairedPt ? new URL(pairedPt, Astro.site ?? SITE).toString() : canonical; const hreflangEn = pairedEn ? new URL(pairedEn, Astro.site ?? SITE).toString() : pairedPt ? canonical : null; @@ -164,13 +157,19 @@ const pageGraph = hasOwnPageNode ? [] : [{ url: canonical, name: title, description, - inLanguage: 'pt-BR', + inLanguage: pageLang, isPartOf: { '@id': WEBSITE_ID }, primaryImageOfPage: { '@type': 'ImageObject', url: ogImage, width: ogImageWidth, height: ogImageHeight, }, }]; +const jsonldLang = (jsonld as { '@type'?: string | string[]; inLanguage?: string }[]).map((n) => { + const t = n?.['@type']; + const types = Array.isArray(t) ? t : t ? [t] : []; + if (types.some((x) => PAGE_NODE_TYPES.has(x)) && !n.inLanguage) return { ...n, inLanguage: pageLang }; + return n; +}); // O /ranking sai do nav enquanto RANKING_ON for false (src/lib/site.ts). A página // continua respondendo - só deixa de ser oferecida, pra ninguém clicar num aviso. @@ -185,7 +184,7 @@ const NAV: [string, string][] = [ ]; --- - + @@ -211,7 +210,8 @@ const NAV: [string, string][] = [ - + +{hreflangEn && } @@ -235,7 +235,7 @@ const NAV: [string, string][] = [ - + + diff --git a/src/pages/sitemap.xml.ts b/src/pages/sitemap.xml.ts index f066caa8f..b66c06bac 100644 --- a/src/pages/sitemap.xml.ts +++ b/src/pages/sitemap.xml.ts @@ -29,6 +29,7 @@ import type { APIRoute } from 'astro'; import { supabaseAdmin } from '../lib/supabase'; import { SITE, RANKING_ON } from '../lib/site'; +import { PT_EN_PAIRS } from '../lib/i18n-pairs'; import { FACCOES } from '../data/jogo'; import { POR_PAGINA, numeroDePaginas, offsetDaPagina, xmlUrlset, xmlIndex, @@ -61,6 +62,7 @@ export const STATIC: [string, string, string][] = [ ['/sobre', '0.7', 'monthly'], ['/mapa', '0.6', 'daily'], ['/changelog', '0.5', 'weekly'], + ...PT_EN_PAIRS.map(([, en]) => [en, '0.5', 'weekly'] as [string, string, string]), ]; export const GET: APIRoute = async () => { diff --git a/src/pages/whats-new.astro b/src/pages/whats-new.astro new file mode 100644 index 000000000..df6d063ea --- /dev/null +++ b/src/pages/whats-new.astro @@ -0,0 +1,160 @@ +--- +// /whats-new - gêmea EM INGLÊS do /changelog (issue #54): mesmo CHANGELOG.md e mesmo +// renderizador, só o cromo traduzido. As notas seguem em PT - traduzi-las é outra frente. +import Layout from '../layouts/Layout.astro'; +import { BRAND_FULL, GAME_ID, abs } from '../lib/site'; +import { parseChangelog } from '../lib/changelog'; + +/* Mesmo `?raw` do /changelog: o prerender mora em dist/server e um readFileSync + por import.meta.url quebraria o build (BUG documentado na irmã PT). */ +import md from '../../CHANGELOG.md?raw'; + +const { versoes, recentes, atualVer } = parseChangelog(md); + +const jsonld = [ + { + '@type': 'ItemList', + '@id': abs('/whats-new') + '#lista', + name: `${BRAND_FULL} version history`, + numberOfItems: recentes.length, + itemListElement: recentes.map((v, i) => ({ + '@type': 'ListItem', position: i + 1, name: `v${v.versao}`, + })), + }, + { + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: abs('/') }, + { '@type': 'ListItem', position: 2, name: 'Changelog', item: abs('/whats-new') }, + ], + }, + { '@id': GAME_ID, softwareVersion: atualVer }, +]; +--- + + + Everything that changed, version by version. Production is v{atualVer}. + The full file lives in CHANGELOG.md in the repository - this page is that + file, rendered. + + +
+ + {recentes.map((v, i) => ( +
+ + v{v.versao} + {v.data && · {v.data}} + # + +
+
+ ))} + + {versoes.length > recentes.length && ( +

+ Versions older than v{recentes[recentes.length - 1].versao} live in + CHANGELOG.md. +

+ )} +
+ + + + + + diff --git a/tools/eval/ambience-registry-check.mjs b/tools/eval/ambience-registry-check.mjs new file mode 100644 index 000000000..a51134a08 --- /dev/null +++ b/tools/eval/ambience-registry-check.mjs @@ -0,0 +1,84 @@ +/* ambience-registry-check.mjs — TODO MAPA DO REGISTRO TEM VIDA, não só o lajes. + * + * POR QUE EXISTE — pedido do dono, 17/08 (BUG-57), com estas palavras: + * "ele [lajes] tem ambiencia real coisa que nenhum dos outros mapas tem, + * horizonte, animais, animacoes no ceu, precismoa disso em todos os mapas" + * A régua irmã (`eval:ambience`, browser) mede QUALIDADE da fauna em 3 mapas com + * lista literal — o mesmo furo do gl-shots que deixou 5 mapas sem captura. Esta + * varre o REGISTRO em node puro: mapa novo entra na cobrança sozinho. + * + * O QUE MEDE + * AR1 todo mapa do registro devolve `ambience` com animais instanciados + * AR2 população mínima por bioma: aberto ≥ 2 espécies (rato E pombo); + * interno (INTERNOS) ≥ 2 ratos — pombo dentro de prédio fechado é ruído + * AR3 nenhum animal nasce DENTRO de colisor (fauna dentro de parede é a + * classe LC5 do lajes-circuito, agora para bicho) + * + * USO + * node tools/eval/ambience-registry-check.mjs + * node tools/eval/ambience-registry-check.mjs --mutante=sem-ambience # AR1/AR2 + * node tools/eval/ambience-registry-check.mjs --mutante=fauna-em-solido # AR3 + * + * Horizonte/vida de céu por mapa é a parte 2 do BUG-57 (direção de arte por + * bioma) e ganha cláusula própria quando o dono aprovar a primeira referência. + */ +import { THREE, MAPS, initTextures } from './harness.mjs'; + +const MUTANTE = (process.argv.find((a) => a.startsWith('--mutante=')) || '').split('=')[1] || null; +const conhecidos = new Set(['sem-ambience', 'fauna-em-solido']); +if (MUTANTE && !conhecidos.has(MUTANTE)) throw new Error(`mutante desconhecido: ${MUTANTE}`); + +/* Mapa 100% interno (sem céu): pombo não entra; rato sim — UPA com rato é a sátira. */ +const INTERNOS = new Set(['upa_24h']); + +const T = await initTextures(); +const ids = Object.keys(MAPS); +const linhas = []; +for (const id of ids) { + const scene = new THREE.Scene(); + let W; + try { W = MAPS[id].build(scene, T); } catch (e) { linhas.push({ id, erro: String(e?.message || e) }); continue; } + let amb = W.ambience; + if (MUTANTE === 'sem-ambience' && id === ids[0]) amb = null; + if (!amb || !Array.isArray(amb.animals)) { linhas.push({ id, animals: null }); continue; } + const por = {}; + for (const a of amb.animals) por[a.type] = (por[a.type] || 0) + 1; + /* AR3: posição inicial dentro de colisor. Amostra o ponto do animal contra os AABBs; + margem de 5 cm para encosto legítimo em parede. */ + const emSolido = []; + for (const a of amb.animals) { + const p = a.root?.position; if (!p) continue; + const px = p.x, py = (p.y ?? 0) + 0.12, pz = p.z; + for (const c of (W.colliders || [])) { + if (typeof c.minX !== 'number') continue; + if (px > c.minX + 0.05 && px < c.maxX - 0.05 && pz > c.minZ + 0.05 && pz < c.maxZ - 0.05 + && py > c.minY && py < c.maxY) { emSolido.push({ type: a.type, x: +px.toFixed(1), z: +pz.toFixed(1) }); break; } + } + } + if (MUTANTE === 'fauna-em-solido' && emSolido.length === 0 && amb.animals.length && (W.colliders || []).length) { + const c = W.colliders.find((k) => typeof k.minX === 'number' && k.maxY - k.minY > 0.5); + if (c) emSolido.push({ type: 'mutante', x: (c.minX + c.maxX) / 2, z: (c.minZ + c.maxZ) / 2 }); + } + linhas.push({ id, animals: amb.animals.length, por, emSolido }); +} + +const ar1 = linhas.filter((r) => r.erro || r.animals === null || r.animals === 0); +const ar2 = linhas.filter((r) => { + if (r.erro || !r.por) return false; + const rato = r.por.rat || 0, pombo = r.por.pigeon || 0; + return INTERNOS.has(r.id) ? rato < 2 : (rato < 1 || pombo < 1); +}); +const ar3 = linhas.filter((r) => r.emSolido && r.emSolido.length); + +console.log(`AMBIÊNCIA NO REGISTRO — ${ids.length} mapas${MUTANTE ? ` [mutante: ${MUTANTE}]` : ''}\n`); +for (const r of linhas) { + if (r.erro) { console.log(` x ${r.id.padEnd(17)} build lançou: ${r.erro}`); continue; } + if (r.animals === null || r.animals === 0) { console.log(` x ${r.id.padEnd(17)} SEM ambiência`); continue; } + const pop = Object.entries(r.por).map(([k, v]) => `${k}:${v}`).join(' '); + const solido = r.emSolido.length ? ` <- ${r.emSolido.length} EM SÓLIDO ${JSON.stringify(r.emSolido[0])}` : ''; + console.log(` ${r.emSolido.length ? 'x' : 'ok'} ${r.id.padEnd(17)} ${String(r.animals).padStart(2)} animais ${pop}${solido}`); +} +console.log(`\n AR1 todo mapa tem ambiência ${ar1.length ? `FALHA — ${ar1.map((r) => r.id).join(', ')}` : 'PASSA'}`); +console.log(` AR2 população mínima por bioma ${ar2.length ? `FALHA — ${ar2.map((r) => r.id).join(', ')}` : 'PASSA'}`); +console.log(` AR3 fauna fora de sólido ${ar3.length ? `FALHA — ${ar3.map((r) => r.id).join(', ')}` : 'PASSA'}`); +process.exit(ar1.length || ar2.length || ar3.length ? 1 : 0); diff --git a/tools/eval/fauna-shots.mjs b/tools/eval/fauna-shots.mjs new file mode 100644 index 000000000..3da4048cd --- /dev/null +++ b/tools/eval/fauna-shots.mjs @@ -0,0 +1,138 @@ +/* fauna-shots.mjs — A FAUNA ESTÁ VIVA NA PARTIDA REAL? (BUG-57, port do /game) + * + * O ambience-registry-check (node puro) prova registro/população/sólido, mas com + * fallback procedural — ele não prova que o GLB carregou, animou ou REAGIU. Este + * script abre o jogo de verdade (?auto=), espera `live` e: + * + * FS1 report() da fauna viva: gltf=true (GLB carregado, não fallback) e counts + * batendo com o registro do mapa + * FS2 snapshot() muda com o tempo (mixer avançando = animação rodando) + * FS3 um tiro REAL (Game._fireHitscan, o mesmo caminho do jogo) perto de um + * animal muda o estado dele para flee/takeoff dentro de 1s + * FS4 captura PNG na hora da reação, com o animal no enquadramento + * + * Uso: BASE=http://localhost:8200 node tools/eval/fauna-shots.mjs [outDir] + * ONLY=posto_treta,parque_tjs … filtra mapas + */ +import { execSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +const OUT = process.argv[2] || '/tmp/fauna-shots'; +const BASE = process.env.BASE || 'http://localhost:8200'; +const ONLY = process.env.ONLY ? new Set(process.env.ONLY.split(',')) : null; +mkdirSync(OUT, { recursive: true }); +const gRoot = execSync('npm root -g').toString().trim(); +const _pw = await import(pathToFileURL(`${gRoot}/playwright/index.js`).href); +const chromium = _pw.chromium || _pw.default?.chromium; +const browser = await chromium.launch({ + executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + args: ['--headless=new', '--mute-audio'], +}); + +/* mapa → animal-alvo (mesmas coords declaradas no createFavelaAmbience do mapa) */ +const ALVOS = { + posto_treta: { type: 'dog', pos: [-4, 0, 20] }, + parque_treta: { type: 'pigeon', pos: [-6, 0, -10] }, + velho_oeste: { type: 'pigeon', pos: [-8, 0, -6] }, + upa_24h: { type: 'rat', pos: [-3, 0, 2] }, + penitenciaria: { type: 'rat', pos: [-3, 0, 8] }, + atacadao_treta: { type: 'pigeon', pos: [-8, 0, 20] }, + obras_prefeitura: { type: 'rat', pos: [-2, 0, 6] }, + piscina_treta: { type: 'rat', pos: [-14, 0, -20] }, + quebrada: { type: 'dog', pos: [-6.5, 0, -23.5] }, + ferro_velho: { type: 'rat', pos: [-11, 0, -4] }, + loja_h: { type: 'pigeon', pos: [-9, 0, 20] }, + praca_poderes: { type: 'pigeon', pos: [-4, 0, -8] }, +}; + +const resultados = []; +for (const [mapa, alvo] of Object.entries(ALVOS)) { + if (ONLY && !ONLY.has(mapa)) continue; + const page = await browser.newPage({ viewport: { width: 1500, height: 1000 } }); + const erros = []; + page.on('pageerror', e => erros.push(e.message)); + try { + /* sem nav=1: o modo navegável pula o preload da partida (main.js `if (!navOnly)`) + e a fauna nasceria em fallback procedural, esconderia justo o que FS1 mede */ + await page.goto(`${BASE}/?debug=1&map=${mapa}&auto=P,mst`, { waitUntil: 'domcontentloaded', timeout: 180000 }); + await page.waitForFunction(() => window.__game && window.__game.state === 'live', null, { timeout: 300000 }); + await page.waitForTimeout(2500); + + /* posiciona o jogador a 4 m do animal e olha pra ele (convenção conferida + in loco: se o dot da câmera apontar pro lado errado, soma π e de novo) */ + const alinhou = await page.evaluate(({ alvo }) => { + const g = window.__game, amb = g?.world?.ambience; + if (!amb) return { ok: false, motivo: 'sem ambience' }; + /* MAIS PRÓXIMO da coord declarada (não raio fixo): os bots do auto= atiram + e um bicho assustado anda — threshold duro viraria flakiness de teste */ + let alvoAnimal = null, melhor = Infinity; + for (const a of amb.animals) { + if (a.type !== alvo.type) continue; + const d = Math.hypot(a.root.position.x - alvo.pos[0], a.root.position.z - alvo.pos[2]); + if (d < melhor) { melhor = d; alvoAnimal = a; } + } + if (!alvoAnimal) return { ok: false, motivo: 'sem animal do tipo no mapa' }; + const p = g.player.pos; + p.set(alvoAnimal.root.position.x - 3.2, alvoAnimal.root.position.y + 1.7, alvoAnimal.root.position.z + 3.2); + const dx = alvoAnimal.root.position.x - p.x, dy = alvoAnimal.root.position.y + 0.4 - p.y, dz = alvoAnimal.root.position.z - p.z; + const yaw = Math.atan2(dx, dz); + g.player.yaw = yaw; g.player.pitch = Math.atan2(dy, Math.hypot(dx, dz)); + g.camera.position.copy(p); + return { ok: true, id: alvoAnimal.id, estadoAntes: alvoAnimal.state }; + }, { alvo }); + if (!alinhou.ok) throw new Error(alinhou.motivo); + await page.waitForTimeout(1200); + + /* FS1+FS2: report + snapshot antes/depois de 1,5 s de jogo */ + const antes = await page.evaluate(() => { + const amb = window.__game.world.ambience; + return { report: amb.report(), snap: JSON.stringify(amb.snapshot()) }; + }); + await page.waitForTimeout(1500); + const meio = await page.evaluate(() => JSON.stringify(window.__game.world.ambience.snapshot())); + await page.screenshot({ path: `${OUT}/${mapa}-1-parado.png`, timeout: 60000 }); + + /* FS3: tiro REAL pelo caminho do jogo, 2 m à esquerda do animal. + Re-posiciona NA MESMA tacada: em partida viva os bots matam o jogador e o + respawn o leva pra longe — tiro do spawn não chega perto do bicho. */ + const reacao = await page.evaluate(async ({ idAlvo }) => { + const THREE = await import('three'); + const g = window.__game, amb = g.world.ambience; + const a = amb.animals.find(x => x.id === idAlvo); + if (!a) return { achou: false }; + const p = g.player.pos; + p.set(a.root.position.x - 3.2, a.root.position.y + 1.7, a.root.position.z + 3.2); + g.camera.position.copy(p); + const dir = a.root.position.clone().add(new THREE.Vector3(1.5, 0.2, 1.5)).sub(p).normalize(); + g._fireHitscan(g.player, p.clone(), dir, 12, true, 'AK', 'ak', false); + return { achou: true, id: a.id, estadoAntes: a.state }; + }, { idAlvo: alinhou.id }); + await page.waitForTimeout(700); + const depois = await page.evaluate(() => { + const amb = window.__game.world.ambience; + return { snap: JSON.stringify(amb.snapshot()), estados: amb.animals.map(a => ({ id: a.id, s: a.state })) }; + }); + await page.screenshot({ path: `${OUT}/${mapa}-2-apos-tiro.png`, timeout: 60000 }); + + const fs1 = antes.report.gltf && antes.report.counts.total > 0; + const fs2 = antes.snap !== meio; + const alvoDepois = JSON.parse(depois.snap).find(s => s.id === reacao.id); + /* no auto= os BOTS também atiram: se o bicho JÁ estava fugindo do tiro de um bot, + o estado pós-tiro continua válido — fugir de tiro é exatamente o contrato */ + const jaEmFuga = ['flee', 'takeoff'].includes(reacao.estadoAntes); + const fs3 = reacao.achou && alvoDepois && (['flee', 'takeoff'].includes(alvoDepois.state) + || (jaEmFuga && ['recover', 'fly', 'walk'].includes(alvoDepois.state))); + resultados.push({ mapa, fs1, fs2, fs3, report: antes.report, tiro: reacao, estadoPos: alvoDepois?.state, erros }); + console.log(`${mapa.padEnd(17)} FS1(gltf)=${fs1 ? 'ok' : 'FALHA'} FS2(anim)=${fs2 ? 'ok' : 'FALHA'} FS3(reacao)=${fs3 ? 'ok' : 'FALHA'} [${antes.report.counts.total} bichos, ${antes.report.triangles} tris]`); + } catch (e) { + resultados.push({ mapa, fatal: e.message.split('\n')[0], erros }); + console.log(`${mapa.padEnd(17)} FATAL: ${e.message.split('\n')[0]}`); + } + await page.close(); +} +writeFileSync(`${OUT}/_fauna.json`, JSON.stringify(resultados, null, 2)); +await browser.close(); +const f1 = resultados.filter(r => !r.fs1).length, f2 = resultados.filter(r => !r.fs2).length, f3 = resultados.filter(r => !r.fs3).length; +console.log(`\nFS1 gltf carregado: ${f1 ? `${f1} FALHA` : 'todos'} · FS2 animação: ${f2 ? `${f2} FALHA` : 'todos'} · FS3 reação ao tiro: ${f3 ? `${f3} FALHA` : 'todos'}`); +process.exit(f1 || f2 || f3 ? 1 : 0); diff --git a/tools/eval/i18n-twins-check.mjs b/tools/eval/i18n-twins-check.mjs new file mode 100644 index 000000000..7a9b1e2f9 --- /dev/null +++ b/tools/eval/i18n-twins-check.mjs @@ -0,0 +1,85 @@ +/* ============================================================================ + i18n-twins-check.mjs — pares PT↔EN das páginas estáticas (issue #54) + ---------------------------------------------------------------------------- + Uma tabela (`src/lib/i18n-pairs.ts`) alimenta hreflang, lang, og:locale e + sitemap. A gêmea do /changelog é /whats-new; o cromo é EN e o corpo continua + o CHANGELOG.md, parseado numa fonte só. + + Mutantes: sem-par | sem-redirect | chrome-pt | lang-pt | sem-sitemap | parser-dup + ============================================================================ */ +import { existsSync, readFileSync } from 'node:fs'; + +const MUT = (process.argv.find((a) => a.startsWith('--mutante=')) || '').split('=')[1] || ''; +if (MUT && !['sem-par', 'sem-redirect', 'chrome-pt', 'lang-pt', 'sem-sitemap', 'parser-dup'].includes(MUT)) + throw new Error(`mutante desconhecido: ${MUT}`); + +const read = (file) => (existsSync(file) ? readFileSync(file, 'utf8') : ''); + +let pairs = read('src/lib/i18n-pairs.ts'); +let layout = read('src/layouts/Layout.astro'); +let sitemap = read('src/pages/sitemap.xml.ts'); +let changelog = read('src/pages/changelog.astro'); +let gemea = read('src/pages/whats-new.astro'); +let menu = read('public/js/main.js'); + +if (MUT === 'sem-par') + pairs = pairs.replace(" ['/changelog', '/whats-new'],\n", ''); +if (MUT === 'sem-redirect') + changelog = changelog.replace("location.replace('/whats-new')", "location.replace('/changelog')"); +if (MUT === 'chrome-pt') + gemea = gemea.replace(/production version/g, 'versão em produção'); +if (MUT === 'lang-pt') + layout = layout.replace('', ''); +if (MUT === 'sem-sitemap') + sitemap = sitemap.replaceAll('PT_EN_PAIRS', '[]'); +if (MUT === 'parser-dup') + gemea = gemea.replace("from '../lib/changelog'", "from '../lib/site'"); + +const failures = []; +if (!pairs.includes("['/changelog', '/whats-new']")) + failures.push('I18N0 a tabela única não declara /changelog ↔ /whats-new'); +if (!layout.includes("from '../lib/i18n-pairs'") || layout.includes("const PT_EN_PAIRS: [string, string][]")) + failures.push('I18N0 Layout ainda copia a tabela em vez de importar src/lib/i18n-pairs.ts'); +if (!sitemap.includes("from '../lib/i18n-pairs'")) + failures.push('I18N0 sitemap não lê a tabela única'); + +if (!existsSync('src/pages/whats-new.astro')) + failures.push('I18N1 falta src/pages/whats-new.astro — gêmea EN do /changelog'); +if (!changelog.includes("location.replace('/whats-new')")) + failures.push('I18N2 /changelog em EN não manda para /whats-new'); +if (!gemea.includes("location.replace('/changelog')")) + failures.push('I18N2 /whats-new em PT não manda para /changelog'); +if (!layout.includes("'/changelog': ['/whats-new'")) + failures.push('I18N3 Layout GEMEA em EN não troca o href do changelog'); +if (!menu.includes("'/changelog': '/whats-new'")) + failures.push('I18N3 menu do jogo em EN não troca o href do changelog'); +if (gemea && !gemea.includes('production version')) + failures.push('I18N4 cromo da gêmea ainda não está em inglês (kicker)'); +if (gemea && !gemea.includes('filter versions')) + failures.push('I18N4 cromo da gêmea ainda não está em inglês (busca)'); +if (gemea && !gemea.includes("import md from '../../CHANGELOG.md?raw'")) + failures.push('I18N5 gêmea EN não renderiza o mesmo CHANGELOG.md'); + +if (!layout.includes('') || !layout.includes("pairedPt ? 'en' : 'pt-BR'")) + failures.push('I18N6 gêmea EN ainda declara html lang=pt-BR (crawler lê mentira)'); +if (!layout.includes("pageLang === 'en' ? 'en_US' : 'pt_BR'")) + failures.push('I18N6 og:locale das gêmeas EN continua pt_BR'); +if (!layout.includes("inLanguage: pageLang") || !layout.includes('jsonldLang')) + failures.push('I18N6 JSON-LD das gêmeas EN continua inLanguage pt-BR'); + +if (!sitemap.includes('PT_EN_PAIRS') || !/PT_EN_PAIRS\.map/.test(sitemap)) + failures.push('I18N7 sitemap não lista as rotas EN da tabela única'); + +if (!changelog.includes("from '../lib/changelog'") || !gemea.includes("from '../lib/changelog'") + || !changelog.includes('{ versoes, recentes, atualVer }') || !gemea.includes('{ versoes, recentes, atualVer }')) + failures.push('I18N8 /changelog e /whats-new não compartilham o parser (vão divergir)'); + +for (const failure of failures) console.error(` \x1b[31m✗\x1b[0m ${failure}`); +if (MUT && !failures.length) failures.push(`mutação ${MUT} não foi detectada`); + +if (failures.length) { + console.error(`\x1b[31mI18N TWINS ${failures.length} VERMELHA(S)\x1b[0m${MUT ? ` (mutante=${MUT})` : ''}`); + process.exitCode = 1; +} else { + console.error('\x1b[32mI18N TWINS verde: pares numa tabela, lang/og/sitemap honestos, changelog com parser único\x1b[0m'); +}