@dopamint-fun/open-sdk 0.1.0-dev.0 → 0.2.0-dev.1
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 +8 -1
- package/dist/acceptance.d.ts +6 -0
- package/dist/acceptance.js +34 -0
- package/dist/channel.d.ts +7 -6
- package/dist/channel.js +20 -16
- package/dist/claim.d.ts +19 -1
- package/dist/claim.js +47 -4
- package/dist/cli.js +890 -57
- package/dist/decide.d.ts +16 -0
- package/dist/decide.js +122 -0
- package/dist/equity.d.ts +34 -0
- package/dist/equity.js +99 -0
- package/dist/handRank.d.ts +36 -0
- package/dist/handRank.js +170 -0
- package/dist/identity.js +2 -1
- package/dist/index.d.ts +12 -4
- package/dist/index.js +15 -4
- package/dist/keypair.js +8 -1
- package/dist/offer.d.ts +7 -0
- package/dist/offer.js +38 -7
- package/dist/openTournament.d.ts +211 -0
- package/dist/openTournament.js +337 -0
- package/dist/refusal.d.ts +45 -0
- package/dist/refusal.js +84 -0
- package/dist/room.d.ts +88 -0
- package/dist/room.js +184 -0
- package/dist/seatState.d.ts +146 -0
- package/dist/seatState.js +286 -0
- package/dist/seatTurn.d.ts +151 -0
- package/dist/seatTurn.js +519 -0
- package/dist/session.d.ts +158 -5
- package/dist/session.js +433 -49
- package/dist/sessionCodec.d.ts +164 -2
- package/dist/sessionCodec.js +665 -11
- package/dist/sessionWire.d.ts +21 -0
- package/dist/sessionWire.js +45 -0
- package/dist/tour.d.ts +55 -4
- package/dist/tour.js +88 -11
- package/package.json +1 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** The one move a refused caller makes next. */
|
|
2
|
+
export type RefusalNext = {
|
|
3
|
+
action: "retry";
|
|
4
|
+
after_ms: number;
|
|
5
|
+
} | {
|
|
6
|
+
action: "wait";
|
|
7
|
+
until_ms?: number;
|
|
8
|
+
poll?: string;
|
|
9
|
+
} | {
|
|
10
|
+
action: "read";
|
|
11
|
+
route: string;
|
|
12
|
+
} | {
|
|
13
|
+
action: "ask_owner";
|
|
14
|
+
what: "claim" | "hand_out" | "invite" | "sign";
|
|
15
|
+
} | {
|
|
16
|
+
action: "use";
|
|
17
|
+
command: string;
|
|
18
|
+
} | {
|
|
19
|
+
action: "stop";
|
|
20
|
+
};
|
|
21
|
+
/** Every refusal shape the product answers with, read loosely: the identity
|
|
22
|
+
* and offer routes say `detail`, the agent and table routes say `message`. */
|
|
23
|
+
export interface RefusalBody {
|
|
24
|
+
code?: string;
|
|
25
|
+
detail?: string;
|
|
26
|
+
message?: string;
|
|
27
|
+
retryable?: boolean;
|
|
28
|
+
next?: RefusalNext;
|
|
29
|
+
docs?: string;
|
|
30
|
+
}
|
|
31
|
+
/** `next …`, as one line an agent can act on. */
|
|
32
|
+
export declare function describeNext(next: RefusalNext): string;
|
|
33
|
+
/** The lines a refusal prints: its code, what it said, the move, the entry. */
|
|
34
|
+
export declare function refusalLines(body: unknown): string[];
|
|
35
|
+
/** `<what> refused (<status>)`, then the refusal's own lines. A body that is
|
|
36
|
+
* not a refusal the product named is printed as it came. */
|
|
37
|
+
export declare function refusalMessage(what: string, status: number, body: unknown): string;
|
|
38
|
+
/** The same, for a body still in the text it arrived as. */
|
|
39
|
+
export declare function refusalMessageFromText(what: string, status: number, text: string): string;
|
|
40
|
+
/** A refusal this client decides on its own, before the product is asked,
|
|
41
|
+
* carrying a move in the same shape the product's refusals do. */
|
|
42
|
+
export declare class ClientRefusal extends Error {
|
|
43
|
+
readonly next: RefusalNext;
|
|
44
|
+
constructor(message: string, next: RefusalNext);
|
|
45
|
+
}
|
package/dist/refusal.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* What a refused agent does next, as the product names it.
|
|
2
|
+
*
|
|
3
|
+
* Every refusal body carries `code`, and since the envelope grew one, `next`:
|
|
4
|
+
* a closed set of moves with their parameters, and `docs`, the code's entry in
|
|
5
|
+
* the errors reference. The client prints all of it verbatim, so an agent
|
|
6
|
+
* reading a refused command's output reads its move off the same lines rather
|
|
7
|
+
* than off a table in a skill document. */
|
|
8
|
+
/** `next …`, as one line an agent can act on. */
|
|
9
|
+
export function describeNext(next) {
|
|
10
|
+
switch (next.action) {
|
|
11
|
+
case "retry":
|
|
12
|
+
return `next retry after ${next.after_ms} ms`;
|
|
13
|
+
case "wait": {
|
|
14
|
+
const parts = ["next wait"];
|
|
15
|
+
if (next.until_ms !== undefined)
|
|
16
|
+
parts.push(`until ${new Date(next.until_ms).toISOString()}`);
|
|
17
|
+
if (next.poll !== undefined)
|
|
18
|
+
parts.push(`poll ${next.poll}`);
|
|
19
|
+
return parts.join(" ");
|
|
20
|
+
}
|
|
21
|
+
case "read":
|
|
22
|
+
return `next read ${next.route}`;
|
|
23
|
+
case "ask_owner":
|
|
24
|
+
return `next ask_owner ${next.what}`;
|
|
25
|
+
case "use":
|
|
26
|
+
return `next use ${next.command}`;
|
|
27
|
+
case "stop":
|
|
28
|
+
return "next stop";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const isNext = (value) => typeof value === "object" &&
|
|
32
|
+
value !== null &&
|
|
33
|
+
typeof value.action === "string";
|
|
34
|
+
/** The lines a refusal prints: its code, what it said, the move, the entry. */
|
|
35
|
+
export function refusalLines(body) {
|
|
36
|
+
if (typeof body !== "object" || body === null)
|
|
37
|
+
return [];
|
|
38
|
+
const refusal = body;
|
|
39
|
+
const lines = [];
|
|
40
|
+
if (refusal.code)
|
|
41
|
+
lines.push(`code ${refusal.code}`);
|
|
42
|
+
const said = refusal.detail ?? refusal.message;
|
|
43
|
+
if (said)
|
|
44
|
+
lines.push(`detail ${said}`);
|
|
45
|
+
if (isNext(refusal.next))
|
|
46
|
+
lines.push(describeNext(refusal.next));
|
|
47
|
+
if (refusal.docs)
|
|
48
|
+
lines.push(`docs ${refusal.docs}`);
|
|
49
|
+
return lines;
|
|
50
|
+
}
|
|
51
|
+
/** `<what> refused (<status>)`, then the refusal's own lines. A body that is
|
|
52
|
+
* not a refusal the product named is printed as it came. */
|
|
53
|
+
export function refusalMessage(what, status, body) {
|
|
54
|
+
const lines = refusalLines(body);
|
|
55
|
+
if (lines.length === 0) {
|
|
56
|
+
/* A body the product did not write -- a proxy's 502 while it restarts --
|
|
57
|
+
arrives empty, and `JSON.stringify(undefined)` printed "undefined". */
|
|
58
|
+
const said = body === undefined || body === null
|
|
59
|
+
? "no body"
|
|
60
|
+
: typeof body === "string"
|
|
61
|
+
? body
|
|
62
|
+
: JSON.stringify(body);
|
|
63
|
+
return `${what} refused (${status}): ${said}`;
|
|
64
|
+
}
|
|
65
|
+
return [`${what} refused (${status})`, ...lines].join("\n");
|
|
66
|
+
}
|
|
67
|
+
/** The same, for a body still in the text it arrived as. */
|
|
68
|
+
export function refusalMessageFromText(what, status, text) {
|
|
69
|
+
try {
|
|
70
|
+
return refusalMessage(what, status, JSON.parse(text));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return `${what} refused (${status}): ${text}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** A refusal this client decides on its own, before the product is asked,
|
|
77
|
+
* carrying a move in the same shape the product's refusals do. */
|
|
78
|
+
export class ClientRefusal extends Error {
|
|
79
|
+
next;
|
|
80
|
+
constructor(message, next) {
|
|
81
|
+
super(`${message}\n${describeNext(next)}`);
|
|
82
|
+
this.next = next;
|
|
83
|
+
}
|
|
84
|
+
}
|
package/dist/room.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { AgentKeypair } from "./keypair.js";
|
|
2
|
+
export interface RoomClient {
|
|
3
|
+
productUrl: string;
|
|
4
|
+
agent: AgentKeypair;
|
|
5
|
+
/** 32-byte agent id, as the arena registered it. */
|
|
6
|
+
agentId: Uint8Array;
|
|
7
|
+
}
|
|
8
|
+
/** A room as the product answers it on create. */
|
|
9
|
+
export interface RoomOpened {
|
|
10
|
+
tableId: string;
|
|
11
|
+
/** The chairs the room was opened for. An upper bound, not a roster: what
|
|
12
|
+
* nobody takes stays empty for the sitting. */
|
|
13
|
+
seatCount: number;
|
|
14
|
+
settlement: string;
|
|
15
|
+
mode: string;
|
|
16
|
+
/** Present once the authority has an execution behind the table. */
|
|
17
|
+
executionId?: string;
|
|
18
|
+
}
|
|
19
|
+
/** A join. `offerId` and `seat` are present once the room has composed, which
|
|
20
|
+
* is where the guest's seat is taken by accepting an offer with its own key.
|
|
21
|
+
* A room still filling answers how full it is instead, and when it deals to
|
|
22
|
+
* the agents who are already in it. On a mock stack there is never an offer
|
|
23
|
+
* and nothing to accept. */
|
|
24
|
+
export interface RoomJoined {
|
|
25
|
+
tableId: string;
|
|
26
|
+
joined: boolean;
|
|
27
|
+
offerId?: string;
|
|
28
|
+
seat?: number;
|
|
29
|
+
/** How many chairs are taken, of `seatCount`. Absent once composed — every
|
|
30
|
+
* seat in the offer holds an agent. */
|
|
31
|
+
seated?: number;
|
|
32
|
+
seatCount?: number;
|
|
33
|
+
/** When the room deals to the agents who are here rather than waiting for
|
|
34
|
+
* the rest. Absent while one agent sits alone: a room of one has no sitting
|
|
35
|
+
* to start, and waits indefinitely. */
|
|
36
|
+
fillAtMs?: number;
|
|
37
|
+
/** `authority` or `mock`, so a caller can tell "no offer because this stack
|
|
38
|
+
* composes none" from "no offer yet". */
|
|
39
|
+
mode?: string;
|
|
40
|
+
}
|
|
41
|
+
/** The seat counts a private room can be opened with, both ends included.
|
|
42
|
+
*
|
|
43
|
+
* Refused here as well as by the server: a room is opened once and its size
|
|
44
|
+
* cannot be changed afterwards, so an out-of-range number is better caught
|
|
45
|
+
* before the agent tells anybody a room exists. */
|
|
46
|
+
export declare const MIN_ROOM_SEATS = 2;
|
|
47
|
+
export declare const MAX_ROOM_SEATS = 10;
|
|
48
|
+
/** Open a room this agent will host.
|
|
49
|
+
*
|
|
50
|
+
* `seatCount` is how many agents may come, including this one. No seat is
|
|
51
|
+
* ever filled for you: the room deals when its last chair is taken, or when
|
|
52
|
+
* its fill window closes with at least two agents in it, and the chairs
|
|
53
|
+
* nobody took stay empty for the sitting. */
|
|
54
|
+
export declare function openRoom(client: RoomClient, seatCount: number): Promise<RoomOpened>;
|
|
55
|
+
/** A join the product turned down, with the code it named. */
|
|
56
|
+
export declare class RoomJoinRefusal extends Error {
|
|
57
|
+
readonly status: number;
|
|
58
|
+
readonly code: string | undefined;
|
|
59
|
+
constructor(status: number, code: string | undefined, body: unknown);
|
|
60
|
+
}
|
|
61
|
+
/** Join a room by the id its opener handed out. */
|
|
62
|
+
export declare function joinRoom(client: RoomClient, tableId: string): Promise<RoomJoined>;
|
|
63
|
+
/** Join, and wait for the room to fill when this agent already holds a seat
|
|
64
|
+
* in it.
|
|
65
|
+
*
|
|
66
|
+
* A room is opened with its opener's seat already reserved, so the opener's
|
|
67
|
+
* own join answers `already_seated` until the guest arrives and the room
|
|
68
|
+
* composes into an offer; after that the same join answers the opener's seat.
|
|
69
|
+
* The document tells the opener to sit down with this command, and it used to
|
|
70
|
+
* end on that first refusal: an opener who sat before its guest arrived had
|
|
71
|
+
* nothing running when the room composed. So `already_seated` is waited out,
|
|
72
|
+
* as the fill timer is on the queue, and every other refusal still ends here. */
|
|
73
|
+
export declare function joinRoomWhenComposed(client: RoomClient, tableId: string, options?: {
|
|
74
|
+
pollMs?: number;
|
|
75
|
+
timeoutMs?: number;
|
|
76
|
+
/** Called once, the first time the room is found still waiting. Carries
|
|
77
|
+
* the join that found it waiting, where there was one: the opener's own
|
|
78
|
+
* join is refused rather than answered, so it has nothing to carry. */
|
|
79
|
+
onWaiting?: (joined?: RoomJoined) => void;
|
|
80
|
+
sleep?: (ms: number) => Promise<void>;
|
|
81
|
+
now?: () => number;
|
|
82
|
+
}): Promise<RoomJoined>;
|
|
83
|
+
/** The one line an opener hands its guest, which is the whole invitation.
|
|
84
|
+
*
|
|
85
|
+
* A prompt rather than a URL: what the guest needs is the document that says
|
|
86
|
+
* what to do and the id to do it with, and an operator pasting two things in
|
|
87
|
+
* the right order is how a room went unjoined. */
|
|
88
|
+
export declare function roomInvitePrompt(productUrl: string, tableId: string, seatsOpen?: number): string;
|
package/dist/room.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/* The private room, on this agent's own key.
|
|
2
|
+
*
|
|
3
|
+
* Two calls: an agent opens a room and is handed a table id, and a second
|
|
4
|
+
* agent joins by that id. On an authority stack the join answers the offer the
|
|
5
|
+
* room composed, and the guest sits down by accepting it — the product holds no
|
|
6
|
+
* key that could accept for either of them, which is why a room is a
|
|
7
|
+
* reservation rather than a seat already taken.
|
|
8
|
+
*
|
|
9
|
+
* Both calls are signed the way every other Product API mutation is: an
|
|
10
|
+
* `AgentHttpCapability` over the exact method, target and body. Nothing here
|
|
11
|
+
* hand-rolls that; it is the same minting the tour path uses.
|
|
12
|
+
*/
|
|
13
|
+
import { AGENT_HTTP_CAPABILITY_HEADER, mintAgentHttpCapability, } from "./agentHttp.js";
|
|
14
|
+
import { textBytes } from "./bytes.js";
|
|
15
|
+
import { refusalMessage } from "./refusal.js";
|
|
16
|
+
async function signedFetch(client, method, target, body) {
|
|
17
|
+
const payload = body === undefined ? new Uint8Array() : textBytes(JSON.stringify(body));
|
|
18
|
+
const binding = {
|
|
19
|
+
method,
|
|
20
|
+
requestTarget: target,
|
|
21
|
+
body: payload,
|
|
22
|
+
};
|
|
23
|
+
const { header } = await mintAgentHttpCapability(client.agent, client.agentId, binding);
|
|
24
|
+
const headers = {
|
|
25
|
+
[AGENT_HTTP_CAPABILITY_HEADER]: header,
|
|
26
|
+
};
|
|
27
|
+
if (body !== undefined)
|
|
28
|
+
headers["content-type"] = "application/json";
|
|
29
|
+
const response = await fetch(`${client.productUrl.replace(/\/$/, "")}${target}`, {
|
|
30
|
+
method,
|
|
31
|
+
headers,
|
|
32
|
+
/* The capability covers these exact bytes, so the body is sent verbatim
|
|
33
|
+
rather than re-serialized by fetch from an object. */
|
|
34
|
+
body: body === undefined ? undefined : payload,
|
|
35
|
+
});
|
|
36
|
+
const text = await response.text();
|
|
37
|
+
return {
|
|
38
|
+
status: response.status,
|
|
39
|
+
json: text.length === 0 ? undefined : JSON.parse(text),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** The seat counts a private room can be opened with, both ends included.
|
|
43
|
+
*
|
|
44
|
+
* Refused here as well as by the server: a room is opened once and its size
|
|
45
|
+
* cannot be changed afterwards, so an out-of-range number is better caught
|
|
46
|
+
* before the agent tells anybody a room exists. */
|
|
47
|
+
export const MIN_ROOM_SEATS = 2;
|
|
48
|
+
export const MAX_ROOM_SEATS = 10;
|
|
49
|
+
/** Open a room this agent will host.
|
|
50
|
+
*
|
|
51
|
+
* `seatCount` is how many agents may come, including this one. No seat is
|
|
52
|
+
* ever filled for you: the room deals when its last chair is taken, or when
|
|
53
|
+
* its fill window closes with at least two agents in it, and the chairs
|
|
54
|
+
* nobody took stay empty for the sitting. */
|
|
55
|
+
export async function openRoom(client, seatCount) {
|
|
56
|
+
if (!Number.isInteger(seatCount) ||
|
|
57
|
+
seatCount < MIN_ROOM_SEATS ||
|
|
58
|
+
seatCount > MAX_ROOM_SEATS)
|
|
59
|
+
throw new Error(`a private room seats ${MIN_ROOM_SEATS} to ${MAX_ROOM_SEATS}, not ${seatCount}`);
|
|
60
|
+
const { status, json } = await signedFetch(client, "POST", "/open/v1/tables/private-rooms", { seatCount });
|
|
61
|
+
if (status !== 201) {
|
|
62
|
+
if (json?.code === "agent_not_claimed")
|
|
63
|
+
throw notClaimed();
|
|
64
|
+
throw new Error(refusalMessage("room open", status, json));
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
tableId: json.tableId,
|
|
68
|
+
seatCount: json.seatCount,
|
|
69
|
+
settlement: json.settlement,
|
|
70
|
+
mode: json.mode,
|
|
71
|
+
executionId: json.executionId ?? undefined,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** The precondition both calls share, said as something to do.
|
|
75
|
+
*
|
|
76
|
+
* A private room is watchable live only by the wallets that own its seats, so
|
|
77
|
+
* every seat has to be claimed -- the opener and the guest alike. Checked when
|
|
78
|
+
* the room is opened and again when somebody joins, rather than discovered by
|
|
79
|
+
* an owner who cannot watch. */
|
|
80
|
+
function notClaimed() {
|
|
81
|
+
return new Error("this agent is not claimed, and every seat of a private room must be: " +
|
|
82
|
+
"the claim names the wallet allowed to watch the room while it plays. " +
|
|
83
|
+
"Run `dopa-open claim-invite` and give the link to the wallet that owns " +
|
|
84
|
+
"this agent.\nnext ask_owner claim");
|
|
85
|
+
}
|
|
86
|
+
/** A join the product turned down, with the code it named. */
|
|
87
|
+
export class RoomJoinRefusal extends Error {
|
|
88
|
+
status;
|
|
89
|
+
code;
|
|
90
|
+
constructor(status, code, body) {
|
|
91
|
+
super(refusalMessage("room join", status, body));
|
|
92
|
+
this.status = status;
|
|
93
|
+
this.code = code;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Join a room by the id its opener handed out. */
|
|
97
|
+
export async function joinRoom(client, tableId) {
|
|
98
|
+
const target = `/open/v1/tables/private-rooms/${tableId}/join`;
|
|
99
|
+
const { status, json } = await signedFetch(client, "POST", target);
|
|
100
|
+
if (status !== 201) {
|
|
101
|
+
if (json?.code === "agent_not_claimed")
|
|
102
|
+
throw notClaimed();
|
|
103
|
+
if (json?.code === "private_room_finished")
|
|
104
|
+
throw new Error(`room ${tableId} has played its sitting, so it seats nobody again; ` +
|
|
105
|
+
"its record is on the history route, and a new room is opened the way this one was\nnext stop");
|
|
106
|
+
throw new RoomJoinRefusal(status, json?.code, json);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
tableId: json.tableId,
|
|
110
|
+
joined: json.joined === true,
|
|
111
|
+
offerId: json.offerId ?? undefined,
|
|
112
|
+
seat: json.seat ?? undefined,
|
|
113
|
+
seated: json.seated ?? undefined,
|
|
114
|
+
seatCount: json.seatCount ?? undefined,
|
|
115
|
+
fillAtMs: json.fillAtMs ?? undefined,
|
|
116
|
+
mode: json.mode ?? undefined,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Join, and wait for the room to fill when this agent already holds a seat
|
|
120
|
+
* in it.
|
|
121
|
+
*
|
|
122
|
+
* A room is opened with its opener's seat already reserved, so the opener's
|
|
123
|
+
* own join answers `already_seated` until the guest arrives and the room
|
|
124
|
+
* composes into an offer; after that the same join answers the opener's seat.
|
|
125
|
+
* The document tells the opener to sit down with this command, and it used to
|
|
126
|
+
* end on that first refusal: an opener who sat before its guest arrived had
|
|
127
|
+
* nothing running when the room composed. So `already_seated` is waited out,
|
|
128
|
+
* as the fill timer is on the queue, and every other refusal still ends here. */
|
|
129
|
+
export async function joinRoomWhenComposed(client, tableId, options = {}) {
|
|
130
|
+
const pollMs = options.pollMs ?? 2_000;
|
|
131
|
+
const timeoutMs = options.timeoutMs ?? 30 * 60_000;
|
|
132
|
+
const sleep = options.sleep ??
|
|
133
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
134
|
+
const now = options.now ?? Date.now;
|
|
135
|
+
const deadline = now() + timeoutMs;
|
|
136
|
+
let told = false;
|
|
137
|
+
const stillWaiting = () => {
|
|
138
|
+
if (now() >= deadline)
|
|
139
|
+
throw new Error(`room ${tableId} had not composed after ${timeoutMs} ms; ` +
|
|
140
|
+
"send the invite line to the guests you are still waiting for, ask its " +
|
|
141
|
+
"owner to start the room with whoever is already in it, or open a room again later");
|
|
142
|
+
};
|
|
143
|
+
for (;;) {
|
|
144
|
+
try {
|
|
145
|
+
const joined = await joinRoom(client, tableId);
|
|
146
|
+
/* Joined, and the room is still filling: this agent holds a chair and
|
|
147
|
+
there is no offer to accept until the room deals. A mock stack
|
|
148
|
+
composes no offer at all, so its join is the answer rather than a step
|
|
149
|
+
towards one. */
|
|
150
|
+
if (joined.offerId !== undefined || joined.mode === "mock")
|
|
151
|
+
return joined;
|
|
152
|
+
stillWaiting();
|
|
153
|
+
if (!told) {
|
|
154
|
+
told = true;
|
|
155
|
+
options.onWaiting?.(joined);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (!(error instanceof RoomJoinRefusal) || error.code !== "already_seated")
|
|
160
|
+
throw error;
|
|
161
|
+
stillWaiting();
|
|
162
|
+
if (!told) {
|
|
163
|
+
told = true;
|
|
164
|
+
options.onWaiting?.();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
await sleep(pollMs);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** The one line an opener hands its guest, which is the whole invitation.
|
|
171
|
+
*
|
|
172
|
+
* A prompt rather than a URL: what the guest needs is the document that says
|
|
173
|
+
* what to do and the id to do it with, and an operator pasting two things in
|
|
174
|
+
* the right order is how a room went unjoined. */
|
|
175
|
+
export function roomInvitePrompt(productUrl, tableId, seatsOpen) {
|
|
176
|
+
const base = productUrl.replace(/\/$/, "");
|
|
177
|
+
/* How many can still come, when the caller knows. A room deals to whoever
|
|
178
|
+
is in it when its window closes, so a guest reading this is being told
|
|
179
|
+
both what to do and how much room is left to do it in. */
|
|
180
|
+
const room = seatsOpen === undefined
|
|
181
|
+
? ""
|
|
182
|
+
: ` (${seatsOpen} ${seatsOpen === 1 ? "seat" : "seats"} open)`;
|
|
183
|
+
return `read ${base}/skills/private-room.md and join table ${tableId}${room}`;
|
|
184
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { SessionContext } from "./sessionWire.js";
|
|
2
|
+
import { type PredictionGateStatus, type ResumeCursor, type ViewSnapshot } from "./sessionCodec.js";
|
|
3
|
+
/** Written beside the key file, as `.dopa-keypair` is. */
|
|
4
|
+
export declare const DEFAULT_SEAT_STATE_FILE = ".dopa-seat";
|
|
5
|
+
export interface SeatSessionState {
|
|
6
|
+
/** Which arena, and which offer the seat was admitted through. */
|
|
7
|
+
productUrl: string;
|
|
8
|
+
offerId: string;
|
|
9
|
+
seat: number;
|
|
10
|
+
/** 32 bytes, `0x`-prefixed: the client nonce this session is keyed by. */
|
|
11
|
+
clientNonce: string;
|
|
12
|
+
/** Absent until the first join answers with one. */
|
|
13
|
+
context: SerialisedContext | null;
|
|
14
|
+
/** The session token the authority issued at join.
|
|
15
|
+
*
|
|
16
|
+
* Kept because `resume` only re-issues one when the authority chooses to,
|
|
17
|
+
* and a fresh process that resumed without a token has nothing to
|
|
18
|
+
* authenticate its next request with -- which surfaces as "session token
|
|
19
|
+
* missing" at the moment it tries to act, not at the moment it resumed. */
|
|
20
|
+
token: string | null;
|
|
21
|
+
cursor: SerialisedCursor;
|
|
22
|
+
/** The position `turn` last printed, so `act` answers that one. */
|
|
23
|
+
view: SerialisedView | null;
|
|
24
|
+
/** The state nonce of the position `act` last answered, as a decimal string.
|
|
25
|
+
*
|
|
26
|
+
* `act` does not move the cursor -- the commit it causes is read by the next
|
|
27
|
+
* `turn` -- so that `turn` resumes from before the answered position and is
|
|
28
|
+
* handed it again. Without this it printed the same turn as `yours`, `act`
|
|
29
|
+
* signed it again, and the loop spun on an answered turn until its clock
|
|
30
|
+
* ran out. Absent in a file written before it existed. */
|
|
31
|
+
answeredNonce?: string | null;
|
|
32
|
+
/** The agent this seat plays as, `0x`-prefixed, once `turn` has found it.
|
|
33
|
+
*
|
|
34
|
+
* Kept so `act` needs nothing the first `turn` did not already learn: the
|
|
35
|
+
* roster lists an agent under the wallet that claimed it, so a key whose
|
|
36
|
+
* agent registered itself and was never claimed found no agent there, and
|
|
37
|
+
* the documented `act` refused its first turn. Absent in a file written
|
|
38
|
+
* before it existed. */
|
|
39
|
+
agentId?: string | null;
|
|
40
|
+
predictionGate: SerialisedGate | null;
|
|
41
|
+
awaitingGatePrefix: boolean;
|
|
42
|
+
}
|
|
43
|
+
interface SerialisedContext {
|
|
44
|
+
wireVersion: number;
|
|
45
|
+
sessionVersion: number;
|
|
46
|
+
sessionId: string;
|
|
47
|
+
executionId: string;
|
|
48
|
+
executionManifestDigest: string;
|
|
49
|
+
protocolId: string;
|
|
50
|
+
protocolVersion: number;
|
|
51
|
+
participantId: string;
|
|
52
|
+
seat: number;
|
|
53
|
+
}
|
|
54
|
+
interface SerialisedState {
|
|
55
|
+
nonce: string;
|
|
56
|
+
commitment: string;
|
|
57
|
+
}
|
|
58
|
+
interface SerialisedReceipt {
|
|
59
|
+
digest: string;
|
|
60
|
+
resultingState: SerialisedState;
|
|
61
|
+
}
|
|
62
|
+
interface SerialisedCursor {
|
|
63
|
+
sequence: string;
|
|
64
|
+
witnessedReceipt: SerialisedReceipt | null;
|
|
65
|
+
}
|
|
66
|
+
interface SerialisedView {
|
|
67
|
+
state: SerialisedState;
|
|
68
|
+
participantView: string;
|
|
69
|
+
participantViewSchema: number;
|
|
70
|
+
legalActions: string;
|
|
71
|
+
legalActionsSchema: number;
|
|
72
|
+
participantDeadlineMs: string;
|
|
73
|
+
latestReceipt: SerialisedReceipt | null;
|
|
74
|
+
}
|
|
75
|
+
interface SerialisedWindow {
|
|
76
|
+
windowId: string;
|
|
77
|
+
marketId: string;
|
|
78
|
+
contract: "pokerActionV1";
|
|
79
|
+
}
|
|
80
|
+
interface SerialisedPreparation {
|
|
81
|
+
window: SerialisedWindow;
|
|
82
|
+
actingSeat: number;
|
|
83
|
+
state: SerialisedState;
|
|
84
|
+
receipt: SerialisedReceipt | null;
|
|
85
|
+
originalDeadlineMs: string;
|
|
86
|
+
preparedAtMs: string;
|
|
87
|
+
}
|
|
88
|
+
interface SerialisedOpening {
|
|
89
|
+
preparation: SerialisedPreparation;
|
|
90
|
+
openedAtMs: string;
|
|
91
|
+
closesAtMs: string;
|
|
92
|
+
}
|
|
93
|
+
interface SerialisedRelease {
|
|
94
|
+
window: SerialisedWindow;
|
|
95
|
+
actingSeat: number;
|
|
96
|
+
state: SerialisedState;
|
|
97
|
+
receipt: SerialisedReceipt | null;
|
|
98
|
+
terminal: "locked" | "cancelled";
|
|
99
|
+
originalDeadlineMs: string;
|
|
100
|
+
arrivalMs: string;
|
|
101
|
+
lockedAtMs: string;
|
|
102
|
+
budgetMs: string;
|
|
103
|
+
}
|
|
104
|
+
type SerialisedGate = {
|
|
105
|
+
phase: "prepared";
|
|
106
|
+
preparation: SerialisedPreparation;
|
|
107
|
+
} | {
|
|
108
|
+
phase: "open";
|
|
109
|
+
opening: SerialisedOpening;
|
|
110
|
+
} | {
|
|
111
|
+
phase: "released";
|
|
112
|
+
release: SerialisedRelease;
|
|
113
|
+
};
|
|
114
|
+
export declare function encodeContext(context: SessionContext): SerialisedContext;
|
|
115
|
+
export declare function decodeContext(stored: SerialisedContext): SessionContext;
|
|
116
|
+
export declare function encodeCursor(cursor: ResumeCursor): SerialisedCursor;
|
|
117
|
+
export declare function decodeCursor(stored: SerialisedCursor): ResumeCursor;
|
|
118
|
+
export declare function encodeView(view: ViewSnapshot): SerialisedView;
|
|
119
|
+
export declare function decodeView(stored: SerialisedView): ViewSnapshot;
|
|
120
|
+
/** The accepted gate phase, in the file's own vocabulary. */
|
|
121
|
+
export declare function encodeGate(gate: PredictionGateStatus): SerialisedGate;
|
|
122
|
+
export declare function decodeGate(stored: SerialisedGate): PredictionGateStatus;
|
|
123
|
+
/** Read the seat's state, or null where it has not been opened yet.
|
|
124
|
+
*
|
|
125
|
+
* A state file whose retained context names a session version this build
|
|
126
|
+
* cannot speak is refused here, before any network call or signature: the
|
|
127
|
+
* sitting it was written for ended under the previous contract, and its
|
|
128
|
+
* cursor and token do not carry over. The file is left untouched, so an
|
|
129
|
+
* operator can still read what the seat was doing.
|
|
130
|
+
*
|
|
131
|
+
* A file that does name this version and omits the gate boundary is refused
|
|
132
|
+
* too. Reading a missing `awaitingGatePrefix` as "not awaiting" would have a
|
|
133
|
+
* restarted seat answer the private view it acknowledged a viewless join
|
|
134
|
+
* for, which is the one thing the flag exists to prevent. */
|
|
135
|
+
export declare function loadSeatState(path: string): SeatSessionState | null;
|
|
136
|
+
/** Write it back, whole, and durably. Read-modify-write, never append: a
|
|
137
|
+
* half-written cursor is a session that cannot resume, and the fix for that
|
|
138
|
+
* is a rejoin that costs the seat every turn in between.
|
|
139
|
+
*
|
|
140
|
+
* Same-directory temporary file, fsync, rename, then fsync the directory -
|
|
141
|
+
* the repository's durable-private-file convention
|
|
142
|
+
* (`arena_authority::recovery_anchor::write_durable_private_file`). `turn`
|
|
143
|
+
* acknowledges an event only after this returns, so a torn file here would
|
|
144
|
+
* be a phase the authority believes was received and the seat cannot see. */
|
|
145
|
+
export declare function saveSeatState(path: string, state: SeatSessionState): void;
|
|
146
|
+
export {};
|