@unson/brainbase-mcp 0.25.0 → 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/world-extension.d.ts +12 -0
- package/dist/world-extension.js +24 -3
- package/package.json +2 -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
|
@@ -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",
|
|
@@ -431,5 +431,5 @@
|
|
|
431
431
|
"engines": {
|
|
432
432
|
"node": ">=20"
|
|
433
433
|
},
|
|
434
|
-
"gitHead": "
|
|
434
|
+
"gitHead": "feb040b1ff7395da03e5775429af9b91d8c2c8b9"
|
|
435
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
|
+
}
|
package/ui/world/world-view.css
CHANGED
|
@@ -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); }
|
package/ui/world/world-view.js
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
* review date passed); a dashed ring says the text names a person the assignee
|
|
21
21
|
* field does not. Above the cities, a sign counts the open work with such a
|
|
22
22
|
* gap, and a grey sign says the work could not be read (never zero).
|
|
23
|
+
*
|
|
24
|
+
* A host may also give the tools a business uses (`businessExits`, story-world-business-exits-v1):
|
|
25
|
+
* they are listed in the city details and stand as stations outside the district's gate. The world
|
|
26
|
+
* only links out to them; it never changes them.
|
|
23
27
|
*/
|
|
24
28
|
|
|
25
29
|
import { THREE, MapControls } from './world-vendor.js';
|
|
@@ -29,6 +33,7 @@ import { createWorldCanvasUI } from './world-canvas-ui.js';
|
|
|
29
33
|
import { workCanvasNotes } from './world-canvas-notes.js';
|
|
30
34
|
import { canvasTexture, drawFacade, FACADE_SIZES, FACADE_UNITS, gableRoof, hashUnit, muted, paintVertices, roundedPlate, valueNoise } from './world-scenery.js';
|
|
31
35
|
import { WORK_STATES, workCityBlocks, workGrowthBlock, workPickBlock, workSign, workSiteBlocks } from './world-work-rail.js';
|
|
36
|
+
import { exitLegend, exitsRailBlock, normalizeBusinessExits } from './world-exits.js';
|
|
32
37
|
import {
|
|
33
38
|
makeWorkspaceElement as el,
|
|
34
39
|
workspacePageHeader,
|
|
@@ -160,12 +165,19 @@ function makeLabel(doc, text, className) {
|
|
|
160
165
|
return label;
|
|
161
166
|
}
|
|
162
167
|
|
|
163
|
-
|
|
168
|
+
/**
|
|
169
|
+
* 'ok' when WebGL runs on a GPU, 'slow' when the browser would only draw it in software (building the
|
|
170
|
+
* scene then freezes the page for tens of seconds), 'none' when there is no WebGL at all.
|
|
171
|
+
*/
|
|
172
|
+
function webglSupport(doc) {
|
|
164
173
|
try {
|
|
174
|
+
const strict = { failIfMajorPerformanceCaveat: true };
|
|
165
175
|
const canvas = doc.createElement('canvas');
|
|
166
|
-
|
|
176
|
+
if (canvas.getContext('webgl2', strict) || canvas.getContext('webgl', strict)) return 'ok';
|
|
177
|
+
const fallback = doc.createElement('canvas');
|
|
178
|
+
return fallback.getContext('webgl2') || fallback.getContext('webgl') ? 'slow' : 'none';
|
|
167
179
|
} catch {
|
|
168
|
-
return
|
|
180
|
+
return 'none';
|
|
169
181
|
}
|
|
170
182
|
}
|
|
171
183
|
|
|
@@ -1380,12 +1392,15 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear,
|
|
|
1380
1392
|
};
|
|
1381
1393
|
}
|
|
1382
1394
|
|
|
1383
|
-
|
|
1395
|
+
/** `exitSlot(business)` gives the node to show under a business for its tools, when the host gives them. */
|
|
1396
|
+
function renderFallbackList(doc, root, businesses, rows, exitSlot = null) {
|
|
1384
1397
|
const list = el(doc, 'div', { className: 'bb-world-fallback' });
|
|
1385
1398
|
list.append(el(doc, 'h2', { text: '事業' }));
|
|
1386
1399
|
const ul = el(doc, 'ul');
|
|
1387
1400
|
for (const business of businesses) {
|
|
1388
|
-
|
|
1401
|
+
const item = el(doc, 'li', { text: `${business.name}(${kindEntry(business.kind).label}・案件${business.engagements.length}件)` });
|
|
1402
|
+
if (exitSlot) item.append(exitSlot(business));
|
|
1403
|
+
ul.append(item);
|
|
1389
1404
|
}
|
|
1390
1405
|
list.append(ul, el(doc, 'h2', { text: '判断の種類' }));
|
|
1391
1406
|
const ol = el(doc, 'ul');
|
|
@@ -1437,8 +1452,16 @@ function addressSelection() {
|
|
|
1437
1452
|
* host's task screen (to check or correct it). Without one the rail says it cannot be corrected here.
|
|
1438
1453
|
* @param {'standard' | 'canvas'} [options.presentation] Canvas owns its accessible overlays and ignores the host rail.
|
|
1439
1454
|
* @param {{ read(): { business: string | null, site: string | null }, write(selection): void }} [options.selection]
|
|
1455
|
+
* @param {(business: object) => (BusinessExitsAnswer | Promise<BusinessExitsAnswer>)} [options.businessExits] The tools
|
|
1456
|
+
* a business uses (story-world-business-exits-v1). The answer is
|
|
1457
|
+
* `{ status: 'complete'|'partial'|'failed'|'not_connected', read_at?, reason?, exits: [{ id, label, href,
|
|
1458
|
+
* state: 'available'|'restricted'|'unknown'|'unavailable', note?, action?: { label, href }, attention?: { count?,
|
|
1459
|
+
* label?, as_of? } }] }`. `href` and `action.href` must be `https:` or a link inside the host (`/…`, `?…`);
|
|
1460
|
+
* others are dropped and counted. Without it the world is drawn exactly as before.
|
|
1461
|
+
* @typedef {{ status: string, read_at?: string, reason?: string, exits: object[] }} BusinessExitsAnswer
|
|
1440
1462
|
*/
|
|
1441
|
-
export function createWorldView({ root, rail, page, presentation = 'standard', document: explicitDocument, fetcher, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}`, taskHref = null, selection = addressSelection() }) {
|
|
1463
|
+
export function createWorldView({ root, rail, page, presentation = 'standard', document: explicitDocument, fetcher, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}`, taskHref = null, selection = addressSelection(), businessExits = null }) {
|
|
1464
|
+
const exitsEnabled = typeof businessExits === 'function';
|
|
1442
1465
|
const doc = explicitDocument ?? globalThis.document;
|
|
1443
1466
|
const reducedMotion = Boolean(globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
|
|
1444
1467
|
const canvasMode = presentation === 'canvas';
|
|
@@ -1503,6 +1526,10 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1503
1526
|
el(doc, 'span', { className: 'bb-world-chip is-ring', text: '破線の輪=本文に人物(担当欄は未接続)' }),
|
|
1504
1527
|
el(doc, 'span', { className: 'bb-world-chip is-road', text: '実線=記録された関係・破線=推定' }),
|
|
1505
1528
|
);
|
|
1529
|
+
// The stations' legend, in the owner's words once the vocabulary is read (AC-08).
|
|
1530
|
+
const exitLegendList = el(doc, 'ul', { className: 'bb-world-exit-legend', attrs: { 'aria-label': '駅の見方' } });
|
|
1531
|
+
const renderExitLegend = () => exitLegendList.replaceChildren(...exitLegend(worldVocabulary).map(([className, text]) => el(doc, 'li', { className, text })));
|
|
1532
|
+
if (exitsEnabled) renderExitLegend();
|
|
1506
1533
|
const stage = el(doc, 'div', { className: 'bb-world-stage' });
|
|
1507
1534
|
const labelsLayer = el(doc, 'div', { className: 'bb-world-labels' });
|
|
1508
1535
|
stage.append(labelsLayer);
|
|
@@ -1530,6 +1557,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1530
1557
|
const list = el(doc, 'ul');
|
|
1531
1558
|
for (const [, text] of DISTRICT_LEGEND) list.append(el(doc, 'li', { text }));
|
|
1532
1559
|
help.append(list);
|
|
1560
|
+
if (exitsEnabled) help.append(exitLegendList);
|
|
1533
1561
|
if (page?.source) help.append(el(doc, 'p', { text: page.source }));
|
|
1534
1562
|
canvasUI.setHelp([help]);
|
|
1535
1563
|
} else {
|
|
@@ -1656,7 +1684,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1656
1684
|
let workConnected = null;
|
|
1657
1685
|
const workCache = new Map();
|
|
1658
1686
|
const workRequests = new Map();
|
|
1659
|
-
const current = { code: null, siteId: null, level: 'world', focus: null, pendingSite: null, detachedSiteId: null };
|
|
1687
|
+
const current = { code: null, siteId: null, level: 'world', focus: null, pendingSite: null, detachedSiteId: null, exitId: null };
|
|
1660
1688
|
|
|
1661
1689
|
/** Inside a city the notices fold away (they would cover it); its rail carries the sources and times. */
|
|
1662
1690
|
function setLevel(level) {
|
|
@@ -1677,6 +1705,58 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1677
1705
|
return knownBusinesses.find((business) => business.code === code) ?? null;
|
|
1678
1706
|
}
|
|
1679
1707
|
|
|
1708
|
+
// --- the tools a business uses (story-world-business-exits-v1) ----------------------------------
|
|
1709
|
+
/** Normalized answers by business code; a read in flight is kept so it is asked only once. */
|
|
1710
|
+
const exitsCache = new Map();
|
|
1711
|
+
const exitsRequests = new Map();
|
|
1712
|
+
/** Under each business in the list shown without 3D (AC-09). */
|
|
1713
|
+
const fallbackExitSlots = new Map();
|
|
1714
|
+
/** The stations of a business for the district: its read tools, or none until read. */
|
|
1715
|
+
const stationsFor = (code) => (exitsEnabled ? exitsCache.get(code)?.exits ?? [] : undefined);
|
|
1716
|
+
|
|
1717
|
+
function fallbackExitSlot(business) {
|
|
1718
|
+
const slot = el(doc, 'div', { className: 'bb-world-fallback-exits' });
|
|
1719
|
+
fallbackExitSlots.set(business.code, slot);
|
|
1720
|
+
slot.append(exitsRailBlock(doc, { result: exitsCache.get(business.code) ?? null, vocabulary: worldVocabulary }).block);
|
|
1721
|
+
return slot;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function loadExits(business) {
|
|
1725
|
+
if (!exitsEnabled || disposed) return;
|
|
1726
|
+
const { code } = business;
|
|
1727
|
+
if (exitsCache.has(code) || exitsRequests.has(code)) return;
|
|
1728
|
+
const request = (async () => {
|
|
1729
|
+
try {
|
|
1730
|
+
return normalizeBusinessExits(await businessExits(business));
|
|
1731
|
+
} catch (error) {
|
|
1732
|
+
return { ...normalizeBusinessExits({ status: 'failed', exits: [] }), reason: error instanceof Error ? error.message : 'request_failed' };
|
|
1733
|
+
}
|
|
1734
|
+
})();
|
|
1735
|
+
exitsRequests.set(code, request);
|
|
1736
|
+
void request.then((result) => {
|
|
1737
|
+
if (disposed || exitsRequests.get(code) !== request) return;
|
|
1738
|
+
exitsRequests.delete(code);
|
|
1739
|
+
exitsCache.set(code, result);
|
|
1740
|
+
fallbackExitSlots.get(code)?.replaceChildren(exitsRailBlock(doc, { result, vocabulary: worldVocabulary }).block);
|
|
1741
|
+
// AC-10: an answer for a city that is no longer open does not reopen it.
|
|
1742
|
+
if (current.code !== code) return;
|
|
1743
|
+
if (districtCode === code && district?.isOpen()) district.setExits(result.exits);
|
|
1744
|
+
if (canvasMode || current.level !== 'site') renderCityRail(business);
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/** A picked station: the city details open with that tool's row selected; nothing leaves the world. */
|
|
1749
|
+
function selectExit(business, exitId) {
|
|
1750
|
+
current.exitId = exitId;
|
|
1751
|
+
if (current.level === 'site') {
|
|
1752
|
+
canvasUI?.closeTask();
|
|
1753
|
+
setCurrent(business.code, null);
|
|
1754
|
+
setLevel('city');
|
|
1755
|
+
}
|
|
1756
|
+
renderCityRail(business, { revealExit: true });
|
|
1757
|
+
canvasUI?.openRail();
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1680
1760
|
/** The registration, engagements, link out and judgments of a city (as before the work layer). */
|
|
1681
1761
|
function cityRegistrationBlocks(business) {
|
|
1682
1762
|
const blocks = [];
|
|
@@ -1730,7 +1810,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1730
1810
|
}
|
|
1731
1811
|
}
|
|
1732
1812
|
|
|
1733
|
-
function renderCityRail(business, { revealFocus = false } = {}) {
|
|
1813
|
+
function renderCityRail(business, { revealFocus = false, revealExit = false } = {}) {
|
|
1734
1814
|
const blocks = [workspaceRailHead(doc, { kicker: `事業・${kindEntry(business.kind).label}`, title: business.name, lead: business.purpose ?? undefined })];
|
|
1735
1815
|
let focusBlock = null;
|
|
1736
1816
|
const work = workCache.get(business.code);
|
|
@@ -1762,8 +1842,15 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1762
1842
|
blocks.push(directory);
|
|
1763
1843
|
}
|
|
1764
1844
|
blocks.push(...cityRegistrationBlocks(business));
|
|
1845
|
+
let exitRow = null;
|
|
1846
|
+
if (exitsEnabled) {
|
|
1847
|
+
const tools = exitsRailBlock(doc, { result: exitsCache.get(business.code) ?? null, vocabulary: worldVocabulary, selectedId: current.exitId });
|
|
1848
|
+
blocks.push(tools.block);
|
|
1849
|
+
exitRow = tools.selectedRow;
|
|
1850
|
+
}
|
|
1765
1851
|
showRail(blocks);
|
|
1766
1852
|
if (revealFocus) reveal(focusBlock ?? blocks[0]);
|
|
1853
|
+
if (revealExit && exitRow) reveal(exitRow);
|
|
1767
1854
|
}
|
|
1768
1855
|
|
|
1769
1856
|
/** The open tasks a focus picks: a kind of gap, or a recorded state; null for all. */
|
|
@@ -1831,7 +1918,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1831
1918
|
updateDistrictNotes(work);
|
|
1832
1919
|
scene?.showWork(code, work);
|
|
1833
1920
|
if (districtCode === code && district?.isOpen()) {
|
|
1834
|
-
district.refresh(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
|
|
1921
|
+
district.refresh(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])), exits: stationsFor(business.code) });
|
|
1835
1922
|
const growth = noteGrowth(code, work, previous);
|
|
1836
1923
|
district.celebrate(growth?.changes ?? null);
|
|
1837
1924
|
district.focus(focusIdsFor(work, current.focus));
|
|
@@ -1875,12 +1962,19 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1875
1962
|
if (anchor.taskId === current.siteId && current.detachedSiteId !== current.siteId) canvasUI?.setTaskAnchor(anchor);
|
|
1876
1963
|
else if (!anchor.visible && !current.siteId) canvasUI?.setTaskAnchor({ visible: false });
|
|
1877
1964
|
},
|
|
1965
|
+
...(exitsEnabled ? {
|
|
1966
|
+
exitLegend: exitLegend(worldVocabulary),
|
|
1967
|
+
onPickExit: (exitId) => {
|
|
1968
|
+
const owner = businessOf(current.code);
|
|
1969
|
+
if (owner) selectExit(owner, exitId);
|
|
1970
|
+
},
|
|
1971
|
+
} : {}),
|
|
1878
1972
|
});
|
|
1879
1973
|
district.setSky(SKY_MODES[skyIndex][0]);
|
|
1880
1974
|
scene.setPaused(true);
|
|
1881
1975
|
if (!canvasMode) { legend.hidden = true; workLegend.hidden = true; }
|
|
1882
1976
|
if (districtCode !== business.code || !district.isOpen()) {
|
|
1883
|
-
district.show(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
|
|
1977
|
+
district.show(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])), exits: stationsFor(business.code) });
|
|
1884
1978
|
districtCode = business.code;
|
|
1885
1979
|
const growth = growthFor(business.code) ?? noteGrowth(business.code, work, readSnapshot(business.code));
|
|
1886
1980
|
district.celebrate(growth?.changes ?? null);
|
|
@@ -1911,7 +2005,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1911
2005
|
scene?.showWork(business.code, work);
|
|
1912
2006
|
if (current.level !== 'world') {
|
|
1913
2007
|
if (districtCode === business.code && district?.isOpen()) {
|
|
1914
|
-
district.refresh(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
|
|
2008
|
+
district.refresh(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])), exits: stationsFor(business.code) });
|
|
1915
2009
|
district.focus(focusIdsFor(work, current.focus));
|
|
1916
2010
|
} else enterDistrict(business, work);
|
|
1917
2011
|
}
|
|
@@ -1931,6 +2025,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1931
2025
|
current.pendingSite = null;
|
|
1932
2026
|
if (current.code !== business.code) {
|
|
1933
2027
|
current.focus = null;
|
|
2028
|
+
current.exitId = null;
|
|
1934
2029
|
leaveDistrict();
|
|
1935
2030
|
}
|
|
1936
2031
|
setCurrent(business.code, null);
|
|
@@ -1949,6 +2044,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1949
2044
|
}
|
|
1950
2045
|
}
|
|
1951
2046
|
renderCityRail(business);
|
|
2047
|
+
loadExits(business);
|
|
1952
2048
|
if (!work) void loadWork(business);
|
|
1953
2049
|
}
|
|
1954
2050
|
|
|
@@ -2020,6 +2116,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
2020
2116
|
setCurrent(null, null);
|
|
2021
2117
|
setLevel('world');
|
|
2022
2118
|
current.focus = null;
|
|
2119
|
+
current.exitId = null;
|
|
2023
2120
|
showEmptyRail();
|
|
2024
2121
|
}
|
|
2025
2122
|
|
|
@@ -2240,6 +2337,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
2240
2337
|
const businessPayload = businessesResult.ok ? businessesResult.data : null;
|
|
2241
2338
|
const businesses = businessPayload?.status === 'ok' ? businessPayload.businesses : [];
|
|
2242
2339
|
if (businessPayload?.status === 'ok' && businessPayload.vocabulary) worldVocabulary = businessPayload.vocabulary;
|
|
2340
|
+
if (exitsEnabled) renderExitLegend();
|
|
2243
2341
|
if (!businessesResult.ok || businessPayload?.status !== 'ok') {
|
|
2244
2342
|
const reason = businessesResult.ok ? `${BUSINESS_STATE_TEXT[businessPayload?.status] ?? '状態不明'}(${businessPayload?.reason ?? '理由不明'})` : `取得に失敗しました(${businessesResult.error})`;
|
|
2245
2343
|
note('事業', `${reason}。事業の件数は未確認です。0件とは確認できません。`, 'warning', '事業:読めない(0件とは確認できません)');
|
|
@@ -2284,17 +2382,21 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
2284
2382
|
|
|
2285
2383
|
knownBusinesses = businesses;
|
|
2286
2384
|
canvasUI?.setCities(businesses, current.code);
|
|
2287
|
-
|
|
2288
|
-
|
|
2385
|
+
const webgl = webglSupport(doc);
|
|
2386
|
+
if (webgl !== 'ok') {
|
|
2387
|
+
if (webgl === 'slow') note('表示', 'この環境ではGPUを使えず、3Dの描画が非常に遅いため、一覧で出しています。', 'warning', '3D表示が遅い環境・一覧表示');
|
|
2388
|
+
else note('表示', 'この環境では3Dを表示できないため、一覧で出しています。', 'warning', '3D表示を利用できません・一覧表示');
|
|
2389
|
+
const exitSlot = exitsEnabled ? fallbackExitSlot : null;
|
|
2289
2390
|
if (canvasMode) {
|
|
2290
|
-
renderFallbackList(doc, stage, businesses, rows);
|
|
2391
|
+
renderFallbackList(doc, stage, businesses, rows, exitSlot);
|
|
2291
2392
|
showEmptyRail();
|
|
2292
2393
|
restoreSelection();
|
|
2293
2394
|
void loadWorkSummary(note);
|
|
2294
2395
|
} else {
|
|
2295
2396
|
stage.hidden = true;
|
|
2296
|
-
renderFallbackList(doc, wrap, businesses, rows);
|
|
2397
|
+
renderFallbackList(doc, wrap, businesses, rows, exitSlot);
|
|
2297
2398
|
}
|
|
2399
|
+
for (const business of exitsEnabled ? businesses : []) loadExits(business);
|
|
2298
2400
|
return;
|
|
2299
2401
|
}
|
|
2300
2402
|
scene = createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear: clearAll, onEscape: goUp });
|