@agent-os-lab/agent-game-sdk 0.1.8 → 0.1.10
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/README.md +32 -0
- package/USAGE.md +46 -2
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/office/core/projection.ts +2 -11
- package/src/office/core/types.ts +18 -0
- package/src/office/layout/config.ts +234 -25
- package/src/office/layout/index.ts +1 -0
- package/src/office/layout/navigation.ts +168 -0
- package/src/office/layout/resolver.ts +644 -13
- package/src/office/mount.ts +20 -11
- package/src/office/react/AgentGameOfficeView.ts +20 -4
- package/src/office/renderers/three/agent-body-instancing.ts +16 -8
- package/src/office/renderers/three/agent-effect-instancing.ts +26 -12
- package/src/office/renderers/three/agent-route.ts +220 -0
- package/src/office/renderers/three/mount.ts +108 -21
- package/src/office/renderers/three/scene.ts +652 -18
- package/src/runtime-agent-list.ts +101 -0
package/README.md
CHANGED
|
@@ -78,6 +78,38 @@ export function Office() {
|
|
|
78
78
|
|
|
79
79
|
`agent-game-sdk/office` is renderer and framework neutral. `agent-game-sdk/office/react` is the React-only entrypoint.
|
|
80
80
|
|
|
81
|
+
## Runtime Agent List
|
|
82
|
+
|
|
83
|
+
Use `subscribeAgentPresenceList` when an app needs the live Agent roster and status outside the 3D office view:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import {
|
|
87
|
+
AgentGameRuntimeBrowserClient,
|
|
88
|
+
formatAgentPresenceStatus,
|
|
89
|
+
subscribeAgentPresenceList,
|
|
90
|
+
} from "@agent-os-lab/agent-game-sdk";
|
|
91
|
+
|
|
92
|
+
const client = new AgentGameRuntimeBrowserClient({
|
|
93
|
+
baseUrl: "",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const subscription = await subscribeAgentPresenceList(client, {
|
|
97
|
+
onAgentsChange: (agents) => {
|
|
98
|
+
renderAgentList(agents.map((agent) => ({
|
|
99
|
+
id: agent.agentId,
|
|
100
|
+
name: agent.displayName,
|
|
101
|
+
status: formatAgentPresenceStatus(agent.status),
|
|
102
|
+
activity: agent.activity?.summary,
|
|
103
|
+
})));
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const currentAgents = subscription.getAgents();
|
|
108
|
+
subscription.close();
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The helper applies runtime `snapshot` messages as the full roster and `patch` messages as incremental updates, so consumers always receive a complete sorted Agent list.
|
|
112
|
+
|
|
81
113
|
The built-in 3D renderer supports semantic room configuration. If `office` is omitted, the SDK renders the default `office + auditorium + gym` layout. To choose visible rooms, pass `office.rooms` in the order you want them assembled:
|
|
82
114
|
|
|
83
115
|
```ts
|
package/USAGE.md
CHANGED
|
@@ -32,6 +32,7 @@ Runtime requirements:
|
|
|
32
32
|
import {
|
|
33
33
|
AgentGameRuntimeBrowserClient,
|
|
34
34
|
AgentGameRuntimeServerClient,
|
|
35
|
+
subscribeAgentPresenceList,
|
|
35
36
|
} from "@agent-os-lab/agent-game-sdk";
|
|
36
37
|
import { mountAgentGameOffice } from "@agent-os-lab/agent-game-sdk/office";
|
|
37
38
|
import { AgentGameOfficeView } from "@agent-os-lab/agent-game-sdk/office/react";
|
|
@@ -91,7 +92,50 @@ The browser client strips caller-provided `authorization`, `x-hermes-tenant-id`,
|
|
|
91
92
|
|
|
92
93
|
## Runtime Subscription
|
|
93
94
|
|
|
94
|
-
Use `
|
|
95
|
+
Use `subscribeAgentPresenceList` when the application wants the current Agent roster and realtime status outside the office view.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import {
|
|
99
|
+
AgentGameRuntimeBrowserClient,
|
|
100
|
+
formatAgentPresenceStatus,
|
|
101
|
+
subscribeAgentPresenceList,
|
|
102
|
+
type AgentPresence,
|
|
103
|
+
} from "@agent-os-lab/agent-game-sdk";
|
|
104
|
+
|
|
105
|
+
const browserClient = new AgentGameRuntimeBrowserClient({
|
|
106
|
+
baseUrl: "",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
let agents: AgentPresence[] = [];
|
|
110
|
+
|
|
111
|
+
const subscription = await subscribeAgentPresenceList(browserClient, {
|
|
112
|
+
onAgentsChange: (nextAgents, message) => {
|
|
113
|
+
agents = nextAgents;
|
|
114
|
+
console.log("agent roster updated from", message.type);
|
|
115
|
+
console.table(agents.map((agent) => ({
|
|
116
|
+
name: agent.displayName,
|
|
117
|
+
status: formatAgentPresenceStatus(agent.status),
|
|
118
|
+
activity: agent.activity?.summary ?? "",
|
|
119
|
+
})));
|
|
120
|
+
},
|
|
121
|
+
onError: (error) => {
|
|
122
|
+
console.error("runtime stream error", error);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const latestAgents = subscription.getAgents();
|
|
127
|
+
subscription.refresh();
|
|
128
|
+
subscription.close();
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`subscribeAgentPresenceList` handles both runtime message shapes:
|
|
132
|
+
|
|
133
|
+
- `snapshot`: replaces the current roster with the full current presence list.
|
|
134
|
+
- `patch`: merges changed Agent presence records into the current roster.
|
|
135
|
+
|
|
136
|
+
The `onAgentsChange` callback receives a complete display-name-sorted `AgentPresence[]` after every snapshot or patch. The returned subscription extends the normal runtime subscription with `getAgents()` for synchronous reads of the latest list.
|
|
137
|
+
|
|
138
|
+
Use `subscribe` when the application needs raw runtime messages instead of the SDK-maintained Agent list.
|
|
95
139
|
|
|
96
140
|
```ts
|
|
97
141
|
const subscription = await browserClient.subscribe({
|
|
@@ -114,7 +158,7 @@ Runtime messages are either:
|
|
|
114
158
|
- `snapshot`: full current presence list for the tenant.
|
|
115
159
|
- `patch`: changed agent presence records.
|
|
116
160
|
|
|
117
|
-
Use `mergeAgentPresence` when maintaining your own
|
|
161
|
+
Use `mergeAgentPresence` only when maintaining your own custom presence reducer from snapshots and patches.
|
|
118
162
|
|
|
119
163
|
```ts
|
|
120
164
|
import { mergeAgentPresence } from "@agent-os-lab/agent-game-sdk";
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -7,16 +7,7 @@ import type {
|
|
|
7
7
|
AgentPresence,
|
|
8
8
|
AgentPresenceStatus,
|
|
9
9
|
} from "./types";
|
|
10
|
-
|
|
11
|
-
const statusLabels = {
|
|
12
|
-
working: "Working",
|
|
13
|
-
thinking: "Thinking",
|
|
14
|
-
meeting: "Meeting",
|
|
15
|
-
resting: "Resting",
|
|
16
|
-
entertaining: "Entertainment",
|
|
17
|
-
idle: "Idle",
|
|
18
|
-
offline: "Offline",
|
|
19
|
-
} satisfies Record<AgentPresenceStatus, string>;
|
|
10
|
+
import { formatAgentPresenceStatus } from "../../runtime-agent-list";
|
|
20
11
|
|
|
21
12
|
const ambientZoneIds = ["lounge", "pantry", "gym", "reading"] as const;
|
|
22
13
|
|
|
@@ -27,7 +18,7 @@ export function mapPresenceToOfficeAgents(
|
|
|
27
18
|
id: agent.agentId,
|
|
28
19
|
name: agent.displayName,
|
|
29
20
|
role: agent.scene ?? null,
|
|
30
|
-
statusLabel:
|
|
21
|
+
statusLabel: formatAgentPresenceStatus(agent.status),
|
|
31
22
|
activity: agent.activity,
|
|
32
23
|
activityLabel: sanitizeActivityLabel(agent.activity, agent.status),
|
|
33
24
|
sceneState: mapStatusToSceneState(agent.status),
|
package/src/office/core/types.ts
CHANGED
|
@@ -88,9 +88,22 @@ export type AgentGameOfficeSourceInput =
|
|
|
88
88
|
export type AgentGameOfficeRendererController = {
|
|
89
89
|
update(snapshot: AgentGameOfficeSnapshot): void;
|
|
90
90
|
focusAgent?(agentId: string | null): void;
|
|
91
|
+
focusFloor?(floorId: string | null): void;
|
|
92
|
+
getFocusedFloor?(): string | null;
|
|
93
|
+
getNavigationErrors?(): AgentNavigationError[];
|
|
91
94
|
destroy(): void;
|
|
92
95
|
};
|
|
93
96
|
|
|
97
|
+
export type AgentNavigationError = {
|
|
98
|
+
agentId: string;
|
|
99
|
+
code: "missing-zone-anchor" | "missing-route" | "stale-location-node";
|
|
100
|
+
message: string;
|
|
101
|
+
zoneId?: AgentGameOfficeZoneId;
|
|
102
|
+
fromNodeId?: string;
|
|
103
|
+
toNodeId?: string;
|
|
104
|
+
floorId?: string;
|
|
105
|
+
};
|
|
106
|
+
|
|
94
107
|
export type AgentGameOfficeCameraState = {
|
|
95
108
|
zoom: number;
|
|
96
109
|
distance: number;
|
|
@@ -114,8 +127,10 @@ export type AgentGameOfficeMountOptions = {
|
|
|
114
127
|
source: AgentGameOfficeSourceInput;
|
|
115
128
|
office?: AgentGameOfficeConfig;
|
|
116
129
|
focusedAgentId?: string | null;
|
|
130
|
+
focusedFloorId?: string | null;
|
|
117
131
|
className?: string;
|
|
118
132
|
onCameraChange?: (state: AgentGameOfficeCameraState) => void;
|
|
133
|
+
onFocusedFloorChange?: (floorId: string | null) => void;
|
|
119
134
|
};
|
|
120
135
|
|
|
121
136
|
export type AgentGameOfficeResolvedOptions = Omit<AgentGameOfficeMountOptions, "renderer"> & {
|
|
@@ -126,6 +141,9 @@ export type AgentGameOfficeResolvedOptions = Omit<AgentGameOfficeMountOptions, "
|
|
|
126
141
|
export type AgentGameOfficeController = {
|
|
127
142
|
updateAgents(agents: AgentPresence[]): void;
|
|
128
143
|
focusAgent(agentId: string | null): void;
|
|
144
|
+
focusFloor(floorId: string | null): void;
|
|
145
|
+
getFocusedFloor(): string | null;
|
|
129
146
|
refreshAgents(): void;
|
|
147
|
+
getNavigationErrors(): AgentNavigationError[];
|
|
130
148
|
destroy(): void;
|
|
131
149
|
};
|
|
@@ -1,75 +1,284 @@
|
|
|
1
|
-
export type AgentGameOfficeRoomType = "office" | "auditorium" | "gym";
|
|
1
|
+
export type AgentGameOfficeRoomType = "office" | "auditorium" | "gym" | "dining" | "cafe" | "skyGarden";
|
|
2
2
|
|
|
3
3
|
export type AgentGameOfficeRoomConfig = {
|
|
4
4
|
type: AgentGameOfficeRoomType;
|
|
5
5
|
capacity?: number;
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
-
export type
|
|
8
|
+
export type AgentGameOfficeConnectorType = "elevator";
|
|
9
|
+
|
|
10
|
+
export type AgentGameOfficeFloorConfig = {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
9
13
|
rooms: AgentGameOfficeRoomConfig[];
|
|
10
14
|
};
|
|
11
15
|
|
|
16
|
+
export type AgentGameOfficeConnectorConfig = {
|
|
17
|
+
id: string;
|
|
18
|
+
type: AgentGameOfficeConnectorType;
|
|
19
|
+
name: string;
|
|
20
|
+
serves: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type AgentGameOfficeConfig = {
|
|
24
|
+
building: {
|
|
25
|
+
floors: AgentGameOfficeFloorConfig[];
|
|
26
|
+
connectors: AgentGameOfficeConnectorConfig[];
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
12
30
|
export type NormalizedOfficeRoomConfig = {
|
|
13
31
|
id: string;
|
|
14
32
|
type: AgentGameOfficeRoomType;
|
|
15
33
|
capacity: number;
|
|
16
34
|
};
|
|
17
35
|
|
|
18
|
-
export type
|
|
36
|
+
export type NormalizedOfficeFloorConfig = {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
level: number;
|
|
19
40
|
rooms: NormalizedOfficeRoomConfig[];
|
|
20
41
|
};
|
|
21
42
|
|
|
43
|
+
export type NormalizedOfficeConnectorConfig = {
|
|
44
|
+
id: string;
|
|
45
|
+
type: AgentGameOfficeConnectorType;
|
|
46
|
+
name: string;
|
|
47
|
+
serves: string[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type NormalizedOfficeConfig = {
|
|
51
|
+
building: {
|
|
52
|
+
floors: NormalizedOfficeFloorConfig[];
|
|
53
|
+
connectors: NormalizedOfficeConnectorConfig[];
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
22
57
|
const DEFAULT_ROOM_CAPACITY = {
|
|
23
58
|
office: 12,
|
|
24
59
|
auditorium: 40,
|
|
25
60
|
gym: 8,
|
|
61
|
+
dining: 16,
|
|
62
|
+
cafe: 12,
|
|
63
|
+
skyGarden: 12,
|
|
26
64
|
} satisfies Record<AgentGameOfficeRoomType, number>;
|
|
27
65
|
|
|
28
66
|
const MAX_ROOM_CAPACITY = {
|
|
29
67
|
office: 48,
|
|
30
68
|
auditorium: 120,
|
|
31
69
|
gym: 24,
|
|
70
|
+
dining: 48,
|
|
71
|
+
cafe: 32,
|
|
72
|
+
skyGarden: 32,
|
|
32
73
|
} satisfies Record<AgentGameOfficeRoomType, number>;
|
|
33
74
|
|
|
34
75
|
export const DEFAULT_OFFICE_CONFIG: AgentGameOfficeConfig = {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
76
|
+
building: {
|
|
77
|
+
floors: [
|
|
78
|
+
{
|
|
79
|
+
id: "floor-1",
|
|
80
|
+
name: "1F",
|
|
81
|
+
rooms: [
|
|
82
|
+
{ type: "office" },
|
|
83
|
+
{ type: "auditorium" },
|
|
84
|
+
{ type: "gym" },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
connectors: [],
|
|
89
|
+
},
|
|
40
90
|
};
|
|
41
91
|
|
|
42
92
|
export function normalizeOfficeConfig(
|
|
43
93
|
config: AgentGameOfficeConfig | undefined = DEFAULT_OFFICE_CONFIG,
|
|
44
94
|
): NormalizedOfficeConfig {
|
|
45
|
-
if (
|
|
46
|
-
throw new Error("Agent game office config
|
|
95
|
+
if (isRecord(config) && "rooms" in config) {
|
|
96
|
+
throw new Error("Agent game office config must use building floors instead of top-level rooms");
|
|
97
|
+
}
|
|
98
|
+
if (!config || !isRecord(config.building)) {
|
|
99
|
+
throw new Error("Agent game office config requires a building");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const floors = config.building.floors;
|
|
103
|
+
if (!Array.isArray(floors) || floors.length === 0) {
|
|
104
|
+
throw new Error("Agent game office building requires at least one floor");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const floorIds = new Set<string>();
|
|
108
|
+
const normalizedFloors = floors.map((floor, level) => {
|
|
109
|
+
if (!isRecord(floor)) {
|
|
110
|
+
throw new Error("Agent game office building floor config must be an object");
|
|
111
|
+
}
|
|
112
|
+
const floorId = assertNonEmptyId(floor.id, "floor");
|
|
113
|
+
assertUnique(floorId, floorIds, "floor");
|
|
114
|
+
if (!Array.isArray(floor.rooms) || floor.rooms.length === 0) {
|
|
115
|
+
throw new Error(`Agent game office floor ${floorId} requires at least one room`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const counts = new Map<AgentGameOfficeRoomType, number>();
|
|
119
|
+
return {
|
|
120
|
+
id: floorId,
|
|
121
|
+
name: normalizeName(floor.name, "floor", floorId),
|
|
122
|
+
level,
|
|
123
|
+
rooms: floor.rooms.map((room) => {
|
|
124
|
+
if (!isRecord(room)) {
|
|
125
|
+
throw new Error(`Agent game office floor ${floorId} room config must be an object`);
|
|
126
|
+
}
|
|
127
|
+
assertRoomType(room.type);
|
|
128
|
+
const nextCount = (counts.get(room.type) ?? 0) + 1;
|
|
129
|
+
counts.set(room.type, nextCount);
|
|
130
|
+
return {
|
|
131
|
+
id: `${floorId}:${room.type}-${nextCount}`,
|
|
132
|
+
type: room.type,
|
|
133
|
+
capacity: normalizeRoomCapacity(room.type, room.capacity),
|
|
134
|
+
};
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const connectors = config.building.connectors;
|
|
140
|
+
if (!Array.isArray(connectors)) {
|
|
141
|
+
throw new Error("Agent game office building connectors must be an array");
|
|
142
|
+
}
|
|
143
|
+
if (normalizedFloors.length === 1 && connectors.length > 0) {
|
|
144
|
+
throw new Error("Agent game office single-floor building should not define connectors");
|
|
47
145
|
}
|
|
146
|
+
if (normalizedFloors.length > 1 && connectors.length === 0) {
|
|
147
|
+
throw new Error("Agent game office building with multiple floors requires at least one connector");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const connectorIds = new Set<string>();
|
|
151
|
+
const normalizedConnectors = connectors.map((connector) => {
|
|
152
|
+
if (!isRecord(connector)) {
|
|
153
|
+
throw new Error("Agent game office building connector config must be an object");
|
|
154
|
+
}
|
|
155
|
+
const connectorId = assertNonEmptyId(connector.id, "connector");
|
|
156
|
+
assertUnique(connectorId, connectorIds, "connector");
|
|
157
|
+
assertConnectorType(connector.type);
|
|
158
|
+
if (!Array.isArray(connector.serves)) {
|
|
159
|
+
throw new Error(`Agent game office connector ${connectorId} must serve at least two floors`);
|
|
160
|
+
}
|
|
161
|
+
const servedFloorIds = new Set<string>();
|
|
162
|
+
const serves = connector.serves.map((floorId) => {
|
|
163
|
+
const normalizedFloorId = assertNonEmptyId(floorId, "connector floor");
|
|
164
|
+
if (!floorIds.has(normalizedFloorId)) {
|
|
165
|
+
throw new Error(`Agent game office connector ${connectorId} serves unknown floor: ${normalizedFloorId}`);
|
|
166
|
+
}
|
|
167
|
+
assertUnique(normalizedFloorId, servedFloorIds, "connector served floor");
|
|
168
|
+
return normalizedFloorId;
|
|
169
|
+
});
|
|
170
|
+
if (serves.length < 2) {
|
|
171
|
+
throw new Error(`Agent game office connector ${connectorId} must serve at least two floors`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
id: connectorId,
|
|
176
|
+
type: connector.type,
|
|
177
|
+
name: normalizeName(connector.name, "connector", connectorId),
|
|
178
|
+
serves,
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
validateConnectorCoverage(normalizedFloors.map((floor) => floor.id), normalizedConnectors);
|
|
48
182
|
|
|
49
|
-
const counts = new Map<AgentGameOfficeRoomType, number>();
|
|
50
183
|
return {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
id: `${room.type}-${nextCount}`,
|
|
57
|
-
type: room.type,
|
|
58
|
-
capacity: normalizeRoomCapacity(room.type, room.capacity),
|
|
59
|
-
};
|
|
60
|
-
}),
|
|
184
|
+
building: {
|
|
185
|
+
floors: normalizedFloors,
|
|
186
|
+
connectors: normalizedConnectors,
|
|
187
|
+
},
|
|
61
188
|
};
|
|
62
189
|
}
|
|
63
190
|
|
|
64
|
-
function
|
|
65
|
-
|
|
191
|
+
function validateConnectorCoverage(
|
|
192
|
+
floorIds: string[],
|
|
193
|
+
connectors: NormalizedOfficeConnectorConfig[],
|
|
194
|
+
) {
|
|
195
|
+
if (floorIds.length <= 1) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const neighbors = new Map(floorIds.map((floorId) => [floorId, new Set<string>()]));
|
|
200
|
+
for (const connector of connectors) {
|
|
201
|
+
for (let leftIndex = 0; leftIndex < connector.serves.length; leftIndex += 1) {
|
|
202
|
+
for (let rightIndex = leftIndex + 1; rightIndex < connector.serves.length; rightIndex += 1) {
|
|
203
|
+
const left = connector.serves[leftIndex]!;
|
|
204
|
+
const right = connector.serves[rightIndex]!;
|
|
205
|
+
neighbors.get(left)?.add(right);
|
|
206
|
+
neighbors.get(right)?.add(left);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const visited = new Set<string>();
|
|
212
|
+
const queue = [floorIds[0]!];
|
|
213
|
+
while (queue.length > 0) {
|
|
214
|
+
const floorId = queue.shift()!;
|
|
215
|
+
if (visited.has(floorId)) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
visited.add(floorId);
|
|
219
|
+
for (const next of neighbors.get(floorId) ?? []) {
|
|
220
|
+
if (!visited.has(next)) {
|
|
221
|
+
queue.push(next);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (visited.size !== floorIds.length) {
|
|
227
|
+
throw new Error("Agent game office building connector graph is disconnected");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
232
|
+
return typeof value === "object" && value !== null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function assertNonEmptyId(value: unknown, label: string): string {
|
|
236
|
+
if (typeof value !== "string") {
|
|
237
|
+
throw new Error(`Agent game office ${label} id must be non-empty`);
|
|
238
|
+
}
|
|
239
|
+
const normalized = value.trim();
|
|
240
|
+
if (!normalized) {
|
|
241
|
+
throw new Error(`Agent game office ${label} id must be non-empty`);
|
|
242
|
+
}
|
|
243
|
+
return normalized;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function assertUnique(value: string, seen: Set<string>, label: string) {
|
|
247
|
+
if (seen.has(value)) {
|
|
248
|
+
throw new Error(`duplicate agent game office ${label} id: ${value}`);
|
|
249
|
+
}
|
|
250
|
+
seen.add(value);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function normalizeName(value: unknown, label: string, id: string): string {
|
|
254
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
255
|
+
throw new Error(`Agent game office ${label} ${id} name must be non-empty`);
|
|
256
|
+
}
|
|
257
|
+
return value.trim();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function assertRoomType(value: unknown): asserts value is AgentGameOfficeRoomType {
|
|
261
|
+
if (
|
|
262
|
+
value !== "office"
|
|
263
|
+
&& value !== "auditorium"
|
|
264
|
+
&& value !== "gym"
|
|
265
|
+
&& value !== "dining"
|
|
266
|
+
&& value !== "cafe"
|
|
267
|
+
&& value !== "skyGarden"
|
|
268
|
+
) {
|
|
66
269
|
throw new Error(`Unsupported agent game office room type: ${value}`);
|
|
67
270
|
}
|
|
68
271
|
}
|
|
69
272
|
|
|
70
|
-
function
|
|
273
|
+
function assertConnectorType(value: unknown): asserts value is AgentGameOfficeConnectorType {
|
|
274
|
+
if (value !== "elevator") {
|
|
275
|
+
throw new Error(`Unsupported agent game office connector type: ${value}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function normalizeRoomCapacity(type: AgentGameOfficeRoomType, capacity: unknown): number {
|
|
71
280
|
const value = capacity ?? DEFAULT_ROOM_CAPACITY[type];
|
|
72
|
-
if (!Number.isInteger(value) || value <= 0) {
|
|
281
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
73
282
|
throw new Error(`Agent game office room ${type} capacity must be a positive integer`);
|
|
74
283
|
}
|
|
75
284
|
return Math.min(value, MAX_ROOM_CAPACITY[type]);
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
export type Vector3Like = { x: number; y: number; z: number };
|
|
2
|
+
|
|
3
|
+
export type NavigationNodeKind =
|
|
4
|
+
| "seat"
|
|
5
|
+
| "zone"
|
|
6
|
+
| "connector-entry"
|
|
7
|
+
| "connector-cabin"
|
|
8
|
+
| "offstage";
|
|
9
|
+
|
|
10
|
+
export type NavigationNode = {
|
|
11
|
+
id: string;
|
|
12
|
+
floorId: string | null;
|
|
13
|
+
kind: NavigationNodeKind;
|
|
14
|
+
anchorId?: string;
|
|
15
|
+
connectorId?: string;
|
|
16
|
+
position: Vector3Like;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type NavigationEdge =
|
|
20
|
+
| { kind: "walk"; from: string; to: string; floorId: string }
|
|
21
|
+
| {
|
|
22
|
+
kind: "elevator";
|
|
23
|
+
from: string;
|
|
24
|
+
to: string;
|
|
25
|
+
connectorId: string;
|
|
26
|
+
fromFloorId: string;
|
|
27
|
+
toFloorId: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ResolvedBuildingNavigationGraph = {
|
|
31
|
+
nodes: NavigationNode[];
|
|
32
|
+
edges: NavigationEdge[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type AgentRouteStep =
|
|
36
|
+
| {
|
|
37
|
+
kind: "walk";
|
|
38
|
+
fromNodeId: string;
|
|
39
|
+
toNodeId: string;
|
|
40
|
+
floorId: string;
|
|
41
|
+
from: Vector3Like;
|
|
42
|
+
to: Vector3Like;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
kind: "elevator";
|
|
46
|
+
connectorId: string;
|
|
47
|
+
fromFloorId: string;
|
|
48
|
+
toFloorId: string;
|
|
49
|
+
fromNodeId: string;
|
|
50
|
+
toNodeId: string;
|
|
51
|
+
from: Vector3Like;
|
|
52
|
+
to: Vector3Like;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type AgentRoute = {
|
|
56
|
+
id: string;
|
|
57
|
+
agentId?: string;
|
|
58
|
+
fromNodeId: string;
|
|
59
|
+
toNodeId: string;
|
|
60
|
+
steps: AgentRouteStep[];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export class NoBuildingRouteError extends Error {
|
|
64
|
+
constructor(fromNodeId: string, toNodeId: string) {
|
|
65
|
+
super(`No building navigation route from ${fromNodeId} to ${toNodeId}`);
|
|
66
|
+
this.name = "NoBuildingRouteError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function planAgentRoute(
|
|
71
|
+
graph: ResolvedBuildingNavigationGraph,
|
|
72
|
+
fromNodeId: string,
|
|
73
|
+
toNodeId: string,
|
|
74
|
+
): AgentRoute {
|
|
75
|
+
const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
76
|
+
if (!nodes.has(fromNodeId) || !nodes.has(toNodeId)) {
|
|
77
|
+
throw new NoBuildingRouteError(fromNodeId, toNodeId);
|
|
78
|
+
}
|
|
79
|
+
if (fromNodeId === toNodeId) {
|
|
80
|
+
return {
|
|
81
|
+
id: `${fromNodeId}->${toNodeId}`,
|
|
82
|
+
fromNodeId,
|
|
83
|
+
toNodeId,
|
|
84
|
+
steps: [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const edgesByFrom = new Map<string, NavigationEdge[]>();
|
|
89
|
+
for (const edge of graph.edges) {
|
|
90
|
+
const edges = edgesByFrom.get(edge.from);
|
|
91
|
+
if (edges) {
|
|
92
|
+
edges.push(edge);
|
|
93
|
+
} else {
|
|
94
|
+
edgesByFrom.set(edge.from, [edge]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const queue = [fromNodeId];
|
|
99
|
+
const visited = new Set<string>([fromNodeId]);
|
|
100
|
+
const previous = new Map<string, NavigationEdge>();
|
|
101
|
+
|
|
102
|
+
while (queue.length > 0) {
|
|
103
|
+
const current = queue.shift()!;
|
|
104
|
+
for (const edge of edgesByFrom.get(current) ?? []) {
|
|
105
|
+
if (visited.has(edge.to)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
visited.add(edge.to);
|
|
109
|
+
previous.set(edge.to, edge);
|
|
110
|
+
if (edge.to === toNodeId) {
|
|
111
|
+
queue.length = 0;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
queue.push(edge.to);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!previous.has(toNodeId)) {
|
|
119
|
+
throw new NoBuildingRouteError(fromNodeId, toNodeId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const path: NavigationEdge[] = [];
|
|
123
|
+
let cursor = toNodeId;
|
|
124
|
+
while (cursor !== fromNodeId) {
|
|
125
|
+
const edge = previous.get(cursor);
|
|
126
|
+
if (!edge) {
|
|
127
|
+
throw new NoBuildingRouteError(fromNodeId, toNodeId);
|
|
128
|
+
}
|
|
129
|
+
path.push(edge);
|
|
130
|
+
cursor = edge.from;
|
|
131
|
+
}
|
|
132
|
+
path.reverse();
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
id: `${fromNodeId}->${toNodeId}`,
|
|
136
|
+
fromNodeId,
|
|
137
|
+
toNodeId,
|
|
138
|
+
steps: path.map((edge) => edgeToRouteStep(edge, nodes)),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function edgeToRouteStep(edge: NavigationEdge, nodes: Map<string, NavigationNode>): AgentRouteStep {
|
|
143
|
+
const from = nodes.get(edge.from);
|
|
144
|
+
const to = nodes.get(edge.to);
|
|
145
|
+
if (!from || !to) {
|
|
146
|
+
throw new NoBuildingRouteError(edge.from, edge.to);
|
|
147
|
+
}
|
|
148
|
+
if (edge.kind === "walk") {
|
|
149
|
+
return {
|
|
150
|
+
kind: "walk",
|
|
151
|
+
fromNodeId: edge.from,
|
|
152
|
+
toNodeId: edge.to,
|
|
153
|
+
floorId: edge.floorId,
|
|
154
|
+
from: from.position,
|
|
155
|
+
to: to.position,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
kind: "elevator",
|
|
160
|
+
connectorId: edge.connectorId,
|
|
161
|
+
fromFloorId: edge.fromFloorId,
|
|
162
|
+
toFloorId: edge.toFloorId,
|
|
163
|
+
fromNodeId: edge.from,
|
|
164
|
+
toNodeId: edge.to,
|
|
165
|
+
from: from.position,
|
|
166
|
+
to: to.position,
|
|
167
|
+
};
|
|
168
|
+
}
|