@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.
- 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 +866 -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 +11 -3
- package/dist/index.js +14 -3
- 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 +66 -0
- package/dist/room.js +154 -0
- package/dist/seatState.d.ts +91 -0
- package/dist/seatState.js +137 -0
- package/dist/seatTurn.d.ts +138 -0
- package/dist/seatTurn.js +442 -0
- package/dist/session.d.ts +158 -5
- package/dist/session.js +365 -25
- package/dist/sessionCodec.d.ts +6 -0
- package/dist/sessionCodec.js +34 -3
- package/dist/sessionWire.d.ts +20 -0
- package/dist/sessionWire.js +44 -0
- package/dist/tour.d.ts +55 -4
- package/dist/tour.js +88 -11
- package/package.json +1 -1
package/dist/session.js
CHANGED
|
@@ -3,8 +3,8 @@ import { blake2b256 } from "./crypto.js";
|
|
|
3
3
|
import { equalBytes, fromHex, textBytes, toHex0x } from "./bytes.js";
|
|
4
4
|
import { AGENT_HTTP_CAPABILITY_HEADER, mintAgentHttpCapability, } from "./agentHttp.js";
|
|
5
5
|
import { signRaw } from "./keypair.js";
|
|
6
|
-
import { decodeAuthorityMessage, decodeEnvelope, encodeAckFrame, encodeEnvelope, encodeSeatAuthSuccessFrame, sessionErrorHint, sessionErrorName, } from "./sessionCodec.js";
|
|
7
|
-
import { actionSigningBytes, encodeActionFrame, encodeJoinFrame, encodeResumeFrame, joinSigningBytes, resumeSigningBytes, } from "./sessionWire.js";
|
|
6
|
+
import { MAX_TRANSPORT_FRAME_BYTES, decodeAuthorityMessage, decodeEnvelope, encodeAckFrame, encodeEnvelope, encodeSeatAuthSuccessFrame, sessionErrorHint, sessionErrorName, } from "./sessionCodec.js";
|
|
7
|
+
import { actionSigningBytes, encodeActionFrame, encodeJoinFrame, encodeResumeFrame, joinSigningBytes, requireSessionVersion, resumeSigningBytes, SESSION_VERSION, UnsupportedSessionVersionError, } from "./sessionWire.js";
|
|
8
8
|
import { decodeLegalActions, decodeParticipantView, encodeAction, pickAction, } from "./texas.js";
|
|
9
9
|
import { authorizeSeatChallenge } from "./seatAuth.js";
|
|
10
10
|
const ACTION_IDENTITY_DOMAIN = textBytes("dopa_open::client::action_identity_v1");
|
|
@@ -26,8 +26,41 @@ export function playReportLines(report) {
|
|
|
26
26
|
lines.push(`rejoins ${report.rejoins}`);
|
|
27
27
|
if (report.said > 0 || report.saidRefused > 0)
|
|
28
28
|
lines.push(`said ${report.said} refused ${report.saidRefused}`);
|
|
29
|
+
lines.push(`eliminated ${report.eliminated}`);
|
|
29
30
|
return lines;
|
|
30
31
|
}
|
|
32
|
+
/** How often a seat with nothing to decide reads the public table.
|
|
33
|
+
*
|
|
34
|
+
* A seat that is out of the hand, or out of the sitting, is sent views with no
|
|
35
|
+
* legal actions for as long as the others play, and the table used to be read
|
|
36
|
+
* only when there was a decision to make -- so a busted seat read nothing and
|
|
37
|
+
* printed nothing for the rest of the sitting. Slow, because the read is the
|
|
38
|
+
* same one the anonymous cap counts. */
|
|
39
|
+
export const IDLE_TABLE_READ_MS = 5_000;
|
|
40
|
+
/** Watches the public table for this seat losing its last chip.
|
|
41
|
+
*
|
|
42
|
+
* Out means no chips and not all in: a seat all in with nothing behind is
|
|
43
|
+
* still in the hand, and only once the hand has settled against it does its
|
|
44
|
+
* stack read zero with no bet out. `observe` answers true exactly once, the
|
|
45
|
+
* first time it sees that, so the line it drives is printed once however many
|
|
46
|
+
* hands the others go on to play. */
|
|
47
|
+
export function eliminationWatch(seat) {
|
|
48
|
+
let out = false;
|
|
49
|
+
return {
|
|
50
|
+
get eliminated() {
|
|
51
|
+
return out;
|
|
52
|
+
},
|
|
53
|
+
observe(table) {
|
|
54
|
+
if (out || !table)
|
|
55
|
+
return false;
|
|
56
|
+
const mine = table.seats.find((entry) => entry.seat === seat);
|
|
57
|
+
if (!mine || mine.stack !== 0 || mine.allIn)
|
|
58
|
+
return false;
|
|
59
|
+
out = true;
|
|
60
|
+
return true;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
31
64
|
/* A session-protocol refusal, with the tag kept on it.
|
|
32
65
|
*
|
|
33
66
|
* `postWire` and the message loop used to throw a plain Error whose text named
|
|
@@ -50,6 +83,30 @@ export class SessionRefusal extends Error {
|
|
|
50
83
|
const UNKNOWN_SESSION = 6;
|
|
51
84
|
const INVALID_RESUME_CURSOR = 7;
|
|
52
85
|
const SESSION_SUPERSEDED = 10;
|
|
86
|
+
/** The shared binding's discovery read bound (`MAX_DISCOVERY_RESPONSE_BYTES`).
|
|
87
|
+
* A document larger than this is not a discovery document this client reads. */
|
|
88
|
+
const MAX_DISCOVERY_RESPONSE_BYTES = 4096;
|
|
89
|
+
/** How long a seat keeps asking for discovery from an origin that is not
|
|
90
|
+
* answering: long enough to outlast a deployment restarting behind it, short
|
|
91
|
+
* enough that a seat whose authority is truly gone still stops. */
|
|
92
|
+
export const DISCOVERY_RETRY_BOUND_MS = 90_000;
|
|
93
|
+
const DISCOVERY_RETRY_PAUSE_MS = 1_000;
|
|
94
|
+
/** Statuses an origin gives while it is coming back, rather than a verdict on
|
|
95
|
+
* whether it can host the session. */
|
|
96
|
+
function discoveryMayAnswerLater(status) {
|
|
97
|
+
return (status === 404 ||
|
|
98
|
+
status === 408 ||
|
|
99
|
+
status === 425 ||
|
|
100
|
+
status === 429 ||
|
|
101
|
+
status >= 500);
|
|
102
|
+
}
|
|
103
|
+
class SessionDiscoveryRefused extends Error {
|
|
104
|
+
status;
|
|
105
|
+
constructor(status) {
|
|
106
|
+
super(`session discovery failed (${status})`);
|
|
107
|
+
this.status = status;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
53
110
|
/** Whether this turn is still open at `nowMs`.
|
|
54
111
|
*
|
|
55
112
|
* A predicate rather than a comparison in the loop, for the same reason
|
|
@@ -93,7 +150,10 @@ export async function chooseSeatAction(legal, view, strategy, decide, context =
|
|
|
93
150
|
legal,
|
|
94
151
|
view,
|
|
95
152
|
seat: own.receivingSeat,
|
|
96
|
-
hole: own.holeCards
|
|
153
|
+
hole: own.holeCards
|
|
154
|
+
? [own.holeCards[0].label, own.holeCards[1].label]
|
|
155
|
+
: null,
|
|
156
|
+
holeCards: own.holeCards,
|
|
97
157
|
executionId: context.executionId,
|
|
98
158
|
table,
|
|
99
159
|
seats: table?.seats ?? [],
|
|
@@ -145,9 +205,91 @@ export class SessionClient {
|
|
|
145
205
|
this.clientNonce = clientNonce;
|
|
146
206
|
this.fetchImpl = fetchImpl;
|
|
147
207
|
}
|
|
208
|
+
/** How long discovery keeps asking an authority that is not answering.
|
|
209
|
+
*
|
|
210
|
+
* Public so a test can shorten it; nothing else should need to. */
|
|
211
|
+
discoveryRetry = {
|
|
212
|
+
boundMs: DISCOVERY_RETRY_BOUND_MS,
|
|
213
|
+
pauseMs: DISCOVERY_RETRY_PAUSE_MS,
|
|
214
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
215
|
+
};
|
|
148
216
|
url(path) {
|
|
149
217
|
return `${this.baseUrl.replace(/\/$/, "")}${path}`;
|
|
150
218
|
}
|
|
219
|
+
/** Requires the authority's published discovery to host the current session
|
|
220
|
+
* contract, before any challenge, signature, or session request.
|
|
221
|
+
*
|
|
222
|
+
* Discovery is a compatibility predicate over published facts, never an
|
|
223
|
+
* authenticity claim: the join handshake is still the admission decision.
|
|
224
|
+
* The body is read under the same bound the shared binding enforces, so a
|
|
225
|
+
* hostile pre-join answer cannot stream into this process. A document that
|
|
226
|
+
* names no compatible contract is final -- an authority that cannot host
|
|
227
|
+
* the session does not become able to by asking again.
|
|
228
|
+
*
|
|
229
|
+
* An origin that did not answer with a document is another matter, and is
|
|
230
|
+
* asked again within `discoveryRetry`. A seat reconnects at whatever moment
|
|
231
|
+
* its stream dropped, and a deployment restarting behind that origin
|
|
232
|
+
* answers 404, 502 or nothing for a few seconds. Taken as final, one such
|
|
233
|
+
* answer ended a seat mid-sitting while its table played on without it, and
|
|
234
|
+
* the clock folded every turn it had left. */
|
|
235
|
+
async requireCompatibleDiscovery() {
|
|
236
|
+
const response = await this.answeredDiscovery();
|
|
237
|
+
const document = JSON.parse(await this.readBoundedDiscovery(response));
|
|
238
|
+
const wires = document.wire_versions;
|
|
239
|
+
const sessions = document.session_versions;
|
|
240
|
+
const ceiling = document.max_transport_frame_bytes;
|
|
241
|
+
if (!Array.isArray(wires) ||
|
|
242
|
+
!Array.isArray(sessions) ||
|
|
243
|
+
!wires.includes(1) ||
|
|
244
|
+
!sessions.includes(SESSION_VERSION) ||
|
|
245
|
+
typeof ceiling !== "number" ||
|
|
246
|
+
!Number.isFinite(ceiling) ||
|
|
247
|
+
ceiling < MAX_TRANSPORT_FRAME_BYTES)
|
|
248
|
+
throw new Error("authority discovery does not support participant session V2");
|
|
249
|
+
}
|
|
250
|
+
async answeredDiscovery() {
|
|
251
|
+
const { boundMs, pauseMs, sleep } = this.discoveryRetry;
|
|
252
|
+
const until = Date.now() + boundMs;
|
|
253
|
+
for (;;) {
|
|
254
|
+
let refusal;
|
|
255
|
+
try {
|
|
256
|
+
const response = await this.fetchImpl(this.url("/v1/session/discovery"));
|
|
257
|
+
if (response.ok)
|
|
258
|
+
return response;
|
|
259
|
+
if (!discoveryMayAnswerLater(response.status))
|
|
260
|
+
throw new SessionDiscoveryRefused(response.status);
|
|
261
|
+
refusal = `session discovery failed (${response.status})`;
|
|
262
|
+
await response.body?.cancel();
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
if (error instanceof SessionDiscoveryRefused)
|
|
266
|
+
throw error;
|
|
267
|
+
refusal = `session discovery failed (${error instanceof Error ? error.message : String(error)})`;
|
|
268
|
+
}
|
|
269
|
+
if (Date.now() + pauseMs > until)
|
|
270
|
+
throw new Error(`${refusal}; still refused after ${boundMs} ms`);
|
|
271
|
+
await sleep(pauseMs);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async readBoundedDiscovery(response) {
|
|
275
|
+
const reader = response.body?.getReader();
|
|
276
|
+
if (!reader)
|
|
277
|
+
return "";
|
|
278
|
+
const chunks = [];
|
|
279
|
+
let total = 0;
|
|
280
|
+
for (;;) {
|
|
281
|
+
const { done, value } = await reader.read();
|
|
282
|
+
if (done)
|
|
283
|
+
break;
|
|
284
|
+
total += value.length;
|
|
285
|
+
if (total > MAX_DISCOVERY_RESPONSE_BYTES) {
|
|
286
|
+
await reader.cancel();
|
|
287
|
+
throw new Error("session discovery exceeds 4096 bytes");
|
|
288
|
+
}
|
|
289
|
+
chunks.push(value);
|
|
290
|
+
}
|
|
291
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
292
|
+
}
|
|
151
293
|
async challenge() {
|
|
152
294
|
const response = await this.fetchImpl(this.url("/v1/session/challenge"), {
|
|
153
295
|
method: "POST",
|
|
@@ -165,14 +307,15 @@ export class SessionClient {
|
|
|
165
307
|
throw new Error(`challenge must be 74 bytes, got ${bytes.length}`);
|
|
166
308
|
return new Uint8Array(bytes);
|
|
167
309
|
}
|
|
168
|
-
async postWire(path, wire, withToken) {
|
|
310
|
+
async postWire(path, wire, withToken, tokenOverride) {
|
|
169
311
|
const headers = {
|
|
170
312
|
"content-type": "application/json",
|
|
171
313
|
};
|
|
172
314
|
if (withToken) {
|
|
173
|
-
|
|
315
|
+
const token = tokenOverride ?? this.token;
|
|
316
|
+
if (!token)
|
|
174
317
|
throw new Error("session token missing");
|
|
175
|
-
headers["session-token"] =
|
|
318
|
+
headers["session-token"] = token;
|
|
176
319
|
}
|
|
177
320
|
let lastError = "";
|
|
178
321
|
let lastRefusal = null;
|
|
@@ -198,7 +341,11 @@ export class SessionClient {
|
|
|
198
341
|
}
|
|
199
342
|
}
|
|
200
343
|
}
|
|
201
|
-
catch {
|
|
344
|
+
catch (error) {
|
|
345
|
+
/* A selected version this build cannot speak is not a transport
|
|
346
|
+
flake: rethrow it rather than retrying the raw status. */
|
|
347
|
+
if (error instanceof UnsupportedSessionVersionError)
|
|
348
|
+
throw error;
|
|
202
349
|
/* keep raw body */
|
|
203
350
|
}
|
|
204
351
|
if (!retryable)
|
|
@@ -212,10 +359,11 @@ export class SessionClient {
|
|
|
212
359
|
: new Error(lastError);
|
|
213
360
|
}
|
|
214
361
|
async join() {
|
|
362
|
+
await this.requireCompatibleDiscovery();
|
|
215
363
|
const challenge = await this.challenge();
|
|
216
364
|
const request = {
|
|
217
365
|
wireVersion: 1,
|
|
218
|
-
supportedSessionVersions: [
|
|
366
|
+
supportedSessionVersions: [SESSION_VERSION],
|
|
219
367
|
executionId: this.executionId,
|
|
220
368
|
executionManifestDigest: this.executionManifestDigest,
|
|
221
369
|
participantId: this.participantId,
|
|
@@ -236,6 +384,7 @@ export class SessionClient {
|
|
|
236
384
|
throw new Error("join did not return a session token");
|
|
237
385
|
this.token = body.token;
|
|
238
386
|
}
|
|
387
|
+
this.eventsAfter = 0n;
|
|
239
388
|
const batch = await this.pollEvents();
|
|
240
389
|
if (!batch.messages.some((message) => message.type === "sessionJoined"))
|
|
241
390
|
throw new Error("join did not deliver SessionJoined");
|
|
@@ -302,12 +451,17 @@ export class SessionClient {
|
|
|
302
451
|
startSubmit(wire) {
|
|
303
452
|
return this.postWire("/v1/session/actions", wire, true);
|
|
304
453
|
}
|
|
305
|
-
async acknowledge(context, cursor) {
|
|
306
|
-
const response = await this.postWire("/v1/session/acknowledgements", encodeAckFrame(context, cursor), true);
|
|
454
|
+
async acknowledge(context, cursor, originToken) {
|
|
455
|
+
const response = await this.postWire("/v1/session/acknowledgements", encodeAckFrame(context, cursor), true, originToken);
|
|
307
456
|
if (!response.ok)
|
|
308
457
|
throw new Error(`ack failed (${response.status}): ${await response.text()}`);
|
|
309
458
|
}
|
|
310
459
|
async resume(context, cursor) {
|
|
460
|
+
/* A retained context from another session version is not resumable: its
|
|
461
|
+
cursor and token belong to a contract this build does not speak. This
|
|
462
|
+
runs first so no challenge, signature, or request is spent on it. */
|
|
463
|
+
requireSessionVersion(context.sessionVersion);
|
|
464
|
+
await this.requireCompatibleDiscovery();
|
|
311
465
|
const challenge = await this.challenge();
|
|
312
466
|
const request = {
|
|
313
467
|
context,
|
|
@@ -323,6 +477,13 @@ export class SessionClient {
|
|
|
323
477
|
const headerToken = response.headers.get("session-token");
|
|
324
478
|
if (headerToken)
|
|
325
479
|
this.token = headerToken;
|
|
480
|
+
/* A resumed binding delivers from a buffer of its own, indexed from zero;
|
|
481
|
+
`after` is an index into that buffer, not the stream position the last
|
|
482
|
+
binding reached. Kept, it asked a fresh buffer for everything past the
|
|
483
|
+
old index, so every poll came back empty while the authority's queue for
|
|
484
|
+
this seat filled and closed: a seat that resumed once, late in a
|
|
485
|
+
sitting, never saw another turn. */
|
|
486
|
+
this.eventsAfter = 0n;
|
|
326
487
|
}
|
|
327
488
|
async answerSeatAuth(challenge, coordinatorKey, timeAuthorityKey, pending) {
|
|
328
489
|
const preimage = authorizeSeatChallenge({
|
|
@@ -340,10 +501,40 @@ export class SessionClient {
|
|
|
340
501
|
throw new Error(`seat-authorization failed (${response.status}): ${await response.text()}`);
|
|
341
502
|
}
|
|
342
503
|
}
|
|
343
|
-
|
|
504
|
+
/** A GET asked again while the origin answers as a restarting one does -- no
|
|
505
|
+
* answer, 408, 429 or a 5xx -- until `bound` has passed. Any other answer is
|
|
506
|
+
* returned as it came, so a refusal stays the caller's to read. */
|
|
507
|
+
export async function fetchWhileRestarting(fetchImpl, url, retry = {
|
|
508
|
+
boundMs: DISCOVERY_RETRY_BOUND_MS,
|
|
509
|
+
pauseMs: DISCOVERY_RETRY_PAUSE_MS,
|
|
510
|
+
}) {
|
|
511
|
+
const sleep = retry.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
512
|
+
const until = Date.now() + retry.boundMs;
|
|
513
|
+
for (;;) {
|
|
514
|
+
const lastChance = Date.now() + retry.pauseMs > until;
|
|
515
|
+
try {
|
|
516
|
+
const response = await fetchImpl(url);
|
|
517
|
+
const restarting = response.status === 408 ||
|
|
518
|
+
response.status === 429 ||
|
|
519
|
+
response.status >= 500;
|
|
520
|
+
if (!restarting || lastChance)
|
|
521
|
+
return response;
|
|
522
|
+
await response.body?.cancel();
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
if (lastChance)
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
await sleep(retry.pauseMs);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
export async function openSeatSession(args) {
|
|
344
532
|
const fetchImpl = args.fetchImpl ?? fetch;
|
|
345
533
|
const product = args.productUrl.replace(/\/$/, "");
|
|
346
|
-
|
|
534
|
+
/* Read again while the product is coming back: a seat reopens its session
|
|
535
|
+
after every dropped stream, and the product answering 502 for the seconds
|
|
536
|
+
it restarts ended the seat as surely as a refusal would have. */
|
|
537
|
+
const offer = await fetchWhileRestarting(fetchImpl, `${product}/open/v1/playground/matches/${args.offerId}`, args.restartRetry);
|
|
347
538
|
if (!offer.ok)
|
|
348
539
|
throw new Error(`offer read failed (${offer.status}): ${await offer.text()}`);
|
|
349
540
|
const record = (await offer.json());
|
|
@@ -374,9 +565,29 @@ export async function playSeat(args) {
|
|
|
374
565
|
throw new Error("the admission names no time-authority key; pass timeAuthorityKey");
|
|
375
566
|
const executionHex = record.admission.execution_id.replace(/^0x/i, "");
|
|
376
567
|
const executionId = `0x${executionHex}`;
|
|
377
|
-
const nonce = new Uint8Array(randomBytes(32));
|
|
378
|
-
|
|
568
|
+
const nonce = args.clientNonce ?? new Uint8Array(randomBytes(32));
|
|
569
|
+
if (!args.clientNonce)
|
|
570
|
+
nonce[0] = args.seat & 0xff;
|
|
379
571
|
const client = new SessionClient(record.admission.session_base_url, args.agent, args.seat, args.agentId, fromHex(record.admission.execution_id), fromHex(record.admission.manifest_digest), nonce, fetchImpl);
|
|
572
|
+
return {
|
|
573
|
+
client,
|
|
574
|
+
product,
|
|
575
|
+
executionHex,
|
|
576
|
+
coordinatorKey,
|
|
577
|
+
timeAuthorityKey,
|
|
578
|
+
sessionBaseUrl: record.admission.session_base_url,
|
|
579
|
+
executionId,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
export async function playSeat(args) {
|
|
583
|
+
const fetchImpl = args.fetchImpl ?? fetch;
|
|
584
|
+
const { client, product, executionHex, coordinatorKey, timeAuthorityKey, executionId, } = await openSeatSession(args);
|
|
585
|
+
const record = {
|
|
586
|
+
admission: {
|
|
587
|
+
execution_id: executionId,
|
|
588
|
+
session_base_url: client.baseUrl,
|
|
589
|
+
},
|
|
590
|
+
};
|
|
380
591
|
const strategy = args.strategy ?? "fold-heavy";
|
|
381
592
|
const decide = args.decide;
|
|
382
593
|
const acted = new Set();
|
|
@@ -386,10 +597,16 @@ export async function playSeat(args) {
|
|
|
386
597
|
let said = 0;
|
|
387
598
|
let saidRefused = 0;
|
|
388
599
|
let lastHandSeen = null;
|
|
600
|
+
const elimination = eliminationWatch(args.seat);
|
|
601
|
+
let lastIdleTableReadMs = 0;
|
|
389
602
|
/* Who the other seats are, read once per agent for the whole sitting: a
|
|
390
603
|
name does not change mid-match, and a read per turn would be a read per
|
|
391
604
|
turn per seat. */
|
|
392
605
|
const names = new Map();
|
|
606
|
+
/* The seat's own key on the table read. Without it a seat in a private room
|
|
607
|
+
is just another stranger at the door, and the decision below would be made
|
|
608
|
+
against a null table while the agent was sitting at it. */
|
|
609
|
+
const tableCapability = agentReadCapability(args.agent, args.agentId);
|
|
393
610
|
let disconnectExerciseDone = false;
|
|
394
611
|
let pending = null;
|
|
395
612
|
let lastContext = null;
|
|
@@ -398,6 +615,10 @@ export async function playSeat(args) {
|
|
|
398
615
|
let joined = await client.join();
|
|
399
616
|
if (joined.context)
|
|
400
617
|
lastContext = joined.context;
|
|
618
|
+
const joinedToken = client.token;
|
|
619
|
+
if (!joinedToken)
|
|
620
|
+
throw new Error("join did not return a session token");
|
|
621
|
+
let originToken = joinedToken;
|
|
401
622
|
const handle = async (message) => {
|
|
402
623
|
if (message.type === "error") {
|
|
403
624
|
if (message.retryable)
|
|
@@ -413,10 +634,15 @@ export async function playSeat(args) {
|
|
|
413
634
|
if (message.type === "actionPending" ||
|
|
414
635
|
message.type === "actionAcknowledged")
|
|
415
636
|
return null;
|
|
637
|
+
if (message.type === "predictionGateReleased") {
|
|
638
|
+
await client.acknowledge(message.context, message.cursor, originToken);
|
|
639
|
+
lastCursor = message.cursor;
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
416
642
|
if ("context" in message)
|
|
417
643
|
lastContext = message.context;
|
|
418
644
|
if (message.type === "sessionTerminal") {
|
|
419
|
-
await client.acknowledge(message.context, message.cursor);
|
|
645
|
+
await client.acknowledge(message.context, message.cursor, originToken);
|
|
420
646
|
lastCursor = message.cursor;
|
|
421
647
|
return {
|
|
422
648
|
outcome: "terminal",
|
|
@@ -425,6 +651,7 @@ export async function playSeat(args) {
|
|
|
425
651
|
rejoins,
|
|
426
652
|
said,
|
|
427
653
|
saidRefused,
|
|
654
|
+
eliminated: elimination.eliminated,
|
|
428
655
|
terminalNonce: message.finalState.nonce.toString(),
|
|
429
656
|
terminalCommitment: toHex0x(message.finalState.commitment),
|
|
430
657
|
executionId: record.admission.execution_id,
|
|
@@ -456,6 +683,25 @@ export async function playSeat(args) {
|
|
|
456
683
|
view = message.view;
|
|
457
684
|
cursor = message.cursor;
|
|
458
685
|
}
|
|
686
|
+
if (view &&
|
|
687
|
+
view.legalActions.length === 0 &&
|
|
688
|
+
!elimination.eliminated &&
|
|
689
|
+
Date.now() - lastIdleTableReadMs >= IDLE_TABLE_READ_MS) {
|
|
690
|
+
/* Nothing to decide: somebody else's turn, a hand this seat folded, or
|
|
691
|
+
a sitting this seat is out of. Read now and then, so the hand lines go
|
|
692
|
+
on and a seat that has lost its last chip says so once. */
|
|
693
|
+
lastIdleTableReadMs = Date.now();
|
|
694
|
+
const idle = await readPublicTable(fetchImpl, product, executionHex, names, tableCapability);
|
|
695
|
+
if (idle && idle.handNumber !== lastHandSeen) {
|
|
696
|
+
lastHandSeen = idle.handNumber;
|
|
697
|
+
args.onHand?.({
|
|
698
|
+
number: idle.handNumber,
|
|
699
|
+
stack: idle.seats.find((entry) => entry.seat === args.seat)?.stack ?? null,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
if (elimination.observe(idle))
|
|
703
|
+
args.onEliminated?.();
|
|
704
|
+
}
|
|
459
705
|
if (view && cursor !== null && lastContext) {
|
|
460
706
|
const nonceKey = view.state.nonce.toString();
|
|
461
707
|
const now = BigInt(Date.now());
|
|
@@ -467,7 +713,7 @@ export async function playSeat(args) {
|
|
|
467
713
|
played a seat wrote this poll itself; the SDK owns the transport, so
|
|
468
714
|
it owns this read too. Per turn, not per event: the table only
|
|
469
715
|
matters at the moment there is a decision to make. */
|
|
470
|
-
const table = await readPublicTable(fetchImpl, product, executionHex, names);
|
|
716
|
+
const table = await readPublicTable(fetchImpl, product, executionHex, names, tableCapability);
|
|
471
717
|
/* And what has been said in this hand, which the skill promised the
|
|
472
718
|
decision would see. Best-effort like the table: a seat that cannot
|
|
473
719
|
read the talk still has its own cards and the legal set. */
|
|
@@ -579,13 +825,15 @@ export async function playSeat(args) {
|
|
|
579
825
|
}
|
|
580
826
|
}
|
|
581
827
|
}
|
|
582
|
-
await client.acknowledge(lastContext, cursor);
|
|
828
|
+
await client.acknowledge(lastContext, cursor, originToken);
|
|
583
829
|
lastCursor = cursor;
|
|
584
830
|
if (!disconnectExerciseDone &&
|
|
585
831
|
args.disconnectAfterActions !== undefined &&
|
|
586
832
|
committedActions >= args.disconnectAfterActions) {
|
|
587
833
|
disconnectExerciseDone = true;
|
|
588
834
|
await client.resume(lastContext, lastCursor);
|
|
835
|
+
originToken = client.token ?? originToken;
|
|
836
|
+
inbox.length = 0;
|
|
589
837
|
reconnects += 1;
|
|
590
838
|
}
|
|
591
839
|
}
|
|
@@ -613,14 +861,21 @@ export async function playSeat(args) {
|
|
|
613
861
|
rejoins,
|
|
614
862
|
said,
|
|
615
863
|
saidRefused,
|
|
864
|
+
eliminated: elimination.eliminated,
|
|
616
865
|
executionId: record.admission.execution_id,
|
|
617
866
|
sessionBaseUrl: record.admission.session_base_url,
|
|
618
867
|
};
|
|
619
868
|
await client.resume(lastContext, lastCursor);
|
|
869
|
+
originToken = client.token ?? originToken;
|
|
620
870
|
reconnects += 1;
|
|
621
871
|
}
|
|
622
872
|
}
|
|
623
873
|
catch (error) {
|
|
874
|
+
/* A version this build cannot speak is never answered by resuming or
|
|
875
|
+
rejoining: the same refusal would come back, and a rejoin could even
|
|
876
|
+
look successful while the seat cannot read the gated stream. */
|
|
877
|
+
if (error instanceof UnsupportedSessionVersionError)
|
|
878
|
+
throw error;
|
|
624
879
|
if (!lastContext || reconnects >= 5)
|
|
625
880
|
throw error;
|
|
626
881
|
/* Three refusals, three answers. A gone session (`UnknownSession`, or a
|
|
@@ -639,11 +894,16 @@ export async function playSeat(args) {
|
|
|
639
894
|
rejoins,
|
|
640
895
|
said,
|
|
641
896
|
saidRefused,
|
|
897
|
+
eliminated: elimination.eliminated,
|
|
642
898
|
executionId: record.admission.execution_id,
|
|
643
899
|
sessionBaseUrl: record.admission.session_base_url,
|
|
644
900
|
};
|
|
901
|
+
/* Whatever the old binding delivered and this loop had not handled yet
|
|
902
|
+
is delivered again from the cursor by the new one. */
|
|
903
|
+
inbox.length = 0;
|
|
645
904
|
if (answer === "rejoin") {
|
|
646
905
|
joined = await client.join();
|
|
906
|
+
originToken = client.token ?? originToken;
|
|
647
907
|
if (joined.context)
|
|
648
908
|
lastContext = joined.context;
|
|
649
909
|
lastCursor = { sequence: 0n, witnessedReceipt: null };
|
|
@@ -653,6 +913,7 @@ export async function playSeat(args) {
|
|
|
653
913
|
continue;
|
|
654
914
|
}
|
|
655
915
|
await client.resume(lastContext, lastCursor);
|
|
916
|
+
originToken = client.token ?? originToken;
|
|
656
917
|
reconnects += 1;
|
|
657
918
|
}
|
|
658
919
|
}
|
|
@@ -684,17 +945,96 @@ export function normaliseCardCode(code) {
|
|
|
684
945
|
const suit = trimmed.slice(-1).toLowerCase();
|
|
685
946
|
return `${rank}${suit}`;
|
|
686
947
|
}
|
|
687
|
-
/**
|
|
948
|
+
/** What the terminal disclosure says one seat takes off the table, read off
|
|
949
|
+
* the spectator snapshot's status.
|
|
688
950
|
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
951
|
+
* Read rather than assumed: consent checks the seat's chips against it before
|
|
952
|
+
* it signs, and a number a client invented would be refused there. The
|
|
953
|
+
* disclosure is published a moment after the seat sees the terminal, so this
|
|
954
|
+
* waits for it, bounded well inside the consent window.
|
|
692
955
|
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
|
|
956
|
+
* Signed as the seat where a capability is given. A private room's snapshot
|
|
957
|
+
* answers only its seats and owning wallets, so an anonymous read of it never
|
|
958
|
+
* finds an entitlement at all, and a room whose seats read that way never
|
|
959
|
+
* settles. A playground or tournament sitting ignores the capability. */
|
|
960
|
+
export async function readDisclosedEntitlement(fetchImpl, product, executionId, seat, capability, options = {}) {
|
|
961
|
+
const attempts = options.attempts ?? 15;
|
|
962
|
+
const pauseMs = options.pauseMs ?? 1_000;
|
|
963
|
+
const target = `/open/v1/spectator/executions/${executionId}`;
|
|
964
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
965
|
+
try {
|
|
966
|
+
const header = capability ? await capability("GET", target) : null;
|
|
967
|
+
const response = await fetchImpl(`${product.replace(/\/$/, "")}${target}`, header === null
|
|
968
|
+
? undefined
|
|
969
|
+
: { headers: { [AGENT_HTTP_CAPABILITY_HEADER]: header } });
|
|
970
|
+
if (response.ok) {
|
|
971
|
+
const body = (await response.json());
|
|
972
|
+
const chips = body.status?.entitlements?.find((entitlement) => entitlement.seat === seat)?.chips;
|
|
973
|
+
if (chips !== undefined)
|
|
974
|
+
return BigInt(chips);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
catch {
|
|
978
|
+
/* A read that did not answer is another attempt, not an end. */
|
|
979
|
+
}
|
|
980
|
+
await new Promise((resolve) => setTimeout(resolve, pauseMs));
|
|
981
|
+
}
|
|
982
|
+
return undefined;
|
|
983
|
+
}
|
|
984
|
+
/** Read a sitting's status off the public history surface.
|
|
985
|
+
*
|
|
986
|
+
* Its own read rather than part of `readPublicTable`, because that answers
|
|
987
|
+
* what is on the table right now and returns nothing at all once the table is
|
|
988
|
+
* gone -- which is precisely the moment this question is being asked. */
|
|
989
|
+
export async function readSittingStatus(fetchImpl, product, executionHex) {
|
|
990
|
+
try {
|
|
991
|
+
const response = await fetchImpl(`${product}/open/v1/history/matches/${executionHex}`);
|
|
992
|
+
if (!response.ok)
|
|
993
|
+
return { state: "unknown" };
|
|
994
|
+
const wire = (await response.json());
|
|
995
|
+
switch (wire.status) {
|
|
996
|
+
case "completed":
|
|
997
|
+
case "settling":
|
|
998
|
+
case "failed":
|
|
999
|
+
return { state: "over", detail: wire.status };
|
|
1000
|
+
case "live":
|
|
1001
|
+
return { state: "live" };
|
|
1002
|
+
/* `unavailable` is the product saying it does not know, and a status
|
|
1003
|
+
this build has never heard of is the same thing. Neither is a reason
|
|
1004
|
+
to tell a seat its sitting ended. */
|
|
1005
|
+
default:
|
|
1006
|
+
return { state: "unknown" };
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
catch {
|
|
1010
|
+
return { state: "unknown" };
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
/** The capability `readPublicTable` asks for, minted on this agent's own key.
|
|
1014
|
+
*
|
|
1015
|
+
* A private room's live view is owner-session-only unless the reader holds a
|
|
1016
|
+
* seat at that room, and the seat says so with the same signature every other
|
|
1017
|
+
* Product API call carries. A playground sitting ignores one, so a seat can
|
|
1018
|
+
* send it without having to know which kind of table it sat down at. */
|
|
1019
|
+
export function agentReadCapability(agent, agentId) {
|
|
1020
|
+
return async (method, requestTarget) => {
|
|
1021
|
+
const { header } = await mintAgentHttpCapability(agent, agentId, {
|
|
1022
|
+
method,
|
|
1023
|
+
requestTarget,
|
|
1024
|
+
body: new Uint8Array(),
|
|
1025
|
+
});
|
|
1026
|
+
return header;
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
export async function readPublicTable(fetchImpl, product, executionHex, names = new Map(), capability) {
|
|
1030
|
+
/* Bound to the target the server reconstructs from the request, which is the
|
|
1031
|
+
origin-form path and not the absolute URL the fetch is given. */
|
|
1032
|
+
const target = `/open/v1/spectator/executions/${executionHex}`;
|
|
696
1033
|
try {
|
|
697
|
-
const
|
|
1034
|
+
const header = capability ? await capability("GET", target) : null;
|
|
1035
|
+
const response = await fetchImpl(`${product}${target}`, header === null
|
|
1036
|
+
? undefined
|
|
1037
|
+
: { headers: { [AGENT_HTTP_CAPABILITY_HEADER]: header } });
|
|
698
1038
|
if (!response.ok)
|
|
699
1039
|
return null;
|
|
700
1040
|
const wire = (await response.json());
|
package/dist/sessionCodec.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export declare const SESSION_TERMINAL_TAG = 12;
|
|
|
10
10
|
export declare const SESSION_ERROR_TAG = 13;
|
|
11
11
|
export declare const SEAT_AUTHORIZATION_CHALLENGE_TAG = 15;
|
|
12
12
|
export declare const SEAT_AUTHORIZATION_RESPONSE_TAG = 16;
|
|
13
|
+
export declare const PREDICTION_GATE_RELEASED_TAG = 17;
|
|
13
14
|
export declare const MAX_TRANSPORT_FRAME_BYTES: number;
|
|
14
15
|
export interface WireEnvelope {
|
|
15
16
|
message: string;
|
|
@@ -107,6 +108,11 @@ export type AuthorityMessage = {
|
|
|
107
108
|
finalState: StateRef;
|
|
108
109
|
cursor: ResumeCursor;
|
|
109
110
|
finalReceipt: ReceiptRef | null;
|
|
111
|
+
} | {
|
|
112
|
+
type: "predictionGateReleased";
|
|
113
|
+
context: SessionContext;
|
|
114
|
+
sequence: bigint;
|
|
115
|
+
cursor: ResumeCursor;
|
|
110
116
|
} | {
|
|
111
117
|
type: "error";
|
|
112
118
|
retryable: boolean;
|