chore: remove effects leftovers, dead SyncedState.set, add modules/resources skill refs
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Removed `client.effects`, the `Effects` / `EffectUnsubscribe` /
|
||||||
|
`NotificationEffect` / `WaitEffect` / `ScenarioCompletedEffect` /
|
||||||
|
`ScenarioFailedEffect` types, and the `onEffectError` client option. Nothing
|
||||||
|
emitted effects since scenarios were removed.
|
||||||
- `client.modules.call` resolves to `CallModuleFunctionClientResponse`, which
|
- `client.modules.call` resolves to `CallModuleFunctionClientResponse`, which
|
||||||
carries only `result`. Console output, the kernel call log and `error` are
|
carries only `result`. Console output, the kernel call log and `error` are
|
||||||
no longer part of the player response; they stay on the platform dry run.
|
no longer part of the player response; they stay on the platform dry run.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const profile = client.player.data;
|
|||||||
const gold = profile?.wallets?.find((w) => w.currency === 'gold')?.balance;
|
const gold = profile?.wallets?.find((w) => w.currency === 'gold')?.balance;
|
||||||
|
|
||||||
const items = await client.resources('items').list();
|
const items = await client.resources('items').list();
|
||||||
const result = await client.modules.call('quests', 'list');
|
const { result } = await client.modules.call('my_module', 'hello', { name: 'Bob' });
|
||||||
|
|
||||||
client.dispose();
|
client.dispose();
|
||||||
```
|
```
|
||||||
@@ -47,7 +47,7 @@ client.dispose();
|
|||||||
|
|
||||||
| Area | Access |
|
| Area | Access |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Auth | `client.auth.loginWithDevice()`, `client.auth.loginWithCustom({ customData })`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` |
|
| Auth | `client.auth.loginWithDevice()`, `loginWithGoogle({ idToken })`, `loginWithApple({ idToken })`, `loginWithCustom({ customData })`, `linkWithGoogle(idToken)`, `linkWithApple(idToken)`, `linkWithCustom({ customData })`, `unlinkIdentity(provider)`, `logout()`, `isAuthenticated`, `onAuthStateChange(cb)` |
|
||||||
| Player / wallets | `client.player` (observable) |
|
| Player / wallets | `client.player` (observable) |
|
||||||
| Catalog currencies | `client.catalog` (observable) |
|
| Catalog currencies | `client.catalog` (observable) |
|
||||||
| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` |
|
| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` |
|
||||||
@@ -55,9 +55,10 @@ client.dispose();
|
|||||||
| Modules | `client.modules.call(module, fn, args?)` |
|
| Modules | `client.modules.call(module, fn, args?)` |
|
||||||
| Resources | `client.resources(slug)` → `list` / `get` / `create` / `update` / `delete` |
|
| Resources | `client.resources(slug)` → `list` / `get` / `create` / `update` / `delete` |
|
||||||
|
|
||||||
Game-design features (quests, stores, inventory, leaderboards, battle pass,
|
Game features are installed modules, not SDK services. The first-party
|
||||||
remote config) live in installed modules. Call them through
|
modules are items, inventory, reward, remote_config, leaderboards, quests,
|
||||||
`client.modules.call` and read their rows through `client.resources(slug)`.
|
store and battlepass. Call a module through `client.modules.call` and read its
|
||||||
|
rows through `client.resources(slug)`.
|
||||||
|
|
||||||
## Error handling
|
## Error handling
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ client.auth.onAuthStateChange((state) => {
|
|||||||
|
|
||||||
Observable domains (`player`, `catalog`, `storage`, `projectStorage`) are
|
Observable domains (`player`, `catalog`, `storage`, `projectStorage`) are
|
||||||
warmed at login (`player` and `catalog`) or on first use, then kept fresh by
|
warmed at login (`player` and `catalog`) or on first use, then kept fresh by
|
||||||
a revision poll: the client asks the gateway for entity revisions every
|
a revision poll: the client asks the backend for entity revisions every
|
||||||
**30 s (±20% jitter)** and reloads only the entities whose revision grew.
|
**30 s (±20% jitter)** and reloads only the entities whose revision grew.
|
||||||
Polling **pauses while the tab is hidden** and resumes on `visibilitychange`.
|
Polling **pauses while the tab is hidden** and resumes on `visibilitychange`.
|
||||||
Storage writes invalidate the affected domain immediately. Expect data to be
|
Storage writes invalidate the affected domain immediately. Expect data to be
|
||||||
@@ -142,8 +143,8 @@ function Wallet() {
|
|||||||
npm run typecheck # tsc over src and tests
|
npm run typecheck # tsc over src and tests
|
||||||
npm test # unit + local e2e (jsdom)
|
npm test # unit + local e2e (jsdom)
|
||||||
npm run build # tsup → dist (ESM + CJS + d.ts)
|
npm run build # tsup → dist (ESM + CJS + d.ts)
|
||||||
npm run test:e2e-prod # drives a seeded project on the live gateway
|
npm run test:e2e-prod # drives a seeded project on the live backend
|
||||||
```
|
```
|
||||||
|
|
||||||
Generated wire types (`src/generated`) come from the gateway's `apigen`; run
|
Generated wire types (`src/generated`) come from `liveops-backend/apigen`; run
|
||||||
`npm run generate` to refresh them. Do not edit them by hand.
|
`npm run generate` to refresh them. Do not edit them by hand.
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@
|
|||||||
"e2e:prod": "vitest run --config vitest.e2e-prod.config.ts",
|
"e2e:prod": "vitest run --config vitest.e2e-prod.config.ts",
|
||||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
||||||
"check:package": "npm run build && npm pack --dry-run --json",
|
"check:package": "npm run build && npm pack --dry-run --json",
|
||||||
"generate": "make -C ../liveops-gateway generate-openapi"
|
"generate": "make -C ../liveops-backend generate-openapi"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ description: Use when working with the Rudder TypeScript/JavaScript SDK (@rudder
|
|||||||
# Rudder Web SDK (@rudder/js-sdk)
|
# Rudder Web SDK (@rudder/js-sdk)
|
||||||
|
|
||||||
Browser-facing player SDK for the Rudder LiveOps platform. Source of truth:
|
Browser-facing player SDK for the Rudder LiveOps platform. Source of truth:
|
||||||
`liveops-web-sdk/src` (generated wire types in `src/generated` come from the
|
`liveops-web-sdk/src` (generated wire types in `src/generated` come from
|
||||||
gateway's apigen — never edit them by hand).
|
`liveops-backend/apigen` — never edit them by hand).
|
||||||
|
|
||||||
2.0 kernel surface: auth, player (wallets), catalog currencies, storage,
|
2.0 kernel surface: auth, player (wallets), catalog currencies, storage,
|
||||||
project storage, revision sync, `client.modules.call(module, fn, args)`,
|
project storage, revision sync, `client.modules.call(module, fn, args)`,
|
||||||
@@ -40,7 +40,6 @@ const client = new RudderClient({
|
|||||||
// tokenStore?: TokenStore — default: localStorage, in-memory fallback
|
// tokenStore?: TokenStore — default: localStorage, in-memory fallback
|
||||||
// requestTimeoutMs?: number — default 10000
|
// requestTimeoutMs?: number — default 10000
|
||||||
// syncIntervalMs?: number — revision poll, default 30000 (±20% jitter)
|
// syncIntervalMs?: number — revision poll, default 30000 (±20% jitter)
|
||||||
// onEffectError?: (error) => void — default console.error
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -86,11 +85,12 @@ Tokens are saved to the `TokenStore` automatically. The transport injects
|
|||||||
| Modules | `client.modules.call(module, fn, args?)` | `CallModuleFunctionClientResponse` (`{ result }`) |
|
| Modules | `client.modules.call(module, fn, args?)` | `CallModuleFunctionClientResponse` (`{ result }`) |
|
||||||
| Resources | `client.resources(slug)` | `list` / `get` / `create` / `update` / `delete` |
|
| Resources | `client.resources(slug)` | `list` / `get` / `create` / `update` / `delete` |
|
||||||
|
|
||||||
There is no UGC, wallet service, or standalone economy service — wallets live
|
Wallets live on `client.player.data.wallets`. Game features are installed
|
||||||
on `client.player.data.wallets`. Quests, stores, inventory, leaderboards,
|
modules, not SDK services (first-party modules: items, inventory, reward,
|
||||||
battle pass, and remote config are installed modules: call
|
remote_config, leaderboards, quests, store, battlepass). Call them with
|
||||||
`client.modules.call('quests', 'list')` (etc.) and read rows with
|
`client.modules.call('my_module', 'hello', { ... })` and read their rows with
|
||||||
`client.resources('items')`.
|
`client.resources(slug)`. Details: [reference/modules.md](reference/modules.md),
|
||||||
|
[reference/resources.md](reference/resources.md).
|
||||||
|
|
||||||
## Observable domains — the shared pattern
|
## Observable domains — the shared pattern
|
||||||
|
|
||||||
@@ -137,3 +137,5 @@ documents. A module trap is a 500 and a timeout a 504, both without details.
|
|||||||
- [reference/auth.md](reference/auth.md) — login flows, options, token store, session lifecycle
|
- [reference/auth.md](reference/auth.md) — login flows, options, token store, session lifecycle
|
||||||
- [reference/player.md](reference/player.md) — profile + wallets (`PlayerProfile`, `Wallet`)
|
- [reference/player.md](reference/player.md) — profile + wallets (`PlayerProfile`, `Wallet`)
|
||||||
- [reference/storage.md](reference/storage.md) — player + project storage, limits
|
- [reference/storage.md](reference/storage.md) — player + project storage, limits
|
||||||
|
- [reference/modules.md](reference/modules.md) — `modules.call`, response, module errors
|
||||||
|
- [reference/resources.md](reference/resources.md) — `resources(slug)` row operations
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Modules
|
||||||
|
|
||||||
|
Source: `src/domains/ModulesDomain.ts`. Calls an installed module function as
|
||||||
|
the signed-in player through `POST /sdk/v1/modules/{module}/{fn}`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
client.modules.call(module: string, fn: string, args?: unknown): Promise<CallModuleFunctionClientResponse>
|
||||||
|
// CallModuleFunctionClientResponse = { result?: unknown }
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { result } = await client.modules.call('my_module', 'hello', { name: 'Bob' });
|
||||||
|
```
|
||||||
|
|
||||||
|
`args` is sent as `{ args }`. The response carries only `result`; console
|
||||||
|
output and the kernel call log stay on the platform dry run.
|
||||||
|
|
||||||
|
A business error raised by the module rejects with `RudderHttpError`
|
||||||
|
(`status` 422, body `{ error, code, requestId }`) whose `code` is the
|
||||||
|
module's own open string code; there are no generated constants for it. A
|
||||||
|
trap is a 500 and a timeout a 504, both without details.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
try {
|
||||||
|
await client.modules.call('my_module', 'hello');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RudderHttpError && error.status === 422) {
|
||||||
|
console.warn(error.code); // module-defined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Resources
|
||||||
|
|
||||||
|
Source: `src/domains/ResourcesDomain.ts`. Row operations on a resource as the
|
||||||
|
signed-in player through `/sdk/v1/resources/{slug}`. `client.resources(slug)`
|
||||||
|
returns a `ResourceHandle`; it performs no request by itself.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const items = client.resources('items');
|
||||||
|
items.list(): Promise<ListResourceRowsResponse> // GET /{slug} → { items?: ResourceRow[], total?: number }
|
||||||
|
items.get(id): Promise<ResourceRow> // GET /{slug}/{id}
|
||||||
|
items.create({ id?, data? }): Promise<ResourceRow> // POST /{slug}
|
||||||
|
items.update(id, { data?, version? }): Promise<ResourceRow> // PATCH /{slug}/{id}
|
||||||
|
items.delete(id): Promise<void> // DELETE /{slug}/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ResourceRow` is `{ id, playerId, data, version, rank, createdAt, updatedAt }`
|
||||||
|
(all optional in the generated type). Pass `version` on `update` for
|
||||||
|
optimistic concurrency.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const row = await client.resources('items').create({ data: { name: 'Sword' } });
|
||||||
|
await client.resources('items').update(row.id!, { data: { name: 'Axe' }, version: row.version });
|
||||||
|
```
|
||||||
|
|
||||||
|
What a player may read or write is decided by the resource's access rules on
|
||||||
|
the server; a denied operation rejects with `RudderHttpError` `code`
|
||||||
|
`forbidden`. Deleting a row that other rows still reference rejects with
|
||||||
|
status 409 and `code` `row_referenced`. Resources are not observable domains:
|
||||||
|
call `list()` again when you need fresh rows.
|
||||||
@@ -18,7 +18,6 @@ import type { RudderContext } from '../core/context.js';
|
|||||||
import { request, type RequestContext } from '../transport/request.js';
|
import { request, type RequestContext } from '../transport/request.js';
|
||||||
import { createDefaultTokenStore } from '../token/TokenStore.js';
|
import { createDefaultTokenStore } from '../token/TokenStore.js';
|
||||||
import { AuthService } from '../auth/AuthService.js';
|
import { AuthService } from '../auth/AuthService.js';
|
||||||
import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
|
|
||||||
import { SyncEngine } from '../state/SyncEngine.js';
|
import { SyncEngine } from '../state/SyncEngine.js';
|
||||||
import { PlayerDomain } from '../domains/PlayerDomain.js';
|
import { PlayerDomain } from '../domains/PlayerDomain.js';
|
||||||
import { CatalogDomain } from '../domains/CatalogDomain.js';
|
import { CatalogDomain } from '../domains/CatalogDomain.js';
|
||||||
@@ -33,7 +32,6 @@ export class RudderClient {
|
|||||||
public readonly options: ResolvedRudderClientOptions;
|
public readonly options: ResolvedRudderClientOptions;
|
||||||
|
|
||||||
public readonly auth: AuthService;
|
public readonly auth: AuthService;
|
||||||
public readonly effects: Effects;
|
|
||||||
|
|
||||||
public readonly player: PlayerDomain;
|
public readonly player: PlayerDomain;
|
||||||
public readonly catalog: CatalogDomain;
|
public readonly catalog: CatalogDomain;
|
||||||
@@ -42,7 +40,6 @@ export class RudderClient {
|
|||||||
public readonly modules: ModulesDomain;
|
public readonly modules: ModulesDomain;
|
||||||
private readonly resourcesDomain: ResourcesDomain;
|
private readonly resourcesDomain: ResourcesDomain;
|
||||||
|
|
||||||
private readonly effectsCenter: EffectsCenter;
|
|
||||||
private readonly syncEngine: SyncEngine;
|
private readonly syncEngine: SyncEngine;
|
||||||
private readonly ctx: RudderContext;
|
private readonly ctx: RudderContext;
|
||||||
private readonly transportContext: RequestContext;
|
private readonly transportContext: RequestContext;
|
||||||
@@ -74,12 +71,8 @@ export class RudderClient {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
this.effectsCenter = new EffectsCenter(this.options.onEffectError);
|
|
||||||
this.effects = this.effectsCenter;
|
|
||||||
|
|
||||||
this.ctx = {
|
this.ctx = {
|
||||||
options: this.options,
|
options: this.options,
|
||||||
effects: this.effectsCenter,
|
|
||||||
request: <TResponse>(method: string, path: string, body?: unknown) =>
|
request: <TResponse>(method: string, path: string, body?: unknown) =>
|
||||||
request<TResponse>(method, path, body, this.transportContext),
|
request<TResponse>(method, path, body, this.transportContext),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,12 +25,6 @@ export interface RudderClientOptions {
|
|||||||
|
|
||||||
/** Revision sync poll interval in milliseconds (default: 30000, ±20% jitter). */
|
/** Revision sync poll interval in milliseconds (default: 30000, ±20% jitter). */
|
||||||
syncIntervalMs?: number;
|
syncIntervalMs?: number;
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an effect handler (or effect preparation) throws.
|
|
||||||
* Defaults to `console.error`.
|
|
||||||
*/
|
|
||||||
onEffectError?: (error: unknown) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Client options with all defaults resolved — what `client.options` exposes. */
|
/** Client options with all defaults resolved — what `client.options` exposes. */
|
||||||
|
|||||||
+1
-3
@@ -1,9 +1,8 @@
|
|||||||
import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.js';
|
import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.js';
|
||||||
import type { EffectsCenter } from '../effects/EffectsCenter.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RudderContext — the low-level surface every feature service depends on:
|
* RudderContext — the low-level surface every feature service depends on:
|
||||||
* the HTTP transport, client options, and the effects bus.
|
* the HTTP transport and client options.
|
||||||
*
|
*
|
||||||
* Built once by {@link RudderClient} and injected into each service. Services
|
* Built once by {@link RudderClient} and injected into each service. Services
|
||||||
* depend on this narrow interface instead of on the client itself, so the
|
* depend on this narrow interface instead of on the client itself, so the
|
||||||
@@ -11,6 +10,5 @@ import type { EffectsCenter } from '../effects/EffectsCenter.js';
|
|||||||
*/
|
*/
|
||||||
export interface RudderContext {
|
export interface RudderContext {
|
||||||
readonly options: ResolvedRudderClientOptions;
|
readonly options: ResolvedRudderClientOptions;
|
||||||
readonly effects: EffectsCenter;
|
|
||||||
request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>;
|
request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
export type EffectUnsubscribe = () => void;
|
|
||||||
|
|
||||||
export interface NotificationEffect {
|
|
||||||
readonly title: string;
|
|
||||||
readonly message: string;
|
|
||||||
done(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WaitEffect {
|
|
||||||
readonly deadlineUtc: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScenarioCompletedEffect {
|
|
||||||
readonly runId: string;
|
|
||||||
readonly scenarioSlug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScenarioFailedEffect {
|
|
||||||
readonly runId: string;
|
|
||||||
readonly scenarioSlug: string;
|
|
||||||
readonly nodeId: string;
|
|
||||||
readonly error: Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EffectHandler<TEffect> = (effect: TEffect) => void | Promise<void>;
|
|
||||||
|
|
||||||
export interface Effects {
|
|
||||||
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe;
|
|
||||||
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe;
|
|
||||||
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe;
|
|
||||||
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): EffectUnsubscribe;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EffectsCenter implements Effects {
|
|
||||||
private readonly notificationHandlers = new Set<EffectHandler<NotificationEffect>>();
|
|
||||||
private readonly waitHandlers = new Set<EffectHandler<WaitEffect>>();
|
|
||||||
private readonly scenarioCompletedHandlers = new Set<EffectHandler<ScenarioCompletedEffect>>();
|
|
||||||
private readonly scenarioFailedHandlers = new Set<EffectHandler<ScenarioFailedEffect>>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly onError: (error: unknown) => void = (error) =>
|
|
||||||
console.error('[Rudder] Effect handler failed', error),
|
|
||||||
) {}
|
|
||||||
|
|
||||||
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe {
|
|
||||||
return this.addHandler(this.notificationHandlers, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe {
|
|
||||||
return this.addHandler(this.waitHandlers, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe {
|
|
||||||
return this.addHandler(this.scenarioCompletedHandlers, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): EffectUnsubscribe {
|
|
||||||
return this.addHandler(this.scenarioFailedHandlers, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
emitNotification(effect: NotificationEffect): void {
|
|
||||||
this.emit(this.notificationHandlers, effect);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
emitWait(effect: WaitEffect): void {
|
|
||||||
this.emit(this.waitHandlers, effect);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
emitScenarioCompleted(effect: ScenarioCompletedEffect): void {
|
|
||||||
this.emit(this.scenarioCompletedHandlers, effect);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
emitScenarioFailed(effect: ScenarioFailedEffect): void {
|
|
||||||
this.emit(this.scenarioFailedHandlers, effect);
|
|
||||||
}
|
|
||||||
|
|
||||||
private addHandler<TEffect>(
|
|
||||||
handlers: Set<EffectHandler<TEffect>>,
|
|
||||||
handler: EffectHandler<TEffect>,
|
|
||||||
): EffectUnsubscribe {
|
|
||||||
handlers.add(handler);
|
|
||||||
return () => {
|
|
||||||
handlers.delete(handler);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private emit<TEffect>(
|
|
||||||
handlers: Set<EffectHandler<TEffect>>,
|
|
||||||
effect: TEffect,
|
|
||||||
): void {
|
|
||||||
for (const handler of handlers) {
|
|
||||||
try {
|
|
||||||
Promise.resolve(handler(effect)).catch((error) => this.onError(error));
|
|
||||||
} catch (error) {
|
|
||||||
this.onError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -45,16 +45,6 @@ export type { ResourcesDomain, ResourceHandle } from './domains/ResourcesDomain.
|
|||||||
export type { StorageDomain } from './domains/StorageDomain.js';
|
export type { StorageDomain } from './domains/StorageDomain.js';
|
||||||
export type { ProjectStorageDomain } from './domains/ProjectStorageDomain.js';
|
export type { ProjectStorageDomain } from './domains/ProjectStorageDomain.js';
|
||||||
|
|
||||||
// Effects
|
|
||||||
export type {
|
|
||||||
Effects,
|
|
||||||
EffectUnsubscribe,
|
|
||||||
NotificationEffect,
|
|
||||||
WaitEffect,
|
|
||||||
ScenarioCompletedEffect,
|
|
||||||
ScenarioFailedEffect,
|
|
||||||
} from './effects/EffectsCenter.js';
|
|
||||||
|
|
||||||
// Generated wire types — the shapes returned by the observable state and
|
// Generated wire types — the shapes returned by the observable state and
|
||||||
// handles, so consumers can name them instead of re-declaring mirrors.
|
// handles, so consumers can name them instead of re-declaring mirrors.
|
||||||
export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js';
|
export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js';
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ export class SyncedState<T> {
|
|||||||
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
|
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
|
||||||
this.invalidateScheduled = true;
|
this.invalidateScheduled = true;
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
// A reset() or set() between scheduling and flushing cancels the reload
|
// A reset() between scheduling and flushing cancels the reload
|
||||||
// (fresh data arrived, or the entity was dropped on logout).
|
// (the entity was dropped on logout).
|
||||||
if (!this.invalidateScheduled) return;
|
if (!this.invalidateScheduled) return;
|
||||||
this.invalidateScheduled = false;
|
this.invalidateScheduled = false;
|
||||||
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
|
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
|
||||||
@@ -85,18 +85,6 @@ export class SyncedState<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies externally-provided data (e.g. scenario config patches).
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
set(data: T): void {
|
|
||||||
this.generation++;
|
|
||||||
this.pending = undefined;
|
|
||||||
this.invalidateScheduled = false;
|
|
||||||
this.setSnapshot({ status: 'ready', data });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drops all state back to idle (logout).
|
* Drops all state back to idle (logout).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ describe('public API surface', () => {
|
|||||||
expect(client.modules).toBeDefined();
|
expect(client.modules).toBeDefined();
|
||||||
expect(typeof client.modules.call).toBe('function');
|
expect(typeof client.modules.call).toBe('function');
|
||||||
expect(typeof client.resources).toBe('function');
|
expect(typeof client.resources).toBe('function');
|
||||||
expect(client.effects).toBeDefined();
|
|
||||||
|
|
||||||
expect('battlePass' in client).toBe(false);
|
expect('battlePass' in client).toBe(false);
|
||||||
expect('quests' in client).toBe(false);
|
expect('quests' in client).toBe(false);
|
||||||
@@ -80,10 +79,6 @@ describe('public API surface', () => {
|
|||||||
expect('inventory' in client).toBe(false);
|
expect('inventory' in client).toBe(false);
|
||||||
expect('remoteConfig' in client).toBe(false);
|
expect('remoteConfig' in client).toBe(false);
|
||||||
expect('scenarios' in client).toBe(false);
|
expect('scenarios' in client).toBe(false);
|
||||||
expect('onBattlePass' in client.effects).toBe(false);
|
expect('effects' in client).toBe(false);
|
||||||
expect('onStoreOffer' in client.effects).toBe(false);
|
|
||||||
expect('onQuest' in client.effects).toBe(false);
|
|
||||||
expect('onLeaderboard' in client.effects).toBe(false);
|
|
||||||
expect('onConfigChanged' in client.effects).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
// Test-side helpers: load the seed artifact, build SDK clients against prod, and
|
// Test-side helpers: load the seed artifact and build SDK clients against prod.
|
||||||
// reach the admin API for wallet grants / player inspection.
|
|
||||||
|
|
||||||
import { RudderClient } from '../../../src/client/RudderClient.js';
|
import { RudderClient } from '../../../src/client/RudderClient.js';
|
||||||
import type { TokenStore } from '../../../src/token/TokenStore.js';
|
import type { TokenStore } from '../../../src/token/TokenStore.js';
|
||||||
import { createAdminClient, type AdminClient } from './adminClient.js';
|
|
||||||
import { readArtifact } from './artifact.js';
|
import { readArtifact } from './artifact.js';
|
||||||
import type { SeedArtifact } from './types.js';
|
import type { SeedArtifact } from './types.js';
|
||||||
|
|
||||||
@@ -58,42 +56,3 @@ export async function freshPlayer(
|
|||||||
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
|
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin (operator) API client authenticated with the seeded operator token. */
|
|
||||||
export function adminApi(artifact: SeedArtifact = loadArtifact()): AdminClient {
|
|
||||||
const api = createAdminClient(artifact.baseUrl);
|
|
||||||
api.setToken(artifact.adminToken);
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Credits a player's wallet through the admin batch endpoint. */
|
|
||||||
export async function grantCurrency(
|
|
||||||
artifact: SeedArtifact,
|
|
||||||
playerId: string,
|
|
||||||
currencyCode: string,
|
|
||||||
amount: number,
|
|
||||||
): Promise<void> {
|
|
||||||
await adminApi(artifact).post(
|
|
||||||
`/platform/v1/projects/${artifact.projectId}/players/wallet/adjust?environment=${artifact.environment}`,
|
|
||||||
{ items: [{ playerId, currencyCode, amount, reason: 'e2e grant' }] },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
|
|
||||||
/** Retries a read until ok(result) holds — absorbs release/cache reload lag. */
|
|
||||||
export async function withReadRetry<T>(
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
ok: (value: T) => boolean,
|
|
||||||
tries = 8,
|
|
||||||
gapMs = 1500,
|
|
||||||
): Promise<T> {
|
|
||||||
let last = await fn();
|
|
||||||
if (ok(last)) return last;
|
|
||||||
for (let i = 1; i < tries; i++) {
|
|
||||||
await sleep(gapMs);
|
|
||||||
last = await fn();
|
|
||||||
if (ok(last)) return last;
|
|
||||||
}
|
|
||||||
return last;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user