@aksp/opencrew 1.6.2 → 1.7.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.
- package/CHANGELOG.md +102 -0
- package/README.md +70 -13
- package/package.json +2 -2
- package/src/cli.js +15 -38
- package/src/commands/init.js +41 -44
- package/src/commands/update.js +78 -75
- package/src/lib/blocos.js +148 -0
- package/src/lib/deteccao.js +69 -0
- package/src/lib/fsx.js +1 -55
- package/src/lib/ides.js +4 -0
- package/src/lib/legado.js +142 -0
- package/src/lib/manifest.js +67 -26
- package/src/lib/mcp.js +131 -0
- package/src/lib/migrations.js +77 -74
- package/src/lib/node-version.js +43 -0
- package/src/lib/prompts.js +25 -2
- package/src/lib/resumo.js +125 -0
- package/templates/.mcp.json +1 -1
- package/templates/AGENTS.md +20 -6
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/best-practices/social-networks-publishing.md +14 -14
- package/templates/_opencrew/core/escritorio/animacao.js +64 -0
- package/templates/_opencrew/core/escritorio/app.js +137 -0
- package/templates/_opencrew/core/escritorio/cena.js +132 -0
- package/templates/_opencrew/core/escritorio/demo.js +79 -0
- package/templates/_opencrew/core/escritorio/escala.js +27 -0
- package/templates/_opencrew/core/escritorio/index.html +166 -0
- package/templates/_opencrew/core/escritorio/modelo-agentes.js +93 -0
- package/templates/_opencrew/core/escritorio/modelo-estado.js +71 -0
- package/templates/_opencrew/core/escritorio/modelo-mesas.js +81 -0
- package/templates/_opencrew/core/escritorio/modelo-pagina.js +95 -0
- package/templates/_opencrew/core/escritorio/modelo-textos.js +65 -0
- package/templates/_opencrew/core/escritorio/modelo-visao.js +91 -0
- package/templates/_opencrew/core/escritorio/modelo.js +29 -0
- package/templates/_opencrew/core/escritorio/painel.js +120 -0
- package/templates/_opencrew/core/escritorio/quadro.js +106 -0
- package/templates/_opencrew/core/escritorio/rota.js +62 -0
- package/templates/_opencrew/core/escritorio/rotulos.js +78 -0
- package/templates/_opencrew/core/escritorio/sprites-mesa.js +122 -0
- package/templates/_opencrew/core/escritorio/sprites-sala.js +92 -0
- package/templates/_opencrew/core/escritorio/sprites.js +187 -0
- package/templates/_opencrew/core/prompts/build.prompt.md +3 -3
- package/templates/_opencrew/core/prompts/export.prompt.md +1 -1
- package/templates/_opencrew/core/prompts/repair.prompt.md +7 -12
- package/templates/_opencrew/core/prompts/sherlock-shared.md +5 -5
- package/templates/_opencrew/core/runner.pipeline.md +76 -139
- package/templates/_opencrew/core/scripts/comum.mjs +49 -4
- package/templates/_opencrew/core/scripts/conferir-fontes/busca.mjs +42 -3
- package/templates/_opencrew/core/scripts/conferir-fontes/relatorio.mjs +18 -6
- package/templates/_opencrew/core/scripts/conferir-fontes.mjs +97 -39
- package/templates/_opencrew/core/scripts/escritorio/leitura.mjs +31 -0
- package/templates/_opencrew/core/scripts/escritorio/porta.mjs +98 -0
- package/templates/_opencrew/core/scripts/escritorio/projeto.mjs +29 -0
- package/templates/_opencrew/core/scripts/escritorio/servidor.mjs +78 -0
- package/templates/_opencrew/core/scripts/escritorio.mjs +117 -0
- package/templates/_opencrew/core/scripts/estado/argumentos.mjs +61 -0
- package/templates/_opencrew/core/scripts/estado/arquivo.mjs +53 -0
- package/templates/_opencrew/core/scripts/estado/decisao.mjs +56 -0
- package/templates/_opencrew/core/scripts/estado/elenco.mjs +58 -0
- package/templates/_opencrew/core/scripts/estado/nucleo.mjs +113 -0
- package/templates/_opencrew/core/scripts/estado/preferencia.mjs +24 -0
- package/templates/_opencrew/core/scripts/estado.mjs +96 -0
- package/templates/_opencrew/core/scripts/verificar.mjs +7 -4
- package/templates/_opencrew/core/skills.engine.md +7 -3
- package/templates/gitignore +1 -0
- package/templates/skills/blotato/SKILL.md +39 -10
- package/templates/skills/image-ai-generator/SKILL.md +18 -5
- package/templates/skills/image-ai-generator/scripts/generate.py +52 -10
- package/templates/skills/instagram-publisher/SKILL.md +4 -0
- package/templates/skills/opencrew-skill-creator/references/skill-format.md +1 -0
- package/templates/skills/resend/SKILL.md +52 -13
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Demonstração do escritório (regra 23): um roteiro fixo e uma função pura que devolve o estado
|
|
2
|
+
// de cada instante, no formato do `state.json`. O roteiro passa por todos os status de agente,
|
|
3
|
+
// por um checkpoint, por três passagens de bastão, por uma falha e pela execução concluída, e
|
|
4
|
+
// recomeça. A página o desenha pelo mesmo caminho de uma execução de verdade.
|
|
5
|
+
// Puro: sem DOM, sem relógio, sem rede.
|
|
6
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regras 18 e 23 (repositório do OpenCrew).
|
|
7
|
+
|
|
8
|
+
const ELENCO = [
|
|
9
|
+
{ id: 'pesquisa', name: 'Pedro Pesquisa', icon: '🔎' },
|
|
10
|
+
{ id: 'redacao', name: 'Rita Redação', icon: '✍️' },
|
|
11
|
+
{ id: 'design', name: 'Davi Design', icon: '🎨' },
|
|
12
|
+
{ id: 'revisao', name: 'Renata Revisão', icon: '🧐' },
|
|
13
|
+
{ id: 'social', name: 'Sara Social', icon: '📣' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const [I, W, C, D, S, F] = ['idle', 'working', 'checkpoint', 'done', 'skipped', 'failed'];
|
|
17
|
+
|
|
18
|
+
// Cada cena: duração, status da execução, passo (`n` e `rotulo`) e o status de cada agente, na
|
|
19
|
+
// ordem do elenco. `passagem` ([de, para, mensagem]) marca a cena em que o bastão troca de mão.
|
|
20
|
+
const CENAS = [
|
|
21
|
+
{ ms: 3000, status: 'running', n: 0, rotulo: '', agentes: [I, I, I, I, S] },
|
|
22
|
+
{ ms: 6000, status: 'running', n: 1, rotulo: 'Pesquisar o tema', agentes: [W, I, I, I, S] },
|
|
23
|
+
{ ms: 5000, status: 'checkpoint', n: 1, rotulo: 'Aprovar a pauta', agentes: [C, I, I, I, S] },
|
|
24
|
+
{ ms: 7000, status: 'running', n: 2, rotulo: 'Escrever o texto', agentes: [D, W, I, I, S], passagem: ['pesquisa', 'redacao', 'Pauta aprovada, com três fontes.'] },
|
|
25
|
+
{ ms: 7000, status: 'running', n: 3, rotulo: 'Criar a imagem', agentes: [D, D, W, I, S], passagem: ['redacao', 'design', 'Texto pronto, com título e chamada.'] },
|
|
26
|
+
{ ms: 5000, status: 'failed', n: 3, rotulo: 'Criar a imagem', agentes: [D, D, F, I, S], motivo: 'Sem acesso ao banco de imagens.' },
|
|
27
|
+
{ ms: 6000, status: 'running', n: 3, rotulo: 'Criar a imagem', agentes: [D, D, W, I, S] },
|
|
28
|
+
{ ms: 7000, status: 'running', n: 4, rotulo: 'Revisar tudo', agentes: [D, D, D, W, S], passagem: ['design', 'revisao', 'Imagem pronta, no formato do post.'] },
|
|
29
|
+
{ ms: 8000, status: 'completed', n: 4, rotulo: 'Revisar tudo', agentes: [D, D, D, D, S] },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** O roteiro, como dado: o elenco e as cenas, na ordem. */
|
|
33
|
+
export const ROTEIRO = Object.freeze({ crew: 'crew-de-exemplo', total: 4, agentes: ELENCO, cenas: CENAS });
|
|
34
|
+
|
|
35
|
+
const INICIOS = CENAS.map((_, i) => CENAS.slice(0, i).reduce((soma, cena) => soma + cena.ms, 0));
|
|
36
|
+
/** Duração de uma volta do roteiro. */
|
|
37
|
+
export const DURACAO_DEMO_MS = INICIOS.at(-1) + CENAS.at(-1).ms;
|
|
38
|
+
// Data fixa das passagens: só precisa mudar de uma passagem para a outra.
|
|
39
|
+
const BASE = Date.UTC(2026, 0, 5, 9);
|
|
40
|
+
|
|
41
|
+
/** Índice da cena em que cai o instante; o roteiro recomeça ao chegar ao fim. */
|
|
42
|
+
function cenaEm(decorridoMs) {
|
|
43
|
+
const t = Number.isFinite(decorridoMs) && decorridoMs > 0 ? decorridoMs % DURACAO_DEMO_MS : 0;
|
|
44
|
+
return INICIOS.findLastIndex((inicio) => inicio <= t);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** O `label` do agente: o rótulo do último passo em que ele trabalhou, até a cena `ate`. */
|
|
48
|
+
function rotuloDe(agente, ate) {
|
|
49
|
+
const feitas = CENAS.slice(0, ate + 1).filter((cena) => cena.agentes[agente] === W);
|
|
50
|
+
return feitas.length ? feitas.at(-1).rotulo : '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A última passagem de bastão até a cena `ate`, no formato do `handoff`. */
|
|
54
|
+
function passagemAte(ate) {
|
|
55
|
+
const cena = CENAS.slice(0, ate + 1).findLastIndex((c) => c.passagem);
|
|
56
|
+
if (cena < 0) return null;
|
|
57
|
+
const [from, to, message] = CENAS[cena].passagem;
|
|
58
|
+
return { from, to, message, completedAt: new Date(BASE + INICIOS[cena]).toISOString() };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* O estado da demonstração num instante. Mesmo instante, mesmo estado; depois de
|
|
63
|
+
* `DURACAO_DEMO_MS` o roteiro recomeça. Vem sem `updatedAt`: a demonstração nunca fica "sem sinal".
|
|
64
|
+
* @param {number} decorridoMs tempo desde que a demonstração começou (inválido vale como 0)
|
|
65
|
+
* @returns {object} um estado no formato do `state.json`
|
|
66
|
+
*/
|
|
67
|
+
export function estadoDaDemo(decorridoMs) {
|
|
68
|
+
const indice = cenaEm(decorridoMs);
|
|
69
|
+
const cena = CENAS[indice];
|
|
70
|
+
return {
|
|
71
|
+
crew: ROTEIRO.crew,
|
|
72
|
+
status: cena.status,
|
|
73
|
+
step: { current: cena.n, total: ROTEIRO.total, label: cena.rotulo },
|
|
74
|
+
agents: ELENCO.map((agente, i) => ({ ...agente, status: cena.agentes[i], label: rotuloDe(i, indice) })),
|
|
75
|
+
handoff: passagemAte(indice),
|
|
76
|
+
...(cena.motivo ? { motivo: cena.motivo } : {}),
|
|
77
|
+
updatedAt: null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Escala do canvas (regra 16): a tela interna de 320×180 só é ampliada em escala inteira, e a
|
|
2
|
+
// conta é feita em pixels do dispositivo, para o pixel continuar quadrado com a tela do sistema
|
|
3
|
+
// em 125% ou 150%.
|
|
4
|
+
// Puro: sem DOM, sem relógio, sem rede. O tamanho disponível e a razão de pixels entram por
|
|
5
|
+
// parâmetro.
|
|
6
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regra 16 (repositório do OpenCrew).
|
|
7
|
+
import { ALTURA, LARGURA } from './modelo-mesas.js';
|
|
8
|
+
|
|
9
|
+
// Folga da conta com decimais: um canvas do tamanho exato que esta função devolveu tem de caber
|
|
10
|
+
// de novo (com razão 1,7 e escala 5, a divisão dá 4,999999999999999).
|
|
11
|
+
const FOLGA = 1e-9;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A maior escala inteira em que a tela cabe no espaço disponível.
|
|
15
|
+
* @param {number} largura largura disponível, em pixels de CSS
|
|
16
|
+
* @param {number} altura altura disponível, em pixels de CSS
|
|
17
|
+
* @param {number} dpr pixels do dispositivo por pixel de CSS; inválido vale como 1
|
|
18
|
+
* @returns {{ n: number, largura: number, altura: number }} `n`: o maior inteiro N ≥ 1 em que
|
|
19
|
+
* 320·N × 180·N cabe em largura·dpr × altura·dpr (se nem N = 1 cabe, 1: a página rola) ·
|
|
20
|
+
* `largura` e `altura`: o tamanho do canvas em pixels de CSS (320·N/dpr × 180·N/dpr)
|
|
21
|
+
*/
|
|
22
|
+
export function escala(largura, altura, dpr) {
|
|
23
|
+
const razao = Number.isFinite(dpr) && dpr > 0 ? dpr : 1;
|
|
24
|
+
const cabe = Math.min((largura * razao) / LARGURA, (altura * razao) / ALTURA);
|
|
25
|
+
const n = Number.isFinite(cabe) ? Math.max(1, Math.floor(cabe + FOLGA)) : 1;
|
|
26
|
+
return { n, largura: (LARGURA * n) / razao, altura: (ALTURA * n) / razao };
|
|
27
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="pt-BR">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="dark">
|
|
7
|
+
<title>Escritório</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: dark;
|
|
11
|
+
--fundo: #12131c;
|
|
12
|
+
--painel: #1c1e2c;
|
|
13
|
+
--borda: #2e3350;
|
|
14
|
+
--poco: #0b0c12;
|
|
15
|
+
--tinta: #1a1c2c;
|
|
16
|
+
--papel: #f4f4ee;
|
|
17
|
+
--texto: #ecebe4;
|
|
18
|
+
--apagado: #9aa1b8;
|
|
19
|
+
--ambar: #f4a73b;
|
|
20
|
+
--verde: #5fd47a;
|
|
21
|
+
--vermelho: #e0564c;
|
|
22
|
+
--ceu: #7ec4e0;
|
|
23
|
+
--mono: ui-monospace, "Cascadia Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
|
|
24
|
+
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
25
|
+
}
|
|
26
|
+
* { box-sizing: border-box; }
|
|
27
|
+
[hidden] { display: none !important; }
|
|
28
|
+
html, body { height: 100%; }
|
|
29
|
+
body { display: flex; flex-direction: column; height: 100vh; height: 100dvh; margin: 0; background: var(--fundo); color: var(--texto); font: 15px/1.45 var(--sans); }
|
|
30
|
+
p, h1, h2, ul { margin: 0; }
|
|
31
|
+
|
|
32
|
+
/* Cabeçalho: a marca, a crew em exibição e o seletor (só com mais de uma crew). */
|
|
33
|
+
.topo { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 12px 16px 10px; background: var(--painel); border-bottom: 4px solid var(--borda); }
|
|
34
|
+
.marca { font: 700 14px/1.2 var(--mono); letter-spacing: .2em; text-transform: uppercase; color: var(--ambar); }
|
|
35
|
+
.marca::before { content: ""; display: inline-block; width: 10px; height: 10px; margin-right: 10px; background: var(--ambar); box-shadow: 4px 4px 0 var(--poco); }
|
|
36
|
+
.crew { font: 700 18px/1.2 var(--mono); overflow-wrap: anywhere; }
|
|
37
|
+
.seletor { margin-left: auto; font: 12px/1.2 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--apagado); }
|
|
38
|
+
.seletor select { max-width: 60vw; margin-left: 6px; padding: 4px 8px; font: 700 13px var(--mono); color: var(--texto); background: var(--poco); border: 2px solid var(--borda); border-radius: 0; }
|
|
39
|
+
.seletor select:focus-visible { outline: 2px solid var(--ambar); outline-offset: 2px; }
|
|
40
|
+
|
|
41
|
+
/* A frase de abertura fica na tela até o primeiro desenho; sem JavaScript, é o que se lê. */
|
|
42
|
+
.abertura { max-width: 64ch; margin: 24px 16px; padding: 14px 16px; font: 15px/1.5 var(--mono); background: var(--painel); border: 2px solid var(--borda); border-left: 6px solid var(--ambar); }
|
|
43
|
+
|
|
44
|
+
.pagina { flex: 1 1 auto; display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 320px); gap: 16px; min-height: 0; padding: 14px 16px 16px; }
|
|
45
|
+
.palco { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; }
|
|
46
|
+
|
|
47
|
+
/* O passo atual, com a contagem e a barra de progresso. */
|
|
48
|
+
.andamento { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; min-height: 28px; }
|
|
49
|
+
.situacao { padding: 2px 8px; font: 700 12px/1.5 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--tinta); background: var(--ceu); box-shadow: 3px 3px 0 var(--poco); }
|
|
50
|
+
[data-execucao="checkpoint"] .situacao { background: var(--ambar); }
|
|
51
|
+
[data-execucao="completed"] .situacao { background: var(--verde); }
|
|
52
|
+
[data-execucao="failed"] .situacao { background: var(--vermelho); }
|
|
53
|
+
.passo { font: 700 15px/1.3 var(--mono); overflow-wrap: anywhere; }
|
|
54
|
+
.barra { position: relative; flex: 1 1 180px; display: flex; height: 14px; background: var(--poco); border: 2px solid var(--borda); }
|
|
55
|
+
.barra span { flex: 0 0 0px; }
|
|
56
|
+
#barra-feito { background: var(--verde); }
|
|
57
|
+
#barra-atual { background: var(--ambar); animation: pulsar 1s steps(1, end) infinite; }
|
|
58
|
+
[data-execucao="failed"] #barra-atual { background: var(--vermelho); animation: none; }
|
|
59
|
+
.barra::after { content: ""; position: absolute; inset: 0; background: repeating-linear-gradient(90deg, transparent 0, transparent calc(100% / var(--total, 1) - 2px), var(--poco) calc(100% / var(--total, 1) - 2px), var(--poco) calc(100% / var(--total, 1))); }
|
|
60
|
+
@keyframes pulsar { 0% { opacity: 1; } 50% { opacity: .45; } }
|
|
61
|
+
|
|
62
|
+
.avisos { display: flex; flex-direction: column; gap: 4px; min-height: 26px; }
|
|
63
|
+
.aviso, .frase { padding: 3px 10px; font: 13px/1.5 var(--mono); border-left: 6px solid; }
|
|
64
|
+
.aviso { color: #ffd9d4; background: #2a1719; border-color: var(--vermelho); }
|
|
65
|
+
.frase { color: #ffe2b0; background: #2a2113; border-color: var(--ambar); }
|
|
66
|
+
|
|
67
|
+
/* O desenho: o canvas em escala inteira e, por cima, a camada de nomes e balões. A área é medida
|
|
68
|
+
pela página para escolher a escala; a moldura em volta dá espaço à borda e rola se nem a
|
|
69
|
+
escala 1 couber. */
|
|
70
|
+
.moldura { flex: 1 1 auto; display: flex; min-height: 200px; padding: 10px; overflow: auto; }
|
|
71
|
+
.area { flex: 1 1 0; display: flex; min-width: 0; min-height: 0; }
|
|
72
|
+
.sala { position: relative; flex: none; width: 320px; height: 180px; margin: auto; box-shadow: 0 0 0 4px var(--borda), 6px 6px 0 4px #07080d; --u: 1; --fio: max(1px, calc(var(--u) * .5px)); }
|
|
73
|
+
.sala canvas { display: block; width: 100%; height: 100%; image-rendering: crisp-edges; image-rendering: pixelated; }
|
|
74
|
+
.rotulos { position: absolute; inset: 0; overflow: hidden; pointer-events: none; font: 700 clamp(9px, calc(var(--u) * 4.5px), 15px)/1.15 var(--mono); }
|
|
75
|
+
.rotulos div { position: absolute; display: flex; justify-content: center; transition: opacity .15s linear; }
|
|
76
|
+
.rotulos span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
77
|
+
.nome span { padding: 0 calc(var(--u) * 1.5px); color: var(--papel); background: rgba(26, 28, 44, .8); }
|
|
78
|
+
.balao { padding-bottom: calc(var(--u) * 2px); }
|
|
79
|
+
.balao span { padding: 0 calc(var(--u) * 2px); color: var(--tinta); background: var(--papel); border: var(--fio) solid var(--tinta); }
|
|
80
|
+
.balao::after { content: ""; position: absolute; bottom: 0; left: calc(50% - var(--u) * 1px - var(--fio)); width: calc(var(--u) * 2px + var(--fio) * 2); height: calc(var(--u) * 2px + var(--fio)); background: var(--papel); border: solid var(--tinta); border-width: 0 var(--fio) var(--fio); }
|
|
81
|
+
.balao.espera span, .balao.espera::after { background: var(--ambar); }
|
|
82
|
+
.faixa { position: absolute; top: 0; right: 0; left: 0; height: calc(var(--u) * 13px); padding: 0 calc(var(--u) * 4px); overflow: hidden; font: 700 clamp(10px, calc(var(--u) * 5.5px), 20px)/calc(var(--u) * 11px) var(--mono); text-align: center; text-overflow: ellipsis; white-space: nowrap; color: var(--tinta); background: var(--ambar); border-bottom: calc(var(--u) * 2px) solid var(--tinta); }
|
|
83
|
+
.etiqueta { position: absolute; right: calc(var(--u) * 3px); bottom: calc(var(--u) * 3px); padding: 0 calc(var(--u) * 2px); font: 700 clamp(10px, calc(var(--u) * 5px), 16px)/1.4 var(--mono); letter-spacing: .12em; text-transform: uppercase; color: var(--ambar); background: rgba(26, 28, 44, .85); border: var(--fio) solid var(--ambar); }
|
|
84
|
+
|
|
85
|
+
.rodape { display: flex; flex-direction: column; gap: 2px; min-height: 42px; }
|
|
86
|
+
.nota { font: 13px/1.5 var(--mono); color: var(--apagado); overflow-wrap: anywhere; }
|
|
87
|
+
.nota.falha { color: #ffd9d4; }
|
|
88
|
+
|
|
89
|
+
/* A lista: todo agente, com o status por extenso e o que fez. */
|
|
90
|
+
.lado { display: flex; flex-direction: column; min-height: 0; background: var(--painel); border: 4px solid var(--borda); box-shadow: 6px 6px 0 #07080d; }
|
|
91
|
+
.lado h2 { padding: 8px 12px; font: 700 12px/1.4 var(--mono); letter-spacing: .2em; text-transform: uppercase; color: var(--apagado); border-bottom: 2px solid var(--borda); }
|
|
92
|
+
.lista { flex: 1 1 auto; padding: 0; overflow: auto; list-style: none; }
|
|
93
|
+
.agente { display: grid; grid-template-columns: 10px 1.6em minmax(0, 1fr); column-gap: 10px; align-items: start; padding: 9px 12px; border-bottom: 2px solid var(--borda); }
|
|
94
|
+
.agente .cor { width: 10px; height: 10px; margin-top: 5px; box-shadow: 0 0 0 2px var(--tinta); }
|
|
95
|
+
.agente .icone { font-size: 17px; line-height: 1.2; text-align: center; }
|
|
96
|
+
.agente .nome { display: block; font: 700 14px/1.3 var(--mono); overflow-wrap: anywhere; }
|
|
97
|
+
.agente .status { display: block; font: 12px/1.5 var(--mono); color: var(--apagado); }
|
|
98
|
+
.agente .sinal { display: inline-block; width: 1.4em; }
|
|
99
|
+
.agente .fez { display: block; font-size: 13px; line-height: 1.4; overflow-wrap: anywhere; opacity: .85; }
|
|
100
|
+
.agente[data-acao="working"], .agente[data-acao="checkpoint"] { background: #222538; }
|
|
101
|
+
.agente[data-acao="working"] .status { color: var(--verde); }
|
|
102
|
+
.agente[data-acao="checkpoint"] .status { color: var(--ambar); }
|
|
103
|
+
.agente[data-acao="done"] .status { color: var(--ceu); }
|
|
104
|
+
.agente[data-acao="failed"] .status { color: var(--vermelho); }
|
|
105
|
+
.agente[data-acao="sem-sinal"] .status { color: var(--texto); }
|
|
106
|
+
.agente[data-acao="skipped"] { opacity: .55; }
|
|
107
|
+
|
|
108
|
+
/* Tela estreita: a lista desce para baixo do desenho e a página rola. */
|
|
109
|
+
@media (max-width: 860px) {
|
|
110
|
+
body { height: auto; min-height: 100vh; min-height: 100dvh; }
|
|
111
|
+
.pagina { display: flex; flex-direction: column; padding: 12px; }
|
|
112
|
+
.moldura { flex: none; min-height: 0; padding: 6px; }
|
|
113
|
+
.area { aspect-ratio: 16 / 9; }
|
|
114
|
+
.lista { overflow: visible; }
|
|
115
|
+
}
|
|
116
|
+
@media (max-width: 359px) {
|
|
117
|
+
.pagina, .moldura { padding-right: 0; padding-left: 0; }
|
|
118
|
+
}
|
|
119
|
+
@media (prefers-reduced-motion: reduce) {
|
|
120
|
+
#barra-atual { animation: none; }
|
|
121
|
+
.rotulos div { transition: none; }
|
|
122
|
+
}
|
|
123
|
+
</style>
|
|
124
|
+
</head>
|
|
125
|
+
<body>
|
|
126
|
+
<header class="topo">
|
|
127
|
+
<h1 class="marca">Escritório</h1>
|
|
128
|
+
<p class="crew" id="crew" hidden></p>
|
|
129
|
+
<label class="seletor" id="seletor-caixa" hidden>Crew em exibição <select id="seletor"></select></label>
|
|
130
|
+
</header>
|
|
131
|
+
<p class="abertura" id="abertura">Abrindo o escritório… Se esta mensagem não sumir, abra pelo endereço que o comando /opencrew dashboard mostrou, e não pelo arquivo.</p>
|
|
132
|
+
<main class="pagina" id="pagina" hidden>
|
|
133
|
+
<section class="palco" aria-label="Sala do escritório">
|
|
134
|
+
<div class="andamento">
|
|
135
|
+
<span class="situacao" id="situacao" hidden></span>
|
|
136
|
+
<p class="passo" id="passo" role="status" hidden></p>
|
|
137
|
+
<div class="barra" id="barra" aria-hidden="true" hidden><span id="barra-feito"></span><span id="barra-atual"></span><span id="barra-resto"></span></div>
|
|
138
|
+
</div>
|
|
139
|
+
<div class="avisos">
|
|
140
|
+
<p class="aviso" id="aviso" role="alert" hidden></p>
|
|
141
|
+
<p class="frase" id="frase" hidden></p>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="moldura">
|
|
144
|
+
<div class="area" id="moldura">
|
|
145
|
+
<div class="sala" id="sala">
|
|
146
|
+
<canvas id="tela" width="320" height="180" role="img" aria-label="Desenho do escritório em pixel-art. A lista Equipe diz o que cada agente está fazendo."></canvas>
|
|
147
|
+
<p class="faixa" id="faixa" role="status" hidden></p>
|
|
148
|
+
<div class="rotulos" id="rotulos" aria-hidden="true"></div>
|
|
149
|
+
<span class="etiqueta" id="etiqueta" hidden>Demonstração</span>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
<div class="rodape">
|
|
154
|
+
<p class="nota" id="parada" hidden></p>
|
|
155
|
+
<p class="nota falha" id="motivo" hidden></p>
|
|
156
|
+
<p class="nota" id="passagem" hidden></p>
|
|
157
|
+
</div>
|
|
158
|
+
</section>
|
|
159
|
+
<aside class="lado" aria-label="Equipe">
|
|
160
|
+
<h2>Equipe</h2>
|
|
161
|
+
<ul class="lista" id="lista"></ul>
|
|
162
|
+
</aside>
|
|
163
|
+
</main>
|
|
164
|
+
<script type="module" src="app.js"></script>
|
|
165
|
+
</body>
|
|
166
|
+
</html>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// O que cada agente mostra: a ação do boneco e o monitor (tabela da regra 18), o balão, a
|
|
2
|
+
// aparência e a linha dele na lista ao lado do desenho.
|
|
3
|
+
// Puro: sem DOM, sem relógio, sem rede.
|
|
4
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regras 16, 18, 21 e 24 (repositório do OpenCrew).
|
|
5
|
+
import { mesaDe } from './modelo-mesas.js';
|
|
6
|
+
import { STATUS_TEXTO, TEXTOS } from './modelo-textos.js';
|
|
7
|
+
|
|
8
|
+
/** Cor da camisa de cada mesa, na ordem das mesas: 12 cores, nenhuma repetida. */
|
|
9
|
+
export const CAMISAS = Object.freeze([
|
|
10
|
+
'#d95763', '#5b6ee1', '#fbf236', '#37946e',
|
|
11
|
+
'#df7126', '#76428a', '#5fcde4', '#d77bba',
|
|
12
|
+
'#99e550', '#3f3f74', '#cbdbfc', '#8a6f30',
|
|
13
|
+
]);
|
|
14
|
+
export const CABELOS = Object.freeze(['#222034', '#45283c', '#b0432b', '#f2c94c', '#9aa0a8', '#e9e4d8']);
|
|
15
|
+
export const PELES = Object.freeze(['#f2d3b1', '#e0ac69', '#c68642', '#8d5524', '#6b4226']);
|
|
16
|
+
|
|
17
|
+
/** Número fixo para cada texto (FNV-1a, 32 bits): o mesmo `id` dá sempre o mesmo. */
|
|
18
|
+
function semente(texto) {
|
|
19
|
+
let h = 2166136261;
|
|
20
|
+
for (let i = 0; i < texto.length; i++) h = Math.imul(h ^ texto.charCodeAt(i), 16777619);
|
|
21
|
+
return h >>> 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cores do boneco, em `#rrggbb`: a camisa vem da posição da mesa; cabelo e pele vêm do `id`,
|
|
26
|
+
* e por isso não mudam quando o agente troca de mesa.
|
|
27
|
+
* @param {string} id
|
|
28
|
+
* @param {number} indiceDaMesa posição no elenco; fora das 12 mesas, `camisa` é `null`
|
|
29
|
+
*/
|
|
30
|
+
export function aparencia(id, indiceDaMesa) {
|
|
31
|
+
const h = semente(String(id));
|
|
32
|
+
return {
|
|
33
|
+
camisa: CAMISAS[indiceDaMesa] ?? null,
|
|
34
|
+
cabelo: CABELOS[h % CABELOS.length],
|
|
35
|
+
pele: PELES[(h >>> 8) % PELES.length],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Monitor de cada ação (regra 18). `animado`: linhas correndo (aceso) ou piscando 1 vez por
|
|
40
|
+
// segundo (âmbar); o vermelho é fixo. `sem-sinal` é o desenho de `idle` com um "?" em cima.
|
|
41
|
+
const MONITOR = Object.freeze({
|
|
42
|
+
idle: { cor: 'apagado' },
|
|
43
|
+
working: { cor: 'aceso', animado: true },
|
|
44
|
+
checkpoint: { cor: 'ambar', animado: true },
|
|
45
|
+
done: { cor: 'apagado' },
|
|
46
|
+
skipped: { cor: 'apagado' },
|
|
47
|
+
failed: { cor: 'vermelho' },
|
|
48
|
+
'sem-sinal': { cor: 'apagado' },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** A ação é o status, menos para quem trabalha numa execução em checkpoint ou sem sinal. */
|
|
52
|
+
function acaoDe(status, contexto) {
|
|
53
|
+
if (status !== 'working') return status;
|
|
54
|
+
if (contexto.execucao === 'checkpoint') return 'idle';
|
|
55
|
+
return contexto.semSinal ? 'sem-sinal' : 'working';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function balaoDe(acao, contexto) {
|
|
59
|
+
if (acao === 'working') return contexto.balaoDoPasso;
|
|
60
|
+
return acao === 'checkpoint' ? TEXTOS.aguardando : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Um agente como a página o mostra.
|
|
65
|
+
* @param {object} agente item de `agents` do estado normalizado
|
|
66
|
+
* @param {number} indice posição no elenco
|
|
67
|
+
* @param {object} contexto `{ execucao, balaoDoPasso, semSinal, reduzirMovimento }`, em que
|
|
68
|
+
* `semSinal` é o texto "Sem sinal há …" ou `null`
|
|
69
|
+
* @returns {object} `{ id, indice, nome, icone, status, statusTexto, label, acao, monitor,
|
|
70
|
+
* monitorAnimado, balao, mesa, aparencia }` · `status`: o do arquivo · `acao`: o que o boneco
|
|
71
|
+
* faz (`idle`, `working`, `checkpoint`, `done`, `skipped`, `failed` ou `sem-sinal`) ·
|
|
72
|
+
* `monitor`: `apagado`, `aceso`, `ambar` ou `vermelho` · `balao`: texto ou `null` · `mesa`:
|
|
73
|
+
* a de `MESAS`, ou `null` do 13º em diante
|
|
74
|
+
*/
|
|
75
|
+
export function verAgente(agente, indice, contexto) {
|
|
76
|
+
const acao = acaoDe(agente.status, contexto);
|
|
77
|
+
const { cor, animado = false } = MONITOR[acao];
|
|
78
|
+
return {
|
|
79
|
+
id: agente.id,
|
|
80
|
+
indice,
|
|
81
|
+
nome: agente.name,
|
|
82
|
+
icone: agente.icon,
|
|
83
|
+
status: agente.status,
|
|
84
|
+
statusTexto: acao === 'sem-sinal' ? contexto.semSinal : STATUS_TEXTO[agente.status],
|
|
85
|
+
label: agente.label,
|
|
86
|
+
acao,
|
|
87
|
+
monitor: cor,
|
|
88
|
+
monitorAnimado: animado && !contexto.reduzirMovimento,
|
|
89
|
+
balao: balaoDe(acao, contexto),
|
|
90
|
+
mesa: mesaDe(indice),
|
|
91
|
+
aparencia: aparencia(agente.id, indice),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Leitura do estado da execução (`crews/<crew>/state.json`) como ele chega do servidor: aceita o
|
|
2
|
+
// arquivo da 1.6.x e devolve sempre o mesmo formato, sem alterar o objeto recebido.
|
|
3
|
+
// Puro: sem DOM, sem relógio, sem rede.
|
|
4
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, §4 e regra 18 (repositório do OpenCrew).
|
|
5
|
+
|
|
6
|
+
/** Status de agente que a página conhece, na ordem da tabela da regra 18. */
|
|
7
|
+
export const STATUS_DE_AGENTE = Object.freeze(['idle', 'working', 'checkpoint', 'done', 'skipped', 'failed']);
|
|
8
|
+
export const STATUS_DE_EXECUCAO = Object.freeze(['running', 'checkpoint', 'completed', 'failed']);
|
|
9
|
+
|
|
10
|
+
const ehObjeto = (valor) => valor !== null && typeof valor === 'object' && !Array.isArray(valor);
|
|
11
|
+
const inteiro = (valor, minimo) => (Number.isInteger(valor) && valor >= minimo ? valor : null);
|
|
12
|
+
|
|
13
|
+
function texto(valor) {
|
|
14
|
+
if (typeof valor === 'string') return valor;
|
|
15
|
+
return typeof valor === 'number' ? String(valor) : '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `delivering` (arquivo antigo) vale como `done`; status desconhecido vale como `idle`. */
|
|
19
|
+
function statusDoAgente(valor) {
|
|
20
|
+
if (valor === 'delivering') return 'done';
|
|
21
|
+
return STATUS_DE_AGENTE.includes(valor) ? valor : 'idle';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function lerAgente(bruto, indice) {
|
|
25
|
+
const id = texto(bruto.id) || `agente-${indice + 1}`;
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
name: texto(bruto.name) || id,
|
|
29
|
+
icon: texto(bruto.icon),
|
|
30
|
+
status: statusDoAgente(bruto.status),
|
|
31
|
+
label: texto(bruto.label),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function lerPasso(bruto) {
|
|
36
|
+
const passo = ehObjeto(bruto) ? bruto : {};
|
|
37
|
+
return { current: inteiro(passo.current, 0) ?? 0, total: inteiro(passo.total, 1), label: texto(passo.label) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function lerPassagem(bruto) {
|
|
41
|
+
if (!ehObjeto(bruto) || !texto(bruto.from) || !texto(bruto.to)) return null;
|
|
42
|
+
return { from: texto(bruto.from), to: texto(bruto.to), message: texto(bruto.message), completedAt: texto(bruto.completedAt) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function lerData(valor) {
|
|
46
|
+
const ms = typeof valor === 'string' ? Date.parse(valor) : Number.NaN;
|
|
47
|
+
return Number.isFinite(ms) ? ms : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* O estado bruto, conferido campo a campo. Nunca lança erro.
|
|
52
|
+
* @param {unknown} bruto o conteúdo de um `state.json`
|
|
53
|
+
* @returns {object|null} `null` ("sem estado") quando a entrada não é objeto ou `agents` não é
|
|
54
|
+
* lista; senão `{ crew, status, step: { current, total, label }, agents: [{ id, name, icon,
|
|
55
|
+
* status, label }], handoff, motivo, updatedAtMs }` · `status`: execução `idle` (1.6.x) ou
|
|
56
|
+
* desconhecida vale como `running` · `step.total` e `updatedAtMs`: `null` quando vazios ou
|
|
57
|
+
* inválidos · `handoff`: `{ from, to, message, completedAt }` ou `null` · item de `agents`
|
|
58
|
+
* que não é objeto fica de fora
|
|
59
|
+
*/
|
|
60
|
+
export function normalizar(bruto) {
|
|
61
|
+
if (!ehObjeto(bruto) || !Array.isArray(bruto.agents)) return null;
|
|
62
|
+
return {
|
|
63
|
+
crew: texto(bruto.crew),
|
|
64
|
+
status: STATUS_DE_EXECUCAO.includes(bruto.status) ? bruto.status : 'running',
|
|
65
|
+
step: lerPasso(bruto.step),
|
|
66
|
+
agents: bruto.agents.filter(ehObjeto).map(lerAgente),
|
|
67
|
+
handoff: lerPassagem(bruto.handoff),
|
|
68
|
+
motivo: texto(bruto.motivo),
|
|
69
|
+
updatedAtMs: lerData(bruto.updatedAt),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Geometria do escritório: a tela lógica de 320×180 e as 12 mesas, em grade de 4 colunas, pela
|
|
2
|
+
// ordem do elenco. Tudo em pixel lógico e inteiro; a página aplica em porcentagem da tela.
|
|
3
|
+
// Puro: sem DOM, sem relógio, sem rede.
|
|
4
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regras 16, 17 e 19 (repositório do OpenCrew).
|
|
5
|
+
|
|
6
|
+
export const LARGURA = 320;
|
|
7
|
+
export const ALTURA = 180;
|
|
8
|
+
/** Lado do molde do boneco. */
|
|
9
|
+
export const BONECO = 16;
|
|
10
|
+
export const COLUNAS = 4;
|
|
11
|
+
export const LINHAS = 3;
|
|
12
|
+
export const MAX_MESAS = COLUNAS * LINHAS;
|
|
13
|
+
/** Largura de uma coluna: é também a largura do nome e do balão na página. */
|
|
14
|
+
export const LARGURA_COLUNA = LARGURA / COLUNAS;
|
|
15
|
+
/** Altura da parede, no alto da sala; a primeira linha de mesas começa logo abaixo. */
|
|
16
|
+
export const TOPO = 18;
|
|
17
|
+
export const ALTURA_LINHA = (ALTURA - TOPO) / LINHAS;
|
|
18
|
+
/** Tamanho do tampo de toda mesa. */
|
|
19
|
+
export const MESA = Object.freeze({ largura: 44, altura: 11 });
|
|
20
|
+
|
|
21
|
+
// Cada célula (80×54), de cima para baixo: 7 px para o sinal sobre a cabeça, o boneco sentado
|
|
22
|
+
// (16), 1 px de folga, o tampo da mesa (11) e o corredor da frente (19), onde um boneco em pé
|
|
23
|
+
// cabe sem encostar em nada. Nenhum boneco, sentado ou andando pelos vãos, encosta num tampo.
|
|
24
|
+
const SINAL = 7;
|
|
25
|
+
const FOLGA_DO_ASSENTO = 1;
|
|
26
|
+
const FOLGA_DA_FRENTE = 2;
|
|
27
|
+
|
|
28
|
+
function congelar(valor) {
|
|
29
|
+
for (const parte of Object.values(valor)) {
|
|
30
|
+
if (parte && typeof parte === 'object') congelar(parte);
|
|
31
|
+
}
|
|
32
|
+
return Object.freeze(valor);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function criarMesa(indice) {
|
|
36
|
+
const coluna = indice % COLUNAS;
|
|
37
|
+
const linha = Math.floor(indice / COLUNAS);
|
|
38
|
+
const centro = coluna * LARGURA_COLUNA + LARGURA_COLUNA / 2;
|
|
39
|
+
const cabeca = TOPO + linha * ALTURA_LINHA + SINAL;
|
|
40
|
+
const tampo = cabeca + BONECO + FOLGA_DO_ASSENTO;
|
|
41
|
+
const chao = tampo + MESA.altura;
|
|
42
|
+
return {
|
|
43
|
+
indice,
|
|
44
|
+
coluna,
|
|
45
|
+
linha,
|
|
46
|
+
retangulo: { x: centro - MESA.largura / 2, y: tampo, ...MESA },
|
|
47
|
+
boneco: { x: centro - BONECO / 2, y: cabeca },
|
|
48
|
+
frente: { x: centro - BONECO / 2, y: chao + FOLGA_DA_FRENTE },
|
|
49
|
+
nome: { x: centro, y: chao + 1 },
|
|
50
|
+
balao: { x: centro, y: cabeca - 1 },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* As 12 mesas, na ordem do elenco (da esquerda para a direita, de cima para baixo). Cada uma:
|
|
56
|
+
* - `indice`, `coluna`, `linha`;
|
|
57
|
+
* - `retangulo` `{ x, y, largura, altura }`: o tampo, que nenhuma caminhada atravessa;
|
|
58
|
+
* - `boneco` `{ x, y }`: canto superior esquerdo do boneco sentado, atrás do tampo;
|
|
59
|
+
* - `frente` `{ x, y }`: canto superior esquerdo do boneco em pé na frente da mesa;
|
|
60
|
+
* - `nome` `{ x, y }`: meio da borda de cima do nome, sob a mesa;
|
|
61
|
+
* - `balao` `{ x, y }`: meio da borda de baixo do balão, sobre a cabeça.
|
|
62
|
+
* A lista e as mesas são imutáveis.
|
|
63
|
+
*/
|
|
64
|
+
export const MESAS = congelar(Array.from({ length: MAX_MESAS }, (_, i) => criarMesa(i)));
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* X do canto esquerdo de um boneco que anda pelo meio de cada vão vertical, entre duas colunas
|
|
68
|
+
* de mesas. Os vãos horizontais de cada linha ficam atrás das mesas (o `boneco.y`, por onde quem
|
|
69
|
+
* está sentado sai) e na frente delas (o `frente.y`).
|
|
70
|
+
*/
|
|
71
|
+
export const VAOS_X = Object.freeze([1, 2, 3].map((coluna) => coluna * LARGURA_COLUNA - BONECO / 2));
|
|
72
|
+
|
|
73
|
+
/** A mesa de quem ocupa a posição `indice` do elenco; `null` do 13º em diante. */
|
|
74
|
+
export function mesaDe(indice) {
|
|
75
|
+
return MESAS[indice] ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** As mesas de um elenco de `total` agentes: no máximo 12. */
|
|
79
|
+
export function mesas(total) {
|
|
80
|
+
return MESAS.slice(0, Math.max(0, total));
|
|
81
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Qual crew está na tela: a memória das consultas ao servidor (o último estado bom de cada
|
|
2
|
+
// crew), a escolha do usuário e a hora de mostrar a demonstração. Cada função devolve uma
|
|
3
|
+
// memória nova; a página guarda a mais recente e pergunta de novo a cada consulta.
|
|
4
|
+
// Puro: sem DOM, sem relógio, sem rede.
|
|
5
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regras 20, 22 e 23 (repositório do OpenCrew).
|
|
6
|
+
import { estadoDaDemo } from './demo.js';
|
|
7
|
+
import { normalizar } from './modelo-estado.js';
|
|
8
|
+
import { TEXTOS } from './modelo-textos.js';
|
|
9
|
+
import { montarModelo } from './modelo-visao.js';
|
|
10
|
+
|
|
11
|
+
/** Crew que some de uma resposta válida continua por este número de consultas; na seguinte, sai. */
|
|
12
|
+
export const CONSULTAS_DE_ESPERA = 3;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A memória antes da primeira consulta: `crews` é `[{ crew, estado, atualizadoMs, faltas }]`, a
|
|
16
|
+
* de atualização mais recente primeiro (`estado` é o bruto, como veio); `escolhida` é o nome da
|
|
17
|
+
* crew que o usuário escolheu, ou `null`; `falhas` conta as consultas seguidas que falharam.
|
|
18
|
+
*/
|
|
19
|
+
export const MEMORIA_INICIAL = Object.freeze({ crews: Object.freeze([]), escolhida: null, falhas: 0 });
|
|
20
|
+
|
|
21
|
+
/** As crews de uma resposta de `/estado`, só as de estado legível; `null` se a resposta é inválida. */
|
|
22
|
+
function lerResposta(resposta) {
|
|
23
|
+
if (resposta === null || typeof resposta !== 'object' || !Array.isArray(resposta.crews)) return null;
|
|
24
|
+
const lidas = [];
|
|
25
|
+
for (const item of resposta.crews) {
|
|
26
|
+
const lido = normalizar(item?.estado);
|
|
27
|
+
const nome = typeof item?.crew === 'string' ? item.crew : '';
|
|
28
|
+
if (lido && nome) lidas.push({ crew: nome, estado: item.estado, atualizadoMs: lido.updatedAtMs ?? 0, faltas: 0 });
|
|
29
|
+
}
|
|
30
|
+
return lidas;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Junta uma consulta à memória (regra 20).
|
|
35
|
+
* @param {object} memoria a memória anterior
|
|
36
|
+
* @param {unknown} resposta o JSON de `/estado`; `null` (ou qualquer coisa inválida) quando a
|
|
37
|
+
* consulta falhou: aí tudo fica como estava e `falhas` sobe
|
|
38
|
+
* @returns {object} a memória nova; a crew ausente continua, com o último estado bom, por
|
|
39
|
+
* `CONSULTAS_DE_ESPERA` respostas válidas seguidas, e a escolha do usuário cai quando a crew sai
|
|
40
|
+
*/
|
|
41
|
+
export function consultar(memoria, resposta) {
|
|
42
|
+
const vindas = lerResposta(resposta);
|
|
43
|
+
if (!vindas) return { ...memoria, falhas: memoria.falhas + 1 };
|
|
44
|
+
const presentes = new Set(vindas.map((c) => c.crew));
|
|
45
|
+
const ausentes = memoria.crews
|
|
46
|
+
.filter((c) => !presentes.has(c.crew) && c.faltas < CONSULTAS_DE_ESPERA)
|
|
47
|
+
.map((c) => ({ ...c, faltas: c.faltas + 1 }));
|
|
48
|
+
const crews = [...vindas, ...ausentes].sort((a, b) => b.atualizadoMs - a.atualizadoMs);
|
|
49
|
+
const escolhida = crews.some((c) => c.crew === memoria.escolhida) ? memoria.escolhida : null;
|
|
50
|
+
return { crews, escolhida, falhas: 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** O usuário escolheu uma crew no seletor (regra 22); nome que não está na memória é ignorado. */
|
|
54
|
+
export function escolher(memoria, crew) {
|
|
55
|
+
return memoria.crews.some((c) => c.crew === crew) ? { ...memoria, escolhida: crew } : memoria;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* O que vai para a tela (regras 22 e 23).
|
|
60
|
+
* @param {object} memoria
|
|
61
|
+
* @param {{ demo?: boolean }} [opcoes] `demo`: o endereço tem `?demo`
|
|
62
|
+
* @returns {{ modo: 'demo'|'real', crew: string|null, estado: object|null, crews: string[], frase: string|null }}
|
|
63
|
+
* `real`: a crew escolhida pelo usuário ou, sem escolha, a de atualização mais recente, com o
|
|
64
|
+
* estado bruto dela; `crews` são os nomes para o seletor · `demo`: sem nenhuma crew, ou com
|
|
65
|
+
* `?demo` (aí sem seletor); `frase` é a da §6 para cada caso
|
|
66
|
+
*/
|
|
67
|
+
export function emExibicao(memoria, { demo = false } = {}) {
|
|
68
|
+
const semCrew = { modo: 'demo', crew: null, estado: null };
|
|
69
|
+
if (demo) return { ...semCrew, crews: [], frase: TEXTOS.demoForcada };
|
|
70
|
+
const crews = memoria.crews.map((c) => c.crew);
|
|
71
|
+
if (!crews.length) return { ...semCrew, crews, frase: TEXTOS.demonstracao };
|
|
72
|
+
const atual = memoria.crews.find((c) => c.crew === memoria.escolhida) ?? memoria.crews[0];
|
|
73
|
+
return { modo: 'real', crew: atual.crew, estado: atual.estado, crews, frase: null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A página inteira num instante: escolhe a crew (ou a demonstração) e monta o modelo dela.
|
|
78
|
+
* @param {object} memoria
|
|
79
|
+
* @param {object} [opcoes]
|
|
80
|
+
* @param {number} [opcoes.agoraMs] a hora atual, em milissegundos
|
|
81
|
+
* @param {boolean} [opcoes.reduzirMovimento] o sistema pede menos movimento
|
|
82
|
+
* @param {boolean} [opcoes.demo] o endereço tem `?demo`
|
|
83
|
+
* @param {number} [opcoes.decorridoDemoMs] tempo desde que a demonstração entrou na tela
|
|
84
|
+
* @returns {object} o modelo de `montarModelo`, mais `modo` (`demo` ou `real`), `crews` (nomes
|
|
85
|
+
* para o seletor), `frase` (a da demonstração, ou `null`) e `aviso` (o "sem conexão" da §6,
|
|
86
|
+
* ou `null`)
|
|
87
|
+
*/
|
|
88
|
+
export function montarPagina(memoria, opcoes = {}) {
|
|
89
|
+
const { agoraMs, reduzirMovimento = false, decorridoDemoMs = 0 } = opcoes;
|
|
90
|
+
const { modo, crew, estado, crews, frase } = emExibicao(memoria, opcoes);
|
|
91
|
+
const demo = modo === 'demo';
|
|
92
|
+
const semConexao = memoria.falhas > 0;
|
|
93
|
+
const modelo = montarModelo(demo ? estadoDaDemo(decorridoDemoMs) : estado, { agoraMs, reduzirMovimento, demo, semConexao, crew });
|
|
94
|
+
return { ...modelo, modo, crews, frase, aviso: semConexao ? TEXTOS.semServidor : null };
|
|
95
|
+
}
|