@unson/brainbase-mcp 0.22.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -407,5 +407,5 @@
407
407
  "engines": {
408
408
  "node": ">=20"
409
409
  },
410
- "gitHead": "4933fdd273d050d9fa76f46b5d3696c1657613eb"
410
+ "gitHead": "8ce6f52ff6e5650f7f26f913de3888b8906cff81"
411
411
  }
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { THREE, MapControls } from './world-vendor.js';
17
- import { DISTRICT_STREETS, districtStreetLots, skyAt } from './world-placement.js';
17
+ import { DISTRICT_STREETS, districtStage, districtStreetLots, skyAt } from './world-placement.js';
18
18
  import { canvasTexture, drawFacade, FACADE_SIZES, FACADE_UNITS, gableRoof, hashUnit, paintVertices, roundedPlate, valueNoise } from './world-scenery.js';
19
19
  import { makeWorkspaceElement as el } from '../../workspace-kit.js';
20
20
 
@@ -37,7 +37,8 @@ export const DISTRICT_LEGEND = Object.freeze([
37
37
  ['is-scaffold', '足場と緑のネット=進行中(記録上)'],
38
38
  ['is-cover', 'ブルーシートの覆い=待ち(記録上)'],
39
39
  ['is-stakes', '杭と縄の空き地=未着手(記録上)'],
40
- ['is-house', '明かりのついた家=完了(記録上)'],
40
+ ['is-house', '明かりのついた家=完了して、出典・成果の記録がある(本設の建物)'],
41
+ ['is-prefab', 'プレハブ=完了したが、出典・成果の記録が無い'],
41
42
  ['is-worker', 'ヘルメットの人=担当欄の担当'],
42
43
  ['is-empty', '誰もいない現場=担当の記録なし'],
43
44
  ['is-outside', '柵の外の人(破線の輪)=本文にだけ名前がある'],
@@ -46,6 +47,7 @@ export const DISTRICT_LEGEND = Object.freeze([
46
47
  ['is-weeds', '雑草と色あせ=見直し予定を過ぎた'],
47
48
  ['is-stone', '庁舎前の石碑=方針の決定(枠だけ=題名からの推定)'],
48
49
  ['is-street', '通りの看板=何のための仕事か(タスクの記録の言葉。言葉が無い仕事は門の近くの空き地)'],
50
+ ['is-stage', '区画の段階(更地・村・町・街・都市)=本設の建物の数。石畳・街灯・噴水・時計塔が増える'],
49
51
  ['is-ambience', '通りを歩く人と煙=街の雰囲気(記録とは関係しません)'],
50
52
  ]);
51
53
 
@@ -258,12 +260,17 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
258
260
  });
259
261
  dashTexture.wrapT = THREE.RepeatWrapping;
260
262
  /** A street along z (or along x when `across`): sidewalks, asphalt, and a dashed centre line if wide. */
261
- function streetStrip(parent, { x = 0, z = 0, width, length, across = false, centreLine = false }) {
263
+ function streetStrip(parent, { x = 0, z = 0, width, length, across = false, centreLine = false, dirt = false }) {
262
264
  const strip = (stripWidth, mat, y) => {
263
265
  const surface = mesh(new THREE.PlaneGeometry(stripWidth, length), mat, { x, y, z, shadow: false });
264
266
  surface.rotation.set(-Math.PI / 2, 0, across ? Math.PI / 2 : 0);
265
267
  parent.add(surface);
266
268
  };
269
+ // A dirt track on vacant land; sidewalks and asphalt once the district has grown.
270
+ if (dirt) {
271
+ strip(width, material(0xb59d7a, { roughness: 1 }), 0.03);
272
+ return;
273
+ }
267
274
  strip(width + 0.7, material(0xe4dfd2, { roughness: 1 }), 0.025);
268
275
  strip(width, material(0x8e938f, { roughness: 0.95 }), 0.04);
269
276
  if (centreLine) {
@@ -288,6 +295,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
288
295
  tarpTexture.wrapT = THREE.RepeatWrapping;
289
296
 
290
297
  let root = null;
298
+ let fountainJet = null;
291
299
  let lots = new Map();
292
300
  let labels = [];
293
301
  let walkers = [];
@@ -298,6 +306,11 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
298
306
  let flight = null;
299
307
  let home = null;
300
308
  let focusIds = null;
309
+ // What changed since the viewer was last here (AC-22, AC-23): a gold ring, a rising building, words.
310
+ let celebrations = [];
311
+ const RISING = new Set(['new', 'built', 'evidenced', 'started']);
312
+ // Changes that set the district back are marked too, in grey, without the gold or the plus.
313
+ const SETBACK = new Set(['worker_out', 'weeds_grew', 'held']);
301
314
  const selectionRing = mesh(new THREE.RingGeometry(1.75, 1.95, 48), new THREE.MeshBasicMaterial({ color: 0x087d62, transparent: true, opacity: 0.9, side: THREE.DoubleSide }), { y: 0.09, shadow: false });
302
315
  selectionRing.rotation.x = -Math.PI / 2;
303
316
  selectionRing.visible = false;
@@ -383,7 +396,15 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
383
396
  for (const x of [-1.08, 1.08]) group.add(mesh(new THREE.BoxGeometry(0.02, 0.02, 2.0), rope, { x, y: scaffoldH * 0.55, z: -0.25, shadow: false }));
384
397
  }
385
398
  }
386
- if (status === 'completed') {
399
+ if (status === 'completed' && !(site.source_refs?.length > 0)) {
400
+ // A prefab: completed, with no record of its source or outcome yet (AC-24).
401
+ const shell = box(1.7, 0.75, 1.3, 0xd7d9d6, { z: -0.3 }, { roughness: 0.6, metalness: 0.15 });
402
+ group.add(shell);
403
+ group.add(mesh(new THREE.BoxGeometry(1.8, 0.06, 1.4), material(0x9fa3a0, { roughness: 0.5, metalness: 0.3 }), { y: 0.78, z: -0.3 }));
404
+ group.add(mesh(new THREE.BoxGeometry(0.5, 0.28, 0.03), material(0x8fb3c8, { roughness: 0.2 }), { x: -0.35, y: 0.45, z: 0.36, shadow: false }));
405
+ group.add(mesh(new THREE.BoxGeometry(0.3, 0.55, 0.03), material(0x7d8a8f), { x: 0.45, y: 0.28, z: 0.36, shadow: false }));
406
+ }
407
+ if (status === 'completed' && site.source_refs?.length > 0) {
387
408
  // A finished house: plastered walls with lit windows, a gable roof, a door to the street.
388
409
  const house = block(1.7, 1.05, 1.4, { style: 'house', wall: 0xf2ebdd, lit: true, roof: 0xd8d0c2 });
389
410
  house.position.z = -0.3;
@@ -452,6 +473,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
452
473
  // --- the district -------------------------------------------------------------------------------
453
474
  function clear() {
454
475
  if (root) scene.remove(root);
476
+ fountainJet = null;
455
477
  litMaterials.clear();
456
478
  lampMaterials.clear();
457
479
  for (const label of labels) label.node.remove();
@@ -462,9 +484,45 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
462
484
  selected = null;
463
485
  hovered = null;
464
486
  focusIds = null;
487
+ celebrations = [];
465
488
  selectionRing.visible = false;
466
489
  }
467
490
 
491
+ /** Marks what changed: each changed lot glows; built ones rise from the ground (not with reduced motion). */
492
+ function celebrate(changes) {
493
+ for (const item of celebrations) {
494
+ root?.remove(item.ring);
495
+ root?.remove(item.beam);
496
+ item.label.node.remove();
497
+ labels = labels.filter((label) => label !== item.label);
498
+ }
499
+ celebrations = [];
500
+ if (!changes || changes.first || !root) return;
501
+ const byTask = new Map();
502
+ for (const item of changes.items) {
503
+ if (item.kind === 'left' || !lots.has(item.task_id)) continue;
504
+ if (!byTask.has(item.task_id)) byTask.set(item.task_id, []);
505
+ byTask.get(item.task_id).push(item);
506
+ }
507
+ let shownWords = 0;
508
+ for (const [taskId, items] of byTask) {
509
+ const group = lots.get(taskId);
510
+ const setback = items.every((item) => SETBACK.has(item.kind));
511
+ const color = setback ? 0x8a8f94 : 0xe0a526;
512
+ const ring = mesh(new THREE.RingGeometry(1.95, 2.35, 48), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9, side: THREE.DoubleSide }), { x: group.position.x, y: 0.1, z: group.position.z, shadow: false });
513
+ ring.rotation.x = -Math.PI / 2;
514
+ root.add(ring);
515
+ // A column of light, so the change can be found from the gate.
516
+ const beam = mesh(new THREE.CylinderGeometry(0.5, 1.6, 9, 24, 1, true), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.22, side: THREE.DoubleSide, depthWrite: false }), { x: group.position.x, y: 4.6, z: group.position.z, shadow: false });
517
+ root.add(beam);
518
+ const words = items.map((item) => `${SETBACK.has(item.kind) ? '!' : '+'}${item.text.split('(')[0]}`).join('・');
519
+ const label = addLabel(words, new THREE.Vector3(group.position.x, 3.3, group.position.z), `is-change${setback ? ' is-setback' : ''}`, { priority: shownWords++ < 6 ? 0 : 3 });
520
+ const rises = !reducedMotion && items.some((item) => RISING.has(item.kind));
521
+ if (rises) group.scale.set(1, 0.02, 1);
522
+ celebrations.push({ ring, beam, label, group, rises, started: null });
523
+ }
524
+ }
525
+
468
526
  function build(business, work, neighbours) {
469
527
  clear();
470
528
  root = new THREE.Group();
@@ -474,6 +532,9 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
474
532
  const shown = readable ? work.sites.filter((site) => site.work.status !== 'cancelled') : [];
475
533
  const layout = districtStreetLots(shown.map((site) => ({ task_id: site.task_id, created_at: site.work.created_at, purpose_label: site.purpose_label })));
476
534
  const L = layout.length;
535
+ // The stage of the district (AC-24): counted from permanent buildings, it sets how built-up it looks.
536
+ const stage = districtStage(shown);
537
+ const grown = stage.level;
477
538
  const gateZ = L / 2 + 4;
478
539
  const hallZ = -L / 2 - 7;
479
540
  // Land as in the world: gentle, non-repeating shades of grass (scenery).
@@ -488,18 +549,19 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
488
549
  // The district's paving: a raised plate with a kerb, from the gate to the hall.
489
550
  const pavingDepth = gateZ - hallZ + 14;
490
551
  root.add(mesh(roundedPlate(28, pavingDepth, 0.04, 2.2), material(0xd4cec0, { roughness: 1 }), { z: (gateZ + hallZ) / 2, shadow: false }));
491
- root.add(mesh(roundedPlate(27, pavingDepth - 1, 0.06, 1.8), material(0xebe6d8, { roughness: 1 }), { z: (gateZ + hallZ) / 2, shadow: false }));
552
+ const pavingColor = [0xd8cdb2, 0xe2dac6, 0xebe6d8, 0xefeadc, 0xf2eee2][grown];
553
+ root.add(mesh(roundedPlate(27, pavingDepth - 1, 0.06, 1.8), material(pavingColor, { roughness: 1 }), { z: (gateZ + hallZ) / 2, shadow: false }));
492
554
  const lift = new THREE.Group();
493
555
  lift.position.y = 0.17;
494
556
  root.add(lift);
495
- streetStrip(lift, { x: 0, z: (gateZ + 8 + hallZ + 3) / 2, width: DISTRICT_STREETS.main * 2, length: gateZ + 8 - (hallZ + 3), centreLine: true });
496
- streetStrip(lift, { x: -DISTRICT_STREETS.backStreet, z: 0, width: 1.6, length: L + 2 });
497
- streetStrip(lift, { x: DISTRICT_STREETS.backStreet, z: 0, width: 1.6, length: L + 2 });
557
+ streetStrip(lift, { x: 0, z: (gateZ + 8 + hallZ + 3) / 2, width: DISTRICT_STREETS.main * 2, length: gateZ + 8 - (hallZ + 3), centreLine: grown >= 2, dirt: grown === 0 });
558
+ streetStrip(lift, { x: -DISTRICT_STREETS.backStreet, z: 0, width: 1.6, length: L + 2, dirt: grown < 2 });
559
+ streetStrip(lift, { x: DISTRICT_STREETS.backStreet, z: 0, width: 1.6, length: L + 2, dirt: grown < 2 });
498
560
  // A cross street between the blocks, and at the hall end of each block a sign saying what its work is for.
499
561
  const crossWidth = DISTRICT_STREETS.backLot * 2 + 4;
500
562
  layout.streets.forEach((block, index) => {
501
563
  if (index < layout.streets.length - 1) {
502
- streetStrip(lift, { x: 0, z: block.z_to + DISTRICT_STREETS.cross / 2, width: DISTRICT_STREETS.cross * 0.62, length: crossWidth, across: true });
564
+ streetStrip(lift, { x: 0, z: block.z_to + DISTRICT_STREETS.cross / 2, width: DISTRICT_STREETS.cross * 0.62, length: crossWidth, across: true, dirt: grown < 2 });
503
565
  }
504
566
  const signZ = block.z_from - (index === 0 ? 0.9 : DISTRICT_STREETS.cross / 2);
505
567
  root.add(mesh(new THREE.CylinderGeometry(0.06, 0.08, 2.5, 8), material(0x3f4844, { roughness: 0.6, metalness: 0.4 }), { x: -DISTRICT_STREETS.main - 0.5, y: 1.25, z: signZ }));
@@ -523,7 +585,8 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
523
585
  trees.push([Math.cos(angle) * rx, (gateZ + hallZ) / 2 + Math.sin(angle) * rz, 0.8 + hashUnit(`belt${i}s`) * 0.6]);
524
586
  }
525
587
  plantTrees(root, trees.filter(([x, z]) => !(Math.abs(x) < 4.2 && z > gateZ - 1)));
526
- placeLamps(lift, lamps);
588
+ // Lamps come with the district's growth: none on vacant land, every other one in a village.
589
+ placeLamps(lift, grown === 0 ? [] : grown === 1 ? lamps.filter((_, i) => i % 4 === 0) : lamps);
527
590
  // The gate: stone pillars on plinths, a lintel with a cornice, lamps on its pillars.
528
591
  const stone = material(0xe2dccf, { roughness: 0.9 });
529
592
  for (const x of [-3.3, 3.3]) {
@@ -534,7 +597,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
534
597
  root.add(mesh(new THREE.BoxGeometry(7.8, 0.55, 0.95), stone, { y: 4.65, z: gateZ }));
535
598
  root.add(mesh(new THREE.BoxGeometry(8.2, 0.16, 1.1), material(0xcfc8ba, { roughness: 0.9 }), { y: 5.0, z: gateZ }));
536
599
  placeLamps(root, [[-3.3, gateZ + 0.6], [3.3, gateZ + 0.6]]);
537
- addLabel(`${business.name}の区画(入口)`, new THREE.Vector3(0, 5.2, gateZ), 'is-city', { priority: 1 });
600
+ addLabel(`${business.name}の区画(入口)・${stage.label}`, new THREE.Vector3(0, 5.2, gateZ), 'is-city', { priority: 1 });
538
601
  // The hall: what this business is for, and the stones of its policies in front of it.
539
602
  // A civic building as in the world: steps, a colonnade, tall lit windows, a pediment.
540
603
  for (let step = 0; step < 3; step += 1) root.add(mesh(roundedPlate(9.6 - step * 0.5, 6.2 - step * 0.4, 0.1, 0.25), stone, { y: step * 0.18, z: hallZ + 0.2 }));
@@ -559,6 +622,28 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
559
622
  const ring = mesh(new THREE.RingGeometry(3.6, 3.85, 48), material(0xcfc8b8, { roughness: 1 }), { y: 0.21, z: hallZ + 5.4, shadow: false });
560
623
  ring.rotation.x = -Math.PI / 2;
561
624
  root.add(ring);
625
+ if (grown >= 3) {
626
+ // A fountain in the plaza once the district is a 街.
627
+ const basin = material(0xd9d3c6, { roughness: 0.8 });
628
+ root.add(mesh(new THREE.CylinderGeometry(1.0, 1.1, 0.35, 32), basin, { y: 0.37, z: hallZ + 5.4 }));
629
+ const water = mesh(new THREE.CylinderGeometry(0.88, 0.88, 0.04, 32), material(0x7fc4d6, { roughness: 0.15, metalness: 0.1, transparent: true, opacity: 0.85 }), { y: 0.52, z: hallZ + 5.4, shadow: false });
630
+ root.add(water);
631
+ root.add(mesh(new THREE.CylinderGeometry(0.12, 0.16, 0.7, 12), basin, { y: 0.7, z: hallZ + 5.4 }));
632
+ const jet = mesh(new THREE.ConeGeometry(0.22, 0.6, 12, 1, true), material(0xcfeef5, { transparent: true, opacity: 0.6, emissive: new THREE.Color(0x6fb6c9), emissiveIntensity: 0.2 }), { y: 1.25, z: hallZ + 5.4, shadow: false });
633
+ jet.rotation.x = Math.PI;
634
+ root.add(jet);
635
+ fountainJet = jet;
636
+ }
637
+ if (grown >= 4) {
638
+ // A clock tower beside the hall once the district is a 都市.
639
+ const tower = block(1.4, 6.0, 1.4, { style: 'civic', wall: 0xe8e0cf, lit: true, roof: 0xb5654a });
640
+ tower.position.set(5.6, 0.2, hallZ - 0.4);
641
+ root.add(tower);
642
+ const cap = mesh(new THREE.ConeGeometry(1.1, 1.4, 4), material(0xb5654a, { roughness: 0.7 }), { x: 5.6, y: 6.9, z: hallZ - 0.4 });
643
+ cap.rotation.y = Math.PI / 4;
644
+ root.add(cap);
645
+ root.add(mesh(new THREE.CylinderGeometry(0.42, 0.42, 0.06, 24), material(0xfaf6ea, { emissive: new THREE.Color(0xffe2a0), emissiveIntensity: 0.3 }), { x: 5.6, y: 5.6, z: hallZ + 0.33, shadow: false }).rotateX(Math.PI / 2));
646
+ }
562
647
  const policies = work?.status === 'ok' ? work.purpose.policies.slice(0, 8) : [];
563
648
  policies.forEach((policy, index) => {
564
649
  const angle = Math.PI * (0.15 + (0.7 * index) / Math.max(policies.length - 1, 1));
@@ -797,6 +882,19 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
797
882
  puff.material.opacity = 0.5 * (1 - cycle);
798
883
  }
799
884
  }
885
+ for (const item of celebrations) {
886
+ item.started ??= time;
887
+ const elapsed = (time - item.started) / 1000;
888
+ if (item.rises) {
889
+ const t = Math.min(elapsed / 1.4, 1);
890
+ item.group.scale.y = 0.02 + 0.98 * (1 - (1 - t) ** 3);
891
+ if (t >= 1) item.rises = false;
892
+ }
893
+ // Pulses for a while, then stays as a faint ring so the change can still be found.
894
+ item.ring.material.opacity = reducedMotion ? 0.8 : elapsed < 8 ? 0.55 + 0.4 * Math.sin(elapsed * 5) : 0.45;
895
+ item.beam.material.opacity = reducedMotion ? 0.2 : elapsed < 8 ? 0.16 + 0.12 * Math.sin(elapsed * 5) : 0.12;
896
+ }
897
+ if (fountainJet && !reducedMotion) fountainJet.scale.y = 1 + 0.12 * Math.sin(time / 260);
800
898
  if (selectionRing.visible) selectionRing.material.opacity = 0.6 + 0.3 * Math.sin(time / 300);
801
899
  renderer.render(scene, camera);
802
900
  placeLabels();
@@ -851,6 +949,16 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
851
949
  focusIds = taskIds ? new Set(taskIds) : null;
852
950
  applyFocus();
853
951
  },
952
+ /** Shows what changed since the viewer's last visit (from `districtChanges`). */
953
+ celebrate,
954
+ /** Rebuilds the open district from fresh records, keeping the camera where it is. */
955
+ refresh(business, work, { neighbours = new Map() } = {}) {
956
+ const keep = { target: controls.target.clone(), position: camera.position.clone() };
957
+ build(business, work, neighbours);
958
+ controls.target.copy(keep.target);
959
+ camera.position.copy(keep.position);
960
+ camera.lookAt(keep.target);
961
+ },
854
962
  dispose() {
855
963
  running = false;
856
964
  observer?.disconnect();
@@ -193,3 +193,106 @@ export function districtStreetLots(sites, { minRows = 4 } = {}) {
193
193
  }
194
194
  return { lots, streets, rows, length };
195
195
  }
196
+
197
+ /*
198
+ * The town grows (story-world-work-sites-and-gaps-v1, AC-22〜24). It grows only from evidenced outcomes
199
+ * and closed gaps, never from activity: counting completions or edits would let splitting work inflate it.
200
+ */
201
+ const WORKER_GAPS = new Set(['assignee_unrecorded', 'assignee_unlinked_mentioned']);
202
+ const PATH_GAPS = new Set(['source_unlinked', 'outcome_unlinked']);
203
+ const OPEN_STATES = new Set(['pending', 'in_progress', 'waiting']);
204
+
205
+ export const DISTRICT_CHANGE_TEXT = Object.freeze({
206
+ new: '新しい現場ができた',
207
+ left: '区画を出た仕事がある(取消・付け替え)',
208
+ built: '家が建った(完了)',
209
+ evidenced: 'プレハブが本設の建物になった(成果の記録がつながった)',
210
+ started: '着工した(未着手→進行中)',
211
+ held: 'シートがかかった(→待ち)',
212
+ resumed: 'シートが外れた(待ち→進行中)',
213
+ worker_in: '作業員が入った(担当がつながった)',
214
+ worker_out: '作業員がいなくなった(担当が外れた)',
215
+ path_linked: '道がつながった(出典・成果の記録がついた)',
216
+ weeds_cleared: '雑草が抜けた(見直された)',
217
+ weeds_grew: '雑草が生えた(見直し予定を過ぎた)',
218
+ });
219
+
220
+ const hasEvidence = (site) => (site.source_refs?.length ?? 0) > 0;
221
+
222
+ /** What this viewer saw of a district, kept in their browser to tell what changed next time. */
223
+ export function districtSnapshot(sites, at) {
224
+ const kept = {};
225
+ for (const site of sites ?? []) {
226
+ if (site.work?.status === 'cancelled') continue;
227
+ kept[site.task_id] = {
228
+ s: site.work?.status ?? null,
229
+ g: [...new Set((site.gaps ?? []).map((gap) => gap.kind))].sort(),
230
+ l: site.purpose_label ?? null,
231
+ e: hasEvidence(site),
232
+ };
233
+ }
234
+ return { v: 1, at, sites: kept };
235
+ }
236
+
237
+ /** What changed in a district since `previous` (a snapshot), in the words of the street. */
238
+ export function districtChanges(previous, sites, at) {
239
+ if (!previous || previous.v !== 1 || typeof previous.sites !== 'object' || previous.sites === null) {
240
+ return { first: true, since: null, at, items: [], counts: {} };
241
+ }
242
+ const now = districtSnapshot(sites, at).sites;
243
+ const items = [];
244
+ const add = (taskId, kind) => items.push({ task_id: taskId, kind, text: DISTRICT_CHANGE_TEXT[kind] });
245
+ for (const [taskId, after] of Object.entries(now)) {
246
+ const before = previous.sites[taskId];
247
+ if (!before) {
248
+ add(taskId, 'new');
249
+ continue;
250
+ }
251
+ if (OPEN_STATES.has(before.s) && after.s === 'completed') add(taskId, 'built');
252
+ if (before.s === 'completed' && after.s === 'completed' && !before.e && after.e) add(taskId, 'evidenced');
253
+ if (before.s === 'pending' && after.s === 'in_progress') add(taskId, 'started');
254
+ if (before.s !== 'waiting' && OPEN_STATES.has(before.s) && after.s === 'waiting') add(taskId, 'held');
255
+ if (before.s === 'waiting' && after.s === 'in_progress') add(taskId, 'resumed');
256
+ const had = (gaps, set) => (gaps ?? []).some((kind) => set.has(kind));
257
+ if (had(before.g, WORKER_GAPS) && !had(after.g, WORKER_GAPS)) add(taskId, 'worker_in');
258
+ if (!had(before.g, WORKER_GAPS) && had(after.g, WORKER_GAPS)) add(taskId, 'worker_out');
259
+ if (had(before.g, PATH_GAPS) && !had(after.g, PATH_GAPS)) add(taskId, 'path_linked');
260
+ const overdue = new Set(['review_overdue']);
261
+ if (had(before.g, overdue) && !had(after.g, overdue)) add(taskId, 'weeds_cleared');
262
+ if (!had(before.g, overdue) && had(after.g, overdue)) add(taskId, 'weeds_grew');
263
+ }
264
+ for (const taskId of Object.keys(previous.sites)) if (!now[taskId]) add(taskId, 'left');
265
+ const counts = {};
266
+ for (const item of items) counts[item.kind] = (counts[item.kind] ?? 0) + 1;
267
+ return { first: false, since: previous.at ?? null, at, items, counts };
268
+ }
269
+
270
+ export const DISTRICT_STAGES = Object.freeze([
271
+ { key: 'vacant', label: '更地', min: 0 },
272
+ { key: 'village', label: '村', min: 1 },
273
+ { key: 'town', label: '町', min: 3 },
274
+ { key: 'street', label: '街', min: 7 },
275
+ { key: 'city', label: '都市', min: 15 },
276
+ ]);
277
+
278
+ /** The stage of a district: counted from completed work with a record of its source (a permanent building). */
279
+ export function districtStage(sites) {
280
+ const live = (sites ?? []).filter((site) => site.work?.status !== 'cancelled');
281
+ const completed = live.filter((site) => site.work?.status === 'completed');
282
+ const permanent = completed.filter(hasEvidence).length;
283
+ const open = live.filter((site) => OPEN_STATES.has(site.work?.status));
284
+ let index = 0;
285
+ DISTRICT_STAGES.forEach((stage, i) => { if (permanent >= stage.min) index = i; });
286
+ const stage = DISTRICT_STAGES[index];
287
+ const following = DISTRICT_STAGES[index + 1] ?? null;
288
+ return {
289
+ key: stage.key,
290
+ label: stage.label,
291
+ level: index,
292
+ permanent,
293
+ prefab: completed.length - permanent,
294
+ open: open.length,
295
+ clear_open: open.filter((site) => (site.gaps?.length ?? 0) === 0).length,
296
+ next: following ? { label: following.label, needed: following.min - permanent } : null,
297
+ };
298
+ }
@@ -217,6 +217,44 @@
217
217
  color: #5c5546;
218
218
  }
219
219
 
220
+ .bb-world-label.is-change {
221
+ font-weight: 700;
222
+ background: #fff4d6;
223
+ border-color: #e0a526;
224
+ color: #6b4a00;
225
+ }
226
+
227
+ .bb-world-label.is-change.is-setback {
228
+ background: #eef0f1;
229
+ border-color: #8a8f94;
230
+ color: #3f454a;
231
+ }
232
+
233
+ .bb-world-growth-stage {
234
+ margin: 0 0 6px;
235
+ }
236
+
237
+ .bb-world-growth-stage strong {
238
+ font-size: 1.25em;
239
+ }
240
+
241
+ .bb-world-growth-since {
242
+ margin: 8px 0 2px;
243
+ font-weight: 700;
244
+ }
245
+
246
+ .bb-world-growth-changes li {
247
+ border-left: 3px solid #e0a526;
248
+ padding-left: 6px;
249
+ }
250
+
251
+ .bb-world-growth-changes li.is-worker_out,
252
+ .bb-world-growth-changes li.is-weeds_grew,
253
+ .bb-world-growth-changes li.is-held,
254
+ .bb-world-growth-changes li.is-left {
255
+ border-left-color: var(--bb-color-muted);
256
+ }
257
+
220
258
  .bb-world-label.is-engagement {
221
259
  font-size: 11px;
222
260
  color: var(--bb-color-muted);
@@ -23,10 +23,10 @@
23
23
  */
24
24
 
25
25
  import { THREE, MapControls } from './world-vendor.js';
26
- import { changesSince, cityMeasures, districtLots, groupJudgmentPlaces, skyAt, UNPLACED_REASON_TEXT } from './world-placement.js';
26
+ import { changesSince, cityMeasures, districtChanges, districtLots, districtSnapshot, districtStage, groupJudgmentPlaces, skyAt, UNPLACED_REASON_TEXT } from './world-placement.js';
27
27
  import { createDistrictView } from './world-district.js';
28
28
  import { canvasTexture, drawFacade, FACADE_SIZES, FACADE_UNITS, gableRoof, hashUnit, muted, paintVertices, roundedPlate, valueNoise } from './world-scenery.js';
29
- import { WORK_STATES, workCityBlocks, workPickBlock, workSign, workSiteBlocks } from './world-work-rail.js';
29
+ import { WORK_STATES, workCityBlocks, workGrowthBlock, workPickBlock, workSign, workSiteBlocks } from './world-work-rail.js';
30
30
  import {
31
31
  makeWorkspaceElement as el,
32
32
  workspacePageHeader,
@@ -1684,6 +1684,8 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1684
1684
  }));
1685
1685
  blocks.push(workspaceRailBlock(doc, { title: '', content: workspaceButton(doc, { text: 'すべての仕事を表示', onClick: () => showFocus(business, null) }) }));
1686
1686
  }
1687
+ const growth = growthFor(business.code);
1688
+ if (growth) blocks.push(workGrowthBlock(doc, growth));
1687
1689
  blocks.push(...workBlocksFor(business), ...cityRegistrationBlocks(business));
1688
1690
  showRail(blocks);
1689
1691
  if (revealFocus) reveal(focusBlock ?? blocks[0]);
@@ -1703,6 +1705,64 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1703
1705
  renderCityRail(business, { revealFocus: true });
1704
1706
  }
1705
1707
 
1708
+ // --- the district grows (AC-22〜24): what changed since this viewer was last inside -------------
1709
+ // The last state seen is a per-viewer convenience kept in this browser; nothing is lost if it forgets.
1710
+ const districtGrowth = new Map();
1711
+ const snapshotKey = (code) => `brainbase.world.district.${code}`;
1712
+ function readSnapshot(code) {
1713
+ try {
1714
+ return JSON.parse(globalThis.localStorage?.getItem(snapshotKey(code)) ?? 'null');
1715
+ } catch {
1716
+ return null;
1717
+ }
1718
+ }
1719
+ function writeSnapshot(code, snapshot) {
1720
+ try {
1721
+ globalThis.localStorage?.setItem(snapshotKey(code), JSON.stringify(snapshot));
1722
+ } catch {
1723
+ // Without storage the district shows no changes next time.
1724
+ }
1725
+ }
1726
+ const liveSites = (work) => (work?.status === 'ok' && work.reads.tasks.state !== 'unavailable' ? work.sites : null);
1727
+ /** Compares against what this viewer last saw, then remembers what they see now. */
1728
+ function noteGrowth(code, work, previous) {
1729
+ const sites = liveSites(work);
1730
+ if (!sites) {
1731
+ districtGrowth.delete(code);
1732
+ return null;
1733
+ }
1734
+ const at = new Date().toISOString();
1735
+ const changes = districtChanges(previous, sites, at);
1736
+ const growth = { stage: districtStage(sites), changes, seen: districtSnapshot(sites, at) };
1737
+ districtGrowth.set(code, growth);
1738
+ writeSnapshot(code, growth.seen);
1739
+ return growth;
1740
+ }
1741
+ const growthFor = (code) => districtGrowth.get(code) ?? null;
1742
+
1743
+ // AC-23: coming back from the task screen with the district open, read its work again and answer.
1744
+ async function recheckDistrict() {
1745
+ const business = districtCode ? businessOf(districtCode) : null;
1746
+ if (!business || !district?.isOpen()) return;
1747
+ const previous = growthFor(business.code)?.seen ?? readSnapshot(business.code);
1748
+ const result = await readJson(fetcher, `/api/extensions/world/businesses/${encodeURIComponent(business.code)}/work`);
1749
+ if (!result.ok || districtCode !== business.code) return;
1750
+ const work = result.data;
1751
+ const before = previous ? districtChanges(previous, liveSites(work) ?? [], new Date().toISOString()) : null;
1752
+ if (!before || before.first || !before.items.length) return;
1753
+ workCache.set(business.code, work);
1754
+ scene?.showWork(business.code, work);
1755
+ district.refresh(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
1756
+ const growth = noteGrowth(business.code, work, previous);
1757
+ district.celebrate(growth?.changes ?? null);
1758
+ district.focus(focusIdsFor(work, current.focus));
1759
+ if (current.level === 'city') renderCityRail(business);
1760
+ }
1761
+ const onVisible = () => {
1762
+ if (doc.visibilityState === 'visible') void recheckDistrict();
1763
+ };
1764
+ doc.addEventListener('visibilitychange', onVisible);
1765
+
1706
1766
  // --- inside a district (AC-11): the world rests behind it while it is open ---------------------
1707
1767
  let district = null;
1708
1768
  let districtCode = null;
@@ -1729,6 +1789,8 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1729
1789
  if (districtCode !== business.code || !district.isOpen()) {
1730
1790
  district.show(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
1731
1791
  districtCode = business.code;
1792
+ const growth = growthFor(business.code) ?? noteGrowth(business.code, work, readSnapshot(business.code));
1793
+ district.celebrate(growth?.changes ?? null);
1732
1794
  }
1733
1795
  district.focus(focusIdsFor(work, current.focus));
1734
1796
  return true;
@@ -2115,6 +2177,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
2115
2177
  void load();
2116
2178
  return {
2117
2179
  dispose() {
2180
+ doc.removeEventListener('visibilitychange', onVisible);
2118
2181
  scene?.dispose();
2119
2182
  },
2120
2183
  };
@@ -335,3 +335,37 @@ export function workSiteBlocks(doc, { business, site, readAt, taskHref }) {
335
335
  blocks.push(workspaceRailBlock(doc, { title: '出典', content: workspaceDefinition(doc, [['記録', `${site.task_id}(版 ${site.source.version ?? '不明'})`], ['事業', business.name]]) }));
336
336
  return blocks;
337
337
  }
338
+
339
+ /**
340
+ * How the district has grown (AC-22, AC-24): its stage, what it is counted from, how far to the next, and
341
+ * what changed since this viewer was last here. Counts of records, never a score.
342
+ */
343
+ export function workGrowthBlock(doc, { stage, changes }) {
344
+ const content = [];
345
+ const head = el(doc, 'p', { className: 'bb-world-growth-stage' });
346
+ head.append(el(doc, 'strong', { text: stage.label }), el(doc, 'span', { text: ` 本設の建物 ${stage.permanent}軒・プレハブ ${stage.prefab}軒` }));
347
+ content.push(head);
348
+ content.push(workspaceDefinition(doc, [
349
+ ['断絶の無い未完了', `${stage.clear_open} / ${stage.open}件`],
350
+ ['次の段階', stage.next ? `${stage.next.label}まで本設の建物あと${stage.next.needed}軒` : 'いちばん上の段階です'],
351
+ ]));
352
+ content.push(el(doc, 'p', {
353
+ className: 'bb-world-rail-note',
354
+ text: '本設の建物=完了して、出典か成果の記録がついた仕事。記録の無い完了はプレハブのままで、件数を増やしても街は育ちません。',
355
+ }));
356
+ if (changes) {
357
+ if (changes.first) {
358
+ content.push(el(doc, 'p', { className: 'bb-world-rail-note', text: 'この区画に初めて入りました。次回から、前回からの変化を出します(このブラウザに覚えます)。' }));
359
+ } else if (!changes.items.length) {
360
+ content.push(el(doc, 'p', { className: 'bb-world-rail-note', text: `前回(${shortTime(changes.since) ?? '時点不明'})から変わったことはありません。` }));
361
+ } else {
362
+ const list = el(doc, 'ul', { className: 'bb-world-rail-list bb-world-growth-changes' });
363
+ for (const [kind, count] of Object.entries(changes.counts)) {
364
+ const text = changes.items.find((item) => item.kind === kind)?.text ?? kind;
365
+ list.append(el(doc, 'li', { className: `is-${kind}`, text: `${text} ${count}件` }));
366
+ }
367
+ content.push(el(doc, 'p', { className: 'bb-world-growth-since', text: `前回(${shortTime(changes.since) ?? '時点不明'})から` }), list);
368
+ }
369
+ }
370
+ return workspaceRailBlock(doc, { title: '区画の育ち', content });
371
+ }