@the-inclusionist/engine 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import { type GameDeclaration } from '../core/contract.js';
6
6
  import { type SceneStack } from '../core/scenes.js';
7
7
  import { createTts, type CarregarVozNeural } from '../platform/tts.js';
8
8
  import { type AudioSonar, type SonarPlayer } from '../platform/audio-sonar.js';
9
+ import { type Tema, type Correcao } from '../render/viz-axes.js';
9
10
  import type { AlcanceDoFiltro } from '../render/port.js';
10
11
  import { type SettingsPanelApi } from '../ui/settings-panel.js';
11
12
  import { type MenuNavApi } from '../ui/menu-nav.js';
@@ -180,6 +181,49 @@ export interface CreateGameOptions {
180
181
  * teste que não as possa responder não consegue exercitar a tela que depende delas.
181
182
  */
182
183
  readonly disponibilidade?: Disponibilidade;
184
+ /**
185
+ * O QUE CADA ITEM DO CARTÃO DE PAUSA FAZ NESTE JOGO — «continuar», «sair», «ajuda», o que o jogo ligar.
186
+ *
187
+ * 🔴 ESTE CAMPO FALTAVA, E A FALTA ALCANÇAVA TODOS OS CONSUMIDORES DE UMA VEZ. O `initPauseIcons`
188
+ * aceita `getPauseActs` desde que existe; esta raiz não o passava e não tinha campo para ele, logo
189
+ * **nenhum jogo montado por `createGame`** conseguia ligar um item. O `refrescarItensDaPausa` esconde o
190
+ * que não acciona — o §5 do ADR-0106, que proíbe botão morto — e o resultado era um cartão com os TRÊS
191
+ * itens que a engine acciona sozinha (`ITENS_DA_ENGINE`) e nada mais, em todo o catálogo.
192
+ *
193
+ * ⚠️ E O CUSTO MAIOR NÃO ERA O CARTÃO, ERA A BARRA. O `entrarNaBarra` chama `acts.resume?.()` para sair
194
+ * do cartão antes de entregar as direcções à barra de acessibilidade; com a tabela vazia esse `resume` era
195
+ * `undefined`, o cartão ficava por cima do jogo, e o item 7 do ADR-0044 — o direccional a conduzir a barra
196
+ * — era **inalcançável a partir de qualquer jogo**.
197
+ *
198
+ * 📌 FUNÇÃO e não valor, pela razão que o próprio `ui/pause-icons` regista: a tabela de um jogo muda
199
+ * durante a partida (um «sair» que só liga depois da primeira fase), e congelá-la no arranque já partiu
200
+ * um caso lá dentro. Ausente = tabela vazia, que é o comportamento de sempre.
201
+ */
202
+ readonly getPauseActs?: () => Record<string, (() => void) | undefined>;
203
+ /**
204
+ * QUEM ABRIU A PAUSA, quando há mais de um assento — o painel de controle edita o assento DESTE índice.
205
+ *
206
+ * ⚠️ A RAIZ JÁ SE DENUNCIAVA POR NÃO TER ISTO: o bloco 4d empurra uma linha de `problems` quando um jogo
207
+ * declara mais de um jogador, porque sem ator da pausa a criança do SEGUNDO assento não tem como remapear.
208
+ * O que faltava para a linha ser accionável era este campo — até agora ela dizia «conserte» sem haver por
209
+ * onde, e a única saída era declarar `semAtorDePausa`, que é aceitar a perda em vez de a corrigir.
210
+ */
211
+ readonly setPauseActor?: (i: number, ...resto: unknown[]) => void;
212
+ /**
213
+ * COMO ESTE JOGO REPINTA PARA ALTO CONTRASTE, e como corrige daltonismo — os dois eixos do ADR-0104.
214
+ *
215
+ * 🔴 SEM ELES OS ÍCONES ⚫ E 🚥 NÃO SÃO MONTÁVEIS POR NENHUM JOGO. O `iconesQueAccionam` só os monta
216
+ * para quem entrega quem os escreve, e essa regra está certa — um ícone que não acciona é pior que um
217
+ * ícone a menos. O que estava errado era não haver PORTA: o consumidor externo que mediu isto leu a
218
+ * ausência como «este jogo tem os seus próprios controles», o que é verdade sobre o resultado e falso
219
+ * sobre a causa. Uma lacuna que o consumidor lê como escolha é a pior forma de lacuna.
220
+ *
221
+ * ⚠️ SÃO DOIS CAMPOS E NÃO UM, porque são duas perguntas: um jogo pode saber repintar texturas e não ter
222
+ * como corrigir cor, ou o contrário. O `game-pinball` é o segundo caso — a imagem dele é um framebuffer
223
+ * de 320x180 sem textura para repintar, e o filtro de cor ele aplica há semanas.
224
+ */
225
+ readonly setTemaDoJogador?: (i: number, tema: Tema) => void;
226
+ readonly setCorrecaoDoJogador?: (i: number, correcao: Correcao) => void;
183
227
  }
184
228
  export interface Engine {
185
229
  readonly declaration: GameDeclaration;
@@ -265,7 +309,28 @@ export interface Engine {
265
309
  * tem de poder ser auditado em vez de ficar só numa tela que já fechou.
266
310
  */
267
311
  readonly alcance: Alcance;
312
+ /**
313
+ * TORNA ESTE CARTUCHO O CORRENTE (ADR-0142). Uma raiz de composição, vários jogos.
314
+ *
315
+ * ⚠️ **LANÇA** numa declaração malformada, e não a põe em `problems`: o contrato é PRÉ-CONDIÇÃO e não
316
+ * diagnóstico, tal como no arranque. Um cartucho mau não chega a ser montado.
317
+ *
318
+ * 📌 O que ele refaz é só o que não se conserta lendo de novo: os dois registos de mapeamento, que são
319
+ * efeito global, e o alcance com o seu aviso, que escreve DOM. `problems` e `alcance` passam a descrever
320
+ * o cartucho montado porque são derivados, não porque `mount` os copie.
321
+ */
322
+ mount(declaration: GameDeclaration, ganchos?: GanchosDoCartucho): void;
323
+ /**
324
+ * SOLTA O CORRENTE: mapeamentos a `null`, aviso de alcance retirado, pilha de cenas esvaziada.
325
+ *
326
+ * ⚠️ A pilha esvazia-se com `pop()` e não com um `clear()`, e a diferença é a decisão: 📏 medido nos seis
327
+ * jogos, os quatro `exit()` que existem são LIMPEZA DE DOM, logo dispará-los é o teardown que se quer.
328
+ * Um `clear()` que os saltasse seria o conserto errado.
329
+ */
330
+ unmount(): void;
268
331
  }
332
+ /** A metade do jogo SEM a declaração — o que `mount` recebe ao lado dela. */
333
+ export type GanchosDoCartucho = Omit<MetadeDoJogo, 'declaration'>;
269
334
  /**
270
335
  * Liga a engine para um jogo declarado.
271
336
  *
@@ -274,4 +339,15 @@ export interface Engine {
274
339
  * um id ausente é lacuna do HOSPEDEIRO, e o quiz provou que ligar só a parte que serve é legítimo — foi
275
340
  * assim que ele recusou o pad e o sonar sem mentir. Por isso um vira exceção e o outro vira `problems`.
276
341
  */
342
+ /**
343
+ * A METADE DO JOGO de `CreateGameOptions` — os quinze campos que o ADR-0139 §1 diz que um cartucho
344
+ * FORNECE, separados dos cinco que descrevem a página e o aparelho.
345
+ *
346
+ * ⚠️ São quinze e não dez: a primeira versão daquele registo contou quinze campos num total de vinte e
347
+ * deixou `declines`, `getPauseActs`, `setPauseActor`, `setTemaDoJogador` e `setCorrecaoDoJogador` de fora.
348
+ * O teste que ele próprio dá — «uma PÁGINA conseguiria responder isto sem saber que jogo corre?» — põe os
349
+ * cinco deste lado, e a errata de 2026-09-11 corrigiu a lista.
350
+ */
351
+ type MetadeDoJogo = Pick<CreateGameOptions, 'declaration' | 'isNavigable' | 'comIndice' | 'naBarraDe' | 'navBar' | 'players' | 'setPhase' | 'sonarPlayers' | 'isBlindMode' | 'preset' | 'declines' | 'getPauseActs' | 'setPauseActor' | 'setTemaDoJogador' | 'setCorrecaoDoJogador'>;
277
352
  export declare function createGame(o: CreateGameOptions): Engine;
353
+ export {};
@@ -50,7 +50,7 @@ import { initI18n, idiomaPronto } from '../core/i18n.js';
50
50
  import { entradaDe } from '../input/state.js';
51
51
  import { criarAvisoDeQueda } from '../ui/loop-crash.js';
52
52
  import { initFocusTrap, focaveisNoDom } from '../ui/focus-trap.js';
53
- import { mostrarAvisoDeAlcance } from '../ui/reach-notice.js';
53
+ import { mostrarAvisoDeAlcance, REACH_NOTICE_ID } from '../ui/reach-notice.js';
54
54
  import { alcance, transportesPadrao } from '../input/transports.js';
55
55
  import { presetActions, ACTIONS } from '../core/actions.js';
56
56
  import { t } from '../core/i18n.js';
@@ -76,6 +76,17 @@ import { kb, initKB, registrarMapeamentoDoTeclado } from '../input/keyboard.js';
76
76
  import { registrarMapeamentoDoPad } from '../input/pad-defaults.js';
77
77
  import { baixarPesados } from '../platform/pesados.js';
78
78
  import { installCvdFilters } from '../render/cvd-matrices.js';
79
+ /*
80
+ * UMA FRASE SÓ PARA AS DUAS RECUSAS, e o gate do pilar 3 é que a pediu.
81
+ *
82
+ * ⚠️ O arranque e o `mount()` recusam uma declaração malformada pela MESMA razão e com a mesma frase, e
83
+ * escrevê-la duas vezes fez o teto de texto cru deste módulo subir de 15 para 16 — o crivo de i18n reprovou,
84
+ * e reprovou com razão. Duas cópias de uma frase são dois sítios para ela divergir, e é também a segunda
85
+ * tradução a fazer quando o pilar 3 chegar aqui.
86
+ */
87
+ function recusarDeclaracao(quem, problemas) {
88
+ throw new Error(`${quem}: declaração malformada — ${problemas.join('; ')}`);
89
+ }
79
90
  /** Os ids que os painéis emprestados exigem do documento. Achado 6: sem eles o painel abre VAZIO, sem erro. */
80
91
  const MARCACAO_EXIGIDA = ['#game-region', '#sr-status', '#sr-alert'];
81
92
  /**
@@ -87,27 +98,38 @@ const MARCACAO_EXIGIDA = ['#game-region', '#sr-status', '#sr-alert'];
87
98
  * frase própria pode explicar O QUE se perde, e é isso que a torna útil a quem a lê pela primeira vez.
88
99
  */
89
100
  const SELETOR_BARRA_A11Y = '#title-icons';
90
- /**
91
- * Liga a engine para um jogo declarado.
92
- *
93
- * ⚠️ LANÇA se a declaração for malformada, e NÃO lança se faltar marcação. A diferença não é gosto: uma
94
- * declaração errada é defeito de PROGRAMA, e um jogo que roda meio declarado é pior do que um que não abre;
95
- * um id ausente é lacuna do HOSPEDEIRO, e o quiz provou que ligar só a parte que serve é legítimo — foi
96
- * assim que ele recusou o pad e o sonar sem mentir. Por isso um vira exceção e o outro vira `problems`.
97
- */
98
101
  export function createGame(o) {
99
- const problemasDoContrato = conformanceProblems(o.declaration);
102
+ /*
103
+ * O CARTUCHO CORRENTE, e por enquanto ele É as opções que chegaram.
104
+ *
105
+ * ⚠️ Este passo não muda comportamento nenhum: `cartucho` começa como o próprio `o`, então toda leitura
106
+ * abaixo devolve exatamente o que devolvia. O que ele compra é o LUGAR onde `mount()` vai escrever
107
+ * (ADR-0142) — sem ele, as trinta e seis leituras da metade do jogo estão presas ao argumento, e uma raiz
108
+ * de composição a servir vários cartuchos ficaria com o primeiro deles para sempre.
109
+ */
110
+ let cartucho = o;
111
+ const problemasDoContrato = conformanceProblems(cartucho.declaration);
100
112
  if (problemasDoContrato.length) {
101
- throw new Error('createGame: declaração malformada — ' + problemasDoContrato.join('; '));
113
+ recusarDeclaracao('createGame', problemasDoContrato);
102
114
  }
103
115
  const { doc, win } = o.host;
104
- const declines = o.declines ?? {};
105
- const problems = [];
116
+ /*
117
+ * ⚠️ LEITOR E NÃO INSTANTÂNEO — terceira vez que este ficheiro comete e conserta o mesmo padrão, depois do
118
+ * `seguraTeclas` e do `players`. `declines` é da metade do JOGO (ADR-0139, errata de 2026-09-11: é o
119
+ * cartucho que declara o que NÃO tem), então um `const` tirado no arranque devolve, depois de um `mount()`,
120
+ * os declínios do cartucho anterior — e um declínio lido errado esconde uma linha de `problems` ou
121
+ * inventa outra.
122
+ *
123
+ * 📌 O vazio é uma constante e não um literal por chamada: `declines()` é lido em sítios que comparam.
124
+ */
125
+ const SEM_DECLINIOS = {};
126
+ const declines = () => cartucho.declines ?? SEM_DECLINIOS;
127
+ const problemasDoHospedeiro = [];
106
128
  const $ = (sel) => doc.querySelector(sel);
107
129
  const $$ = (sel) => [...doc.querySelectorAll(sel)];
108
130
  for (const sel of MARCACAO_EXIGIDA) {
109
131
  if (!$(sel))
110
- problems.push(`marcação ausente: ${sel}`);
132
+ problemasDoHospedeiro.push(`marcação ausente: ${sel}`);
111
133
  }
112
134
  // ⚠️ O MUNDO DECLARADO TEM DE EXISTIR NO DOCUMENTO, e esta é a falha que o ADR-0087 deixaria aberta se
113
135
  // parasse na conformidade. `conformanceProblems` confere a FORMA — que há um seletor e que ele não está
@@ -117,9 +139,40 @@ export function createGame(o) {
117
139
  // que o registro existe para eliminar: a simulação de empatia aplicada a NADA, e um adulto informado de
118
140
  // que sentiu algo que não sentiu. É um problema do HOSPEDEIRO e não do programa, então entra em
119
141
  // `problems` como as marcações — o jogo abre, e quem o integrou lê que o mundo dele não está lá.
120
- const mundo = o.declaration.world();
121
- if (mundo.kind === 'element' && !$(mundo.selector)) {
122
- problems.push(`mundo declarado não encontrado: ${mundo.selector}`);
142
+ /*
143
+ * AS TRÊS LINHAS DE `problems` QUE DEPENDEM DO CARTUCHO, juntas e recalculáveis.
144
+ *
145
+ * 📏 Medido: das oito que esta raiz produz, CINCO são sobre a PÁGINA — marcação ausente, sem host de
146
+ * filtros, sem barra de acessibilidade, uma barra que não aceita conteúdo, sem sítio para a pausa — e
147
+ * essas não mexem quando se troca de jogo, porque não é o jogo que as causa. Só estas três mexem.
148
+ *
149
+ * ⚠️ E é por isso que `problems` não podia continuar a ser UM array construído no arranque: metade dele
150
+ * descreve o hospedeiro e vale para sempre, a outra metade descreve um cartucho e caduca no `mount()`.
151
+ * Recalcular tudo apagaria diagnósticos do hospedeiro que ninguém consertou; não recalcular nada deixaria
152
+ * o diagnóstico a falar do jogo errado.
153
+ */
154
+ function problemasDoCartucho() {
155
+ const p = [];
156
+ // O mundo declarado tem de existir na página — e quem o declara é o jogo, não o hospedeiro.
157
+ const mundo = cartucho.declaration.world();
158
+ if (mundo.kind === 'element' && !$(mundo.selector)) {
159
+ p.push(`mundo declarado não encontrado: ${mundo.selector}`);
160
+ }
161
+ // ⚠️ MISTA, e fica deste lado por causa da segunda metade: a porta é do hospedeiro
162
+ // (`carregarVozNeural`), mas o declínio é do CARTUCHO — logo a linha pode aparecer ou calar-se ao
163
+ // trocar de jogo, com o mesmo hospedeiro.
164
+ if (!o.carregarVozNeural && !declines().semVozNeural) {
165
+ p.push('sem voz neural: declare `carregarVozNeural` (uma linha — ver ADR-0094) ou `declines.semVozNeural`. '
166
+ + 'Sem ela a criança que não lê fica com a voz do sistema, que em Chromebook de escola pode não existir '
167
+ + 'em português');
168
+ }
169
+ const assentos = (cartucho.players ?? []).length;
170
+ if (assentos > 1 && !declines().semAtorDePausa && !cartucho.setPauseActor) {
171
+ p.push(`declarou ${assentos} jogadores e não registra o ator da pausa: o painel de controle edita sempre o `
172
+ + 'assento 0, então ninguém além do primeiro consegue remapear. Declare `declines.semAtorDePausa` se '
173
+ + 'for de propósito');
174
+ }
175
+ return p;
123
176
  }
124
177
  // 1. IDIOMA ANTES DE TUDO. A interface não pode ser construída antes de a língua ser conhecida — foi o que
125
178
  // o item 14 consertou movendo `initI18n()` para o topo do boot. O documento entra: ver o achado 15.
@@ -131,11 +184,7 @@ export function createGame(o) {
131
184
  getSoundOn: () => soundOn, getVolume: () => volume, getAudioCat: () => audioCat,
132
185
  carregarVozNeural: o.carregarVozNeural,
133
186
  });
134
- if (!o.carregarVozNeural && !declines.semVozNeural) {
135
- problems.push('sem voz neural: declare `carregarVozNeural` (uma linha — ver ADR-0094) ou `declines.semVozNeural`. '
136
- + 'Sem ela a criança que não lê fica com a voz do sistema, que em Chromebook de escola pode não existir '
137
- + 'em português');
138
- }
187
+ // 📌 A linha da voz neural mudou-se para `problemasDoCartucho()`: o declínio que a cala é do jogo.
139
188
  /**
140
189
  * O FILTRO DE VISÃO, aplicado ao MUNDO QUE O JOGO DECLAROU (ADR-0087).
141
190
  *
@@ -149,7 +198,7 @@ export function createGame(o) {
149
198
  * filtro sobre ela seria a mentira que o ADR-0087 existe para impedir, só que ao contrário.
150
199
  */
151
200
  function aplicarFiltroDeVisao(css, alcance) {
152
- const mundo = o.declaration.world();
201
+ const mundo = cartucho.declaration.world();
153
202
  if (mundo.kind !== 'element')
154
203
  return;
155
204
  const el = $(mundo.selector);
@@ -174,13 +223,13 @@ export function createGame(o) {
174
223
  // 4. Daltonismo: a engine ENTREGA o markup em vez de exigir que o consumidor o adivinhe (achado 7).
175
224
  const cvdFilters = installCvdFilters(o.host.cvdHost ?? null);
176
225
  if (!cvdFilters)
177
- problems.push('sem host de filtros (<svg>): a correção de daltonismo não foi montada');
226
+ problemasDoHospedeiro.push('sem host de filtros (<svg>): a correção de daltonismo não foi montada');
178
227
  // 4c. A BARRA DE ACESSIBILIDADE DA PRIMEIRA TELA. Ver a nota em `EngineHost.a11yBarHost`: cinco dos seis
179
228
  // jogos do catálogo não têm nenhuma, e nada o dizia. Isto não a monta — diz que ela falta, que é o
180
229
  // passo que tira o silêncio. A frase nomeia a saída, como as outras deste bloco fazem.
181
230
  const a11yBar = o.host.a11yBarHost ?? $(SELETOR_BARRA_A11Y);
182
231
  if (!a11yBar) {
183
- problems.push(`sem barra de acessibilidade na primeira tela: declare \`host.a11yBarHost\` ou ponha um ${SELETOR_BARRA_A11Y} no documento. Sem ela a criança não alcança modo cego, TTS, alto contraste nem Libras antes de começar`);
232
+ problemasDoHospedeiro.push(`sem barra de acessibilidade na primeira tela: declare \`host.a11yBarHost\` ou ponha um ${SELETOR_BARRA_A11Y} no documento. Sem ela a criança não alcança modo cego, TTS, alto contraste nem Libras antes de começar`);
184
233
  }
185
234
  /*
186
235
  * ⚠️ E AGORA A ENGINE MONTA-A (ADR-0106 §4, etapa 2). Até 2026-09-08 esta raiz só REPORTAVA a ausência, e o
@@ -213,7 +262,7 @@ export function createGame(o) {
213
262
  * respostas à mesma pergunta divergem, e foi assim que esta divergiu. `import * as state` dá ligação VIVA,
214
263
  * então isto lê o valor de agora e não o do arranque.
215
264
  */
216
- const lerModoCego = o.isBlindMode ?? (() => state.modoCego);
265
+ const lerModoCego = cartucho.isBlindMode ?? (() => state.modoCego);
217
266
  const pauseIcons = initPauseIcons({
218
267
  doc,
219
268
  /*
@@ -227,9 +276,12 @@ export function createGame(o) {
227
276
  * a pé e nada dentro de um veículo declara `true`, e o registo não disse isto porque a pergunta só
228
277
  * aparece quando se monta a barra.
229
278
  */
230
- seguraTeclas: o.declaration.seguraTeclas(),
231
- getPlayers: () => o.players ?? [],
232
- getNumPlayers: () => (o.players ?? [null]).length,
279
+ // ⚠️ A REFERÊNCIA, e não o resultado. Chamar aqui congelava a resposta no arranque, e o
280
+ // `reflectPauseIcons` — que existe porque a tabela de acções muda (ADR-0106 §5) — refrescava a partir
281
+ // dela. Com vários cartuchos numa raiz de composição (ADR-0142) o ícone descrevia o primeiro deles.
282
+ seguraTeclas: () => cartucho.declaration.seguraTeclas(),
283
+ getPlayers: () => cartucho.players ?? [],
284
+ getNumPlayers: () => (cartucho.players ?? [null]).length,
233
285
  srSay, srAlert,
234
286
  // ⚠️ NÃO `instanceof HTMLElement`: esse é um GLOBAL DO NAVEGADOR, e lê-lo onde ele não existe LANÇA —
235
287
  // não devolve falso. Escrito assim na etapa 2, fazia o `reflectPauseIcons` rebentar em qualquer ambiente
@@ -252,6 +304,14 @@ export function createGame(o) {
252
304
  reflectTtsPanelEnabled: false,
253
305
  isLibrasOn: vlibrasOpen,
254
306
  toggleLibras,
307
+ /*
308
+ * ⚠️ O QUE O JOGO ENTREGA, E QUE ATÉ HOJE NÃO TINHA POR ONDE. Os três campos são opcionais dos dois
309
+ * lados: ausentes, tudo se comporta como antes — tabela de acções vazia e os dois ícones visuais
310
+ * não montados. Ver as notas em `CreateGameOptions` para o que a ausência custava.
311
+ */
312
+ ...(cartucho.getPauseActs ? { getPauseActs: cartucho.getPauseActs } : {}),
313
+ ...(cartucho.setTemaDoJogador ? { setTemaDoJogador: cartucho.setTemaDoJogador } : {}),
314
+ ...(cartucho.setCorrecaoDoJogador ? { setCorrecaoDoJogador: cartucho.setCorrecaoDoJogador } : {}),
255
315
  });
256
316
  /*
257
317
  * ⚠️ O HOSPEDEIRO TEM DE SABER SER UMA BARRA, e perguntar isso não é zelo: `a11yBarHost` é `Element` no
@@ -265,7 +325,7 @@ export function createGame(o) {
265
325
  && typeof a11yBar.addEventListener === 'function'
266
326
  && 'innerHTML' in a11yBar;
267
327
  if (a11yBar && !barraUsavel) {
268
- problems.push('o elemento da barra de acessibilidade não aceita conteúdo nem clique: os ícones não foram montados');
328
+ problemasDoHospedeiro.push('o elemento da barra de acessibilidade não aceita conteúdo nem clique: os ícones não foram montados');
269
329
  }
270
330
  if (a11yBar && barraUsavel) {
271
331
  a11yBar.innerHTML = iconsMarkup(pauseIcons.iconesMontados);
@@ -317,16 +377,11 @@ export function createGame(o) {
317
377
  // esquema, e não há selector de assento — o `#ctrl-players` é uma FRASE, não abas. Quem decide o assento é
318
378
  // o consumidor, passando o ator da pausa: «edita o controle de quem abriu o menu».
319
379
  //
320
- // ⚠️ E É AQUI QUE ISTO FICA MUDO. O `setPauseActor` desta raiz é `() => {}` — literal, logo abaixo. Um jogo
321
- // montado por `createGame` com dois assentos deixa a criança do SEGUNDO sem como remapear, e nada o diz.
322
- // Não é a mesma coisa que declarar `semAtorDePausa`: essa é uma ausência declarada, e uma ausência
323
- // declarada é uma escolha. Esta era uma ausência por omissão, que é a forma de defeito do ADR-0106 §2.
324
- const assentos = (o.players ?? []).length;
325
- if (assentos > 1 && !declines.semAtorDePausa) {
326
- problems.push(`declarou ${assentos} jogadores e não registra o ator da pausa: o painel de controle edita sempre o `
327
- + 'assento 0, então ninguém além do primeiro consegue remapear. Declare `declines.semAtorDePausa` se '
328
- + 'for de propósito');
329
- }
380
+ // ✅ E ISTO DEIXOU DE SER MUDO. O `setPauseActor` desta raiz era `() => {}` LITERAL, sem campo por onde um
381
+ // jogo o entregar: a linha abaixo dizia «conserte» sem haver por onde, e a única saída era declarar
382
+ // `semAtorDePausa`, que é aceitar a perda em vez de a corrigir. Agora ela só acusa quem NÃO respondeu —
383
+ // que é o que uma linha de `problems` deve fazer, pelo §2 do ADR-0106.
384
+ // 📌 A linha do ator da pausa mudou-se para `problemasDoCartucho()`: quem declara assentos é o jogo.
330
385
  /*
331
386
  * 4e. O CARTÃO DE PAUSA DA PRIMEIRA TELA — e isto fecha um LAÇO QUE ESTAVA ABERTO.
332
387
  *
@@ -343,7 +398,7 @@ export function createGame(o) {
343
398
  const hospedeiroDaPausa = o.host.pauseHost ?? $('#game-region');
344
399
  const pausaUsavel = !!hospedeiroDaPausa && typeof hospedeiroDaPausa.appendChild === 'function';
345
400
  if (!pausaUsavel) {
346
- problems.push('sem sítio para o menu de pausa: declare `host.pauseHost` ou tenha um #game-region que aceite filhos. '
401
+ problemasDoHospedeiro.push('sem sítio para o menu de pausa: declare `host.pauseHost` ou tenha um #game-region que aceite filhos. '
347
402
  + 'Sem ele a criança não alcança os ajustes durante a partida — e NÃO há como declinar: desde o '
348
403
  + 'ADR-0122 a pausa é da engine em todo jogo, e o que este jogo declara é só ONDE ela cabe');
349
404
  }
@@ -356,13 +411,13 @@ export function createGame(o) {
356
411
  }
357
412
  // 4b. NAVEGAÇÃO SONORA. Só o contrato entra: nada de tile, caixa de colisão ou array de moedas.
358
413
  const sonar = createAudioSonar({
359
- topology: () => o.declaration.topology(),
360
- targetsOf: (i) => o.declaration.targetsOf(i),
361
- nameAt: (at) => o.declaration.nameAt(at),
414
+ topology: () => cartucho.declaration.topology(),
415
+ targetsOf: (i) => cartucho.declaration.targetsOf(i),
416
+ nameAt: (at) => cartucho.declaration.nameAt(at),
362
417
  // Campo 2 + o barramento do mixer: o que o GUIA CONTÍNUO precisa e o bipe não precisava (#84 item 2). O
363
418
  // `roleAt` é o que deixa a rota contornar parede; o `catNode`/`audioOut`/`getVolume` são o que põem um
364
419
  // grafo PERMANENTE no mesmo cursor de volume que todo o resto do áudio usa.
365
- roleAt: (at) => o.declaration.roleAt(at),
420
+ roleAt: (at) => cartucho.declaration.roleAt(at),
366
421
  tonePan, srSay, narrate: (texto) => tts.narrate(texto),
367
422
  catNode, audioOut, getVolume: () => volume,
368
423
  // ⚠️ A RESPOSTA, E NÃO A TABELA (#104). O `platform/audio-sonar` recebia o `VIZ_BY_KEY` e atravessava-o
@@ -375,11 +430,11 @@ export function createGame(o) {
375
430
  getModoCego: lerModoCego, LOGICAL_W,
376
431
  // O jogador DERIVADO do foco: campo 4 respondendo "onde a criança está". Um jogo que não fornece lista
377
432
  // ainda tem sonar, e é isso que faz a pilha de acessibilidade não ser acessório.
378
- getPlayers: o.sonarPlayers ?? (() => {
379
- const f = o.declaration.focusOf(0);
433
+ getPlayers: cartucho.sonarPlayers ?? (() => {
434
+ const f = cartucho.declaration.focusOf(0);
380
435
  return f ? [{ i: 0, x: f.at.x, y: f.at.y, visual: PADRAO }] : [];
381
436
  }),
382
- getNumPlayers: () => (o.players ?? [null]).length,
437
+ getNumPlayers: () => (cartucho.players ?? [null]).length,
383
438
  getAudioCtx: () => audioCtx, getSoundOn: () => soundOn, getAudioCat: () => audioCat,
384
439
  });
385
440
  // 5. Teclado remapeável — o melhor recorte da base (achado 11): esquema de teclas, sem mundo.
@@ -389,24 +444,45 @@ export function createGame(o) {
389
444
  // primeiro arranque com a fábrica da ENGINE e o segundo com a do jogo — a pior espécie de defeito, porque
390
445
  // desaparece quando alguém vai ver.
391
446
  // 📌 E o registo aceita `null`, que é o que um jogo sem opinião produz: fica a fábrica da engine.
392
- registrarMapeamentoDoTeclado(o.declaration.mapeamentoDoTeclado
393
- ? (jogadores, assento) => o.declaration.mapeamentoDoTeclado(jogadores, assento)
394
- : null);
395
- // ⚠️ E O DO CONTROLE REGISTA-SE AQUI AINDA QUE ESTA RAIZ NÃO MONTE GAMEPAD NENHUM. Não é descuido: quem
396
- // chama `initGamepad` é o cartucho, e é exactamente por isso que o registo não pode viver lá — seria mais
397
- // um campo que um jogo pode esquecer, e esquecê-lo devolve o mapa da ENGINE a quem declarou outro, calado.
398
- registrarMapeamentoDoPad(o.declaration.mapeamentoDoPad
399
- ? (jogadores, assento) => o.declaration.mapeamentoDoPad(jogadores, assento)
400
- : null);
447
+ /*
448
+ * OS DOIS REGISTOS NUMA FUNÇÃO, porque são EFEITO GLOBAL e não valor: quem os chama por último ganha.
449
+ *
450
+ * ⚠️ É o que os torna diferentes de tudo o mais nesta raiz. Repontar uma leitura para o `cartucho` chega
451
+ * para os campos que são lidos quando alguém pergunta; estes dois já foram escritos noutro sítio no
452
+ * momento do arranque, então trocar de cartucho sem os reescrever deixa o mapa do anterior a valer —
453
+ * calado, e exactamente no lugar onde uma criança que remapeou teclas iria notar primeiro.
454
+ *
455
+ * 📌 `null` é o valor honesto de «este jogo não tem opinião», e é também o que o `desmontar()` escreve.
456
+ */
457
+ function registrarMapeamentosDoCartucho() {
458
+ registrarMapeamentoDoTeclado(cartucho.declaration.mapeamentoDoTeclado
459
+ ? (jogadores, assento) => cartucho.declaration.mapeamentoDoTeclado(jogadores, assento)
460
+ : null);
461
+ // ⚠️ E O DO CONTROLE REGISTA-SE AQUI AINDA QUE ESTA RAIZ NÃO MONTE GAMEPAD NENHUM. Não é descuido: quem
462
+ // chama `initGamepad` é o cartucho, e é exactamente por isso que o registo não pode viver lá — seria mais
463
+ // um campo que um jogo pode esquecer, e esquecê-lo devolve o mapa da ENGINE a quem declarou outro, calado.
464
+ registrarMapeamentoDoPad(cartucho.declaration.mapeamentoDoPad
465
+ ? (jogadores, assento) => cartucho.declaration.mapeamentoDoPad(jogadores, assento)
466
+ : null);
467
+ }
468
+ registrarMapeamentosDoCartucho();
401
469
  initKB();
402
470
  // ⚠️ O ESQUEMA DE ARRANQUE ALCANÇA NADA, e diz isso com `null` em vez de com um objeto vazio (issue #118).
403
471
  // Ele vive um instante — `assignControls()` logo abaixo substitui-o pelo esquema real —, mas enquanto vive
404
472
  // é um `KeyScheme` como qualquer outro, e a única forma honesta de um esquema que não alcança nada é
405
473
  // catorze ausências declaradas. Um `{}` fazia o tipo mentir sobre estar completo.
406
474
  const semAlcance = Object.fromEntries(ACTIONS.map((a) => [a, null]));
407
- const players = o.players ?? [{ ctrl: semAlcance }];
475
+ // ⚠️ O FALLBACK É UMA CONSTANTE e não um literal novo a cada chamada: `getPlayers` é lido pelo runtime de
476
+ // teclado a cada leitura de controlo, e devolver um array novo de cada vez faria qualquer comparação de
477
+ // identidade mentir — um defeito que só aparece em quem compara, e tarde.
478
+ const semJogadores = [{ ctrl: semAlcance }];
479
+ // ⚠️ LÊ `cartucho.players`, E NÃO UM INSTANTÂNEO. Os getters já existiam; o que eles fechavam é que era um `const`
480
+ // tirado no arranque. As linhas de `initPauseIcons` e do sonar, neste mesmo ficheiro, já liam a fonte viva —
481
+ // esta era a que faltava. Com vários cartuchos numa raiz de composição (ADR-0142), o teclado ficava com os
482
+ // jogadores do cartucho que arrancou primeiro.
483
+ const players = () => cartucho.players ?? semJogadores;
408
484
  const keyboard = initKeyboardRuntime({
409
- getKB: () => kb, getNumPlayers: () => players.length, getPlayers: () => players,
485
+ getKB: () => kb, getNumPlayers: () => players().length, getPlayers: () => players(),
410
486
  });
411
487
  keyboard.assignControls();
412
488
  // 6. Navegação de menu. Os três declínios entram como AUSÊNCIA DECLARADA, não como getter que devolve null.
@@ -414,13 +490,13 @@ export function createGame(o) {
414
490
  $, getActiveElement: () => doc.activeElement,
415
491
  topVisibleOverlay: overlays.topVisibleOverlay, closeById: overlays.closeById,
416
492
  getPauseMenu: (i) => $(`#vp-pause-${i}`),
417
- setPhase: o.setPhase ?? (() => { }),
418
- setPauseActor: () => { },
493
+ setPhase: cartucho.setPhase ?? (() => { }),
494
+ setPauseActor: cartucho.setPauseActor ?? (() => { }),
419
495
  srSay,
420
496
  // Sem opinião declarada, o índice fica LIGADO: quem precisa dele para se orientar não tem como saber
421
497
  // que ele existe se vier desligado (a mesma razão de o modo cego nascer com TTS e sonar).
422
- comIndice: o.comIndice ?? (() => true),
423
- isNavigable: o.isNavigable ?? (() => true),
498
+ comIndice: cartucho.comIndice ?? (() => true),
499
+ isNavigable: cartucho.isNavigable ?? (() => true),
424
500
  /*
425
501
  * ⚠️ ESTE PADRÃO ERA `() => false` / `() => {}`, E DESDE HOJE ISSO SERIA UM BURACO QUE EU ABRI. O
426
502
  * comentário que estava aqui dizia «um hospedeiro que não tenha barra de acessibilidade responde nunca e
@@ -438,8 +514,8 @@ export function createGame(o) {
438
514
  * modo (ADR-0044 item 7). No cartucho ela chega por outra rota (o encaminhador do gamepad, `main.ts:1470`)
439
515
  * que esta raiz ainda não monta. Logo: o direcional navega a barra; sair por START, por enquanto, não.
440
516
  */
441
- naBarraDe: o.naBarraDe ?? ((i) => pauseIcons.naBarraDe(i)),
442
- navBar: o.navBar ?? ((i, k) => pauseIcons.navBar(i, k)),
517
+ naBarraDe: cartucho.naBarraDe ?? ((i) => pauseIcons.naBarraDe(i)),
518
+ navBar: cartucho.navBar ?? ((i, k) => pauseIcons.navBar(i, k)),
443
519
  isCapturing: () => false,
444
520
  closePadWiz: () => { },
445
521
  whichPlayer: (code) => keyboard.whichPlayer(code),
@@ -515,22 +591,50 @@ export function createGame(o) {
515
591
  return false;
516
592
  } },
517
593
  };
518
- const acoesDoJogo = o.preset ? presetActions(o.preset) : [];
519
594
  // O segundo eixo entra aqui, e vem do jogo (ADR-0104 §A): quantas posições ele segura ao mesmo tempo.
520
595
  // ⚠️ O TERCEIRO EIXO ENTRA AQUI (ADR-0112), e vem do jogo tal como os outros dois. `?? false` e não um
521
596
  // padrão inventado: o campo é opcional de propósito — ver a nota nele —, e a ausência significa «este jogo
522
597
  // não desenha», que é a resposta certa para a esmagadora maioria dos trezentos.
523
- const alcanceAqui = alcance(transportesPadrao(disponibilidade), acoesDoJogo, o.declaration.holdsAtOnce(), o.declaration.needsPointer?.() ?? false);
524
- // ⚠️ SÓ APARECE QUANDO HÁ O QUE DIZER. Um aviso que aparece sempre deixa de ser lido, e um jogo cujas ações
525
- // cabem no toque não tem nada a avisar — que é o caso comum e tem de continuar silencioso.
526
- if (acoesDoJogo.length) {
527
- mostrarAvisoDeAlcance({
528
- procurar: (sel) => $(sel),
529
- criar: (tag) => doc.createElement(tag),
530
- t,
531
- srAlert,
532
- }, alcanceAqui);
598
+ /*
599
+ * O ALCANCE E O SEU AVISO, numa função, porque os dois dependem do cartucho e o segundo CRIA DOM.
600
+ *
601
+ * ⚠️ O aviso é o único sítio desta raiz que escreve um elemento a partir de uma resposta do jogo, e por
602
+ * isso é o único que precisa de ser RETIRADO antes de ser reescrito: `mostrarAvisoDeAlcance` cria um `div`
603
+ * com `id` fixo, então chamá-lo duas vezes deixaria dois — e o segundo cartucho ficaria com o aviso do
604
+ * primeiro por baixo do seu.
605
+ *
606
+ * 📌 `retirarAvisoDeAlcance()` corre SEMPRE antes, e não só quando há o que mostrar: um cartucho que não
607
+ * tem nada a avisar tem de apagar o aviso do anterior, e é esse o caso que se esquece.
608
+ */
609
+ function retirarAvisoDeAlcance() {
610
+ const aviso = $(`#${REACH_NOTICE_ID}`);
611
+ if (!aviso)
612
+ return;
613
+ // ⚠️ CAPACIDADE E NÃO TIPO, pela mesma razão que este ficheiro já escreve mais acima sobre o
614
+ // `instanceof HTMLElement`: o hospedeiro pode ser um documento falso, e os que estes testes usam têm
615
+ // `parentNode` mas não `remove`. Perguntar pelo método é o que funciona nos dois.
616
+ if (typeof aviso.remove === 'function')
617
+ aviso.remove();
618
+ else
619
+ aviso.parentNode?.removeChild(aviso);
620
+ }
621
+ function derivarAlcance() {
622
+ const acoes = cartucho.preset ? presetActions(cartucho.preset) : [];
623
+ const a = alcance(transportesPadrao(disponibilidade), acoes, cartucho.declaration.holdsAtOnce(), cartucho.declaration.needsPointer?.() ?? false);
624
+ retirarAvisoDeAlcance();
625
+ // ⚠️ SÓ APARECE QUANDO HÁ O QUE DIZER. Um aviso que aparece sempre deixa de ser lido, e um jogo cujas
626
+ // ações cabem no toque não tem nada a avisar — que é o caso comum e tem de continuar silencioso.
627
+ if (acoes.length) {
628
+ mostrarAvisoDeAlcance({
629
+ procurar: (sel) => $(sel),
630
+ criar: (tag) => doc.createElement(tag),
631
+ t,
632
+ srAlert,
633
+ }, a);
634
+ }
635
+ return a;
533
636
  }
637
+ let alcanceAtual = derivarAlcance();
534
638
  // O ANÚNCIO DE QUE O LAÇO PAROU (ADR-0054). Entregue e não instalado: quem chama `startLoop` é o JOGO, que
535
639
  // é o dono do ticker. Um jogo que monte o laço sem passar isto continua a PARAR — parar não é opcional; o
536
640
  // que ele perde é dizer que parou.
@@ -583,5 +687,57 @@ export function createGame(o) {
583
687
  void baixarPesados({ aoProgredir: o.aoProgredirPesados })
584
688
  .catch(() => { });
585
689
  }
586
- return { declaration: o.declaration, pausa, tts, overlays, nav, keyboard, sonar, aplicarFiltroDeVisao, cenas: criarPilha(), cvdFilters, problems, declines, aoFalhar, alcance: alcanceAqui };
690
+ /*
691
+ * ⚠️ `declaration` E `declines` SÃO GETTERS; o resto não é, e a assimetria é a decisão.
692
+ *
693
+ * Os dois pertencem à metade do JOGO (ADR-0139 §1), logo têm de seguir o cartucho que estiver montado —
694
+ * um campo fixo aqui devolveria, depois de um `mount()`, a declaração do cartucho que arrancou primeiro.
695
+ * `pausa`, `tts`, `overlays`, `nav`, `keyboard` e o sonar são da PÁGINA e existem uma vez só, que é a
696
+ * decisão inteira do ADR-0117 §2 — e é por isso que eles ficam como estão.
697
+ *
698
+ * 📌 `problems` e `alcance` ainda são fixos, e ainda descrevem o arranque. É a dívida que o ADR-0142
699
+ * nomeia e que o `mount()` fecha.
700
+ */
701
+ /*
702
+ * A PILHA DE CENAS É DA RAIZ, e não do retorno, porque o `desmontar()` tem de a alcançar. Nasce uma vez
703
+ * (ADR-0117 §2: a página tem uma) e é esvaziada entre cartuchos, nunca substituída.
704
+ */
705
+ const cenasDaRaiz = criarPilha();
706
+ function montar(declaration, ganchos = {}) {
707
+ // ⚠️ LANÇA, NÃO DIAGNOSTICA — a mesma regra do arranque, e por isso a mesma frase. Uma declaração
708
+ // malformada é pré-condição: `problems` é para lacunas com que se consegue jogar, e isto não é uma.
709
+ const malformada = conformanceProblems(declaration);
710
+ if (malformada.length) {
711
+ recusarDeclaracao('mount', malformada);
712
+ }
713
+ cartucho = { ...ganchos, declaration };
714
+ registrarMapeamentosDoCartucho();
715
+ alcanceAtual = derivarAlcance();
716
+ }
717
+ function desmontar() {
718
+ registrarMapeamentoDoTeclado(null);
719
+ registrarMapeamentoDoPad(null);
720
+ retirarAvisoDeAlcance();
721
+ // ⚠️ `pop()` E NÃO UM `clear()`: cada `exit()` é a limpeza de DOM daquela cena, e saltá-la deixaria na
722
+ // página o que o cartucho anterior desenhou. O laço tem fim porque `pop()` devolve `null` na pilha vazia.
723
+ while (cenasDaRaiz.pop()) { /* o `exit()` de cada cena É o teardown dela */ }
724
+ }
725
+ return {
726
+ get declaration() { return cartucho.declaration; },
727
+ get declines() { return declines(); },
728
+ mount: montar,
729
+ unmount: desmontar,
730
+ pausa,
731
+ tts,
732
+ overlays,
733
+ nav,
734
+ keyboard,
735
+ sonar,
736
+ aplicarFiltroDeVisao,
737
+ cenas: cenasDaRaiz,
738
+ cvdFilters,
739
+ get problems() { return [...problemasDoHospedeiro, ...problemasDoCartucho()]; },
740
+ aoFalhar,
741
+ get alcance() { return alcanceAtual; },
742
+ };
587
743
  }
@@ -195,8 +195,14 @@ export interface AccionaveisDoJogo {
195
195
  * controle fica DESABILITADO com o motivo, porque o aparelho EXIGE a alternância e ela não se pode
196
196
  * desligar. Aqui não há nada a travar, e um controle que explica por que não faz nada continua a ser um
197
197
  * controle que não faz nada.
198
+ *
199
+ * ⚠️ É FUNÇÃO E NÃO VALOR, e a razão é a mesma do ADR-0084: uma resposta lida uma vez envelhece em
200
+ * silêncio. Aqui envelhecia no pior sítio — o `reflectPauseIcons` existe justamente porque «a tabela de
201
+ * acções deste jogo pode ter mudado desde a montagem» (ADR-0106 §5), e refrescava a partir de um booleano
202
+ * congelado no arranque. Com um `createGame` a servir vários cartuchos (ADR-0142), o ícone descrevia o
203
+ * jogo que arrancou primeiro. O contrato nunca esteve errado: `GameDeclaration.seguraTeclas` já é função.
198
204
  */
199
- readonly seguraTeclas: boolean;
205
+ readonly seguraTeclas: () => boolean;
200
206
  }
201
207
  /**
202
208
  * @deprecated O nome dizia «escritores VISUAIS» e a pergunta deixou de ser só visual quando o `seguraTeclas`
@@ -429,8 +435,11 @@ export interface PauseIconsCtx {
429
435
  * 📌 Quem passa pelo `createGame` não escreve isto: a raiz lê a declaração, que já é obrigatória. O campo
430
436
  * só é visível para um cartucho que chame `initPauseIcons` por fora — e é exactamente esse que não pode
431
437
  * ficar em silêncio.
438
+ *
439
+ * ⚠️ FUNÇÃO, não valor — ver a nota no campo homónimo de `EscritoresVisuais`. Um cartucho que monte isto
440
+ * por fora passa `() => this.declaration.seguraTeclas()` e não o resultado dela.
432
441
  */
433
- seguraTeclas: boolean;
442
+ seguraTeclas: () => boolean;
434
443
  }
435
444
  export interface PauseIconsApi {
436
445
  /** Builds one `.screen-pause` (hidden), wired for click + hover/focus caption. Caller appends it. */
@@ -235,7 +235,7 @@ export function iconesQueAccionam(escritores) {
235
235
  // Um terceiro ramo, e não uma regra nova.
236
236
  return PAUSE_ICONS.filter((ic) => (ic.k === 'contrast' ? escritores.tema
237
237
  : ic.k === 'cvd' ? escritores.correcao
238
- : ic.k === 'altmove' ? escritores.seguraTeclas
238
+ : ic.k === 'altmove' ? escritores.seguraTeclas()
239
239
  : true));
240
240
  }
241
241
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-inclusionist/engine",
3
- "version": "8.0.0",
3
+ "version": "9.0.0",
4
4
  "type": "module",
5
5
  "description": "Accessibility-first (WCAG 2.2 + GAG) engine for educational 2D pixel-art games, built with PixiJS. Screen reader, sonar, high contrast by role, colour-vision filters, remappable input as intent, i18n and offline PWA — the parts every game inherits (ADR-0035, ADR-0036).",
6
6
  "comment:exports": [