@unson/brainbase-mcp 0.24.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/meeting-source-connection.d.ts +265 -0
- package/dist/meeting-source-connection.js +702 -0
- package/dist/meeting-source-reader.d.ts +18 -0
- package/dist/meeting-source-reader.js +31 -0
- package/dist/world-extension.d.ts +12 -0
- package/dist/world-extension.js +24 -3
- package/package.json +6 -2
- package/ui/world/world-canvas-ui.js +3 -2
- package/ui/world/world-district.js +135 -8
- package/ui/world/world-exits.js +200 -0
- package/ui/world/world-view.css +79 -0
- package/ui/world/world-view.js +117 -15
|
@@ -123,3 +123,21 @@ export declare function readProviderSummary(input: {
|
|
|
123
123
|
meetingId: string;
|
|
124
124
|
now?: () => Date;
|
|
125
125
|
}): Promise<MeetingSourceSummaryResult>;
|
|
126
|
+
export type MeetingSourceTranscriptProbe = {
|
|
127
|
+
status: 'readable';
|
|
128
|
+
segmentCount: number;
|
|
129
|
+
} | {
|
|
130
|
+
status: 'unavailable';
|
|
131
|
+
failure: MeetingSourceFailure;
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Reads only the first transcript page, to check that the connection may read
|
|
135
|
+
* full transcripts. A Tactiq probe spends one read of the hourly budget.
|
|
136
|
+
*/
|
|
137
|
+
export declare function probeTranscript(input: {
|
|
138
|
+
provider: MeetingSourceProvider;
|
|
139
|
+
callTool: MeetingSourceCallTool;
|
|
140
|
+
meetingId: string;
|
|
141
|
+
budget?: TactiqTranscriptBudget;
|
|
142
|
+
now?: () => Date;
|
|
143
|
+
}): Promise<MeetingSourceTranscriptProbe>;
|
|
@@ -111,6 +111,37 @@ export async function readProviderSummary(input) {
|
|
|
111
111
|
return { status: 'unavailable', failure: await classifyFailure(error, input.provider, input.callTool, 'meeting', now()) };
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Reads only the first transcript page, to check that the connection may read
|
|
116
|
+
* full transcripts. A Tactiq probe spends one read of the hourly budget.
|
|
117
|
+
*/
|
|
118
|
+
export async function probeTranscript(input) {
|
|
119
|
+
const now = input.now ?? (() => new Date());
|
|
120
|
+
if (input.provider === 'tactiq') {
|
|
121
|
+
const slot = (input.budget ?? createTactiqTranscriptBudget()).tryAcquire(input.meetingId, now());
|
|
122
|
+
if (!slot.ok) {
|
|
123
|
+
return {
|
|
124
|
+
status: 'unavailable',
|
|
125
|
+
failure: { reason: 'rate_limited', scope: 'connection', message: 'Tactiq transcript read limit reached for this hour', retryAt: slot.retryAt },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
if (input.provider === 'plaud') {
|
|
131
|
+
const payload = asRecord(await invoke(input.callTool, 'get_transcript', { file_id: input.meetingId, block: 'transaction', limit: 10 }));
|
|
132
|
+
if (!payload || !Array.isArray(payload.segments))
|
|
133
|
+
throw new UnexpectedShapeError('Plaud get_transcript returned no segments');
|
|
134
|
+
return { status: 'readable', segmentCount: payload.segments.length };
|
|
135
|
+
}
|
|
136
|
+
const payload = asRecord(await invoke(input.callTool, 'get_transcript', { meetingId: input.meetingId, page: 1 }));
|
|
137
|
+
if (!payload || !Array.isArray(payload.entries))
|
|
138
|
+
throw new UnexpectedShapeError('Tactiq get_transcript returned no entries');
|
|
139
|
+
return { status: 'readable', segmentCount: payload.entries.length };
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
return { status: 'unavailable', failure: await classifyFailure(error, input.provider, input.callTool, 'meeting', now()) };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
114
145
|
// ---------------------------------------------------------------------------
|
|
115
146
|
// Plaud
|
|
116
147
|
async function listPlaud(callTool, since, until, utcOffsetMinutes, pageSize) {
|
|
@@ -35,7 +35,14 @@ export interface WorldVocabularyConfig {
|
|
|
35
35
|
readonly label?: string;
|
|
36
36
|
readonly phase?: WorldLifecyclePhase;
|
|
37
37
|
}>>;
|
|
38
|
+
/** The owner's words for the states of a business's tools (story-world-business-exits-v1 AC-04). */
|
|
39
|
+
readonly exit_states?: Readonly<Partial<Record<WorldExitState, {
|
|
40
|
+
readonly label?: string;
|
|
41
|
+
}>>>;
|
|
38
42
|
}
|
|
43
|
+
declare const EXIT_STATES: readonly ["available", "restricted", "unknown", "unavailable"];
|
|
44
|
+
/** The state a host gives a tool of a business (`businessExits` in the world view). */
|
|
45
|
+
export type WorldExitState = (typeof EXIT_STATES)[number];
|
|
39
46
|
export interface WorldVocabulary {
|
|
40
47
|
readonly kinds: readonly {
|
|
41
48
|
readonly key: string | null;
|
|
@@ -54,6 +61,11 @@ export interface WorldVocabulary {
|
|
|
54
61
|
readonly label: string;
|
|
55
62
|
readonly phase: WorldLifecyclePhase;
|
|
56
63
|
}[];
|
|
64
|
+
/** The words for the four tool states, the owner's over the defaults, in a fixed order. */
|
|
65
|
+
readonly exit_states: readonly {
|
|
66
|
+
readonly key: WorldExitState;
|
|
67
|
+
readonly label: string;
|
|
68
|
+
}[];
|
|
57
69
|
}
|
|
58
70
|
export interface WorldEngagement {
|
|
59
71
|
readonly id: string;
|
package/dist/world-extension.js
CHANGED
|
@@ -20,6 +20,7 @@ export const WORLD_EXTENSION_ID = 'world';
|
|
|
20
20
|
export const WORLD_EXTENSION_VERSION = 'world-extension.v1';
|
|
21
21
|
const LIFECYCLE_PHASES = ['active', 'maintenance', 'finished', 'concept'];
|
|
22
22
|
const BUILDING_FORMS = ['tower', 'hall', 'dome', 'office'];
|
|
23
|
+
const EXIT_STATES = ['available', 'restricted', 'unknown', 'unavailable'];
|
|
23
24
|
/** Lifecycle words common to any Graph; a host vocabulary may relabel or add to them. */
|
|
24
25
|
const DEFAULT_STATUSES = Object.freeze({
|
|
25
26
|
active: { label: '進行中', phase: 'active' },
|
|
@@ -29,6 +30,13 @@ const DEFAULT_STATUSES = Object.freeze({
|
|
|
29
30
|
archived: { label: '保管', phase: 'finished' },
|
|
30
31
|
concept: { label: '構想', phase: 'concept' },
|
|
31
32
|
});
|
|
33
|
+
/** The words for the states of a business's tools when the owner gives none. */
|
|
34
|
+
const DEFAULT_EXIT_STATES = Object.freeze({
|
|
35
|
+
available: '使える',
|
|
36
|
+
restricted: '権限が必要',
|
|
37
|
+
unknown: '未確認',
|
|
38
|
+
unavailable: '読めない',
|
|
39
|
+
});
|
|
32
40
|
const KIND_PALETTE = ['#1261ad', '#b07a1f', '#35684c', '#6b4fa0', '#a8433a', '#2f7d86', '#7a6a2b', '#5b6470'];
|
|
33
41
|
const UNCLASSIFIED_COLOR = '#69746d';
|
|
34
42
|
const HEX_COLOR = /^#[0-9a-f]{6}$/iu;
|
|
@@ -197,7 +205,8 @@ export function resolveWorldVocabulary(config, businesses, terms = undefined) {
|
|
|
197
205
|
phase: entry.phase && LIFECYCLE_PHASES.includes(entry.phase) ? entry.phase : base?.phase ?? 'active',
|
|
198
206
|
});
|
|
199
207
|
}
|
|
200
|
-
|
|
208
|
+
const exitStates = EXIT_STATES.map((key) => ({ key, label: text(config?.exit_states?.[key]?.label) ?? DEFAULT_EXIT_STATES[key] }));
|
|
209
|
+
return { kinds, statuses: [...statuses].map(([key, entry]) => ({ key, ...entry })), exit_states: exitStates, terms: terms === undefined ? 'none' : terms === null ? 'unavailable' : 'read' };
|
|
201
210
|
}
|
|
202
211
|
/** Reads and checks a vocabulary file; an invalid file is an error, never silently ignored. */
|
|
203
212
|
export async function readWorldVocabulary(path) {
|
|
@@ -222,7 +231,19 @@ export async function readWorldVocabulary(path) {
|
|
|
222
231
|
throw new Error(`world vocabulary status ${key} has an unknown phase`);
|
|
223
232
|
statuses[key] = { ...(text(entry.label) ? { label: text(entry.label) } : {}), ...(entry.phase ? { phase: entry.phase } : {}) };
|
|
224
233
|
}
|
|
225
|
-
|
|
234
|
+
if (parsed.exit_states === undefined)
|
|
235
|
+
return { kinds, statuses };
|
|
236
|
+
if (!isRecord(parsed.exit_states))
|
|
237
|
+
throw new Error('world vocabulary exit_states must be an object');
|
|
238
|
+
const exitStates = {};
|
|
239
|
+
for (const [key, entry] of Object.entries(parsed.exit_states)) {
|
|
240
|
+
if (!EXIT_STATES.includes(key))
|
|
241
|
+
throw new Error(`world vocabulary has an unknown exit state ${key}`);
|
|
242
|
+
if (!isRecord(entry))
|
|
243
|
+
throw new Error(`world vocabulary exit state ${key} must be an object`);
|
|
244
|
+
exitStates[key] = text(entry.label) ? { label: text(entry.label) } : {};
|
|
245
|
+
}
|
|
246
|
+
return { kinds, statuses, exit_states: exitStates };
|
|
226
247
|
}
|
|
227
248
|
/**
|
|
228
249
|
* The businesses from the same Graph the other screens read (this Mac's Graph).
|
|
@@ -347,7 +368,7 @@ export function createWorldExtension(options = {}) {
|
|
|
347
368
|
return {
|
|
348
369
|
id: WORLD_EXTENSION_ID,
|
|
349
370
|
uiDir: fileURLToPath(new URL('../ui/world/', import.meta.url)),
|
|
350
|
-
uiFiles: ['world-view.js', 'world-view.css', 'world-canvas-ui.js', 'world-canvas-ui.css', 'world-canvas-notes.js', 'world-vendor.js', 'world-placement.js', 'world-work-rail.js', 'world-district.js', 'world-scenery.js'],
|
|
371
|
+
uiFiles: ['world-view.js', 'world-view.css', 'world-canvas-ui.js', 'world-canvas-ui.css', 'world-canvas-notes.js', 'world-vendor.js', 'world-placement.js', 'world-work-rail.js', 'world-district.js', 'world-scenery.js', 'world-exits.js'],
|
|
351
372
|
screenEntry: 'world-view.js',
|
|
352
373
|
// P17: the world sits above the home and opens first.
|
|
353
374
|
navPosition: 'first',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unson/brainbase-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
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",
|
|
@@ -135,6 +135,10 @@
|
|
|
135
135
|
"types": "./dist/meeting-minutes-lineage-adapters.d.ts",
|
|
136
136
|
"import": "./dist/meeting-minutes-lineage-adapters.js"
|
|
137
137
|
},
|
|
138
|
+
"./meeting-source-connection": {
|
|
139
|
+
"types": "./dist/meeting-source-connection.d.ts",
|
|
140
|
+
"import": "./dist/meeting-source-connection.js"
|
|
141
|
+
},
|
|
138
142
|
"./meeting-source-reader": {
|
|
139
143
|
"types": "./dist/meeting-source-reader.d.ts",
|
|
140
144
|
"import": "./dist/meeting-source-reader.js"
|
|
@@ -427,5 +431,5 @@
|
|
|
427
431
|
"engines": {
|
|
428
432
|
"node": ">=20"
|
|
429
433
|
},
|
|
430
|
-
"gitHead": "
|
|
434
|
+
"gitHead": "feb040b1ff7395da03e5775429af9b91d8c2c8b9"
|
|
431
435
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
/**
|
|
977
|
-
|
|
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
|
+
}
|