@unson/brainbase-mcp 0.22.0 → 0.22.2
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 +2 -2
- package/ui/world/world-district.js +119 -11
- package/ui/world/world-placement.js +103 -0
- package/ui/world/world-view.css +38 -0
- package/ui/world/world-view.js +66 -2
- package/ui/world/world-work-rail.js +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unson/brainbase-mcp",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.2",
|
|
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": "
|
|
410
|
+
"gitHead": "3a54233ed19d3ddae6b1f346955fecb878af962e"
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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}
|
|
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
|
+
}
|
package/ui/world/world-view.css
CHANGED
|
@@ -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);
|
package/ui/world/world-view.js
CHANGED
|
@@ -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,65 @@ 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
|
+
// Hosts may hand in a document without events (tests, server rendering); the recheck is then off.
|
|
1765
|
+
doc.addEventListener?.('visibilitychange', onVisible);
|
|
1766
|
+
|
|
1706
1767
|
// --- inside a district (AC-11): the world rests behind it while it is open ---------------------
|
|
1707
1768
|
let district = null;
|
|
1708
1769
|
let districtCode = null;
|
|
@@ -1729,6 +1790,8 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
|
|
|
1729
1790
|
if (districtCode !== business.code || !district.isOpen()) {
|
|
1730
1791
|
district.show(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
|
|
1731
1792
|
districtCode = business.code;
|
|
1793
|
+
const growth = growthFor(business.code) ?? noteGrowth(business.code, work, readSnapshot(business.code));
|
|
1794
|
+
district.celebrate(growth?.changes ?? null);
|
|
1732
1795
|
}
|
|
1733
1796
|
district.focus(focusIdsFor(work, current.focus));
|
|
1734
1797
|
return true;
|
|
@@ -2115,6 +2178,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
|
|
|
2115
2178
|
void load();
|
|
2116
2179
|
return {
|
|
2117
2180
|
dispose() {
|
|
2181
|
+
doc.removeEventListener?.('visibilitychange', onVisible);
|
|
2118
2182
|
scene?.dispose();
|
|
2119
2183
|
},
|
|
2120
2184
|
};
|
|
@@ -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
|
+
}
|