@flow-industries/id 0.19.4 → 0.20.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/dist/sdk/client/actions.d.ts +20 -0
- package/dist/sdk/client/actions.js +68 -0
- package/dist/sdk/client/index.d.ts +4 -1
- package/dist/sdk/client/index.js +3 -0
- package/dist/sdk/client/json-api.d.ts +25 -0
- package/dist/sdk/client/json-api.js +51 -0
- package/dist/sdk/client/study.d.ts +16 -0
- package/dist/sdk/client/study.js +42 -0
- package/dist/sdk/driver-error.d.ts +3 -0
- package/dist/sdk/driver-error.js +6 -0
- package/dist/sdk/react/active-action.d.ts +14 -0
- package/dist/sdk/react/active-action.js +124 -0
- package/dist/sdk/react/index.d.ts +2 -1
- package/dist/sdk/react/index.js +1 -0
- package/dist/sdk/types/actions.d.ts +93 -0
- package/dist/sdk/types/actions.js +4 -0
- package/dist/sdk/types/index.d.ts +3 -1
- package/dist/sdk/types/study.d.ts +88 -0
- package/dist/sdk/types/study.js +1 -0
- package/dist/sdk/types/xp.d.ts +88 -4
- package/dist/sdk/xp/curve.d.ts +29 -0
- package/dist/sdk/xp/curve.js +64 -0
- package/package.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ActionsApi, ActiveSessionConflict, StartActionResult } from "../types";
|
|
2
|
+
/** Thrown on any non-2xx actions response other than a start's 409;
|
|
3
|
+
* `reason` carries the route's machine-readable code (`rate_limited`,
|
|
4
|
+
* `not_found`, `subject_required`, ...). */
|
|
5
|
+
export declare class ActionsRequestError extends Error {
|
|
6
|
+
readonly status: number;
|
|
7
|
+
readonly reason?: string | undefined;
|
|
8
|
+
constructor(status: number, message: string, reason?: string | undefined);
|
|
9
|
+
}
|
|
10
|
+
/** Whether a start collided with a running session instead of opening one. */
|
|
11
|
+
export declare function isActiveSessionConflict(result: StartActionResult): result is ActiveSessionConflict;
|
|
12
|
+
/**
|
|
13
|
+
* Thin typed client for the caller's own action sessions, bound to a host and
|
|
14
|
+
* the Flow token supplier — the `createRoomsApi` pattern. The catalog is
|
|
15
|
+
* public; everything under `/me` attaches the caller's bearer and fails fast
|
|
16
|
+
* with a 401-shaped error when no session exists. A start that collides with
|
|
17
|
+
* a running session resolves to the conflict rather than throwing, because a
|
|
18
|
+
* "switch?" prompt is the ordinary next step, not a fault.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createActionsApi(host: string, getToken: () => Promise<string | null>): ActionsApi;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createJsonApi } from "./json-api";
|
|
3
|
+
/** Thrown on any non-2xx actions response other than a start's 409;
|
|
4
|
+
* `reason` carries the route's machine-readable code (`rate_limited`,
|
|
5
|
+
* `not_found`, `subject_required`, ...). */
|
|
6
|
+
export class ActionsRequestError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
reason;
|
|
9
|
+
constructor(status, message, reason) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.reason = reason;
|
|
13
|
+
this.name = "ActionsRequestError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const conflictSchema = z.object({
|
|
17
|
+
error: z.literal("active_session"),
|
|
18
|
+
active: z.object({ sessionId: z.string() }).loose(),
|
|
19
|
+
});
|
|
20
|
+
/** Whether a start collided with a running session instead of opening one. */
|
|
21
|
+
export function isActiveSessionConflict(result) {
|
|
22
|
+
return "error" in result && result.error === "active_session";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Thin typed client for the caller's own action sessions, bound to a host and
|
|
26
|
+
* the Flow token supplier — the `createRoomsApi` pattern. The catalog is
|
|
27
|
+
* public; everything under `/me` attaches the caller's bearer and fails fast
|
|
28
|
+
* with a 401-shaped error when no session exists. A start that collides with
|
|
29
|
+
* a running session resolves to the conflict rather than throwing, because a
|
|
30
|
+
* "switch?" prompt is the ordinary next step, not a fault.
|
|
31
|
+
*/
|
|
32
|
+
export function createActionsApi(host, getToken) {
|
|
33
|
+
const api = createJsonApi(host, "/api/actions", getToken, (status, message, reason) => new ActionsRequestError(status, message, reason));
|
|
34
|
+
return {
|
|
35
|
+
catalog: () => api.request("/catalog"),
|
|
36
|
+
me: () => api.request("/me", { authRequired: true }),
|
|
37
|
+
async start(input) {
|
|
38
|
+
const body = {
|
|
39
|
+
...input,
|
|
40
|
+
sessionId: input.sessionId ?? crypto.randomUUID(),
|
|
41
|
+
};
|
|
42
|
+
const result = await api.exchange("/me/start", {
|
|
43
|
+
method: "POST",
|
|
44
|
+
body,
|
|
45
|
+
authRequired: true,
|
|
46
|
+
});
|
|
47
|
+
if (result.status === 409 &&
|
|
48
|
+
conflictSchema.safeParse(result.payload).success) {
|
|
49
|
+
/* SAFETY: the schema just confirmed the 409 body is the active_session conflict. */
|
|
50
|
+
return result.payload;
|
|
51
|
+
}
|
|
52
|
+
if (!result.ok)
|
|
53
|
+
throw api.failure(result);
|
|
54
|
+
/* SAFETY: a 2xx from /me/start is the { session } envelope. */
|
|
55
|
+
return result.payload;
|
|
56
|
+
},
|
|
57
|
+
event: (request) => api.request("/me/event", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
body: request,
|
|
60
|
+
authRequired: true,
|
|
61
|
+
}),
|
|
62
|
+
finish: (request) => api.request("/me/finish", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
body: request,
|
|
65
|
+
authRequired: true,
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { defaultIdHost, isLocalHostname } from "../id-host";
|
|
2
|
-
export type { AccessKeyOptions, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
|
|
2
|
+
export type { AccessKeyOptions, ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionSurface, ActionsApi, ActionTransitionKind, ActiveActionResponse, ActiveSessionConflict, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, CreateStudySubjectInput, DialogHost, FinishActionRequest, FinishActionResponse, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, ListStudySubjectsOptions, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, Session, StartActionInput, StartActionResponse, StartActionResult, StudyApi, StudyCatalog, StudyCategory, StudyField, StudySubject, StudySubjectList, StudySubjectRefusal, StudySubjectRemovalResponse, StudySubjectResponse, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, XpMode, } from "../types";
|
|
3
|
+
export { consistencyCurve, estimateActionXp, FLOW_SCORE_MAX, flowMultiplier, } from "../xp/curve";
|
|
4
|
+
export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "./actions";
|
|
3
5
|
export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
4
6
|
export { createDialogHost } from "./dialog-host";
|
|
5
7
|
export { createFlowWidget } from "./flow-widget";
|
|
@@ -8,3 +10,4 @@ export { closeProfile, openProfile } from "./open-profile";
|
|
|
8
10
|
export { createProfileButton } from "./profile-button";
|
|
9
11
|
export { createRoomsApi, RoomsRequestError } from "./rooms";
|
|
10
12
|
export { createStaticFlow } from "./static-flow";
|
|
13
|
+
export { createStudyApi, StudyRequestError } from "./study";
|
package/dist/sdk/client/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { defaultIdHost, isLocalHostname } from "../id-host";
|
|
2
|
+
export { consistencyCurve, estimateActionXp, FLOW_SCORE_MAX, flowMultiplier, } from "../xp/curve";
|
|
3
|
+
export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "./actions";
|
|
2
4
|
export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
3
5
|
export { createDialogHost } from "./dialog-host";
|
|
4
6
|
export { createFlowWidget } from "./flow-widget";
|
|
@@ -7,3 +9,4 @@ export { closeProfile, openProfile } from "./open-profile";
|
|
|
7
9
|
export { createProfileButton } from "./profile-button";
|
|
8
10
|
export { createRoomsApi, RoomsRequestError } from "./rooms";
|
|
9
11
|
export { createStaticFlow } from "./static-flow";
|
|
12
|
+
export { createStudyApi, StudyRequestError } from "./study";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type JsonRequestOptions = {
|
|
2
|
+
method?: "GET" | "POST" | "PATCH" | "DELETE";
|
|
3
|
+
body?: unknown;
|
|
4
|
+
/** Fail fast with a 401-shaped error when the caller has no token, instead
|
|
5
|
+
* of spending a request the server is going to refuse. */
|
|
6
|
+
authRequired?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type JsonExchange = {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
status: number;
|
|
11
|
+
/** The decoded JSON body, or `{}` when there was none to decode. */
|
|
12
|
+
payload: unknown;
|
|
13
|
+
};
|
|
14
|
+
export type JsonApiError = (status: number, message: string, reason?: string) => Error;
|
|
15
|
+
/**
|
|
16
|
+
* The transport under the typed clients: one host, one route prefix, the Flow
|
|
17
|
+
* token supplier, and the error class a client throws. `exchange` returns the
|
|
18
|
+
* raw status and payload for the few responses a client treats as values (a
|
|
19
|
+
* start's 409); `request` is the common path that throws on any non-2xx.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createJsonApi(host: string, prefix: string, getToken: () => Promise<string | null>, makeError: JsonApiError): {
|
|
22
|
+
exchange: (path: string, options?: JsonRequestOptions) => Promise<JsonExchange>;
|
|
23
|
+
request: <T>(path: string, options?: JsonRequestOptions) => Promise<T>;
|
|
24
|
+
failure: (exchange: JsonExchange) => Error;
|
|
25
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Every first-party JSON route answers a failure as `{ error, reason? }`;
|
|
3
|
+
* for the actions and study routes `error` is itself the machine code. */
|
|
4
|
+
const failureSchema = z.object({
|
|
5
|
+
error: z.string().optional(),
|
|
6
|
+
reason: z.string().optional(),
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* The transport under the typed clients: one host, one route prefix, the Flow
|
|
10
|
+
* token supplier, and the error class a client throws. `exchange` returns the
|
|
11
|
+
* raw status and payload for the few responses a client treats as values (a
|
|
12
|
+
* start's 409); `request` is the common path that throws on any non-2xx.
|
|
13
|
+
*/
|
|
14
|
+
export function createJsonApi(host, prefix, getToken, makeError) {
|
|
15
|
+
/** The error a non-2xx exchange throws: the route's own wording when it
|
|
16
|
+
* sent one, and its machine code as `reason`. */
|
|
17
|
+
function failure(exchange) {
|
|
18
|
+
const parsed = failureSchema.safeParse(exchange.payload);
|
|
19
|
+
const { error, reason } = parsed.success ? parsed.data : {};
|
|
20
|
+
return makeError(exchange.status, error ?? `Request failed (${exchange.status})`, reason ?? error);
|
|
21
|
+
}
|
|
22
|
+
async function exchange(path, options = {}) {
|
|
23
|
+
const headers = {};
|
|
24
|
+
const token = await getToken();
|
|
25
|
+
if (token) {
|
|
26
|
+
headers.Authorization = `Bearer ${token}`;
|
|
27
|
+
}
|
|
28
|
+
else if (options.authRequired) {
|
|
29
|
+
throw makeError(401, "Sign in first");
|
|
30
|
+
}
|
|
31
|
+
if (options.body !== undefined) {
|
|
32
|
+
headers["Content-Type"] = "application/json";
|
|
33
|
+
}
|
|
34
|
+
const res = await fetch(`${host}${prefix}${path}`, {
|
|
35
|
+
method: options.method ?? "GET",
|
|
36
|
+
headers,
|
|
37
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
38
|
+
});
|
|
39
|
+
const payload = await res.json().catch(() => ({}));
|
|
40
|
+
return { ok: res.ok, status: res.status, payload };
|
|
41
|
+
}
|
|
42
|
+
async function request(path, options = {}) {
|
|
43
|
+
const result = await exchange(path, options);
|
|
44
|
+
if (!result.ok)
|
|
45
|
+
throw failure(result);
|
|
46
|
+
/* SAFETY: each caller names the response type of the route it just called; a
|
|
47
|
+
non-ok status has already thrown above with the server's own wording. */
|
|
48
|
+
return result.payload;
|
|
49
|
+
}
|
|
50
|
+
return { exchange, request, failure };
|
|
51
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { StudyApi } from "../types";
|
|
2
|
+
/** Thrown on any non-2xx study response; `reason` carries the route's
|
|
3
|
+
* machine-readable code (`name_taken`, `subject_limit`, `field_unknown`,
|
|
4
|
+
* `not_found`, ...). */
|
|
5
|
+
export declare class StudyRequestError extends Error {
|
|
6
|
+
readonly status: number;
|
|
7
|
+
readonly reason?: string | undefined;
|
|
8
|
+
constructor(status: number, message: string, reason?: string | undefined);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Thin typed client for the study catalog and the caller's study subjects,
|
|
12
|
+
* bound to a host and the Flow token supplier. The catalog is public and
|
|
13
|
+
* long-cached by the browser; the subject routes attach the caller's bearer
|
|
14
|
+
* and fail fast with a 401-shaped error when no session exists.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createStudyApi(host: string, getToken: () => Promise<string | null>): StudyApi;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createJsonApi } from "./json-api";
|
|
2
|
+
/** Thrown on any non-2xx study response; `reason` carries the route's
|
|
3
|
+
* machine-readable code (`name_taken`, `subject_limit`, `field_unknown`,
|
|
4
|
+
* `not_found`, ...). */
|
|
5
|
+
export class StudyRequestError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
reason;
|
|
8
|
+
constructor(status, message, reason) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.reason = reason;
|
|
12
|
+
this.name = "StudyRequestError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Thin typed client for the study catalog and the caller's study subjects,
|
|
17
|
+
* bound to a host and the Flow token supplier. The catalog is public and
|
|
18
|
+
* long-cached by the browser; the subject routes attach the caller's bearer
|
|
19
|
+
* and fail fast with a 401-shaped error when no session exists.
|
|
20
|
+
*/
|
|
21
|
+
export function createStudyApi(host, getToken) {
|
|
22
|
+
const api = createJsonApi(host, "/api/study", getToken, (status, message, reason) => new StudyRequestError(status, message, reason));
|
|
23
|
+
const subjectPath = (id) => `/subjects/${encodeURIComponent(id)}`;
|
|
24
|
+
return {
|
|
25
|
+
catalog: () => api.request("/catalog"),
|
|
26
|
+
subjects: (options) => api.request(options?.archived ? "/subjects?archived=1" : "/subjects", { authRequired: true }),
|
|
27
|
+
createSubject: (input) => api.request("/subjects", {
|
|
28
|
+
method: "POST",
|
|
29
|
+
body: input,
|
|
30
|
+
authRequired: true,
|
|
31
|
+
}),
|
|
32
|
+
updateSubject: (id, patch) => api.request(subjectPath(id), {
|
|
33
|
+
method: "PATCH",
|
|
34
|
+
body: patch,
|
|
35
|
+
authRequired: true,
|
|
36
|
+
}),
|
|
37
|
+
deleteSubject: (id) => api.request(subjectPath(id), {
|
|
38
|
+
method: "DELETE",
|
|
39
|
+
authRequired: true,
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -6,3 +6,6 @@
|
|
|
6
6
|
/** Reads a named string field off a driver error. */
|
|
7
7
|
export declare function errField(cause: unknown, key: string): string;
|
|
8
8
|
export declare function errCause(cause: unknown): object | undefined;
|
|
9
|
+
/** Whether a driver error is Postgres's unique_violation (23505), read off the
|
|
10
|
+
* error itself or the cause a query wrapper nests it under. */
|
|
11
|
+
export declare function isUniqueViolation(cause: unknown): boolean;
|
package/dist/sdk/driver-error.js
CHANGED
|
@@ -15,3 +15,9 @@ export function errCause(cause) {
|
|
|
15
15
|
const value = Object.entries(Object(cause)).find(([k]) => k === "cause")?.[1];
|
|
16
16
|
return isObject(value) ? value : undefined;
|
|
17
17
|
}
|
|
18
|
+
/** Whether a driver error is Postgres's unique_violation (23505), read off the
|
|
19
|
+
* error itself or the cause a query wrapper nests it under. */
|
|
20
|
+
export function isUniqueViolation(cause) {
|
|
21
|
+
return (errField(cause, "code") === "23505" ||
|
|
22
|
+
errField(errCause(cause), "code") === "23505");
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ActiveActionState, UseActiveActionOptions } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* The caller's running action session, kept current without polling: it is
|
|
4
|
+
* read from `GET /api/actions/me` on mount, whenever the tab becomes visible
|
|
5
|
+
* again, after this hook's own `start`/`event`/`finish`, and on `refresh()`.
|
|
6
|
+
* Between reads the clock runs locally from the server's `accruedSeconds`, so
|
|
7
|
+
* a reload mid-session recovers the right time and a paused session stands
|
|
8
|
+
* still. Signed-out callers read as idle rather than as an error.
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* const { active, elapsedSeconds, start, finish } = useActiveAction();
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export declare function useActiveAction(options?: UseActiveActionOptions): ActiveActionState;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "../client/actions";
|
|
3
|
+
import { useFlow } from "./hooks";
|
|
4
|
+
function asError(cause) {
|
|
5
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
6
|
+
}
|
|
7
|
+
function elapsedFromAnchor(anchor, now) {
|
|
8
|
+
if (anchor.paused)
|
|
9
|
+
return anchor.accrued;
|
|
10
|
+
return anchor.accrued + (now - anchor.at) / 1000;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The caller's running action session, kept current without polling: it is
|
|
14
|
+
* read from `GET /api/actions/me` on mount, whenever the tab becomes visible
|
|
15
|
+
* again, after this hook's own `start`/`event`/`finish`, and on `refresh()`.
|
|
16
|
+
* Between reads the clock runs locally from the server's `accruedSeconds`, so
|
|
17
|
+
* a reload mid-session recovers the right time and a paused session stands
|
|
18
|
+
* still. Signed-out callers read as idle rather than as an error.
|
|
19
|
+
*
|
|
20
|
+
* ```tsx
|
|
21
|
+
* const { active, elapsedSeconds, start, finish } = useActiveAction();
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function useActiveAction(options = {}) {
|
|
25
|
+
const flow = useFlow();
|
|
26
|
+
const host = options.host ?? flow.host;
|
|
27
|
+
const api = useMemo(() => createActionsApi(host, flow.getToken), [host, flow]);
|
|
28
|
+
const [active, setActive] = useState(null);
|
|
29
|
+
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
30
|
+
const [loaded, setLoaded] = useState(false);
|
|
31
|
+
const [error, setError] = useState(null);
|
|
32
|
+
const anchor = useRef(null);
|
|
33
|
+
const activeId = useRef(null);
|
|
34
|
+
// Bumped by every adoption, so a read that was in flight when a start or
|
|
35
|
+
// finish answered cannot land afterwards and resurrect the older state.
|
|
36
|
+
const generation = useRef(0);
|
|
37
|
+
const adopt = useCallback((next) => {
|
|
38
|
+
generation.current += 1;
|
|
39
|
+
anchor.current = next
|
|
40
|
+
? { accrued: next.accruedSeconds, at: Date.now(), paused: next.paused }
|
|
41
|
+
: null;
|
|
42
|
+
activeId.current = next?.sessionId ?? null;
|
|
43
|
+
setActive(next);
|
|
44
|
+
setElapsedSeconds(next?.accruedSeconds ?? 0);
|
|
45
|
+
}, []);
|
|
46
|
+
const refresh = useCallback(async () => {
|
|
47
|
+
const ticket = generation.current;
|
|
48
|
+
try {
|
|
49
|
+
const next = await api.me();
|
|
50
|
+
if (ticket !== generation.current)
|
|
51
|
+
return;
|
|
52
|
+
adopt(next);
|
|
53
|
+
setError(null);
|
|
54
|
+
}
|
|
55
|
+
catch (cause) {
|
|
56
|
+
if (ticket !== generation.current)
|
|
57
|
+
return;
|
|
58
|
+
if (cause instanceof ActionsRequestError && cause.status === 401) {
|
|
59
|
+
adopt(null);
|
|
60
|
+
setError(null);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
setError(asError(cause));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
setLoaded(true);
|
|
68
|
+
}
|
|
69
|
+
}, [api, adopt]);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
void refresh();
|
|
72
|
+
const onVisibility = () => {
|
|
73
|
+
if (document.visibilityState === "visible")
|
|
74
|
+
void refresh();
|
|
75
|
+
};
|
|
76
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
77
|
+
return () => document.removeEventListener("visibilitychange", onVisibility);
|
|
78
|
+
}, [refresh]);
|
|
79
|
+
// A render clock, not a poll: one local tick per second while the session's
|
|
80
|
+
// clock runs, and nothing at all while it is paused or idle.
|
|
81
|
+
const running = active !== null && !active.paused;
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!running)
|
|
84
|
+
return;
|
|
85
|
+
const tick = () => {
|
|
86
|
+
const a = anchor.current;
|
|
87
|
+
if (a)
|
|
88
|
+
setElapsedSeconds(elapsedFromAnchor(a, Date.now()));
|
|
89
|
+
};
|
|
90
|
+
const timer = setInterval(tick, 1000);
|
|
91
|
+
return () => clearInterval(timer);
|
|
92
|
+
}, [running]);
|
|
93
|
+
const start = useCallback(async (input) => {
|
|
94
|
+
const result = await api.start(input);
|
|
95
|
+
if (!isActiveSessionConflict(result))
|
|
96
|
+
adopt(result.session);
|
|
97
|
+
return result;
|
|
98
|
+
}, [api, adopt]);
|
|
99
|
+
const event = useCallback(async (request) => {
|
|
100
|
+
const response = await api.event(request);
|
|
101
|
+
// Whether the clock stopped is the server's derivation from the event
|
|
102
|
+
// log, so a pause or resume re-reads rather than guessing.
|
|
103
|
+
if (request.kind === "pause" || request.kind === "resume") {
|
|
104
|
+
await refresh();
|
|
105
|
+
}
|
|
106
|
+
return response;
|
|
107
|
+
}, [api, refresh]);
|
|
108
|
+
const finish = useCallback(async (request) => {
|
|
109
|
+
const response = await api.finish(request);
|
|
110
|
+
if (activeId.current === request.sessionId)
|
|
111
|
+
adopt(null);
|
|
112
|
+
return response;
|
|
113
|
+
}, [api, adopt]);
|
|
114
|
+
return {
|
|
115
|
+
active,
|
|
116
|
+
elapsedSeconds,
|
|
117
|
+
loading: !loaded,
|
|
118
|
+
error,
|
|
119
|
+
refresh,
|
|
120
|
+
start,
|
|
121
|
+
event,
|
|
122
|
+
finish,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export type { FlowIdProviderProps, FlowWidgetProps, ProfileButtonProps, UseOpenProfileOptions, } from "../types";
|
|
1
|
+
export type { ActiveActionState, FlowIdProviderProps, FlowWidgetProps, ProfileButtonProps, UseActiveActionOptions, UseOpenProfileOptions, } from "../types";
|
|
2
|
+
export { useActiveAction } from "./active-action";
|
|
2
3
|
export { FlowWidget } from "./flow-widget";
|
|
3
4
|
export { useFlow, useFlowId, useFlowState, useRoom, useRooms } from "./hooks";
|
|
4
5
|
export { useOpenProfile } from "./open-profile";
|
package/dist/sdk/react/index.js
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** The SDK-side contract for actions and study: what `createActionsApi`,
|
|
2
|
+
* `createStudyApi` and `useActiveAction` take and return. The wire shapes
|
|
3
|
+
* themselves live in `./xp` and `./study`; this file only names the client
|
|
4
|
+
* inputs and the composed results a consumer app programs against. */
|
|
5
|
+
import type { CreateStudySubjectInput, StudyCatalog, StudySubject, StudySubjectList, StudySubjectRemovalResponse, UpdateStudySubjectInput } from "./study";
|
|
6
|
+
import type { ActionCatalogEntry, ActionCatalogResponse, ActionTransitionKind, ActiveActionResponse, ActiveSessionConflict, FinishActionResponse, StartActionResponse, UserActionSurface } from "./xp";
|
|
7
|
+
/** The curve parameters `estimateActionXp` reads. A catalog entry satisfies
|
|
8
|
+
* it, and so does the server's own registry descriptor, so the widget and a
|
|
9
|
+
* consumer app estimate from the same function without a conversion. */
|
|
10
|
+
export type ActionCurve = Pick<ActionCatalogEntry, "mode" | "base" | "tauMinutes" | "dailyCapXp" | "minDurationSeconds" | "flowMultiplier">;
|
|
11
|
+
/** What a live estimate is computed from: the running session's elapsed time
|
|
12
|
+
* plus the day context `GET /api/actions/me` carries. */
|
|
13
|
+
export interface XpEstimateContext {
|
|
14
|
+
elapsedSeconds: number;
|
|
15
|
+
/** Minutes already credited to this source today, so a day-cumulative curve
|
|
16
|
+
* starts from the right point. */
|
|
17
|
+
priorMinutes: number;
|
|
18
|
+
flowScore: number;
|
|
19
|
+
}
|
|
20
|
+
/** Body of POST /api/actions/me/start as the SDK takes it. `sessionId` is the
|
|
21
|
+
* client-minted idempotency key for the whole session; the client mints a UUID
|
|
22
|
+
* when it is omitted. */
|
|
23
|
+
export interface StartActionInput {
|
|
24
|
+
source: string;
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
subjectId?: string;
|
|
27
|
+
/** Recorded as `metadata.surface`; defaults to `web` on the server. */
|
|
28
|
+
surface?: UserActionSurface;
|
|
29
|
+
/** Finish and grant the running session before starting this one. */
|
|
30
|
+
replace?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** A start either opened (or replayed) a session or collided with a running
|
|
33
|
+
* one. The 409 is a value, not an error: the UI's next step is a "switch?"
|
|
34
|
+
* prompt, not an error state. */
|
|
35
|
+
export type StartActionResult = StartActionResponse | ActiveSessionConflict;
|
|
36
|
+
/** Body of POST /api/actions/me/event. */
|
|
37
|
+
export interface ActionEventRequest {
|
|
38
|
+
sessionId: string;
|
|
39
|
+
kind: ActionTransitionKind;
|
|
40
|
+
}
|
|
41
|
+
/** Response of POST /api/actions/me/event. `recorded` is false when the
|
|
42
|
+
* transition was a no-op (pausing a paused session, a second focus_lost). */
|
|
43
|
+
export interface ActionEventResponse {
|
|
44
|
+
ok: true;
|
|
45
|
+
recorded: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** Body of POST /api/actions/me/finish. */
|
|
48
|
+
export interface FinishActionRequest {
|
|
49
|
+
sessionId: string;
|
|
50
|
+
}
|
|
51
|
+
/** Typed client for the caller's own action sessions and the public catalog. */
|
|
52
|
+
export interface ActionsApi {
|
|
53
|
+
catalog(): Promise<ActionCatalogResponse>;
|
|
54
|
+
/** The caller's running session, or null when idle. */
|
|
55
|
+
me(): Promise<ActiveActionResponse | null>;
|
|
56
|
+
start(input: StartActionInput): Promise<StartActionResult>;
|
|
57
|
+
event(request: ActionEventRequest): Promise<ActionEventResponse>;
|
|
58
|
+
finish(request: FinishActionRequest): Promise<FinishActionResponse>;
|
|
59
|
+
}
|
|
60
|
+
export interface ListStudySubjectsOptions {
|
|
61
|
+
/** Include archived subjects after the live ones. */
|
|
62
|
+
archived?: boolean;
|
|
63
|
+
}
|
|
64
|
+
/** Envelope of every single-subject study route. */
|
|
65
|
+
export interface StudySubjectResponse {
|
|
66
|
+
subject: StudySubject;
|
|
67
|
+
}
|
|
68
|
+
/** Typed client for the study catalog and the caller's study subjects. */
|
|
69
|
+
export interface StudyApi {
|
|
70
|
+
catalog(): Promise<StudyCatalog>;
|
|
71
|
+
subjects(options?: ListStudySubjectsOptions): Promise<StudySubjectList>;
|
|
72
|
+
createSubject(input: CreateStudySubjectInput): Promise<StudySubjectResponse>;
|
|
73
|
+
updateSubject(id: string, patch: UpdateStudySubjectInput): Promise<StudySubjectResponse>;
|
|
74
|
+
deleteSubject(id: string): Promise<StudySubjectRemovalResponse>;
|
|
75
|
+
}
|
|
76
|
+
export type UseActiveActionOptions = {
|
|
77
|
+
/** Override the Flow ID origin; defaults to the resolved Flow's `host`. */
|
|
78
|
+
host?: string;
|
|
79
|
+
};
|
|
80
|
+
/** What `useActiveAction` returns. `elapsedSeconds` is extrapolated locally
|
|
81
|
+
* from the server's `accruedSeconds` and stands still while `paused`. */
|
|
82
|
+
export interface ActiveActionState {
|
|
83
|
+
active: ActiveActionResponse | null;
|
|
84
|
+
elapsedSeconds: number;
|
|
85
|
+
/** True until the first read of `/api/actions/me` has answered. */
|
|
86
|
+
loading: boolean;
|
|
87
|
+
error: Error | null;
|
|
88
|
+
/** Re-reads the running session from the server. */
|
|
89
|
+
refresh: () => Promise<void>;
|
|
90
|
+
start: (input: StartActionInput) => Promise<StartActionResult>;
|
|
91
|
+
event: (request: ActionEventRequest) => Promise<ActionEventResponse>;
|
|
92
|
+
finish: (request: FinishActionRequest) => Promise<FinishActionResponse>;
|
|
93
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The SDK-side contract for actions and study: what `createActionsApi`,
|
|
2
|
+
* `createStudyApi` and `useActiveAction` take and return. The wire shapes
|
|
3
|
+
* themselves live in `./xp` and `./study`; this file only names the client
|
|
4
|
+
* inputs and the composed results a consumer app programs against. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export type { ActionCurve, ActionEventRequest, ActionEventResponse, ActionsApi, ActiveActionState, FinishActionRequest, ListStudySubjectsOptions, StartActionInput, StartActionResult, StudyApi, StudySubjectResponse, UseActiveActionOptions, XpEstimateContext, } from "./actions";
|
|
1
2
|
export type { AccountSession, AccountSessionsResponse, AdditionalSession, AuthConfig, AuthResponse, AuthResponseWithWebAuthn, BioNormalization, BioRejection, BioWriteRefusal, BioWriteResult, FlowCredential, FlowUser, PasskeyPluginOptions, RevokeAllSessionsResponse, RevokeSessionResponse, SecurityActivityEntry, SecurityActivityResponse, SessionUser, VerifiedFlowJWT, VerifyOptions, WebAuthnSignature, } from "./auth";
|
|
2
3
|
export type { BodyRegion, CosmeticItem, CosmeticMaterial, CosmeticPaint, CosmeticSlot, EquipConflict, EquippedCosmetic, EquippedCosmetics, EquipRegion, PaintSlot, SettingsConflict, } from "./cosmetics";
|
|
3
4
|
export { BODY_REGIONS, COSMETIC_MATERIALS } from "./cosmetics";
|
|
@@ -12,5 +13,6 @@ export type { CreateRoomEventInput, RoomEventArchive, RoomEventAttendance, RoomE
|
|
|
12
13
|
export type { CreateRoomInput, GlobalRole, ModerationSubject, MyRooms, PlayerPositionReport, RoleChangeVerdict, RoomCapabilities, RoomCapability, RoomChannel, RoomDetail, RoomList, RoomMemberEntry, RoomMembers, RoomOccupancy, RoomOwner, RoomPlayerPosition, RoomPresenceEntry, RoomPresenceSnapshot, RoomRestrictionKind, RoomRole, RoomSummary, RoomSurfaceSettings, RoomsApi, RoomVisibility, StaffEntry, UpdateRoomInput, VerifyRoomContext, Viewer, VoiceDecisionReason, VoiceDecisionSubject, VoiceRoomDecision, } from "./rooms";
|
|
13
14
|
export type { AccessKeyOptions, AccessKeyPreparation, Address, ColorScheme, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, FlowState, FlowWidgetHandle, FlowWidgetName, FlowWidgetProps, Listener, LoginOptions, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, UseOpenProfileOptions, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
|
|
14
15
|
export type { GracefulShutdownCleanup, GracefulShutdownOptions, GracefulShutdownServer, GracefulShutdownSignal, ResolvedFlowSession, ResolveSessionOptions, SessionRouteOptions, SessionRouteResponse, SessionRouteSession, } from "./server";
|
|
16
|
+
export type { CreateStudySubjectInput, StudyCatalog, StudyCategory, StudyField, StudySubject, StudySubjectCreation, StudySubjectList, StudySubjectRefusal, StudySubjectRemoval, StudySubjectRemovalResponse, StudySubjectUpdate, UpdateStudySubjectInput, } from "./study";
|
|
15
17
|
export type { CoinAsset, IdentifiedTx, TxApprove, TxConvert, TxSend, TxSwap, } from "./tx";
|
|
16
|
-
export type { ActionDayContext, ActionEventKind, ActionSessionState, ActionTransitionKind, ActiveActionResponse, LevelProgress, PublicProfile, PublicProfileRefusal, PublicProfileResult, PublicProfileSignal, PublicUserProfile, XpGrantResult, XpRecentGrant, XpSummary, } from "./xp";
|
|
18
|
+
export type { ActionCatalogEntry, ActionCatalogResponse, ActionDayContext, ActionEventKind, ActionSessionState, ActionSubjectRef, ActionSurface, ActionTransitionKind, ActiveActionResponse, ActiveSessionConflict, FinishActionResponse, LevelProgress, PublicProfile, PublicProfileRefusal, PublicProfileResult, PublicProfileSignal, PublicUserProfile, StartActionResponse, StartUserSessionResult, UserActionSurface, XpGrantResult, XpMode, XpRecentGrant, XpSummary, } from "./xp";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Study catalog and study-subject shapes that cross the server/SDK boundary. */
|
|
2
|
+
/**
|
|
3
|
+
* One predefined thing a person can study. `slug` is `category/field`,
|
|
4
|
+
* lowercase ASCII and stable forever — it is what a subject row stores and
|
|
5
|
+
* what totals roll up on. `label` and `aliases` are display and search only,
|
|
6
|
+
* so either may change without touching data.
|
|
7
|
+
*/
|
|
8
|
+
export interface StudyField {
|
|
9
|
+
slug: string;
|
|
10
|
+
label: string;
|
|
11
|
+
aliases?: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
export interface StudyCategory {
|
|
14
|
+
slug: string;
|
|
15
|
+
label: string;
|
|
16
|
+
fields: readonly StudyField[];
|
|
17
|
+
}
|
|
18
|
+
/** Response of GET /api/study/catalog. */
|
|
19
|
+
export interface StudyCatalog {
|
|
20
|
+
version: number;
|
|
21
|
+
categories: readonly StudyCategory[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* One of the caller's study subjects, as every `/api/study/subjects` route
|
|
25
|
+
* returns it. `field` is a catalog field slug, a bare category slug (a custom
|
|
26
|
+
* subject that rolls up under one category), or null for a subject the catalog
|
|
27
|
+
* has no home for. `name` is the user's own words for it.
|
|
28
|
+
*/
|
|
29
|
+
export interface StudySubject {
|
|
30
|
+
id: string;
|
|
31
|
+
field: string | null;
|
|
32
|
+
name: string;
|
|
33
|
+
archivedAt: string | null;
|
|
34
|
+
createdAt: string;
|
|
35
|
+
lastUsedAt: string;
|
|
36
|
+
}
|
|
37
|
+
/** Response of GET /api/study/subjects. */
|
|
38
|
+
export interface StudySubjectList {
|
|
39
|
+
subjects: StudySubject[];
|
|
40
|
+
}
|
|
41
|
+
/** Body of POST /api/study/subjects. `name` defaults to the field's label. */
|
|
42
|
+
export interface CreateStudySubjectInput {
|
|
43
|
+
field?: string | null;
|
|
44
|
+
name?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Body of PATCH /api/study/subjects/:id — every key optional, at least one required. */
|
|
47
|
+
export interface UpdateStudySubjectInput {
|
|
48
|
+
name?: string;
|
|
49
|
+
field?: string | null;
|
|
50
|
+
archived?: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** Why a subject write was refused; each is also the `error` code the route answers with. */
|
|
53
|
+
export type StudySubjectRefusal = "field_unknown" | "name_invalid" | "name_taken" | "subject_limit" | "not_found";
|
|
54
|
+
/** Outcome of a create: `created: false` means a live subject with that name
|
|
55
|
+
* already existed and is returned instead (the route answers 200, not 201). */
|
|
56
|
+
export type StudySubjectCreation = {
|
|
57
|
+
ok: true;
|
|
58
|
+
created: boolean;
|
|
59
|
+
subject: StudySubject;
|
|
60
|
+
} | {
|
|
61
|
+
ok: false;
|
|
62
|
+
reason: Exclude<StudySubjectRefusal, "not_found" | "name_taken">;
|
|
63
|
+
};
|
|
64
|
+
export type StudySubjectUpdate = {
|
|
65
|
+
ok: true;
|
|
66
|
+
subject: StudySubject;
|
|
67
|
+
} | {
|
|
68
|
+
ok: false;
|
|
69
|
+
reason: StudySubjectRefusal;
|
|
70
|
+
};
|
|
71
|
+
/** Outcome of a delete: a subject no session references is removed outright;
|
|
72
|
+
* one with history is archived instead and returned. */
|
|
73
|
+
export type StudySubjectRemoval = {
|
|
74
|
+
ok: true;
|
|
75
|
+
outcome: "deleted";
|
|
76
|
+
subject: null;
|
|
77
|
+
} | {
|
|
78
|
+
ok: true;
|
|
79
|
+
outcome: "archived";
|
|
80
|
+
subject: StudySubject;
|
|
81
|
+
} | {
|
|
82
|
+
ok: false;
|
|
83
|
+
reason: "not_found";
|
|
84
|
+
};
|
|
85
|
+
/** Response of DELETE /api/study/subjects/:id. */
|
|
86
|
+
export type StudySubjectRemovalResponse = Extract<StudySubjectRemoval, {
|
|
87
|
+
ok: true;
|
|
88
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/** Study catalog and study-subject shapes that cross the server/SDK boundary. */
|
package/dist/sdk/types/xp.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
/** XP / leveling shapes that cross the server/dialog boundary. */
|
|
2
|
+
/** How a source turns activity into XP: a flat amount per grant, XP per
|
|
3
|
+
* minute, or a day-saturating curve. The curves themselves live in
|
|
4
|
+
* `src/xp/registry.ts`. */
|
|
5
|
+
export type XpMode = "fixed" | "linear" | "consistency";
|
|
2
6
|
export interface LevelProgress {
|
|
3
7
|
/** Level within the current tier, 1..100 — it resets to 1 on each tier-up. */
|
|
4
8
|
level: number;
|
|
@@ -52,15 +56,95 @@ export type ActionSessionState = "active" | "finished" | "abandoned";
|
|
|
52
56
|
export type ActionEventKind = "start" | "pause" | "resume" | "focus_lost" | "focus_gained" | "finish" | "abandon";
|
|
53
57
|
/** Reporter-supplied transitions accepted while an action is active. */
|
|
54
58
|
export type ActionTransitionKind = Extract<ActionEventKind, "pause" | "resume" | "focus_lost" | "focus_gained">;
|
|
55
|
-
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* `
|
|
59
|
+
/** Where a session was started: the game world under the report token, or a
|
|
60
|
+
* web surface under the caller's own session. Recorded in
|
|
61
|
+
* `action_session.metadata.surface`; the source id stays surface-agnostic, so
|
|
62
|
+
* Meditate is `game.action.meditation` whether it began in the game or in
|
|
63
|
+
* Talk. */
|
|
64
|
+
export type ActionSurface = "game" | "talk" | "web";
|
|
65
|
+
/** The surfaces a caller may claim on POST /api/actions/me/start; `game` is
|
|
66
|
+
* reserved for the report-token path. */
|
|
67
|
+
export type UserActionSurface = Exclude<ActionSurface, "game">;
|
|
68
|
+
/** The study subject a session is attributed to, resolved from the joined
|
|
69
|
+
* `study_subject` row whether or not it has since been archived, so history
|
|
70
|
+
* keeps its name. Null for a source without subjects and for a Study the
|
|
71
|
+
* game started while the user had none. */
|
|
72
|
+
export interface ActionSubjectRef {
|
|
73
|
+
id: string;
|
|
74
|
+
name: string;
|
|
75
|
+
field: string | null;
|
|
76
|
+
}
|
|
77
|
+
/** The caller's own view of one session — the shape of GET /api/actions/me
|
|
78
|
+
* and /active (null when idle), of the `session` a start returns, and of the
|
|
79
|
+
* `active` a 409 carries — plus the day context so a timer can render the
|
|
80
|
+
* live XP estimate from the timestamp-derived accrued duration. The lifecycle
|
|
81
|
+
* state is not carried: an idle caller reads null, and a start or finish
|
|
82
|
+
* answers with the session as it stands at that instant. */
|
|
59
83
|
export interface ActiveActionResponse extends ActionDayContext {
|
|
84
|
+
sessionId: string;
|
|
60
85
|
source: string;
|
|
61
86
|
label: string | null;
|
|
87
|
+
surface: ActionSurface;
|
|
88
|
+
/** ISO timestamp of the session's start. */
|
|
89
|
+
startedAt: string;
|
|
90
|
+
/** Whether the last pause/resume transition left the clock stopped. */
|
|
91
|
+
paused: boolean;
|
|
62
92
|
accruedSeconds: number;
|
|
93
|
+
subject: ActionSubjectRef | null;
|
|
94
|
+
}
|
|
95
|
+
/** One user-startable action as GET /api/actions/catalog lists it: the
|
|
96
|
+
* registry descriptor minus its reporter, with the curve parameters a client
|
|
97
|
+
* needs to estimate XP the way the server grants it. `label` and
|
|
98
|
+
* `description` are always present (a source without them lists under its
|
|
99
|
+
* id); the curve fields are present only when the source declares them. */
|
|
100
|
+
export interface ActionCatalogEntry {
|
|
101
|
+
id: string;
|
|
102
|
+
label: string;
|
|
103
|
+
description: string;
|
|
104
|
+
mode: XpMode;
|
|
105
|
+
base: number;
|
|
106
|
+
tauMinutes?: number;
|
|
107
|
+
dailyCapXp?: number;
|
|
108
|
+
minDurationSeconds?: number;
|
|
109
|
+
flowMultiplier: boolean;
|
|
110
|
+
userStartable: boolean;
|
|
111
|
+
requiresSubject: boolean;
|
|
112
|
+
}
|
|
113
|
+
/** Response of GET /api/actions/catalog, in registry declaration order. */
|
|
114
|
+
export interface ActionCatalogResponse {
|
|
115
|
+
actions: ActionCatalogEntry[];
|
|
116
|
+
}
|
|
117
|
+
/** Response of POST /api/actions/me/start (201 created, 200 on an idempotent
|
|
118
|
+
* replay of the same sessionId). */
|
|
119
|
+
export interface StartActionResponse {
|
|
120
|
+
session: ActiveActionResponse;
|
|
121
|
+
}
|
|
122
|
+
/** The 409 body of POST /api/actions/me/start: the session already running,
|
|
123
|
+
* which `replace: true` would finish. */
|
|
124
|
+
export interface ActiveSessionConflict {
|
|
125
|
+
error: "active_session";
|
|
126
|
+
active: ActiveActionResponse;
|
|
63
127
|
}
|
|
128
|
+
/** Response of POST /api/actions/me/finish. Idempotent: a replay answers with
|
|
129
|
+
* the XP and duration the session was credited when it ended. */
|
|
130
|
+
export interface FinishActionResponse {
|
|
131
|
+
ok: true;
|
|
132
|
+
xpGranted: number;
|
|
133
|
+
durationSeconds: number;
|
|
134
|
+
}
|
|
135
|
+
/** Outcome of a user-initiated start. The refusals map to one status each in
|
|
136
|
+
* the route; `subject_unknown` is a subjectId that is not one of the caller's
|
|
137
|
+
* live subjects, `session_id_taken` a client id that another user's session
|
|
138
|
+
* already holds, and `rate_limited` the per-user hourly start cap. */
|
|
139
|
+
export type StartUserSessionResult = {
|
|
140
|
+
status: "created" | "replayed";
|
|
141
|
+
session: ActiveActionResponse;
|
|
142
|
+
} | {
|
|
143
|
+
status: "active_session";
|
|
144
|
+
active: ActiveActionResponse;
|
|
145
|
+
} | {
|
|
146
|
+
status: "unknown_source" | "not_startable" | "subject_required" | "subject_unknown" | "session_id_taken" | "rate_limited";
|
|
147
|
+
};
|
|
64
148
|
/** Non-sensitive profile facts any player may see about another — nested into
|
|
65
149
|
* the /api/session/verify payload beside `appearance` so game servers can
|
|
66
150
|
* broadcast them (avatar + level badge on nametags) without extra fetches.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure XP curve math: the Flow Score multiplier, the day-saturating
|
|
3
|
+
* consistency curve, and the live estimate a timer renders from them. No
|
|
4
|
+
* registry, no DB, no I/O — this module is shipped in the SDK and imported by
|
|
5
|
+
* the grant service alike, so a count-up in a consumer app and the ledger row
|
|
6
|
+
* the server writes come from one function and cannot drift.
|
|
7
|
+
*/
|
|
8
|
+
import type { ActionCurve, XpEstimateContext } from "../types";
|
|
9
|
+
export declare const FLOW_SCORE_MAX = 10;
|
|
10
|
+
export declare function clampFlowScore(score: number): number;
|
|
11
|
+
/** The Flow Score IS the XP multiplier: score 10 grants 10x. Floored at 1x so a
|
|
12
|
+
* lapsed player (score 0 or 1) still earns full base XP. */
|
|
13
|
+
export declare function flowMultiplier(flowScore: number): number;
|
|
14
|
+
/** The (unrounded) cumulative pre-multiplier XP a consistency source has earned
|
|
15
|
+
* after `totalMinutes` of activity in the day — a saturating curve toward
|
|
16
|
+
* `base`. A single session's grant is the delta between this at the new and old
|
|
17
|
+
* totals, so the first minutes of the day are worth the most. The live timer
|
|
18
|
+
* uses this raw form for a smooth count-up; grants round it. */
|
|
19
|
+
export declare function consistencyCurve(source: Pick<ActionCurve, "base" | "tauMinutes">, totalMinutes: number): number;
|
|
20
|
+
/**
|
|
21
|
+
* The XP a session of `elapsedSeconds` would grant if it finished now,
|
|
22
|
+
* mirroring the grant service: consistency is the delta on the day-cumulative
|
|
23
|
+
* curve from today's prior minutes, linear is `base` per minute, fixed is flat;
|
|
24
|
+
* below the source's minimum duration it is 0. The Flow Score multiplies the
|
|
25
|
+
* result, and a daily cap bounds it by what is left of the day's ceiling after
|
|
26
|
+
* the prior minutes. Unrounded curve, rounded once at the end, so a count-up
|
|
27
|
+
* advances smoothly and lands on the number the ledger will show.
|
|
28
|
+
*/
|
|
29
|
+
export declare function estimateActionXp(entry: ActionCurve, context: XpEstimateContext): number;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure XP curve math: the Flow Score multiplier, the day-saturating
|
|
3
|
+
* consistency curve, and the live estimate a timer renders from them. No
|
|
4
|
+
* registry, no DB, no I/O — this module is shipped in the SDK and imported by
|
|
5
|
+
* the grant service alike, so a count-up in a consumer app and the ledger row
|
|
6
|
+
* the server writes come from one function and cannot drift.
|
|
7
|
+
*/
|
|
8
|
+
export const FLOW_SCORE_MAX = 10;
|
|
9
|
+
export function clampFlowScore(score) {
|
|
10
|
+
return Math.min(FLOW_SCORE_MAX, Math.max(0, Math.trunc(score)));
|
|
11
|
+
}
|
|
12
|
+
/** The Flow Score IS the XP multiplier: score 10 grants 10x. Floored at 1x so a
|
|
13
|
+
* lapsed player (score 0 or 1) still earns full base XP. */
|
|
14
|
+
export function flowMultiplier(flowScore) {
|
|
15
|
+
return Math.max(1, clampFlowScore(flowScore));
|
|
16
|
+
}
|
|
17
|
+
/** The (unrounded) cumulative pre-multiplier XP a consistency source has earned
|
|
18
|
+
* after `totalMinutes` of activity in the day — a saturating curve toward
|
|
19
|
+
* `base`. A single session's grant is the delta between this at the new and old
|
|
20
|
+
* totals, so the first minutes of the day are worth the most. The live timer
|
|
21
|
+
* uses this raw form for a smooth count-up; grants round it. */
|
|
22
|
+
export function consistencyCurve(source, totalMinutes) {
|
|
23
|
+
const tau = source.tauMinutes ?? 40;
|
|
24
|
+
return source.base * (1 - Math.exp(-Math.max(0, totalMinutes) / tau));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The XP a session of `elapsedSeconds` would grant if it finished now,
|
|
28
|
+
* mirroring the grant service: consistency is the delta on the day-cumulative
|
|
29
|
+
* curve from today's prior minutes, linear is `base` per minute, fixed is flat;
|
|
30
|
+
* below the source's minimum duration it is 0. The Flow Score multiplies the
|
|
31
|
+
* result, and a daily cap bounds it by what is left of the day's ceiling after
|
|
32
|
+
* the prior minutes. Unrounded curve, rounded once at the end, so a count-up
|
|
33
|
+
* advances smoothly and lands on the number the ledger will show.
|
|
34
|
+
*/
|
|
35
|
+
export function estimateActionXp(entry, context) {
|
|
36
|
+
const { elapsedSeconds, priorMinutes, flowScore } = context;
|
|
37
|
+
if (entry.minDurationSeconds !== undefined &&
|
|
38
|
+
elapsedSeconds < entry.minDurationSeconds) {
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
const elapsedMinutes = Math.max(0, elapsedSeconds) / 60;
|
|
42
|
+
const prior = Math.max(0, priorMinutes);
|
|
43
|
+
const multiplier = entry.flowMultiplier ? flowMultiplier(flowScore) : 1;
|
|
44
|
+
let base;
|
|
45
|
+
let priorBase = 0;
|
|
46
|
+
if (entry.mode === "fixed") {
|
|
47
|
+
base = entry.base;
|
|
48
|
+
}
|
|
49
|
+
else if (entry.mode === "linear") {
|
|
50
|
+
base = entry.base * elapsedMinutes;
|
|
51
|
+
priorBase = entry.base * prior;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
base =
|
|
55
|
+
consistencyCurve(entry, prior + elapsedMinutes) -
|
|
56
|
+
consistencyCurve(entry, prior);
|
|
57
|
+
priorBase = consistencyCurve(entry, prior);
|
|
58
|
+
}
|
|
59
|
+
const xp = Math.max(0, Math.round(base * multiplier));
|
|
60
|
+
if (entry.dailyCapXp === undefined)
|
|
61
|
+
return xp;
|
|
62
|
+
const remaining = Math.max(0, entry.dailyCapXp - Math.round(priorBase * multiplier));
|
|
63
|
+
return Math.min(xp, remaining);
|
|
64
|
+
}
|