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