@crowdedkingdoms/crowdyjs 8.3.0 → 8.4.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/README.md +60 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/stores/actors.d.ts +316 -0
- package/dist/stores/actors.d.ts.map +1 -0
- package/dist/stores/actors.js +506 -0
- package/dist/stores/chunks.d.ts +180 -0
- package/dist/stores/chunks.d.ts.map +1 -0
- package/dist/stores/chunks.js +378 -0
- package/dist/stores/codec.d.ts +109 -0
- package/dist/stores/codec.d.ts.map +1 -0
- package/dist/stores/codec.js +186 -0
- package/dist/stores/durable.d.ts +144 -0
- package/dist/stores/durable.d.ts.map +1 -0
- package/dist/stores/durable.js +246 -0
- package/dist/stores/errors.d.ts +69 -0
- package/dist/stores/errors.d.ts.map +1 -0
- package/dist/stores/errors.js +87 -0
- package/dist/stores/inbox.d.ts +184 -0
- package/dist/stores/inbox.d.ts.map +1 -0
- package/dist/stores/inbox.js +326 -0
- package/dist/stores/index.d.ts +186 -0
- package/dist/stores/index.d.ts.map +1 -0
- package/dist/stores/index.js +109 -0
- package/dist/stores/keys.d.ts +52 -0
- package/dist/stores/keys.d.ts.map +1 -0
- package/dist/stores/keys.js +76 -0
- package/dist/stores/model.d.ts +81 -0
- package/dist/stores/model.d.ts.map +1 -0
- package/dist/stores/model.js +163 -0
- package/dist/stores/session.d.ts +119 -0
- package/dist/stores/session.d.ts.map +1 -0
- package/dist/stores/session.js +116 -0
- package/dist/stores/ticker.d.ts +44 -0
- package/dist/stores/ticker.d.ts.map +1 -0
- package/dist/stores/ticker.js +127 -0
- package/package.json +6 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable-state stores: host election tracking ({@link HostTracker}), the
|
|
3
|
+
* per-user app save blob ({@link SaveStateStore}), and typed avatar state
|
|
4
|
+
* ({@link AvatarStateStore}). These wrap the GraphQL surfaces with local
|
|
5
|
+
* caches and typed codecs — independent types from your replication state,
|
|
6
|
+
* as durable and realtime payloads rarely share a layout.
|
|
7
|
+
*/
|
|
8
|
+
import { type StateCodec } from './codec.js';
|
|
9
|
+
import type { WorldSessionContext } from './session.js';
|
|
10
|
+
/** Options for {@link attachHostTracker}. */
|
|
11
|
+
export interface HostTrackerConfig {
|
|
12
|
+
/**
|
|
13
|
+
* The authenticated user's id (or a getter), used to compute
|
|
14
|
+
* {@link HostTracker.isHost}. Without it only `hostUserId` is tracked.
|
|
15
|
+
*/
|
|
16
|
+
myUserId?: string | (() => string | null);
|
|
17
|
+
/** Heartbeat cadence in ms (also keeps you host-eligible). Defaults to 3000. */
|
|
18
|
+
intervalMs?: number;
|
|
19
|
+
/** Send one heartbeat immediately on attach. Defaults to true. */
|
|
20
|
+
heartbeatImmediately?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The SDK-managed **host election tracker**: heartbeats on the session
|
|
24
|
+
* ticker (keeping this client host-eligible), caches the elected host, and
|
|
25
|
+
* fires {@link onHostChanged} on transitions. Election is informational —
|
|
26
|
+
* gate authoritative writes with `is_host` invoke policies server-side.
|
|
27
|
+
* Transient heartbeat failures keep the last known host.
|
|
28
|
+
*/
|
|
29
|
+
export declare class HostTracker {
|
|
30
|
+
private readonly ctx;
|
|
31
|
+
private readonly config;
|
|
32
|
+
private hostUserIdValue;
|
|
33
|
+
private readonly listeners;
|
|
34
|
+
constructor(ctx: WorldSessionContext, config?: HostTrackerConfig);
|
|
35
|
+
/** The elected host's user id (null until the first successful beat). */
|
|
36
|
+
get hostUserId(): string | null;
|
|
37
|
+
/** Whether the configured user is the elected host. */
|
|
38
|
+
get isHost(): boolean;
|
|
39
|
+
/** Fired when the elected host changes (including the first election). @returns off. */
|
|
40
|
+
onHostChanged(listener: (hostUserId: string | null) => void): () => void;
|
|
41
|
+
/** Send one heartbeat now and apply the result. */
|
|
42
|
+
beat(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
/** Attach a {@link HostTracker}. Prefer the `host` config key. */
|
|
45
|
+
export declare function attachHostTracker(ctx: WorldSessionContext, config?: HostTrackerConfig): HostTracker;
|
|
46
|
+
/** Options for {@link attachSaveState}. */
|
|
47
|
+
export interface SaveStateConfig<T> {
|
|
48
|
+
/** Codec for the save blob. Defaults to JSON. */
|
|
49
|
+
codec?: StateCodec<T>;
|
|
50
|
+
/**
|
|
51
|
+
* Debounced autosave: when set, a dirty value persists automatically at
|
|
52
|
+
* most once per this many ms (on the session ticker). Defaults to off.
|
|
53
|
+
*/
|
|
54
|
+
autosaveMs?: number | false;
|
|
55
|
+
/** Clock override for tests. Defaults to `Date.now`. */
|
|
56
|
+
now?: () => number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The SDK-managed **save state**: a typed local cache over the per-user
|
|
60
|
+
* per-app `client.state` blob. `load()` hydrates it, `set()` updates it (and
|
|
61
|
+
* autosave persists it when configured), `save()` persists on demand.
|
|
62
|
+
* The type is intentionally independent of your replication state — durable
|
|
63
|
+
* saves and 5 Hz poses are different data.
|
|
64
|
+
*/
|
|
65
|
+
export declare class SaveStateStore<T> {
|
|
66
|
+
private readonly ctx;
|
|
67
|
+
private current;
|
|
68
|
+
private dirtyFlag;
|
|
69
|
+
private saving;
|
|
70
|
+
private lastSavedAtValue;
|
|
71
|
+
private readonly codec;
|
|
72
|
+
private readonly now;
|
|
73
|
+
constructor(ctx: WorldSessionContext, config?: SaveStateConfig<T>);
|
|
74
|
+
/** The cached typed save (null before {@link load}/{@link set}). */
|
|
75
|
+
get value(): T | null;
|
|
76
|
+
/** Whether the cache has unsaved changes. */
|
|
77
|
+
get dirty(): boolean;
|
|
78
|
+
/** Local time of the last successful save. */
|
|
79
|
+
get lastSavedAt(): number | null;
|
|
80
|
+
/** Fetch and decode the server copy into the cache (null when none). */
|
|
81
|
+
load(): Promise<T | null>;
|
|
82
|
+
/** Update the cached save and mark it dirty (autosave persists it). */
|
|
83
|
+
set(value: T): void;
|
|
84
|
+
/** Merge a partial update into the cached save (object saves only). */
|
|
85
|
+
patch(patch: Partial<T>): void;
|
|
86
|
+
/** Persist the cached save now. No-op when nothing is cached. */
|
|
87
|
+
save(): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
/** Attach a {@link SaveStateStore}. Prefer the `save` config key. */
|
|
90
|
+
export declare function attachSaveState<T>(ctx: WorldSessionContext, config?: SaveStateConfig<T>): SaveStateStore<T>;
|
|
91
|
+
/** Options for {@link attachAvatarState}. */
|
|
92
|
+
export interface AvatarStateConfig<TPublic, TPrivate, TApp> {
|
|
93
|
+
/** The avatar to bind. Omit to bind the caller's first avatar on load. */
|
|
94
|
+
avatarId?: string;
|
|
95
|
+
/** Codec for the public (anyone-readable) avatar state. Defaults to JSON. */
|
|
96
|
+
publicCodec?: StateCodec<TPublic>;
|
|
97
|
+
/** Codec for the private (owner-only) avatar state. Defaults to JSON. */
|
|
98
|
+
privateCodec?: StateCodec<TPrivate>;
|
|
99
|
+
/** Codec for the per-app avatar state. Defaults to JSON. */
|
|
100
|
+
appCodec?: StateCodec<TApp>;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The SDK-managed **avatar state**: typed, cached views of one avatar's
|
|
104
|
+
* public / private / per-app state blobs, each with its own codec (three
|
|
105
|
+
* independent types — public profiles, private inventory-ish data, and
|
|
106
|
+
* app-specific progress rarely share a shape).
|
|
107
|
+
*/
|
|
108
|
+
export declare class AvatarStateStore<TPublic = unknown, TPrivate = unknown, TApp = unknown> {
|
|
109
|
+
private readonly ctx;
|
|
110
|
+
private avatarIdValue;
|
|
111
|
+
private publicValue;
|
|
112
|
+
private privateValue;
|
|
113
|
+
private appValue;
|
|
114
|
+
private readonly publicCodec;
|
|
115
|
+
private readonly privateCodec;
|
|
116
|
+
private readonly appCodec;
|
|
117
|
+
constructor(ctx: WorldSessionContext, config?: AvatarStateConfig<TPublic, TPrivate, TApp>);
|
|
118
|
+
/** The bound avatar id (null before {@link load} resolves a default). */
|
|
119
|
+
get avatarId(): string | null;
|
|
120
|
+
/** The cached decoded public state. */
|
|
121
|
+
get publicState(): TPublic | null;
|
|
122
|
+
/** The cached decoded private state (owner-only). */
|
|
123
|
+
get privateState(): TPrivate | null;
|
|
124
|
+
/** The cached decoded per-app state. */
|
|
125
|
+
get appState(): TApp | null;
|
|
126
|
+
/**
|
|
127
|
+
* Hydrate the cache: resolves the avatar (the caller's first when no
|
|
128
|
+
* `avatarId` was configured), decodes its public/private state, and
|
|
129
|
+
* fetches this app's avatar state.
|
|
130
|
+
*/
|
|
131
|
+
load(): Promise<void>;
|
|
132
|
+
/** Write the public and/or private state (typed) and update the cache. */
|
|
133
|
+
setIdentityState(input: {
|
|
134
|
+
publicState?: TPublic;
|
|
135
|
+
privateState?: TPrivate;
|
|
136
|
+
}): Promise<void>;
|
|
137
|
+
/** Write this app's avatar state (typed) and update the cache. */
|
|
138
|
+
setAppState(value: TApp): Promise<void>;
|
|
139
|
+
private requireAvatar;
|
|
140
|
+
private decodeWith;
|
|
141
|
+
}
|
|
142
|
+
/** Attach an {@link AvatarStateStore}. Prefer the `avatar` config key. */
|
|
143
|
+
export declare function attachAvatarState<TPublic = unknown, TPrivate = unknown, TApp = unknown>(ctx: WorldSessionContext, config?: AvatarStateConfig<TPublic, TPrivate, TApp>): AvatarStateStore<TPublic, TPrivate, TApp>;
|
|
144
|
+
//# sourceMappingURL=durable.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"durable.d.ts","sourceRoot":"","sources":["../../src/stores/durable.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable-state stores: host election tracking ({@link HostTracker}), the
|
|
3
|
+
* per-user app save blob ({@link SaveStateStore}), and typed avatar state
|
|
4
|
+
* ({@link AvatarStateStore}). These wrap the GraphQL surfaces with local
|
|
5
|
+
* caches and typed codecs — independent types from your replication state,
|
|
6
|
+
* as durable and realtime payloads rarely share a layout.
|
|
7
|
+
*/
|
|
8
|
+
import { jsonCodec } from './codec.js';
|
|
9
|
+
/**
|
|
10
|
+
* The SDK-managed **host election tracker**: heartbeats on the session
|
|
11
|
+
* ticker (keeping this client host-eligible), caches the elected host, and
|
|
12
|
+
* fires {@link onHostChanged} on transitions. Election is informational —
|
|
13
|
+
* gate authoritative writes with `is_host` invoke policies server-side.
|
|
14
|
+
* Transient heartbeat failures keep the last known host.
|
|
15
|
+
*/
|
|
16
|
+
export class HostTracker {
|
|
17
|
+
constructor(ctx, config = {}) {
|
|
18
|
+
this.ctx = ctx;
|
|
19
|
+
this.config = config;
|
|
20
|
+
this.hostUserIdValue = null;
|
|
21
|
+
this.listeners = new Set();
|
|
22
|
+
const interval = config.intervalMs ?? 3000;
|
|
23
|
+
ctx.onDispose(ctx.ticker.every(interval, () => void this.beat()));
|
|
24
|
+
if (config.heartbeatImmediately ?? true)
|
|
25
|
+
void this.beat();
|
|
26
|
+
}
|
|
27
|
+
/** The elected host's user id (null until the first successful beat). */
|
|
28
|
+
get hostUserId() {
|
|
29
|
+
return this.hostUserIdValue;
|
|
30
|
+
}
|
|
31
|
+
/** Whether the configured user is the elected host. */
|
|
32
|
+
get isHost() {
|
|
33
|
+
const mine = typeof this.config.myUserId === 'function'
|
|
34
|
+
? this.config.myUserId()
|
|
35
|
+
: this.config.myUserId;
|
|
36
|
+
return mine != null && this.hostUserIdValue != null && String(mine) === this.hostUserIdValue;
|
|
37
|
+
}
|
|
38
|
+
/** Fired when the elected host changes (including the first election). @returns off. */
|
|
39
|
+
onHostChanged(listener) {
|
|
40
|
+
this.listeners.add(listener);
|
|
41
|
+
return () => this.listeners.delete(listener);
|
|
42
|
+
}
|
|
43
|
+
/** Send one heartbeat now and apply the result. */
|
|
44
|
+
async beat() {
|
|
45
|
+
let result;
|
|
46
|
+
try {
|
|
47
|
+
result = await this.ctx.client.host.heartbeat(this.ctx.appId);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return; // Transient failure: keep the last known host.
|
|
51
|
+
}
|
|
52
|
+
const next = result?.hostUserId != null ? String(result.hostUserId) : null;
|
|
53
|
+
if (next !== this.hostUserIdValue) {
|
|
54
|
+
this.hostUserIdValue = next;
|
|
55
|
+
for (const listener of [...this.listeners])
|
|
56
|
+
listener(next);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Attach a {@link HostTracker}. Prefer the `host` config key. */
|
|
61
|
+
export function attachHostTracker(ctx, config = {}) {
|
|
62
|
+
return new HostTracker(ctx, config);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The SDK-managed **save state**: a typed local cache over the per-user
|
|
66
|
+
* per-app `client.state` blob. `load()` hydrates it, `set()` updates it (and
|
|
67
|
+
* autosave persists it when configured), `save()` persists on demand.
|
|
68
|
+
* The type is intentionally independent of your replication state — durable
|
|
69
|
+
* saves and 5 Hz poses are different data.
|
|
70
|
+
*/
|
|
71
|
+
export class SaveStateStore {
|
|
72
|
+
constructor(ctx, config = {}) {
|
|
73
|
+
this.ctx = ctx;
|
|
74
|
+
this.current = null;
|
|
75
|
+
this.dirtyFlag = false;
|
|
76
|
+
this.saving = false;
|
|
77
|
+
this.lastSavedAtValue = null;
|
|
78
|
+
this.codec = config.codec ?? jsonCodec();
|
|
79
|
+
this.now = config.now ?? Date.now;
|
|
80
|
+
const autosave = config.autosaveMs ?? false;
|
|
81
|
+
if (autosave !== false && autosave > 0) {
|
|
82
|
+
ctx.onDispose(ctx.ticker.every(autosave, () => {
|
|
83
|
+
if (this.dirtyFlag && !this.saving)
|
|
84
|
+
void this.save();
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** The cached typed save (null before {@link load}/{@link set}). */
|
|
89
|
+
get value() {
|
|
90
|
+
return this.current;
|
|
91
|
+
}
|
|
92
|
+
/** Whether the cache has unsaved changes. */
|
|
93
|
+
get dirty() {
|
|
94
|
+
return this.dirtyFlag;
|
|
95
|
+
}
|
|
96
|
+
/** Local time of the last successful save. */
|
|
97
|
+
get lastSavedAt() {
|
|
98
|
+
return this.lastSavedAtValue;
|
|
99
|
+
}
|
|
100
|
+
/** Fetch and decode the server copy into the cache (null when none). */
|
|
101
|
+
async load() {
|
|
102
|
+
const record = await this.ctx.client.state.getOne(this.ctx.appId);
|
|
103
|
+
if (record?.state == null) {
|
|
104
|
+
this.current = null;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
this.current = this.codec.decode(record.state);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
this.current = null;
|
|
112
|
+
}
|
|
113
|
+
this.dirtyFlag = false;
|
|
114
|
+
return this.current;
|
|
115
|
+
}
|
|
116
|
+
/** Update the cached save and mark it dirty (autosave persists it). */
|
|
117
|
+
set(value) {
|
|
118
|
+
this.current = value;
|
|
119
|
+
this.dirtyFlag = true;
|
|
120
|
+
}
|
|
121
|
+
/** Merge a partial update into the cached save (object saves only). */
|
|
122
|
+
patch(patch) {
|
|
123
|
+
this.set({ ...this.current, ...patch });
|
|
124
|
+
}
|
|
125
|
+
/** Persist the cached save now. No-op when nothing is cached. */
|
|
126
|
+
async save() {
|
|
127
|
+
if (this.current === null)
|
|
128
|
+
return;
|
|
129
|
+
this.saving = true;
|
|
130
|
+
try {
|
|
131
|
+
await this.ctx.client.state.update({
|
|
132
|
+
appId: this.ctx.appId,
|
|
133
|
+
state: this.codec.encode(this.current),
|
|
134
|
+
});
|
|
135
|
+
this.dirtyFlag = false;
|
|
136
|
+
this.lastSavedAtValue = this.now();
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
this.saving = false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Attach a {@link SaveStateStore}. Prefer the `save` config key. */
|
|
144
|
+
export function attachSaveState(ctx, config = {}) {
|
|
145
|
+
return new SaveStateStore(ctx, config);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The SDK-managed **avatar state**: typed, cached views of one avatar's
|
|
149
|
+
* public / private / per-app state blobs, each with its own codec (three
|
|
150
|
+
* independent types — public profiles, private inventory-ish data, and
|
|
151
|
+
* app-specific progress rarely share a shape).
|
|
152
|
+
*/
|
|
153
|
+
export class AvatarStateStore {
|
|
154
|
+
constructor(ctx, config = {}) {
|
|
155
|
+
this.ctx = ctx;
|
|
156
|
+
this.publicValue = null;
|
|
157
|
+
this.privateValue = null;
|
|
158
|
+
this.appValue = null;
|
|
159
|
+
this.avatarIdValue = config.avatarId ?? null;
|
|
160
|
+
this.publicCodec = config.publicCodec ?? jsonCodec();
|
|
161
|
+
this.privateCodec = config.privateCodec ?? jsonCodec();
|
|
162
|
+
this.appCodec = config.appCodec ?? jsonCodec();
|
|
163
|
+
}
|
|
164
|
+
/** The bound avatar id (null before {@link load} resolves a default). */
|
|
165
|
+
get avatarId() {
|
|
166
|
+
return this.avatarIdValue;
|
|
167
|
+
}
|
|
168
|
+
/** The cached decoded public state. */
|
|
169
|
+
get publicState() {
|
|
170
|
+
return this.publicValue;
|
|
171
|
+
}
|
|
172
|
+
/** The cached decoded private state (owner-only). */
|
|
173
|
+
get privateState() {
|
|
174
|
+
return this.privateValue;
|
|
175
|
+
}
|
|
176
|
+
/** The cached decoded per-app state. */
|
|
177
|
+
get appState() {
|
|
178
|
+
return this.appValue;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Hydrate the cache: resolves the avatar (the caller's first when no
|
|
182
|
+
* `avatarId` was configured), decodes its public/private state, and
|
|
183
|
+
* fetches this app's avatar state.
|
|
184
|
+
*/
|
|
185
|
+
async load() {
|
|
186
|
+
if (!this.avatarIdValue) {
|
|
187
|
+
const mine = await this.ctx.client.avatars.mine();
|
|
188
|
+
const first = mine[0];
|
|
189
|
+
if (!first) {
|
|
190
|
+
throw new Error('No avatar to bind — create one with client.avatars.create()');
|
|
191
|
+
}
|
|
192
|
+
this.avatarIdValue = String(first.avatarId);
|
|
193
|
+
}
|
|
194
|
+
const avatar = await this.ctx.client.avatars.get(this.avatarIdValue);
|
|
195
|
+
this.publicValue = this.decodeWith(this.publicCodec, avatar?.publicState);
|
|
196
|
+
this.privateValue = this.decodeWith(this.privateCodec, avatar?.privateState);
|
|
197
|
+
const appRecord = await this.ctx.client.avatars.appState(this.ctx.appId, this.avatarIdValue);
|
|
198
|
+
this.appValue = this.decodeWith(this.appCodec, appRecord?.state);
|
|
199
|
+
}
|
|
200
|
+
/** Write the public and/or private state (typed) and update the cache. */
|
|
201
|
+
async setIdentityState(input) {
|
|
202
|
+
const avatarId = this.requireAvatar();
|
|
203
|
+
await this.ctx.client.avatars.updateState(avatarId, {
|
|
204
|
+
...(input.publicState !== undefined
|
|
205
|
+
? { publicState: this.publicCodec.encode(input.publicState) }
|
|
206
|
+
: {}),
|
|
207
|
+
...(input.privateState !== undefined
|
|
208
|
+
? { privateState: this.privateCodec.encode(input.privateState) }
|
|
209
|
+
: {}),
|
|
210
|
+
});
|
|
211
|
+
if (input.publicState !== undefined)
|
|
212
|
+
this.publicValue = input.publicState;
|
|
213
|
+
if (input.privateState !== undefined)
|
|
214
|
+
this.privateValue = input.privateState;
|
|
215
|
+
}
|
|
216
|
+
/** Write this app's avatar state (typed) and update the cache. */
|
|
217
|
+
async setAppState(value) {
|
|
218
|
+
const avatarId = this.requireAvatar();
|
|
219
|
+
await this.ctx.client.avatars.updateAppState({
|
|
220
|
+
appId: this.ctx.appId,
|
|
221
|
+
avatarId,
|
|
222
|
+
state: this.appCodec.encode(value),
|
|
223
|
+
});
|
|
224
|
+
this.appValue = value;
|
|
225
|
+
}
|
|
226
|
+
requireAvatar() {
|
|
227
|
+
if (!this.avatarIdValue) {
|
|
228
|
+
throw new Error('AvatarStateStore is unbound — call load() first or configure avatarId');
|
|
229
|
+
}
|
|
230
|
+
return this.avatarIdValue;
|
|
231
|
+
}
|
|
232
|
+
decodeWith(codec, encoded) {
|
|
233
|
+
if (encoded == null || encoded === '')
|
|
234
|
+
return null;
|
|
235
|
+
try {
|
|
236
|
+
return codec.decode(encoded);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Attach an {@link AvatarStateStore}. Prefer the `avatar` config key. */
|
|
244
|
+
export function attachAvatarState(ctx, config = {}) {
|
|
245
|
+
return new AvatarStateStore(ctx, config);
|
|
246
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error attribution — the bookkeeping games skip and then regret.
|
|
3
|
+
*
|
|
4
|
+
* The server reports UDP-side failures asynchronously as
|
|
5
|
+
* `GenericErrorResponse { sequenceNumber, errorCode }`. Without a record of
|
|
6
|
+
* what each sequence number was, apps can only log them. The session tracks
|
|
7
|
+
* every outbound send made through the stores (kind, actor uuid, detail) in
|
|
8
|
+
* a 256-slot table (sequence numbers are uint8), so each error is
|
|
9
|
+
* **attributed** to the send that caused it and kept in a queryable ring
|
|
10
|
+
* buffer.
|
|
11
|
+
*/
|
|
12
|
+
import type { SentPacketRecord, WorldSessionContext } from './session.js';
|
|
13
|
+
/** A server-reported send error, attributed to the send that caused it. */
|
|
14
|
+
export interface AttributedError {
|
|
15
|
+
/** The server's `UdpErrorCode` (e.g. `'UNAUTHORIZED'`). */
|
|
16
|
+
errorCode: string;
|
|
17
|
+
sequenceNumber: number;
|
|
18
|
+
receivedAt: number;
|
|
19
|
+
/**
|
|
20
|
+
* The tracked outbound send with this sequence number, when the session
|
|
21
|
+
* saw one (undefined for sends made outside the stores).
|
|
22
|
+
*/
|
|
23
|
+
send?: SentPacketRecord;
|
|
24
|
+
}
|
|
25
|
+
/** Options for {@link attachErrorStore}. */
|
|
26
|
+
export interface ErrorStoreConfig {
|
|
27
|
+
/** Errors kept in the ring buffer. Defaults to 50. */
|
|
28
|
+
capacity?: number;
|
|
29
|
+
/** Clock override for tests. Defaults to `Date.now`. */
|
|
30
|
+
now?: () => number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The SDK-managed **send-error log**: every `GenericErrorResponse` is
|
|
34
|
+
* attributed to the tracked send with the same sequence number and recorded
|
|
35
|
+
* (newest first). Query {@link recent}, subscribe with {@link onError}, or
|
|
36
|
+
* look up the latest error for one actor with {@link lastFor}.
|
|
37
|
+
*
|
|
38
|
+
* Sequence numbers are uint8 correlation ids that wrap at 256, so
|
|
39
|
+
* attribution is best-effort by design: a very old error after 256 newer
|
|
40
|
+
* sends would attribute to the newer send with the reused number.
|
|
41
|
+
*/
|
|
42
|
+
export declare class ErrorStore {
|
|
43
|
+
private readonly ring;
|
|
44
|
+
private readonly sends;
|
|
45
|
+
private readonly byActor;
|
|
46
|
+
private readonly listeners;
|
|
47
|
+
private readonly capacity;
|
|
48
|
+
private readonly now;
|
|
49
|
+
private totalCount;
|
|
50
|
+
constructor(ctx: WorldSessionContext, config?: ErrorStoreConfig);
|
|
51
|
+
/** The most recent errors, newest first (up to `n`, default all kept). */
|
|
52
|
+
recent(n?: number): AttributedError[];
|
|
53
|
+
/** The single most recent error, if any. */
|
|
54
|
+
get last(): AttributedError | undefined;
|
|
55
|
+
/** Total errors seen (including ones evicted from the ring). */
|
|
56
|
+
get total(): number;
|
|
57
|
+
/** The latest error attributed to sends by one actor uuid. */
|
|
58
|
+
lastFor(uuid: string): AttributedError | undefined;
|
|
59
|
+
/** Subscribe to every attributed error. @returns off. */
|
|
60
|
+
onError(listener: (error: AttributedError) => void): () => void;
|
|
61
|
+
/** Drop the recorded errors (send tracking continues). */
|
|
62
|
+
clear(): void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Attach an {@link ErrorStore} to a world session context. Prefer the
|
|
66
|
+
* `errors` key of `createWorldSession`'s config.
|
|
67
|
+
*/
|
|
68
|
+
export declare function attachErrorStore(ctx: WorldSessionContext, config?: ErrorStoreConfig): ErrorStore;
|
|
69
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/stores/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAE1E,2EAA2E;AAC3E,MAAM,WAAW,eAAe;IAC9B,2DAA2D;IAC3D,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,IAAI,CAAC,EAAE,gBAAgB,CAAC;CACzB;AAED,4CAA4C;AAC5C,MAAM,WAAW,gBAAgB;IAC/B,sDAAsD;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAyB;IAC9C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAuC;IAC7D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsC;IAC9D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA+C;IACzE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,UAAU,CAAK;gBAEX,GAAG,EAAE,mBAAmB,EAAE,MAAM,GAAE,gBAAqB;IA4BnE,0EAA0E;IAC1E,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,GAAG,eAAe,EAAE;IAIrC,4CAA4C;IAC5C,IAAI,IAAI,IAAI,eAAe,GAAG,SAAS,CAEtC;IAED,gEAAgE;IAChE,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,8DAA8D;IAC9D,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS;IAIlD,yDAAyD;IACzD,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,GAAG,MAAM,IAAI;IAK/D,0DAA0D;IAC1D,KAAK,IAAI,IAAI;CAId;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,mBAAmB,EACxB,MAAM,GAAE,gBAAqB,GAC5B,UAAU,CAEZ"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error attribution — the bookkeeping games skip and then regret.
|
|
3
|
+
*
|
|
4
|
+
* The server reports UDP-side failures asynchronously as
|
|
5
|
+
* `GenericErrorResponse { sequenceNumber, errorCode }`. Without a record of
|
|
6
|
+
* what each sequence number was, apps can only log them. The session tracks
|
|
7
|
+
* every outbound send made through the stores (kind, actor uuid, detail) in
|
|
8
|
+
* a 256-slot table (sequence numbers are uint8), so each error is
|
|
9
|
+
* **attributed** to the send that caused it and kept in a queryable ring
|
|
10
|
+
* buffer.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The SDK-managed **send-error log**: every `GenericErrorResponse` is
|
|
14
|
+
* attributed to the tracked send with the same sequence number and recorded
|
|
15
|
+
* (newest first). Query {@link recent}, subscribe with {@link onError}, or
|
|
16
|
+
* look up the latest error for one actor with {@link lastFor}.
|
|
17
|
+
*
|
|
18
|
+
* Sequence numbers are uint8 correlation ids that wrap at 256, so
|
|
19
|
+
* attribution is best-effort by design: a very old error after 256 newer
|
|
20
|
+
* sends would attribute to the newer send with the reused number.
|
|
21
|
+
*/
|
|
22
|
+
export class ErrorStore {
|
|
23
|
+
constructor(ctx, config = {}) {
|
|
24
|
+
this.ring = [];
|
|
25
|
+
this.sends = new Map(); // seq → last send
|
|
26
|
+
this.byActor = new Map();
|
|
27
|
+
this.listeners = new Set();
|
|
28
|
+
this.totalCount = 0;
|
|
29
|
+
this.capacity = Math.max(1, config.capacity ?? 50);
|
|
30
|
+
this.now = config.now ?? Date.now;
|
|
31
|
+
// Become the session's send-tracking sink: stores that send call
|
|
32
|
+
// ctx.trackSend(...) and we remember the last send per sequence number.
|
|
33
|
+
ctx.setSendTracker((record) => {
|
|
34
|
+
this.sends.set(record.sequenceNumber, record);
|
|
35
|
+
});
|
|
36
|
+
ctx.onDispose(ctx.on('genericError', (notification) => {
|
|
37
|
+
const send = this.sends.get(notification.sequenceNumber);
|
|
38
|
+
const error = {
|
|
39
|
+
errorCode: String(notification.errorCode),
|
|
40
|
+
sequenceNumber: notification.sequenceNumber,
|
|
41
|
+
receivedAt: this.now(),
|
|
42
|
+
...(send ? { send } : {}),
|
|
43
|
+
};
|
|
44
|
+
this.ring.unshift(error);
|
|
45
|
+
if (this.ring.length > this.capacity)
|
|
46
|
+
this.ring.length = this.capacity;
|
|
47
|
+
this.totalCount += 1;
|
|
48
|
+
if (send?.uuid)
|
|
49
|
+
this.byActor.set(send.uuid, error);
|
|
50
|
+
for (const listener of [...this.listeners])
|
|
51
|
+
listener(error);
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
/** The most recent errors, newest first (up to `n`, default all kept). */
|
|
55
|
+
recent(n) {
|
|
56
|
+
return n === undefined ? [...this.ring] : this.ring.slice(0, n);
|
|
57
|
+
}
|
|
58
|
+
/** The single most recent error, if any. */
|
|
59
|
+
get last() {
|
|
60
|
+
return this.ring[0];
|
|
61
|
+
}
|
|
62
|
+
/** Total errors seen (including ones evicted from the ring). */
|
|
63
|
+
get total() {
|
|
64
|
+
return this.totalCount;
|
|
65
|
+
}
|
|
66
|
+
/** The latest error attributed to sends by one actor uuid. */
|
|
67
|
+
lastFor(uuid) {
|
|
68
|
+
return this.byActor.get(uuid);
|
|
69
|
+
}
|
|
70
|
+
/** Subscribe to every attributed error. @returns off. */
|
|
71
|
+
onError(listener) {
|
|
72
|
+
this.listeners.add(listener);
|
|
73
|
+
return () => this.listeners.delete(listener);
|
|
74
|
+
}
|
|
75
|
+
/** Drop the recorded errors (send tracking continues). */
|
|
76
|
+
clear() {
|
|
77
|
+
this.ring.length = 0;
|
|
78
|
+
this.byActor.clear();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Attach an {@link ErrorStore} to a world session context. Prefer the
|
|
83
|
+
* `errors` key of `createWorldSession`'s config.
|
|
84
|
+
*/
|
|
85
|
+
export function attachErrorStore(ctx, config = {}) {
|
|
86
|
+
return new ErrorStore(ctx, config);
|
|
87
|
+
}
|