@dopamint-fun/open-sdk 0.1.0-dev.0 → 0.2.0-dev.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.
@@ -5,7 +5,7 @@
5
5
  * the seat loop: views, commits, terminal, errors, and seat-auth challenges.
6
6
  */
7
7
  import { ByteReader, ByteWriter } from "./bytes.js";
8
- import { encodeContext } from "./sessionWire.js";
8
+ import { encodeContext, requireSessionVersion, } from "./sessionWire.js";
9
9
  const AUTHORITY_EVENT_ACKNOWLEDGEMENT_TAG = 0x09;
10
10
  export const SESSION_JOINED_TAG = 0x02;
11
11
  export const PARTICIPANT_VIEW_TAG = 0x03;
@@ -18,6 +18,7 @@ export const SESSION_TERMINAL_TAG = 0x0c;
18
18
  export const SESSION_ERROR_TAG = 0x0d;
19
19
  export const SEAT_AUTHORIZATION_CHALLENGE_TAG = 0x0f;
20
20
  export const SEAT_AUTHORIZATION_RESPONSE_TAG = 0x10;
21
+ export const PREDICTION_GATE_RELEASED_TAG = 0x11;
21
22
  export const MAX_TRANSPORT_FRAME_BYTES = 1 << 20;
22
23
  export function encodeAckFrame(context, cursor) {
23
24
  const sequence = typeof cursor === "object" ? cursor.sequence : cursor;
@@ -97,9 +98,11 @@ export function sessionErrorHint(tag) {
97
98
  }
98
99
  }
99
100
  function readContext(reader, wireVersion) {
101
+ const sessionVersion = reader.readU16("session version");
102
+ requireSessionVersion(sessionVersion);
100
103
  return {
101
104
  wireVersion,
102
- sessionVersion: reader.readU16("session version"),
105
+ sessionVersion,
103
106
  sessionId: reader.readFixed(32, "session id"),
104
107
  executionId: reader.readFixed(32, "execution id"),
105
108
  executionManifestDigest: reader.readFixed(32, "execution manifest digest"),
@@ -211,7 +214,14 @@ export function decodeAuthorityMessage(bytes) {
211
214
  const wireVersion = reader.readU16("wire version");
212
215
  const tag = reader.readByte("message tag");
213
216
  if (tag === SESSION_ERROR_TAG) {
214
- readOption(reader, "session version option", () => reader.readU16("session version"));
217
+ // A present selected version is the authority's answer for this session,
218
+ // so it has to be one this build can speak; the unsupported-version
219
+ // payload read below is a raw diagnostic and stays readable without one.
220
+ readOption(reader, "session version option", () => {
221
+ const version = reader.readU16("session version");
222
+ requireSessionVersion(version);
223
+ return version;
224
+ });
215
225
  const errorTag = reader.readByte("session protocol error tag");
216
226
  if (errorTag === 0 || errorTag === 1)
217
227
  reader.readU16("unsupported version");
@@ -323,6 +333,27 @@ export function decodeAuthorityMessage(bytes) {
323
333
  };
324
334
  break;
325
335
  }
336
+ case PREDICTION_GATE_RELEASED_TAG: {
337
+ reader.readU64("prediction window id");
338
+ reader.readU64("prediction market id");
339
+ reader.readByte("prediction window contract");
340
+ reader.readU16("prediction gate acting seat");
341
+ readState(reader, "prediction gate state nonce", "prediction gate state commitment");
342
+ readOption(reader, "prediction gate receipt option", () => readReceipt(reader));
343
+ reader.readByte("prediction gate terminal");
344
+ reader.readU64("original participant deadline milliseconds");
345
+ reader.readU64("prediction gate arrival milliseconds");
346
+ reader.readU64("prediction gate locked-at milliseconds");
347
+ reader.readU64("participant deadline budget milliseconds");
348
+ const cursor = readCursor(reader);
349
+ message = {
350
+ type: "predictionGateReleased",
351
+ context,
352
+ sequence,
353
+ cursor,
354
+ };
355
+ break;
356
+ }
326
357
  default:
327
358
  throw new Error(`unknown authority message tag ${tag}`);
328
359
  }
@@ -1,4 +1,24 @@
1
1
  import { ByteWriter } from "./bytes.js";
2
+ /** The participant session version this build speaks.
3
+ *
4
+ * It is the contract that carries the named `PredictionGateReleased` overlay
5
+ * event, so an authority that cannot name it in its join advertisement is
6
+ * refused at discovery and a selected context from any other version is
7
+ * refused rather than re-encoded. */
8
+ export declare const SESSION_VERSION = 2;
9
+ /** A selected participant session version this build cannot speak.
10
+ *
11
+ * Thrown where a *selected* version enters or leaves the process -- a
12
+ * retained seat state, a signed context, or an authority event. It is never
13
+ * thrown for a raw join advertisement, which may carry any ascending `u16`
14
+ * values for the authority to choose among. */
15
+ export declare class UnsupportedSessionVersionError extends Error {
16
+ readonly version: number;
17
+ constructor(version: number);
18
+ }
19
+ /** The selected-context guard. Call it wherever a session context, or the
20
+ * selected version on an authority message, is about to be acted on. */
21
+ export declare function requireSessionVersion(version: number): void;
2
22
  export interface SessionContext {
3
23
  wireVersion: number;
4
24
  sessionVersion: number;
@@ -12,7 +12,50 @@ const RESUME_DOMAIN = textBytes("arena_session::resume");
12
12
  const SESSION_JOIN_REQUEST_TAG = 0x01;
13
13
  const ACTION_PROPOSAL_TAG = 0x04;
14
14
  const SESSION_RESUME_REQUEST_TAG = 0x0a;
15
+ /** The participant session version this build speaks.
16
+ *
17
+ * It is the contract that carries the named `PredictionGateReleased` overlay
18
+ * event, so an authority that cannot name it in its join advertisement is
19
+ * refused at discovery and a selected context from any other version is
20
+ * refused rather than re-encoded. */
21
+ export const SESSION_VERSION = 2;
22
+ /** A selected participant session version this build cannot speak.
23
+ *
24
+ * Thrown where a *selected* version enters or leaves the process -- a
25
+ * retained seat state, a signed context, or an authority event. It is never
26
+ * thrown for a raw join advertisement, which may carry any ascending `u16`
27
+ * values for the authority to choose among. */
28
+ export class UnsupportedSessionVersionError extends Error {
29
+ version;
30
+ constructor(version) {
31
+ super(`unsupported participant session version ${version}; expected 2`);
32
+ this.version = version;
33
+ this.name = "UnsupportedSessionVersionError";
34
+ }
35
+ }
36
+ /** The selected-context guard. Call it wherever a session context, or the
37
+ * selected version on an authority message, is about to be acted on. */
38
+ export function requireSessionVersion(version) {
39
+ if (version !== SESSION_VERSION)
40
+ throw new UnsupportedSessionVersionError(version);
41
+ }
42
+ /** Structural validation of a join advertisement, mirroring the shared codec:
43
+ * the list is raw, so it need not name a version this build supports, but an
44
+ * empty, non-u16, or non-ascending list is not encodable at all. */
45
+ function requireRawVersionList(versions) {
46
+ if (versions.length === 0)
47
+ throw new Error("supported session versions must not be empty");
48
+ if (versions.length > 0xffff)
49
+ throw new Error("supported session versions exceed the u16 count limit");
50
+ for (const [index, version] of versions.entries()) {
51
+ if (!Number.isInteger(version) || version < 0 || version > 0xffff)
52
+ throw new Error(`supported session version ${version} is not a u16`);
53
+ if (index > 0 && versions[index - 1] >= version)
54
+ throw new Error("supported session versions must be strictly ascending");
55
+ }
56
+ }
15
57
  export function encodeContext(writer, context) {
58
+ requireSessionVersion(context.sessionVersion);
16
59
  writer
17
60
  .pushU16(context.sessionVersion)
18
61
  .pushFixed(context.sessionId, 32, "session id")
@@ -36,6 +79,7 @@ export function signedFrame(body, signature) {
36
79
  .bytes();
37
80
  }
38
81
  export function joinBodyBytes(request) {
82
+ requireRawVersionList(request.supportedSessionVersions);
39
83
  const writer = new ByteWriter()
40
84
  .pushU16(request.wireVersion)
41
85
  .pushByte(SESSION_JOIN_REQUEST_TAG)
package/dist/tour.d.ts CHANGED
@@ -11,8 +11,16 @@ export type TourEntry = {
11
11
  waiting: number;
12
12
  seatsNeeded: number;
13
13
  fillAtMs: number;
14
- /** how many seats the house will take at `fillAtMs` if nobody else comes */
14
+ /** how many seats the house will take at `fillAtMs` if nobody else
15
+ * comes; 0 when the fill will not seat this agent at all */
15
16
  houseSeatsAtFill?: number;
17
+ /** what the fill at `fillAtMs` does with this agent: `stays_queued` when
18
+ * its floor (`minAgents`) cannot be met by who is queued, so it is not
19
+ * seated and waits for another real agent to arrive. Absent from an
20
+ * arena that predates floors, like `houseSeatsAtFill` before it */
21
+ atFill?: "seated" | "stays_queued";
22
+ /** the floor this agent stated, when above one */
23
+ minAgents?: number;
16
24
  }
17
25
  /** The queue composed an offer this agent accepts and plays itself. Since
18
26
  * ADR-0175 this is the answer for every external agent: the product holds
@@ -37,8 +45,25 @@ export type TourEntry = {
37
45
  state: "closed" | "unpaid";
38
46
  [key: string]: unknown;
39
47
  };
40
- export declare function enterTour(client: TourClient, tour: TourKind): Promise<TourEntry>;
41
- export interface QueueOptions {
48
+ /** What an agent states when it enters the playground. */
49
+ export interface PlaygroundFloor {
50
+ /** The fewest real agents, this one included, it will sit with: 1 to 3.
51
+ * Omitted, the playground seats it with house seats after the wait, as it
52
+ * always has. */
53
+ minAgents?: number;
54
+ }
55
+ /** The origin answering as a restarting one does -- no answer at all, 408,
56
+ * 429 or a 5xx -- rather than the tour refusing the entry. A queue waits it
57
+ * out; every other refusal is the caller's to read. */
58
+ export declare class TourEntryUnavailable extends Error {
59
+ readonly status: number;
60
+ constructor(status: number, message: string);
61
+ }
62
+ /** How long a queue keeps entering while the origin stays unavailable. Long
63
+ * enough to cover a product restart, which is what this is for. */
64
+ export declare const QUEUE_RESTART_BOUND_MS = 90000;
65
+ export declare function enterTour(client: TourClient, tour: TourKind, floor?: PlaygroundFloor): Promise<TourEntry>;
66
+ export interface QueueOptions extends PlaygroundFloor {
42
67
  /** How long to keep polling before giving up. */
43
68
  timeoutMs?: number;
44
69
  /** Gap between polls. The queue answers immediately; this paces the caller. */
@@ -46,6 +71,12 @@ export interface QueueOptions {
46
71
  onWaiting?: (entry: Extract<TourEntry, {
47
72
  state: "waiting";
48
73
  }>) => void;
74
+ /** Told each time the origin answers as a restarting one does, so a run can
75
+ * say why it is still waiting. */
76
+ onUnavailable?: (error: unknown) => void;
77
+ /** How long the entry keeps being re-sent while the origin stays that way;
78
+ * tests shorten it. */
79
+ restartBoundMs?: number;
49
80
  }
50
81
  /** Enter and poll until seated.
51
82
  *
@@ -89,13 +120,33 @@ export interface TourPlayReport {
89
120
  outcome: string;
90
121
  committedActions: number;
91
122
  hands: number;
123
+ /** Whether this seat lost its last chip before the sitting ended. Kept
124
+ * separately from `outcome`, which is the table's ending rather than the
125
+ * seat's: a seat that busts on hand two and a seat that wins are both at the
126
+ * table when it reaches its terminal, and both have to be reported as
127
+ * having seen it. */
128
+ eliminated: boolean;
92
129
  }
93
130
  /** Drive a seated tour table to a terminal disposition.
94
131
  *
95
132
  * A lapsed turn is not an error: the authority acts for a silent seat on its
96
133
  * own clock, so a slow decision costs the hand rather than the match, and the
97
- * loop keeps reading. Only a refusal the server calls terminal stops it. */
134
+ * loop keeps reading. Only a refusal the server calls terminal stops it.
135
+ *
136
+ * **Being eliminated does not end the sitting.** This used to return the
137
+ * moment the seat lost its last chip, which reads as the natural thing to do —
138
+ * there is no turn coming — and is how a seat ends up reporting a result
139
+ * before the table has one and walking away from a settlement the whole table
140
+ * is waiting on. A busted seat keeps watching to the table's terminal, which
141
+ * the route answers as `complete` for an out seat too, and says it busted in
142
+ * the report instead. */
98
143
  export declare function playTour(client: TourClient, tableId: string, decide?: (turn: TurnView) => TableMove, options?: {
99
144
  pollMs?: number;
145
+ /** How long to keep watching before giving up, as `queueUntilSeated` does.
146
+ * A table that stops dealing without reaching a terminal would otherwise
147
+ * hold this loop for as long as the process lives. */
148
+ timeoutMs?: number;
100
149
  onTurn?: (turn: TurnView, move: TableMove) => void;
150
+ /** Called once, when this seat loses its last chip. */
151
+ onEliminated?: () => void;
101
152
  }): Promise<TourPlayReport>;
package/dist/tour.js CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { AGENT_HTTP_CAPABILITY_HEADER, mintAgentHttpCapability, } from "./agentHttp.js";
15
15
  import { textBytes } from "./bytes.js";
16
+ import { refusalMessage } from "./refusal.js";
16
17
  /** One signed Product API call. `target` is origin-form, exactly as the server
17
18
  * will reconstruct it - a leading-slash path plus any query string. */
18
19
  async function signedFetch(client, method, target, body) {
@@ -41,13 +42,41 @@ async function signedFetch(client, method, target, body) {
41
42
  json: text.length === 0 ? undefined : JSON.parse(text),
42
43
  };
43
44
  }
44
- export async function enterTour(client, tour) {
45
+ /** The origin answering as a restarting one does -- no answer at all, 408,
46
+ * 429 or a 5xx -- rather than the tour refusing the entry. A queue waits it
47
+ * out; every other refusal is the caller's to read. */
48
+ export class TourEntryUnavailable extends Error {
49
+ status;
50
+ constructor(status, message) {
51
+ super(message);
52
+ this.name = "TourEntryUnavailable";
53
+ this.status = status;
54
+ }
55
+ }
56
+ /** How long a queue keeps entering while the origin stays unavailable. Long
57
+ * enough to cover a product restart, which is what this is for. */
58
+ export const QUEUE_RESTART_BOUND_MS = 90_000;
59
+ function unavailable(error) {
60
+ /* A fetch that never reached an origin throws `TypeError`; a stopped product
61
+ behind a proxy answers 502, and one coming back answers 5xx or 429. */
62
+ return error instanceof TourEntryUnavailable || error instanceof TypeError;
63
+ }
64
+ export async function enterTour(client, tour, floor = {}) {
45
65
  const target = tour === "playground"
46
66
  ? "/open/v1/tours/playground/entries"
47
67
  : "/open/v1/tours/tournament/entries";
48
- const { status, json } = await signedFetch(client, "POST", target);
49
- if (status !== 200)
50
- throw new Error(`tour entry refused (${status}): ${JSON.stringify(json)}`);
68
+ if (floor.minAgents !== undefined && tour !== "playground")
69
+ throw new Error("a floor of real agents only applies to the playground");
70
+ /* No body at all when nothing is stated: the join every client sent before
71
+ floors existed, signed over the same empty bytes. */
72
+ const body = floor.minAgents === undefined ? undefined : { minAgents: floor.minAgents };
73
+ const { status, json } = await signedFetch(client, "POST", target, body);
74
+ if (status !== 200) {
75
+ const said = refusalMessage("tour entry", status, json);
76
+ if (status === 408 || status === 429 || status >= 500)
77
+ throw new TourEntryUnavailable(status, said);
78
+ throw new Error(said);
79
+ }
51
80
  return json;
52
81
  }
53
82
  /** Enter and poll until seated.
@@ -60,28 +89,50 @@ export async function queueUntilSeated(client, tour, options = {}) {
60
89
  const timeoutMs = options.timeoutMs ?? 300_000;
61
90
  const pollMs = options.pollMs ?? 3_000;
62
91
  const deadline = Date.now() + timeoutMs;
92
+ /* When the origin first went quiet on us, or null while it is answering. A
93
+ product restarted under a queued agent used to end its run on the proxy's
94
+ 502, which is a deployment rather than a refusal. */
95
+ let unavailableSince = null;
63
96
  for (;;) {
64
- const entry = await enterTour(client, tour);
97
+ let entry;
98
+ try {
99
+ entry = await enterTour(client, tour, { minAgents: options.minAgents });
100
+ unavailableSince = null;
101
+ }
102
+ catch (error) {
103
+ if (!unavailable(error))
104
+ throw error;
105
+ unavailableSince ??= Date.now();
106
+ const bound = options.restartBoundMs ?? QUEUE_RESTART_BOUND_MS;
107
+ if (Date.now() - unavailableSince >= bound || Date.now() >= deadline)
108
+ throw error;
109
+ options.onUnavailable?.(error);
110
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
111
+ continue;
112
+ }
65
113
  if (entry.state === "seated" || entry.state === "offered")
66
114
  return entry;
67
115
  if (entry.state !== "waiting")
68
116
  throw new Error(`tour is not open: ${JSON.stringify(entry)}`);
69
117
  options.onWaiting?.(entry);
70
118
  if (Date.now() >= deadline)
71
- throw new Error(`still waiting for a seat after ${timeoutMs} ms; the tour fills at ${entry.fillAtMs}`);
119
+ throw new Error(entry.atFill === "stays_queued"
120
+ ? `still waiting for a seat after ${timeoutMs} ms; no fill seats this agent until ` +
121
+ `${entry.minAgents ?? options.minAgents} real agents are queued`
122
+ : `still waiting for a seat after ${timeoutMs} ms; the tour fills at ${entry.fillAtMs}`);
72
123
  await new Promise((resolve) => setTimeout(resolve, pollMs));
73
124
  }
74
125
  }
75
126
  export async function readPosition(client, tableId) {
76
127
  const { status, json } = await signedFetch(client, "GET", `/open/v1/tables/${tableId}/decision`);
77
128
  if (status !== 200)
78
- throw new Error(`decision read refused (${status}): ${JSON.stringify(json)}`);
129
+ throw new Error(refusalMessage("decision read", status, json));
79
130
  return json;
80
131
  }
81
132
  export async function act(client, tableId, move) {
82
133
  const { status, json } = await signedFetch(client, "POST", `/open/v1/tables/${tableId}/actions`, move);
83
134
  if (status !== 200)
84
- throw new Error(`action refused (${status}): ${JSON.stringify(json)}`);
135
+ throw new Error(refusalMessage("action", status, json));
85
136
  return json;
86
137
  }
87
138
  /** The reference strategy: never wager, call only what is free. It exists so
@@ -98,15 +149,41 @@ export function foldHeavy(turn) {
98
149
  *
99
150
  * A lapsed turn is not an error: the authority acts for a silent seat on its
100
151
  * own clock, so a slow decision costs the hand rather than the match, and the
101
- * loop keeps reading. Only a refusal the server calls terminal stops it. */
152
+ * loop keeps reading. Only a refusal the server calls terminal stops it.
153
+ *
154
+ * **Being eliminated does not end the sitting.** This used to return the
155
+ * moment the seat lost its last chip, which reads as the natural thing to do —
156
+ * there is no turn coming — and is how a seat ends up reporting a result
157
+ * before the table has one and walking away from a settlement the whole table
158
+ * is waiting on. A busted seat keeps watching to the table's terminal, which
159
+ * the route answers as `complete` for an out seat too, and says it busted in
160
+ * the report instead. */
102
161
  export async function playTour(client, tableId, decide = foldHeavy, options = {}) {
103
162
  const pollMs = options.pollMs ?? 1_000;
163
+ const timeoutMs = options.timeoutMs ?? 300_000;
164
+ const deadline = Date.now() + timeoutMs;
104
165
  let committedActions = 0;
166
+ let eliminated = false;
105
167
  const hands = new Set();
106
168
  for (;;) {
169
+ if (Date.now() >= deadline)
170
+ throw new Error(`the table reached no terminal after ${timeoutMs} ms; it committed ${committedActions} action(s) over ${hands.size} hand(s)`);
107
171
  const turn = await readPosition(client, tableId);
108
- if (turn.state === "complete" || turn.state === "eliminated")
109
- return { outcome: turn.state, committedActions, hands: hands.size };
172
+ if (turn.state === "complete")
173
+ return {
174
+ outcome: turn.state,
175
+ committedActions,
176
+ hands: hands.size,
177
+ eliminated,
178
+ };
179
+ if (turn.state === "eliminated") {
180
+ if (!eliminated) {
181
+ eliminated = true;
182
+ options.onEliminated?.();
183
+ }
184
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
185
+ continue;
186
+ }
110
187
  if (turn.state !== "your_turn") {
111
188
  await new Promise((resolve) => setTimeout(resolve, pollMs));
112
189
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dopamint-fun/open-sdk",
3
- "version": "0.1.0-dev.0",
3
+ "version": "0.2.0-dev.0",
4
4
  "description": "DOPA-OPEN client SDK: generate and hold your own agent key, sign registrations, and play a Participant Session",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {