@bunnyland/ui-web 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +211 -0
  3. package/assets/bunnyland-api.js +345 -0
  4. package/assets/bunnyland-play.js +1223 -0
  5. package/assets/bunnyland-ui.css +1634 -0
  6. package/assets/bunnyland-ui.js +795 -0
  7. package/dist/admin-widgets.d.ts +31 -0
  8. package/dist/admin-widgets.d.ts.map +1 -0
  9. package/dist/admin-widgets.js +100 -0
  10. package/dist/admin-widgets.js.map +1 -0
  11. package/dist/api.d.ts +37 -0
  12. package/dist/api.d.ts.map +1 -0
  13. package/dist/api.js +125 -0
  14. package/dist/api.js.map +1 -0
  15. package/dist/index.d.ts +7 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +7 -0
  18. package/dist/play.d.ts +265 -0
  19. package/dist/play.d.ts.map +1 -0
  20. package/dist/play.js +806 -0
  21. package/dist/play.js.map +1 -0
  22. package/dist/player-widgets.d.ts +11 -0
  23. package/dist/player-widgets.d.ts.map +1 -0
  24. package/dist/player-widgets.js +21 -0
  25. package/dist/player-widgets.js.map +1 -0
  26. package/dist/preact/auth.d.ts +37 -0
  27. package/dist/preact/auth.d.ts.map +1 -0
  28. package/dist/preact/components.d.ts +58 -0
  29. package/dist/preact/components.d.ts.map +1 -0
  30. package/dist/preact/theme.d.ts +16 -0
  31. package/dist/preact/theme.d.ts.map +1 -0
  32. package/dist/preact.d.ts +4 -0
  33. package/dist/preact.d.ts.map +1 -0
  34. package/dist/preact.js +488 -0
  35. package/dist/preact.js.map +1 -0
  36. package/dist/theme.d.ts +31 -0
  37. package/dist/theme.d.ts.map +1 -0
  38. package/dist/theme.js +165 -0
  39. package/dist/theme.js.map +1 -0
  40. package/dist/widgets.d.ts +7 -0
  41. package/dist/widgets.d.ts.map +1 -0
  42. package/dist/widgets.js +31 -0
  43. package/dist/widgets.js.map +1 -0
  44. package/docs/admin/custom-web-themes.md +112 -0
  45. package/docs/developer/design-language.md +116 -0
  46. package/package.json +101 -0
  47. package/src/admin-widgets.ts +189 -0
  48. package/src/api.ts +193 -0
  49. package/src/index.ts +6 -0
  50. package/src/play.ts +1151 -0
  51. package/src/player-widgets.ts +29 -0
  52. package/src/preact/auth.tsx +338 -0
  53. package/src/preact/components.tsx +249 -0
  54. package/src/preact/theme.tsx +72 -0
  55. package/src/preact.ts +3 -0
  56. package/src/theme.ts +225 -0
  57. package/src/widgets.ts +41 -0
@@ -0,0 +1,1223 @@
1
+ (function () {
2
+ 'use strict';
3
+
4
+ const KIND_ICON = {
5
+ room: '🏠', character: '🐰', container: '📦', item: '✦',
6
+ door: '🚪', food: '🍎', water: '💧', chair: '🪑', table: '🪵',
7
+ bed: '🛏', art: '🖼', window: '🪟', other: '⬡',
8
+ };
9
+
10
+ // Mirror of the server's imagegen/affordance.py so the camera gesture reads the same
11
+ // everywhere: the request/ack/deliver/fail emoji and the button label are one source of
12
+ // truth shared by every web client. Keep these in lockstep with affordance.py.
13
+ const IMAGE_AFFORDANCE = {
14
+ REQUEST_EMOJI: '📷',
15
+ ACK_EMOJI: '👀',
16
+ DELIVER_EMOJI: '📸',
17
+ FAIL_EMOJI: '⚠️',
18
+ REQUEST_LABEL: 'Request image',
19
+ };
20
+
21
+ const ACTION_ICON_BY_COMMAND_TYPE = {
22
+ look: '👁️', inspect: '🔎', move: '➡️', take: '🤲', put: '📥',
23
+ drop: '📤', open: '🚪', close: '🚪', lock: '🔒', unlock: '🔓',
24
+ hold: '✊', unhold: '🫳', wear: '🧥', remove: '🧥', use: '🛠️',
25
+ write: '✍️', sleep: '💤', wake: '☀️', wait: '⏳', 'move-sprite': '🎯',
26
+ say: '💬', tell: '🗣️', 'take-note': '📝', remember: '🧠',
27
+ forget: '🧹', reflect: '💭', ignite: '🔥', extinguish: '🧯',
28
+ 'water-crop': '💧', eat: '🍽️', drink: '💧', craft: '🛠️',
29
+ attack: '⚔️', defend: '🛡️', 'cast-spell': '✨', scan: '📡', jump: '🚀',
30
+ };
31
+
32
+ const ACTION_ICON_KEYWORDS = [
33
+ ['move', '➡️'], ['travel', '🧭'], ['enter', '🚪'], ['leave', '🚪'],
34
+ ['open', '🚪'], ['close', '🚪'], ['lock', '🔒'], ['unlock', '🔓'],
35
+ ['search', '🔎'], ['inspect', '🔎'], ['scan', '📡'], ['survey', '🗺️'],
36
+ ['study', '📚'], ['learn', '📚'], ['read', '📖'], ['write', '✍️'],
37
+ ['say', '💬'], ['tell', '🗣️'], ['ask', '❓'], ['remember', '🧠'],
38
+ ['reflect', '💭'], ['note', '📝'], ['take', '🤲'], ['collect', '🤲'],
39
+ ['claim', '🏳️'], ['drop', '📤'], ['put', '📥'], ['store', '📥'],
40
+ ['retrieve', '📤'], ['haul', '📦'], ['cargo', '📦'], ['deliver', '📦'],
41
+ ['buy', '🛒'], ['sell', '🏷️'], ['trade', '🤝'], ['pay', '💰'],
42
+ ['work', '💼'], ['job', '💼'], ['craft', '🛠️'], ['repair', '🛠️'],
43
+ ['build', '🏗️'], ['upgrade', '⬆️'], ['install', '🔧'], ['machine', '⚙️'],
44
+ ['power', '⚡'], ['water', '💧'], ['drink', '💧'], ['plant', '🌱'],
45
+ ['harvest', '🌾'], ['forage', '🌿'], ['egg', '🥚'], ['feed', '🍽️'],
46
+ ['eat', '🍽️'], ['potion', '⚗️'], ['chem', '⚗️'], ['heal', '🩹'],
47
+ ['treat', '🩹'], ['poison', '☠️'], ['radiation', '☢️'], ['sample', '🧪'],
48
+ ['mine', '⛏️'], ['salvage', '🔧'], ['quest', '📜'], ['faction', '🏳️'],
49
+ ['crime', '⚖️'], ['jail', '⚖️'], ['bounty', '⚖️'], ['attack', '⚔️'],
50
+ ['fight', '⚔️'], ['raid', '⚔️'], ['defeat', '⚔️'], ['defend', '🛡️'],
51
+ ['trap', '🪤'], ['sneak', '🥷'], ['hide', '🥷'], ['steal', '🫴'],
52
+ ['spell', '✨'], ['magic', '✨'], ['ritual', '✨'], ['dungeon', '🗝️'],
53
+ ['map', '🗺️'], ['recall', '🌀'], ['rest', '💤'], ['sleep', '💤'],
54
+ ['ship', '🚀'], ['orbit', '🪐'], ['drone', '🛰️'], ['ai', '🤖'],
55
+ ['network', '📡'], ['hack', '💻'], ['exploit', '💻'], ['terminal', '💻'],
56
+ ['credential', '🪪'], ['data', '💾'], ['evidence', '🧾'], ['camera', '📷'],
57
+ ['sensor', '📡'], ['implant', '🦾'], ['call', '📣'], ['signal', '📣'],
58
+ ['command', '📣'], ['assign', '📌'], ['set', '📌'], ['configure', '⚙️'],
59
+ ['resolve', '✅'], ['complete', '✅'], ['accept', '✅'], ['decline', '✋'],
60
+ ['cancel', '🚫'], ['release', '🫳'], ['clean', '🧼'], ['clear', '🧹'],
61
+ ];
62
+
63
+ function parseCharacterList(data) {
64
+ return {
65
+ epoch: data?.world_epoch || 0,
66
+ characters: (data?.characters || []).map(c => ({
67
+ id: c.id || c.character_id,
68
+ name: c.name || c.id || c.character_id,
69
+ kind: c.kind || 'character',
70
+ suspended: Boolean(c.suspended),
71
+ })),
72
+ };
73
+ }
74
+
75
+ function targetIcon(kind) {
76
+ if (kind === 'exit') return KIND_ICON.door;
77
+ if (kind === 'object') return KIND_ICON.other;
78
+ return KIND_ICON[kind] || KIND_ICON.other;
79
+ }
80
+
81
+ function parseCharacterProjection(data) {
82
+ const envelope = data || {};
83
+ data = envelope.character || envelope;
84
+ if (!data || typeof data !== 'object' || !data.character_id) return null;
85
+ const scene = envelope.scene || {};
86
+ const targetGroups = {};
87
+ for (const [kind, targets] of Object.entries(data.target_groups || {})) {
88
+ targetGroups[kind] = (targets || []).map(target => ({
89
+ value: target.id,
90
+ label: target.label || target.id,
91
+ kind: target.kind || kind,
92
+ icon: targetIcon(target.kind),
93
+ }));
94
+ }
95
+ return {
96
+ characterId: data.character_id,
97
+ characterName: data.character_name || data.character_id,
98
+ worldEpoch: envelope.world_epoch || data.world_epoch || 0,
99
+ room: scene.room || data.room || {},
100
+ inventory: data.inventory || [],
101
+ points: data.points || {},
102
+ controller: data.controller || null,
103
+ portrait: data.portrait || {},
104
+ sheet: envelope.sheet || data.sheet || {},
105
+ targetGroups,
106
+ actions: envelope.actions || data.actions || [],
107
+ };
108
+ }
109
+
110
+ function parseRoomProjection(data) {
111
+ const envelope = data || {};
112
+ data = envelope.scene || envelope;
113
+ const room = data?.room;
114
+ if (!room || !room.id) return null;
115
+ return {
116
+ worldEpoch: envelope.world_epoch || data.world_epoch || 0,
117
+ room: {
118
+ id: room.id,
119
+ name: room.title || room.id,
120
+ kind: 'room',
121
+ isCharacter: false,
122
+ defaultStart: Boolean(room.default_start),
123
+ sprite: room.sprite || {},
124
+ exits: (room.exits || []).map(exit => ({
125
+ id: exit.id,
126
+ direction: exit.direction || '',
127
+ label: exit.label || exit.id,
128
+ locked: Boolean(exit.locked),
129
+ })),
130
+ },
131
+ entities: (room.entities || []).map(entity => ({
132
+ id: entity.id,
133
+ name: entity.name || entity.id,
134
+ kind: entity.kind || 'other',
135
+ isCharacter: Boolean(entity.is_character),
136
+ sprite: entity.sprite || {},
137
+ portrait: entity.portrait || {},
138
+ })),
139
+ };
140
+ }
141
+
142
+ function parseQueuedCommands(data) {
143
+ const envelope = data || {};
144
+ data = envelope.character || envelope;
145
+ if (!data || typeof data !== 'object' || !data.character_id) return null;
146
+ return {
147
+ characterId: data.character_id,
148
+ worldEpoch: envelope.world_epoch || data.world_epoch || 0,
149
+ generatedAtUnix: data.generated_at_unix ?? null,
150
+ nextTickAtUnix: data.next_tick_at_unix ?? null,
151
+ tickSeconds: data.tick_seconds ?? null,
152
+ timeScale: data.time_scale ?? null,
153
+ gameSecondsPerTick: data.game_seconds_per_tick ?? null,
154
+ commands: envelope.commands || data.commands || [],
155
+ };
156
+ }
157
+
158
+ function entityType(entity) {
159
+ if (entity?.kind) return entity.kind;
160
+ if (entity?.isCharacter) return 'character';
161
+ const c = entity?.components || {};
162
+ if (c.RoomComponent) return 'room';
163
+ if (c.CharacterComponent) return 'character';
164
+ if (c.DoorComponent) return 'door';
165
+ if (c.ContainerComponent) return 'container';
166
+ if (c.PortableComponent) return 'item';
167
+ return 'other';
168
+ }
169
+
170
+ function entityIcon(entity) {
171
+ const emoji = entity?.sprite?.emoji || entity?.components?.EditorDisplayComponent?.emoji;
172
+ if (emoji) return emoji;
173
+ const kind = entity?.kind || entity?.components?.IdentityComponent?.kind;
174
+ return KIND_ICON[kind] || KIND_ICON[entityType(entity)] || KIND_ICON.other;
175
+ }
176
+
177
+ function entityName(entity) {
178
+ if (entity?.name) return entity.name;
179
+ const c = entity?.components || {};
180
+ if (c.RoomComponent) return c.RoomComponent.title || entity.id;
181
+ return c.IdentityComponent?.name || entity?.id?.slice(0, 16) || '';
182
+ }
183
+
184
+ function actionTitle(action) {
185
+ return String(action?.title || action?.tool_name || action?.command_type || 'Action');
186
+ }
187
+
188
+ function actionIcon(action) {
189
+ if (action?.icon) return String(action.icon);
190
+ const commandType = actionCommandType(action).trim().toLowerCase().replaceAll('_', '-');
191
+ if (ACTION_ICON_BY_COMMAND_TYPE[commandType]) return ACTION_ICON_BY_COMMAND_TYPE[commandType];
192
+ const tokens = commandType.split('-');
193
+ const match = ACTION_ICON_KEYWORDS.find(([token]) => tokens.includes(token));
194
+ return match ? match[1] : '•';
195
+ }
196
+
197
+ function actionTool(action) {
198
+ return String(action?.tool_name || action?.command_type || 'action');
199
+ }
200
+
201
+ function actionCommandType(action) {
202
+ return String(action?.command_type || actionTool(action));
203
+ }
204
+
205
+ function actionCost(action) {
206
+ const cost = action?.cost || {};
207
+ return { action: Number(cost.action || 0), focus: Number(cost.focus || 0) };
208
+ }
209
+
210
+ function actionLane(action) {
211
+ return String(action?.lane || 'world');
212
+ }
213
+
214
+ function actionArguments(action) {
215
+ return Array.isArray(action?.arguments) ? action.arguments : [];
216
+ }
217
+
218
+ function actionAvailable(action) {
219
+ return action?.available !== false;
220
+ }
221
+
222
+ function actionUnavailableReason(action) {
223
+ return actionAvailable(action) ? '' : String(action?.unavailable_reason || 'Unavailable right now');
224
+ }
225
+
226
+ function orderActionsByAvailability(actions) {
227
+ return (actions || [])
228
+ .map((action, index) => ({ action, index }))
229
+ .sort((a, b) => {
230
+ const aAvailable = actionAvailable(a.action) ? 0 : 1;
231
+ const bAvailable = actionAvailable(b.action) ? 0 : 1;
232
+ return aAvailable - bAvailable || a.index - b.index;
233
+ })
234
+ .map(item => item.action);
235
+ }
236
+
237
+ function filterActions(actions, query = '') {
238
+ const q = String(query || '').trim().toLowerCase();
239
+ const rows = q ? (actions || []).filter(action =>
240
+ actionTitle(action).toLowerCase().includes(q) ||
241
+ actionTool(action).toLowerCase().includes(q) ||
242
+ actionCommandType(action).toLowerCase().includes(q)) : (actions || []);
243
+ return orderActionsByAvailability(rows);
244
+ }
245
+
246
+ function formatPoints(value) {
247
+ return Number.isInteger(Number(value)) ? String(Number(value)) : Number(value || 0).toFixed(1);
248
+ }
249
+
250
+ function queuedCommandCost(command) {
251
+ const cost = command?.cost || {};
252
+ const parts = [];
253
+ if (cost.action) parts.push(`${cost.action} AP`);
254
+ if (cost.focus) parts.push(`${cost.focus} FP`);
255
+ return parts.length ? parts.join(' + ') : 'free';
256
+ }
257
+
258
+ function queuedCommandDetail(command) {
259
+ const payload = command?.payload || {};
260
+ return Object.entries(payload)
261
+ .filter(([, value]) => value != null && value !== '')
262
+ .map(([key, value]) => `${key}: ${value}`)
263
+ .join(', ');
264
+ }
265
+
266
+ function queuedCommandLabel(command, actions = []) {
267
+ const name = queuedCommandName(command, actions);
268
+ const lane = command?.lane ? ` [${command.lane}]` : '';
269
+ const details = [queuedCommandCost(command), queuedCommandDetail(command)].filter(Boolean);
270
+ return `${name}${lane}${details.length ? ` - ${details.join(' · ')}` : ''}`;
271
+ }
272
+
273
+ function queuedCommandName(command, actions = []) {
274
+ const match = actions.find(action => action.command_type === command?.command_type);
275
+ return match ? actionTitle(match) : String(command?.command_type || 'command').replaceAll('-', ' ');
276
+ }
277
+
278
+ function randomClientId(prefix = 'web') {
279
+ if (globalThis.crypto?.randomUUID) return crypto.randomUUID();
280
+ const bytes = new Uint8Array(16);
281
+ if (globalThis.crypto?.getRandomValues) {
282
+ crypto.getRandomValues(bytes);
283
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
284
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
285
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, '0'));
286
+ return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`;
287
+ }
288
+ return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
289
+ }
290
+
291
+ function persistentClientId(key, prefix = 'web') {
292
+ try {
293
+ let clientId = localStorage.getItem(key);
294
+ if (!clientId) {
295
+ clientId = randomClientId(prefix);
296
+ localStorage.setItem(key, clientId);
297
+ }
298
+ return clientId;
299
+ } catch (_err) {
300
+ return randomClientId(prefix);
301
+ }
302
+ }
303
+
304
+ function iconPreference(key, defaultValue = true) {
305
+ try {
306
+ const value = localStorage.getItem(key);
307
+ return value == null ? Boolean(defaultValue) : value !== 'false';
308
+ } catch (_err) {
309
+ return Boolean(defaultValue);
310
+ }
311
+ }
312
+
313
+ function setIconPreference(key, value) {
314
+ try {
315
+ localStorage.setItem(key, value ? 'true' : 'false');
316
+ } catch (_err) {
317
+ // Best-effort preference only.
318
+ }
319
+ }
320
+
321
+ function claimSettings({
322
+ fallbackId = 'claim-fallback',
323
+ fallbackControllerId = 'claim-fallback-controller',
324
+ timeoutId = 'claim-timeout',
325
+ defaultMinutes = 30,
326
+ } = {}) {
327
+ const selectedFallback = document.getElementById(fallbackId)?.value || 'suspend';
328
+ const customFallback = document.getElementById(fallbackControllerId)?.value?.trim() || '';
329
+ const fallback = selectedFallback === 'controller' ? customFallback : selectedFallback;
330
+ const rawMinutes = Number(document.getElementById(timeoutId)?.value);
331
+ const minutes = Number.isFinite(rawMinutes) ? Math.min(60, Math.max(5, rawMinutes)) : defaultMinutes;
332
+ return { fallback_controller: fallback, timeout_seconds: Math.round(minutes * 60) };
333
+ }
334
+
335
+ function controlFromResponse(data, fallbackCharacterId = '', { active = true } = {}) {
336
+ if (!data) return null;
337
+ return {
338
+ characterId: data.character_id || fallbackCharacterId,
339
+ controllerId: data.controller_id,
340
+ generation: Number(data.controller_generation ?? data.generation ?? 0),
341
+ claimId: data.id || data.claim_id || '',
342
+ claimSecret: data.claim_secret || '',
343
+ clientId: data.client_id || BunnylandApi.getClientId(),
344
+ active,
345
+ };
346
+ }
347
+
348
+ function syncClaimControl(control, projection, playerId) {
349
+ const projected = projection?.characterId === playerId ? projection?.controller : null;
350
+ if (!control?.characterId || control.characterId !== playerId || !projected) return null;
351
+ const next = {
352
+ characterId: control.characterId,
353
+ controllerId: projected.controller_id,
354
+ generation: Number(projected.generation || 0),
355
+ claimId: control.claimId || '',
356
+ claimSecret: control.claimSecret || '',
357
+ clientId: control.clientId || BunnylandApi.getClientId(),
358
+ active: projected.controller_id === control.controllerId && control.active !== false,
359
+ };
360
+ if (next.claimId && next.claimSecret) return next;
361
+ return projected.controller_id === control.controllerId ? next : null;
362
+ }
363
+
364
+ function playerControl(control, projection, playerId) {
365
+ if (control?.active === false) return null;
366
+ const projected = projection?.characterId === playerId ? projection?.controller : null;
367
+ if (
368
+ control?.characterId === playerId &&
369
+ projected?.controller_id === control.controllerId
370
+ ) {
371
+ const next = {
372
+ controllerId: control.controllerId,
373
+ generation: Number(projected.generation || 0),
374
+ };
375
+ if (control.claimId) next.claimId = control.claimId;
376
+ if (control.claimSecret) next.claimSecret = control.claimSecret;
377
+ return next;
378
+ }
379
+ return null;
380
+ }
381
+
382
+ function claimStorageKey(key, characterId) {
383
+ return `${key}.claim.${characterId}`;
384
+ }
385
+
386
+ function storedClaimControl(key, characterId) {
387
+ try {
388
+ const data = JSON.parse(localStorage.getItem(claimStorageKey(key, characterId)) || '{}');
389
+ if (!data.controllerId || !data.claimId || !data.claimSecret) return null;
390
+ return {
391
+ characterId,
392
+ controllerId: data.controllerId,
393
+ generation: Number(data.generation || 0),
394
+ claimId: data.claimId,
395
+ claimSecret: data.claimSecret,
396
+ clientId: data.clientId || BunnylandApi.getClientId(),
397
+ active: data.active !== false,
398
+ };
399
+ } catch (_err) {
400
+ return null;
401
+ }
402
+ }
403
+
404
+ function storeClaimControl(key, control) {
405
+ if (!key || !control?.characterId || !control?.claimId || !control?.claimSecret) return;
406
+ try {
407
+ localStorage.setItem(claimStorageKey(key, control.characterId), JSON.stringify({
408
+ controllerId: control.controllerId,
409
+ generation: control.generation,
410
+ claimId: control.claimId,
411
+ claimSecret: control.claimSecret,
412
+ clientId: control.clientId || BunnylandApi.getClientId(),
413
+ active: control.active !== false,
414
+ }));
415
+ } catch (_err) {
416
+ // Best-effort continuity only.
417
+ }
418
+ }
419
+
420
+ function clearClaimControl(key, characterId) {
421
+ if (!key || !characterId) return;
422
+ try {
423
+ localStorage.removeItem(claimStorageKey(key, characterId));
424
+ } catch (_err) {
425
+ // Best-effort continuity only.
426
+ }
427
+ }
428
+
429
+ function isClaimNotFoundError(error) {
430
+ return error instanceof BunnylandApi.ApiError && error.status === 404;
431
+ }
432
+
433
+ const claimProjectionRequests = new Map();
434
+
435
+ async function fetchClaimProjectionData(base, control) {
436
+ const normalizedBase = BunnylandApi.assertSameOriginBase(base);
437
+ const key = `${normalizedBase}\0${control.claimId}\0${control.claimSecret || ''}`;
438
+ const pending = claimProjectionRequests.get(key);
439
+ if (pending) return pending;
440
+ const request = BunnylandApi.sendJson(
441
+ normalizedBase,
442
+ `/play/claims/${encodeURIComponent(control.claimId)}/projection`,
443
+ { headers: BunnylandApi.claimHeaders(control) },
444
+ );
445
+ claimProjectionRequests.set(key, request);
446
+ try {
447
+ return await request;
448
+ } finally {
449
+ if (claimProjectionRequests.get(key) === request) claimProjectionRequests.delete(key);
450
+ }
451
+ }
452
+
453
+ async function fetchClaimProjection(base, _characterId, control = null) {
454
+ if (!control?.claimId) return { character: null, queued: null, room: null };
455
+ const data = await fetchClaimProjectionData(base, control);
456
+ return {
457
+ character: parseCharacterProjection(data),
458
+ queued: parseQueuedCommands(data),
459
+ room: parseRoomProjection(data),
460
+ };
461
+ }
462
+
463
+ function allTargets(projection) {
464
+ const targets = [];
465
+ const seen = new Set();
466
+ const add = (value, label, kind = '') => {
467
+ if (!value || seen.has(value)) return;
468
+ seen.add(value);
469
+ targets.push({ value, label: label || value, kind });
470
+ };
471
+ for (const group of Object.values(projection?.targetGroups || {})) {
472
+ for (const item of group || []) add(item.value, item.label, item.kind);
473
+ }
474
+ for (const entity of projection?.room?.entities || []) {
475
+ add(entity.id, entity.name || entity.label || entity.id, entity.kind);
476
+ }
477
+ for (const exit of projection?.room?.exits || []) {
478
+ add(exit.id, exit.direction || exit.label || exit.id, 'exit');
479
+ }
480
+ for (const item of projection?.inventory || []) {
481
+ add(item.id, item.label || item.name || item.id, item.kind);
482
+ }
483
+ return targets;
484
+ }
485
+
486
+ function targetCandidates(projection, arg) {
487
+ if (arg?.target_group && projection?.targetGroups?.[arg.target_group]) {
488
+ return projection.targetGroups[arg.target_group];
489
+ }
490
+ return allTargets(projection);
491
+ }
492
+
493
+ function actionFields(action, targetCandidateFn) {
494
+ return actionArguments(action)
495
+ .filter(arg => arg.key && (arg.required || arg.target_group))
496
+ .map(arg => ({
497
+ key: arg.key,
498
+ label: arg.title || arg.key,
499
+ kind: arg.kind || 'string',
500
+ required: Boolean(arg.required),
501
+ candidates: arg.target_group ? targetCandidateFn(arg.target_group, arg) : null,
502
+ }));
503
+ }
504
+
505
+ function isReferenceArg(arg) {
506
+ return arg?.kind === 'entity' || Boolean(arg?.target_group) || String(arg?.key || '').endsWith('_id');
507
+ }
508
+
509
+ function resolveTargetName(value, candidates) {
510
+ if ((candidates || []).some(c => c.value === value)) return candidates.find(c => c.value === value);
511
+ const query = String(value || '').trim().toLowerCase();
512
+ if (!query) return null;
513
+ const normalize = (text) => String(text || '').trim().toLowerCase().replace(/^(a|an|the)\s+/, '');
514
+ return (candidates || []).find(c => String(c.label).toLowerCase() === query || normalize(c.label) === query) ||
515
+ (candidates || []).slice()
516
+ .sort((a, b) => String(a.label).length - String(b.label).length || String(a.label).localeCompare(String(b.label)))
517
+ .find(c => String(c.label).toLowerCase().startsWith(query) || normalize(c.label).startsWith(query)) || null;
518
+ }
519
+
520
+ function suggestTargetNames(value, candidates) {
521
+ const query = String(value || '').trim().toLowerCase();
522
+ if (!query) return [];
523
+ return (candidates || [])
524
+ .map(c => String(c.label))
525
+ .filter(label => label.toLowerCase().includes(query.slice(0, 3)))
526
+ .slice(0, 3);
527
+ }
528
+
529
+ function targetPrefix(rest, candidates) {
530
+ const lower = String(rest || '').toLowerCase();
531
+ const sorted = (candidates || []).slice().sort((a, b) => String(b.label).length - String(a.label).length);
532
+ for (const candidate of sorted) {
533
+ const label = String(candidate.label);
534
+ if (lower === label.toLowerCase()) return { raw: label, remaining: '' };
535
+ if (lower.startsWith(`${label.toLowerCase()} `)) {
536
+ return { raw: label, remaining: String(rest).slice(label.length).trim() };
537
+ }
538
+ }
539
+ const [first, ...restParts] = String(rest || '').split(/\s+/);
540
+ if (first) return { raw: first, remaining: restParts.join(' ') };
541
+ return null;
542
+ }
543
+
544
+ async function fetchRecentEvents(base) {
545
+ return BunnylandApi.sendJson(base, '/admin/world/events');
546
+ }
547
+
548
+ async function fetchCharacterRecentEvents(base, _characterId, control = null) {
549
+ if (!control?.claimId) throw new Error('An active claim is required');
550
+ return BunnylandApi.sendJson(
551
+ base,
552
+ `/play/claims/${encodeURIComponent(control.claimId)}/events`,
553
+ { headers: BunnylandApi.claimHeaders(control) }
554
+ );
555
+ }
556
+
557
+ const PLAYER_FRAME_TYPES = new Set(['ready', 'event', 'invalidate', 'resync', 'heartbeat']);
558
+ const RECONNECT_SECONDS = [1, 2, 4, 8, 16, 30];
559
+ const SEEN_EVENT_LIMIT = 512;
560
+
561
+ function openPlayerUpdates({
562
+ base,
563
+ characterId: _characterId,
564
+ control = null,
565
+ onFrame,
566
+ onOpen = () => {},
567
+ onClose = () => {},
568
+ onError = () => {},
569
+ onAnyFrame = () => {},
570
+ webSocketFactory = null,
571
+ }) {
572
+ const factory = webSocketFactory || (
573
+ typeof WebSocket === 'function' ? url => new WebSocket(url) : null
574
+ );
575
+ if (!factory) return null;
576
+ if (!control?.claimId) return null;
577
+ const path = `/play/claims/${encodeURIComponent(control.claimId)}/stream`;
578
+ const socket = factory(BunnylandApi.socketUrl(base, path, BunnylandApi.getPlayerAuth()));
579
+ socket.onopen = () => {
580
+ socket.send(JSON.stringify({
581
+ type: 'authenticate',
582
+ data: {
583
+ token: BunnylandApi.getPlayerAuth()?.startsWith('Bearer ')
584
+ ? BunnylandApi.getPlayerAuth().slice(7)
585
+ : null,
586
+ client_id: control?.clientId || BunnylandApi.getClientId(),
587
+ claim_secret: control?.claimSecret || null,
588
+ },
589
+ }));
590
+ onOpen();
591
+ };
592
+ socket.onmessage = event => {
593
+ onAnyFrame();
594
+ let frame;
595
+ try {
596
+ frame = JSON.parse(String(event.data ?? ''));
597
+ } catch (_err) {
598
+ return;
599
+ }
600
+ if (!frame || typeof frame !== 'object' || !PLAYER_FRAME_TYPES.has(frame.type) ||
601
+ !frame.data || typeof frame.data !== 'object') return;
602
+ onFrame(frame);
603
+ };
604
+ socket.onclose = onClose;
605
+ socket.onerror = onError;
606
+ return socket;
607
+ }
608
+
609
+ function createPlayerLiveUpdates({
610
+ base,
611
+ characterId,
612
+ control = null,
613
+ refresh,
614
+ onFrame = () => {},
615
+ onState = () => {},
616
+ webSocketFactory = null,
617
+ random = Math.random,
618
+ }) {
619
+ let closed = false;
620
+ let state = 'connecting';
621
+ let socket = null;
622
+ let generation = 0;
623
+ let reconnectAttempt = 0;
624
+ let reconnectTimer = null;
625
+ let pollTimer = null;
626
+ let heartbeatTimer = null;
627
+ let refreshTimer = null;
628
+ let refreshing = false;
629
+ let refreshPending = false;
630
+ let lastStreamSequence = 0;
631
+ const seenEventIds = new Set();
632
+ const seenEventOrder = [];
633
+ const setState = next => {
634
+ if (state === next) return;
635
+ state = next;
636
+ onState(next);
637
+ };
638
+ const clearHeartbeat = () => {
639
+ clearTimeout(heartbeatTimer);
640
+ heartbeatTimer = null;
641
+ };
642
+ const runRefresh = async () => {
643
+ refreshTimer = null;
644
+ if (closed) return;
645
+ if (refreshing) {
646
+ refreshPending = true;
647
+ return;
648
+ }
649
+ refreshing = true;
650
+ try {
651
+ await refresh();
652
+ } catch (_err) {
653
+ // Keep later polls and live frames active after a transient HTTP failure.
654
+ } finally {
655
+ refreshing = false;
656
+ if (refreshPending && !closed) {
657
+ refreshPending = false;
658
+ void runRefresh();
659
+ }
660
+ }
661
+ };
662
+ const requestRefresh = (delay = 0) => {
663
+ if (closed) return;
664
+ if (refreshing) {
665
+ refreshPending = true;
666
+ return;
667
+ }
668
+ if (refreshTimer != null) return;
669
+ refreshTimer = setTimeout(() => { void runRefresh(); }, delay);
670
+ };
671
+ const stopPolling = () => {
672
+ clearInterval(pollTimer);
673
+ pollTimer = null;
674
+ };
675
+ const startPolling = () => {
676
+ if (closed || pollTimer != null) return;
677
+ requestRefresh();
678
+ pollTimer = setInterval(() => requestRefresh(), 2000);
679
+ };
680
+ const scheduleHeartbeat = token => {
681
+ clearHeartbeat();
682
+ heartbeatTimer = setTimeout(() => {
683
+ if (closed || token !== generation) return;
684
+ const stale = socket;
685
+ socket = null;
686
+ generation += 1;
687
+ stale?.close();
688
+ disconnected();
689
+ }, 70000);
690
+ };
691
+ const scheduleReconnect = () => {
692
+ if (closed || reconnectTimer != null) return;
693
+ const seconds = RECONNECT_SECONDS[Math.min(reconnectAttempt, RECONNECT_SECONDS.length - 1)];
694
+ reconnectAttempt += 1;
695
+ const delay = seconds * 1000 * (0.8 + random() * 0.4);
696
+ reconnectTimer = setTimeout(() => {
697
+ reconnectTimer = null;
698
+ connect();
699
+ }, delay);
700
+ };
701
+ const disconnected = () => {
702
+ if (closed) return;
703
+ clearHeartbeat();
704
+ setState('fallback');
705
+ startPolling();
706
+ scheduleReconnect();
707
+ };
708
+ const connect = () => {
709
+ if (closed) return;
710
+ const token = ++generation;
711
+ lastStreamSequence = 0;
712
+ setState('connecting');
713
+ startPolling();
714
+ const opened = openPlayerUpdates({
715
+ base,
716
+ characterId,
717
+ control,
718
+ webSocketFactory,
719
+ onOpen: () => {
720
+ if (!closed && token === generation) scheduleHeartbeat(token);
721
+ },
722
+ onAnyFrame: () => {
723
+ if (!closed && token === generation) scheduleHeartbeat(token);
724
+ },
725
+ onFrame: frame => {
726
+ if (closed || token !== generation) return;
727
+ const sequence = Number(frame.stream_sequence || 0);
728
+ const sequenceGap = sequence > 0 && lastStreamSequence > 0
729
+ && sequence !== lastStreamSequence + 1;
730
+ if (sequence > 0) lastStreamSequence = sequence;
731
+
732
+ const eventId = typeof frame.event_id === 'string' ? frame.event_id : '';
733
+ if (eventId && seenEventIds.has(eventId)) return;
734
+ if (eventId) {
735
+ seenEventIds.add(eventId);
736
+ seenEventOrder.push(eventId);
737
+ if (seenEventOrder.length > SEEN_EVENT_LIMIT) {
738
+ seenEventIds.delete(seenEventOrder.shift());
739
+ }
740
+ }
741
+
742
+ onFrame(frame);
743
+ if (frame.type === 'ready') {
744
+ reconnectAttempt = 0;
745
+ stopPolling();
746
+ setState('live');
747
+ } else if (frame.type === 'resync' || sequenceGap) {
748
+ requestRefresh();
749
+ } else if (frame.type !== 'heartbeat') {
750
+ requestRefresh(150);
751
+ }
752
+ },
753
+ onClose: () => {
754
+ if (closed || token !== generation) return;
755
+ socket = null;
756
+ disconnected();
757
+ },
758
+ onError: () => {
759
+ if (closed || token !== generation) return;
760
+ const failed = socket;
761
+ socket = null;
762
+ generation += 1;
763
+ failed?.close();
764
+ disconnected();
765
+ },
766
+ });
767
+ if (closed || token !== generation) {
768
+ opened?.close();
769
+ return;
770
+ }
771
+ socket = opened;
772
+ if (!opened) disconnected();
773
+ };
774
+ onState(state);
775
+ connect();
776
+ return {
777
+ close() {
778
+ if (closed) return;
779
+ closed = true;
780
+ generation += 1;
781
+ clearTimeout(reconnectTimer);
782
+ clearTimeout(refreshTimer);
783
+ reconnectTimer = null;
784
+ refreshTimer = null;
785
+ stopPolling();
786
+ clearHeartbeat();
787
+ const current = socket;
788
+ socket = null;
789
+ current?.close();
790
+ setState('closed');
791
+ },
792
+ getState: () => state,
793
+ };
794
+ }
795
+
796
+ async function fetchCharacterList(base) {
797
+ return parseCharacterList(await BunnylandApi.sendJson(base, '/play/characters'));
798
+ }
799
+
800
+ async function fetchCharacterProfileList(base) {
801
+ return parseCharacterList(await BunnylandApi.sendJson(base, '/profile/characters'));
802
+ }
803
+
804
+ async function fetchCharacterProfile(base, characterId) {
805
+ return parseCharacterProjection(
806
+ await BunnylandApi.sendJson(
807
+ base,
808
+ `/profile/characters/${encodeURIComponent(characterId)}`
809
+ )
810
+ );
811
+ }
812
+
813
+ async function fetchCharacterProjection(base, _characterId, control = null) {
814
+ return (await fetchClaimProjection(base, _characterId, control)).character;
815
+ }
816
+
817
+ async function fetchRoomProjection(base, _roomId, _characterId, control = null) {
818
+ return (await fetchClaimProjection(base, _characterId, control)).room;
819
+ }
820
+
821
+ async function fetchQueuedCommands(base, _characterId, control = null) {
822
+ return (await fetchClaimProjection(base, _characterId, control)).queued;
823
+ }
824
+
825
+ async function cancelQueuedCommand(base, _characterId, commandId, control) {
826
+ if (!control?.claimId) throw new Error('An active claim is required');
827
+ return BunnylandApi.sendJson(
828
+ base,
829
+ `/play/claims/${encodeURIComponent(control.claimId)}/commands/${encodeURIComponent(commandId)}`,
830
+ { method: 'DELETE', headers: BunnylandApi.claimHeaders(control) }
831
+ );
832
+ }
833
+
834
+ async function claimWebController(base, payload, control = null) {
835
+ BunnylandApi.setClientId(payload.client_id || control?.clientId);
836
+ const claimId = control?.claimId || payload.claim_id || '';
837
+ const request = async (resumeId, requestControl) => BunnylandApi.sendJsonWithResponse(
838
+ base,
839
+ resumeId ? `/play/claims/${encodeURIComponent(resumeId)}` : '/play/claims',
840
+ {
841
+ method: resumeId ? 'PUT' : 'POST',
842
+ headers: BunnylandApi.claimHeaders(requestControl),
843
+ body: resumeId ? null : JSON.stringify({
844
+ character_id: payload.character_id,
845
+ label: payload.label,
846
+ fallback_controller: payload.fallback_controller,
847
+ fallback_reason: payload.fallback_reason,
848
+ llm_profile_name: payload.llm_profile_name,
849
+ llm_model: payload.llm_model,
850
+ llm_provider: payload.llm_provider,
851
+ timeout_seconds: payload.timeout_seconds,
852
+ }),
853
+ },
854
+ );
855
+ let result;
856
+ let priorSecret = control?.claimSecret || '';
857
+ try {
858
+ result = await request(claimId, control);
859
+ } catch (error) {
860
+ if (!claimId || !isClaimNotFoundError(error)) throw error;
861
+ result = await request('', null);
862
+ priorSecret = '';
863
+ }
864
+ const { data, response } = result;
865
+ return {
866
+ ...data,
867
+ claim_id: data.id,
868
+ claim_secret: response.headers.get('X-Bunnyland-Claim-Secret') || priorSecret,
869
+ };
870
+ }
871
+
872
+ async function updateWebControllerFallback(base, payload, control = null) {
873
+ if (!control?.claimId) throw new Error('An active claim is required');
874
+ return BunnylandApi.sendJson(base, `/play/claims/${encodeURIComponent(control.claimId)}`, {
875
+ method: 'PATCH',
876
+ headers: BunnylandApi.claimHeaders(control),
877
+ body: JSON.stringify({
878
+ kind: 'fallback',
879
+ fallback_controller: payload.fallback_controller,
880
+ fallback_reason: payload.fallback_reason,
881
+ llm_profile_name: payload.llm_profile_name,
882
+ llm_model: payload.llm_model,
883
+ llm_provider: payload.llm_provider,
884
+ timeout_seconds: payload.timeout_seconds,
885
+ }),
886
+ });
887
+ }
888
+
889
+ async function releaseWebController(base, _payload, control = null) {
890
+ if (!control?.claimId) throw new Error('An active claim is required');
891
+ return BunnylandApi.sendJson(base, `/play/claims/${encodeURIComponent(control.claimId)}`, {
892
+ method: 'PATCH',
893
+ headers: BunnylandApi.claimHeaders(control),
894
+ body: JSON.stringify({ kind: 'control', desired: 'fallback' }),
895
+ });
896
+ }
897
+
898
+ async function releaseWebClaim(base, _payload, control = null) {
899
+ if (!control?.claimId) throw new Error('An active claim is required');
900
+ return BunnylandApi.sendJson(base, `/play/claims/${encodeURIComponent(control.claimId)}`, {
901
+ method: 'DELETE',
902
+ headers: BunnylandApi.claimHeaders(control),
903
+ });
904
+ }
905
+
906
+ async function submitCommand(base, payload, control = null) {
907
+ if (!control?.claimId) throw new Error('An active claim is required');
908
+ const {
909
+ character_id: _characterId,
910
+ controller_id: _controllerId,
911
+ controller_generation: _generation,
912
+ claim_id: _claimId,
913
+ client_id: _clientId,
914
+ ...command
915
+ } = payload;
916
+ return BunnylandApi.sendJson(base, `/play/claims/${encodeURIComponent(control.claimId)}/commands`, {
917
+ method: 'POST',
918
+ headers: BunnylandApi.claimHeaders(control),
919
+ body: JSON.stringify(command),
920
+ });
921
+ }
922
+
923
+ function queuedCountdownSeconds(queueProjection) {
924
+ const nextTick = queueProjection?.nextTickAtUnix;
925
+ if (nextTick == null) return null;
926
+ return Math.max(0, Math.round(Number(nextTick) - Date.now() / 1000));
927
+ }
928
+
929
+ const UNNARRATED_EVENT_TYPES = new Set([
930
+ 'CommandSubmittedEvent', 'CommandAcceptedEvent', 'CommandQueuedEvent',
931
+ 'CommandCancelledEvent',
932
+ 'CommandExecutedEvent', 'CommandExpiredEvent',
933
+ 'ActionPointsChangedEvent', 'FocusPointsChangedEvent', 'EncumbranceChangedEvent',
934
+ 'PainChangedEvent', 'BleedingChangedEvent', 'AttentionShiftedEvent', 'AffectChangedEvent',
935
+ 'EntitySeenEvent', 'RoomQualityUpdatedEvent', 'HungerChangedEvent',
936
+ 'ThirstChangedEvent', 'DailyNeedChangedEvent', 'SkillXPChangedEvent',
937
+ ]);
938
+
939
+ const SYSTEM_EVENT_TYPES = new Set([
940
+ 'ControllerChangedEvent',
941
+ 'WorldPauseStatusChangedEvent',
942
+ ]);
943
+
944
+ const EVENT_ICON_BY_TYPE = {
945
+ ActorMovedEvent: '➡️',
946
+ RoomLookedEvent: '👁️',
947
+ CommandRejectedEvent: '⚠️',
948
+ ControllerChangedEvent: '🎮',
949
+ WorldPauseStatusChangedEvent: '⏸️',
950
+ CharacterClaimedEvent: '🎮',
951
+ };
952
+
953
+ const EVENT_BASE_KEYS = new Set([
954
+ 'event_id', 'world_epoch', 'created_at', 'visibility', 'actor_id', 'room_id',
955
+ 'target_ids', 'causation_id', 'correlation_id', 'arrival_summary',
956
+ ]);
957
+
958
+ function humanizeEventType(eventType) {
959
+ const name = String(eventType || 'Event').replace(/Event$/, '');
960
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, c => c.toUpperCase());
961
+ }
962
+
963
+ function eventIcon(eventType, event = {}) {
964
+ if (eventType === 'CommandRejectedEvent' && event.command_type) {
965
+ return actionIcon({ command_type: event.command_type });
966
+ }
967
+ return EVENT_ICON_BY_TYPE[eventType] || '•';
968
+ }
969
+
970
+ function perceivesEvent(event, { playerId = '', roomOf = () => null } = {}) {
971
+ const visibility = event?.visibility;
972
+ if (visibility === 'public') return true;
973
+ if (visibility === 'room') return Boolean(playerId) && event.room_id === roomOf(playerId);
974
+ if (visibility === 'directed') {
975
+ return Boolean(playerId) && (
976
+ event.actor_id === playerId || (event.target_ids || []).includes(playerId)
977
+ );
978
+ }
979
+ if (visibility === 'private') return Boolean(playerId) && event.actor_id === playerId;
980
+ return false;
981
+ }
982
+
983
+ function renderEventLine(data, { playerId = '', nameFor = () => null } = {}) {
984
+ const event = data?.event || {};
985
+ const eventType = String(data?.event_type || 'Event');
986
+ if (eventType === 'ActorMovedEvent' && playerId &&
987
+ event.actor_id === playerId && event.arrival_summary) {
988
+ return { text: String(event.arrival_summary), kind: 'event', icon: eventIcon(eventType, event) };
989
+ }
990
+ if (eventType === 'RoomLookedEvent' && event.summary) {
991
+ return { text: String(event.summary), kind: 'event', icon: eventIcon(eventType, event) };
992
+ }
993
+ const actor = event.actor_id ? nameFor(event.actor_id) : null;
994
+ const details = [];
995
+ for (const [key, value] of Object.entries(event)) {
996
+ if (EVENT_BASE_KEYS.has(key) || value == null || value === '' ||
997
+ (Array.isArray(value) && !value.length)) continue;
998
+ if (key.endsWith('_ids') && Array.isArray(value)) {
999
+ const names = value.map(item => nameFor(String(item))).filter(Boolean);
1000
+ if (names.length) details.push(names.join(', '));
1001
+ } else if (key.endsWith('_id')) {
1002
+ const name = nameFor(String(value));
1003
+ if (name) details.push(name);
1004
+ } else if (key === 'facts' && Array.isArray(value)) {
1005
+ details.push(...value.flatMap(fact => {
1006
+ if (!fact || typeof fact !== 'object' || !('text' in fact)) return [];
1007
+ const text = String(fact.text || '').trim();
1008
+ return text ? [text] : [];
1009
+ }));
1010
+ } else {
1011
+ details.push(`${key.replaceAll('_', ' ')} ${String(value)}`);
1012
+ }
1013
+ }
1014
+ const label = humanizeEventType(eventType);
1015
+ return {
1016
+ text: `${actor ? `${actor}: ` : ''}${label}${details.length ? ` - ${details.join('; ')}` : ''}`,
1017
+ kind: eventType === 'CommandRejectedEvent'
1018
+ ? 'rejection'
1019
+ : SYSTEM_EVENT_TYPES.has(eventType) ? 'system' : 'event',
1020
+ icon: eventIcon(eventType, event),
1021
+ };
1022
+ }
1023
+
1024
+ function drainNarratedEvents(messages, {
1025
+ seenIds = new Set(),
1026
+ playerId = '',
1027
+ roomOf = () => null,
1028
+ nameFor = () => null,
1029
+ } = {}) {
1030
+ const current = new Set(seenIds);
1031
+ const lines = [];
1032
+ for (const message of messages || []) {
1033
+ const data = message.data || message;
1034
+ const event = data.event || {};
1035
+ const eventId = event.event_id;
1036
+ if (!eventId) continue;
1037
+ current.add(eventId);
1038
+ if (seenIds.has(eventId)) continue;
1039
+ const eventType = data.event_type || 'Event';
1040
+ if (UNNARRATED_EVENT_TYPES.has(eventType)) continue;
1041
+ const own = playerId && event.actor_id === playerId;
1042
+ if (own || perceivesEvent(event, { playerId, roomOf })) {
1043
+ lines.push(renderEventLine(data, { playerId, nameFor }));
1044
+ }
1045
+ }
1046
+ return { lines, seenIds: current };
1047
+ }
1048
+
1049
+ function inventoryEntries(projection) {
1050
+ // Normalize a character projection's carried items into display rows (id, label, kind,
1051
+ // icon). Shared by the inventory views so every client lists the same thing the same way.
1052
+ return (projection?.inventory || []).map(item => ({
1053
+ id: item.id,
1054
+ label: item.label || item.id,
1055
+ kind: item.kind || '',
1056
+ icon: entityIcon(item),
1057
+ }));
1058
+ }
1059
+
1060
+ function imageRequestMessage(result) {
1061
+ if (!result || result.ok === false) {
1062
+ return `${IMAGE_AFFORDANCE.REQUEST_EMOJI} ${(result && result.reason) || 'image request failed'}`;
1063
+ }
1064
+ if (result.status === 'skipped') return `${IMAGE_AFFORDANCE.DELIVER_EMOJI} image ready`;
1065
+ return `${IMAGE_AFFORDANCE.ACK_EMOJI} image requested`;
1066
+ }
1067
+
1068
+ function imageCompletionFromMessage(message, base = '') {
1069
+ const data = message?.data || message || {};
1070
+ if (data.event_type !== 'ImageGenerationCompletedEvent') return null;
1071
+ const event = data.event || {};
1072
+ if (!event.url) return null;
1073
+ return {
1074
+ entityId: String(event.entity_id || ''),
1075
+ purpose: String(event.purpose || ''),
1076
+ url: BunnylandApi.mediaUrl(base, event.url),
1077
+ alphaUrl: event.alpha_url ? BunnylandApi.mediaUrl(base, event.alpha_url) : '',
1078
+ epoch: Number(event.world_epoch || 0),
1079
+ };
1080
+ }
1081
+
1082
+ function latestImageCompletion(messages, { base = '', purpose = '' } = {}) {
1083
+ let best = null;
1084
+ for (const message of messages || []) {
1085
+ const image = imageCompletionFromMessage(message, base);
1086
+ if (!image) continue;
1087
+ if (purpose && image.purpose !== purpose) continue;
1088
+ if (!best || image.epoch >= best.epoch) best = image;
1089
+ }
1090
+ return best;
1091
+ }
1092
+
1093
+ function imageFailureFromMessage(message) {
1094
+ const data = message?.data || message || {};
1095
+ if (data.event_type !== 'ImageGenerationFailedEvent') return null;
1096
+ const event = data.event || {};
1097
+ return {
1098
+ entityId: String(event.entity_id || ''),
1099
+ purpose: String(event.purpose || ''),
1100
+ reason: String(event.reason || 'image generation failed'),
1101
+ epoch: Number(event.world_epoch || 0),
1102
+ };
1103
+ }
1104
+
1105
+ function latestImageFailure(messages, { purpose = '' } = {}) {
1106
+ let best = null;
1107
+ for (const message of messages || []) {
1108
+ const failure = imageFailureFromMessage(message);
1109
+ if (!failure) continue;
1110
+ if (purpose && failure.purpose !== purpose) continue;
1111
+ if (!best || failure.epoch >= best.epoch) best = failure;
1112
+ }
1113
+ return best;
1114
+ }
1115
+
1116
+ function characterHref(apiBase, characterId, view = 'sheet', page = 'character.html') {
1117
+ const url = new URL(page, location.href);
1118
+ if (url.origin !== location.origin) {
1119
+ throw new Error('Bunnyland browser links must use the page origin');
1120
+ }
1121
+ const normalized = BunnylandApi.assertSameOriginBase(apiBase);
1122
+ if (normalized) url.searchParams.set('server', normalized);
1123
+ else url.searchParams.delete('server');
1124
+ if (view === 'chat') url.searchParams.set('view', 'chat');
1125
+ else url.searchParams.delete('view');
1126
+ url.hash = characterId || '';
1127
+ return `${url.pathname.split('/').pop()}${url.search}${url.hash}`;
1128
+ }
1129
+
1130
+ function portraitStatusMessage(projection, requestState = '') {
1131
+ if (projection?.portrait?.url) return 'Portrait ready.';
1132
+ if (requestState === 'requesting') return 'Requesting portrait...';
1133
+ if (requestState === 'queued') return 'Portrait generation queued.';
1134
+ if (requestState === 'failed') return 'Portrait generation unavailable.';
1135
+ return 'Portrait pending.';
1136
+ }
1137
+
1138
+ window.BunnylandPlay = {
1139
+ KIND_ICON,
1140
+ IMAGE_AFFORDANCE,
1141
+ actionIcon,
1142
+ actionArguments,
1143
+ actionAvailable,
1144
+ actionCommandType,
1145
+ actionCost,
1146
+ actionFields,
1147
+ actionLane,
1148
+ actionTitle,
1149
+ actionTool,
1150
+ actionUnavailableReason,
1151
+ allTargets,
1152
+ cancelQueuedCommand,
1153
+ claimWebController,
1154
+ clearClaimControl,
1155
+ isClaimNotFoundError,
1156
+ storedClaimControl,
1157
+ storeClaimControl,
1158
+ claimSettings,
1159
+ controlFromResponse,
1160
+ syncClaimControl,
1161
+ entityIcon,
1162
+ entityName,
1163
+ entityType,
1164
+ fetchCharacterList,
1165
+ fetchCharacterProfile,
1166
+ fetchCharacterProfileList,
1167
+ fetchCharacterRecentEvents,
1168
+ fetchClaimProjection,
1169
+ fetchCharacterProjection,
1170
+ fetchQueuedCommands,
1171
+ fetchRecentEvents,
1172
+ fetchRoomProjection,
1173
+ filterActions,
1174
+ formatPoints,
1175
+ characterHref,
1176
+ drainNarratedEvents,
1177
+ eventIcon,
1178
+ humanizeEventType,
1179
+ iconPreference,
1180
+ imageCompletionFromMessage,
1181
+ imageFailureFromMessage,
1182
+ imageRequestMessage,
1183
+ inventoryEntries,
1184
+ latestImageCompletion,
1185
+ latestImageFailure,
1186
+ openPlayerUpdates,
1187
+ createPlayerLiveUpdates,
1188
+ isReferenceArg,
1189
+ perceivesEvent,
1190
+ orderActionsByAvailability,
1191
+ parseCharacterList,
1192
+ parseCharacterProjection,
1193
+ parseQueuedCommands,
1194
+ parseRoomProjection,
1195
+ persistentClientId,
1196
+ portraitStatusMessage,
1197
+ playerControl,
1198
+ queuedCommandLabel,
1199
+ queuedCommandCost,
1200
+ queuedCommandDetail,
1201
+ queuedCommandName,
1202
+ queuedCountdownSeconds,
1203
+ renderEventLine,
1204
+ resolveTargetName,
1205
+ randomClientId,
1206
+ releaseWebClaim,
1207
+ releaseWebController,
1208
+ setIconPreference,
1209
+ suggestTargetNames,
1210
+ submitCommand,
1211
+ targetCandidates,
1212
+ targetIcon,
1213
+ targetPrefix,
1214
+ updateWebControllerFallback,
1215
+ };
1216
+
1217
+ Object.assign(window, {
1218
+ parseCharacterList,
1219
+ parseCharacterProjection,
1220
+ parseQueuedCommands,
1221
+ parseRoomProjection,
1222
+ });
1223
+ }());