@songsid/agend 2.3.1 → 2.3.2-beta.2

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 (160) hide show
  1. package/dist/authority/authority-state.d.ts +4 -0
  2. package/dist/authority/authority-state.js +16 -10
  3. package/dist/authority/authority-state.js.map +1 -1
  4. package/dist/authority/session-authorizations.d.ts +7 -1
  5. package/dist/authority/session-authorizations.js +6 -4
  6. package/dist/authority/session-authorizations.js.map +1 -1
  7. package/dist/authority/web-authority.d.ts +19 -6
  8. package/dist/authority/web-authority.js +22 -8
  9. package/dist/authority/web-authority.js.map +1 -1
  10. package/dist/channel/adapters/discord.d.ts +2 -0
  11. package/dist/channel/adapters/discord.js +33 -3
  12. package/dist/channel/adapters/discord.js.map +1 -1
  13. package/dist/channel/mcp-tools.js +5 -5
  14. package/dist/channel/mcp-tools.js.map +1 -1
  15. package/dist/channel/types.d.ts +11 -0
  16. package/dist/config-validator.js +40 -3
  17. package/dist/config-validator.js.map +1 -1
  18. package/dist/daemon.d.ts +34 -3
  19. package/dist/daemon.js +227 -46
  20. package/dist/daemon.js.map +1 -1
  21. package/dist/delivery-outbox.d.ts +12 -1
  22. package/dist/delivery-outbox.js +67 -15
  23. package/dist/delivery-outbox.js.map +1 -1
  24. package/dist/doctor.d.ts +3 -0
  25. package/dist/doctor.js +9 -0
  26. package/dist/doctor.js.map +1 -1
  27. package/dist/event-log.d.ts +5 -0
  28. package/dist/event-log.js +13 -0
  29. package/dist/event-log.js.map +1 -1
  30. package/dist/fleet-manager.d.ts +140 -0
  31. package/dist/fleet-manager.js +414 -29
  32. package/dist/fleet-manager.js.map +1 -1
  33. package/dist/fleet-process-handlers.d.ts +1 -0
  34. package/dist/fleet-process-handlers.js +9 -1
  35. package/dist/fleet-process-handlers.js.map +1 -1
  36. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +4 -4
  37. package/dist/general-knowledge/skills/web-appearance/SKILL.md +7 -7
  38. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +113 -55
  39. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +114 -56
  40. package/dist/guest-seen-people.d.ts +38 -0
  41. package/dist/guest-seen-people.js +78 -0
  42. package/dist/guest-seen-people.js.map +1 -0
  43. package/dist/instance-config-impact.js +4 -1
  44. package/dist/instance-config-impact.js.map +1 -1
  45. package/dist/instance-lifecycle.d.ts +1 -0
  46. package/dist/instance-lifecycle.js +16 -1
  47. package/dist/instance-lifecycle.js.map +1 -1
  48. package/dist/locale.js +38 -10
  49. package/dist/locale.js.map +1 -1
  50. package/dist/mac-auto-restore.d.ts +117 -0
  51. package/dist/mac-auto-restore.js +294 -0
  52. package/dist/mac-auto-restore.js.map +1 -0
  53. package/dist/outbound-schemas.d.ts +2 -0
  54. package/dist/outbound-schemas.js +2 -0
  55. package/dist/outbound-schemas.js.map +1 -1
  56. package/dist/public-web-gateway.js +9 -1
  57. package/dist/public-web-gateway.js.map +1 -1
  58. package/dist/settings-api.d.ts +26 -0
  59. package/dist/settings-api.js +107 -1
  60. package/dist/settings-api.js.map +1 -1
  61. package/dist/settings-change.js +37 -3
  62. package/dist/settings-change.js.map +1 -1
  63. package/dist/settings-effect.js +5 -2
  64. package/dist/settings-effect.js.map +1 -1
  65. package/dist/start-attempt.d.ts +9 -5
  66. package/dist/start-attempt.js +27 -8
  67. package/dist/start-attempt.js.map +1 -1
  68. package/dist/start-failure-chat.d.ts +4 -0
  69. package/dist/start-failure-chat.js +23 -6
  70. package/dist/start-failure-chat.js.map +1 -1
  71. package/dist/start-failure-notice.d.ts +4 -0
  72. package/dist/start-failure-notice.js +8 -0
  73. package/dist/start-failure-notice.js.map +1 -1
  74. package/dist/start-failure.d.ts +25 -0
  75. package/dist/start-failure.js +75 -2
  76. package/dist/start-failure.js.map +1 -1
  77. package/dist/turn-reply-guard.d.ts +25 -0
  78. package/dist/turn-reply-guard.js +111 -12
  79. package/dist/turn-reply-guard.js.map +1 -1
  80. package/dist/types.d.ts +18 -1
  81. package/dist/ui/backend-signin.js +5 -2
  82. package/dist/ui/chat-ambient.js +1 -1
  83. package/dist/ui/chat-effects.js +4 -4
  84. package/dist/ui/chat-render.js +5 -2
  85. package/dist/ui/chat-strings.js +2 -2
  86. package/dist/ui/chat-thread.js +2 -2
  87. package/dist/ui/first-run.js +2 -2
  88. package/dist/ui/fleet-canvas-view.js +114 -0
  89. package/dist/ui/fleet-canvas.js +15 -77
  90. package/dist/ui/fleet-office-editor.js +80 -5
  91. package/dist/ui/fleet-office-layout.js +197 -0
  92. package/dist/ui/fleet-office-model.js +6 -3
  93. package/dist/ui/fleet-office-overview.js +167 -0
  94. package/dist/ui/fleet-office-pack.d.ts +39 -0
  95. package/dist/ui/fleet-office-pack.js +240 -29
  96. package/dist/ui/fleet-office-skin.js +140 -29
  97. package/dist/ui/fleet-office-store.js +84 -5
  98. package/dist/ui/fleet-office-view.js +154 -0
  99. package/dist/ui/fleet-office-zones.js +95 -33
  100. package/dist/ui/fleet-office.js +779 -140
  101. package/dist/ui/fleet-usage.js +7 -6
  102. package/dist/ui/panel-chat.js +1 -1
  103. package/dist/ui/panel-details.js +2 -2
  104. package/dist/ui/panel-fleet.js +11 -77
  105. package/dist/ui/panel-log.js +2 -2
  106. package/dist/ui/panel-settings.js +578 -163
  107. package/dist/ui/search-dialog.js +26 -7
  108. package/dist/ui/settings-appearance.js +82 -29
  109. package/dist/ui/settings-apply.js +42 -7
  110. package/dist/ui/settings-dialogs.js +68 -22
  111. package/dist/ui/settings-draft.js +51 -0
  112. package/dist/ui/settings-guests.js +244 -78
  113. package/dist/ui/settings-learn.js +2 -2
  114. package/dist/ui/settings-model.js +2 -0
  115. package/dist/ui/settings-registry.js +142 -0
  116. package/dist/ui/settings-strings.js +102 -44
  117. package/dist/ui/settings-web-authority.js +34 -23
  118. package/dist/ui/shared/app-i18n.js +14 -0
  119. package/dist/ui/shared/app-nav.js +10 -5
  120. package/dist/ui/shared/app-prefs.js +80 -2
  121. package/dist/ui/shared/app-route.js +39 -5
  122. package/dist/ui/shared/app-shell.js +59 -4
  123. package/dist/ui/shared/app-stream.js +2 -1
  124. package/dist/ui/shared/app.css +254 -17
  125. package/dist/ui/shared/app.js +3 -0
  126. package/dist/ui/shared/effect-stock-trust.js +1 -1
  127. package/dist/ui/shared/identity-color.js +4 -4
  128. package/dist/ui/shared/signin.js +2 -2
  129. package/dist/ui/shared/ui-dialog.js +6 -1
  130. package/dist/ui/shared/ui-icons.js +2 -0
  131. package/dist/ui/shared/web-authorize.js +13 -12
  132. package/dist/web-api.d.ts +8 -2
  133. package/dist/web-api.js +160 -5
  134. package/dist/web-api.js.map +1 -1
  135. package/dist/web-auth.js +7 -1
  136. package/dist/web-auth.js.map +1 -1
  137. package/dist/web-experimental.d.ts +1 -1
  138. package/dist/web-experimental.js +1 -1
  139. package/dist/web-experimental.js.map +1 -1
  140. package/dist/web-mod-agent.js +2 -2
  141. package/dist/web-mod-agent.js.map +1 -1
  142. package/dist/web-office-skins.d.ts +108 -0
  143. package/dist/web-office-skins.js +298 -0
  144. package/dist/web-office-skins.js.map +1 -0
  145. package/dist/web-office-store.d.ts +8 -2
  146. package/dist/web-office-store.js +37 -7
  147. package/dist/web-office-store.js.map +1 -1
  148. package/dist/web-settings-patch.d.ts +6 -0
  149. package/dist/web-settings-patch.js +61 -4
  150. package/dist/web-settings-patch.js.map +1 -1
  151. package/dist/web-shell-routes.d.ts +4 -2
  152. package/dist/web-shell-routes.js +17 -7
  153. package/dist/web-shell-routes.js.map +1 -1
  154. package/dist/working-recovery.d.ts +104 -0
  155. package/dist/working-recovery.js +263 -0
  156. package/dist/working-recovery.js.map +1 -0
  157. package/dist/working-state-journal.d.ts +3 -1
  158. package/dist/working-state-journal.js +15 -2
  159. package/dist/working-state-journal.js.map +1 -1
  160. package/package.json +1 -1
@@ -6,14 +6,16 @@
6
6
  // office the page makes itself (AUTO_ID, "the whole fleet", built from the teams as the canvas is), whose member
7
7
  // filter is the team / people picker; any other office is the host's (GET /ui/offices, fleet-office-store.js),
8
8
  // checked again here.
9
- // TODO(office editor): adding and renaming offices, assigning people, dragging seats and drawing paths is a later
10
- // step; it writes the same model through the store's saveOffices().
9
+ // The office editor (fleet-office-editor.js) adds, renames and deletes offices and picks who is in them; the layout
10
+ // editor (fleet-office-layout.js, #2513) moves things on a zones skin and keeps them as the office's `overrides`
11
+ // (fleet-office-pack.js) — both write the same model through the store's saveOffices().
11
12
  // - The auto layout: a lobby (General and the instances in no team), then a room per team in fleet.yaml's order, in a
12
13
  // grid that wraps at the stage's width (a phone gets narrower rooms); a desk per member; a corridor graph of waypoints
13
14
  // (each desk's aisle, a spine down each room's left padding, its door, a corridor under each row of rooms and one down
14
15
  // the left margin) that traffic walks along (bfsPath).
15
16
  // - Poses come from the org chart's states (fleet-org.js ORG_STATES / orgState); nothing here invents a status.
16
17
  import { hue } from "./fleet-traffic.js";
18
+ import { checkLayoutOverrides } from "./fleet-office-pack.js";
17
19
 
18
20
  // ── Poses: the org chart's states, as a character at a desk ──
19
21
 
@@ -115,7 +117,8 @@ export function officeOk(x) {
115
117
  if (!x || typeof x !== "object" || typeof x.id !== "string" || !ID.test(x.id) || x.id === AUTO_ID) return null;
116
118
  const name = typeof x.name === "string" && x.name.trim() ? x.name.trim().slice(0, 60) : x.id;
117
119
  const skin = typeof x.skin === "string" && ID.test(x.skin) ? x.skin : "pixel";
118
- return { id: x.id, name, skin, members: nameList(x.members), layout: layoutOk(x.layout) };
120
+ const ov = x.overrides === undefined ? null : checkLayoutOverrides(x.overrides);
121
+ return { id: x.id, name, skin, members: nameList(x.members), layout: layoutOk(x.layout), ...(ov && ov.ok ? { overrides: ov.overrides } : {}) };
119
122
  }
120
123
  /** A stored list of offices: the valid ones, ids unique, at most MAX_OFFICES. */
121
124
  export function officesOk(list) {
@@ -0,0 +1,167 @@
1
+ // #2554: the Office view's "All offices" — every stored office as a tile on one canvas, side by side, each drawn exactly
2
+ // as its own view would be (its style, its members, seats, poses, bubbles, walking), scaled into its tile. This module
3
+ // is the pure part (fleet-office.js draws it): who is in no office (the lobby tile), where the tiles go, what a point
4
+ // on the overview hits, which tiles are on screen, and the traffic that crosses between tiles.
5
+ //
6
+ // - Shown when at least two offices are stored (overviewOk); an id no stored office can have (ALL_ID: '*' is not an
7
+ // office id's character), kept per device as any office pick is.
8
+ // - The tiles: each as large as its office's drawn content — a zones scene its 1280×720, a desks office its rooms'
9
+ // bounds (cropToRooms: an office of three is small) — scaled down to at most CELL_W × CELL_H, never up; a LABEL band
10
+ // above each (the page's label sits there), GAP apart, M from the edge. Shelf packing in the stored order (the lobby
11
+ // last): a row takes tiles while they fit a row width, each row as tall as its tallest tile and centred across. The
12
+ // row width: whichever (of the widths a run of tiles makes) lets the whole overview show largest in the panel (Fit's
13
+ // contain); a tie keeps the narrower. The same input always gives the same picture.
14
+ // - Traffic between people in different tiles is a straight flight between their seats in overview coordinates
15
+ // (createCrossFlights); between two people in the same tile it is that tile's own (createFlights, along its corridors).
16
+ import { FLIGHT, along, desksOf, membersOf, pathLength } from "./fleet-office-model.js";
17
+ import { hue } from "./fleet-traffic.js";
18
+
19
+ /** The switcher's "All offices" value: '*' is never in a stored office's id (fleet-office-model.js officeOk). */
20
+ export const ALL_ID = "*all";
21
+ /** The lobby tile's key: never an office's id. */
22
+ export const LOBBY_TILE = "\u0000lobby";
23
+ /** The overview's geometry, in logical units. */
24
+ export const OV = { CELL_W: 1280, CELL_H: 720, LABEL: 56, GAP: 24, M: 24 };
25
+
26
+ /** Whether "All offices" is offered: at least two stored offices. */
27
+ export const overviewOk = (stored) => Array.isArray(stored) && stored.length >= 2;
28
+
29
+ /**
30
+ * The people in no stored office, in the org chart's order (General, the teams, the rest): the lobby tile's. An office
31
+ * with no members holds everyone (fleet-office-model.js membersOf), so then nobody is left for the lobby.
32
+ */
33
+ export function lobbyNodes(stored, view) {
34
+ const placed = new Set((stored || []).flatMap((o) => membersOf(o, view).map((n) => n.name)));
35
+ const out = new Map();
36
+ for (const n of [...view.top, ...view.teams.flatMap((tm) => tm.nodes), ...view.rest.nodes]) if (!placed.has(n.name) && !out.has(n.name)) out.set(n.name, n);
37
+ return [...out.values()];
38
+ }
39
+
40
+ /** A scene's scale in its tile: its own size, scaled down to fit a CELL_W × CELL_H cell (never up). */
41
+ export const tileScale = (w, h) => Math.min(1, OV.CELL_W / Math.max(1, w), OV.CELL_H / Math.max(1, h));
42
+
43
+ /**
44
+ * A desks layout cropped to its rooms: everything moved so the rooms' bounds start at (0, 0), its size theirs (the
45
+ * corridors outside the rooms and the margins are not part of a tile). Unchanged when it has no rooms.
46
+ */
47
+ export function cropToRooms(L) {
48
+ if (!L.rooms.length) return L;
49
+ const x0 = Math.min(...L.rooms.map((r) => r.x)), y0 = Math.min(...L.rooms.map((r) => r.y));
50
+ const x1 = Math.max(...L.rooms.map((r) => r.x + r.w)), y1 = Math.max(...L.rooms.map((r) => r.y + r.h));
51
+ const mv = (p) => ({ ...p, x: p.x - x0, y: p.y - y0 });
52
+ return { ...L, rooms: L.rooms.map((r) => ({ ...mv(r), desks: r.desks.map((d) => ({ ...mv(d), seat: mv(d.seat) })) })), waypoints: (L.waypoints || []).map(mv),
53
+ width: x1 - x0, height: y1 - y0 };
54
+ }
55
+
56
+ /**
57
+ * The tiles of `scenes` ([{ key, w, h }], in order) on shelves at most `rowW` wide (a tile wider than that has a row of
58
+ * its own): each { key, x, y, w, h, s (its scale), label: { x, y, w, h } (the band above it) }; and the overview's size.
59
+ * Each row is centred across the widest; its tiles stand at its top, under their labels.
60
+ */
61
+ export function arrangeTiles(scenes, rowW) {
62
+ const { LABEL, GAP, M } = OV;
63
+ const rows = [];
64
+ let row = null;
65
+ for (const sc of scenes) {
66
+ const s = tileScale(sc.w, sc.h), it = { key: sc.key, s, w: Math.max(1, sc.w) * s, h: Math.max(1, sc.h) * s };
67
+ if (!row || row.w + GAP + it.w > rowW + 1e-6) { row = { items: [], w: -GAP, h: 0 }; rows.push(row); }
68
+ row.items.push(it); row.w += GAP + it.w; row.h = Math.max(row.h, it.h);
69
+ }
70
+ const inner = rows.length ? Math.max(...rows.map((r) => r.w)) : 0, tiles = [];
71
+ let y = M;
72
+ for (const r of rows) {
73
+ let x = M + (inner - r.w) / 2;
74
+ for (const it of r.items) {
75
+ tiles.push({ key: it.key, x, y: y + LABEL, w: it.w, h: it.h, s: it.s, label: { x, y, w: it.w, h: LABEL } });
76
+ x += it.w + GAP;
77
+ }
78
+ y += LABEL + r.h + GAP;
79
+ }
80
+ return { rows: rows.length, rowW, tiles, width: 2 * M + inner, height: rows.length ? y - GAP + M : 2 * M };
81
+ }
82
+
83
+ /**
84
+ * The tiles packed for a `size` panel: of the row widths a run of consecutive tiles makes, the one whose overview shows
85
+ * largest there (min of the two fits); a tie keeps the narrower.
86
+ */
87
+ export function tileLayout(scenes, size) {
88
+ const ws = scenes.map((sc) => Math.max(1, sc.w) * tileScale(sc.w, sc.h)), cands = new Set();
89
+ for (let i = 0; i < ws.length; i++) { let run = -OV.GAP; for (let j = i; j < ws.length; j++) { run += OV.GAP + ws[j]; cands.add(run); } }
90
+ let best = arrangeTiles(scenes, 0), bestK = -1;
91
+ for (const rowW of [...cands].sort((a, b) => a - b)) {
92
+ const a = arrangeTiles(scenes, rowW), k = Math.min(size.w / a.width, size.h / a.height);
93
+ if (k > bestK + 1e-9) { best = a; bestK = k; }
94
+ }
95
+ return best;
96
+ }
97
+
98
+ /** The tile whose scene holds the overview point (x, y), or null. */
99
+ export const tileAt = (lay, x, y) => lay.tiles.find((t) => x >= t.x && x < t.x + t.w && y >= t.y && y < t.y + t.h) || null;
100
+ /** The tile whose label band holds the overview point, or null. */
101
+ export const labelAt = (lay, x, y) => lay.tiles.find((t) => x >= t.label.x && x < t.label.x + t.label.w && y >= t.label.y && y < t.label.y + t.label.h) || null;
102
+ /** Overview → the tile's own scene units, and back; a box in the scene → the overview. */
103
+ export const inTile = (t, x, y) => ({ x: (x - t.x) / t.s, y: (y - t.y) / t.s });
104
+ export const fromTile = (t, p) => ({ x: t.x + p.x * t.s, y: t.y + p.y * t.s });
105
+ export const tileBox = (t, b) => ({ x: t.x + b.x * t.s, y: t.y + b.y * t.s, w: b.w * t.s, h: b.h * t.s });
106
+ /** The tiles a view ({ k, x, y }) shows any of in a `size` viewport: the rest are not drawn. */
107
+ export function visibleTiles(lay, v, size) {
108
+ return lay.tiles.filter((t) => {
109
+ const x = v.x + t.x * v.k, y = v.y + t.y * v.k;
110
+ return x < size.w && x + t.w * v.k > 0 && y < size.h && y + t.h * v.k > 0;
111
+ });
112
+ }
113
+
114
+ /**
115
+ * A straight flight between two people in different tiles: [the sender's seat, the receiver's] in overview units (the
116
+ * first tile each is in), or null — when either is drawn nowhere, or some tile holds both (that tile carries it along
117
+ * its corridors). `parts`: [{ tile, L }] in the tiles' order.
118
+ */
119
+ export function crossPath(parts, from, to) {
120
+ let a = null, z = null;
121
+ for (const p of parts) {
122
+ const ds = desksOf(p.L), da = ds.find((d) => d.node.name === from), dz = ds.find((d) => d.node.name === to);
123
+ if (da && dz) return null;
124
+ if (da && !a) a = fromTile(p.tile, da.seat);
125
+ if (dz && !z) z = fromTile(p.tile, dz.seat);
126
+ }
127
+ return a && z ? [a, z] : null;
128
+ }
129
+
130
+ const MAX_SEEN = 2000;
131
+ /**
132
+ * The items in the air between tiles — fleet-office-model.js createFlights's lifecycle (each new item once; none for a
133
+ * stale delegation or with reduced motion), on crossPath's straight line: `frame(now, parts)` → { id, hue, kind, x, y,
134
+ * t } in overview units; a trip with no crossing (both in one tile, or either gone) is dropped.
135
+ */
136
+ export function createCrossFlights({ max = FLIGHT.MAX } = {}) {
137
+ const seen = new Set();
138
+ let trips = [];
139
+ return {
140
+ sync(items, now, still = false) {
141
+ let k = 0, started = 0;
142
+ for (const it of items || []) {
143
+ if (seen.has(it.id)) continue;
144
+ seen.add(it.id);
145
+ if (seen.size > MAX_SEEN) seen.delete(seen.values().next().value);
146
+ if (still || it.phase === "stale") continue;
147
+ trips.push({ id: it.id, from: it.from, to: it.to, kind: it.kind, hue: hue(it.kind), start: now + k++ * FLIGHT.STAGGER_MS, parts: null, pts: null, ms: 0 });
148
+ started++;
149
+ }
150
+ if (trips.length > max) trips = trips.slice(trips.length - max);
151
+ return started;
152
+ },
153
+ frame(now, parts) {
154
+ const out = [];
155
+ trips = trips.filter((f) => {
156
+ if (f.parts !== parts) { f.parts = parts; f.pts = crossPath(parts, f.from, f.to); f.ms = f.pts ? Math.min(FLIGHT.MAX_MS, Math.max(FLIGHT.MIN_MS, pathLength(f.pts) / FLIGHT.SPEED)) : 0; }
157
+ if (!f.pts) return false;
158
+ const t = (now - f.start) / f.ms;
159
+ if (t >= 1) return false;
160
+ if (t >= 0) { const p = along(f.pts, t); out.push({ id: f.id, hue: f.hue, kind: f.kind, x: p.x, y: p.y, t }); }
161
+ return true;
162
+ });
163
+ return out;
164
+ },
165
+ size: () => trips.length,
166
+ };
167
+ }
@@ -0,0 +1,39 @@
1
+ // Types for fleet-office-pack.js: the page's module, which the host imports too (web-office-skins.ts,
2
+ // web-office-store.ts), so both check a skin pack and an office's layout overrides by the same rules.
3
+ /** `dw` × `dh`: its displayed size in logical units (w·scale × h·scale unless the manifest says otherwise). */
4
+ export interface PackStrip { src: string; frames: number; w: number; h: number; fps: number; ax: number; ay: number; dw: number; dh: number }
5
+ export interface PackPoint { x: number; y: number }
6
+ export interface PackArea { name: string; rect: { x: number; y: number; w: number; h: number }; seats: PackPoint[] }
7
+ export interface PackFurniture { id: string; sprite: PackStrip; x: number; y: number; depth: number | "y"; activeWhen: string[] | null; activeSprite: PackStrip | null }
8
+ export interface CheckedPack {
9
+ format: 1; id: string; scale: number; background?: string; foreground?: string; matte?: string;
10
+ areas: Record<string, PackArea>; stateZones: Record<string, string> | null; door: PackPoint;
11
+ waypoints: Array<{ id: string; x: number; y: number }>; edges: Array<[string, string]>;
12
+ furniture: PackFurniture[]; characters: Record<string, Record<string, PackStrip>>; byInstance: Record<string, string>;
13
+ props: Record<string, PackStrip>; bubbles: Record<string, Record<string, string[]>>;
14
+ room: { floor?: string; wall?: string }; desk: { back?: string; front?: string; lit?: string };
15
+ }
16
+ export interface LayoutOverrides {
17
+ pack: string;
18
+ seats?: Record<string, Array<[number, number]>>;
19
+ furniture?: Record<string, [number, number]>;
20
+ door?: [number, number];
21
+ waypoints?: Array<{ id: string; x: number; y: number }>;
22
+ edges?: Array<[string, string]>;
23
+ }
24
+ export const LOGICAL: { w: number; h: number };
25
+ export const CHAR_DEPTH: number;
26
+ export const PACK_STATES: string[];
27
+ export function own<T = unknown>(o: unknown, k: unknown): T | undefined;
28
+ export function inherited(k: unknown): boolean;
29
+ export function matteOk(v: unknown): v is string;
30
+ export function checkStrip(v: unknown, at?: string, scale?: number): PackStrip;
31
+ export function checkSkinPack(x: unknown): { ok: true; pack: CheckedPack } | { ok: false; error: string };
32
+ export function skinPackOk(x: unknown): CheckedPack | null;
33
+ export function packImages(pack: CheckedPack): string[];
34
+ export function packStrips(pack: CheckedPack): Array<{ at: string; strip: PackStrip }>;
35
+ export function checkLayoutOverrides(x: unknown): { ok: true; overrides: LayoutOverrides } | { ok: false; error: string };
36
+ export function applyOverrides(pack: CheckedPack, ov: LayoutOverrides | null | undefined): { ok: true; pack: CheckedPack } | { ok: false; error: string };
37
+ export function overridesBetween(pack: CheckedPack, edited: CheckedPack): LayoutOverrides | null;
38
+ export function poseStrip(set: Record<string, PackStrip> | null | undefined, pose: string, heading?: { dx: number; dy: number } | null): { strip: PackStrip; mirror: boolean } | null;
39
+ export function stripBox(st: PackStrip, x: number, y: number, mirror?: boolean): { x: number; y: number; w: number; h: number };
@@ -1,16 +1,26 @@
1
- // The Office view's skin-pack format, v1 (fleet-office-skin.js draws a checked pack; nothing loads one from outside yet).
1
+ // The Office view's skin-pack format, v1 (fleet-office-skin.js draws a checked pack; an admin uploads one — Fleet → Org
2
+ // chart → Office → Edit offices → Upload a skin pack — and the host keeps it, src/web-office-skins.ts, checking it with
3
+ // this same module: the page and the host agree on every rule).
2
4
  // Its shape follows Star-Office-UI's scene (MIT; github.com/ringhyacinth/Star-Office-UI): one background on a 16:9
3
5
  // logical canvas, named areas with ordered seat slots, furniture with a depth, and a state → area map. No code or art of
4
6
  // it is used here — the idea of the format only.
5
7
  //
6
8
  // A pack is a JSON manifest plus images. Every coordinate is in logical units on a 1280×720 canvas (LOGICAL), which
7
- // the page scales to fit its width. Images are pixel art, drawn unsmoothed at `scale` logical units per image pixel.
9
+ // the page shows whole, then pans and zooms (fleet-office-view.js). Images are pixel art, drawn unsmoothed at `scale` logical units per image pixel.
8
10
  //
9
11
  // {
10
12
  // "format": 1, required
11
13
  // "id": "cozy", letters, digits, - and _ (≤ 40); not a built-in skin's id
12
14
  // "scale": 2, 1–8 (default 2)
13
15
  // "background": "office.png", drawn over the whole 1280×720 canvas (16:9)
16
+ // "foreground": "front.png", optional, with stateZones: drawn over the whole canvas (16:9, as
17
+ // the background) above the people, the furniture and the items in
18
+ // the air, below the name tags, bubbles and "+n" counts (and Edit
19
+ // layout's overlay): door frames, pillars, table fronts that hide
20
+ // whoever walks behind them — transparent everywhere else
21
+ // "matte": "#1b1d24", optional (#2540): the colour around the scene where the Office
22
+ // view's panel is not 16:9 (or is zoomed out), "#rrggbb"; without it
23
+ // the page samples the background's edge pixels
14
24
  // "areas": { named zones: a rect [x, y, w, h] and its seats, in the order
15
25
  // "desks": { "rect": [40, 120, 620, 440], "seats": [[120, 220], [260, 220], …] }, they fill up
16
26
  // "lounge": { "rect": [700, 120, 540, 260], "seats": [[760, 260], …] } },
@@ -23,11 +33,20 @@
23
33
  // "furniture": [ { "id": "rack", "sprite": STRIP, "x": 1020, "y": 140, "depth": 2,
24
34
  // "activeWhen": "crashed", "activeSprite": STRIP } ],
25
35
  // drawn by depth: below 1000 under the people, 1000 and up over
26
- // them; (x, y) is where the strip's anchor goes. `activeWhen` (a
36
+ // them; "depth": "y" (the string) sorts it among the people by base
37
+ // y — its anchor's y against each person's feet: someone whose feet
38
+ // are lower down (a greater y; a tie too) is drawn after it, in
39
+ // front. (x, y) is where the strip's anchor goes. `activeWhen` (a
27
40
  // state, or a list): while anyone shown is in it, the item plays its
28
41
  // strip (or `activeSprite`); otherwise it holds the first frame
29
- // "characters": { "default": { "working": STRIP, "idle": STRIP, "paused": STRIP, "needs": STRIP, "walk": STRIP },
30
- // "fox": { … } }, "default" with at least "idle" is required; a missing pose is idle
42
+ // "characters": { "default": { "working": STRIP, "idle": STRIP, "paused": STRIP, "needs": STRIP, "walk": STRIP,
43
+ // "walkDown": STRIP, "walkUp": STRIP, "walkSide": STRIP },
44
+ // "fox": { … } }, "default" with at least "idle" is required; a missing pose is idle.
45
+ // Walking (poseStrip): the current step's main axis picks the strip —
46
+ // |dy| > |dx|: walkDown (dy > 0, toward the viewer) or walkUp; else
47
+ // walkSide, drawn as it is when moving right and mirrored when moving
48
+ // left (so draw it facing right). A missing one is "walk", then idle
49
+ // ("walk" itself is never mirrored)
31
50
  // "byInstance": { "atlas": "fox" }, optional: an instance name → its character set
32
51
  // "props": { "task": STRIP, "send": STRIP, "report": STRIP, "broadcast": STRIP }, optional traffic items
33
52
  // "bubbles": { "en": { "working": ["On it."] }, "zh-TW": { … } } optional: short lines per state (≤ 12 a state,
@@ -44,20 +63,52 @@
44
63
  // "..". The check is strict: an unknown key, a value out of range or a name that refers to nothing refuses the pack,
45
64
  // with where it broke.
46
65
  //
47
- // TODO(office editor): the planned editor follows the reference's approach — a "show coordinates" overlay on the
48
- // canvas and dragging furniture and seats, the positions saved into the office (PUT /ui/offices) — out of v1.
66
+ // Its displayed size: w·scale × h·scale logical units, unless the strip says `dw` and/or `dh` (logical units, 1–1280 and
67
+ // 1–720): its frame is then drawn dw × dh (still unsmoothed: nearest neighbour), and with only one of them the other keeps
68
+ // the frame's aspect. The anchor (ax, ay) stays in image pixels and moves with it. So art drawn at 120×120 px shows at
69
+ // 96×96 with "dw": 96, "dh": 96 — whatever the pack's scale. What a person hovers or clicks is the box drawn (stripBox).
70
+ //
71
+ // A name the pack declares and refers to (an area, a character set, a furniture item, an instance in byInstance, and the
72
+ // same in an office's overrides) is never one an object already has (Object.prototype's: "constructor", "toString",
73
+ // "__proto__", …): such a name is refused, and every lookup is of the pack's own names only (own()).
74
+ //
75
+ // The host keeps an uploaded pack whole and never changes it: every PNG starts with its signature, a STRIP's image is
76
+ // exactly frames·w × h, the background and the foreground are 16:9, and each file and the pack have a size cap (web-office-skins.ts).
77
+ //
78
+ // ── Layout overrides (the Office view's "Edit layout": a stored office's own positions on a zones pack) ──
79
+ //
80
+ // An office may keep `overrides` beside its skin — the pack stays as it was uploaded; the office moves things on it:
81
+ //
82
+ // { "pack": "cozy", the pack they were made on (on another pack they are ignored)
83
+ // "seats": { "desks": [[120, 220], …] }, an area's seats: as many as the pack's, each inside the area's rect
84
+ // "furniture": { "rack": [1000, 150] }, an item's (x, y)
85
+ // "door": [640, 700],
86
+ // "waypoints": [{ "id": "hall", "x": 640, "y": 600 }, …], "edges": [["hall", "east"], …] }
87
+ // the walkable graph, replaced whole (edges need waypoints)
88
+ //
89
+ // Every coordinate is on the same 1280×720 logical canvas, in the same ranges as the pack's. checkLayoutOverrides is
90
+ // the shape (the host checks it on every PUT /ui/offices); applyOverrides puts them on a checked pack, refusing a
91
+ // seat outside its area, an area or furniture the pack does not have, or a seat count that differs (the host checks
92
+ // that too, for a pack it keeps).
49
93
 
50
94
  export const LOGICAL = { w: 1280, h: 720 };
51
95
  /** The people's depth: furniture below it is drawn under them, the rest over them. */
52
96
  export const CHAR_DEPTH = 1000;
53
97
  export const PACK_STATES = ["needs", "crashed", "stuck", "working", "idle", "paused", "stopped", "missing"];
54
- const POSES = ["working", "idle", "paused", "needs", "walk"], PROPS = ["task", "send", "report", "broadcast"];
98
+ const POSES = ["working", "idle", "paused", "needs", "walk", "walkDown", "walkUp", "walkSide"], PROPS = ["task", "send", "report", "broadcast"];
55
99
  const BUILT_IN = new Set(["pixel", "studio"]);
56
100
  const ID = /^[A-Za-z0-9_-]{1,40}$/;
57
101
  const SRC = /^[A-Za-z0-9._/-]{1,200}$/;
102
+ const HEX = /^#[0-9a-f]{6}$/i;
58
103
  const LIMIT = { areas: 32, seats: 64, furniture: 128, waypoints: 512, edges: 2048, sets: 64, byInstance: 500, lines: 12, line: 40 };
59
104
 
60
105
  class Refused extends Error { constructor(at, why) { super(`${at}: ${why}`); } }
106
+ /** `o[k]` when it is o's own property, else undefined: a name from a pack or a page never reaches Object.prototype. */
107
+ export const own = (o, k) => (o !== null && typeof o === "object" && typeof k === "string" && Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined);
108
+ /** Whether `k` is a name every object already has ("constructor", "toString", "__proto__", …): never a pack's name. */
109
+ export const inherited = (k) => typeof k === "string" && k in Object.prototype;
110
+ /** A name a pack declares or refers to: ID's letters, never an inherited one. */
111
+ const nameOk = (k) => typeof k === "string" && ID.test(k) && !inherited(k);
61
112
  const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
62
113
  function keys(v, allowed, at) { for (const k of Object.keys(v)) if (!allowed.includes(k)) throw new Refused(at, `unknown key "${k}"`); }
63
114
  function obj(v, at) { if (!isObj(v)) throw new Refused(at, "an object"); return v; }
@@ -68,20 +119,29 @@ function point(v, at) {
68
119
  if (!Array.isArray(v) || v.length !== 2) throw new Refused(at, "[x, y]");
69
120
  return { x: num(v[0], 0, LOGICAL.w, `${at}[0]`), y: num(v[1], 0, LOGICAL.h, `${at}[1]`) };
70
121
  }
122
+ /** Where an item sorts: by depth, a "y" one among the people (after every number under them, before the rest). */
123
+ const order = (f) => (f.depth === "y" ? CHAR_DEPTH - 0.5 : f.depth);
124
+ /** A furniture item's numeric depth (its "y" is taken before this). */
125
+ function depth(v, at) { if (!Number.isInteger(v) || v < -10000 || v > 10000) throw new Refused(at, 'a whole number from -10000 to 10000, or "y"'); return v; }
71
126
  function list(v, max, at) { if (!Array.isArray(v)) throw new Refused(at, "a list"); if (v.length > max) throw new Refused(at, `at most ${max}`); return v; }
72
127
 
73
- /** A frame strip, strictly. */
74
- export function checkStrip(v, at = "strip") {
128
+ /** Whether `v` is a pack's `matte`: a colour as #rrggbb (six hex digits, either case). */
129
+ export const matteOk = (v) => typeof v === "string" && HEX.test(v);
130
+
131
+ /** A frame strip, strictly; `scale` the pack's (its displayed size, dw × dh, is resolved here). */
132
+ export function checkStrip(v, at = "strip", scale = 2) {
75
133
  obj(v, at);
76
- keys(v, ["src", "frames", "w", "h", "fps", "ax", "ay"], at);
134
+ keys(v, ["src", "frames", "w", "h", "fps", "ax", "ay", "dw", "dh"], at);
77
135
  const w = int(v.w, 1, 512, `${at}.w`), h = int(v.h, 1, 512, `${at}.h`);
136
+ const dw0 = v.dw === undefined ? null : num(v.dw, 1, LOGICAL.w, `${at}.dw`), dh0 = v.dh === undefined ? null : num(v.dh, 1, LOGICAL.h, `${at}.dh`);
137
+ const dw = dw0 ?? (dh0 !== null ? (dh0 * w) / h : w * scale), dh = dh0 ?? (dw0 !== null ? (dw0 * h) / w : h * scale);
78
138
  return { src: src(v.src, `${at}.src`), frames: int(v.frames, 1, 32, `${at}.frames`), w, h, fps: v.fps === undefined ? 4 : num(v.fps, 0, 30, `${at}.fps`),
79
- ax: v.ax === undefined ? w / 2 : num(v.ax, 0, w, `${at}.ax`), ay: v.ay === undefined ? h : num(v.ay, 0, h, `${at}.ay`) };
139
+ ax: v.ax === undefined ? w / 2 : num(v.ax, 0, w, `${at}.ax`), ay: v.ay === undefined ? h : num(v.ay, 0, h, `${at}.ay`), dw, dh };
80
140
  }
81
- function strips(v, names, at) {
141
+ function strips(v, names, at, scale) {
82
142
  obj(v, at);
83
143
  keys(v, names, at);
84
- return Object.fromEntries(Object.entries(v).map(([k, s]) => [k, checkStrip(s, `${at}.${k}`)]));
144
+ return Object.fromEntries(Object.entries(v).map(([k, s]) => [k, checkStrip(s, `${at}.${k}`, scale)]));
85
145
  }
86
146
  function states(v, at) {
87
147
  const one = (s, here) => { if (!PACK_STATES.includes(s)) throw new Refused(here, `one of ${PACK_STATES.join(", ")}`); return s; };
@@ -92,17 +152,18 @@ function states(v, at) {
92
152
  export function checkSkinPack(x) {
93
153
  try {
94
154
  obj(x, "pack");
95
- keys(x, ["format", "id", "scale", "background", "areas", "stateZones", "door", "waypoints", "edges", "furniture", "characters", "byInstance", "props", "bubbles", "room", "desk"], "pack");
155
+ keys(x, ["format", "id", "scale", "background", "foreground", "matte", "areas", "stateZones", "door", "waypoints", "edges", "furniture", "characters", "byInstance", "props", "bubbles", "room", "desk"], "pack");
96
156
  if (x.format !== 1) throw new Refused("format", "1");
97
157
  if (typeof x.id !== "string" || !ID.test(x.id)) throw new Refused("id", "1–40 letters, digits, _ or -");
98
158
  if (BUILT_IN.has(x.id)) throw new Refused("id", "a built-in skin's id");
99
159
  const scale = x.scale === undefined ? 2 : int(x.scale, 1, 8, "scale");
160
+ if (x.matte !== undefined && !matteOk(x.matte)) throw new Refused("matte", "a colour as #rrggbb");
100
161
  const areas = {};
101
162
  const rawAreas = x.areas === undefined ? {} : obj(x.areas, "areas");
102
163
  if (Object.keys(rawAreas).length > LIMIT.areas) throw new Refused("areas", `at most ${LIMIT.areas}`);
103
164
  for (const [name, a] of Object.entries(rawAreas)) {
104
165
  const at = `areas.${name}`;
105
- if (!ID.test(name)) throw new Refused(at, "a name of letters, digits, _ or -");
166
+ if (!nameOk(name)) throw new Refused(at, "a name of letters, digits, _ or - (not an inherited one)");
106
167
  obj(a, at); keys(a, ["rect", "seats"], at);
107
168
  if (!Array.isArray(a.rect) || a.rect.length !== 4) throw new Refused(`${at}.rect`, "[x, y, w, h]");
108
169
  const [rx, ry] = [num(a.rect[0], 0, LOGICAL.w, `${at}.rect[0]`), num(a.rect[1], 0, LOGICAL.h, `${at}.rect[1]`)];
@@ -122,11 +183,12 @@ export function checkSkinPack(x) {
122
183
  stateZones = {};
123
184
  for (const [s, area] of Object.entries(x.stateZones)) {
124
185
  if (s !== "default" && !PACK_STATES.includes(s)) throw new Refused(`stateZones.${s}`, `a state (${PACK_STATES.join(", ")}) or "default"`);
125
- if (typeof area !== "string" || !areas[area]) throw new Refused(`stateZones.${s}`, "an area the pack defines");
186
+ if (typeof area !== "string" || !own(areas, area)) throw new Refused(`stateZones.${s}`, "an area the pack defines");
126
187
  stateZones[s] = area;
127
188
  }
128
189
  if (x.background === undefined) throw new Refused("background", "required with stateZones");
129
190
  }
191
+ if (x.foreground !== undefined && !stateZones) throw new Refused("foreground", "only with stateZones");
130
192
  const waypoints = [], ids = new Set();
131
193
  for (const [i, w] of list(x.waypoints ?? [], LIMIT.waypoints, "waypoints").entries()) {
132
194
  const at = `waypoints[${i}]`;
@@ -143,26 +205,30 @@ export function checkSkinPack(x) {
143
205
  const furniture = list(x.furniture ?? [], LIMIT.furniture, "furniture").map((f, i) => {
144
206
  const at = `furniture[${i}]`;
145
207
  obj(f, at); keys(f, ["id", "sprite", "x", "y", "depth", "activeWhen", "activeSprite"], at);
146
- if (typeof f.id !== "string" || !ID.test(f.id) || fids.has(f.id)) throw new Refused(`${at}.id`, "a new name of letters, digits, _ or -");
208
+ if (!nameOk(f.id) || fids.has(f.id)) throw new Refused(`${at}.id`, "a new name of letters, digits, _ or - (not an inherited one)");
147
209
  fids.add(f.id);
148
210
  if (f.activeSprite !== undefined && f.activeWhen === undefined) throw new Refused(`${at}.activeSprite`, "only with activeWhen");
149
- return { id: f.id, sprite: checkStrip(f.sprite, `${at}.sprite`), x: num(f.x, 0, LOGICAL.w, `${at}.x`), y: num(f.y, 0, LOGICAL.h, `${at}.y`),
150
- depth: int(f.depth ?? 0, -10000, 10000, `${at}.depth`), activeWhen: f.activeWhen === undefined ? null : states(f.activeWhen, `${at}.activeWhen`),
151
- activeSprite: f.activeSprite === undefined ? null : checkStrip(f.activeSprite, `${at}.activeSprite`) };
152
- }).sort((a, b) => a.depth - b.depth);
211
+ return { id: f.id, sprite: checkStrip(f.sprite, `${at}.sprite`, scale), x: num(f.x, 0, LOGICAL.w, `${at}.x`), y: num(f.y, 0, LOGICAL.h, `${at}.y`),
212
+ depth: f.depth === "y" ? "y" : depth(f.depth ?? 0, `${at}.depth`), activeWhen: f.activeWhen === undefined ? null : states(f.activeWhen, `${at}.activeWhen`),
213
+ activeSprite: f.activeSprite === undefined ? null : checkStrip(f.activeSprite, `${at}.activeSprite`, scale) };
214
+ }).sort((a, b) => order(a) - order(b));
153
215
  const sets = obj(x.characters, "characters");
154
216
  if (Object.keys(sets).length > LIMIT.sets) throw new Refused("characters", `at most ${LIMIT.sets} sets`);
155
217
  const characters = {};
156
218
  for (const [k, v] of Object.entries(sets)) {
157
- if (!ID.test(k)) throw new Refused(`characters.${k}`, "a name of letters, digits, _ or -");
158
- characters[k] = strips(v, POSES, `characters.${k}`);
219
+ if (!nameOk(k)) throw new Refused(`characters.${k}`, "a name of letters, digits, _ or - (not an inherited one)");
220
+ characters[k] = strips(v, POSES, `characters.${k}`, scale);
159
221
  if (!characters[k].idle) throw new Refused(`characters.${k}.idle`, "required");
160
222
  }
161
- if (!characters.default) throw new Refused("characters.default", "required");
223
+ if (!own(characters, "default")) throw new Refused("characters.default", "required");
162
224
  const byInstance = {};
163
225
  const rawBy = x.byInstance === undefined ? {} : obj(x.byInstance, "byInstance");
164
226
  if (Object.keys(rawBy).length > LIMIT.byInstance) throw new Refused("byInstance", `at most ${LIMIT.byInstance}`);
165
- for (const [k, v] of Object.entries(rawBy)) { if (typeof v !== "string" || !characters[v]) throw new Refused(`byInstance.${k}`, "a character set the pack defines"); byInstance[k] = v; }
227
+ for (const [k, v] of Object.entries(rawBy)) {
228
+ if (inherited(k)) throw new Refused(`byInstance.${k}`, "an instance's name (not an inherited one)");
229
+ if (typeof v !== "string" || !own(characters, v)) throw new Refused(`byInstance.${k}`, "a character set the pack defines");
230
+ byInstance[k] = v;
231
+ }
166
232
  const bubbles = {};
167
233
  for (const [lang, table] of Object.entries(x.bubbles === undefined ? {} : obj(x.bubbles, "bubbles"))) {
168
234
  if (lang !== "en" && lang !== "zh-TW") throw new Refused(`bubbles.${lang}`, '"en" or "zh-TW"');
@@ -181,9 +247,9 @@ export function checkSkinPack(x) {
181
247
  keys(room, ["floor", "wall"], "room"); keys(desk, ["back", "front", "lit"], "desk");
182
248
  const opt = (v, at) => (v === undefined ? undefined : src(v, at));
183
249
  return { ok: true, pack: {
184
- format: 1, id: x.id, scale, background: opt(x.background, "background"), areas, stateZones,
250
+ format: 1, id: x.id, scale, background: opt(x.background, "background"), foreground: opt(x.foreground, "foreground"), matte: x.matte === undefined ? undefined : x.matte.toLowerCase(), areas, stateZones,
185
251
  door: x.door === undefined ? { x: LOGICAL.w / 2, y: LOGICAL.h - 10 } : point(x.door, "door"), waypoints, edges, furniture, characters, byInstance,
186
- props: x.props === undefined ? {} : strips(x.props, PROPS, "props"), bubbles,
252
+ props: x.props === undefined ? {} : strips(x.props, PROPS, "props", scale), bubbles,
187
253
  room: { floor: opt(room.floor, "room.floor"), wall: opt(room.wall, "room.wall") }, desk: { back: opt(desk.back, "desk.back"), front: opt(desk.front, "desk.front"), lit: opt(desk.lit, "desk.lit") },
188
254
  } };
189
255
  } catch (err) {
@@ -194,6 +260,151 @@ export function checkSkinPack(x) {
194
260
  /** The checked pack, or null. */
195
261
  export const skinPackOk = (x) => { const r = checkSkinPack(x); return r.ok ? r.pack : null; };
196
262
  /** Every image a pack names (what a loader fetches before spriteSkin draws). */
197
- export const packImages = (pack) => [...new Set([pack.background, pack.room.floor, pack.room.wall, pack.desk.back, pack.desk.front, pack.desk.lit,
263
+ export const packImages = (pack) => [...new Set([pack.background, pack.foreground, pack.room.floor, pack.room.wall, pack.desk.back, pack.desk.front, pack.desk.lit,
198
264
  ...pack.furniture.flatMap((f) => [f.sprite.src, f.activeSprite && f.activeSprite.src]),
199
265
  ...Object.values(pack.characters).flatMap((s) => Object.values(s).map((st) => st.src)), ...Object.values(pack.props).map((st) => st.src)].filter(Boolean))];
266
+
267
+ /** Every frame strip a checked pack names, with where (the host checks each image is frames·w × h). */
268
+ export function packStrips(pack) {
269
+ const out = [];
270
+ for (const f of pack.furniture) { out.push({ at: `furniture.${f.id}.sprite`, strip: f.sprite }); if (f.activeSprite) out.push({ at: `furniture.${f.id}.activeSprite`, strip: f.activeSprite }); }
271
+ for (const [k, set] of Object.entries(pack.characters)) for (const [pose, st] of Object.entries(set)) out.push({ at: `characters.${k}.${pose}`, strip: st });
272
+ for (const [k, st] of Object.entries(pack.props)) out.push({ at: `props.${k}`, strip: st });
273
+ return out;
274
+ }
275
+
276
+ // ── Layout overrides ──
277
+
278
+ const OV_LIMIT = { areas: LIMIT.areas, seats: LIMIT.seats, furniture: LIMIT.furniture, waypoints: LIMIT.waypoints, edges: LIMIT.edges };
279
+ /** An office's layout overrides, strictly (their shape and ranges): { ok: true, overrides } or { ok: false, error }. */
280
+ export function checkLayoutOverrides(x) {
281
+ try {
282
+ obj(x, "overrides");
283
+ keys(x, ["pack", "seats", "furniture", "door", "waypoints", "edges"], "overrides");
284
+ if (typeof x.pack !== "string" || !ID.test(x.pack)) throw new Refused("overrides.pack", "1–40 letters, digits, _ or -");
285
+ const out = { pack: x.pack };
286
+ if (x.seats !== undefined) {
287
+ obj(x.seats, "overrides.seats");
288
+ if (Object.keys(x.seats).length > OV_LIMIT.areas) throw new Refused("overrides.seats", `at most ${OV_LIMIT.areas} areas`);
289
+ out.seats = {};
290
+ for (const [area, list0] of Object.entries(x.seats)) {
291
+ const at = `overrides.seats.${area}`;
292
+ if (!nameOk(area)) throw new Refused(at, "an area's name");
293
+ const pts = list(list0, OV_LIMIT.seats, at).map((p, i) => { const q = point(p, `${at}[${i}]`); return [q.x, q.y]; });
294
+ if (!pts.length) throw new Refused(at, "at least one seat");
295
+ out.seats[area] = pts;
296
+ }
297
+ }
298
+ if (x.furniture !== undefined) {
299
+ obj(x.furniture, "overrides.furniture");
300
+ if (Object.keys(x.furniture).length > OV_LIMIT.furniture) throw new Refused("overrides.furniture", `at most ${OV_LIMIT.furniture}`);
301
+ out.furniture = {};
302
+ for (const [id, p] of Object.entries(x.furniture)) {
303
+ if (!nameOk(id)) throw new Refused(`overrides.furniture.${id}`, "an item's id");
304
+ const q = point(p, `overrides.furniture.${id}`);
305
+ out.furniture[id] = [q.x, q.y];
306
+ }
307
+ }
308
+ if (x.door !== undefined) { const q = point(x.door, "overrides.door"); out.door = [q.x, q.y]; }
309
+ if (x.edges !== undefined && x.waypoints === undefined) throw new Refused("overrides.edges", "only with waypoints");
310
+ if (x.waypoints !== undefined) {
311
+ const ids = new Set();
312
+ out.waypoints = list(x.waypoints, OV_LIMIT.waypoints, "overrides.waypoints").map((w, i) => {
313
+ const at = `overrides.waypoints[${i}]`;
314
+ obj(w, at); keys(w, ["id", "x", "y"], at);
315
+ if (typeof w.id !== "string" || !ID.test(w.id) || ids.has(w.id)) throw new Refused(`${at}.id`, "a new name of letters, digits, _ or -");
316
+ ids.add(w.id);
317
+ return { id: w.id, x: num(w.x, 0, LOGICAL.w, `${at}.x`), y: num(w.y, 0, LOGICAL.h, `${at}.y`) };
318
+ });
319
+ const seen = new Set();
320
+ out.edges = list(x.edges ?? [], OV_LIMIT.edges, "overrides.edges").map((e, i) => {
321
+ if (!Array.isArray(e) || e.length !== 2 || !ids.has(e[0]) || !ids.has(e[1]) || e[0] === e[1]) throw new Refused(`overrides.edges[${i}]`, "two different waypoint ids");
322
+ const k = e[0] < e[1] ? `${e[0]}\u0000${e[1]}` : `${e[1]}\u0000${e[0]}`;
323
+ if (seen.has(k)) throw new Refused(`overrides.edges[${i}]`, "joined twice");
324
+ seen.add(k);
325
+ return [e[0], e[1]];
326
+ });
327
+ }
328
+ return { ok: true, overrides: out };
329
+ } catch (err) {
330
+ if (err instanceof Refused) return { ok: false, error: err.message };
331
+ throw err;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * A checked pack with an office's checked overrides on it: { ok: true, pack } (a new object; the pack is untouched) or
337
+ * { ok: false, error }. Overrides made on another pack change nothing (ok, the pack as it is).
338
+ */
339
+ export function applyOverrides(pack, ov) {
340
+ try {
341
+ if (!ov || ov.pack !== pack.id) return { ok: true, pack };
342
+ const areas = { ...pack.areas };
343
+ for (const [name, pts] of Object.entries(ov.seats || {})) {
344
+ const a = own(pack.areas, name), at = `overrides.seats.${name}`;
345
+ if (!a) throw new Refused(at, "an area the pack defines");
346
+ if (pts.length !== a.seats.length) throw new Refused(at, `${a.seats.length} seats, as the pack has`);
347
+ const r = a.rect;
348
+ areas[name] = { ...a, seats: pts.map(([x, y], i) => {
349
+ if (x < r.x || x > r.x + r.w || y < r.y || y > r.y + r.h) throw new Refused(`${at}[${i}]`, "outside its area's rect");
350
+ return { x, y };
351
+ }) };
352
+ }
353
+ const fids = new Set(pack.furniture.map((f) => f.id));
354
+ for (const id of Object.keys(ov.furniture || {})) if (!fids.has(id)) throw new Refused(`overrides.furniture.${id}`, "an item the pack has");
355
+ const furniture = pack.furniture.map((f) => { const q = own(ov.furniture, f.id); return q ? { ...f, x: q[0], y: q[1] } : f; });
356
+ const door = ov.door ? { x: ov.door[0], y: ov.door[1] } : pack.door;
357
+ const graph = ov.waypoints ? { waypoints: ov.waypoints.map((w) => ({ ...w })), edges: (ov.edges || []).map((e) => [e[0], e[1]]) } : { waypoints: pack.waypoints, edges: pack.edges };
358
+ return { ok: true, pack: { ...pack, areas, furniture, door, ...graph } };
359
+ } catch (err) {
360
+ if (err instanceof Refused) return { ok: false, error: err.message };
361
+ throw err;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * What an edited layout keeps as overrides: only the parts that differ from the pack's own (`edited` is a pack with
367
+ * the same areas and furniture, positions moved); null when nothing does (the office goes back to the pack's).
368
+ */
369
+ export function overridesBetween(pack, edited) {
370
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
371
+ const ov = { pack: pack.id };
372
+ for (const [name, a] of Object.entries(pack.areas)) {
373
+ const e = own(edited.areas, name);
374
+ if (e && !same(a.seats, e.seats)) (ov.seats ||= {})[name] = e.seats.map((s) => [s.x, s.y]);
375
+ }
376
+ const at = new Map(pack.furniture.map((f) => [f.id, f]));
377
+ for (const f of edited.furniture) { const o = at.get(f.id); if (o && (o.x !== f.x || o.y !== f.y)) (ov.furniture ||= {})[f.id] = [f.x, f.y]; }
378
+ if (pack.door.x !== edited.door.x || pack.door.y !== edited.door.y) ov.door = [edited.door.x, edited.door.y];
379
+ if (!same(pack.waypoints, edited.waypoints) || !same(pack.edges, edited.edges)) {
380
+ ov.waypoints = edited.waypoints.map((w) => ({ id: w.id, x: w.x, y: w.y }));
381
+ ov.edges = edited.edges.map((e) => [e[0], e[1]]);
382
+ }
383
+ return Object.keys(ov).length > 1 ? ov : null;
384
+ }
385
+
386
+ // ── Drawing a strip: which one a pose shows, and the box it covers ──
387
+
388
+ /**
389
+ * The strip a character set shows for `pose` (heading: the current step { dx, dy }, while walking): { strip, mirror }, or
390
+ * null (no set). Walking: |dy| > |dx| → walkDown (dy > 0) or walkUp, else walkSide (mirror: moving left); a missing one
391
+ * → "walk" (never mirrored) → idle. Any other pose: its own strip, else idle.
392
+ */
393
+ export function poseStrip(set, pose, heading = null) {
394
+ if (!set || typeof set !== "object") return null;
395
+ if (pose === "walk" && heading) {
396
+ const dx = Number(heading.dx) || 0, dy = Number(heading.dy) || 0;
397
+ if (dx || dy) {
398
+ const dir = Math.abs(dy) > Math.abs(dx) ? (dy > 0 ? "walkDown" : "walkUp") : "walkSide";
399
+ const st = own(set, dir);
400
+ if (st) return { strip: st, mirror: dir === "walkSide" && dx < 0 };
401
+ }
402
+ }
403
+ const st = own(set, pose) || own(set, "idle");
404
+ return st ? { strip: st, mirror: false } : null;
405
+ }
406
+ /** The box (logical units) a strip covers with its anchor at (x, y): its displayed size; mirrored about the anchor. */
407
+ export function stripBox(st, x, y, mirror = false) {
408
+ const kx = st.dw / st.w, ky = st.dh / st.h;
409
+ return { x: Math.round(x - (mirror ? st.w - st.ax : st.ax) * kx), y: Math.round(y - st.ay * ky), w: st.dw, h: st.dh };
410
+ }