agents-city 0.1.0 → 0.2.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/.claude-plugin/marketplace.json +1 -1
- package/bin/hall.html +103 -54
- package/bin/navegador.mjs +59 -1
- package/bin/serve.py +21 -0
- package/bin/test-navegador.py +8 -1
- package/bin/test-seat.py +1 -1
- package/bin/test-serve.py +15 -9
- package/city/web/dist-hall/hall.js +514 -13
- package/city/web/src/bienvenida.ts +520 -0
- package/city/web/src/hall.ts +159 -13
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/bus.js +1 -1
- package/plugin/channel/bus.ts +1 -1
- package/plugin/channel/runtime/codex.ts +1 -1
- package/plugin/channel/runtime-gateway.js +1 -1
- package/plugin/scripts/cities.py +34 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First run: a city, built by answering five short questions.
|
|
3
|
+
*
|
|
4
|
+
* What was here before was a checklist on a page of statistics about a city
|
|
5
|
+
* that did not exist yet, and the Hall opened on a map with nothing to draw —
|
|
6
|
+
* "Not drawn yet" as a first impression. A person arriving for the first time
|
|
7
|
+
* does not need a dashboard. They need to be told, in their own words, what
|
|
8
|
+
* this thing is and what to do next.
|
|
9
|
+
*
|
|
10
|
+
* So: one question per screen, plain language, every jargon word explained the
|
|
11
|
+
* first time it appears, and nothing mandatory except the two decisions the
|
|
12
|
+
* product genuinely cannot invent — what kind of work happens here, and who
|
|
13
|
+
* does it. Everything is skippable and everything is re-editable afterwards
|
|
14
|
+
* from the same pages that own it, because this flow writes through the very
|
|
15
|
+
* same endpoints those pages use. It is a guide, not a second implementation.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface Dominio {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
summary: string;
|
|
22
|
+
}
|
|
23
|
+
export interface Rol {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
summary: string;
|
|
27
|
+
trade: string;
|
|
28
|
+
}
|
|
29
|
+
export interface RepoDelDisco {
|
|
30
|
+
nombre: string;
|
|
31
|
+
ruta: string;
|
|
32
|
+
cuando: string;
|
|
33
|
+
mio: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the onboarding needs from the Hall. Passed in rather than imported, so
|
|
37
|
+
* this file cannot quietly grow its own way of talking to the server. */
|
|
38
|
+
export interface Puerta {
|
|
39
|
+
api: <T>(ruta: string, opts?: RequestInit) => Promise<T>;
|
|
40
|
+
esc: (s: unknown) => string;
|
|
41
|
+
aviso: (texto: string, malo?: boolean) => void;
|
|
42
|
+
refresca: () => Promise<void>;
|
|
43
|
+
/** Leave the guide and open a normal Hall section. */
|
|
44
|
+
vete: (seccion: string) => void;
|
|
45
|
+
ciudad: string;
|
|
46
|
+
yo: string;
|
|
47
|
+
datos: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface AgenteNuevo {
|
|
51
|
+
nombre: string;
|
|
52
|
+
clase: string;
|
|
53
|
+
rol: string;
|
|
54
|
+
montajes: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CLASES: Array<[string, string, string]> = [
|
|
58
|
+
[
|
|
59
|
+
'code',
|
|
60
|
+
'It writes code',
|
|
61
|
+
'Its house grows with the pull requests it merges. Mounts repositories and worktrees.',
|
|
62
|
+
],
|
|
63
|
+
[
|
|
64
|
+
'knowledge',
|
|
65
|
+
'It keeps knowledge',
|
|
66
|
+
'Its house grows with the documents it writes. Needs no git at all.',
|
|
67
|
+
],
|
|
68
|
+
['coordinator', 'It coordinates', 'Its house grows with the decisions it records.'],
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const PASOS = ['Welcome', 'The work', 'Your chair', 'The agents', 'Ready'];
|
|
72
|
+
|
|
73
|
+
/** A house, drawn rather than shipped: the same isometric shape the map and the
|
|
74
|
+
* desktop icon use, so the first thing somebody sees is already the product. */
|
|
75
|
+
function casita(color: string): string {
|
|
76
|
+
return `<svg viewBox="0 0 120 108" role="img" aria-label="an isometric house" class="bvCasa">
|
|
77
|
+
<polygon points="60,10 112,40 60,70 8,40" fill="${color}" opacity=".95"/>
|
|
78
|
+
<polygon points="8,40 60,70 60,100 8,70" fill="${color}" opacity=".62"/>
|
|
79
|
+
<polygon points="112,40 60,70 60,100 112,70" fill="${color}" opacity=".40"/>
|
|
80
|
+
</svg>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class Bienvenida {
|
|
84
|
+
private paso = 0;
|
|
85
|
+
private dominios: Dominio[] = [];
|
|
86
|
+
private roles: Rol[] = [];
|
|
87
|
+
private rolesDeAgente: Rol[] = [];
|
|
88
|
+
private repos: RepoDelDisco[] = [];
|
|
89
|
+
private dominio = '';
|
|
90
|
+
private rol = '';
|
|
91
|
+
private roster: AgenteNuevo[] = [];
|
|
92
|
+
private enCurso: AgenteNuevo | null = null;
|
|
93
|
+
private guardando = false;
|
|
94
|
+
private buscandoRepos = false;
|
|
95
|
+
private reposListos = false;
|
|
96
|
+
private lienzo: HTMLElement | null = null;
|
|
97
|
+
|
|
98
|
+
constructor(private readonly p: Puerta) {}
|
|
99
|
+
|
|
100
|
+
/** Draw the current step into `lienzo`. The only entry point. */
|
|
101
|
+
async pinta(lienzo: HTMLElement): Promise<void> {
|
|
102
|
+
this.lienzo = lienzo;
|
|
103
|
+
if (!this.dominios.length) {
|
|
104
|
+
lienzo.innerHTML = '<p class="cargando">reading the domain packs</p>';
|
|
105
|
+
const [d, ra] = await Promise.all([
|
|
106
|
+
this.p.api<{ domains: Dominio[] }>('/api/domains'),
|
|
107
|
+
this.p.api<{ roles: Rol[] }>('/api/roles?scope=agent'),
|
|
108
|
+
]);
|
|
109
|
+
this.dominios = d.domains;
|
|
110
|
+
this.rolesDeAgente = ra.roles;
|
|
111
|
+
}
|
|
112
|
+
lienzo.innerHTML = `<div class="bv">${this.cabecera()}${await this.cuerpo()}</div>`;
|
|
113
|
+
this.enlaza(lienzo);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private cabecera(): string {
|
|
117
|
+
const puntos = PASOS.map(
|
|
118
|
+
(nombre, i) =>
|
|
119
|
+
`<span class="bvPunto ${i === this.paso ? 'aqui' : i < this.paso ? 'hecho' : ''}"
|
|
120
|
+
title="${this.p.esc(nombre)}"><i></i>${this.p.esc(nombre)}</span>`,
|
|
121
|
+
).join('');
|
|
122
|
+
return `<div class="bvPasos">${puntos}</div>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async cuerpo(): Promise<string> {
|
|
126
|
+
switch (this.paso) {
|
|
127
|
+
case 0:
|
|
128
|
+
return this.pantallaHola();
|
|
129
|
+
case 1:
|
|
130
|
+
return this.pantallaDominio();
|
|
131
|
+
case 2:
|
|
132
|
+
return await this.pantallaRol();
|
|
133
|
+
case 3:
|
|
134
|
+
return this.pantallaAgentes();
|
|
135
|
+
default:
|
|
136
|
+
return this.pantallaFinal();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── 0. what this is ────────────────────────────────────────────────────────
|
|
141
|
+
private pantallaHola(): string {
|
|
142
|
+
return `
|
|
143
|
+
<div class="bvHola">
|
|
144
|
+
<div class="bvArte">${casita('#7048e8')}${casita('#45b9a7')}${casita('#f2674a')}</div>
|
|
145
|
+
<div>
|
|
146
|
+
<span class="sub">welcome</span>
|
|
147
|
+
<h1>A city is you, and the agents who work with you</h1>
|
|
148
|
+
<p class="prosa">You take the chair. Each <b>agent</b> is one worker with its own
|
|
149
|
+
window, its own role and its own corner of your disk — a repository, three of them,
|
|
150
|
+
a folder of documents, whatever it actually works on. They never talk to each other
|
|
151
|
+
behind your back: you chair, they answer.</p>
|
|
152
|
+
<p class="prosa">Everything lives as plain files in
|
|
153
|
+
<code class="mono">${this.p.esc(this.p.datos)}</code>. Nothing leaves this machine,
|
|
154
|
+
there is no account, and you can edit any of it by hand afterwards.</p>
|
|
155
|
+
<p class="prosa"><b>Four questions and you are working.</b> You can skip any of them
|
|
156
|
+
and change everything later.</p>
|
|
157
|
+
<div class="bvBotones">
|
|
158
|
+
<button class="bt ppal" data-bv="siguiente">Let’s build it</button>
|
|
159
|
+
<button class="bt" data-bv="salir">I’ll set it up myself</button>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── 1. the domain ─────────────────────────────────────────────────────────
|
|
166
|
+
private pantallaDominio(): string {
|
|
167
|
+
const tarjetas = this.dominios
|
|
168
|
+
.map(
|
|
169
|
+
(d) => `
|
|
170
|
+
<button class="bvOpcion ${this.dominio === d.id ? 'elegida' : ''}" data-bv="dominio"
|
|
171
|
+
data-id="${this.p.esc(d.id)}">
|
|
172
|
+
<b>${this.p.esc(d.name)}</b><span>${this.p.esc(d.summary)}</span></button>`,
|
|
173
|
+
)
|
|
174
|
+
.join('');
|
|
175
|
+
return `
|
|
176
|
+
<div class="bvPaso">
|
|
177
|
+
<span class="sub">question 1 of 4</span>
|
|
178
|
+
<h1>What kind of work happens here?</h1>
|
|
179
|
+
<p class="prosa">It decides the vocabulary, the roles you will be offered and what
|
|
180
|
+
counts as evidence in a decision. A clinic does not ship pull requests, and a law
|
|
181
|
+
firm does not measure story points. Pick the closest one — you can change it later.</p>
|
|
182
|
+
<div class="bvRejilla">${tarjetas}</div>
|
|
183
|
+
<div class="bvBotones">
|
|
184
|
+
<button class="bt" data-bv="atras">Back</button>
|
|
185
|
+
<button class="bt ppal" data-bv="siguiente" ${this.dominio ? '' : 'disabled'}>Next</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── 2. your chair ─────────────────────────────────────────────────────────
|
|
191
|
+
private async pantallaRol(): Promise<string> {
|
|
192
|
+
if (!this.roles.length || this.roles[0]?.id === undefined) {
|
|
193
|
+
const r = await this.p.api<{ roles: Rol[] }>(
|
|
194
|
+
'/api/roles?domain=' + encodeURIComponent(this.dominio),
|
|
195
|
+
);
|
|
196
|
+
this.roles = r.roles;
|
|
197
|
+
}
|
|
198
|
+
const tarjetas = this.roles
|
|
199
|
+
.map(
|
|
200
|
+
(r) => `
|
|
201
|
+
<button class="bvOpcion ${this.rol === r.id ? 'elegida' : ''}" data-bv="rol"
|
|
202
|
+
data-id="${this.p.esc(r.id)}">
|
|
203
|
+
<b>${this.p.esc(r.name)}</b><span>${this.p.esc(r.summary || r.trade)}</span></button>`,
|
|
204
|
+
)
|
|
205
|
+
.join('');
|
|
206
|
+
return `
|
|
207
|
+
<div class="bvPaso">
|
|
208
|
+
<span class="sub">question 2 of 4</span>
|
|
209
|
+
<h1>And what are you, here?</h1>
|
|
210
|
+
<p class="prosa">You chair this city whatever you answer — this is your
|
|
211
|
+
<b>speciality</b>, not your authority. It shapes the perspective you bring to a
|
|
212
|
+
decision and the knowledge files the city writes for you. <b>Blank</b> is a real
|
|
213
|
+
answer: it means no preset knowledge.</p>
|
|
214
|
+
<div class="bvRejilla">${tarjetas}</div>
|
|
215
|
+
<div class="bvBotones">
|
|
216
|
+
<button class="bt" data-bv="atras">Back</button>
|
|
217
|
+
<button class="bt ppal" data-bv="siguiente" ${this.rol ? '' : 'disabled'}>Next</button>
|
|
218
|
+
</div>
|
|
219
|
+
</div>`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── 3. the agents ─────────────────────────────────────────────────────────
|
|
223
|
+
private pantallaAgentes(): string {
|
|
224
|
+
// The repository index is a disk scan — a minute on a big home the first
|
|
225
|
+
// time. Awaiting it here froze the guide on the PREVIOUS screen with no
|
|
226
|
+
// explanation: the person clicks Next and nothing happens. So the step
|
|
227
|
+
// draws immediately and the list arrives when it arrives.
|
|
228
|
+
this.buscaReposUnaVez();
|
|
229
|
+
if (!this.enCurso) return this.listaDeAgentes();
|
|
230
|
+
return this.formularioDeAgente(this.enCurso);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private buscaReposUnaVez(): void {
|
|
234
|
+
if (this.reposListos || this.buscandoRepos) return;
|
|
235
|
+
this.buscandoRepos = true;
|
|
236
|
+
void this.p
|
|
237
|
+
.api<{ repos: RepoDelDisco[] }>('/api/misrepos?user=' + encodeURIComponent(this.p.yo))
|
|
238
|
+
.then((r) => {
|
|
239
|
+
this.repos = (r.repos ?? []).slice(0, 200);
|
|
240
|
+
})
|
|
241
|
+
.catch(() => {
|
|
242
|
+
this.repos = []; // no index is not a reason to block the flow
|
|
243
|
+
})
|
|
244
|
+
.finally(() => {
|
|
245
|
+
this.buscandoRepos = false;
|
|
246
|
+
this.reposListos = true;
|
|
247
|
+
// Only repaint if this step is still on screen.
|
|
248
|
+
if (this.lienzo && this.paso === 3) void this.pinta(this.lienzo);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private listaDeAgentes(): string {
|
|
253
|
+
const fichas = this.roster
|
|
254
|
+
.map(
|
|
255
|
+
(a) => `
|
|
256
|
+
<div class="bvFicha">
|
|
257
|
+
<b>${this.p.esc(a.nombre)}</b>
|
|
258
|
+
<span>${this.p.esc(a.clase)} · ${this.p.esc(a.rol)}</span>
|
|
259
|
+
<span class="de">${
|
|
260
|
+
a.montajes.length
|
|
261
|
+
? this.p.esc(a.montajes.map((m) => m.split('/').pop()).join(', '))
|
|
262
|
+
: 'nothing mounted yet'
|
|
263
|
+
}</span>
|
|
264
|
+
</div>`,
|
|
265
|
+
)
|
|
266
|
+
.join('');
|
|
267
|
+
return `
|
|
268
|
+
<div class="bvPaso">
|
|
269
|
+
<span class="sub">question 3 of 4</span>
|
|
270
|
+
<h1>${this.roster.length ? 'Who else works here?' : 'Who works with you?'}</h1>
|
|
271
|
+
<p class="prosa">An <b>agent</b> is one worker: it gets its own window, its own role
|
|
272
|
+
and its own corner of your disk. It is <b>not</b> a repository — one agent can answer
|
|
273
|
+
for three services and a folder of documents at once, and an agent whose work is
|
|
274
|
+
documents needs no git anywhere.</p>
|
|
275
|
+
${this.roster.length ? `<div class="bvFichas">${fichas}</div>` : ''}
|
|
276
|
+
<div class="bvBotones">
|
|
277
|
+
<button class="bt ppal" data-bv="nuevo">${
|
|
278
|
+
this.roster.length ? 'Add another agent' : 'Add the first agent'
|
|
279
|
+
}</button>
|
|
280
|
+
<button class="bt" data-bv="siguiente">${
|
|
281
|
+
this.roster.length ? 'That is everyone' : 'Skip — just me for now'
|
|
282
|
+
}</button>
|
|
283
|
+
</div>
|
|
284
|
+
</div>`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private formularioDeAgente(a: AgenteNuevo): string {
|
|
288
|
+
const clases = CLASES.map(
|
|
289
|
+
([id, titulo, porque]) => `
|
|
290
|
+
<button class="bvOpcion ${a.clase === id ? 'elegida' : ''}" data-bv="clase" data-id="${id}">
|
|
291
|
+
<b>${titulo}</b><span>${porque}</span></button>`,
|
|
292
|
+
).join('');
|
|
293
|
+
const roles = this.rolesDeAgente
|
|
294
|
+
.map(
|
|
295
|
+
(r) =>
|
|
296
|
+
`<option value="${this.p.esc(r.id)}"${r.id === a.rol ? ' selected' : ''}>${this.p.esc(
|
|
297
|
+
r.name,
|
|
298
|
+
)}</option>`,
|
|
299
|
+
)
|
|
300
|
+
.join('');
|
|
301
|
+
const sugeridos = this.repos
|
|
302
|
+
.filter((r) => r.mio)
|
|
303
|
+
.slice(0, 12)
|
|
304
|
+
.map(
|
|
305
|
+
(r) => `<button class="bvChip ${a.montajes.includes(r.ruta) ? 'elegida' : ''}"
|
|
306
|
+
data-bv="monta" data-ruta="${this.p.esc(r.ruta)}">${this.p.esc(r.nombre)}</button>`,
|
|
307
|
+
)
|
|
308
|
+
.join('');
|
|
309
|
+
return `
|
|
310
|
+
<div class="bvPaso">
|
|
311
|
+
<span class="sub">a new agent</span>
|
|
312
|
+
<h1>Tell me about this one</h1>
|
|
313
|
+
<div class="campo"><label>What do you call it?</label>
|
|
314
|
+
<input type="text" id="bvNombre" value="${this.p.esc(a.nombre)}"
|
|
315
|
+
placeholder="urgencias, api, the handbook — whatever you would say out loud"></div>
|
|
316
|
+
<label class="bvEtiqueta">What kind of work does it do?</label>
|
|
317
|
+
<div class="bvRejilla bvTres">${clases}</div>
|
|
318
|
+
<div class="campo"><label>Its role — its speciality, never authority</label>
|
|
319
|
+
<select id="bvRol">${roles}</select></div>
|
|
320
|
+
<label class="bvEtiqueta">What does it work on?</label>
|
|
321
|
+
<p class="pista">Pick any of your repositories, and add folders by hand. As many as
|
|
322
|
+
you like — this is the part that makes it an agent and not a repo.</p>
|
|
323
|
+
${
|
|
324
|
+
sugeridos
|
|
325
|
+
? `<div class="bvChips">${sugeridos}</div>`
|
|
326
|
+
: this.buscandoRepos
|
|
327
|
+
? '<p class="cargando">looking through your disk for repositories…</p>'
|
|
328
|
+
: '<p class="pista">No repositories found on this disk — add folders by path below.</p>'
|
|
329
|
+
}
|
|
330
|
+
<div class="campo"><label>…or a path, and Enter</label>
|
|
331
|
+
<input type="text" id="bvRuta" placeholder="~/documents/handbook"></div>
|
|
332
|
+
${
|
|
333
|
+
a.montajes.length
|
|
334
|
+
? `<p class="pista">Working on: <b>${this.p.esc(a.montajes.join(', '))}</b></p>`
|
|
335
|
+
: ''
|
|
336
|
+
}
|
|
337
|
+
<div class="bvBotones">
|
|
338
|
+
<button class="bt" data-bv="cancela">Cancel</button>
|
|
339
|
+
<button class="bt ppal" data-bv="guarda" ${this.guardando ? 'disabled' : ''}>${
|
|
340
|
+
this.guardando ? 'adding…' : 'Add this agent'
|
|
341
|
+
}</button>
|
|
342
|
+
</div>
|
|
343
|
+
</div>`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── 4. done ───────────────────────────────────────────────────────────────
|
|
347
|
+
private pantallaFinal(): string {
|
|
348
|
+
return `
|
|
349
|
+
<div class="bvPaso">
|
|
350
|
+
<span class="sub">ready</span>
|
|
351
|
+
<h1>${this.p.esc(this.p.ciudad)} is alive</h1>
|
|
352
|
+
<p class="prosa">Your seat is written and ${
|
|
353
|
+
this.roster.length
|
|
354
|
+
? `<b>${this.roster.length}</b> agent${this.roster.length === 1 ? '' : 's'} live here`
|
|
355
|
+
: 'the city is waiting for its first agent'
|
|
356
|
+
}. Everything you just answered is a plain file you can read and edit.</p>
|
|
357
|
+
<p class="prosa">What people usually do next:</p>
|
|
358
|
+
<div class="bvSiguientes">
|
|
359
|
+
<button class="bt ppal" data-bv="sesion">Open my session</button>
|
|
360
|
+
<button class="bt" data-bv="ir-gente">See the agents</button>
|
|
361
|
+
<button class="bt" data-bv="ir-mapa">Draw the map</button>
|
|
362
|
+
<button class="bt" data-bv="ir-puesto">Set a goal</button>
|
|
363
|
+
</div>
|
|
364
|
+
<p class="pista">A goal is optional, and the city works without one — but a round with
|
|
365
|
+
no goal is a status report, because there is nothing to argue against.</p>
|
|
366
|
+
</div>`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── wiring ────────────────────────────────────────────────────────────────
|
|
370
|
+
private enlaza(lienzo: HTMLElement): void {
|
|
371
|
+
const repinta = (): void => void this.pinta(lienzo);
|
|
372
|
+
lienzo.querySelectorAll<HTMLElement>('[data-bv]').forEach((el) => {
|
|
373
|
+
el.onclick = async (evento) => {
|
|
374
|
+
evento.preventDefault();
|
|
375
|
+
const que = el.dataset.bv;
|
|
376
|
+
if (que === 'salir') return this.p.vete('resumen');
|
|
377
|
+
if (que === 'atras') {
|
|
378
|
+
this.paso = Math.max(0, this.paso - 1);
|
|
379
|
+
return repinta();
|
|
380
|
+
}
|
|
381
|
+
if (que === 'dominio') {
|
|
382
|
+
this.dominio = el.dataset.id ?? '';
|
|
383
|
+
this.roles = [];
|
|
384
|
+
this.rol = '';
|
|
385
|
+
return repinta();
|
|
386
|
+
}
|
|
387
|
+
if (que === 'rol') {
|
|
388
|
+
this.rol = el.dataset.id ?? '';
|
|
389
|
+
return repinta();
|
|
390
|
+
}
|
|
391
|
+
if (que === 'nuevo') {
|
|
392
|
+
this.enCurso = { nombre: '', clase: 'code', rol: 'blank', montajes: [] };
|
|
393
|
+
return repinta();
|
|
394
|
+
}
|
|
395
|
+
if (que === 'clase' && this.enCurso) {
|
|
396
|
+
this.enCurso.clase = el.dataset.id ?? 'code';
|
|
397
|
+
this.recogeFormulario(lienzo);
|
|
398
|
+
return repinta();
|
|
399
|
+
}
|
|
400
|
+
if (que === 'monta' && this.enCurso) {
|
|
401
|
+
const ruta = el.dataset.ruta ?? '';
|
|
402
|
+
const ya = this.enCurso.montajes.indexOf(ruta);
|
|
403
|
+
if (ya >= 0) this.enCurso.montajes.splice(ya, 1);
|
|
404
|
+
else this.enCurso.montajes.push(ruta);
|
|
405
|
+
this.recogeFormulario(lienzo);
|
|
406
|
+
return repinta();
|
|
407
|
+
}
|
|
408
|
+
if (que === 'cancela') {
|
|
409
|
+
this.enCurso = null;
|
|
410
|
+
return repinta();
|
|
411
|
+
}
|
|
412
|
+
if (que === 'guarda') return void (await this.guardaAgente(lienzo));
|
|
413
|
+
if (que === 'siguiente') return void (await this.avanza(lienzo));
|
|
414
|
+
if (que === 'sesion') return void (await this.abreSesion());
|
|
415
|
+
if (que === 'ir-gente') return this.p.vete('gente');
|
|
416
|
+
if (que === 'ir-mapa') return this.p.vete('mapa');
|
|
417
|
+
if (que === 'ir-puesto') return this.p.vete('puesto');
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
const ruta = lienzo.querySelector<HTMLInputElement>('#bvRuta');
|
|
421
|
+
if (ruta)
|
|
422
|
+
ruta.onkeydown = (e) => {
|
|
423
|
+
if (e.key !== 'Enter' || !this.enCurso) return;
|
|
424
|
+
e.preventDefault();
|
|
425
|
+
const valor = ruta.value.trim();
|
|
426
|
+
if (valor) this.enCurso.montajes.push(valor);
|
|
427
|
+
this.recogeFormulario(lienzo);
|
|
428
|
+
void this.pinta(lienzo);
|
|
429
|
+
};
|
|
430
|
+
lienzo.querySelector<HTMLInputElement>('#bvNombre')?.focus();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Keep what is typed when a click forces a repaint. */
|
|
434
|
+
private recogeFormulario(lienzo: HTMLElement): void {
|
|
435
|
+
if (!this.enCurso) return;
|
|
436
|
+
const nombre = lienzo.querySelector<HTMLInputElement>('#bvNombre');
|
|
437
|
+
const rol = lienzo.querySelector<HTMLSelectElement>('#bvRol');
|
|
438
|
+
if (nombre) this.enCurso.nombre = nombre.value;
|
|
439
|
+
if (rol) this.enCurso.rol = rol.value;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private async guardaAgente(lienzo: HTMLElement): Promise<void> {
|
|
443
|
+
this.recogeFormulario(lienzo);
|
|
444
|
+
const a = this.enCurso;
|
|
445
|
+
if (!a) return;
|
|
446
|
+
if (!a.nombre.trim()) {
|
|
447
|
+
this.p.aviso('Give it a name — it is how you will call it in its window', true);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
this.guardando = true;
|
|
451
|
+
await this.pinta(lienzo);
|
|
452
|
+
try {
|
|
453
|
+
// The seat has to exist before an agent can join it: agents live on the
|
|
454
|
+
// owner's card. Writing it here is why this step can come before the goal.
|
|
455
|
+
await this.aseguraFicha();
|
|
456
|
+
const r = await this.p.api<{ ok?: boolean; agent?: string; error?: string }>('/api/agentes', {
|
|
457
|
+
method: 'POST',
|
|
458
|
+
body: JSON.stringify({ name: a.nombre.trim(), kind: a.clase, role: a.rol }),
|
|
459
|
+
});
|
|
460
|
+
if (!r.ok || !r.agent) {
|
|
461
|
+
this.p.aviso(r.error || 'Could not add that agent', true);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
for (const ruta of a.montajes) {
|
|
465
|
+
const m = await this.p.api<{ ok?: boolean; error?: string }>('/api/montaje', {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
body: JSON.stringify({ agent: r.agent, add: ruta }),
|
|
468
|
+
});
|
|
469
|
+
if (!m.ok) this.p.aviso(`${ruta}: ${m.error ?? 'could not mount'}`, true);
|
|
470
|
+
}
|
|
471
|
+
this.roster.push({ ...a, nombre: a.nombre.trim() });
|
|
472
|
+
this.enCurso = null;
|
|
473
|
+
this.p.aviso(`${a.nombre.trim()} joined the city`);
|
|
474
|
+
await this.p.refresca();
|
|
475
|
+
} catch (e) {
|
|
476
|
+
this.p.aviso(String(e), true);
|
|
477
|
+
} finally {
|
|
478
|
+
this.guardando = false;
|
|
479
|
+
await this.pinta(lienzo);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private async aseguraFicha(): Promise<void> {
|
|
484
|
+
await this.p.api<{ ok?: boolean; error?: string }>('/api/ficha', {
|
|
485
|
+
method: 'POST',
|
|
486
|
+
body: JSON.stringify({ domain: this.dominio || 'software', role: this.rol || 'blank' }),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private async avanza(lienzo: HTMLElement): Promise<void> {
|
|
491
|
+
// The step moves FIRST, and only then does anything touch the server.
|
|
492
|
+
// Refreshing the Hall repaints this view, and a repaint that lands after a
|
|
493
|
+
// slow write would otherwise redraw the step the person just left — the
|
|
494
|
+
// guide silently bouncing back one screen.
|
|
495
|
+
const escribeLaFicha = this.paso === 2;
|
|
496
|
+
this.paso = Math.min(PASOS.length - 1, this.paso + 1);
|
|
497
|
+
await this.pinta(lienzo);
|
|
498
|
+
if (escribeLaFicha) {
|
|
499
|
+
// The chair is written the moment it is answered, so leaving halfway
|
|
500
|
+
// still leaves a usable city behind rather than nothing.
|
|
501
|
+
await this.aseguraFicha();
|
|
502
|
+
await this.p.refresca();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private async abreSesion(): Promise<void> {
|
|
507
|
+
try {
|
|
508
|
+
const r = await this.p.api<{ ok?: boolean; attach?: string; error?: string }>('/api/sesion', {
|
|
509
|
+
method: 'POST',
|
|
510
|
+
body: JSON.stringify({ user: this.p.yo }),
|
|
511
|
+
});
|
|
512
|
+
this.p.aviso(
|
|
513
|
+
r.ok && r.attach ? `Session built — attach with: ${r.attach}` : (r.error ?? 'Could not'),
|
|
514
|
+
!r.ok,
|
|
515
|
+
);
|
|
516
|
+
} catch (e) {
|
|
517
|
+
this.p.aviso(String(e), true);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|