@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.42

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.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/README_zh.md +1 -1
  3. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
  4. package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
  5. package/assets/stageDist/index.html +12 -12
  6. package/lib/client.js +64 -64
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +57 -9
  9. package/lib/origin-claim.js +4 -0
  10. package/lib/skill/SKILL.md +117 -117
  11. package/lib/skill/references/collaboration.md +428 -428
  12. package/lib/skill/references/discovery.md +78 -78
  13. package/lib/skill/references/intake.md +107 -107
  14. package/lib/skill/references/membership.md +73 -73
  15. package/lib/skill/references/pitfalls.md +51 -51
  16. package/lib/skill/references/router.md +96 -96
  17. package/lib/skill/references/routing.md +65 -65
  18. package/lib/types/origin-claim.d.ts +2 -0
  19. package/nexus-stage/package.json +21 -21
  20. package/nexus-stage/scripts/mockhost.py +261 -261
  21. package/nexus-stage/src/canvas-ops.ts +191 -191
  22. package/nexus-stage/src/canvas-wire.ts +164 -164
  23. package/nexus-stage/src/cli-home-picker.ts +42 -42
  24. package/nexus-stage/src/fault.ts +56 -56
  25. package/nexus-stage/src/federation-overlay.ts +103 -103
  26. package/nexus-stage/src/federation.ts +241 -241
  27. package/nexus-stage/src/layout-doc.ts +145 -145
  28. package/nexus-stage/src/main.ts +513 -513
  29. package/nexus-stage/src/planning-card-text.ts +73 -73
  30. package/nexus-stage/src/planning-card.ts +63 -63
  31. package/nexus-stage/src/planning-edges.ts +26 -26
  32. package/nexus-stage/src/planning-fit.ts +45 -45
  33. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  34. package/nexus-stage/src/planning-legend.ts +39 -39
  35. package/nexus-stage/src/planning-member-handles.ts +61 -61
  36. package/nexus-stage/src/planning-menus.ts +422 -422
  37. package/nexus-stage/src/planning-mirror.ts +75 -75
  38. package/nexus-stage/src/planning-nodelist.ts +294 -294
  39. package/nexus-stage/src/planning-overlap.ts +159 -159
  40. package/nexus-stage/src/planning-reachability.ts +28 -28
  41. package/nexus-stage/src/planning-relayout.ts +121 -121
  42. package/nexus-stage/src/planning-roster.ts +15 -15
  43. package/nexus-stage/src/planning-search.ts +168 -168
  44. package/nexus-stage/src/planning-types.ts +115 -115
  45. package/nexus-stage/src/planning-undo.ts +68 -68
  46. package/nexus-stage/src/planning-view.ts +1499 -1499
  47. package/nexus-stage/src/planning.css +340 -340
  48. package/nexus-stage/src/seat-name-editor.ts +31 -31
  49. package/nexus-stage/src/session-drag.ts +64 -64
  50. package/nexus-stage/src/viewport.ts +106 -106
  51. package/nexus-stage/src/wb-chat.ts +684 -684
  52. package/nexus-stage/src/wb-directory.ts +237 -237
  53. package/nexus-stage/src/wb-ledger.ts +279 -279
  54. package/nexus-stage/src/wb-names.ts +43 -43
  55. package/nexus-stage/src/wb-rail.ts +147 -147
  56. package/nexus-stage/src/workbench.css +388 -388
  57. package/nexus-stage/src/workbench.ts +1272 -1272
  58. package/nexus-stage/src/world.ts +509 -509
  59. package/nexus-stage/tsconfig.json +24 -24
  60. package/nexus-stage/vite.config.ts +15 -15
  61. package/package.json +1 -1
@@ -1,509 +1,509 @@
1
- /**
2
- * The 2D planning-canvas world model: joined session nodes, team-frame
3
- * rectangles, the selection set, and the drag/marquee mutations. Pure
4
- * state + math, no DOM and no three (structural types redeclared here so
5
- * this module stays unit-testable from the root suite; main.ts passes the
6
- * topology.ts rows straight in - they satisfy these shapes structurally).
7
- *
8
- * Coordinate contract: a node's persisted {x, y} is its CARD CENTER (the
9
- * 3D scene writes mesh centers into the same layout document), so every
10
- * rectangle math goes through nodeRect(). Team frames own their saved
11
- * rects; a frame absent from the layout derives one from its members'
12
- * card bounding box. Group drag works from a pointerdown snapshot and
13
- * applies total deltas - never incremental accumulation, so a long drag
14
- * cannot drift.
15
- * @module nexus-stage/world
16
- */
17
-
18
- import { clampDoc, type LayoutDoc, type LayoutPoint, type LayoutRect } from './layout-doc'
19
- import { hashId } from './seat'
20
- import type { WorldBounds } from './viewport'
21
-
22
- /** Card geometry: the single source both CSS and edge math must agree with. */
23
- export const NODE_W = 172
24
- export const NODE_H = 56
25
-
26
- /**
27
- * Deterministic 2D fallback seating. The 3D scene's polar ring (radius
28
- * 12-30) is sized for unit spheres - 172px cards dropped onto it stack
29
- * into one pile. Unarranged cards instead spread on three card-scaled
30
- * rings (48 hash slots, y compressed for wide screens), so a fresh
31
- * canvas is readable before the first save; once anything is saved, the
32
- * persisted document wins in BOTH modes and this is only history.
33
- */
34
- const PLAN_RINGS = [150, 290, 430]
35
- const PLAN_SLOTS = 16
36
-
37
- export function planSeatFor(sid: string): { x: number; y: number } {
38
- const h = hashId(sid)
39
- const ring = Math.floor(h / PLAN_SLOTS) % PLAN_RINGS.length
40
- const slot = h % PLAN_SLOTS
41
- const angle = (slot / PLAN_SLOTS) * Math.PI * 2 + ring * (Math.PI / PLAN_SLOTS)
42
- const r = PLAN_RINGS[ring]!
43
- return { x: Math.round(Math.cos(angle) * r), y: Math.round(Math.sin(angle) * r * 0.6) }
44
- }
45
-
46
- /**
47
- * Home team per member id: the first team in state order that lists it.
48
- * That is the team whose membership edge is solid (edges.ts); every later
49
- * membership is a dashed 跨队 edge. A cross-team member therefore sits in
50
- * its home frame only — the other frames reach it by the dashed edge
51
- * instead of stretching over the home frame.
52
- */
53
- export function homeTeams(teams: ReadonlyArray<TeamLite>): Map<string, string> {
54
- const home = new Map<string, string>()
55
- for (const team of teams) {
56
- for (const m of team.members) if (!home.has(m.id)) home.set(m.id, team.name)
57
- }
58
- return home
59
- }
60
-
61
- const CLUSTER_GAP = 64
62
- const CLUSTER_COL_GAP = 24
63
- const CLUSTER_ROW_GAP = 20
64
- const CLUSTERS_PER_ROW = 4
65
- /** Same padding deriveInitialFrame wraps around member cards. */
66
- const FRAME_PAD_X = 24
67
- const FRAME_PAD_TOP = 44
68
- const FRAME_PAD_BOTTOM = 24
69
-
70
- /**
71
- * First-run seats grouped by team: each team's home members fill a small
72
- * grid (2–3 columns), clusters sit side by side with a 64px gutter and wrap
73
- * every four teams; members with no canvas team (declared roster teams
74
- * only) form a trailing cluster. Hash ring seats ignored teams, so the
75
- * frames derived from scattered members nested inside each other and their
76
- * centered titles printed on top of one another. A row also wraps before it
77
- * outgrows `maxRowW` (the board width), so a portrait phone stacks teams
78
- * instead of fitting one long strip at ~30%. Deterministic for a fixed
79
- * state order and width. Only ids in `ids` are seated.
80
- */
81
- export function planTeamClusters(teams: ReadonlyArray<TeamLite>, ids: ReadonlyArray<string>, maxRowW = Number.POSITIVE_INFINITY): Map<string, { x: number; y: number }> {
82
- const want = new Set(ids)
83
- const home = homeTeams(teams)
84
- const groups: string[][] = teams.map(t => [...new Set(t.members.map(m => m.id))].filter(id => want.has(id) && home.get(id) === t.name))
85
- groups.push(ids.filter(id => !home.has(id)))
86
- const seats = new Map<string, { x: number; y: number }>()
87
- let cursorX = 0
88
- let cursorY = 0
89
- let rowH = 0
90
- let placed = 0
91
- for (const group of groups) {
92
- if (group.length === 0) continue
93
- const cols = Math.min(3, Math.max(group.length > 1 ? 2 : 1, Math.ceil(Math.sqrt(group.length))))
94
- const rows = Math.ceil(group.length / cols)
95
- const w = Math.max(260, cols * NODE_W + (cols - 1) * CLUSTER_COL_GAP + FRAME_PAD_X * 2)
96
- const h = Math.max(160, rows * NODE_H + (rows - 1) * CLUSTER_ROW_GAP + FRAME_PAD_TOP + FRAME_PAD_BOTTOM)
97
- if (placed > 0 && (placed % CLUSTERS_PER_ROW === 0 || cursorX + w > maxRowW)) {
98
- cursorX = 0
99
- cursorY += rowH + CLUSTER_GAP
100
- rowH = 0
101
- }
102
- group.forEach((id, i) => {
103
- const c = i % cols
104
- const r = Math.floor(i / cols)
105
- seats.set(id, {
106
- x: cursorX + FRAME_PAD_X + NODE_W / 2 + c * (NODE_W + CLUSTER_COL_GAP),
107
- y: cursorY + FRAME_PAD_TOP + NODE_H / 2 + r * (NODE_H + CLUSTER_ROW_GAP),
108
- })
109
- })
110
- cursorX += w + CLUSTER_GAP
111
- rowH = Math.max(rowH, h)
112
- placed += 1
113
- }
114
- return seats
115
- }
116
-
117
- /** Breathing room kept between two unarranged cards. */
118
- const SEAT_GAP = 12
119
-
120
- /**
121
- * Collision-free fallback seat: the hashed ring seat when it is clear of
122
- * every card in `occupied`, else the next clear slot walking the rings
123
- * outward from the hashed one. The bare hash put 8 sessions into 48
124
- * slots and stacked pairs pixel-for-pixel on top of each other (and the
125
- * inner ring's slots sit ~60px apart, so neighbours overlapped too) —
126
- * a fresh board read as fewer nodes than the fleet had. Deterministic for
127
- * a fixed input order; when every ring slot is taken, cards queue on a
128
- * grid row under the outer ring.
129
- */
130
- export function planSeatClear(sid: string, occupied: ReadonlyArray<{ readonly x: number; readonly y: number }>): { x: number; y: number } {
131
- const clear = (p: { x: number; y: number }): boolean => occupied.every(o =>
132
- Math.abs(o.x - p.x) >= NODE_W + SEAT_GAP || Math.abs(o.y - p.y) >= NODE_H + SEAT_GAP)
133
- const first = planSeatFor(sid)
134
- if (clear(first)) return first
135
- const h = hashId(sid)
136
- const total = PLAN_SLOTS * PLAN_RINGS.length
137
- const start = (Math.floor(h / PLAN_SLOTS) % PLAN_RINGS.length) * PLAN_SLOTS + (h % PLAN_SLOTS)
138
- for (let k = 1; k < total; k++) {
139
- const i = (start + k) % total
140
- const ring = Math.floor(i / PLAN_SLOTS)
141
- const angle = ((i % PLAN_SLOTS) / PLAN_SLOTS) * Math.PI * 2 + ring * (Math.PI / PLAN_SLOTS)
142
- const r = PLAN_RINGS[ring]!
143
- const p = { x: Math.round(Math.cos(angle) * r), y: Math.round(Math.sin(angle) * r * 0.6) }
144
- if (clear(p)) return p
145
- }
146
- const outer = PLAN_RINGS[PLAN_RINGS.length - 1]!
147
- for (let row = 0; ; row++) {
148
- for (let col = 0; col < 6; col++) {
149
- const p = { x: Math.round(-outer + col * (NODE_W + SEAT_GAP)), y: Math.round(outer * 0.6 + NODE_H * 2 + row * (NODE_H + SEAT_GAP)) }
150
- if (clear(p)) return p
151
- }
152
- }
153
- }
154
-
155
- /** Minimal session row shape the model needs (topology.SessionRow satisfies it). */
156
- export interface SessionLite {
157
- id: string; label: string; team: string; name?: string; description?: string; joined: boolean; live?: boolean; teams?: readonly string[]
158
- /** Live local sessions only: mid-turn ('running') or idle, and how many receipt-owing tasks wait on it. */
159
- status?: 'running' | 'idle'; queued?: number
160
- }
161
-
162
- /** Minimal team shape (topology.CanvasTeam satisfies it). `team` is the
163
- * mono route alias `<host>/canvas/<name>` when the payload carries it. */
164
- export interface TeamLite { name: string; team?: string; members: ReadonlyArray<{ id: string }> }
165
-
166
- /** One node's memberships in state order: team name + index (member order = routing priority). */
167
- export interface Membership { readonly team: string; readonly index: number }
168
-
169
- /** A placed session node: center position plus everything the card renders. */
170
- export interface WorldNode {
171
- x: number
172
- y: number
173
- readonly id: string
174
- /** Rendered attributes refresh on each poll; the position never does. */
175
- label: string
176
- /** The route alias `<team>/<id8>` the state payload provides. */
177
- team: string
178
- name?: string
179
- /** What the session is for (/state `description`) — the card's second line. */
180
- description?: string
181
- live: boolean
182
- /** Peer node (PR D v1 ruling): a remote host entry rendered as a card.
183
- * Excluded from local content bounds; canvas memberships allowed since
184
- * gh#20 W-B (the peer rides its zone/id8 handle at the server boundary). */
185
- remote?: boolean
186
- /** Full peer URL (identity is the bare host:port in the node id). */
187
- peerUrl?: string
188
- /** gh#20 W-B: the peer's zone/id8 remote handle (its first remote team
189
- * row) — the server's canvas member form for cross-host teams. */
190
- remoteHandle?: string
191
- /** Reachability score from a2a_probe (peers only). */
192
- score?: number
193
- /** Declared roster teams (a2a/teams.json) — the network-declaration half
194
- * of "teamed"; canvas memberships are the visual half. Peers: never. */
195
- teams?: readonly string[]
196
- memberships: ReadonlyArray<Membership>
197
- }
198
-
199
- /** A team frame rectangle in world space (top-left + size). */
200
- export interface FrameGeom { x: number; y: number; w: number; h: number }
201
-
202
- /** Snapshot for a frame drag: the frame rect at pointerdown (owner ruling 2026-10-28: a frame always moves only itself — members never ride along). */
203
- export interface FrameDragSnapshot {
204
- readonly name: string
205
- readonly rect: FrameGeom
206
- }
207
-
208
- /** The card rectangle (top-left + size) for a node centered at (x, y). */
209
- export function nodeRect(n: { readonly x: number; readonly y: number }): LayoutRect {
210
- return { x: n.x - NODE_W / 2, y: n.y - NODE_H / 2, w: NODE_W, h: NODE_H }
211
- }
212
-
213
- /**
214
- * Derive an initial frame rect from member card rects: bounding box plus
215
- * padding (extra headroom on top for the straddling titlebar), minimum
216
- * 260x160, rounded to integers - deterministic, so the same roster always
217
- * proposes the same frame until the user moves something.
218
- */
219
- export function deriveInitialFrame(memberRects: ReadonlyArray<LayoutRect>): FrameGeom {
220
- if (memberRects.length === 0) return { x: 0, y: 0, w: 260, h: 160 }
221
- let minX = Number.POSITIVE_INFINITY
222
- let minY = Number.POSITIVE_INFINITY
223
- let maxX = Number.NEGATIVE_INFINITY
224
- let maxY = Number.NEGATIVE_INFINITY
225
- for (const r of memberRects) {
226
- minX = Math.min(minX, r.x)
227
- minY = Math.min(minY, r.y)
228
- maxX = Math.max(maxX, r.x + r.w)
229
- maxY = Math.max(maxY, r.y + r.h)
230
- }
231
- const padX = 24
232
- const padTop = 44
233
- const padBottom = 24
234
- const w = Math.max(260, Math.round(maxX - minX + padX * 2))
235
- const h = Math.max(160, Math.round(maxY - minY + padTop + padBottom))
236
- return { x: Math.round(minX - padX), y: Math.round(minY - padTop), w, h }
237
- }
238
-
239
- /** The planning-canvas model. */
240
- export class WorldModel {
241
- private revisionCounter = 0
242
-
243
- /** Monotonic mutation counter: the view re-renders only when this moves. */
244
- get revision(): number { return this.revisionCounter }
245
-
246
- private nodes = new Map<string, WorldNode>()
247
- private frames = new Map<string, FrameGeom>()
248
- private selection = new Set<string>()
249
- viewport = { x: 0, y: 0, scale: 1 }
250
-
251
- /** Read-only node access for rendering/edges. */
252
- getNode(id: string): WorldNode | undefined { return this.nodes.get(id) }
253
- allNodes(): ReadonlyArray<WorldNode> { return [...this.nodes.values()] }
254
- nodeIds(): ReadonlyArray<string> { return [...this.nodes.keys()] }
255
- getFrame(name: string): FrameGeom | undefined { return this.frames.get(name) }
256
- allFrames(): ReadonlyArray<[string, FrameGeom]> { return [...this.frames.entries()] }
257
- isSelected(id: string): boolean { return this.selection.has(id) }
258
- selectedIds(): ReadonlyArray<string> { return [...this.selection] }
259
-
260
- private bump(): void { this.revisionCounter += 1 }
261
-
262
- /**
263
- * Move nodes by a world-space delta (drag/nudge); also moves each
264
- * selected node out of no frame - frames track rects, not membership,
265
- * so positions and rects stay independent.
266
- */
267
- dragNodes(ids: ReadonlyArray<string>, dx: number, dy: number): void {
268
- let moved = false
269
- for (const id of ids) {
270
- const n = this.nodes.get(id)
271
- if (n === undefined) continue
272
- n.x += dx
273
- n.y += dy
274
- moved = true
275
- }
276
- if (moved) this.bump()
277
- }
278
-
279
- /**
280
- * Start a frame drag: snapshot the rect so the drag applies total deltas
281
- * from this origin (no float drift). Returns undefined for an unknown
282
- * frame. Owner ruling 2026-10-28: the snapshot carries NO member state —
283
- * a frame drag moves only the rect; members stay where the user put them.
284
- */
285
- beginFrameDrag(name: string): FrameDragSnapshot | undefined {
286
- const rect = this.frames.get(name)
287
- if (rect === undefined) return undefined
288
- return { name, rect: { ...rect } }
289
- }
290
-
291
- /** Apply a total world-space delta from the drag origin to the rect ONLY. */
292
- applyFrameDrag(snap: FrameDragSnapshot, dx: number, dy: number): void {
293
- const rect = this.frames.get(snap.name)
294
- if (rect === undefined) return
295
- rect.x = snap.rect.x + dx
296
- rect.y = snap.rect.y + dy
297
- this.bump()
298
- }
299
-
300
- /** Nudge (keyboard micro-move): moves the SELECTED CARDS only — frames are never in the card selection, so nudging already honours the owner ruling (a frame always moves only itself). */
301
- nudge(ids: ReadonlyArray<string>, dx: number, dy: number): void { this.dragNodes(ids, dx, dy) }
302
-
303
- /** Set selection: nodes whose card rect intersects the world-space marquee. */
304
- marqueeSelect(rect: LayoutRect, additive: boolean): void {
305
- if (!additive) this.selection.clear()
306
- for (const n of this.nodes.values()) {
307
- const r = nodeRect(n)
308
- const intersects = r.x < rect.x + rect.w && r.x + r.w > rect.x && r.y < rect.y + rect.h && r.y + r.h > rect.y
309
- if (intersects) this.selection.add(n.id)
310
- }
311
- this.bump()
312
- }
313
-
314
- /** Replace the whole selection (click / Shift-click add / Esc clears). */
315
- setSelection(ids: ReadonlyArray<string>): void {
316
- this.selection = new Set(ids)
317
- this.bump()
318
- }
319
-
320
- /**
321
- * Poll-cycle maintenance: insert or refresh one node without touching
322
- * its position (the live model is authoritative between saves; a poll
323
- * must never snap a dragged card back to its last-saved spot). New
324
- * nodes seat at the given coordinates (saved spot or hash seat).
325
- */
326
- upsertNode(n: WorldNode): void {
327
- const existing = this.nodes.get(n.id)
328
- if (existing === undefined) {
329
- this.nodes.set(n.id, n)
330
- this.bump()
331
- return
332
- }
333
- existing.label = n.label
334
- existing.name = n.name
335
- existing.description = n.description
336
- existing.live = n.live
337
- existing.memberships = n.memberships
338
- this.bump()
339
- }
340
-
341
- /** Drop a node that left the joined roster; clears it from selection. */
342
- removeNode(id: string): void {
343
- if (this.nodes.delete(id)) {
344
- this.selection.delete(id)
345
- this.bump()
346
- }
347
- }
348
-
349
- /** Set or replace one frame rect (adopted layout or derived initial). */
350
- setFrame(name: string, rect: FrameGeom): void {
351
- this.frames.set(name, rect)
352
- this.bump()
353
- }
354
-
355
- /** Drop a frame whose team vanished from the state payload. */
356
- removeFrame(name: string): void {
357
- if (this.frames.delete(name)) this.bump()
358
- }
359
-
360
- /** Update a peer node's reachability score (render attr; no bump unless it changed). */
361
- upsertScore(id: string, score: number | undefined): void {
362
- const n = this.nodes.get(id)
363
- if (n === undefined || n.score === score) return
364
- n.score = score
365
- this.bump()
366
- }
367
-
368
- /** Node center positions, for the layout document builder. */
369
- positions(): Map<string, LayoutPoint> {
370
- const out = new Map<string, LayoutPoint>()
371
- for (const [id, n] of this.nodes) out.set(id, { x: n.x, y: n.y })
372
- return out
373
- }
374
-
375
- /** Frame rectangles, for the layout document builder. */
376
- frameRects(): Map<string, LayoutRect> {
377
- const out = new Map<string, LayoutRect>()
378
- for (const [name, r] of this.frames) out.set(name, { ...r })
379
- return out
380
- }
381
-
382
- /** Member ids of one team, in routing-priority (index) order. */
383
- teamMemberIds(name: string): string[] {
384
- const rows: Array<{ id: string; index: number }> = []
385
- for (const n of this.nodes.values()) {
386
- const m = n.memberships.find(x => x.team === name)
387
- if (m !== undefined) rows.push({ id: n.id, index: m.index })
388
- }
389
- return rows.sort((a, b) => a.index - b.index).map(r => r.id)
390
- }
391
-
392
- /**
393
- * Replace one team's roster (the single primitive every write action and
394
- * its undo go through): entries for OTHER teams are preserved verbatim
395
- * in their array position, this team's entries are rebuilt from `ids`
396
- * (order = routing priority, indexes renormalized to 0..n-1 so the P
397
- * badges stay continuous). One bump for the whole operation.
398
- */
399
- setTeamMembers(name: string, ids: ReadonlyArray<string>): void {
400
- for (const n of this.nodes.values()) {
401
- const rest = n.memberships.filter(m => m.team !== name)
402
- const idx = ids.indexOf(n.id)
403
- if (idx >= 0) rest.push({ team: name, index: idx })
404
- n.memberships = rest
405
- }
406
- this.bump()
407
- }
408
-
409
- /** Union of node cards and frame rects; null when the canvas is empty. */
410
- contentBounds(): WorldBounds | null {
411
- let minX = Number.POSITIVE_INFINITY
412
- let minY = Number.POSITIVE_INFINITY
413
- let maxX = Number.NEGATIVE_INFINITY
414
- let maxY = Number.NEGATIVE_INFINITY
415
- const absorb = (r: LayoutRect): void => {
416
- minX = Math.min(minX, r.x)
417
- minY = Math.min(minY, r.y)
418
- maxX = Math.max(maxX, r.x + r.w)
419
- maxY = Math.max(maxY, r.y + r.h)
420
- }
421
- for (const n of this.nodes.values()) {
422
- if (n.remote === true) continue // peer cards hang outside the field
423
- absorb(nodeRect(n))
424
- }
425
- for (const r of this.frames.values()) absorb(r)
426
- return minX === Number.POSITIVE_INFINITY ? null : { minX, minY, maxX, maxY }
427
- }
428
-
429
- /**
430
- * Build from one state payload plus the persisted layout (when the host
431
- * has one). Joined sessions only (same roster rule as the 3D scene);
432
- * saved positions win, the deterministic card-scaled ring seat
433
- * falls back - the same world spot the 3D grid plane shows for an
434
- * unsaved node. Frames come from the layout when present; a frame with
435
- * neither saved rect nor members is skipped. The layout is clamped with
436
- * the host-mirror discipline, so malformed rows fall through to seats
437
- * exactly like the 3D scene's typeof guard.
438
- */
439
- static fromState(input: {
440
- sessions: ReadonlyArray<SessionLite>
441
- teams: ReadonlyArray<TeamLite>
442
- layout: unknown
443
- }): WorldModel {
444
- const model = new WorldModel()
445
- const doc: LayoutDoc | undefined = clampDoc(input.layout)
446
-
447
- const memberships = new Map<string, Array<Membership>>()
448
- for (const team of input.teams) {
449
- for (let i = 0; i < team.members.length; i++) {
450
- const id = team.members[i]!.id
451
- const list = memberships.get(id) ?? []
452
- list.push({ team: team.name, index: i })
453
- memberships.set(id, list)
454
- }
455
- }
456
-
457
- for (const s of input.sessions) {
458
- if (s.joined !== true) continue
459
- const saved: LayoutPoint | undefined = doc?.nodes[s.id]
460
- let x: number
461
- let y: number
462
- if (saved !== undefined && typeof saved.x === 'number' && typeof saved.y === 'number') {
463
- x = saved.x
464
- y = saved.y
465
- } else {
466
- const p = planSeatClear(s.id, [...model.nodes.values()])
467
- x = p.x
468
- y = p.y
469
- }
470
- model.nodes.set(s.id, {
471
- id: s.id,
472
- x,
473
- y,
474
- label: s.label,
475
- team: s.team,
476
- name: s.name,
477
- description: s.description,
478
- live: s.live !== false,
479
- memberships: memberships.get(s.id) ?? [],
480
- })
481
- }
482
-
483
- for (const team of input.teams) {
484
- const savedRect = doc?.frames[team.name]
485
- if (savedRect !== undefined) {
486
- model.frames.set(team.name, { x: savedRect.x, y: savedRect.y, w: savedRect.w, h: savedRect.h })
487
- continue
488
- }
489
- const memberRects: Array<LayoutRect> = []
490
- for (const m of team.members) {
491
- const n = model.nodes.get(m.id)
492
- if (n !== undefined) memberRects.push(nodeRect(n))
493
- }
494
- if (memberRects.length > 0) model.frames.set(team.name, deriveInitialFrame(memberRects))
495
- }
496
-
497
- model.viewport = doc !== undefined ? { ...doc.viewport } : { x: 0, y: 0, scale: 1 }
498
- return model
499
- }
500
- }
501
-
502
- /** Functional entry point; see {@link WorldModel.fromState} for the contract. */
503
- export function buildWorld(input: {
504
- sessions: ReadonlyArray<SessionLite>
505
- teams: ReadonlyArray<TeamLite>
506
- layout: unknown
507
- }): WorldModel {
508
- return WorldModel.fromState(input)
509
- }
1
+ /**
2
+ * The 2D planning-canvas world model: joined session nodes, team-frame
3
+ * rectangles, the selection set, and the drag/marquee mutations. Pure
4
+ * state + math, no DOM and no three (structural types redeclared here so
5
+ * this module stays unit-testable from the root suite; main.ts passes the
6
+ * topology.ts rows straight in - they satisfy these shapes structurally).
7
+ *
8
+ * Coordinate contract: a node's persisted {x, y} is its CARD CENTER (the
9
+ * 3D scene writes mesh centers into the same layout document), so every
10
+ * rectangle math goes through nodeRect(). Team frames own their saved
11
+ * rects; a frame absent from the layout derives one from its members'
12
+ * card bounding box. Group drag works from a pointerdown snapshot and
13
+ * applies total deltas - never incremental accumulation, so a long drag
14
+ * cannot drift.
15
+ * @module nexus-stage/world
16
+ */
17
+
18
+ import { clampDoc, type LayoutDoc, type LayoutPoint, type LayoutRect } from './layout-doc'
19
+ import { hashId } from './seat'
20
+ import type { WorldBounds } from './viewport'
21
+
22
+ /** Card geometry: the single source both CSS and edge math must agree with. */
23
+ export const NODE_W = 172
24
+ export const NODE_H = 56
25
+
26
+ /**
27
+ * Deterministic 2D fallback seating. The 3D scene's polar ring (radius
28
+ * 12-30) is sized for unit spheres - 172px cards dropped onto it stack
29
+ * into one pile. Unarranged cards instead spread on three card-scaled
30
+ * rings (48 hash slots, y compressed for wide screens), so a fresh
31
+ * canvas is readable before the first save; once anything is saved, the
32
+ * persisted document wins in BOTH modes and this is only history.
33
+ */
34
+ const PLAN_RINGS = [150, 290, 430]
35
+ const PLAN_SLOTS = 16
36
+
37
+ export function planSeatFor(sid: string): { x: number; y: number } {
38
+ const h = hashId(sid)
39
+ const ring = Math.floor(h / PLAN_SLOTS) % PLAN_RINGS.length
40
+ const slot = h % PLAN_SLOTS
41
+ const angle = (slot / PLAN_SLOTS) * Math.PI * 2 + ring * (Math.PI / PLAN_SLOTS)
42
+ const r = PLAN_RINGS[ring]!
43
+ return { x: Math.round(Math.cos(angle) * r), y: Math.round(Math.sin(angle) * r * 0.6) }
44
+ }
45
+
46
+ /**
47
+ * Home team per member id: the first team in state order that lists it.
48
+ * That is the team whose membership edge is solid (edges.ts); every later
49
+ * membership is a dashed 跨队 edge. A cross-team member therefore sits in
50
+ * its home frame only — the other frames reach it by the dashed edge
51
+ * instead of stretching over the home frame.
52
+ */
53
+ export function homeTeams(teams: ReadonlyArray<TeamLite>): Map<string, string> {
54
+ const home = new Map<string, string>()
55
+ for (const team of teams) {
56
+ for (const m of team.members) if (!home.has(m.id)) home.set(m.id, team.name)
57
+ }
58
+ return home
59
+ }
60
+
61
+ const CLUSTER_GAP = 64
62
+ const CLUSTER_COL_GAP = 24
63
+ const CLUSTER_ROW_GAP = 20
64
+ const CLUSTERS_PER_ROW = 4
65
+ /** Same padding deriveInitialFrame wraps around member cards. */
66
+ const FRAME_PAD_X = 24
67
+ const FRAME_PAD_TOP = 44
68
+ const FRAME_PAD_BOTTOM = 24
69
+
70
+ /**
71
+ * First-run seats grouped by team: each team's home members fill a small
72
+ * grid (2–3 columns), clusters sit side by side with a 64px gutter and wrap
73
+ * every four teams; members with no canvas team (declared roster teams
74
+ * only) form a trailing cluster. Hash ring seats ignored teams, so the
75
+ * frames derived from scattered members nested inside each other and their
76
+ * centered titles printed on top of one another. A row also wraps before it
77
+ * outgrows `maxRowW` (the board width), so a portrait phone stacks teams
78
+ * instead of fitting one long strip at ~30%. Deterministic for a fixed
79
+ * state order and width. Only ids in `ids` are seated.
80
+ */
81
+ export function planTeamClusters(teams: ReadonlyArray<TeamLite>, ids: ReadonlyArray<string>, maxRowW = Number.POSITIVE_INFINITY): Map<string, { x: number; y: number }> {
82
+ const want = new Set(ids)
83
+ const home = homeTeams(teams)
84
+ const groups: string[][] = teams.map(t => [...new Set(t.members.map(m => m.id))].filter(id => want.has(id) && home.get(id) === t.name))
85
+ groups.push(ids.filter(id => !home.has(id)))
86
+ const seats = new Map<string, { x: number; y: number }>()
87
+ let cursorX = 0
88
+ let cursorY = 0
89
+ let rowH = 0
90
+ let placed = 0
91
+ for (const group of groups) {
92
+ if (group.length === 0) continue
93
+ const cols = Math.min(3, Math.max(group.length > 1 ? 2 : 1, Math.ceil(Math.sqrt(group.length))))
94
+ const rows = Math.ceil(group.length / cols)
95
+ const w = Math.max(260, cols * NODE_W + (cols - 1) * CLUSTER_COL_GAP + FRAME_PAD_X * 2)
96
+ const h = Math.max(160, rows * NODE_H + (rows - 1) * CLUSTER_ROW_GAP + FRAME_PAD_TOP + FRAME_PAD_BOTTOM)
97
+ if (placed > 0 && (placed % CLUSTERS_PER_ROW === 0 || cursorX + w > maxRowW)) {
98
+ cursorX = 0
99
+ cursorY += rowH + CLUSTER_GAP
100
+ rowH = 0
101
+ }
102
+ group.forEach((id, i) => {
103
+ const c = i % cols
104
+ const r = Math.floor(i / cols)
105
+ seats.set(id, {
106
+ x: cursorX + FRAME_PAD_X + NODE_W / 2 + c * (NODE_W + CLUSTER_COL_GAP),
107
+ y: cursorY + FRAME_PAD_TOP + NODE_H / 2 + r * (NODE_H + CLUSTER_ROW_GAP),
108
+ })
109
+ })
110
+ cursorX += w + CLUSTER_GAP
111
+ rowH = Math.max(rowH, h)
112
+ placed += 1
113
+ }
114
+ return seats
115
+ }
116
+
117
+ /** Breathing room kept between two unarranged cards. */
118
+ const SEAT_GAP = 12
119
+
120
+ /**
121
+ * Collision-free fallback seat: the hashed ring seat when it is clear of
122
+ * every card in `occupied`, else the next clear slot walking the rings
123
+ * outward from the hashed one. The bare hash put 8 sessions into 48
124
+ * slots and stacked pairs pixel-for-pixel on top of each other (and the
125
+ * inner ring's slots sit ~60px apart, so neighbours overlapped too) —
126
+ * a fresh board read as fewer nodes than the fleet had. Deterministic for
127
+ * a fixed input order; when every ring slot is taken, cards queue on a
128
+ * grid row under the outer ring.
129
+ */
130
+ export function planSeatClear(sid: string, occupied: ReadonlyArray<{ readonly x: number; readonly y: number }>): { x: number; y: number } {
131
+ const clear = (p: { x: number; y: number }): boolean => occupied.every(o =>
132
+ Math.abs(o.x - p.x) >= NODE_W + SEAT_GAP || Math.abs(o.y - p.y) >= NODE_H + SEAT_GAP)
133
+ const first = planSeatFor(sid)
134
+ if (clear(first)) return first
135
+ const h = hashId(sid)
136
+ const total = PLAN_SLOTS * PLAN_RINGS.length
137
+ const start = (Math.floor(h / PLAN_SLOTS) % PLAN_RINGS.length) * PLAN_SLOTS + (h % PLAN_SLOTS)
138
+ for (let k = 1; k < total; k++) {
139
+ const i = (start + k) % total
140
+ const ring = Math.floor(i / PLAN_SLOTS)
141
+ const angle = ((i % PLAN_SLOTS) / PLAN_SLOTS) * Math.PI * 2 + ring * (Math.PI / PLAN_SLOTS)
142
+ const r = PLAN_RINGS[ring]!
143
+ const p = { x: Math.round(Math.cos(angle) * r), y: Math.round(Math.sin(angle) * r * 0.6) }
144
+ if (clear(p)) return p
145
+ }
146
+ const outer = PLAN_RINGS[PLAN_RINGS.length - 1]!
147
+ for (let row = 0; ; row++) {
148
+ for (let col = 0; col < 6; col++) {
149
+ const p = { x: Math.round(-outer + col * (NODE_W + SEAT_GAP)), y: Math.round(outer * 0.6 + NODE_H * 2 + row * (NODE_H + SEAT_GAP)) }
150
+ if (clear(p)) return p
151
+ }
152
+ }
153
+ }
154
+
155
+ /** Minimal session row shape the model needs (topology.SessionRow satisfies it). */
156
+ export interface SessionLite {
157
+ id: string; label: string; team: string; name?: string; description?: string; joined: boolean; live?: boolean; teams?: readonly string[]
158
+ /** Live local sessions only: mid-turn ('running') or idle, and how many receipt-owing tasks wait on it. */
159
+ status?: 'running' | 'idle'; queued?: number
160
+ }
161
+
162
+ /** Minimal team shape (topology.CanvasTeam satisfies it). `team` is the
163
+ * mono route alias `<host>/canvas/<name>` when the payload carries it. */
164
+ export interface TeamLite { name: string; team?: string; members: ReadonlyArray<{ id: string }> }
165
+
166
+ /** One node's memberships in state order: team name + index (member order = routing priority). */
167
+ export interface Membership { readonly team: string; readonly index: number }
168
+
169
+ /** A placed session node: center position plus everything the card renders. */
170
+ export interface WorldNode {
171
+ x: number
172
+ y: number
173
+ readonly id: string
174
+ /** Rendered attributes refresh on each poll; the position never does. */
175
+ label: string
176
+ /** The route alias `<team>/<id8>` the state payload provides. */
177
+ team: string
178
+ name?: string
179
+ /** What the session is for (/state `description`) — the card's second line. */
180
+ description?: string
181
+ live: boolean
182
+ /** Peer node (PR D v1 ruling): a remote host entry rendered as a card.
183
+ * Excluded from local content bounds; canvas memberships allowed since
184
+ * gh#20 W-B (the peer rides its zone/id8 handle at the server boundary). */
185
+ remote?: boolean
186
+ /** Full peer URL (identity is the bare host:port in the node id). */
187
+ peerUrl?: string
188
+ /** gh#20 W-B: the peer's zone/id8 remote handle (its first remote team
189
+ * row) — the server's canvas member form for cross-host teams. */
190
+ remoteHandle?: string
191
+ /** Reachability score from a2a_probe (peers only). */
192
+ score?: number
193
+ /** Declared roster teams (a2a/teams.json) — the network-declaration half
194
+ * of "teamed"; canvas memberships are the visual half. Peers: never. */
195
+ teams?: readonly string[]
196
+ memberships: ReadonlyArray<Membership>
197
+ }
198
+
199
+ /** A team frame rectangle in world space (top-left + size). */
200
+ export interface FrameGeom { x: number; y: number; w: number; h: number }
201
+
202
+ /** Snapshot for a frame drag: the frame rect at pointerdown (owner ruling 2026-10-28: a frame always moves only itself — members never ride along). */
203
+ export interface FrameDragSnapshot {
204
+ readonly name: string
205
+ readonly rect: FrameGeom
206
+ }
207
+
208
+ /** The card rectangle (top-left + size) for a node centered at (x, y). */
209
+ export function nodeRect(n: { readonly x: number; readonly y: number }): LayoutRect {
210
+ return { x: n.x - NODE_W / 2, y: n.y - NODE_H / 2, w: NODE_W, h: NODE_H }
211
+ }
212
+
213
+ /**
214
+ * Derive an initial frame rect from member card rects: bounding box plus
215
+ * padding (extra headroom on top for the straddling titlebar), minimum
216
+ * 260x160, rounded to integers - deterministic, so the same roster always
217
+ * proposes the same frame until the user moves something.
218
+ */
219
+ export function deriveInitialFrame(memberRects: ReadonlyArray<LayoutRect>): FrameGeom {
220
+ if (memberRects.length === 0) return { x: 0, y: 0, w: 260, h: 160 }
221
+ let minX = Number.POSITIVE_INFINITY
222
+ let minY = Number.POSITIVE_INFINITY
223
+ let maxX = Number.NEGATIVE_INFINITY
224
+ let maxY = Number.NEGATIVE_INFINITY
225
+ for (const r of memberRects) {
226
+ minX = Math.min(minX, r.x)
227
+ minY = Math.min(minY, r.y)
228
+ maxX = Math.max(maxX, r.x + r.w)
229
+ maxY = Math.max(maxY, r.y + r.h)
230
+ }
231
+ const padX = 24
232
+ const padTop = 44
233
+ const padBottom = 24
234
+ const w = Math.max(260, Math.round(maxX - minX + padX * 2))
235
+ const h = Math.max(160, Math.round(maxY - minY + padTop + padBottom))
236
+ return { x: Math.round(minX - padX), y: Math.round(minY - padTop), w, h }
237
+ }
238
+
239
+ /** The planning-canvas model. */
240
+ export class WorldModel {
241
+ private revisionCounter = 0
242
+
243
+ /** Monotonic mutation counter: the view re-renders only when this moves. */
244
+ get revision(): number { return this.revisionCounter }
245
+
246
+ private nodes = new Map<string, WorldNode>()
247
+ private frames = new Map<string, FrameGeom>()
248
+ private selection = new Set<string>()
249
+ viewport = { x: 0, y: 0, scale: 1 }
250
+
251
+ /** Read-only node access for rendering/edges. */
252
+ getNode(id: string): WorldNode | undefined { return this.nodes.get(id) }
253
+ allNodes(): ReadonlyArray<WorldNode> { return [...this.nodes.values()] }
254
+ nodeIds(): ReadonlyArray<string> { return [...this.nodes.keys()] }
255
+ getFrame(name: string): FrameGeom | undefined { return this.frames.get(name) }
256
+ allFrames(): ReadonlyArray<[string, FrameGeom]> { return [...this.frames.entries()] }
257
+ isSelected(id: string): boolean { return this.selection.has(id) }
258
+ selectedIds(): ReadonlyArray<string> { return [...this.selection] }
259
+
260
+ private bump(): void { this.revisionCounter += 1 }
261
+
262
+ /**
263
+ * Move nodes by a world-space delta (drag/nudge); also moves each
264
+ * selected node out of no frame - frames track rects, not membership,
265
+ * so positions and rects stay independent.
266
+ */
267
+ dragNodes(ids: ReadonlyArray<string>, dx: number, dy: number): void {
268
+ let moved = false
269
+ for (const id of ids) {
270
+ const n = this.nodes.get(id)
271
+ if (n === undefined) continue
272
+ n.x += dx
273
+ n.y += dy
274
+ moved = true
275
+ }
276
+ if (moved) this.bump()
277
+ }
278
+
279
+ /**
280
+ * Start a frame drag: snapshot the rect so the drag applies total deltas
281
+ * from this origin (no float drift). Returns undefined for an unknown
282
+ * frame. Owner ruling 2026-10-28: the snapshot carries NO member state —
283
+ * a frame drag moves only the rect; members stay where the user put them.
284
+ */
285
+ beginFrameDrag(name: string): FrameDragSnapshot | undefined {
286
+ const rect = this.frames.get(name)
287
+ if (rect === undefined) return undefined
288
+ return { name, rect: { ...rect } }
289
+ }
290
+
291
+ /** Apply a total world-space delta from the drag origin to the rect ONLY. */
292
+ applyFrameDrag(snap: FrameDragSnapshot, dx: number, dy: number): void {
293
+ const rect = this.frames.get(snap.name)
294
+ if (rect === undefined) return
295
+ rect.x = snap.rect.x + dx
296
+ rect.y = snap.rect.y + dy
297
+ this.bump()
298
+ }
299
+
300
+ /** Nudge (keyboard micro-move): moves the SELECTED CARDS only — frames are never in the card selection, so nudging already honours the owner ruling (a frame always moves only itself). */
301
+ nudge(ids: ReadonlyArray<string>, dx: number, dy: number): void { this.dragNodes(ids, dx, dy) }
302
+
303
+ /** Set selection: nodes whose card rect intersects the world-space marquee. */
304
+ marqueeSelect(rect: LayoutRect, additive: boolean): void {
305
+ if (!additive) this.selection.clear()
306
+ for (const n of this.nodes.values()) {
307
+ const r = nodeRect(n)
308
+ const intersects = r.x < rect.x + rect.w && r.x + r.w > rect.x && r.y < rect.y + rect.h && r.y + r.h > rect.y
309
+ if (intersects) this.selection.add(n.id)
310
+ }
311
+ this.bump()
312
+ }
313
+
314
+ /** Replace the whole selection (click / Shift-click add / Esc clears). */
315
+ setSelection(ids: ReadonlyArray<string>): void {
316
+ this.selection = new Set(ids)
317
+ this.bump()
318
+ }
319
+
320
+ /**
321
+ * Poll-cycle maintenance: insert or refresh one node without touching
322
+ * its position (the live model is authoritative between saves; a poll
323
+ * must never snap a dragged card back to its last-saved spot). New
324
+ * nodes seat at the given coordinates (saved spot or hash seat).
325
+ */
326
+ upsertNode(n: WorldNode): void {
327
+ const existing = this.nodes.get(n.id)
328
+ if (existing === undefined) {
329
+ this.nodes.set(n.id, n)
330
+ this.bump()
331
+ return
332
+ }
333
+ existing.label = n.label
334
+ existing.name = n.name
335
+ existing.description = n.description
336
+ existing.live = n.live
337
+ existing.memberships = n.memberships
338
+ this.bump()
339
+ }
340
+
341
+ /** Drop a node that left the joined roster; clears it from selection. */
342
+ removeNode(id: string): void {
343
+ if (this.nodes.delete(id)) {
344
+ this.selection.delete(id)
345
+ this.bump()
346
+ }
347
+ }
348
+
349
+ /** Set or replace one frame rect (adopted layout or derived initial). */
350
+ setFrame(name: string, rect: FrameGeom): void {
351
+ this.frames.set(name, rect)
352
+ this.bump()
353
+ }
354
+
355
+ /** Drop a frame whose team vanished from the state payload. */
356
+ removeFrame(name: string): void {
357
+ if (this.frames.delete(name)) this.bump()
358
+ }
359
+
360
+ /** Update a peer node's reachability score (render attr; no bump unless it changed). */
361
+ upsertScore(id: string, score: number | undefined): void {
362
+ const n = this.nodes.get(id)
363
+ if (n === undefined || n.score === score) return
364
+ n.score = score
365
+ this.bump()
366
+ }
367
+
368
+ /** Node center positions, for the layout document builder. */
369
+ positions(): Map<string, LayoutPoint> {
370
+ const out = new Map<string, LayoutPoint>()
371
+ for (const [id, n] of this.nodes) out.set(id, { x: n.x, y: n.y })
372
+ return out
373
+ }
374
+
375
+ /** Frame rectangles, for the layout document builder. */
376
+ frameRects(): Map<string, LayoutRect> {
377
+ const out = new Map<string, LayoutRect>()
378
+ for (const [name, r] of this.frames) out.set(name, { ...r })
379
+ return out
380
+ }
381
+
382
+ /** Member ids of one team, in routing-priority (index) order. */
383
+ teamMemberIds(name: string): string[] {
384
+ const rows: Array<{ id: string; index: number }> = []
385
+ for (const n of this.nodes.values()) {
386
+ const m = n.memberships.find(x => x.team === name)
387
+ if (m !== undefined) rows.push({ id: n.id, index: m.index })
388
+ }
389
+ return rows.sort((a, b) => a.index - b.index).map(r => r.id)
390
+ }
391
+
392
+ /**
393
+ * Replace one team's roster (the single primitive every write action and
394
+ * its undo go through): entries for OTHER teams are preserved verbatim
395
+ * in their array position, this team's entries are rebuilt from `ids`
396
+ * (order = routing priority, indexes renormalized to 0..n-1 so the P
397
+ * badges stay continuous). One bump for the whole operation.
398
+ */
399
+ setTeamMembers(name: string, ids: ReadonlyArray<string>): void {
400
+ for (const n of this.nodes.values()) {
401
+ const rest = n.memberships.filter(m => m.team !== name)
402
+ const idx = ids.indexOf(n.id)
403
+ if (idx >= 0) rest.push({ team: name, index: idx })
404
+ n.memberships = rest
405
+ }
406
+ this.bump()
407
+ }
408
+
409
+ /** Union of node cards and frame rects; null when the canvas is empty. */
410
+ contentBounds(): WorldBounds | null {
411
+ let minX = Number.POSITIVE_INFINITY
412
+ let minY = Number.POSITIVE_INFINITY
413
+ let maxX = Number.NEGATIVE_INFINITY
414
+ let maxY = Number.NEGATIVE_INFINITY
415
+ const absorb = (r: LayoutRect): void => {
416
+ minX = Math.min(minX, r.x)
417
+ minY = Math.min(minY, r.y)
418
+ maxX = Math.max(maxX, r.x + r.w)
419
+ maxY = Math.max(maxY, r.y + r.h)
420
+ }
421
+ for (const n of this.nodes.values()) {
422
+ if (n.remote === true) continue // peer cards hang outside the field
423
+ absorb(nodeRect(n))
424
+ }
425
+ for (const r of this.frames.values()) absorb(r)
426
+ return minX === Number.POSITIVE_INFINITY ? null : { minX, minY, maxX, maxY }
427
+ }
428
+
429
+ /**
430
+ * Build from one state payload plus the persisted layout (when the host
431
+ * has one). Joined sessions only (same roster rule as the 3D scene);
432
+ * saved positions win, the deterministic card-scaled ring seat
433
+ * falls back - the same world spot the 3D grid plane shows for an
434
+ * unsaved node. Frames come from the layout when present; a frame with
435
+ * neither saved rect nor members is skipped. The layout is clamped with
436
+ * the host-mirror discipline, so malformed rows fall through to seats
437
+ * exactly like the 3D scene's typeof guard.
438
+ */
439
+ static fromState(input: {
440
+ sessions: ReadonlyArray<SessionLite>
441
+ teams: ReadonlyArray<TeamLite>
442
+ layout: unknown
443
+ }): WorldModel {
444
+ const model = new WorldModel()
445
+ const doc: LayoutDoc | undefined = clampDoc(input.layout)
446
+
447
+ const memberships = new Map<string, Array<Membership>>()
448
+ for (const team of input.teams) {
449
+ for (let i = 0; i < team.members.length; i++) {
450
+ const id = team.members[i]!.id
451
+ const list = memberships.get(id) ?? []
452
+ list.push({ team: team.name, index: i })
453
+ memberships.set(id, list)
454
+ }
455
+ }
456
+
457
+ for (const s of input.sessions) {
458
+ if (s.joined !== true) continue
459
+ const saved: LayoutPoint | undefined = doc?.nodes[s.id]
460
+ let x: number
461
+ let y: number
462
+ if (saved !== undefined && typeof saved.x === 'number' && typeof saved.y === 'number') {
463
+ x = saved.x
464
+ y = saved.y
465
+ } else {
466
+ const p = planSeatClear(s.id, [...model.nodes.values()])
467
+ x = p.x
468
+ y = p.y
469
+ }
470
+ model.nodes.set(s.id, {
471
+ id: s.id,
472
+ x,
473
+ y,
474
+ label: s.label,
475
+ team: s.team,
476
+ name: s.name,
477
+ description: s.description,
478
+ live: s.live !== false,
479
+ memberships: memberships.get(s.id) ?? [],
480
+ })
481
+ }
482
+
483
+ for (const team of input.teams) {
484
+ const savedRect = doc?.frames[team.name]
485
+ if (savedRect !== undefined) {
486
+ model.frames.set(team.name, { x: savedRect.x, y: savedRect.y, w: savedRect.w, h: savedRect.h })
487
+ continue
488
+ }
489
+ const memberRects: Array<LayoutRect> = []
490
+ for (const m of team.members) {
491
+ const n = model.nodes.get(m.id)
492
+ if (n !== undefined) memberRects.push(nodeRect(n))
493
+ }
494
+ if (memberRects.length > 0) model.frames.set(team.name, deriveInitialFrame(memberRects))
495
+ }
496
+
497
+ model.viewport = doc !== undefined ? { ...doc.viewport } : { x: 0, y: 0, scale: 1 }
498
+ return model
499
+ }
500
+ }
501
+
502
+ /** Functional entry point; see {@link WorldModel.fromState} for the contract. */
503
+ export function buildWorld(input: {
504
+ sessions: ReadonlyArray<SessionLite>
505
+ teams: ReadonlyArray<TeamLite>
506
+ layout: unknown
507
+ }): WorldModel {
508
+ return WorldModel.fromState(input)
509
+ }