@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.43
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 +2 -1
- package/README_zh.md +2 -1
- package/assets/nexusDist/assets/index-CaA76j0F.js +9 -0
- package/assets/nexusDist/index.html +1 -1
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
- package/assets/stageDist/index.html +12 -12
- package/cli/a2a.mjs +24 -4
- package/lib/canvas-store.js +249 -17
- package/lib/client.js +62 -72
- package/lib/client.js.map +1 -1
- package/lib/config.js +1 -0
- package/lib/faces/admin.js +103 -63
- package/lib/faces/wire.js +3 -1
- package/lib/index.js +116 -22
- package/lib/origin-claim.js +4 -0
- package/lib/skill/SKILL.md +117 -117
- package/lib/skill/references/collaboration.md +428 -428
- package/lib/skill/references/discovery.md +78 -78
- package/lib/skill/references/intake.md +107 -107
- package/lib/skill/references/membership.md +73 -73
- package/lib/skill/references/pitfalls.md +51 -51
- package/lib/skill/references/router.md +96 -96
- package/lib/skill/references/routing.md +65 -65
- package/lib/types/canvas-store.d.ts +48 -1
- package/lib/types/config.d.ts +2 -0
- package/lib/types/faces/admin.d.ts +2 -0
- package/lib/types/origin-claim.d.ts +2 -0
- package/nexus-stage/package.json +21 -21
- package/nexus-stage/scripts/mockhost.py +261 -261
- package/nexus-stage/src/canvas-ops.ts +191 -191
- package/nexus-stage/src/canvas-wire.ts +164 -164
- package/nexus-stage/src/cli-home-picker.ts +42 -42
- package/nexus-stage/src/fault.ts +56 -56
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +241 -241
- package/nexus-stage/src/layout-doc.ts +145 -145
- package/nexus-stage/src/main.ts +520 -513
- package/nexus-stage/src/planning-card-text.ts +73 -73
- package/nexus-stage/src/planning-card.ts +63 -63
- package/nexus-stage/src/planning-edges.ts +26 -26
- package/nexus-stage/src/planning-fit.ts +45 -45
- package/nexus-stage/src/planning-frame-hue.ts +13 -13
- package/nexus-stage/src/planning-legend.ts +39 -39
- package/nexus-stage/src/planning-member-handles.ts +61 -61
- package/nexus-stage/src/planning-menus.ts +422 -422
- package/nexus-stage/src/planning-mirror.ts +75 -75
- package/nexus-stage/src/planning-nodelist.ts +294 -294
- package/nexus-stage/src/planning-overlap.ts +159 -159
- package/nexus-stage/src/planning-reachability.ts +28 -28
- package/nexus-stage/src/planning-relayout.ts +121 -121
- package/nexus-stage/src/planning-roster.ts +15 -15
- package/nexus-stage/src/planning-search.ts +168 -168
- package/nexus-stage/src/planning-types.ts +115 -115
- package/nexus-stage/src/planning-undo.ts +68 -68
- package/nexus-stage/src/planning-view.ts +1499 -1499
- package/nexus-stage/src/planning.css +340 -340
- package/nexus-stage/src/seat-name-editor.ts +31 -31
- package/nexus-stage/src/session-drag.ts +64 -64
- package/nexus-stage/src/viewport.ts +106 -106
- package/nexus-stage/src/wb-chat.ts +684 -684
- package/nexus-stage/src/wb-directory.ts +237 -237
- package/nexus-stage/src/wb-ledger.ts +279 -279
- package/nexus-stage/src/wb-names.ts +43 -43
- package/nexus-stage/src/wb-rail.ts +147 -147
- package/nexus-stage/src/workbench.css +388 -388
- package/nexus-stage/src/workbench.ts +1294 -1272
- package/nexus-stage/src/world.ts +509 -509
- package/nexus-stage/tsconfig.json +24 -24
- package/nexus-stage/vite.config.ts +15 -15
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
|
@@ -1,191 +1,191 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Canvas action algebra for PR C (design.md §3.3/§3.4): the five user-facing
|
|
3
|
-
* write actions as optimistic model mutations that each return a TEAM-SCOPED
|
|
4
|
-
* undo. Scoped undos are the feedback loop's core - a whole-state revert
|
|
5
|
-
* would clobber other queued actions' optimistic state; a per-team undo only
|
|
6
|
-
* ever touches the team whose write failed, so queued actions on other teams
|
|
7
|
-
* survive, and undo state (captured at apply time) stays consistent with the
|
|
8
|
-
* per-team serial settle order.
|
|
9
|
-
*
|
|
10
|
-
* Pure: no DOM, no three, no fetch. The wire (canvas-wire.ts) owns transport;
|
|
11
|
-
* the view owns calling applyAction before emitting and undo() on failure.
|
|
12
|
-
* @module nexus-stage/canvas-ops
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { nodeRect, deriveInitialFrame, type WorldModel } from './world'
|
|
16
|
-
import type { LayoutDoc } from './layout-doc'
|
|
17
|
-
|
|
18
|
-
/** One pre-computed host op (remove = remove-member, add = add-member). */
|
|
19
|
-
export type RosterOp = { op: 'remove' | 'add'; id: string }
|
|
20
|
-
|
|
21
|
-
/** The five user-facing write actions (one per design.md §3.3 row), plus the two network join/leave actions the panel-slim migration moved into the planning view. */
|
|
22
|
-
export type CanvasAction = (
|
|
23
|
-
| { type: 'create-team'; name: string; ids: readonly string[]; /** true when the model had no such team before this action; NOT server creation provenance and never authority to delete on failure. */ created?: boolean }
|
|
24
|
-
| { type: 'add-member'; team: string; ids: readonly string[]; /** gh#41: set when the target frame is a REMOTE peer's team — the wire routes the op through the host fleet relay. */ fleetPeer?: string }
|
|
25
|
-
| { type: 'remove-member'; team: string; ids: readonly string[]; /** gh#41: see add-member. */ fleetPeer?: string }
|
|
26
|
-
| { type: 'remove-team'; name: string; /** set when the frame is a REMOTE peer's team: the owner dissolves it, through the relay. */ fleetPeer?: string }
|
|
27
|
-
/** `ops` comes from reorderOps() at emit time; the wire stays model-free. */
|
|
28
|
-
| { type: 'reorder'; team: string; ops: ReadonlyArray<RosterOp> }
|
|
29
|
-
/** Network-level join/leave (the session joins the A2A mesh) — distinct from canvas-team membership. */
|
|
30
|
-
| { type: 'join-network'; id: string }
|
|
31
|
-
| { type: 'leave-network'; id: string }
|
|
32
|
-
) & { roster?: boolean }
|
|
33
|
-
|
|
34
|
-
/** Reverts exactly what one applyAction did (team-scoped). */
|
|
35
|
-
export type Undo = () => void
|
|
36
|
-
|
|
37
|
-
/** The team whose writes this action touches (queue key for the wire). */
|
|
38
|
-
export function actionTeam(a: CanvasAction): string {
|
|
39
|
-
// Network join/leave are session-scoped, not team-scoped: one shared
|
|
40
|
-
// queue key keeps them serial against each other.
|
|
41
|
-
if (a.type === 'join-network' || a.type === 'leave-network') return '$network'
|
|
42
|
-
return a.type === 'create-team' || a.type === 'remove-team' ? a.name : a.team
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Apply one action's optimistic mutation and return its undo. The undo is
|
|
47
|
-
* expressed as the INVERSE DELTA against the roster at undo time, never as
|
|
48
|
-
* a wholesale restore of a captured prefix - a later queued action on the
|
|
49
|
-
* same team may have applied optimistically before this action's write
|
|
50
|
-
* fails, and rolling back must remove only this action's effect.
|
|
51
|
-
*/
|
|
52
|
-
export function applyAction(model: WorldModel, a: CanvasAction): Undo {
|
|
53
|
-
if (a.type === 'create-team') {
|
|
54
|
-
const rects = a.ids
|
|
55
|
-
.map(id => model.getNode(id))
|
|
56
|
-
.filter(n => n !== undefined)
|
|
57
|
-
.map(n => nodeRect(n))
|
|
58
|
-
// Ids not on the canvas yet (a create emitted from the node list for an
|
|
59
|
-
// unteamed session) resolve no rect: skip the optimistic frame so the
|
|
60
|
-
// next poll derives it from the member's real seat instead of pinning a
|
|
61
|
-
// 260x160 placeholder at the origin.
|
|
62
|
-
if (rects.length > 0) model.setFrame(a.name, deriveInitialFrame(rects))
|
|
63
|
-
model.setTeamMembers(a.name, a.ids)
|
|
64
|
-
return () => {
|
|
65
|
-
// Only the created ids (a later add to this new team must survive).
|
|
66
|
-
model.setTeamMembers(a.name, model.teamMemberIds(a.name).filter(id => !a.ids.includes(id)))
|
|
67
|
-
model.removeFrame(a.name)
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
if (a.type === 'add-member') {
|
|
71
|
-
const pre = model.teamMemberIds(a.team)
|
|
72
|
-
const fresh = a.ids.filter(id => !pre.includes(id))
|
|
73
|
-
model.setTeamMembers(a.team, [...pre, ...fresh])
|
|
74
|
-
return () => {
|
|
75
|
-
model.setTeamMembers(a.team, model.teamMemberIds(a.team).filter(id => !fresh.includes(id)))
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (a.type === 'remove-member') {
|
|
79
|
-
const pre = model.teamMemberIds(a.team)
|
|
80
|
-
model.setTeamMembers(a.team, pre.filter(id => !a.ids.includes(id)))
|
|
81
|
-
return () => {
|
|
82
|
-
// Re-append the removed ids that nothing else has re-added since.
|
|
83
|
-
const current = model.teamMemberIds(a.team)
|
|
84
|
-
model.setTeamMembers(a.team, [...current, ...a.ids.filter(id => !current.includes(id))])
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (a.type === 'remove-team') {
|
|
88
|
-
const preIds = model.teamMemberIds(a.name)
|
|
89
|
-
const frame = model.getFrame(a.name)
|
|
90
|
-
const preRect = frame !== undefined ? { ...frame } : undefined
|
|
91
|
-
model.setTeamMembers(a.name, [])
|
|
92
|
-
model.removeFrame(a.name)
|
|
93
|
-
return () => {
|
|
94
|
-
if (preRect !== undefined) model.setFrame(a.name, preRect)
|
|
95
|
-
model.setTeamMembers(a.name, preIds)
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
// Network join/leave: no optimistic canvas mutation — the next inventory
|
|
99
|
-
// poll absorbs the host's truth (the node appears/disappears on the
|
|
100
|
-
// reconcile after the host accepts), so the undo is a no-op.
|
|
101
|
-
if (a.type === 'join-network' || a.type === 'leave-network') {
|
|
102
|
-
return () => {}
|
|
103
|
-
}
|
|
104
|
-
// reorder: apply the pre-computed ops to the current roster (remove
|
|
105
|
-
// anywhere, append). The undo WALKS the current roster back to the
|
|
106
|
-
// captured pre-order (same append-host discipline), so members a later
|
|
107
|
-
// queued action added stay put.
|
|
108
|
-
const pre = model.teamMemberIds(a.team)
|
|
109
|
-
let roster = [...pre]
|
|
110
|
-
for (const op of a.ops) {
|
|
111
|
-
if (op.op === 'remove') roster = roster.filter(x => x !== op.id)
|
|
112
|
-
else roster.push(op.id)
|
|
113
|
-
}
|
|
114
|
-
model.setTeamMembers(a.team, roster)
|
|
115
|
-
return () => {
|
|
116
|
-
let back = model.teamMemberIds(a.team)
|
|
117
|
-
for (let i = 0; i < pre.length; i++) {
|
|
118
|
-
const id = pre[i]!
|
|
119
|
-
if (back[i] === id) continue
|
|
120
|
-
if (!back.includes(id)) continue
|
|
121
|
-
back = back.filter(x => x !== id)
|
|
122
|
-
back.push(id)
|
|
123
|
-
}
|
|
124
|
-
model.setTeamMembers(a.team, back)
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Settle the optimistic delta and forget geometry only for a removed frame.
|
|
129
|
-
* Never prune all saved frames: remote/offline teams may be absent this poll.
|
|
130
|
-
* The caller schedules a correcting save even on rollback, so an earlier
|
|
131
|
-
* in-flight layout POST cannot leave optimistic geometry persisted forever.
|
|
132
|
-
*/
|
|
133
|
-
export function settleAction(model: WorldModel, a: CanvasAction, ok: boolean, undo: Undo, layout: LayoutDoc | undefined): LayoutDoc | undefined {
|
|
134
|
-
if (!ok) undo()
|
|
135
|
-
const removed = (a.type === 'remove-team' && ok) || (a.type === 'create-team' && !ok && a.created === true)
|
|
136
|
-
if (!removed || layout === undefined || model.getFrame(actionTeam(a)) !== undefined) return layout
|
|
137
|
-
const frames = { ...layout.frames }
|
|
138
|
-
delete frames[actionTeam(a)]
|
|
139
|
-
return { ...layout, frames }
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* The minimal remove+add walk that turns `current` into `desired` on a
|
|
144
|
-
* host whose add-member always APPENDS: walk desired positions in order;
|
|
145
|
-
* whenever the working copy disagrees, remove+re-add the desired member
|
|
146
|
-
* (host-side remove is positional, add appends, so the working copy mirrors
|
|
147
|
-
* the host roster exactly). Bounded at 2*|desired| writes and converges;
|
|
148
|
-
* an already-equal roster yields zero ops; desired ids absent from current
|
|
149
|
-
* are skipped (adding members is never reorder's business).
|
|
150
|
-
*/
|
|
151
|
-
export function reorderOps(
|
|
152
|
-
current: ReadonlyArray<string>,
|
|
153
|
-
desired: ReadonlyArray<string>,
|
|
154
|
-
): Array<{ op: 'remove' | 'add'; id: string }> {
|
|
155
|
-
const working = [...current]
|
|
156
|
-
const ops: Array<{ op: 'remove' | 'add'; id: string }> = []
|
|
157
|
-
for (let i = 0; i < desired.length; i++) {
|
|
158
|
-
const id = desired[i]!
|
|
159
|
-
if (working[i] === id) continue
|
|
160
|
-
if (!working.includes(id)) continue // not a member: not reorder's business
|
|
161
|
-
ops.push({ op: 'remove', id })
|
|
162
|
-
ops.push({ op: 'add', id })
|
|
163
|
-
working.splice(working.indexOf(id), 1)
|
|
164
|
-
working.push(id)
|
|
165
|
-
}
|
|
166
|
-
return ops
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** That team's members ordered by card-center y (ties: current priority). */
|
|
170
|
-
export function yOrderedMembers(model: WorldModel, team: string): string[] {
|
|
171
|
-
return model
|
|
172
|
-
.teamMemberIds(team)
|
|
173
|
-
.map(id => ({ id, y: model.getNode(id)?.y ?? 0 }))
|
|
174
|
-
.sort((a, b) => a.y - b.y)
|
|
175
|
-
.map(r => r.id)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Innermost (smallest-area) frame containing the world point, if any. */
|
|
179
|
-
export function innermostFrameAt(
|
|
180
|
-
frames: ReadonlyMap<string, { x: number; y: number; w: number; h: number }>,
|
|
181
|
-
p: { x: number; y: number },
|
|
182
|
-
): string | undefined {
|
|
183
|
-
let best: string | undefined
|
|
184
|
-
let bestArea = Number.POSITIVE_INFINITY
|
|
185
|
-
for (const [name, r] of frames) {
|
|
186
|
-
if (p.x < r.x || p.x > r.x + r.w || p.y < r.y || p.y > r.y + r.h) continue
|
|
187
|
-
const area = r.w * r.h
|
|
188
|
-
if (area < bestArea) { best = name; bestArea = area }
|
|
189
|
-
}
|
|
190
|
-
return best
|
|
191
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Canvas action algebra for PR C (design.md §3.3/§3.4): the five user-facing
|
|
3
|
+
* write actions as optimistic model mutations that each return a TEAM-SCOPED
|
|
4
|
+
* undo. Scoped undos are the feedback loop's core - a whole-state revert
|
|
5
|
+
* would clobber other queued actions' optimistic state; a per-team undo only
|
|
6
|
+
* ever touches the team whose write failed, so queued actions on other teams
|
|
7
|
+
* survive, and undo state (captured at apply time) stays consistent with the
|
|
8
|
+
* per-team serial settle order.
|
|
9
|
+
*
|
|
10
|
+
* Pure: no DOM, no three, no fetch. The wire (canvas-wire.ts) owns transport;
|
|
11
|
+
* the view owns calling applyAction before emitting and undo() on failure.
|
|
12
|
+
* @module nexus-stage/canvas-ops
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { nodeRect, deriveInitialFrame, type WorldModel } from './world'
|
|
16
|
+
import type { LayoutDoc } from './layout-doc'
|
|
17
|
+
|
|
18
|
+
/** One pre-computed host op (remove = remove-member, add = add-member). */
|
|
19
|
+
export type RosterOp = { op: 'remove' | 'add'; id: string }
|
|
20
|
+
|
|
21
|
+
/** The five user-facing write actions (one per design.md §3.3 row), plus the two network join/leave actions the panel-slim migration moved into the planning view. */
|
|
22
|
+
export type CanvasAction = (
|
|
23
|
+
| { type: 'create-team'; name: string; ids: readonly string[]; /** true when the model had no such team before this action; NOT server creation provenance and never authority to delete on failure. */ created?: boolean }
|
|
24
|
+
| { type: 'add-member'; team: string; ids: readonly string[]; /** gh#41: set when the target frame is a REMOTE peer's team — the wire routes the op through the host fleet relay. */ fleetPeer?: string }
|
|
25
|
+
| { type: 'remove-member'; team: string; ids: readonly string[]; /** gh#41: see add-member. */ fleetPeer?: string }
|
|
26
|
+
| { type: 'remove-team'; name: string; /** set when the frame is a REMOTE peer's team: the owner dissolves it, through the relay. */ fleetPeer?: string }
|
|
27
|
+
/** `ops` comes from reorderOps() at emit time; the wire stays model-free. */
|
|
28
|
+
| { type: 'reorder'; team: string; ops: ReadonlyArray<RosterOp> }
|
|
29
|
+
/** Network-level join/leave (the session joins the A2A mesh) — distinct from canvas-team membership. */
|
|
30
|
+
| { type: 'join-network'; id: string }
|
|
31
|
+
| { type: 'leave-network'; id: string }
|
|
32
|
+
) & { roster?: boolean }
|
|
33
|
+
|
|
34
|
+
/** Reverts exactly what one applyAction did (team-scoped). */
|
|
35
|
+
export type Undo = () => void
|
|
36
|
+
|
|
37
|
+
/** The team whose writes this action touches (queue key for the wire). */
|
|
38
|
+
export function actionTeam(a: CanvasAction): string {
|
|
39
|
+
// Network join/leave are session-scoped, not team-scoped: one shared
|
|
40
|
+
// queue key keeps them serial against each other.
|
|
41
|
+
if (a.type === 'join-network' || a.type === 'leave-network') return '$network'
|
|
42
|
+
return a.type === 'create-team' || a.type === 'remove-team' ? a.name : a.team
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Apply one action's optimistic mutation and return its undo. The undo is
|
|
47
|
+
* expressed as the INVERSE DELTA against the roster at undo time, never as
|
|
48
|
+
* a wholesale restore of a captured prefix - a later queued action on the
|
|
49
|
+
* same team may have applied optimistically before this action's write
|
|
50
|
+
* fails, and rolling back must remove only this action's effect.
|
|
51
|
+
*/
|
|
52
|
+
export function applyAction(model: WorldModel, a: CanvasAction): Undo {
|
|
53
|
+
if (a.type === 'create-team') {
|
|
54
|
+
const rects = a.ids
|
|
55
|
+
.map(id => model.getNode(id))
|
|
56
|
+
.filter(n => n !== undefined)
|
|
57
|
+
.map(n => nodeRect(n))
|
|
58
|
+
// Ids not on the canvas yet (a create emitted from the node list for an
|
|
59
|
+
// unteamed session) resolve no rect: skip the optimistic frame so the
|
|
60
|
+
// next poll derives it from the member's real seat instead of pinning a
|
|
61
|
+
// 260x160 placeholder at the origin.
|
|
62
|
+
if (rects.length > 0) model.setFrame(a.name, deriveInitialFrame(rects))
|
|
63
|
+
model.setTeamMembers(a.name, a.ids)
|
|
64
|
+
return () => {
|
|
65
|
+
// Only the created ids (a later add to this new team must survive).
|
|
66
|
+
model.setTeamMembers(a.name, model.teamMemberIds(a.name).filter(id => !a.ids.includes(id)))
|
|
67
|
+
model.removeFrame(a.name)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (a.type === 'add-member') {
|
|
71
|
+
const pre = model.teamMemberIds(a.team)
|
|
72
|
+
const fresh = a.ids.filter(id => !pre.includes(id))
|
|
73
|
+
model.setTeamMembers(a.team, [...pre, ...fresh])
|
|
74
|
+
return () => {
|
|
75
|
+
model.setTeamMembers(a.team, model.teamMemberIds(a.team).filter(id => !fresh.includes(id)))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (a.type === 'remove-member') {
|
|
79
|
+
const pre = model.teamMemberIds(a.team)
|
|
80
|
+
model.setTeamMembers(a.team, pre.filter(id => !a.ids.includes(id)))
|
|
81
|
+
return () => {
|
|
82
|
+
// Re-append the removed ids that nothing else has re-added since.
|
|
83
|
+
const current = model.teamMemberIds(a.team)
|
|
84
|
+
model.setTeamMembers(a.team, [...current, ...a.ids.filter(id => !current.includes(id))])
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (a.type === 'remove-team') {
|
|
88
|
+
const preIds = model.teamMemberIds(a.name)
|
|
89
|
+
const frame = model.getFrame(a.name)
|
|
90
|
+
const preRect = frame !== undefined ? { ...frame } : undefined
|
|
91
|
+
model.setTeamMembers(a.name, [])
|
|
92
|
+
model.removeFrame(a.name)
|
|
93
|
+
return () => {
|
|
94
|
+
if (preRect !== undefined) model.setFrame(a.name, preRect)
|
|
95
|
+
model.setTeamMembers(a.name, preIds)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Network join/leave: no optimistic canvas mutation — the next inventory
|
|
99
|
+
// poll absorbs the host's truth (the node appears/disappears on the
|
|
100
|
+
// reconcile after the host accepts), so the undo is a no-op.
|
|
101
|
+
if (a.type === 'join-network' || a.type === 'leave-network') {
|
|
102
|
+
return () => {}
|
|
103
|
+
}
|
|
104
|
+
// reorder: apply the pre-computed ops to the current roster (remove
|
|
105
|
+
// anywhere, append). The undo WALKS the current roster back to the
|
|
106
|
+
// captured pre-order (same append-host discipline), so members a later
|
|
107
|
+
// queued action added stay put.
|
|
108
|
+
const pre = model.teamMemberIds(a.team)
|
|
109
|
+
let roster = [...pre]
|
|
110
|
+
for (const op of a.ops) {
|
|
111
|
+
if (op.op === 'remove') roster = roster.filter(x => x !== op.id)
|
|
112
|
+
else roster.push(op.id)
|
|
113
|
+
}
|
|
114
|
+
model.setTeamMembers(a.team, roster)
|
|
115
|
+
return () => {
|
|
116
|
+
let back = model.teamMemberIds(a.team)
|
|
117
|
+
for (let i = 0; i < pre.length; i++) {
|
|
118
|
+
const id = pre[i]!
|
|
119
|
+
if (back[i] === id) continue
|
|
120
|
+
if (!back.includes(id)) continue
|
|
121
|
+
back = back.filter(x => x !== id)
|
|
122
|
+
back.push(id)
|
|
123
|
+
}
|
|
124
|
+
model.setTeamMembers(a.team, back)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Settle the optimistic delta and forget geometry only for a removed frame.
|
|
129
|
+
* Never prune all saved frames: remote/offline teams may be absent this poll.
|
|
130
|
+
* The caller schedules a correcting save even on rollback, so an earlier
|
|
131
|
+
* in-flight layout POST cannot leave optimistic geometry persisted forever.
|
|
132
|
+
*/
|
|
133
|
+
export function settleAction(model: WorldModel, a: CanvasAction, ok: boolean, undo: Undo, layout: LayoutDoc | undefined): LayoutDoc | undefined {
|
|
134
|
+
if (!ok) undo()
|
|
135
|
+
const removed = (a.type === 'remove-team' && ok) || (a.type === 'create-team' && !ok && a.created === true)
|
|
136
|
+
if (!removed || layout === undefined || model.getFrame(actionTeam(a)) !== undefined) return layout
|
|
137
|
+
const frames = { ...layout.frames }
|
|
138
|
+
delete frames[actionTeam(a)]
|
|
139
|
+
return { ...layout, frames }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The minimal remove+add walk that turns `current` into `desired` on a
|
|
144
|
+
* host whose add-member always APPENDS: walk desired positions in order;
|
|
145
|
+
* whenever the working copy disagrees, remove+re-add the desired member
|
|
146
|
+
* (host-side remove is positional, add appends, so the working copy mirrors
|
|
147
|
+
* the host roster exactly). Bounded at 2*|desired| writes and converges;
|
|
148
|
+
* an already-equal roster yields zero ops; desired ids absent from current
|
|
149
|
+
* are skipped (adding members is never reorder's business).
|
|
150
|
+
*/
|
|
151
|
+
export function reorderOps(
|
|
152
|
+
current: ReadonlyArray<string>,
|
|
153
|
+
desired: ReadonlyArray<string>,
|
|
154
|
+
): Array<{ op: 'remove' | 'add'; id: string }> {
|
|
155
|
+
const working = [...current]
|
|
156
|
+
const ops: Array<{ op: 'remove' | 'add'; id: string }> = []
|
|
157
|
+
for (let i = 0; i < desired.length; i++) {
|
|
158
|
+
const id = desired[i]!
|
|
159
|
+
if (working[i] === id) continue
|
|
160
|
+
if (!working.includes(id)) continue // not a member: not reorder's business
|
|
161
|
+
ops.push({ op: 'remove', id })
|
|
162
|
+
ops.push({ op: 'add', id })
|
|
163
|
+
working.splice(working.indexOf(id), 1)
|
|
164
|
+
working.push(id)
|
|
165
|
+
}
|
|
166
|
+
return ops
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** That team's members ordered by card-center y (ties: current priority). */
|
|
170
|
+
export function yOrderedMembers(model: WorldModel, team: string): string[] {
|
|
171
|
+
return model
|
|
172
|
+
.teamMemberIds(team)
|
|
173
|
+
.map(id => ({ id, y: model.getNode(id)?.y ?? 0 }))
|
|
174
|
+
.sort((a, b) => a.y - b.y)
|
|
175
|
+
.map(r => r.id)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Innermost (smallest-area) frame containing the world point, if any. */
|
|
179
|
+
export function innermostFrameAt(
|
|
180
|
+
frames: ReadonlyMap<string, { x: number; y: number; w: number; h: number }>,
|
|
181
|
+
p: { x: number; y: number },
|
|
182
|
+
): string | undefined {
|
|
183
|
+
let best: string | undefined
|
|
184
|
+
let bestArea = Number.POSITIVE_INFINITY
|
|
185
|
+
for (const [name, r] of frames) {
|
|
186
|
+
if (p.x < r.x || p.x > r.x + r.w || p.y < r.y || p.y > r.y + r.h) continue
|
|
187
|
+
const area = r.w * r.h
|
|
188
|
+
if (area < bestArea) { best = name; bestArea = area }
|
|
189
|
+
}
|
|
190
|
+
return best
|
|
191
|
+
}
|