agents-city 0.3.0-beta.21

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.
Files changed (440) hide show
  1. package/.claude-plugin/marketplace.json +16 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.es.md +2021 -0
  5. package/README.md +1999 -0
  6. package/benchmarks/committee/README.md +39 -0
  7. package/benchmarks/committee/metrics.py +51 -0
  8. package/benchmarks/committee/run.py +64 -0
  9. package/benchmarks/committee/traces/01-single.json +8 -0
  10. package/benchmarks/committee/traces/02-mesh.json +16 -0
  11. package/benchmarks/committee/traces/03-chair.json +15 -0
  12. package/benchmarks/latency/README.md +35 -0
  13. package/benchmarks/latency/fake-claude-cli.mjs +119 -0
  14. package/benchmarks/latency/fake-native-server.mjs +340 -0
  15. package/benchmarks/latency/fake-tui.py +91 -0
  16. package/benchmarks/latency/live.py +380 -0
  17. package/benchmarks/stress/README.md +20 -0
  18. package/benchmarks/stress/run.py +487 -0
  19. package/bin/agents +4 -0
  20. package/bin/agents-city.js +116 -0
  21. package/bin/benchmark +17 -0
  22. package/bin/bus +6 -0
  23. package/bin/cities +3 -0
  24. package/bin/city +86 -0
  25. package/bin/committee +6 -0
  26. package/bin/demo +200 -0
  27. package/bin/exit +5 -0
  28. package/bin/hall +73 -0
  29. package/bin/hall.html +426 -0
  30. package/bin/logs +3 -0
  31. package/bin/report.py +14 -0
  32. package/bin/reset +3 -0
  33. package/bin/road +3 -0
  34. package/bin/seat +10 -0
  35. package/bin/serve.py +1428 -0
  36. package/bin/setup.py +753 -0
  37. package/bin/skills +3 -0
  38. package/bin/test +131 -0
  39. package/bin/test-adapter.py +183 -0
  40. package/bin/test-admision.py +99 -0
  41. package/bin/test-avatar.py +58 -0
  42. package/bin/test-benchmark.py +40 -0
  43. package/bin/test-broker.py +334 -0
  44. package/bin/test-cage.py +206 -0
  45. package/bin/test-card.py +402 -0
  46. package/bin/test-channel.py +379 -0
  47. package/bin/test-cities.py +453 -0
  48. package/bin/test-claude-runtime.py +252 -0
  49. package/bin/test-committee.py +340 -0
  50. package/bin/test-contracts.py +544 -0
  51. package/bin/test-crecimiento.py +164 -0
  52. package/bin/test-demo.py +308 -0
  53. package/bin/test-doctor.py +106 -0
  54. package/bin/test-domains.py +151 -0
  55. package/bin/test-evidencia.py +50 -0
  56. package/bin/test-exit.py +128 -0
  57. package/bin/test-hall-protocol.py +93 -0
  58. package/bin/test-launch.py +169 -0
  59. package/bin/test-live-feed.py +508 -0
  60. package/bin/test-pairing.py +99 -0
  61. package/bin/test-parcels.py +283 -0
  62. package/bin/test-runtime-failures.py +252 -0
  63. package/bin/test-runtime-ui.py +488 -0
  64. package/bin/test-runtime.py +268 -0
  65. package/bin/test-rutas.py +80 -0
  66. package/bin/test-seat.py +1386 -0
  67. package/bin/test-security.py +150 -0
  68. package/bin/test-serve.py +1057 -0
  69. package/bin/test-stress.py +64 -0
  70. package/bin/test-widgets.py +143 -0
  71. package/bin/test-workspace.py +188 -0
  72. package/bin/testlib.py +95 -0
  73. package/bin/tokens.py +14 -0
  74. package/bus/scripts/deploy.sh +60 -0
  75. package/bus/scripts/mint-token.sh +68 -0
  76. package/bus/scripts/setup-dev.sh +79 -0
  77. package/bus/scripts/test-channel.ts +8 -0
  78. package/bus/scripts/test-hub.ts +266 -0
  79. package/bus/scripts/test-local.sh +40 -0
  80. package/bus/scripts/test-queue.ts +132 -0
  81. package/bus/worker/package-lock.json +1971 -0
  82. package/bus/worker/package.json +17 -0
  83. package/bus/worker/src/index.ts +485 -0
  84. package/bus/worker/tsconfig.json +13 -0
  85. package/bus/worker/wrangler.toml +32 -0
  86. package/city/oven/README.md +59 -0
  87. package/city/oven/collect.py +43 -0
  88. package/city/oven/oven.html +4 -0
  89. package/city/scripts/history.py +120 -0
  90. package/city/scripts/seed.py +442 -0
  91. package/city/web/assets/modelos/License.txt +28 -0
  92. package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
  93. package/city/web/assets/modelos/building-a.glb +0 -0
  94. package/city/web/assets/modelos/building-b.glb +0 -0
  95. package/city/web/assets/modelos/building-c.glb +0 -0
  96. package/city/web/assets/modelos/building-d.glb +0 -0
  97. package/city/web/assets/modelos/building-e.glb +0 -0
  98. package/city/web/assets/modelos/building-f.glb +0 -0
  99. package/city/web/assets/modelos/building-g.glb +0 -0
  100. package/city/web/assets/modelos/building-h.glb +0 -0
  101. package/city/web/assets/sprites/building-a.png +0 -0
  102. package/city/web/assets/sprites/building-b.png +0 -0
  103. package/city/web/assets/sprites/building-c.png +0 -0
  104. package/city/web/assets/sprites/building-d.png +0 -0
  105. package/city/web/assets/sprites/building-e.png +0 -0
  106. package/city/web/assets/sprites/building-f.png +0 -0
  107. package/city/web/assets/sprites/building-g.png +0 -0
  108. package/city/web/assets/sprites/building-h.png +0 -0
  109. package/city/web/assets/sprites/building-i.png +0 -0
  110. package/city/web/assets/sprites/building-j.png +0 -0
  111. package/city/web/assets/sprites/building-k.png +0 -0
  112. package/city/web/assets/sprites/building-l.png +0 -0
  113. package/city/web/assets/sprites/building-m.png +0 -0
  114. package/city/web/assets/sprites/building-n.png +0 -0
  115. package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
  116. package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
  117. package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
  118. package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
  119. package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
  120. package/city/web/assets/sprites/catalogo.json +47 -0
  121. package/city/web/assets/sprites/detail-awning-wide.png +0 -0
  122. package/city/web/assets/sprites/detail-awning.png +0 -0
  123. package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
  124. package/city/web/assets/sprites/detail-overhang.png +0 -0
  125. package/city/web/assets/sprites/detail-parasol-a.png +0 -0
  126. package/city/web/assets/sprites/detail-parasol-b.png +0 -0
  127. package/city/web/assets/sprites/low-detail-building-a.png +0 -0
  128. package/city/web/assets/sprites/low-detail-building-b.png +0 -0
  129. package/city/web/assets/sprites/low-detail-building-c.png +0 -0
  130. package/city/web/assets/sprites/low-detail-building-d.png +0 -0
  131. package/city/web/assets/sprites/low-detail-building-e.png +0 -0
  132. package/city/web/assets/sprites/low-detail-building-f.png +0 -0
  133. package/city/web/assets/sprites/low-detail-building-g.png +0 -0
  134. package/city/web/assets/sprites/low-detail-building-h.png +0 -0
  135. package/city/web/assets/sprites/low-detail-building-i.png +0 -0
  136. package/city/web/assets/sprites/low-detail-building-j.png +0 -0
  137. package/city/web/assets/sprites/low-detail-building-k.png +0 -0
  138. package/city/web/assets/sprites/low-detail-building-l.png +0 -0
  139. package/city/web/assets/sprites/low-detail-building-m.png +0 -0
  140. package/city/web/assets/sprites/low-detail-building-n.png +0 -0
  141. package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
  142. package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
  143. package/city/web/assets/sprites/medidas.json +248 -0
  144. package/city/web/dist/city.js +939 -0
  145. package/city/web/dist/index.html +294 -0
  146. package/city/web/dist/modelos/License.txt +28 -0
  147. package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
  148. package/city/web/dist/modelos/building-a.glb +0 -0
  149. package/city/web/dist/modelos/building-b.glb +0 -0
  150. package/city/web/dist/modelos/building-c.glb +0 -0
  151. package/city/web/dist/modelos/building-d.glb +0 -0
  152. package/city/web/dist/modelos/building-e.glb +0 -0
  153. package/city/web/dist/modelos/building-f.glb +0 -0
  154. package/city/web/dist/modelos/building-g.glb +0 -0
  155. package/city/web/dist/modelos/building-h.glb +0 -0
  156. package/city/web/dist/sprites/building-a.png +0 -0
  157. package/city/web/dist/sprites/building-b.png +0 -0
  158. package/city/web/dist/sprites/building-c.png +0 -0
  159. package/city/web/dist/sprites/building-d.png +0 -0
  160. package/city/web/dist/sprites/building-e.png +0 -0
  161. package/city/web/dist/sprites/building-f.png +0 -0
  162. package/city/web/dist/sprites/building-g.png +0 -0
  163. package/city/web/dist/sprites/building-h.png +0 -0
  164. package/city/web/dist/sprites/building-i.png +0 -0
  165. package/city/web/dist/sprites/building-j.png +0 -0
  166. package/city/web/dist/sprites/building-k.png +0 -0
  167. package/city/web/dist/sprites/building-l.png +0 -0
  168. package/city/web/dist/sprites/building-m.png +0 -0
  169. package/city/web/dist/sprites/building-n.png +0 -0
  170. package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
  171. package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
  172. package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
  173. package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
  174. package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
  175. package/city/web/dist/sprites/catalogo.json +47 -0
  176. package/city/web/dist/sprites/detail-awning-wide.png +0 -0
  177. package/city/web/dist/sprites/detail-awning.png +0 -0
  178. package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
  179. package/city/web/dist/sprites/detail-overhang.png +0 -0
  180. package/city/web/dist/sprites/detail-parasol-a.png +0 -0
  181. package/city/web/dist/sprites/detail-parasol-b.png +0 -0
  182. package/city/web/dist/sprites/low-detail-building-a.png +0 -0
  183. package/city/web/dist/sprites/low-detail-building-b.png +0 -0
  184. package/city/web/dist/sprites/low-detail-building-c.png +0 -0
  185. package/city/web/dist/sprites/low-detail-building-d.png +0 -0
  186. package/city/web/dist/sprites/low-detail-building-e.png +0 -0
  187. package/city/web/dist/sprites/low-detail-building-f.png +0 -0
  188. package/city/web/dist/sprites/low-detail-building-g.png +0 -0
  189. package/city/web/dist/sprites/low-detail-building-h.png +0 -0
  190. package/city/web/dist/sprites/low-detail-building-i.png +0 -0
  191. package/city/web/dist/sprites/low-detail-building-j.png +0 -0
  192. package/city/web/dist/sprites/low-detail-building-k.png +0 -0
  193. package/city/web/dist/sprites/low-detail-building-l.png +0 -0
  194. package/city/web/dist/sprites/low-detail-building-m.png +0 -0
  195. package/city/web/dist/sprites/low-detail-building-n.png +0 -0
  196. package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
  197. package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
  198. package/city/web/dist/sprites/medidas.json +248 -0
  199. package/city/web/dist-hall/hall.js +264 -0
  200. package/city/web/index.html +294 -0
  201. package/city/web/package.json +18 -0
  202. package/city/web/sella.py +21 -0
  203. package/city/web/src/activity-actors.ts +47 -0
  204. package/city/web/src/activity.ts +172 -0
  205. package/city/web/src/ayuntamiento.ts +580 -0
  206. package/city/web/src/draw.ts +640 -0
  207. package/city/web/src/game-speech.ts +79 -0
  208. package/city/web/src/hall.ts +1867 -0
  209. package/city/web/src/main.ts +2225 -0
  210. package/city/web/src/oven.ts +124 -0
  211. package/city/web/src/people.ts +244 -0
  212. package/city/web/src/presencia.ts +193 -0
  213. package/city/web/src/puertas.ts +180 -0
  214. package/city/web/src/repo-roles.ts +59 -0
  215. package/city/worker/package.json +16 -0
  216. package/city/worker/schema.sql +117 -0
  217. package/city/worker/src/index.ts +702 -0
  218. package/city/worker/src/square.ts +218 -0
  219. package/city/worker/wrangler.toml +58 -0
  220. package/demo/ada.md +50 -0
  221. package/demo/bruno.md +62 -0
  222. package/demo/camila.md +56 -0
  223. package/demo/city/ada.md +40 -0
  224. package/demo/city/city.yml +8 -0
  225. package/demo/city/parcels.yml +15 -0
  226. package/demo/city/roads.json +4 -0
  227. package/demo/city/units.yml +10 -0
  228. package/demo/clinica/city.yml +8 -0
  229. package/demo/clinica/parcels.yml +15 -0
  230. package/demo/clinica/roads.json +4 -0
  231. package/demo/clinica/units.yml +9 -0
  232. package/demo/clinica/vera.md +46 -0
  233. package/demo/dante.md +54 -0
  234. package/demo/despacho/city.yml +8 -0
  235. package/demo/despacho/marta.md +46 -0
  236. package/demo/despacho/parcels.yml +15 -0
  237. package/demo/despacho/roads.json +4 -0
  238. package/demo/despacho/units.yml +9 -0
  239. package/demo/elsa.md +50 -0
  240. package/demo/farid.md +52 -0
  241. package/demo/greta.md +50 -0
  242. package/demo/hugo.md +49 -0
  243. package/demo/iris.md +49 -0
  244. package/demo/jonas.md +51 -0
  245. package/demo/kira.md +51 -0
  246. package/demo/luca.md +49 -0
  247. package/demo/parcels.yml +92 -0
  248. package/demo/seed.py +20 -0
  249. package/demo/show.py +161 -0
  250. package/demo/stories.py +824 -0
  251. package/demo/units.yml +33 -0
  252. package/docs/agents-first.md +99 -0
  253. package/docs/glossary.md +55 -0
  254. package/docs/map-live-layers.md +73 -0
  255. package/docs/security.md +231 -0
  256. package/docs/self-host.md +151 -0
  257. package/docs/testing.md +205 -0
  258. package/package.json +73 -0
  259. package/plugin/.claude-plugin/plugin.json +62 -0
  260. package/plugin/.mcp.json +15 -0
  261. package/plugin/channel/activity-cli.ts +16 -0
  262. package/plugin/channel/adapter-prompts.ts +113 -0
  263. package/plugin/channel/adapter.js +4292 -0
  264. package/plugin/channel/adapter.ts +133 -0
  265. package/plugin/channel/bus.js +20188 -0
  266. package/plugin/channel/bus.ts +131 -0
  267. package/plugin/channel/city-config.ts +125 -0
  268. package/plugin/channel/claude-channel.ts +58 -0
  269. package/plugin/channel/cli-args.ts +75 -0
  270. package/plugin/channel/client.js +4200 -0
  271. package/plugin/channel/client.ts +38 -0
  272. package/plugin/channel/committee/activity.ts +256 -0
  273. package/plugin/channel/committee/collection.ts +154 -0
  274. package/plugin/channel/committee/decision.ts +175 -0
  275. package/plugin/channel/committee/floor.ts +135 -0
  276. package/plugin/channel/committee/guards.ts +30 -0
  277. package/plugin/channel/committee/history.ts +38 -0
  278. package/plugin/channel/committee/render.ts +102 -0
  279. package/plugin/channel/committee/service.ts +72 -0
  280. package/plugin/channel/committee/storage.ts +92 -0
  281. package/plugin/channel/committee/types.ts +163 -0
  282. package/plugin/channel/committee/view.ts +63 -0
  283. package/plugin/channel/committee-cli.ts +109 -0
  284. package/plugin/channel/delivery-metrics.ts +61 -0
  285. package/plugin/channel/delivery-queue.ts +118 -0
  286. package/plugin/channel/hub/activity-controller.ts +72 -0
  287. package/plugin/channel/hub/activity-feed.ts +169 -0
  288. package/plugin/channel/hub/committee-controller.ts +60 -0
  289. package/plugin/channel/hub/connections.ts +48 -0
  290. package/plugin/channel/hub/diagnostics.ts +67 -0
  291. package/plugin/channel/hub/envelope-validity.ts +109 -0
  292. package/plugin/channel/hub/envelopes.ts +66 -0
  293. package/plugin/channel/hub/lifecycle.ts +91 -0
  294. package/plugin/channel/hub/local-roads.ts +89 -0
  295. package/plugin/channel/hub/remote-roads.ts +165 -0
  296. package/plugin/channel/hub/road-controller.ts +99 -0
  297. package/plugin/channel/hub-client.ts +149 -0
  298. package/plugin/channel/local-hub.js +5995 -0
  299. package/plugin/channel/local-hub.ts +349 -0
  300. package/plugin/channel/map-reporter.ts +48 -0
  301. package/plugin/channel/package-lock.json +1731 -0
  302. package/plugin/channel/package.json +19 -0
  303. package/plugin/channel/protocol.ts +98 -0
  304. package/plugin/channel/road-cli.ts +18 -0
  305. package/plugin/channel/run.sh +15 -0
  306. package/plugin/channel/runtime/claude.ts +458 -0
  307. package/plugin/channel/runtime/codex-config.ts +77 -0
  308. package/plugin/channel/runtime/codex.ts +669 -0
  309. package/plugin/channel/runtime/command.ts +62 -0
  310. package/plugin/channel/runtime/factory.ts +15 -0
  311. package/plugin/channel/runtime/json-rpc.ts +154 -0
  312. package/plugin/channel/runtime/kimi.ts +277 -0
  313. package/plugin/channel/runtime/opencode.ts +248 -0
  314. package/plugin/channel/runtime/process.ts +84 -0
  315. package/plugin/channel/runtime/types.ts +45 -0
  316. package/plugin/channel/runtime-files.ts +73 -0
  317. package/plugin/channel/runtime-gateway.js +6396 -0
  318. package/plugin/channel/runtime-gateway.ts +358 -0
  319. package/plugin/channel/runtime-metrics.ts +52 -0
  320. package/plugin/channel/runtime-subscription.ts +180 -0
  321. package/plugin/channel/terminal-delivery.ts +131 -0
  322. package/plugin/channel/untrusted.ts +60 -0
  323. package/plugin/commands/committee.md +31 -0
  324. package/plugin/commands/exit.md +32 -0
  325. package/plugin/commands/goals.md +20 -0
  326. package/plugin/commands/join.md +18 -0
  327. package/plugin/commands/notice.md +20 -0
  328. package/plugin/commands/propose.md +19 -0
  329. package/plugin/commands/round.md +17 -0
  330. package/plugin/commands/session.md +22 -0
  331. package/plugin/commands/settings.md +25 -0
  332. package/plugin/commands/setup.md +21 -0
  333. package/plugin/commands/team.md +23 -0
  334. package/plugin/domains/custom.md +27 -0
  335. package/plugin/domains/finance.md +31 -0
  336. package/plugin/domains/healthcare.md +34 -0
  337. package/plugin/domains/legal.md +31 -0
  338. package/plugin/domains/marketing.md +33 -0
  339. package/plugin/domains/operations.md +31 -0
  340. package/plugin/domains/research.md +32 -0
  341. package/plugin/domains/sales.md +30 -0
  342. package/plugin/domains/software.md +37 -0
  343. package/plugin/hooks/activity.sh +6 -0
  344. package/plugin/hooks/digging.sh +68 -0
  345. package/plugin/hooks/growth.sh +42 -0
  346. package/plugin/hooks/hooks.json +106 -0
  347. package/plugin/hooks/notice-on-pr.sh +35 -0
  348. package/plugin/hooks/notice-on-stop.sh +80 -0
  349. package/plugin/hooks/notice-pending.sh +58 -0
  350. package/plugin/hooks/solo-en-ciudad.sh +23 -0
  351. package/plugin/hooks/tokens.sh +47 -0
  352. package/plugin/roles/examples/account-executive.md +25 -0
  353. package/plugin/roles/examples/ai-manager.md +34 -0
  354. package/plugin/roles/examples/associate.md +24 -0
  355. package/plugin/roles/examples/brand-lead.md +20 -0
  356. package/plugin/roles/examples/cfo.md +20 -0
  357. package/plugin/roles/examples/city-lead.md +25 -0
  358. package/plugin/roles/examples/clinical-director.md +26 -0
  359. package/plugin/roles/examples/clinical-ops.md +25 -0
  360. package/plugin/roles/examples/clinician.md +25 -0
  361. package/plugin/roles/examples/compliance.md +27 -0
  362. package/plugin/roles/examples/content.md +24 -0
  363. package/plugin/roles/examples/controller.md +27 -0
  364. package/plugin/roles/examples/cpto.md +41 -0
  365. package/plugin/roles/examples/customer-success.md +25 -0
  366. package/plugin/roles/examples/data-engineer.md +37 -0
  367. package/plugin/roles/examples/data.md +60 -0
  368. package/plugin/roles/examples/dev.md +37 -0
  369. package/plugin/roles/examples/devops.md +39 -0
  370. package/plugin/roles/examples/enablement.md +24 -0
  371. package/plugin/roles/examples/ethics.md +24 -0
  372. package/plugin/roles/examples/fin-analytics.md +27 -0
  373. package/plugin/roles/examples/health-compliance.md +25 -0
  374. package/plugin/roles/examples/health-data.md +26 -0
  375. package/plugin/roles/examples/knowledge.md +25 -0
  376. package/plugin/roles/examples/lifecycle.md +26 -0
  377. package/plugin/roles/examples/llm-engineer.md +34 -0
  378. package/plugin/roles/examples/managing-partner.md +20 -0
  379. package/plugin/roles/examples/methods.md +23 -0
  380. package/plugin/roles/examples/operations-lead.md +23 -0
  381. package/plugin/roles/examples/ops.md +24 -0
  382. package/plugin/roles/examples/patient-safety.md +26 -0
  383. package/plugin/roles/examples/performance.md +27 -0
  384. package/plugin/roles/examples/po.md +36 -0
  385. package/plugin/roles/examples/process-owner.md +23 -0
  386. package/plugin/roles/examples/product-design.md +39 -0
  387. package/plugin/roles/examples/program-manager.md +24 -0
  388. package/plugin/roles/examples/quality.md +25 -0
  389. package/plugin/roles/examples/research-director.md +24 -0
  390. package/plugin/roles/examples/research-ops.md +25 -0
  391. package/plugin/roles/examples/researcher.md +25 -0
  392. package/plugin/roles/examples/revenue-lead.md +25 -0
  393. package/plugin/roles/examples/revops.md +26 -0
  394. package/plugin/roles/examples/seo.md +27 -0
  395. package/plugin/roles/examples/specialist.md +24 -0
  396. package/plugin/scripts/admision.py +165 -0
  397. package/plugin/scripts/apaga.py +232 -0
  398. package/plugin/scripts/avatar.py +211 -0
  399. package/plugin/scripts/broker.py +531 -0
  400. package/plugin/scripts/cage.py +252 -0
  401. package/plugin/scripts/capabilities.py +197 -0
  402. package/plugin/scripts/card.py +312 -0
  403. package/plugin/scripts/cities.py +520 -0
  404. package/plugin/scripts/city-env.sh +90 -0
  405. package/plugin/scripts/city-runtime.sh +87 -0
  406. package/plugin/scripts/city-session.sh +526 -0
  407. package/plugin/scripts/city_env.py +67 -0
  408. package/plugin/scripts/crecimiento.py +140 -0
  409. package/plugin/scripts/deliberations.py +48 -0
  410. package/plugin/scripts/doctor.py +172 -0
  411. package/plugin/scripts/domains.py +201 -0
  412. package/plugin/scripts/evidencia.py +64 -0
  413. package/plugin/scripts/find-repos.sh +111 -0
  414. package/plugin/scripts/gh.py +159 -0
  415. package/plugin/scripts/hall_protocol.py +147 -0
  416. package/plugin/scripts/hook_activity.py +139 -0
  417. package/plugin/scripts/launch.py +94 -0
  418. package/plugin/scripts/logs.py +98 -0
  419. package/plugin/scripts/pairing.py +185 -0
  420. package/plugin/scripts/parcels.py +132 -0
  421. package/plugin/scripts/read-card.py +51 -0
  422. package/plugin/scripts/report.py +201 -0
  423. package/plugin/scripts/reset.py +180 -0
  424. package/plugin/scripts/roads.py +230 -0
  425. package/plugin/scripts/roles.py +274 -0
  426. package/plugin/scripts/runtime_log.py +69 -0
  427. package/plugin/scripts/runtime_processes.py +132 -0
  428. package/plugin/scripts/rutas.py +83 -0
  429. package/plugin/scripts/seat.py +1349 -0
  430. package/plugin/scripts/tokens.py +194 -0
  431. package/plugin/scripts/trust-repos.py +59 -0
  432. package/plugin/scripts/ui.py +280 -0
  433. package/plugin/scripts/units.py +80 -0
  434. package/plugin/scripts/workspace.py +364 -0
  435. package/plugin/skills/city/SKILL.md +191 -0
  436. package/templates/blank.md +39 -0
  437. package/templates/finance.md +44 -0
  438. package/templates/legal.md +45 -0
  439. package/templates/marketing.md +48 -0
  440. package/templates/product.md +45 -0
@@ -0,0 +1,2225 @@
1
+ /**
2
+ * Agents City.
3
+ *
4
+ * District = business unit. House = parcel, a slice of a repo serving that unit.
5
+ * And the people: the architect draws the plan, the foremen answer for one
6
+ * property of every house, and the workers — the agents — dig.
7
+ *
8
+ * Pixi for the canvas and HTML for the HUD: that way the text reads, selects and
9
+ * is accessible, and the canvas does what it is good at.
10
+ */
11
+ import { Application, Container, Graphics, Sprite, Text, TextStyle, Texture } from 'pixi.js';
12
+ import {
13
+ TW,
14
+ TH,
15
+ PASO,
16
+ iso,
17
+ casa,
18
+ arbol,
19
+ suelo,
20
+ plano,
21
+ estandarte,
22
+ cargaSprites,
23
+ factorDe,
24
+ polvo,
25
+ alturaDe,
26
+ reviste,
27
+ tono,
28
+ } from './draw';
29
+ import type { Casa } from './draw';
30
+ import { obrero, perito, arquitecto, colorDe, ESCALA_GENTE } from './people';
31
+ import { ActivityActors } from './activity-actors';
32
+ import {
33
+ ActivityEvent,
34
+ MAP_ACTIVITY_PROTOCOL,
35
+ MapConfigMessage,
36
+ RoadInfo,
37
+ isMapActivityMessage,
38
+ isMapConfigMessage,
39
+ isPresenceEvent,
40
+ isSpeechEvent,
41
+ } from './activity';
42
+ import { CitySpeech } from './game-speech';
43
+ import { Ayuntamiento } from './ayuntamiento';
44
+ import { Presencia } from './presencia';
45
+ import { Puertas } from './puertas';
46
+
47
+ // Units and their colours come from the API, which reads them from the database.
48
+ // There is no hardcoded list of anybody's business here: every organisation has
49
+ // its own, and changing them should not require a deploy.
50
+ let COLOR: Record<string, number> = {};
51
+ let BARRIOS: { id: string; nom: string; cols: number; nota?: string }[] = [];
52
+ const COLOR_POR_DEFECTO = 0xc8b48a;
53
+ const SUELO = 0x121821;
54
+
55
+ interface Parcela {
56
+ id: string;
57
+ repo: string;
58
+ ruta: string;
59
+ unidad: string;
60
+ nombre: string;
61
+ dueno: string | null;
62
+ pisos: number;
63
+ ladrillos: number;
64
+ andamios: number;
65
+ andamio_viejo: number;
66
+ grieta: number;
67
+ actividad30: number;
68
+ /** How many parcels share this parcel's repo. Above one, the counts are the
69
+ * whole repo's and the fiche says so instead of pretending otherwise. */
70
+ hermanas?: number;
71
+ /** The agent's kind, told by the Hall: picks the building family it wears. */
72
+ clase?: string;
73
+ }
74
+ interface Persona {
75
+ usuario: string;
76
+ nombre: string;
77
+ rol: string;
78
+ oficio: string;
79
+ agente: string;
80
+ }
81
+ interface Unidad {
82
+ id: string;
83
+ nombre: string;
84
+ color: string;
85
+ orden: number;
86
+ nota?: string;
87
+ cols: number;
88
+ }
89
+
90
+ let escalaActual = 1;
91
+ /** A house on the map: its container, its parcel, and the handle the drawing
92
+ * returns — which is what lets a house grow instead of being rebuilt. */
93
+ type CasaViva = { g: Container; p: Parcela; h?: Casa; dibujado?: number; adorno?: string };
94
+ const casas = new Map<string, CasaViva>();
95
+ const enObra = new Map<string, { c: Container; parcela: string }>();
96
+ const peritos = new Map<string, Container>();
97
+ let personas: Persona[] = [];
98
+ /** Goals, by user. The invariant of this whole thing, so the map carries them. */
99
+ interface Meta {
100
+ usuario: string;
101
+ n: number;
102
+ titulo: string;
103
+ como?: string;
104
+ medida?: string;
105
+ partida?: string;
106
+ meta?: string;
107
+ cuando?: string;
108
+ estado?: string;
109
+ }
110
+ let objetivos: Meta[] = [];
111
+ let capaGente: Container;
112
+ /** The banners live above every district, not inside their own. Inside, the
113
+ * district drawn next covers the one before it: at any zoom where the city fits
114
+ * on screen, half the names were hidden behind somebody else's tower. */
115
+ let capaCarteles: Container;
116
+ let plaza = { x: 0, y: 0 };
117
+ const enPlaza = new Map<string, Container>();
118
+ let mundo: Container;
119
+ let appGlobal: Application | null = null;
120
+ let activityActors: ActivityActors | null = null;
121
+ let gameSpeech: CitySpeech | null = null;
122
+ let ayuntamiento: Ayuntamiento | null = null;
123
+ let presencia: Presencia | null = null;
124
+ let puertas: Puertas | null = null;
125
+ let configPendiente: MapConfigMessage | null = null;
126
+ /** Where the town hall stands: the camera's committee shot aims here. */
127
+ let posAyto = { x: 0, y: 0 };
128
+ /** Faces the Hall sent, as textures for figure labels and as URIs for fiches. */
129
+ const carasMapa = new Map<string, Texture>();
130
+ let avataresURI: Record<string, string> = {};
131
+ /** Who walked to the committee, and where they stood before it opened. */
132
+ const enSesion = new Map<string, { fig: Container; vuelta: { x: number; y: number } }>();
133
+ const queuedSpeech: ActivityEvent[] = [];
134
+
135
+ /**
136
+ * Every animation on the map runs from one ticker.
137
+ *
138
+ * Not a detail: growth used to be animated by hanging an `onRender` on the house
139
+ * itself, so a second animation on the same house silently replaced the first —
140
+ * and any house whose sprite got rebuilt mid-animation kept the old scale for
141
+ * ever. One list, one owner, and a callback that says when it is done.
142
+ */
143
+ const animaciones = new Set<(dt: number) => boolean>();
144
+
145
+ /**
146
+ * Somebody who asked their system for less motion gets less motion: every tween
147
+ * is run once, to its end state, instead of over time. The map still says
148
+ * everything it says — a house that grew is taller, a notice is in the ticker —
149
+ * it just stops moving to say it. Growth is the whole point of the replay, so
150
+ * this is the one place where honouring the setting has to be deliberate rather
151
+ * than a blanket `animation: none`.
152
+ */
153
+ const SIN_MOVIMIENTO =
154
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
155
+
156
+ const anima = (f: (dt: number) => boolean) => {
157
+ if (SIN_MOVIMIENTO) {
158
+ f(1e6);
159
+ return;
160
+ } // straight to the end state
161
+ animaciones.add(f);
162
+ };
163
+
164
+ const parentOrigin = new URLSearchParams(location.search).get('parent_origin') || '';
165
+
166
+ /** The local Hall owns the authenticated activity WebSocket. The map receives
167
+ * only its semantic, already-sanitised events, and only from the parent origin
168
+ * explicitly written into the iframe URL. */
169
+ window.addEventListener('message', (message) => {
170
+ if (!parentOrigin || message.source !== window.parent || message.origin !== parentOrigin) {
171
+ return;
172
+ }
173
+ if (isMapConfigMessage(message.data)) {
174
+ if (puertas) aplicaConfig(message.data);
175
+ else configPendiente = message.data;
176
+ return;
177
+ }
178
+ if (!isMapActivityMessage(message.data)) return;
179
+ const event = message.data.event;
180
+ if (!isSpeechEvent(event) && !isPresenceEvent(event) && !event.kind.startsWith('committee.')) {
181
+ return;
182
+ }
183
+ showActivity(event);
184
+ });
185
+
186
+ function showActivity(event: ActivityEvent): void {
187
+ if (!gameSpeech || !activityActors) {
188
+ queuedSpeech.push(event);
189
+ if (queuedSpeech.length > 20) queuedSpeech.shift();
190
+ return;
191
+ }
192
+ // The town hall and the lights watch the same feed the bubbles do: a
193
+ // committee event is both something somebody said and something happening to
194
+ // the building, and a lifecycle beat is only ever a light.
195
+ coreografia(event);
196
+ ayuntamiento?.recibe(event);
197
+ presencia?.recibe(event);
198
+ if (isSpeechEvent(event)) gameSpeech.show(event);
199
+ }
200
+
201
+ /**
202
+ * The staging around a committee: the camera flies to the town hall when a
203
+ * session opens, the chair and each member who submits walk over, and when it
204
+ * closes everybody walks home and the camera pulls back to the whole city.
205
+ * All of it defers to the person: a camera you dragged, or a follow switch you
206
+ * turned off, is never overridden.
207
+ */
208
+ function coreografia(event: ActivityEvent): void {
209
+ if (!event.kind.startsWith('committee.')) return;
210
+ if (event.kind === 'committee.opened') {
211
+ if (camara.sigue && !camara.tuyo) mira(posAyto.x, posAyto.y - 44, 0.95);
212
+ const chair = activityActors?.resolve(event);
213
+ if (chair && !enSesion.has('seat')) {
214
+ enSesion.set('seat', { fig: chair, vuelta: { x: chair.x, y: chair.y } });
215
+ anda(chair, { x: posAyto.x - 36, y: posAyto.y + 30 });
216
+ }
217
+ return;
218
+ }
219
+ if (event.kind === 'committee.position.submitted' && activityActors) {
220
+ const fig = activityActors.resolve(event);
221
+ if (!enSesion.has(event.actor)) {
222
+ const sitio = enSesion.size;
223
+ enSesion.set(event.actor, { fig, vuelta: { x: fig.x, y: fig.y } });
224
+ // The forecourt: members line up on the paving, facing the door.
225
+ anda(fig, { x: posAyto.x - 96 + sitio * 30, y: posAyto.y + 46 + (sitio % 2) * 11 });
226
+ }
227
+ return;
228
+ }
229
+ if (event.kind === 'committee.closed' || event.kind === 'committee.cancelled') {
230
+ for (const { fig, vuelta } of enSesion.values()) anda(fig, vuelta);
231
+ enSesion.clear();
232
+ if (camara.sigue && !camara.tuyo && appGlobal) encaja(appGlobal, false);
233
+ }
234
+ }
235
+
236
+ /** What the Hall told this map: gates, kinds and faces, applied in place. */
237
+ function aplicaConfig(config: MapConfigMessage): void {
238
+ puertas?.configura(config.roads);
239
+
240
+ // Kinds: dress each agent's parcel in its family. Only movers are rebuilt.
241
+ for (const agente of config.agents ?? []) {
242
+ const wanted = actorKey(agente.name);
243
+ for (const c of casas.values()) {
244
+ const es =
245
+ actorMatches(c.p.repo, wanted) ||
246
+ actorMatches(c.p.nombre, wanted) ||
247
+ actorMatches(c.p.id, wanted);
248
+ if (es && c.p.clase !== agente.kind) {
249
+ c.p.clase = agente.kind;
250
+ reconstruye(c);
251
+ }
252
+ }
253
+ }
254
+
255
+ // Faces: decoded once into textures; figures pick them up as they are made,
256
+ // and the crew already on screen gets chipped as each face finishes decoding.
257
+ avataresURI = { ...avataresURI, ...(config.avatars ?? {}) };
258
+ for (const [nombre, uri] of Object.entries(config.avatars ?? {})) {
259
+ if (!uri.startsWith('data:image/svg+xml;base64,') || carasMapa.has(nombre)) continue;
260
+ const img = new Image();
261
+ img.src = uri;
262
+ img
263
+ .decode()
264
+ .then(() => {
265
+ carasMapa.set(nombre, Texture.from(img));
266
+ for (const [id, o] of enObra) abrochaCara(o.c, id.split('/').at(-1) ?? '');
267
+ })
268
+ .catch(() => {});
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Replace a house in place — same plot, same click — because its data changed
274
+ * shape (its kind arrived), not just its height. The growth path never comes
275
+ * through here; `subeHasta` keeps owning that.
276
+ */
277
+ function reconstruye(c: CasaViva): void {
278
+ const padre = c.g.parent;
279
+ if (!padre) return;
280
+ const col = COLOR[c.p.unidad] ?? COLOR_POR_DEFECTO;
281
+ const px = c.g.x,
282
+ py = c.g.y,
283
+ z = padre.getChildIndex(c.g);
284
+ padre.removeChild(c.g);
285
+ const h = casa(col, c.p, c.p.id);
286
+ h.g.position.set(px, py);
287
+ h.g.eventMode = 'static';
288
+ h.g.cursor = 'pointer';
289
+ h.g.on('pointertap', () => ficha(c.p));
290
+ padre.addChildAt(h.g, Math.min(z, padre.children.length));
291
+ c.g = h.g;
292
+ c.h = h;
293
+ c.dibujado = c.p.pisos;
294
+ }
295
+
296
+ /** The agent's face, pinned beside its figure's label once the Hall sent one. */
297
+ function abrochaCara(fig: Container, nombre: string): void {
298
+ const tex = carasMapa.get(nombre);
299
+ const cartel = (fig as any).__cartel as Container | undefined;
300
+ if (!tex || !cartel || (cartel as any).__caraChip) return;
301
+ const chip = new Sprite(tex);
302
+ chip.width = 15;
303
+ chip.height = 15;
304
+ const b = cartel.getLocalBounds();
305
+ chip.position.set(b.x - 17, -8);
306
+ cartel.addChild(chip);
307
+ (cartel as any).__caraChip = chip;
308
+ }
309
+
310
+ /** Ask the Hall around this iframe for one of its sections. Standalone maps
311
+ * have no Hall to ask and simply say so. */
312
+ function navHall(view: string): boolean {
313
+ if (!parentOrigin || window.parent === window) return false;
314
+ window.parent.postMessage(
315
+ { protocol: MAP_ACTIVITY_PROTOCOL, type: 'map.nav', view },
316
+ parentOrigin,
317
+ );
318
+ return true;
319
+ }
320
+
321
+ async function arranca() {
322
+ const app = new Application();
323
+ appGlobal = app;
324
+ const lienzo = document.querySelector('main') as HTMLElement;
325
+ await app.init({ background: SUELO, antialias: true, resizeTo: lienzo });
326
+ lienzo.appendChild(app.canvas);
327
+
328
+ mundo = new Container();
329
+ capaGente = new Container();
330
+ capaCarteles = new Container();
331
+ app.stage.addChild(mundo);
332
+
333
+ const [datos, conSprites] = await Promise.all([
334
+ fetch('/api/city').then((r) => r.json()) as Promise<{
335
+ parcelas: Parcela[];
336
+ personas: Persona[];
337
+ unidades: Unidad[];
338
+ gasto?: Gasto;
339
+ objetivos?: Meta[];
340
+ }>,
341
+ cargaSprites(),
342
+ ]);
343
+ // The units define the districts: name, colour, order and grid width.
344
+ for (const u of datos.unidades ?? []) COLOR[u.id] = parseInt(hex(u.color), 16);
345
+ BARRIOS = (datos.unidades ?? []).map((u) => ({
346
+ id: u.id,
347
+ nom: u.nombre,
348
+ cols: u.cols,
349
+ nota: u.nota ?? undefined,
350
+ }));
351
+ if (!BARRIOS.length) BARRIOS = [{ id: 'none', nom: 'All repos', cols: 16 }];
352
+ leyenda(datos.unidades ?? []);
353
+ if (!conSprites) console.log('city: no baked sprites, falling back to polygons');
354
+ const parcelas = datos.parcelas ?? [];
355
+ personas = datos.personas ?? [];
356
+ objetivos = (datos as any).objetivos ?? [];
357
+
358
+ const porUnidad = new Map<string, Parcela[]>(BARRIOS.map((b) => [b.id, []]));
359
+ for (const p of parcelas) (porUnidad.get(p.unidad) ?? porUnidad.get('none')!).push(p);
360
+
361
+ const barrios: Container[] = [];
362
+ for (const b of BARRIOS) {
363
+ const lista = porUnidad.get(b.id)!;
364
+ const col = COLOR[b.id] ?? COLOR_POR_DEFECTO;
365
+ const cols = Math.min(b.cols, Math.max(1, lista.length));
366
+ const filas = Math.ceil(lista.length / cols) || 1;
367
+
368
+ const barrio = new Container();
369
+ barrio.addChild(suelo(cols, filas, col, b.id === 'lab'));
370
+
371
+ // Houses, and a tree in the gaps the last row leaves.
372
+ const dentro: Container[] = [];
373
+ for (let i = 0; i < cols * filas; i++) {
374
+ const cx = i % cols,
375
+ cy = Math.floor(i / cols);
376
+ const p = lista[i];
377
+ const { x, y } = iso(cx * PASO, cy * PASO);
378
+ if (!p) {
379
+ if ((cx + cy) % 3 === 0) {
380
+ const a = arbol();
381
+ a.position.set(x, y);
382
+ dentro.push(a);
383
+ }
384
+ continue;
385
+ }
386
+ const c = casa(col, p, p.id);
387
+ c.g.position.set(x, y);
388
+ c.g.eventMode = 'static';
389
+ c.g.cursor = 'pointer';
390
+ c.g.on('pointertap', () => ficha(p));
391
+ dentro.push(c.g);
392
+ casas.set(p.id, { g: c.g, p, h: c });
393
+ }
394
+ // What is behind gets painted first: that is the whole trick of isometric.
395
+ dentro.sort((a, b2) => a.y - b2.y).forEach((x) => barrio.addChild(x));
396
+
397
+ const cuantas = `${lista.length} ${plural(lista.length, 'parcel')}`;
398
+ const est = estandarte(b.nom, b.nota ? `${cuantas} · ${b.nota}` : cuantas, col);
399
+ const esq = iso(-1.5 * PASO, (filas + 0.5) * PASO);
400
+ // Positioned inside the district, drawn in the layer above: the offset is
401
+ // applied once the district knows where it stands, at the end of the layout.
402
+ (barrio as any).__cartel = { est, dx: esq.x, dy: esq.y + TH };
403
+
404
+ // What the layout needs to know about a district: how many plots wide and
405
+ // deep it is. In plots, not pixels — the whole city is packed on one grid.
406
+ (barrio as any).__m = { cols, filas };
407
+ barrios.push(barrio);
408
+ mundo.addChild(barrio);
409
+ }
410
+ mundo.addChild(capaGente);
411
+ // Every figure in this layer opens its person's card. Delegated to the layer
412
+ // rather than wired per figure: figures are created and destroyed constantly
413
+ // as people start and stop, and a listener per figure is a listener to forget.
414
+ capaGente.eventMode = 'static';
415
+ capaGente.on('pointertap', (e: any) => {
416
+ let n: any = e.target;
417
+ while (n && n !== capaGente && !n.__usuario) n = n.parent;
418
+ if (n?.__usuario) fichaPersona(n.__usuario);
419
+ });
420
+
421
+ /**
422
+ * Composition: one city on one isometric plane, not eight islands.
423
+ *
424
+ * Every district is a block of plots, and the blocks are packed onto the same
425
+ * grid with avenues between them — so the streets of one district meet the
426
+ * streets of the next, and the whole thing reads as a place you could walk
427
+ * across. Laid out in screen rows before, which is why it looked like eight
428
+ * rugs thrown on a floor: nothing connected to anything.
429
+ *
430
+ * It cannot assume how many districts there are — units come from the data, so
431
+ * there may be one or twenty — and the shared district and the lab go at the
432
+ * back, because the shared one is usually bigger than all the others together.
433
+ */
434
+ const AVENIDA = 2; // plots of road between blocks
435
+ const esFondo = (id: string) => id === 'lab' || id === 'none';
436
+ const orden = barrios
437
+ .map((b, i) => ({ b, id: BARRIOS[i].id, m: (b as any).__m }))
438
+ .sort((x, y) => Number(esFondo(x.id)) - Number(esFondo(y.id)));
439
+
440
+ // How wide the city should be, in blocks: roughly square, so it fits a screen
441
+ // rather than running off one edge.
442
+ const porFila = Math.max(1, Math.round(Math.sqrt(orden.length)));
443
+ let ox = 0,
444
+ oy = 0,
445
+ filaAlta = 0,
446
+ anchoCeldas = 0,
447
+ altoCeldas = 0;
448
+ orden.forEach((d, i) => {
449
+ if (i > 0 && i % porFila === 0) {
450
+ oy += filaAlta + AVENIDA;
451
+ ox = 0;
452
+ filaAlta = 0;
453
+ }
454
+ const { x, y } = iso(ox * PASO, oy * PASO);
455
+ d.b.position.set(x, y);
456
+ (d.b as any).__celda = { ox, oy };
457
+ ox += d.m.cols + AVENIDA;
458
+ filaAlta = Math.max(filaAlta, d.m.filas);
459
+ anchoCeldas = Math.max(anchoCeldas, ox);
460
+ altoCeldas = Math.max(altoCeldas, oy + d.m.filas);
461
+ });
462
+
463
+ // Painting order is the whole trick of isometric: what is further back goes
464
+ // down first. On one plane that is (ox + oy), and it has to be applied across
465
+ // districts, not just inside them.
466
+ for (const d of [...orden].sort(
467
+ (a, b) =>
468
+ (a.b as any).__celda.ox +
469
+ (a.b as any).__celda.oy -
470
+ ((b.b as any).__celda.ox + (b.b as any).__celda.oy),
471
+ )) {
472
+ mundo.setChildIndex(d.b, mundo.children.length - 1);
473
+ }
474
+ mundo.setChildIndex(capaGente, mundo.children.length - 1);
475
+
476
+ // The ground goes in behind everything, now that the extent is known.
477
+ const bloques = orden.map((d) => ({ ...(d.b as any).__celda, cols: d.m.cols, filas: d.m.filas }));
478
+ const base = plano(anchoCeldas, altoCeldas, bloques);
479
+ mundo.addChildAt(base, 0);
480
+
481
+ const yFondo = iso(anchoCeldas * PASO * 0.5, altoCeldas * PASO).y;
482
+
483
+ // Now that every district has a position, hang its banner in the top layer.
484
+ for (const b of barrios) {
485
+ const c = (b as any).__cartel;
486
+ if (!c) continue;
487
+ c.est.position.set(b.x + c.dx, b.y + c.dy);
488
+ capaCarteles.addChild(c.est);
489
+ }
490
+ mundo.addChild(capaCarteles);
491
+
492
+ // The city square: where people who are on the bus with no work started stand.
493
+ // Being connected is not the same as digging, and a map that does not
494
+ // distinguish them is lying.
495
+ // The square sits at the near corner of the city, past the last block: it is
496
+ // the entrance, which is where people who have arrived but not started belong.
497
+ plaza = { x: 0, y: yFondo + 70 };
498
+ const suelo2 = new Graphics()
499
+ .ellipse(0, 6, 128, 44)
500
+ .fill({ color: 0xc8b48a, alpha: 0.05 })
501
+ .ellipse(0, 6, 128, 44)
502
+ .stroke({ color: 0xc8b48a, alpha: 0.22, width: 1 });
503
+ suelo2.position.set(plaza.x, plaza.y);
504
+ mundo.addChildAt(suelo2, mundo.children.indexOf(capaGente));
505
+
506
+ // The town hall stands past the square, at the city's entrance: the committee
507
+ // is the seat's process, and the seat's place has always been the square.
508
+ posAyto = { x: plaza.x + 216, y: plaza.y - 12 };
509
+ ayuntamiento = new Ayuntamiento(capaGente, posAyto, casaAncla, anima, SIN_MOVIMIENTO);
510
+ mundo.addChildAt(ayuntamiento.edificio, mundo.children.indexOf(capaGente));
511
+ capaCarteles.addChild(ayuntamiento.cartel);
512
+ // Clicking the hall answers the obvious question — what is the committee
513
+ // doing — the way clicking anything else on this map answers its own.
514
+ ayuntamiento.edificio.eventMode = 'static';
515
+ ayuntamiento.edificio.cursor = 'pointer';
516
+ ayuntamiento.edificio.on('pointertap', fichaAyuntamiento);
517
+
518
+ presencia = new Presencia(
519
+ casaVivaDe,
520
+ (id) => casas.get(id) ?? null,
521
+ existingActorFigure,
522
+ SIN_MOVIMIENTO,
523
+ );
524
+
525
+ // The gates stand past the town hall, one per road, once the Hall says which.
526
+ puertas = new Puertas({ x: plaza.x, y: plaza.y }, anima, fichaPuerta);
527
+ mundo.addChildAt(puertas.capa, mundo.children.indexOf(capaGente));
528
+ capaCarteles.addChild(puertas.carteles);
529
+ if (configPendiente) {
530
+ aplicaConfig(configPendiente);
531
+ configPendiente = null;
532
+ }
533
+
534
+ activityActors = new ActivityActors(
535
+ capaGente,
536
+ existingActorFigure,
537
+ activityPosition,
538
+ (figura, actor) => abrochaCara(figura, actor),
539
+ );
540
+ gameSpeech = new CitySpeech(lienzo, activityActors.resolve);
541
+ for (const event of queuedSpeech.splice(0)) {
542
+ ayuntamiento.recibe(event);
543
+ gameSpeech.show(event);
544
+ }
545
+
546
+ // The architect is drawn when present, like everybody else: reparteEnPlaza()
547
+ // places them from the bus lights. Drawing them always made the map claim
548
+ // somebody was there when they were not.
549
+
550
+ // A handle for debugging in the console: what each house is, and how tall it is
551
+ // right now. Cheap, and the alternative is guessing about the one thing this
552
+ // map has to get right.
553
+ (window as any).__city = {
554
+ casas,
555
+ // The two live painters, exposed so a broken live update can be reproduced
556
+ // from the console instead of guessed at.
557
+ pintaLuces: (l: any[]) => conectados(l),
558
+ pintaObreros: (o: any[]) => cuadrilla(o),
559
+ speak: (actor: string, target: string, text: string) =>
560
+ showActivity({
561
+ protocol: 'agents-city-activity/1',
562
+ id: `debug-${Date.now()}`,
563
+ seq: 0,
564
+ city: 'local-preview',
565
+ thread: null,
566
+ kind: 'conversation.agent',
567
+ actor,
568
+ role: actor === 'seat' ? 'chair' : 'member',
569
+ phase: 'answered',
570
+ tone: 'evidence',
571
+ title: `${actor} speaks`,
572
+ summary: text,
573
+ details: [],
574
+ target,
575
+ at: new Date().toISOString(),
576
+ }),
577
+ // Rehearse the gates without a Hall: `roads([{name:'home',address:'you/home'}])`,
578
+ // then `letter('ada','you/home','security')` sends one out through the arch.
579
+ roads: (lista: { name: string; address: string }[]) => puertas?.configura(lista),
580
+ letter: (de: string, para: string, etiqueta = 'product') => volando(de, para, etiqueta),
581
+ // Drive the live lights from the console: `presence('conversation.user','nova')`
582
+ // and the house breathes without waiting for a real turn.
583
+ presence: (kind: string, actor: string) =>
584
+ showActivity({
585
+ protocol: 'agents-city-activity/1',
586
+ id: `debug-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
587
+ seq: 0,
588
+ city: 'local-preview',
589
+ thread: null,
590
+ kind,
591
+ actor,
592
+ role: 'member',
593
+ phase: 'debug',
594
+ tone: 'system',
595
+ title: kind,
596
+ summary: '',
597
+ details: [],
598
+ at: new Date().toISOString(),
599
+ }),
600
+ // Drive the town hall from the console: a whole session can be rehearsed
601
+ // without opening a real deliberation, which is how the scene gets debugged.
602
+ committee: (kind: string, actor = 'seat', summary = '', thread = 'debug-delib') =>
603
+ showActivity({
604
+ protocol: 'agents-city-activity/1',
605
+ id: `debug-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
606
+ seq: 0,
607
+ city: 'local-preview',
608
+ thread,
609
+ kind,
610
+ actor,
611
+ role: actor === 'seat' ? 'chair' : 'member',
612
+ phase: 'debug',
613
+ tone: 'decision',
614
+ title: kind,
615
+ summary,
616
+ details: [],
617
+ at: new Date().toISOString(),
618
+ }),
619
+ animando: () => animaciones.size,
620
+ // Where the town hall sits on screen right now: how a headless check aims
621
+ // a click at it without guessing camera math.
622
+ hallScreen: () => ayuntamiento?.edificio.getGlobalPosition() ?? { x: 0, y: 0 },
623
+ alturas: () =>
624
+ [...casas.entries()].map(([id, c]) => ({
625
+ id,
626
+ pisos: c.dibujado ?? 0,
627
+ // The settled height, not the frame's: sprite.height mid-tween is a
628
+ // number about the animation, not about the parcel.
629
+ objetivo: +alturaDe(c.dibujado ?? 0).toFixed(3),
630
+ escalaY: c.h?.sp ? +(c.h.sp.scale.y / (c.h.escala ?? 1)).toFixed(3) : 0,
631
+ modelo: c.h?.medida ? +c.h.medida.alto.toFixed(2) : 0,
632
+ })),
633
+ };
634
+
635
+ app.ticker.add((t) => {
636
+ for (const f of [...animaciones]) if (!f(t.deltaTime)) animaciones.delete(f);
637
+ mueveCamara(t.deltaTime);
638
+ gameSpeech?.tick();
639
+ presencia?.tick(t.deltaTime);
640
+ });
641
+
642
+ encaja(app);
643
+ window.addEventListener('resize', () => encaja(app));
644
+ raton(app);
645
+ cifra('c-casas', parcelas.length);
646
+ gastoHUD(datos.gasto);
647
+ setInterval(
648
+ () =>
649
+ fetch('/api/tokens')
650
+ .then((r) => r.json())
651
+ .then(gastoHUD)
652
+ .catch(() => {}),
653
+ 60000,
654
+ );
655
+ vivo();
656
+ cargaPeli();
657
+ }
658
+
659
+ /** Reuse the figure already painted by the presence/work feeds whenever they
660
+ * can identify the speaker. Activity figures only fill the gap for repo agents
661
+ * those older feeds do not represent. */
662
+ function existingActorFigure(actor: string): Container | null {
663
+ const wanted = actorKey(actor);
664
+ const users = new Set<string>([actor]);
665
+ for (const light of ultimasLuces) {
666
+ if (actorMatches(light.agente, wanted) || actorMatches(light.usuario, wanted)) {
667
+ users.add(String(light.usuario || ''));
668
+ }
669
+ }
670
+ if (actor === 'seat') {
671
+ const chair = personas.find((person) => person.rol === 'cpto') || personas[0];
672
+ if (chair) users.add(chair.usuario);
673
+ }
674
+ for (const user of users) {
675
+ const figure = enPlaza.get(user) || peritos.get(user);
676
+ if (figure) return figure;
677
+ }
678
+ for (const [id, worker] of enObra) {
679
+ if (
680
+ actorMatches(id, wanted) ||
681
+ [...users].some((user) => id === user || id.startsWith(`${user}/`))
682
+ ) {
683
+ return worker.c;
684
+ }
685
+ }
686
+ return null;
687
+ }
688
+
689
+ /** The parcel an actor answers to, by the same matching the bubbles use — or
690
+ * null when the map has no parcel it can honestly pin them to. */
691
+ function casaVivaDe(actor: string): { id: string; casa: CasaViva } | null {
692
+ const wanted = actorKey(actor);
693
+ for (const [id, candidate] of casas) {
694
+ if (
695
+ actorMatches(candidate.p.repo, wanted) ||
696
+ actorMatches(candidate.p.nombre, wanted) ||
697
+ actorMatches(candidate.p.id, wanted)
698
+ ) {
699
+ return { id, casa: candidate };
700
+ }
701
+ }
702
+ return null;
703
+ }
704
+
705
+ /** Where an actor's house stands, in world coordinates. */
706
+ function casaAncla(actor: string): { x: number; y: number } | null {
707
+ const viva = casaVivaDe(actor);
708
+ if (!viva) return null;
709
+ const parent = viva.casa.g.parent;
710
+ return { x: (parent?.x ?? 0) + viva.casa.g.x, y: (parent?.y ?? 0) + viva.casa.g.y };
711
+ }
712
+
713
+ /** Members stand beside their repo house; the chair stands in the square. */
714
+ function activityPosition(event: ActivityEvent): { x: number; y: number } {
715
+ if (event.role === 'chair' || event.actor === 'seat') {
716
+ return { x: plaza.x - 58, y: plaza.y + 18 };
717
+ }
718
+ const ancla = casaAncla(event.actor);
719
+ if (ancla) {
720
+ return { x: ancla.x + TW * 0.38, y: ancla.y + TH * 0.72 };
721
+ }
722
+ const angle = ((stableNumber(event.actor) % 9) / 9) * Math.PI * 2;
723
+ return { x: plaza.x + Math.cos(angle) * 92, y: plaza.y + Math.sin(angle) * 34 + 14 };
724
+ }
725
+
726
+ function actorKey(value: unknown): string {
727
+ return String(value || '')
728
+ .split('/')
729
+ .at(-1)!
730
+ .toLowerCase()
731
+ .replace(/[^a-z0-9]/g, '');
732
+ }
733
+
734
+ function actorMatches(value: unknown, wanted: string): boolean {
735
+ return Boolean(wanted && actorKey(value) === wanted);
736
+ }
737
+
738
+ function stableNumber(value: string): number {
739
+ let hash = 17;
740
+ for (const character of value) hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
741
+ return hash;
742
+ }
743
+
744
+ /** The HUD legend comes from the data too, not from the HTML. */
745
+ function leyenda(unidades: Unidad[]) {
746
+ const c = document.getElementById('leyenda');
747
+ if (!c) return;
748
+ c.innerHTML = '';
749
+ for (const u of unidades) {
750
+ const d = document.createElement('div');
751
+ d.innerHTML =
752
+ `<span class="pip" style="background:#${hex(u.color)}"></span>${esc(u.nombre)}` +
753
+ (u.nota ? ` <span class="nota2">· ${esc(u.nota)}</span>` : '');
754
+ c.appendChild(d);
755
+ }
756
+ }
757
+
758
+ /**
759
+ * The camera.
760
+ *
761
+ * Not a fit-to-screen and nothing else: a map where the whole city always fits
762
+ * is a map where the people are four pixels tall, and the people are the point.
763
+ * So there is a target — a place and a zoom — and the view eases towards it every
764
+ * frame. The replay moves that target to wherever work landed that day; the
765
+ * present frames the whole city; and the moment you drag or wheel, the camera
766
+ * lets go and leaves you alone until you ask for it back.
767
+ */
768
+ const camara = {
769
+ x: 0,
770
+ y: 0,
771
+ s: 1, // where it is going
772
+ tuyo: false, // the human took over
773
+ sigue: true, // chase the action during the replay
774
+ pantalla: { w: 0, h: 0 },
775
+ };
776
+
777
+ function mira(x: number, y: number, s: number) {
778
+ camara.x = x;
779
+ camara.y = y;
780
+ camara.s = s;
781
+ }
782
+
783
+ /** Frame the whole city. What the present looks at, and what the ⤢ button does. */
784
+ function encaja(app: Application, inmediato = true) {
785
+ camara.pantalla = { w: app.screen.width, h: app.screen.height };
786
+ const b = mundo.getLocalBounds();
787
+ const esc = Math.min(
788
+ app.screen.width / (b.width + 90),
789
+ app.screen.height / (b.height + 120),
790
+ 1.5,
791
+ );
792
+ camara.tuyo = false;
793
+ mira(b.x + b.width / 2, b.y + b.height / 2, esc);
794
+ if (inmediato) {
795
+ mundo.scale.set(esc);
796
+ mundo.position.set(
797
+ app.screen.width / 2 - camara.x * esc,
798
+ app.screen.height / 2 - camara.y * esc,
799
+ );
800
+ carteles(esc);
801
+ }
802
+ }
803
+
804
+ /** One step of the easing. Called from the same ticker as everything else. */
805
+ function mueveCamara(dt: number) {
806
+ if (camara.tuyo) return;
807
+ const k = Math.min(1, 0.055 * dt);
808
+ const s = mundo.scale.x + (camara.s - mundo.scale.x) * k;
809
+ const px = camara.pantalla.w / 2 - camara.x * s;
810
+ const py = camara.pantalla.h / 2 - camara.y * s;
811
+ mundo.scale.set(s);
812
+ mundo.x += (px - mundo.x) * k;
813
+ mundo.y += (py - mundo.y) * k;
814
+ if (Math.abs(s - escalaActual) > 0.02) carteles(s);
815
+ }
816
+
817
+ /**
818
+ * Point the camera at what is happening on this day of the replay.
819
+ *
820
+ * The centre is the average of the houses that grew, so with work in two corners
821
+ * it pulls back and shows both, and with everything in one district it goes in
822
+ * close. Weighted by nothing: a parcel that landed one PR matters as much as the
823
+ * monolith, because what is being shown is where somebody was working.
824
+ */
825
+ function miraLaAccion(activas: string[]) {
826
+ if (!camara.sigue) return;
827
+ const puntos = activas
828
+ .map((id) => casas.get(id))
829
+ .filter(Boolean)
830
+ .map((c) => ({ x: (c!.g.parent?.x ?? 0) + c!.g.x, y: (c!.g.parent?.y ?? 0) + c!.g.y }));
831
+ if (!puntos.length) return;
832
+ const cx = puntos.reduce((a, p) => a + p.x, 0) / puntos.length;
833
+ const cy = puntos.reduce((a, p) => a + p.y, 0) / puntos.length;
834
+ // How spread out the day's work is decides the zoom: everything in one street
835
+ // gets a close shot, work in three districts gets a wide one.
836
+ const r = Math.max(...puntos.map((p) => Math.hypot(p.x - cx, p.y - cy)), 120);
837
+ // Capped at 1: closer than that and you are looking at three buildings with no
838
+ // idea where they are. The point of following is to be near the work AND still
839
+ // see the city around it.
840
+ const quiere = Math.max(
841
+ 0.5,
842
+ Math.min(1, Math.min(camara.pantalla.w / (r * 3.2), camara.pantalla.h / (r * 2.8))),
843
+ );
844
+ // The zoom only moves when it really has to. Recomputing it every day meant
845
+ // the camera pushed in and pulled out constantly as the day's work spread or
846
+ // gathered — accurate, and nauseating to watch.
847
+ const s = Math.abs(quiere - camara.s) > 0.22 ? quiere : camara.s;
848
+ // And the pan gets the same medicine. "Panning is free" turned out to be wrong
849
+ // in the same way: retargeting on every day's centroid sent the view lurching
850
+ // corner to corner. Now the camera moves only when the action has really left
851
+ // the shot — more than a fifth of the visible world away — and parks otherwise.
852
+ const dx = Math.abs(cx - camara.x) / (camara.pantalla.w / Math.max(s, 0.1));
853
+ const dy = Math.abs(cy - 60 / s - camara.y) / (camara.pantalla.h / Math.max(s, 0.1));
854
+ if (dx > 0.2 || dy > 0.2 || s !== camara.s) mira(cx, cy - 60 / s, s);
855
+ }
856
+
857
+ function raton(app: Application) {
858
+ let arrastra = false,
859
+ ax = 0,
860
+ ay = 0;
861
+ app.canvas.addEventListener('pointerdown', (e) => {
862
+ arrastra = true;
863
+ ax = e.clientX;
864
+ ay = e.clientY;
865
+ camara.tuyo = true; // you drag, the camera stops deciding
866
+ });
867
+ window.addEventListener('pointerup', () => {
868
+ arrastra = false;
869
+ });
870
+ window.addEventListener('pointermove', (e) => {
871
+ if (!arrastra) return;
872
+ mundo.x += e.clientX - ax;
873
+ mundo.y += e.clientY - ay;
874
+ ax = e.clientX;
875
+ ay = e.clientY;
876
+ });
877
+ app.canvas.addEventListener(
878
+ 'wheel',
879
+ (e) => {
880
+ e.preventDefault();
881
+ camara.tuyo = true;
882
+ const k = e.deltaY < 0 ? 1.12 : 1 / 1.12;
883
+ const antes = mundo.scale.x,
884
+ desp = Math.max(0.25, Math.min(3, antes * k));
885
+ // Zoom hacia el puntero: si no, alejarse te saca del sitio que mirabas.
886
+ mundo.x = e.offsetX - (e.offsetX - mundo.x) * (desp / antes);
887
+ mundo.y = e.offsetY - (e.offsetY - mundo.y) * (desp / antes);
888
+ mundo.scale.set(desp);
889
+ carteles(desp);
890
+ },
891
+ { passive: false },
892
+ );
893
+
894
+ // Double click on the ground: give the camera back.
895
+ app.canvas.addEventListener('dblclick', () => encaja(app, false));
896
+
897
+ // A click on the map that did not land on a house or a person closes the card.
898
+ // Clicking away is what everybody tries first, and it did nothing.
899
+ app.stage.eventMode = 'static';
900
+ app.stage.hitArea = { contains: () => true } as any;
901
+ app.stage.on('pointertap', (e: any) => {
902
+ let n: any = e.target;
903
+ while (n) {
904
+ if (n.__usuario || (n.eventMode === 'static' && n !== app.stage && n.cursor === 'pointer'))
905
+ return;
906
+ n = n.parent;
907
+ }
908
+ cierraFicha();
909
+ });
910
+
911
+ /**
912
+ * The controls.
913
+ *
914
+ * A map you can only move with a trackpad is a map half the people who open it
915
+ * cannot move at all. Zoom, fit, and a switch for whether the camera chases
916
+ * what is happening — plus the same four things on the keyboard, because
917
+ * anybody who watches this for more than a minute reaches for the arrows.
918
+ */
919
+ const zoom = (k: number) => {
920
+ camara.tuyo = true;
921
+ const antes = mundo.scale.x,
922
+ desp = Math.max(0.25, Math.min(3, antes * k));
923
+ const cx = app.screen.width / 2,
924
+ cy = app.screen.height / 2;
925
+ mundo.x = cx - (cx - mundo.x) * (desp / antes);
926
+ mundo.y = cy - (cy - mundo.y) * (desp / antes);
927
+ mundo.scale.set(desp);
928
+ carteles(desp);
929
+ };
930
+ const sigue = document.getElementById('m-sigue');
931
+ const marcaSeguir = () => sigue?.classList.toggle('on', camara.sigue);
932
+ document.getElementById('m-mas')?.addEventListener('click', () => zoom(1.25));
933
+ document.getElementById('m-menos')?.addEventListener('click', () => zoom(1 / 1.25));
934
+ document.getElementById('m-encaja')?.addEventListener('click', () => {
935
+ encaja(app, false);
936
+ marcaSeguir();
937
+ });
938
+ sigue?.addEventListener('click', () => {
939
+ camara.sigue = !camara.sigue;
940
+ if (camara.sigue) camara.tuyo = false;
941
+ marcaSeguir();
942
+ });
943
+ // Fullscreen: the whole page, not just the canvas — the HUD, the fiches and
944
+ // the bubbles are part of watching the city, so they come along. Embedded in
945
+ // the Hall this fullscreens the map's iframe, which is exactly the map.
946
+ const full = document.getElementById('m-full');
947
+ const pantallaCompleta = () => {
948
+ if (document.fullscreenElement) void document.exitFullscreen();
949
+ else void document.documentElement.requestFullscreen().catch(() => {});
950
+ };
951
+ full?.addEventListener('click', pantallaCompleta);
952
+ document.addEventListener('fullscreenchange', () => {
953
+ full?.classList.toggle('on', Boolean(document.fullscreenElement));
954
+ encaja(app, false); // the viewport just changed size: reframe the city
955
+ });
956
+
957
+ window.addEventListener('keydown', (e) => {
958
+ if ((e.target as HTMLElement)?.tagName === 'INPUT') return;
959
+ const paso = e.shiftKey ? 120 : 45;
960
+ switch (e.key) {
961
+ case '+':
962
+ case '=':
963
+ zoom(1.25);
964
+ break;
965
+ case '-':
966
+ case '_':
967
+ zoom(1 / 1.25);
968
+ break;
969
+ case 'f':
970
+ case 'F':
971
+ encaja(app, false);
972
+ marcaSeguir();
973
+ break;
974
+ case 'g':
975
+ case 'G':
976
+ camara.sigue = !camara.sigue;
977
+ if (camara.sigue) camara.tuyo = false;
978
+ marcaSeguir();
979
+ break;
980
+ case 'p':
981
+ case 'P':
982
+ pantallaCompleta();
983
+ break;
984
+ case ' ':
985
+ reproduce();
986
+ e.preventDefault();
987
+ break;
988
+ case 'ArrowLeft':
989
+ case 'a':
990
+ camara.tuyo = true;
991
+ mundo.x += paso;
992
+ break;
993
+ case 'ArrowRight':
994
+ case 'd':
995
+ camara.tuyo = true;
996
+ mundo.x -= paso;
997
+ break;
998
+ case 'ArrowUp':
999
+ case 'w':
1000
+ camara.tuyo = true;
1001
+ mundo.y += paso;
1002
+ break;
1003
+ case 'ArrowDown':
1004
+ case 's':
1005
+ camara.tuyo = true;
1006
+ mundo.y -= paso;
1007
+ break;
1008
+ default:
1009
+ return;
1010
+ }
1011
+ });
1012
+ }
1013
+
1014
+ /** Zoomed out the trade drops, then the whole label: who it is shows up close. */
1015
+ /**
1016
+ * Labels keep their size on screen, whatever the zoom.
1017
+ *
1018
+ * The alternative — letting them scale with the world — means the district
1019
+ * banner is unreadable when you are looking at the whole city and the size of a
1020
+ * road sign when you are not. Every map ever drawn keeps its type at one size;
1021
+ * the city underneath is what zooms.
1022
+ */
1023
+ function carteles(escala: number) {
1024
+ escalaActual = escala;
1025
+ const inverso = Math.max(0.55, Math.min(1.8, 1 / escala));
1026
+ for (const c of capaCarteles?.children ?? []) (c as Container).scale.set(inverso);
1027
+ const figuras: Container[] = [
1028
+ ...[...enObra.values()].map((o) => o.c),
1029
+ ...peritos.values(),
1030
+ ...enPlaza.values(),
1031
+ ];
1032
+ for (const g of figuras) {
1033
+ const ca = (g as any).__cartel;
1034
+ if (ca) ca.scale.set(inverso / ESCALA_GENTE);
1035
+ }
1036
+ const conCartel = escala > 0.5,
1037
+ conSegunda = escala > 0.85;
1038
+ for (const { c } of enObra.values()) toca(c, conCartel, conSegunda);
1039
+ for (const c of peritos.values()) toca(c, conCartel, conSegunda);
1040
+ for (const c of enPlaza.values()) toca(c, conCartel, conSegunda);
1041
+ for (const c of capaGente.children) toca(c as Container, conCartel, conSegunda);
1042
+ // District banners keep their name at every zoom — it is how you know what you
1043
+ // are looking at — and drop the parcel count when it stops being readable.
1044
+ for (const c of capaCarteles?.children ?? []) {
1045
+ const sub = (c as any).__sub;
1046
+ if (sub) sub.visible = escala > 0.8;
1047
+ }
1048
+ }
1049
+ function toca(c: Container, cartel: boolean, segunda: boolean) {
1050
+ const ca = (c as any).__cartel,
1051
+ sg = (c as any).__agente;
1052
+ if (ca) ca.visible = cartel;
1053
+ if (sg) sg.visible = segunda;
1054
+ }
1055
+
1056
+ /**
1057
+ * Who this person is, and what they are doing right now.
1058
+ *
1059
+ * The map is about work, and work belongs to people: clicking a figure and
1060
+ * getting nothing was the map refusing to answer the most obvious question
1061
+ * anybody asks of it.
1062
+ *
1063
+ * What it does NOT show is anything that ranks them. The crew size is here
1064
+ * because it says what is open, and it is labelled as that — never as output.
1065
+ */
1066
+ function fichaPersona(usuario: string) {
1067
+ const f = document.getElementById('ficha')!;
1068
+ const cuerpo = document.getElementById('ficha-cuerpo')!;
1069
+ const p = personas.find((x) => x.usuario === usuario);
1070
+ const col = '#' + colorDe(usuario).toString(16).padStart(6, '0');
1071
+ const suyas = [...casas.values()].filter((c) => c.p.dueno === usuario);
1072
+ const picando = ultimosObreros.filter((o) => o.usuario === usuario);
1073
+
1074
+ const lista = suyas
1075
+ .slice(0, 8)
1076
+ .map(
1077
+ (c) =>
1078
+ `<div class="ag"><span class="an">${esc(c.p.nombre)}</span>` +
1079
+ `<span class="do">${esc(c.p.unidad)}</span></div>`,
1080
+ )
1081
+ .join('');
1082
+
1083
+ cuerpo.innerHTML =
1084
+ `<h3><span class="pip" style="background:${col}"></span> ${esc(p?.nombre ?? usuario)}</h3><dl>` +
1085
+ `<dt>On the bus</dt><dd>${esc(p?.agente ?? usuario + '/…')}</dd>` +
1086
+ `<dt>Role</dt><dd>${esc(p?.rol ?? '—')}${p?.oficio ? ` · ${esc(p.oficio)}` : ''}</dd>` +
1087
+ `<dt>Answers for</dt><dd>${suyas.length} ${plural(suyas.length, 'parcel')}</dd>` +
1088
+ `<dt>Open now</dt><dd>${
1089
+ picando.length
1090
+ ? picando
1091
+ .map((o) => `${esc(o.ventana)} → ${esc(casas.get(o.parcela)?.p.nombre ?? o.parcela)}`)
1092
+ .join('<br>')
1093
+ : 'nothing started'
1094
+ }</dd></dl>` +
1095
+ (lista ? `<div class="lista">${lista}</div>` : '') +
1096
+ (suyas.length > 8 ? `<p class="nota2">and ${suyas.length - 8} more</p>` : '') +
1097
+ metasDe(usuario);
1098
+ f.hidden = false;
1099
+ }
1100
+
1101
+ /**
1102
+ * This person's goals, as they are written on their card.
1103
+ *
1104
+ * The map shows properties and work; the goal is what says whether any of it is
1105
+ * going anywhere. It comes with the measure that decides it — a goal you cannot
1106
+ * run a command against is an opinion — and with the baseline and the date it
1107
+ * was taken, so nobody gets to relitigate the starting point in month three.
1108
+ */
1109
+ function metasDe(usuario: string): string {
1110
+ const suyos = objetivos.filter((o) => o.usuario === usuario);
1111
+ if (!suyos.length) {
1112
+ return `<p class="nota2">No goal set yet. That is a gap, not a detail: a round
1113
+ with no goal can only ask what happened, never whether it mattered.</p>`;
1114
+ }
1115
+ return (
1116
+ `<div class="metas">` +
1117
+ suyos
1118
+ .map((o) => {
1119
+ const estado = (o.estado ?? '').toLowerCase().replace(/\s+/g, '-');
1120
+ return (
1121
+ `<div class="meta">` +
1122
+ `<div class="mt"><span class="est ${esc(estado)}">${esc(o.estado ?? '—')}</span>` +
1123
+ `<b>${esc(o.titulo)}</b></div>` +
1124
+ (o.como ? `<div class="ml"><span>measured by</span>${esc(o.como)}</div>` : '') +
1125
+ (o.medida
1126
+ ? `<div class="ml"><span>measure</span><code>${esc(o.medida)}</code></div>`
1127
+ : '') +
1128
+ (o.partida ? `<div class="ml"><span>from</span>${esc(o.partida)}</div>` : '') +
1129
+ (o.meta
1130
+ ? `<div class="ml"><span>to</span>${esc(o.meta)}${o.cuando ? ` · ${esc(o.cuando)}` : ''}</div>`
1131
+ : '') +
1132
+ `</div>`
1133
+ );
1134
+ })
1135
+ .join('') +
1136
+ `</div>`
1137
+ );
1138
+ }
1139
+
1140
+ /** The town hall's card: what the committee is doing right now, and the door
1141
+ * to the full transcript when a Hall is wrapped around this map. */
1142
+ function fichaAyuntamiento() {
1143
+ if (!ayuntamiento) return;
1144
+ const f = document.getElementById('ficha')!;
1145
+ const cuerpo = document.getElementById('ficha-cuerpo')!;
1146
+ const s = ayuntamiento.estado();
1147
+ cuerpo.innerHTML =
1148
+ `<h3>${caraHTML('seat')}Town hall</h3><dl>
1149
+ <dt>Committee</dt><dd>${esc(s.fase)}</dd>
1150
+ ${s.actor ? `<dt>Last turn</dt><dd>${esc(s.actor)}</dd>` : ''}
1151
+ <dt>Positions in</dt><dd>${s.posiciones | 0}</dd>
1152
+ <dt>Session</dt><dd>${s.activo ? 'sitting now' : 'not sitting'}</dd></dl>` +
1153
+ (parentOrigin
1154
+ ? `<p><button class="irHall" id="vamosComite">Open the committee in the Hall</button></p>`
1155
+ : `<p class="nota2">The acts live in the city's deliberations folder.</p>`);
1156
+ document.getElementById('vamosComite')?.addEventListener('click', () => navHall('committee'));
1157
+ f.hidden = false;
1158
+ }
1159
+
1160
+ /** A gate's card: where the road goes, and the Hall's roads page behind it. */
1161
+ function fichaPuerta(road: RoadInfo) {
1162
+ const f = document.getElementById('ficha')!;
1163
+ const cuerpo = document.getElementById('ficha-cuerpo')!;
1164
+ cuerpo.innerHTML =
1165
+ `<h3>${esc(road.name)}</h3><dl>
1166
+ <dt>Road to</dt><dd><code>${esc(road.address)}</code></dd>
1167
+ <dt>Grants</dt><dd>reachability between seats — never authority</dd></dl>` +
1168
+ (parentOrigin
1169
+ ? `<p><button class="irHall" id="vamosRed">Open roads in the Hall</button></p>`
1170
+ : '');
1171
+ document.getElementById('vamosRed')?.addEventListener('click', () => navHall('red'));
1172
+ f.hidden = false;
1173
+ }
1174
+
1175
+ /** An agent's face for HTML cards, when the Hall has sent one. */
1176
+ function caraHTML(nombre: string): string {
1177
+ const uri = avataresURI[nombre];
1178
+ if (!uri || !uri.startsWith('data:image/svg+xml;base64,')) return '';
1179
+ return `<img class="fichaCara" src="${esc(uri)}" alt="">`;
1180
+ }
1181
+
1182
+ function ficha(p: Parcela) {
1183
+ const f = document.getElementById('ficha')!;
1184
+ const cuerpo = document.getElementById('ficha-cuerpo')!;
1185
+ const due = personas.find((x) => x.usuario === p.dueno);
1186
+ cuerpo.innerHTML =
1187
+ `<h3>${caraHTML(p.nombre) || caraHTML(p.repo)}${esc(p.nombre)}</h3><dl>
1188
+ <dt>Repo</dt><dd>${esc(p.repo)}</dd>
1189
+ <dt>Parcel</dt><dd>${esc(p.ruta || 'whole repo')}</dd>
1190
+ <dt>District</dt><dd>${esc(p.unidad)}</dd>
1191
+ <dt>Owner</dt><dd>${due ? `${esc(due.usuario)} · ${esc(due.oficio)}` : esc(p.dueno ?? 'no owner')}</dd>
1192
+ <dt>Capital</dt><dd>${p.pisos | 0} landed ${plural(p.pisos | 0, 'PR')}${(p.hermanas ?? 1) > 1 ? ' *' : ''}</dd>
1193
+ <dt>Open</dt><dd>${p.andamios | 0} ${plural(p.andamios | 0, 'scaffold')}${p.andamio_viejo ? ` · ${p.andamio_viejo | 0} stale` : ''}</dd>
1194
+ <dt>30 days</dt><dd>${p.actividad30 | 0} ${plural(p.actividad30 | 0, 'commit')}</dd></dl>` +
1195
+ ((p.hermanas ?? 1) > 1
1196
+ ? `<p class="aviso2">* Whole-repo figure, not this parcel:
1197
+ ${p.hermanas} parcels of <code>${esc(p.repo)}</code> share this number.
1198
+ Measuring per path needs one more API call per PR and is not done yet.</p>`
1199
+ : '');
1200
+ f.hidden = false;
1201
+ }
1202
+
1203
+ /**
1204
+ * Escape before anything reaches innerHTML.
1205
+ *
1206
+ * Everything painted here is written by somebody else: the window name comes from
1207
+ * a hook on another person's machine, the text of a notice from the agent that
1208
+ * sent it, and a parcel's name from parcels.yml. Without this, an agent called
1209
+ * `<img onerror=...>` runs code on the screen the whole team is looking at.
1210
+ */
1211
+ /** "1 floor", "2 floors". Small, but a map that says "1 floors" is a map nobody
1212
+ * believes about anything else either. */
1213
+ const plural = (n: number, palabra: string) => (n === 1 ? palabra : palabra + 's');
1214
+
1215
+ /**
1216
+ * A colour that came from the database, reduced to what a colour can be.
1217
+ *
1218
+ * The units are data, and data is edited by people: a value like
1219
+ * `red;background:url(x)` cannot escape a quoted attribute once escaped, but it
1220
+ * can still redefine what that attribute paints. Six hex digits or nothing.
1221
+ */
1222
+ const hex = (v: unknown, pordefecto = 'c8b48a') =>
1223
+ /^[0-9a-fA-F]{3,8}$/.test(String(v ?? '')) ? String(v) : pordefecto;
1224
+
1225
+ const esc = (v: unknown) =>
1226
+ String(v ?? '')
1227
+ .replace(/&/g, '&amp;')
1228
+ .replace(/</g, '&lt;')
1229
+ .replace(/>/g, '&gt;')
1230
+ .replace(/"/g, '&quot;')
1231
+ .replace(/'/g, '&#39;');
1232
+
1233
+ const cifra = (id: string, v: number | string) => {
1234
+ const e = document.getElementById(id);
1235
+ if (e) e.textContent = String(v);
1236
+ };
1237
+
1238
+ /**
1239
+ * What the whole city spent. Deliberately one number: the reports arrive per
1240
+ * person because that is the only way to deduplicate them, but nothing here
1241
+ * splits them back out. A leaderboard would change what people do, and what
1242
+ * this map is for is the opposite of that.
1243
+ *
1244
+ * Hidden until somebody reports, so a city that has never run bin/tokens.py
1245
+ * shows nothing rather than a confident zero.
1246
+ */
1247
+ type Gasto = {
1248
+ hoy?: { tokens?: number; cache?: number; gente?: number };
1249
+ mes?: { tokens?: number; cache?: number };
1250
+ };
1251
+
1252
+ const corto = (n: number) =>
1253
+ n >= 1e9
1254
+ ? (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B'
1255
+ : n >= 1e6
1256
+ ? (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'
1257
+ : n >= 1e3
1258
+ ? Math.round(n / 1e3) + 'k'
1259
+ : String(n);
1260
+
1261
+ function gastoHUD(g?: Gasto) {
1262
+ const caja = document.getElementById('gasto') as HTMLElement | null;
1263
+ if (!caja) return;
1264
+ const hoy = g?.hoy?.tokens ?? 0,
1265
+ mes = g?.mes?.tokens ?? 0;
1266
+ if (!hoy && !mes) {
1267
+ caja.hidden = true;
1268
+ return;
1269
+ }
1270
+ caja.hidden = false;
1271
+ cifra('g-hoy', corto(hoy));
1272
+ cifra('g-mes', corto(mes) + ' tokens');
1273
+ const cache = g?.mes?.cache ?? 0;
1274
+ cifra('g-cache', mes ? Math.round((cache / (mes + cache)) * 100) + '%' : '—');
1275
+ const gente = g?.hoy?.gente ?? 0;
1276
+ const t = caja.querySelector('.gt span');
1277
+ if (t)
1278
+ t.textContent = gente
1279
+ ? `tokens spent today, across ${gente} ${gente === 1 ? 'person' : 'people'}`
1280
+ : 'tokens spent today, whole city';
1281
+ }
1282
+
1283
+ /**
1284
+ * The shapes the API speaks, translated to the ones the map speaks — in one
1285
+ * place, at the door.
1286
+ *
1287
+ * The square returns `user`, `agent`, `window`, `parcel`; the drawing code has
1288
+ * always used `usuario`, `agente`, `ventana`, `parcela`. Nothing complained:
1289
+ * every field simply arrived undefined, so the live view drew nobody at all
1290
+ * while the replay — which builds its own objects — worked perfectly. That is
1291
+ * the worst kind of bug, and the fix is to have exactly one place where the two
1292
+ * vocabularies meet instead of hoping every call site remembers.
1293
+ */
1294
+ const deLuz = (l: any) => ({
1295
+ usuario: l.usuario ?? l.user,
1296
+ agente: l.agente ?? l.agent,
1297
+ desde: l.since ?? l.desde,
1298
+ });
1299
+ const deObrero = (o: any) => ({
1300
+ id: o.id,
1301
+ usuario: o.usuario ?? o.user,
1302
+ ventana: o.ventana ?? o.window,
1303
+ parcela: o.parcela ?? o.parcel,
1304
+ });
1305
+ const deCarta = (n: any) => ({
1306
+ de: n.de ?? n.from,
1307
+ a: n.a ?? n.to,
1308
+ etiqueta: n.etiqueta ?? n.tag,
1309
+ texto: n.texto ?? n.text,
1310
+ ts: n.ts,
1311
+ });
1312
+
1313
+ function vivo() {
1314
+ const punto = document.getElementById('punto')!,
1315
+ conex = document.getElementById('conex')!;
1316
+ let ws: WebSocket,
1317
+ espera = 1000;
1318
+ const conecta = () => {
1319
+ ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`);
1320
+ ws.onopen = () => {
1321
+ punto.classList.add('on');
1322
+ conex.textContent = 'live';
1323
+ espera = 1000;
1324
+ };
1325
+ ws.onclose = () => {
1326
+ punto.classList.remove('on');
1327
+ conex.textContent = 'reconnecting…';
1328
+ setTimeout(conecta, (espera = Math.min(espera * 2, 20000)));
1329
+ };
1330
+ ws.onmessage = (e) => {
1331
+ const m = JSON.parse(e.data);
1332
+ if (m.t === 'state') {
1333
+ conectados((m.lights ?? []).map(deLuz));
1334
+ cuadrilla((m.workers ?? []).map(deObrero));
1335
+ cartas((m.notices ?? []).map(deCarta));
1336
+ } else if (m.t === 'change' && m.ev.t === 'notice') {
1337
+ const notice = deCarta({ ...m.ev, ts: '' });
1338
+ cartas([notice], true);
1339
+ showActivity({
1340
+ protocol: 'agents-city-activity/1',
1341
+ id: `notice-${Date.now()}`,
1342
+ seq: 0,
1343
+ city: 'map',
1344
+ thread: null,
1345
+ kind: 'conversation.agent',
1346
+ actor: String(notice.de || ''),
1347
+ role: 'member',
1348
+ phase: 'answered',
1349
+ tone: 'evidence',
1350
+ title: `${notice.de} sent a notice`,
1351
+ summary: String(notice.texto || ''),
1352
+ details: [],
1353
+ target: String(notice.a || ''),
1354
+ at: new Date().toISOString(),
1355
+ });
1356
+ } else if (m.t === 'change') repasa();
1357
+ };
1358
+ };
1359
+ const repasa = () =>
1360
+ fetch('/api/state')
1361
+ .then((r) => r.json())
1362
+ .then((d: any) => {
1363
+ conectados((d.lights ?? []).map(deLuz));
1364
+ cuadrilla((d.workers ?? []).map(deObrero));
1365
+ })
1366
+ .catch(() => {});
1367
+ setInterval(repasa, 20000);
1368
+ conecta();
1369
+ }
1370
+
1371
+ /**
1372
+ * Who has a session open on the bus. Different from digging: this is who is
1373
+ * *there*, even if they have not touched a file. Anyone with no work started
1374
+ * waits in the square; the moment they start, their worker appears at the house.
1375
+ */
1376
+ let ultimasLuces: any[] = [];
1377
+ function conectados(luces: any[]) {
1378
+ ultimasLuces = luces;
1379
+ cifra('c-conectados', luces.length);
1380
+
1381
+ // The strip is decoration; the map is not. A throw in here used to take the
1382
+ // whole live update with it — the count updated, the crew never did.
1383
+ try {
1384
+ cintaDelBus(luces);
1385
+ } catch (e) {
1386
+ console.error('city: ticker', e);
1387
+ }
1388
+ reparteEnPlaza();
1389
+ }
1390
+
1391
+ /**
1392
+ * The ticker at the top: who is on the bus right now.
1393
+ *
1394
+ * A strip that slides rather than a list that sits still, because this is the
1395
+ * one thing on screen that changes by itself — somebody opens a session and they
1396
+ * are there. The content is written twice so the loop has no seam, and the
1397
+ * duration comes from how much there is to say, so three people scroll slowly
1398
+ * and thirty scroll at the same speed rather than three times faster.
1399
+ */
1400
+ function cintaDelBus(luces: any[]) {
1401
+ const tira = document.getElementById('tira');
1402
+ if (!tira) return;
1403
+ if (!luces.length) {
1404
+ tira.style.animation = 'none';
1405
+ tira.innerHTML =
1406
+ '<span class="nadie">Nobody on the bus right now — ' +
1407
+ 'open a session with the city plugin and you appear here</span>';
1408
+ return;
1409
+ }
1410
+ const uno = (l: any) => {
1411
+ const p = personas.find((x) => x.usuario === l.usuario);
1412
+ const col = '#' + colorDe(l.usuario).toString(16).padStart(6, '0');
1413
+ const donde = [...enObra.entries()].find(([id]) => id.startsWith(l.usuario + '/'));
1414
+ const casa = donde ? casas.get(donde[1].parcela)?.p.nombre : '';
1415
+ // Every name on the tape is a door: click it and you get who they are and
1416
+ // what they are trying to do.
1417
+ return (
1418
+ `<span class="uno" data-u="${esc(l.usuario)}" role="button" tabindex="0">` +
1419
+ `<span class="pip" style="background:${esc(col)}"></span>` +
1420
+ `<span>${esc(l.agente)}</span>` +
1421
+ (p?.oficio ? `<span class="of2">${esc(p.oficio)}</span>` : '') +
1422
+ (casa ? `<span class="en">· ${esc(casa)}</span>` : '') +
1423
+ `</span>`
1424
+ );
1425
+ };
1426
+ const fila = luces.map(uno).join('');
1427
+ tira.innerHTML = fila + fila; // twice: the loop has no seam
1428
+ // Anybody on the tape can be clicked: it is the fastest route to "who is this
1429
+ // and what are they trying to do", which is the question the strip provokes.
1430
+ tira.onclick = (e) => {
1431
+ const n = (e.target as HTMLElement).closest('[data-u]') as HTMLElement | null;
1432
+ if (n?.dataset.u) fichaPersona(n.dataset.u);
1433
+ };
1434
+ tira.style.animation = `desliza ${Math.max(14, luces.length * 6)}s linear infinite`;
1435
+ }
1436
+
1437
+ /** Only people who are connected with no work started stay in the square. */
1438
+ function reparteEnPlaza() {
1439
+ const conObra = new Set([...enObra.values()].map(() => '')); // se rellena abajo
1440
+ const ocupados = new Set<string>();
1441
+ for (const [id] of enObra) ocupados.add(id.split('/')[0]);
1442
+
1443
+ const deben = ultimasLuces.filter((l) => !ocupados.has(l.usuario));
1444
+ for (const [u, c] of enPlaza) {
1445
+ if (!deben.some((l) => l.usuario === u)) {
1446
+ c.parent?.removeChild(c);
1447
+ enPlaza.delete(u);
1448
+ }
1449
+ }
1450
+ deben.forEach((l, i) => {
1451
+ if (enPlaza.has(l.usuario)) return;
1452
+ const p = personas.find((x) => x.usuario === l.usuario);
1453
+ // El arquitecto tiene su propia figura: no pica y no lleva cuadrilla.
1454
+ const fig =
1455
+ p?.rol === 'cpto' ? arquitecto(l.usuario) : perito(l.usuario, p?.oficio ?? 'on the bus');
1456
+ // Spread around the square, with labels at staggered heights: five figures in
1457
+ // a small ellipse with every label at the same height is a smudge.
1458
+ const ang = (i / Math.max(5, deben.length)) * Math.PI * 2 - Math.PI / 2;
1459
+ fig.position.set(plaza.x + Math.cos(ang) * 104, plaza.y + Math.sin(ang) * 34 + 10);
1460
+ const cartel = (fig as any).__cartel;
1461
+ if (cartel) cartel.y -= (i % 3) * 15;
1462
+ capaGente.addChild(fig);
1463
+ enPlaza.set(l.usuario, fig);
1464
+ });
1465
+ }
1466
+ /** Reconcile the workers and foremen on screen with what the square reports. */
1467
+ let ultimosObreros: any[] = [];
1468
+ /**
1469
+ * The mark on the plot somebody is digging in, in their colour.
1470
+ *
1471
+ * Without it a figure standing near a building is just a figure standing near a
1472
+ * building: at any zoom where more than one house fits on screen, you cannot
1473
+ * tell which one they are working on. The ring is drawn on the ground under the
1474
+ * house, beneath the house itself, so the building still occludes it the way it
1475
+ * occludes everything else.
1476
+ */
1477
+ const marcas = new Map<string, Container>();
1478
+ function marca(id: string, c: CasaViva, usuario: string) {
1479
+ quitaMarca(id);
1480
+ const col = colorDe(usuario);
1481
+ const g = new Graphics()
1482
+ .poly([0, 0, TW / 2, TH / 2, 0, TH, -TW / 2, TH / 2])
1483
+ .fill({ color: col, alpha: 0.13 })
1484
+ .poly([0, 0, TW / 2, TH / 2, 0, TH, -TW / 2, TH / 2])
1485
+ .stroke({ color: col, width: 1.4, alpha: 0.85 });
1486
+ const cont = new Container();
1487
+ cont.addChild(g);
1488
+ cont.position.set(c.g.x, c.g.y);
1489
+ const padre = c.g.parent!;
1490
+ padre.addChildAt(cont, Math.max(0, padre.getChildIndex(c.g)));
1491
+ marcas.set(id, cont);
1492
+
1493
+ let t = 0;
1494
+ anima((dt) => {
1495
+ if (!cont.parent) return false;
1496
+ t += 0.05 * dt;
1497
+ g.alpha = 0.75 + Math.sin(t) * 0.25; // a slow breath, not a blink
1498
+ return true;
1499
+ });
1500
+ }
1501
+ function quitaMarca(id: string) {
1502
+ const m = marcas.get(id);
1503
+ if (m) {
1504
+ m.parent?.removeChild(m);
1505
+ m.destroy({ children: true });
1506
+ marcas.delete(id);
1507
+ }
1508
+ }
1509
+
1510
+ function cuadrilla(lista: any[]) {
1511
+ ultimosObreros = lista;
1512
+ cifra('c-luces', lista.length);
1513
+ panel(lista);
1514
+
1515
+ const vistos = new Set<string>();
1516
+ for (const o of lista) {
1517
+ vistos.add(o.id);
1518
+ const c = casas.get(o.parcela);
1519
+ if (!c) continue;
1520
+ const ya = enObra.get(o.id);
1521
+ if (ya && ya.parcela === o.parcela) continue;
1522
+ const cuantos = [...enObra.values()].filter(
1523
+ (x) => x.parcela === o.parcela && x.c !== ya?.c,
1524
+ ).length;
1525
+ const b = c.g.parent!;
1526
+ const destino = {
1527
+ x: b.x + c.g.x - TW * 0.24 + cuantos * 13,
1528
+ y: b.y + c.g.y + TH * 0.66 + cuantos * 5,
1529
+ };
1530
+ if (ya) {
1531
+ anda(ya.c, destino);
1532
+ enObra.set(o.id, { c: ya.c, parcela: o.parcela });
1533
+ } else {
1534
+ const fig = obrero(o.usuario, o.ventana, cuantos);
1535
+ fig.position.set(destino.x, destino.y);
1536
+ abrochaCara(fig, o.ventana);
1537
+ capaGente.addChild(fig);
1538
+ enObra.set(o.id, { c: fig, parcela: o.parcela });
1539
+ late(c.g);
1540
+ }
1541
+ marca(o.id, c, o.usuario);
1542
+ }
1543
+ for (const [id, x] of enObra)
1544
+ if (!vistos.has(id)) {
1545
+ x.c.parent?.removeChild(x.c);
1546
+ enObra.delete(id);
1547
+ quitaMarca(id);
1548
+ }
1549
+
1550
+ // One foreman per person with a crew, next to the house holding most of them.
1551
+ const por = new Map<string, any[]>();
1552
+ for (const o of lista) {
1553
+ (por.get(o.usuario) ?? por.set(o.usuario, []).get(o.usuario)!).push(o);
1554
+ }
1555
+ for (const [u, c] of peritos)
1556
+ if (!por.has(u)) {
1557
+ c.parent?.removeChild(c);
1558
+ peritos.delete(u);
1559
+ }
1560
+ for (const [u, ags] of por) {
1561
+ const p = personas.find((x) => x.usuario === u);
1562
+ if (!p || p.rol === 'cpto') continue;
1563
+ const ref = casas.get(ags[0].parcela);
1564
+ if (!ref) continue;
1565
+ const b = ref.g.parent!;
1566
+ // Outside the plot and in front: next to a house, their label eats the labels
1567
+ // of their own workers. A foreman does not dig, so they need not be inside.
1568
+ // In front of the block and to its side, in plot coordinates — the district
1569
+ // metrics are its grid now, not a screen box, and reading the old ones gave
1570
+ // every foreman a position of NaN.
1571
+ const m = (b as any).__m;
1572
+ const hueco = Math.max(0, [...peritos.keys()].indexOf(u));
1573
+ const esq = iso(-1.2 * PASO, (m.filas + 0.2) * PASO);
1574
+ const pos = { x: b.x + esq.x + hueco * 8, y: b.y + esq.y + 16 + hueco * 16 };
1575
+ const ya = peritos.get(u);
1576
+ if (ya) {
1577
+ anda(ya, pos);
1578
+ continue;
1579
+ }
1580
+ const fig = perito(u, p.oficio, ags.length);
1581
+ fig.position.set(pos.x, pos.y);
1582
+ capaGente.addChild(fig);
1583
+ peritos.set(u, fig);
1584
+ }
1585
+ reparteEnPlaza();
1586
+ carteles(escalaActual);
1587
+ }
1588
+
1589
+ /** Somebody walking to where they are working now. */
1590
+ function anda(fig: Container, a: { x: number; y: number }) {
1591
+ const de = { x: fig.x, y: fig.y };
1592
+ const d = Math.hypot(a.x - de.x, a.y - de.y);
1593
+ if (d < 2) {
1594
+ fig.position.set(a.x, a.y);
1595
+ return;
1596
+ }
1597
+ const paso = 1 / Math.max(28, Math.min(110, d / 3));
1598
+ let t = 0;
1599
+ anima((dt) => {
1600
+ t = Math.min(1, t + paso * dt);
1601
+ const s = t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t);
1602
+ fig.x = de.x + (a.x - de.x) * s;
1603
+ // A slight hop, so a figure crossing the map reads as walking and not sliding.
1604
+ fig.y = de.y + (a.y - de.y) * s - Math.sin(s * Math.PI) * 10;
1605
+ if (t >= 1) {
1606
+ fig.position.set(a.x, a.y);
1607
+ return false;
1608
+ }
1609
+ return true;
1610
+ });
1611
+ }
1612
+
1613
+ /** A ring on a plot: something just happened here, look. */
1614
+ function late(g: Container) {
1615
+ const anillo = new Graphics()
1616
+ .poly([0, 0, TW / 2, TH / 2, 0, TH, -TW / 2, TH / 2])
1617
+ .stroke({ color: 0xf5c451, width: 2, alpha: 0.9 });
1618
+ g.addChild(anillo);
1619
+ let t = 0;
1620
+ anima((dt) => {
1621
+ t += 0.03 * dt;
1622
+ anillo.alpha = Math.max(0, 0.9 - t);
1623
+ anillo.scale.set(1 + t * 0.7);
1624
+ if (t > 1) {
1625
+ anillo.parent?.removeChild(anillo);
1626
+ anillo.destroy();
1627
+ return false;
1628
+ }
1629
+ return true;
1630
+ });
1631
+ }
1632
+
1633
+ function panel(lista: any[]) {
1634
+ const ul = document.getElementById('cuadrillas')!;
1635
+ if (!lista.length) {
1636
+ ul.innerHTML = '<li class="vacio">Nobody digging right now</li>';
1637
+ return;
1638
+ }
1639
+ const por = new Map<string, any[]>();
1640
+ for (const o of lista) {
1641
+ (por.get(o.usuario) ?? por.set(o.usuario, []).get(o.usuario)!).push(o);
1642
+ }
1643
+ ul.innerHTML = '';
1644
+ for (const [u, ags] of [...por].sort((a, b) => b[1].length - a[1].length)) {
1645
+ const p = personas.find((x) => x.usuario === u);
1646
+ const col = '#' + colorDe(u).toString(16).padStart(6, '0');
1647
+ const li = document.createElement('li');
1648
+ li.innerHTML =
1649
+ `<div class="quien"><span class="pip" style="background:${esc(col)}"></span>${esc(u)}
1650
+ <span class="of">${esc(p?.oficio ?? '')}</span><span class="cuantos">×${ags.length | 0}</span></div>` +
1651
+ ags
1652
+ .map((a) => {
1653
+ const c = casas.get(a.parcela);
1654
+ return `<div class="ag"><span class="an">/${esc(a.ventana)}</span><span class="do">${esc(c ? c.p.nombre : a.parcela)}</span></div>`;
1655
+ })
1656
+ .join('');
1657
+ ul.appendChild(li);
1658
+ }
1659
+ }
1660
+
1661
+ function cartas(cs: any[], anadir = false) {
1662
+ const ul = document.getElementById('tt')!;
1663
+ // Colours by property, not by unit: a tag does not belong to any district.
1664
+ const COL: Record<string, string> = {
1665
+ data: 'var(--carta)',
1666
+ ux: '#8f7ae6',
1667
+ security: 'var(--lampara)',
1668
+ cost: '#3fb8a0',
1669
+ product: '#e08a3c',
1670
+ llm: '#4a9ede',
1671
+ };
1672
+ const pinta = (c: any) => {
1673
+ const li = document.createElement('li');
1674
+ const dia = String(c.ts ?? '').slice(0, 10);
1675
+ // The tag is not interpolated into the colour: it selects from a whitelist.
1676
+ // An invented tag paints as text, in the default colour.
1677
+ li.innerHTML =
1678
+ `<div class="cab">` +
1679
+ `<span class="etq" style="color:${COL[c.etiqueta] ?? 'var(--tinta2)'}">${esc(c.etiqueta ?? 'notice')}</span>` +
1680
+ `<span class="de">${esc(c.de)} → ${esc(c.a)}</span>` +
1681
+ `<span class="cuando">${esc(dia)}</span>` +
1682
+ `</div><div class="cuerpo">${esc(c.texto ?? '')}</div>`;
1683
+ return li;
1684
+ };
1685
+ if (!anadir) ul.innerHTML = '';
1686
+ for (const c of cs) anadir ? ul.prepend(pinta(c)) : ul.appendChild(pinta(c));
1687
+ // A feed, not a ticker: there is a column for these now, so they are worth
1688
+ // keeping. Sixty is about a screenful and a half of scrollback.
1689
+ while (ul.children.length > 60) ul.lastChild!.remove();
1690
+ const vacio = document.getElementById('tt-vacio');
1691
+ if (vacio) vacio.hidden = ul.children.length > 0;
1692
+ // The count lives in this column's header now. It was also a tile in the left
1693
+ // sidebar, which meant the same number in two places and the notices reading
1694
+ // as if they were in both.
1695
+ const n = ul.children.length;
1696
+ cifra('c-cartas', n ? `${n}` : '');
1697
+ }
1698
+
1699
+ /* ── The replay ─────────────────────────────────────────────────────────────
1700
+ * Rewinds the city day by day. A house's floors on 3 March is how much had landed
1701
+ * by 3 March, so the history is computed by accumulating over a base and there is
1702
+ * no need to store a snapshot per day.
1703
+ *
1704
+ * Painting a day redraws the house: it changes model when it changes bracket, and
1705
+ * watching a two-storey building turn into a tower is exactly the point.
1706
+ */
1707
+ interface Peli {
1708
+ desde: string;
1709
+ hasta: string;
1710
+ limites: { primero?: string; ultimo?: string };
1711
+ base: [string, number][];
1712
+ dias: [string, [string, number][]][];
1713
+ avisos: [string, string, string, string, string][]; // day, from, to, tag, text
1714
+ }
1715
+ let peli: Peli | null = null;
1716
+ let indice = -1;
1717
+ let reproduciendo = false;
1718
+ let reloj: number | undefined;
1719
+
1720
+ async function cargaPeli(desde?: string, hasta?: string) {
1721
+ const q = new URLSearchParams();
1722
+ if (desde) q.set('desde', desde);
1723
+ if (hasta) q.set('hasta', hasta);
1724
+ try {
1725
+ peli = await (await fetch(`/api/historia?${q}`)).json();
1726
+ } catch {
1727
+ return;
1728
+ }
1729
+ if (!peli?.dias?.length) return;
1730
+
1731
+ const barra = document.getElementById('peli')!;
1732
+ barra.hidden = false;
1733
+ const d = document.getElementById('desde') as HTMLInputElement;
1734
+ const h = document.getElementById('hasta') as HTMLInputElement;
1735
+ d.value = peli.desde;
1736
+ h.value = peli.hasta;
1737
+ if (peli.limites.primero) {
1738
+ d.min = peli.limites.primero;
1739
+ h.min = peli.limites.primero;
1740
+ }
1741
+ if (peli.limites.ultimo) {
1742
+ d.max = peli.limites.ultimo;
1743
+ h.max = peli.limites.ultimo;
1744
+ }
1745
+
1746
+ const r = document.getElementById('dia') as HTMLInputElement;
1747
+ r.max = String(peli.dias.length - 1);
1748
+ r.value = r.max;
1749
+ pintaDia(peli.dias.length - 1);
1750
+ }
1751
+
1752
+ /** Each parcel's floors at the end of day `hasta` of the range. */
1753
+ function capitalEn(hasta: number): Map<string, number> {
1754
+ const acc = new Map<string, number>(peli!.base);
1755
+ for (let i = 0; i <= hasta; i++) {
1756
+ for (const [parcela, n] of peli!.dias[i][1]) {
1757
+ acc.set(parcela, (acc.get(parcela) ?? 0) + n);
1758
+ }
1759
+ }
1760
+ return acc;
1761
+ }
1762
+
1763
+ /**
1764
+ * The replay is not a bar chart that grows. What makes it worth watching is the
1765
+ * activity: somebody digging where work landed that day, a letter crossing the
1766
+ * city, a floor popping up with dust. All of it derived from the same history —
1767
+ * nothing invented at render time.
1768
+ */
1769
+ function pintaDia(i: number) {
1770
+ if (!peli) return;
1771
+ indice = i;
1772
+ const acc = capitalEn(i);
1773
+ const ultimo = i >= peli.dias.length - 1 && peli.hasta >= (peli.limites.ultimo ?? peli.hasta);
1774
+ if (!ultimo) vidaDelDia(i);
1775
+
1776
+ if (ultimo) repasaEnVivo();
1777
+ for (const [id, c] of casas) {
1778
+ const capital = ultimo ? c.p.pisos : (acc.get(id) ?? 0);
1779
+ // A parcel with nothing landed yet leaves the plot empty: a house that
1780
+ // APPEARS tells "something started here" better than one that was always up.
1781
+ const datos = {
1782
+ ...c.p,
1783
+ pisos: capital,
1784
+ andamios: ultimo ? c.p.andamios : 0,
1785
+ andamio_viejo: ultimo ? c.p.andamio_viejo : 0,
1786
+ actividad30: ultimo ? c.p.actividad30 : capital > 0 ? 2 : 0,
1787
+ };
1788
+ // What is drawn on top of the house — scaffolding, crane, crack, lights —
1789
+ // and whether there is a house at all. Height is deliberately not in here:
1790
+ // height is what gets animated, everything else is what forces a redraw.
1791
+ const adorno = [
1792
+ capital > 0,
1793
+ datos.andamios,
1794
+ datos.andamio_viejo,
1795
+ datos.grieta,
1796
+ datos.actividad30 > 0,
1797
+ ].join('|');
1798
+ if (c.dibujado === capital && c.adorno === adorno) continue;
1799
+
1800
+ if (c.adorno === adorno && c.h?.sp && c.dibujado !== undefined && capital > 0) {
1801
+ // The house is already there and only its capital moved: grow it. Rebuilding
1802
+ // it — which is what this did before — meant the same sprite was destroyed
1803
+ // and recreated once per day, and the city read as flickering rather than
1804
+ // growing.
1805
+ const gano = capital - c.dibujado;
1806
+ subeHasta(c, capital, gano > 0);
1807
+ if (gano > 0 && !ultimo) burbuja(c, gano);
1808
+ c.dibujado = capital;
1809
+ continue;
1810
+ }
1811
+
1812
+ const col = COLOR[c.p.unidad] ?? COLOR_POR_DEFECTO;
1813
+ const padre = c.g.parent!,
1814
+ px = c.g.x,
1815
+ py = c.g.y,
1816
+ z = padre.getChildIndex(c.g);
1817
+ padre.removeChild(c.g);
1818
+ const h = capital === 0 && !ultimo ? undefined : casa(col, datos, c.p.id);
1819
+ const nuevo = h ? h.g : new Container();
1820
+ nuevo.position.set(px, py);
1821
+ nuevo.eventMode = 'static';
1822
+ nuevo.cursor = 'pointer';
1823
+ nuevo.on('pointertap', () => ficha(c.p));
1824
+ padre.addChildAt(nuevo, Math.min(z, padre.children.length));
1825
+ // A house that appears deserves to be seen appearing: it rises out of the
1826
+ // plot with dust at its feet.
1827
+ if (!ultimo && h && c.dibujado === 0) {
1828
+ brota(h);
1829
+ if (capital > 0) burbuja({ ...c, g: nuevo, h }, capital);
1830
+ }
1831
+ c.g = nuevo;
1832
+ c.h = h;
1833
+ c.dibujado = capital;
1834
+ c.adorno = adorno;
1835
+ }
1836
+ rotula(acc, ultimo);
1837
+ }
1838
+
1839
+ /**
1840
+ * Take a house to the height its capital asks for, over about a third of a
1841
+ * second, and kick up dust if it went up.
1842
+ *
1843
+ * The one place where growth is expressed. Both the replay and the live map call
1844
+ * it, which is why the easing and the overshoot live here and not at either call
1845
+ * site: a building that grows differently depending on who asked would read as
1846
+ * two different maps.
1847
+ */
1848
+ function subeHasta(c: CasaViva, pisos: number, arriba: boolean) {
1849
+ const h = c.h;
1850
+ if (!h?.sp || !h.medida || !h.escala) return;
1851
+ // Outgrown its shape? Put it in a bigger building first, at the size it has
1852
+ // now, and let the tween below take it the rest of the way.
1853
+ reviste(h, c.p.id, pisos, c.p.clase);
1854
+ const f0 = h.sp.scale.y / h.escala;
1855
+ const f1 = factorDe(pisos, h.medida);
1856
+ if (Math.abs(f1 - f0) < 0.002) return;
1857
+ const motas = arriba ? polvo(h.g) : [];
1858
+ let t = 0;
1859
+ anima((dt) => {
1860
+ // Fast enough to settle before the next day of the film lands, and with no
1861
+ // overshoot at all: a floor is a floor. The version that bounced — up a
1862
+ // little past the target and back — read as the houses oscillating, because
1863
+ // with a PR landing every quarter of a second each bounce started on top of
1864
+ // the last one. Work stacks; it does not wobble.
1865
+ t = Math.min(1, t + dt * 0.14);
1866
+ const e = 1 - Math.pow(1 - t, 3); // ease out, monotone
1867
+ h.sp!.scale.y = h.escala! * (f0 + (f1 - f0) * e);
1868
+ for (const m of motas) {
1869
+ m.y -= 0.6 * dt;
1870
+ m.alpha -= 0.02 * dt;
1871
+ }
1872
+ if (t >= 1) {
1873
+ h.sp!.scale.y = h.escala! * f1;
1874
+ for (const m of motas) {
1875
+ m.parent?.removeChild(m);
1876
+ m.destroy();
1877
+ }
1878
+ return false;
1879
+ }
1880
+ return true;
1881
+ });
1882
+ }
1883
+
1884
+ /**
1885
+ * A bubble over a house when work lands: "+3", rising and fading.
1886
+ *
1887
+ * The floor that grew is the fact; the bubble is what makes you notice the fact.
1888
+ * Watching the replay without it, the eye reads the city as slowly inflating and
1889
+ * never sees the individual moment something shipped — which is the moment the
1890
+ * whole map exists to show.
1891
+ */
1892
+ function burbuja(c: CasaViva, cuantos: number) {
1893
+ const col = COLOR[c.p.unidad] ?? COLOR_POR_DEFECTO;
1894
+ const g = new Container();
1895
+ const alto = c.h?.alto ?? 40;
1896
+
1897
+ const texto = new Text({
1898
+ text: `+${cuantos}`,
1899
+ style: new TextStyle({
1900
+ fontFamily: 'Chakra Petch, sans-serif',
1901
+ fontSize: 12,
1902
+ fontWeight: '700',
1903
+ fill: 0x131a24,
1904
+ }),
1905
+ });
1906
+ texto.anchor.set(0.5);
1907
+ const ancho = texto.width + 12;
1908
+ const chapa = new Graphics()
1909
+ .roundRect(-ancho / 2, -9, ancho, 18, 9)
1910
+ .fill({ color: tono(col, 0.45) })
1911
+ .roundRect(-ancho / 2, -9, ancho, 18, 9)
1912
+ .stroke({ color: 0x131a24, width: 1, alpha: 0.25 });
1913
+ g.addChild(chapa, texto);
1914
+ g.position.set((c.g.parent?.x ?? 0) + c.g.x, (c.g.parent?.y ?? 0) + c.g.y - alto - 14);
1915
+ capaGente.addChild(g);
1916
+
1917
+ let t = 0;
1918
+ anima((dt) => {
1919
+ t = Math.min(1, t + 0.022 * dt);
1920
+ // Up and settling, not a straight rise: it should read as something popping
1921
+ // out of the building, not as a balloon let go.
1922
+ g.y -= (1 - t) * 0.9 * dt;
1923
+ g.scale.set(t < 0.18 ? 0.6 + t * 2.2 : 1);
1924
+ g.alpha = t > 0.7 ? (1 - t) / 0.3 : 1;
1925
+ if (t >= 1) {
1926
+ g.parent?.removeChild(g);
1927
+ g.destroy({ children: true });
1928
+ return false;
1929
+ }
1930
+ return true;
1931
+ });
1932
+ }
1933
+
1934
+ /** A house appearing on an empty plot: it rises from nothing, with dust. */
1935
+ function brota(h: Casa) {
1936
+ const motas = polvo(h.g);
1937
+ const y0 = h.sp ? h.sp.scale.y : 0;
1938
+ if (h.sp) h.sp.scale.y = y0 * 0.06;
1939
+ let t = 0;
1940
+ anima((dt) => {
1941
+ t = Math.min(1, t + dt * 0.06);
1942
+ const e = 1 - Math.pow(1 - t, 3);
1943
+ if (h.sp) h.sp.scale.y = y0 * (0.06 + 0.94 * e);
1944
+ else h.g.scale.set(1, 0.2 + 0.8 * e);
1945
+ for (const m of motas) {
1946
+ m.y -= 0.6 * dt;
1947
+ m.alpha -= 0.02 * dt;
1948
+ }
1949
+ if (t >= 1) {
1950
+ if (h.sp) h.sp.scale.y = y0;
1951
+ else h.g.scale.set(1, 1);
1952
+ for (const m of motas) {
1953
+ m.parent?.removeChild(m);
1954
+ m.destroy();
1955
+ }
1956
+ return false;
1957
+ }
1958
+ return true;
1959
+ });
1960
+ }
1961
+
1962
+ /** Who was working that day, and which letters crossed the city. */
1963
+ /** Notices already in the column, so scrubbing does not add them twice. */
1964
+ const yaContadas = new Set<string>();
1965
+
1966
+ function vidaDelDia(i: number) {
1967
+ if (!peli) return;
1968
+ const [dia, activas] = peli.dias[i];
1969
+ // The camera watches the trailing week, not the single day: day-by-day
1970
+ // centroids ping-pong between districts, a week's worth is a stable, honest
1971
+ // shot of where the work is. The workers and bubbles below stay strictly
1972
+ // daily — only the framing takes the long view.
1973
+ const enCuadro = new Set<string>();
1974
+ for (let j = Math.max(0, i - 5); j <= i; j++) {
1975
+ for (const [pid] of peli.dias[j][1]) enCuadro.add(pid);
1976
+ }
1977
+ miraLaAccion([...enCuadro]);
1978
+
1979
+ // Workers where something landed that day, standing at that house. The person
1980
+ // is the parcel's owner when it has one — the map does not invent who did what.
1981
+ const trabajando: any[] = [];
1982
+ for (const [pid] of activas.slice(0, 14)) {
1983
+ const c = casas.get(pid);
1984
+ if (!c) continue;
1985
+ const quien = c.p.dueno || '—';
1986
+ trabajando.push({
1987
+ id: `${quien}/${pid}`,
1988
+ usuario: quien,
1989
+ ventana: pid.split(':')[0],
1990
+ parcela: pid,
1991
+ });
1992
+ }
1993
+ cuadrilla(trabajando);
1994
+ // The ticker follows the film too: during a replay it carries the people who
1995
+ // worked that day. Otherwise the strip says "nobody on the bus" over a city
1996
+ // visibly full of people, which is the map contradicting itself on screen.
1997
+ cintaDelBus(trabajando.map((t) => ({ usuario: t.usuario, agente: `${t.usuario}/${t.ventana}` })));
1998
+
1999
+ // And the letters of that day, flying from sender to recipient.
2000
+ // The film's notices pile up in the column as they happen, newest first, and
2001
+ // each one lands once however many times you scrub over its day — a feed that
2002
+ // repeats itself is a feed nobody reads. The live ones replace them when the
2003
+ // playhead reaches the present.
2004
+ const delDia = peli.avisos
2005
+ .filter(([d]) => d === dia)
2006
+ .map(([d, de, para, etq, texto]) => ({ de, a: para, etiqueta: etq, texto, ts: d }));
2007
+ const nuevos = delDia.filter((c) => {
2008
+ const k = `${c.ts}|${c.de}|${c.a}|${c.texto}`;
2009
+ if (yaContadas.has(k)) return false;
2010
+ yaContadas.add(k);
2011
+ return true;
2012
+ });
2013
+ if (nuevos.length) cartas(nuevos, true);
2014
+ for (const c of nuevos) volando(c.de, c.a, c.etiqueta);
2015
+ }
2016
+
2017
+ /**
2018
+ * A letter crossing the city, from the sender's house to the recipient's.
2019
+ *
2020
+ * Drawn between houses and not between people on purpose: what a notice says is
2021
+ * "this change touched your property", and the change has a place.
2022
+ */
2023
+ function volando(de: string, para: string, etiqueta: string) {
2024
+ const casaDe = [...casas.values()].find((c) => c.p.dueno === de);
2025
+ const casaA = [...casas.values()].find((c) => c.p.dueno === para);
2026
+ const COL: Record<string, number> = {
2027
+ data: 0xe2604f,
2028
+ ux: 0x8f7ae6,
2029
+ security: 0xf5c451,
2030
+ cost: 0x3fb8a0,
2031
+ product: 0xe08a3c,
2032
+ llm: 0x4a9ede,
2033
+ };
2034
+ const col = COL[etiqueta] ?? 0xe8e2d4;
2035
+ const donde = (c: CasaViva) => ({
2036
+ x: (c.g.parent?.x ?? 0) + c.g.x,
2037
+ y: (c.g.parent?.y ?? 0) + c.g.y,
2038
+ });
2039
+
2040
+ if (casaDe && casaA && casaDe !== casaA) {
2041
+ vueloDeCarta(donde(casaDe), donde(casaA), col);
2042
+ return;
2043
+ }
2044
+ // One end of this letter is not in this city: it enters or leaves by a gate.
2045
+ // The map draws its own city and nothing else — the other city exists here
2046
+ // exactly as the seat knows it, as an address over an arch.
2047
+ if (casaDe && !casaA) {
2048
+ const puerta = puertas?.puerta(para);
2049
+ if (!puerta) return;
2050
+ vueloDeCarta(donde(casaDe), { x: puerta.x - 4, y: puerta.y - 18 }, col, () =>
2051
+ puertas?.vuela(puerta, col, true),
2052
+ );
2053
+ return;
2054
+ }
2055
+ if (!casaDe && casaA) {
2056
+ const puerta = puertas?.puerta(de);
2057
+ if (!puerta) return;
2058
+ const destino = donde(casaA);
2059
+ puertas?.vuela(puerta, col, false);
2060
+ vueloDeCarta({ x: puerta.x - 4, y: puerta.y - 18 }, destino, col);
2061
+ }
2062
+ }
2063
+
2064
+ /** The letter itself: sender to recipient, in an arc, with its wobble. */
2065
+ function vueloDeCarta(
2066
+ p0: { x: number; y: number },
2067
+ p1: { x: number; y: number },
2068
+ col: number,
2069
+ alLlegar?: () => void,
2070
+ ) {
2071
+ const carta = new Graphics()
2072
+ .rect(-4, -3, 8, 6)
2073
+ .fill({ color: 0xf2ecdd })
2074
+ .rect(-4, -3, 8, 2)
2075
+ .fill({ color: col });
2076
+ const estela = new Graphics().circle(0, 0, 5).fill({ color: col, alpha: 0.16 });
2077
+ const g = new Container();
2078
+ g.addChild(estela, carta);
2079
+ capaGente.addChild(g);
2080
+
2081
+ let t = 0;
2082
+ anima((dt) => {
2083
+ t += 0.022 * dt;
2084
+ const s = t < 1 ? t : 1;
2085
+ g.x = p0.x + (p1.x - p0.x) * s;
2086
+ // An arc, so it reads as flying and not sliding along the ground.
2087
+ g.y = p0.y + (p1.y - p0.y) * s - Math.sin(s * Math.PI) * 46;
2088
+ g.alpha = alLlegar ? 1 : s > 0.85 ? (1 - s) / 0.15 : 1;
2089
+ carta.rotation = Math.sin(t * 6) * 0.25;
2090
+ if (t >= 1) {
2091
+ g.parent?.removeChild(g);
2092
+ g.destroy({ children: true });
2093
+ alLlegar?.();
2094
+ return false;
2095
+ }
2096
+ return true;
2097
+ });
2098
+ }
2099
+
2100
+ function rotula(acc: Map<string, number>, ultimo: boolean) {
2101
+ if (!peli) return;
2102
+ const [dia] = peli.dias[indice];
2103
+ // The total counts ONCE per repo, not per parcel: every parcel of a repo
2104
+ // inherits the whole repo's count, so summing them multiplied the total by the
2105
+ // number of parcels. Take the largest of each repo.
2106
+ const porRepo = new Map<string, number>();
2107
+ let conObra = 0;
2108
+ for (const [id, v] of acc) {
2109
+ const c = casas.get(id);
2110
+ if (!c) continue;
2111
+ if (v > 0) conObra++;
2112
+ const repo = c.p.repo;
2113
+ porRepo.set(repo, Math.max(porRepo.get(repo) ?? 0, v));
2114
+ }
2115
+ let total = 0;
2116
+ for (const v of porRepo.values()) total += v;
2117
+ const f = new Date(dia + 'T00:00:00Z');
2118
+ document.getElementById('diaTexto')!.textContent =
2119
+ f.toLocaleDateString('en-GB', {
2120
+ day: 'numeric',
2121
+ month: 'long',
2122
+ year: 'numeric',
2123
+ timeZone: 'UTC',
2124
+ }) + (ultimo ? ' · today' : '');
2125
+ document.getElementById('diaDato')!.textContent =
2126
+ `${total.toLocaleString('en-GB')} ${plural(total, 'floor')} · ` +
2127
+ `${conObra} ${plural(conObra, 'parcel')} under work`;
2128
+ }
2129
+
2130
+ /** Back to the present: the people on screen are the real ones again. */
2131
+ function repasaEnVivo() {
2132
+ if (!camara.tuyo) encaja(appGlobal!, false); // back to the whole city
2133
+ fetch('/api/state')
2134
+ .then((r) => r.json())
2135
+ .then((d: any) => {
2136
+ conectados((d.lights ?? []).map(deLuz));
2137
+ cuadrilla((d.workers ?? []).map(deObrero));
2138
+ // The notices too: leaving the replay's on screen next to the live workers
2139
+ // is the map claiming something happened today that happened in 2024.
2140
+ cartas((d.notices ?? []).map(deCarta));
2141
+ })
2142
+ .catch(() => {});
2143
+ }
2144
+
2145
+ /**
2146
+ * Play tells the story from the beginning.
2147
+ *
2148
+ * It used to start wherever the playhead happened to be — which, on a fresh
2149
+ * load, is today: you pressed play and watched a finished city do nothing. Play
2150
+ * now means "build this city in front of me", so it loads the whole history and
2151
+ * starts at day one. Anybody who wants a slice still has the two date fields,
2152
+ * and pressing play inside a slice plays that slice.
2153
+ */
2154
+ function reproduce() {
2155
+ if (!peli) return;
2156
+ if (reproduciendo) {
2157
+ para();
2158
+ return;
2159
+ }
2160
+
2161
+ const desdeElPrincipio = indice >= peli.dias.length - 1;
2162
+ const todaLaHistoria = peli.desde === (peli.limites.primero ?? peli.desde);
2163
+ if (desdeElPrincipio && !todaLaHistoria) {
2164
+ // At the end, and looking at a slice: rewind to the whole story.
2165
+ cargaPeli(peli.limites.primero, peli.limites.ultimo).then(() => empieza(0));
2166
+ return;
2167
+ }
2168
+ empieza(desdeElPrincipio ? 0 : indice);
2169
+ }
2170
+
2171
+ function para() {
2172
+ reproduciendo = false;
2173
+ clearInterval(reloj);
2174
+ document.getElementById('play')!.textContent = '▶';
2175
+ }
2176
+
2177
+ /** Run the film from this day on, one day every 260 ms. */
2178
+ function empieza(desde: number) {
2179
+ if (!peli) return;
2180
+ if (desde === 0) {
2181
+ yaContadas.clear();
2182
+ cartas([]);
2183
+ }
2184
+ reproduciendo = true;
2185
+ document.getElementById('play')!.textContent = '❙❙';
2186
+ const barra = document.getElementById('dia') as HTMLInputElement;
2187
+ indice = desde;
2188
+ barra.value = String(desde);
2189
+ pintaDia(desde);
2190
+ clearInterval(reloj);
2191
+ reloj = setInterval(() => {
2192
+ if (!peli) return para();
2193
+ if (indice >= peli.dias.length - 1) return para();
2194
+ barra.value = String(indice + 1);
2195
+ pintaDia(indice + 1);
2196
+ }, 260) as unknown as number;
2197
+ }
2198
+
2199
+ document.getElementById('play')?.addEventListener('click', reproduce);
2200
+ document.getElementById('dia')?.addEventListener('input', (e) => {
2201
+ if (reproduciendo) para();
2202
+ pintaDia(Number((e.target as HTMLInputElement).value));
2203
+ });
2204
+ for (const id of ['desde', 'hasta']) {
2205
+ document.getElementById(id)?.addEventListener('change', () => {
2206
+ if (reproduciendo) para();
2207
+ for (const [, c] of casas) c.dibujado = undefined;
2208
+ cargaPeli(
2209
+ (document.getElementById('desde') as HTMLInputElement).value,
2210
+ (document.getElementById('hasta') as HTMLInputElement).value,
2211
+ );
2212
+ });
2213
+ }
2214
+
2215
+ /** Close the card. Three ways in, one way out, and it used to have none: the
2216
+ * close handler was wired to a button that was never in the page. */
2217
+ function cierraFicha() {
2218
+ const f = document.getElementById('ficha');
2219
+ if (f) f.hidden = true;
2220
+ }
2221
+ document.getElementById('cerrar')?.addEventListener('click', cierraFicha);
2222
+ window.addEventListener('keydown', (e) => {
2223
+ if (e.key === 'Escape') cierraFicha();
2224
+ });
2225
+ arranca();