@unson/brainbase-mcp 0.25.0 → 0.26.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.25.0",
3
+ "version": "0.26.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",
@@ -79,6 +79,10 @@
79
79
  "types": "./dist/embedding-provider.d.ts",
80
80
  "import": "./dist/embedding-provider.js"
81
81
  },
82
+ "./graph-embedding-provider": {
83
+ "types": "./dist/graph-embedding-provider.d.ts",
84
+ "import": "./dist/graph-embedding-provider.js"
85
+ },
82
86
  "./graph-revision-reader": {
83
87
  "types": "./dist/graph-revision-reader.d.ts",
84
88
  "import": "./dist/graph-revision-reader.js"
@@ -431,5 +435,5 @@
431
435
  "engines": {
432
436
  "node": ">=20"
433
437
  },
434
- "gitHead": "8b9b0e51e70847db4ed540d10c79655d0fd6e050"
438
+ "gitHead": "fbf6dd5d0f2b9df5e524c5a69623dce6cf24144e"
435
439
  }
@@ -28,7 +28,8 @@ const nodeList = (nodes) => (Array.isArray(nodes) ? nodes : [nodes]).filter((nod
28
28
  * @param {() => void} [options.onDismissTask] User dismissed task details.
29
29
  *
30
30
  * showRail/showTask/setHelp accept actual DOM nodes, preserving their events and
31
- * all source details. showRail never opens the drawer. showTask does not move
31
+ * all source details. showRail never opens the drawer; openRail opens it without
32
+ * moving focus (a picked station opens its city's details). showTask does not move
32
33
  * focus unless its optional second argument is { focus: true }.
33
34
  * addNote shows a quest only for an explicit nonempty summary; its full text
34
35
  * remains in help. No summary is inferred from a long or nonurgent notice.
@@ -451,7 +452,7 @@ export function createWorldCanvasUI({ doc, stage, reducedMotion = false, onReset
451
452
  setCities([]);
452
453
 
453
454
  return {
454
- setCities, setCity, setSky, showRail, showTask, closeTask, setTaskAnchor,
455
+ setCities, setCity, setSky, showRail, openRail, showTask, closeTask, setTaskAnchor,
455
456
  setHelp(nodes) { if (!disposed) helpContent.replaceChildren(...nodeList(nodes)); },
456
457
  addNote, clearNotes, dismissOverlay,
457
458
  dispose() {
@@ -11,12 +11,19 @@
11
11
  * colours (a review date passed), fog (the work could not be read).
12
12
  * People walking the streets and chimney smoke are the town's atmosphere: they come from no record,
13
13
  * never enter a lot, and the legend says so. Nothing here writes.
14
+ *
15
+ * When the host gives the tools a business uses (story-world-business-exits-v1), each stands as a
16
+ * station in a yard outside the gate: lit when usable, its wicket closed when a permission is needed,
17
+ * in fog when its state is unconfirmed or could not be read. A station's sign carries the count its
18
+ * tool asks attention for. Picking a station selects its row in the city details; it never leaves
19
+ * the world.
14
20
  */
15
21
 
16
22
  import { THREE, MapControls } from './world-vendor.js';
17
23
  import { DISTRICT_STREETS, districtGroundPlan, districtStage, districtStreetLots, skyAt } from './world-placement.js';
18
24
  import { canvasTexture, drawFacade, FACADE_SIZES, FACADE_UNITS, gableRoof, hashUnit, paintVertices, roundedPlate, valueNoise } from './world-scenery.js';
19
25
  import { makeWorkspaceElement as el } from '../../workspace-kit.js';
26
+ import { attentionSignText } from './world-exits.js';
20
27
 
21
28
  export const WORLD_DISTRICT_CONTRACT_VERSION = 'brainbase.world-district.v0';
22
29
 
@@ -76,14 +83,24 @@ export function projectDistrictSelection({ taskId = null, anchor, camera, viewpo
76
83
  };
77
84
  }
78
85
 
86
+ /** Where the station of the `index`th tool stands: rows of four in the yard outside the gate, to its right. */
87
+ export function districtStationSpot(index, gateZ) {
88
+ return { x: 8 + (index % 4) * 3.4, z: gateZ + 3.6 + Math.floor(index / 4) * 3.2 };
89
+ }
90
+ const STATION_YARD = Object.freeze({ xFrom: 6, xTo: 21.5, zFrom: 1.5, zTo: 12.5 });
91
+
79
92
  /**
80
93
  * `showChrome: false` is for hosts that present DISTRICT_LEGEND and the work's read warnings in their
81
94
  * own overlay. `focusSelection: false` leaves navigation with the viewer when selecting a work site.
82
95
  * `onProjectSelection` receives { taskId, x, y, visible } on each rendered frame and immediately when
83
96
  * selection, visibility, size or records change. Coordinates are stage-local CSS pixels, never device
84
97
  * pixels; a cleared selection is { taskId: null, x: null, y: null, visible: false }.
98
+ * `exitLegend` (lines as DISTRICT_LEGEND) turns on the station yard for the tools of a business:
99
+ * stations come from `setExits`, and a picked station calls `onPickExit(id)`. Without it nothing of
100
+ * the yard is drawn or listed.
85
101
  */
86
- export function createDistrictView({ doc, stage, reducedMotion = false, focusSelection = true, showChrome = true, onPick, onClear, onEscape, onProjectSelection }) {
102
+ export function createDistrictView({ doc, stage, reducedMotion = false, focusSelection = true, showChrome = true, onPick, onClear, onEscape, onProjectSelection, exitLegend = null, onPickExit }) {
103
+ const stationsEnabled = Array.isArray(exitLegend);
87
104
  const renderer = new THREE.WebGLRenderer({ antialias: true });
88
105
  renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio ?? 1, 2));
89
106
  renderer.shadowMap.enabled = true;
@@ -100,7 +117,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
100
117
  const legend = el(doc, 'details', { className: 'bb-world-district-legend', attrs: { open: true } });
101
118
  legend.append(el(doc, 'summary', { text: '区画の見方' }));
102
119
  const legendList = el(doc, 'ul');
103
- for (const [className, text] of DISTRICT_LEGEND) legendList.append(el(doc, 'li', { className, text }));
120
+ for (const [className, text] of [...DISTRICT_LEGEND, ...(stationsEnabled ? exitLegend : [])]) legendList.append(el(doc, 'li', { className, text }));
104
121
  legend.append(legendList);
105
122
  legend.hidden = true;
106
123
  stage.append(legend);
@@ -329,6 +346,12 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
329
346
  let root = null;
330
347
  let fountainJet = null;
331
348
  let lots = new Map();
349
+ // The tools of the business (from the host), and their stations once the district is built.
350
+ let exitsData = [];
351
+ let stations = new Map();
352
+ let stationLayer = null;
353
+ let stationLabels = [];
354
+ let gateAt = 0;
332
355
  let labels = [];
333
356
  let walkers = [];
334
357
  let puffs = [];
@@ -503,6 +526,83 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
503
526
  return group;
504
527
  }
505
528
 
529
+ // --- a station for a tool of the business, in local space: its front toward +z ------------------
530
+ function stationGroup(exit) {
531
+ const group = new THREE.Group();
532
+ const state = exit.state;
533
+ const lit = state === 'available';
534
+ const fogged = state === 'unknown' || state === 'unavailable';
535
+ const part = (object, name) => {
536
+ object.userData.part = name;
537
+ group.add(object);
538
+ return object;
539
+ };
540
+ group.add(mesh(roundedPlate(2.8, 1.9, 0.22, 0.2), material(0xd9d3c6, { roughness: 1 })));
541
+ group.add(mesh(new THREE.BoxGeometry(2.8, 0.04, 0.12), material(0xe9d27a, { roughness: 0.8 }), { y: 0.24, z: 0.86, shadow: false }));
542
+ const house = block(1.8, 1.15, 1.0, { style: 'civic', wall: fogged ? 0xcfd2d0 : 0xeee6d6, lit, roof: 0x8a6d55 });
543
+ house.position.set(0, 0.22, -0.35);
544
+ group.add(house);
545
+ const roof = mesh(gableRoof(1.0, 1.8, 0.5), material(fogged ? 0x9aa0a0 : 0x8a5a44, { roughness: 0.75 }), { y: 1.37, z: -0.35 });
546
+ roof.rotation.y = Math.PI / 2;
547
+ group.add(roof);
548
+ if (lit) {
549
+ // The station's lamp: always on, whatever the sky (its light is its state).
550
+ part(mesh(new THREE.CylinderGeometry(0.05, 0.06, 1.7, 6), material(0x3f4844, { roughness: 0.6, metalness: 0.4 }), { x: 1.15, y: 1.07, z: 0.55 }), 'light-pole');
551
+ part(mesh(new THREE.SphereGeometry(0.2, 12, 10), material(0xfff3d6, { emissive: new THREE.Color(0xffc867), emissiveIntensity: 2.2 }), { x: 1.15, y: 1.98, z: 0.55, shadow: false }), 'light');
552
+ const pool = mesh(new THREE.CircleGeometry(1.1, 24), new THREE.MeshBasicMaterial({ color: 0xffd98a, transparent: true, opacity: 0.45, depthWrite: false }), { x: 1.15, y: 0.25, z: 0.55, shadow: false });
553
+ pool.rotation.x = -Math.PI / 2;
554
+ part(pool, 'light-pool');
555
+ }
556
+ if (state === 'restricted') {
557
+ // The wicket closed: posts and a striped bar across the way in.
558
+ const post = material(0x4b524e, { roughness: 0.6, metalness: 0.3 });
559
+ for (const x of [-0.95, 0.95]) part(mesh(new THREE.BoxGeometry(0.14, 0.8, 0.14), post, { x, y: 0.62, z: 0.55 }), 'gate-post');
560
+ part(mesh(new THREE.BoxGeometry(1.9, 0.12, 0.08), material(0xc8323c, { roughness: 0.6, emissive: new THREE.Color(0x3a0a0e) }), { y: 0.86, z: 0.55 }), 'gate');
561
+ for (const x of [-0.5, 0.1, 0.7]) group.add(mesh(new THREE.BoxGeometry(0.22, 0.125, 0.085), material(0xf4f1e8), { x, y: 0.86, z: 0.55, shadow: false }));
562
+ }
563
+ if (fogged) {
564
+ // Fog round the station: its state could not be seen, which is not "unusable" or "usable".
565
+ const fogMaterial = new THREE.MeshBasicMaterial({ color: 0xe9edf0, transparent: true, opacity: 0.68, depthWrite: false });
566
+ const fog = mesh(new THREE.SphereGeometry(1.9, 18, 12), fogMaterial, { y: 0.9, shadow: false });
567
+ fog.scale.set(1, 0.72, 0.9);
568
+ part(fog, 'fog');
569
+ }
570
+ // The sign by the platform: what the tool asks attention for, if anything.
571
+ group.add(mesh(new THREE.CylinderGeometry(0.04, 0.05, 1.6, 6), material(0x4b524e), { x: -1.2, y: 1.0, z: 0.75 }));
572
+ group.add(mesh(new THREE.BoxGeometry(0.9, 0.42, 0.05), material(exit.attention ? 0x1f5f8b : 0x8e9a95, { roughness: 0.6 }), { x: -1.2, y: 1.85, z: 0.75 }));
573
+ group.userData = { kind: 'exit', exit, state };
574
+ return group;
575
+ }
576
+
577
+ function clearStations() {
578
+ if (stationLayer) root?.remove(stationLayer);
579
+ for (const label of stationLabels) {
580
+ label.node.remove();
581
+ labels = labels.filter((entry) => entry !== label);
582
+ }
583
+ stationLayer = null;
584
+ stationLabels = [];
585
+ if (selected?.userData?.kind === 'exit') select(null, { focus: false });
586
+ stations = new Map();
587
+ }
588
+
589
+ function placeStations() {
590
+ clearStations();
591
+ if (!stationsEnabled || !root || exitsData.length === 0) return;
592
+ stationLayer = new THREE.Group();
593
+ exitsData.forEach((exit, index) => {
594
+ const spot = districtStationSpot(index, gateAt);
595
+ const group = stationGroup(exit);
596
+ group.position.set(spot.x, 0, spot.z);
597
+ stationLayer.add(group);
598
+ stations.set(exit.id, group);
599
+ const sign = attentionSignText(exit.attention);
600
+ const name = exit.label.length > 16 ? `${exit.label.slice(0, 15)}…` : exit.label;
601
+ stationLabels.push(addLabel(`駅:${name}${sign ? `・${sign}` : ''}`, new THREE.Vector3(spot.x, 2.9, spot.z), `is-exit is-${exit.state}${exit.attention ? ' has-attention' : ''}`, { priority: 2 }));
602
+ });
603
+ root.add(stationLayer);
604
+ }
605
+
506
606
  // --- the district -------------------------------------------------------------------------------
507
607
  function clear() {
508
608
  if (root) scene.remove(root);
@@ -512,6 +612,9 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
512
612
  for (const label of labels) label.node.remove();
513
613
  labels = [];
514
614
  lots = new Map();
615
+ stations = new Map();
616
+ stationLayer = null;
617
+ stationLabels = [];
515
618
  walkers = [];
516
619
  puffs = [];
517
620
  selected = null;
@@ -570,6 +673,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
570
673
  const grown = stage.level;
571
674
  const plan = districtGroundPlan(L);
572
675
  const { gateZ, hallZ, plazaZ, plazaRadius } = plan;
676
+ gateAt = gateZ;
573
677
  // Land as in the world: gentle, non-repeating shades of grass (scenery).
574
678
  const grass = { low: new THREE.Color(0x9fbf8c), high: new THREE.Color(0xd3e4bd), warm: new THREE.Color(0xd9d6a6) };
575
679
  const land = mesh(paintVertices(new THREE.CircleGeometry(150, 96, 0, Math.PI * 2), (color, x, y) => {
@@ -618,7 +722,8 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
618
722
  const rz = pavingDepth / 2 + 3 + hashUnit(`belt${i}z`) * 8;
619
723
  trees.push([Math.cos(angle) * rx, (gateZ + hallZ) / 2 + Math.sin(angle) * rz, 0.8 + hashUnit(`belt${i}s`) * 0.6]);
620
724
  }
621
- plantTrees(root, trees.filter(([x, z]) => !(Math.abs(x) < 4.2 && z > gateZ - 1)));
725
+ const inYard = (x, z) => stationsEnabled && x > STATION_YARD.xFrom && x < STATION_YARD.xTo && z > gateZ + STATION_YARD.zFrom && z < gateZ + STATION_YARD.zTo;
726
+ plantTrees(root, trees.filter(([x, z]) => !(Math.abs(x) < 4.2 && z > gateZ - 1) && !inYard(x, z)));
622
727
  // Lamps come with the district's growth: none on vacant land, every other one in a village.
623
728
  placeLamps(lift, grown === 0 ? [] : grown === 1 ? lamps.filter((_, i) => i % 4 === 0) : lamps);
624
729
  // The gate: stone pillars on plinths, a lintel with a cornice, lamps on its pillars.
@@ -731,6 +836,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
731
836
  root.add(walker);
732
837
  walkers.push(walker);
733
838
  }
839
+ placeStations();
734
840
  root.updateMatrixWorld(true);
735
841
  const chimneys = [hallChimney];
736
842
  for (const group of lots.values()) {
@@ -791,7 +897,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
791
897
  pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
792
898
  pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
793
899
  raycaster.setFromCamera(pointer, camera);
794
- const targets = [...lots.values()];
900
+ const targets = [...lots.values(), ...stations.values()];
795
901
  for (const hit of raycaster.intersectObjects(targets, true)) {
796
902
  let object = hit.object;
797
903
  while (object && !targets.includes(object)) object = object.parent;
@@ -809,7 +915,11 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
809
915
  renderer.domElement.addEventListener('pointerup', (event) => {
810
916
  if (!downAt || Math.hypot(event.clientX - downAt.x, event.clientY - downAt.y) > 5) return;
811
917
  const group = pickAt(event.clientX, event.clientY);
812
- if (group) {
918
+ if (group?.userData.kind === 'exit') {
919
+ // A station selects its tool in the city details; it never leaves the world.
920
+ select(group, { focus: false });
921
+ onPickExit?.(group.userData.exit.id);
922
+ } else if (group) {
813
923
  select(group);
814
924
  onPick?.(group.userData.site);
815
925
  } else {
@@ -973,8 +1083,12 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
973
1083
  }
974
1084
 
975
1085
  return {
976
- /** Enters the district of a business; `neighbours` maps other business codes to their names. */
977
- show(business, work, { neighbours = new Map() } = {}) {
1086
+ /**
1087
+ * Enters the district of a business; `neighbours` maps other business codes to their names, and
1088
+ * `exits` (normalized tools, see world-exits.js) are its stations when the yard is on.
1089
+ */
1090
+ show(business, work, { neighbours = new Map(), exits } = {}) {
1091
+ if (exits !== undefined) exitsData = exits;
978
1092
  build(business, work, neighbours);
979
1093
  setVisible(true);
980
1094
  resize();
@@ -1017,7 +1131,8 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
1017
1131
  /** Shows what changed since the viewer's last visit (from `districtChanges`). */
1018
1132
  celebrate,
1019
1133
  /** Rebuilds the open district from fresh records, keeping the camera where it is. */
1020
- refresh(business, work, { neighbours = new Map() } = {}) {
1134
+ refresh(business, work, { neighbours = new Map(), exits } = {}) {
1135
+ if (exits !== undefined) exitsData = exits;
1021
1136
  const keep = { target: controls.target.clone(), position: camera.position.clone(), taskId: selected?.userData?.site?.task_id };
1022
1137
  flight = null;
1023
1138
  controls.enabled = true;
@@ -1027,6 +1142,18 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
1027
1142
  camera.lookAt(keep.target);
1028
1143
  select(lots.get(keep.taskId) ?? null, { focus: false });
1029
1144
  },
1145
+ /** The tools of the open business arrived (or changed): redraws only its stations. */
1146
+ setExits(exits) {
1147
+ exitsData = Array.isArray(exits) ? exits : [];
1148
+ if (root) placeStations();
1149
+ },
1150
+ /** Marks a tool's station as selected (without moving the camera); false when it has none. */
1151
+ selectExit(id) {
1152
+ const group = stations.get(id);
1153
+ if (!group) return false;
1154
+ select(group, { focus: false });
1155
+ return true;
1156
+ },
1030
1157
  dispose() {
1031
1158
  running = false;
1032
1159
  setVisible(false);
@@ -0,0 +1,200 @@
1
+ /*
2
+ * The tools a business uses (story-world-business-exits-v1).
3
+ *
4
+ * A host may pass `businessExits(business)` to the world. Its answer is read here into what the
5
+ * world draws: the read state of the answer, and each tool with its link, its state and what it
6
+ * asks attention for. Only `https:` links and links inside this host (starting with `/` or `?`)
7
+ * are drawn; anything else is dropped and counted, never shown as a link. A read that failed or is
8
+ * not connected is never shown as zero tools. Nothing here writes, and the host's answer is never
9
+ * changed: the world draws copies.
10
+ *
11
+ * The words for the four states come from the owner's vocabulary (`exit_states` in the businesses
12
+ * answer, as story-world-generic-graph-v1), over the defaults below. No organization, member, role
13
+ * or request concept lives here: `state` and `action` are whatever the host decided.
14
+ */
15
+
16
+ import {
17
+ makeWorkspaceElement as el,
18
+ workspaceRailBlock,
19
+ } from '../../workspace-kit.js';
20
+ import { shortTime } from './world-work-rail.js';
21
+
22
+ export const WORLD_EXITS_CONTRACT_VERSION = 'brainbase.world-exits.v0';
23
+
24
+ /** How the host read the tools of a business. */
25
+ export const EXIT_READ_STATUSES = Object.freeze(['complete', 'partial', 'failed', 'not_connected']);
26
+
27
+ /** The states of a tool, with the words used when the owner gives none. */
28
+ export const EXIT_STATES = Object.freeze([
29
+ Object.freeze({ key: 'available', label: '使える' }),
30
+ Object.freeze({ key: 'restricted', label: '権限が必要' }),
31
+ Object.freeze({ key: 'unknown', label: '未確認' }),
32
+ Object.freeze({ key: 'unavailable', label: '読めない' }),
33
+ ]);
34
+ const STATE_KEYS = new Set(EXIT_STATES.map((entry) => entry.key));
35
+
36
+ const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
37
+
38
+ /**
39
+ * Where a link goes: 'external' for an https: URL, 'internal' for a link inside this host (a path
40
+ * starting with a single `/`, or a query starting with `?`), null for anything the world must not draw.
41
+ */
42
+ export function exitHrefKind(href) {
43
+ if (typeof href !== 'string' || !href || href !== href.trim() || /[\u0000-\u001f\u007f\\]/u.test(href)) return null;
44
+ if (href.startsWith('?')) return 'internal';
45
+ if (href.startsWith('/')) return href.startsWith('//') ? null : 'internal';
46
+ if (!/^https:\/\//iu.test(href)) return null;
47
+ try {
48
+ const url = new URL(href);
49
+ return url.protocol === 'https:' && url.hostname ? 'external' : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function attentionOf(value) {
56
+ if (!value || typeof value !== 'object') return null;
57
+ const count = typeof value.count === 'number' && Number.isInteger(value.count) && value.count >= 0 ? value.count : null;
58
+ return { count, label: text(value.label), as_of: text(value.as_of) };
59
+ }
60
+
61
+ /**
62
+ * Reads a host's answer into `{ status, read_at, reason, exits, dropped }`. An answer the world
63
+ * cannot understand is a failed read (`invalid_answer`), never an empty one. A tool without an id
64
+ * or a label, with a link it must not draw, or with an id already given is dropped and counted; an
65
+ * action with such a link is dropped from its tool and counted. A state the world does not know is
66
+ * drawn as `unknown`, never as usable.
67
+ */
68
+ export function normalizeBusinessExits(answer) {
69
+ const invalid = { status: 'failed', read_at: null, reason: 'invalid_answer', exits: [], dropped: { exits: 0, actions: 0 } };
70
+ if (!answer || typeof answer !== 'object' || !EXIT_READ_STATUSES.includes(answer.status)) return invalid;
71
+ const readable = answer.status === 'complete' || answer.status === 'partial';
72
+ if (readable && !Array.isArray(answer.exits)) return invalid;
73
+ const dropped = { exits: 0, actions: 0 };
74
+ const exits = [];
75
+ const seen = new Set();
76
+ for (const entry of readable ? answer.exits : []) {
77
+ const id = text(entry?.id);
78
+ const label = text(entry?.label);
79
+ const target = exitHrefKind(entry?.href);
80
+ if (!id || !label || !target || seen.has(id)) {
81
+ dropped.exits += 1;
82
+ continue;
83
+ }
84
+ seen.add(id);
85
+ let action = null;
86
+ if (entry.action != null) {
87
+ const actionLabel = text(entry.action?.label);
88
+ const actionTarget = exitHrefKind(entry.action?.href);
89
+ if (actionLabel && actionTarget) action = { label: actionLabel, href: entry.action.href, target: actionTarget };
90
+ else dropped.actions += 1;
91
+ }
92
+ exits.push({
93
+ id,
94
+ label,
95
+ href: entry.href,
96
+ target,
97
+ state: STATE_KEYS.has(entry.state) ? entry.state : 'unknown',
98
+ note: text(entry.note),
99
+ action,
100
+ attention: attentionOf(entry.attention),
101
+ });
102
+ }
103
+ return { status: answer.status, read_at: text(answer.read_at), reason: text(answer.reason), exits, dropped };
104
+ }
105
+
106
+ /** The owner's word for a state (`exit_states` of the vocabulary), else the default. */
107
+ export function exitStateLabel(state, vocabulary) {
108
+ const given = Array.isArray(vocabulary?.exit_states) ? vocabulary.exit_states.find((entry) => entry?.key === state) : null;
109
+ return text(given?.label) ?? EXIT_STATES.find((entry) => entry.key === state)?.label ?? EXIT_STATES.find((entry) => entry.key === 'unknown').label;
110
+ }
111
+
112
+ /** What a tool asks attention for: its label, its count (never 0 for a missing count) and its time. */
113
+ export function attentionText(attention) {
114
+ if (!attention) return null;
115
+ const count = attention.count === null ? '件数は未確認' : `${attention.count}件`;
116
+ const at = shortTime(attention.as_of);
117
+ return `${attention.label ?? '知らせ'} ${count}(${at ? `${at}時点` : '時点不明'})`;
118
+ }
119
+
120
+ /** The short words on a station's sign. */
121
+ export function attentionSignText(attention) {
122
+ if (!attention) return null;
123
+ return attention.count === null ? '件数未確認' : `${attention.count}件`;
124
+ }
125
+
126
+ /** The legend lines for the stations, in the owner's words for the states (AC-08). */
127
+ export function exitLegend(vocabulary) {
128
+ const word = (state) => exitStateLabel(state, vocabulary);
129
+ return [
130
+ ['is-exit-available', `明かりのついた駅=${word('available')}`],
131
+ ['is-exit-restricted', `改札が閉じた駅=${word('restricted')}`],
132
+ ['is-exit-fog', `霧の駅=${word('unknown')}・${word('unavailable')}`],
133
+ ['is-exit-sign', '駅の看板の数=その道具が知らせている件数(ホストが渡した値。数が無いときは「件数未確認」)'],
134
+ ['is-exit-pick', '駅=この事業で使う外の道具。選ぶと都市の詳細でその道具の行が選ばれます(駅からは外へ移りません)'],
135
+ ];
136
+ }
137
+
138
+ const READ_FAILURE_TEXT = Object.freeze({ not_connected: '未接続', invalid_answer: '道具の答えの形式が正しくありません' });
139
+
140
+ function linkTo(doc, href, target, textValue, label) {
141
+ const attrs = { href, 'aria-label': target === 'external' ? `${label}(新しいタブで開く)` : label };
142
+ if (target === 'external') Object.assign(attrs, { target: '_blank', rel: 'noopener noreferrer' });
143
+ return el(doc, 'a', { className: 'bb-world-rail-link bb-world-exit-link', text: textValue, attrs });
144
+ }
145
+
146
+ /**
147
+ * The 「この事業の道具」 section of a city (AC-03, AC-05, AC-06). `result` is a normalized answer, or
148
+ * null while it is being read. Returns the section and the row of `selectedId`, if any.
149
+ */
150
+ export function exitsRailBlock(doc, { result, vocabulary, selectedId = null }) {
151
+ const title = 'この事業の道具';
152
+ if (!result) return { block: workspaceRailBlock(doc, { title, className: 'bb-world-exits', content: { text: '道具を読み込んでいます…' } }), selectedRow: null };
153
+ if (result.status === 'failed' || result.status === 'not_connected') {
154
+ const reason = READ_FAILURE_TEXT[result.reason] ?? result.reason ?? (result.status === 'not_connected' ? READ_FAILURE_TEXT.not_connected : '理由不明');
155
+ return {
156
+ block: workspaceRailBlock(doc, {
157
+ title,
158
+ className: 'bb-world-exits is-unreadable',
159
+ content: [
160
+ { text: '道具を読めません(0件とは確認できません)', className: 'bb-world-exits-unreadable' },
161
+ { text: `理由:${result.status === 'not_connected' && result.reason ? `未接続(${reason})` : reason}`, className: 'bb-world-rail-note' },
162
+ ],
163
+ }),
164
+ selectedRow: null,
165
+ };
166
+ }
167
+ const content = [];
168
+ let selectedRow = null;
169
+ if (result.status === 'partial') {
170
+ content.push({ text: `一部だけ読めた${result.reason ? `(${result.reason})` : ''}。読めていない道具は0件とは限りません。`, className: 'bb-world-rail-caveat' });
171
+ }
172
+ if (result.exits.length === 0) {
173
+ content.push({ text: result.status === 'complete' ? 'この事業の道具は登録されていません' : '読めた範囲では道具は0件です' });
174
+ } else {
175
+ const list = el(doc, 'ul', { className: 'bb-world-rail-list bb-world-exit-list' });
176
+ for (const exit of result.exits) {
177
+ const selected = exit.id === selectedId;
178
+ const row = el(doc, 'li', { className: `bb-world-exit is-${exit.state}${selected ? ' is-selected' : ''}`, attrs: { 'data-exit-id': exit.id, 'aria-current': selected ? 'true' : null } });
179
+ const head = el(doc, 'div', { className: 'bb-world-exit-head' });
180
+ head.append(
181
+ el(doc, 'span', { className: 'bb-world-exit-label', text: exit.label }),
182
+ el(doc, 'span', { className: `bb-world-exit-state is-${exit.state}`, text: exitStateLabel(exit.state, vocabulary) }),
183
+ );
184
+ row.append(head);
185
+ if (exit.note) row.append(el(doc, 'small', { className: 'bb-world-rail-note', text: exit.note }));
186
+ if (exit.attention) row.append(el(doc, 'small', { className: 'bb-world-exit-attention', text: attentionText(exit.attention) }));
187
+ const links = el(doc, 'div', { className: 'bb-world-exit-links' });
188
+ links.append(linkTo(doc, exit.href, exit.target, '開く', `${exit.label}を開く`));
189
+ if (exit.action) links.append(linkTo(doc, exit.action.href, exit.action.target, exit.action.label, `${exit.label}:${exit.action.label}`));
190
+ row.append(links);
191
+ list.append(row);
192
+ if (selected) selectedRow = row;
193
+ }
194
+ content.push(list);
195
+ }
196
+ if (result.dropped.exits) content.push({ text: `形式が正しくない道具、または開けないリンク(https:とこの画面の中のリンク以外)の道具${result.dropped.exits}件は描いていません。`, className: 'bb-world-rail-note' });
197
+ if (result.dropped.actions) content.push({ text: `開けないリンクの操作${result.dropped.actions}件は出していません。`, className: 'bb-world-rail-note' });
198
+ content.push({ text: `読み込み:${shortTime(result.read_at) ?? '時点不明'}。状態と件数はホストが渡した値で、世界からは書き換えません。`, className: 'bb-world-rail-note' });
199
+ return { block: workspaceRailBlock(doc, { title, className: 'bb-world-exits', content }), selectedRow };
200
+ }
@@ -563,3 +563,82 @@
563
563
  .bb-world-district-notice[hidden] {
564
564
  display: none;
565
565
  }
566
+
567
+ /* --- the tools a business uses (story-world-business-exits-v1) ----------- */
568
+ .bb-world-exit-list {
569
+ list-style: none;
570
+ padding-left: 0;
571
+ }
572
+
573
+ .bb-world-exit {
574
+ padding: 6px 8px;
575
+ border: 1px solid var(--bb-color-border);
576
+ border-left: 3px solid var(--bb-color-border);
577
+ border-radius: var(--bb-radius-sm);
578
+ }
579
+
580
+ .bb-world-exit.is-available { border-left-color: #c99a3a; }
581
+ .bb-world-exit.is-restricted { border-left-color: #c8323c; }
582
+ .bb-world-exit.is-unknown,
583
+ .bb-world-exit.is-unavailable { border-left-color: #9aa3a8; }
584
+
585
+ .bb-world-exit.is-selected {
586
+ border-color: var(--bb-color-accent);
587
+ box-shadow: 0 0 0 2px rgb(8 125 98 / 18%);
588
+ }
589
+
590
+ .bb-world-exit-head {
591
+ display: flex;
592
+ flex-wrap: wrap;
593
+ align-items: baseline;
594
+ gap: 6px;
595
+ }
596
+
597
+ .bb-world-exit-label {
598
+ font-weight: 600;
599
+ overflow-wrap: anywhere;
600
+ }
601
+
602
+ .bb-world-exit-state {
603
+ font-size: 11px;
604
+ padding: 0 6px;
605
+ border-radius: 999px;
606
+ border: 1px solid var(--bb-color-border);
607
+ color: var(--bb-color-muted);
608
+ }
609
+
610
+ .bb-world-exit-state.is-available { color: #7a5a12; border-color: #e0c27a; }
611
+ .bb-world-exit-state.is-restricted { color: #a1192a; border-color: #e3a1a7; }
612
+
613
+ .bb-world-exit-attention {
614
+ display: block;
615
+ margin-top: 2px;
616
+ font-size: 12px;
617
+ color: var(--bb-color-ink);
618
+ }
619
+
620
+ .bb-world-exit-links {
621
+ display: flex;
622
+ flex-wrap: wrap;
623
+ gap: 10px;
624
+ }
625
+
626
+ .bb-world-exits-unreadable {
627
+ display: block;
628
+ color: var(--bb-color-warning);
629
+ font-weight: 600;
630
+ }
631
+
632
+ .bb-world-fallback-exits {
633
+ margin: 4px 0 10px;
634
+ }
635
+
636
+ .bb-world-label.is-exit {
637
+ font-size: 11px;
638
+ font-weight: 600;
639
+ }
640
+
641
+ .bb-world-label.is-exit.is-available { border-color: #e0c27a; }
642
+ .bb-world-label.is-exit.is-restricted { border-color: #e3a1a7; color: #a1192a; }
643
+ .bb-world-label.is-exit.is-unknown,
644
+ .bb-world-label.is-exit.is-unavailable { color: var(--bb-color-muted); }