@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
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, PredictionGateConflictError, SessionBoundaryError, admitAuthorityEvent, decodeAuthorityMessage, freshSessionBoundary, 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 V3");
|
|
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,18 +597,33 @@ 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;
|
|
396
613
|
let lastCursor = { sequence: 0n, witnessedReceipt: null };
|
|
614
|
+
/* What this seat has accepted: the event sequence, the receipt floor, the
|
|
615
|
+
view it was last shown, and the named gate phase it holds. Every event
|
|
616
|
+
goes through it before anything is acknowledged, so the two doors -- this
|
|
617
|
+
loop and `openTurn` -- refuse the same streams for the same reasons. */
|
|
618
|
+
let accepted = freshSessionBoundary();
|
|
397
619
|
const inbox = [];
|
|
398
620
|
let joined = await client.join();
|
|
399
621
|
if (joined.context)
|
|
400
622
|
lastContext = joined.context;
|
|
623
|
+
const joinedToken = client.token;
|
|
624
|
+
if (!joinedToken)
|
|
625
|
+
throw new Error("join did not return a session token");
|
|
626
|
+
let originToken = joinedToken;
|
|
401
627
|
const handle = async (message) => {
|
|
402
628
|
if (message.type === "error") {
|
|
403
629
|
if (message.retryable)
|
|
@@ -413,11 +639,34 @@ export async function playSeat(args) {
|
|
|
413
639
|
if (message.type === "actionPending" ||
|
|
414
640
|
message.type === "actionAcknowledged")
|
|
415
641
|
return null;
|
|
642
|
+
if (message.type === "predictionGatePrepared" ||
|
|
643
|
+
message.type === "predictionGateOpened" ||
|
|
644
|
+
message.type === "predictionGateReleased") {
|
|
645
|
+
/* A named notice moves no receipt and carries no view: it is accepted,
|
|
646
|
+
acknowledged, and waited on. Only the real view that follows a
|
|
647
|
+
release is a turn to decide. */
|
|
648
|
+
const admitted = admitAuthorityEvent(accepted, message);
|
|
649
|
+
if (admitted.disposition !== "applied")
|
|
650
|
+
return null;
|
|
651
|
+
accepted = admitted.boundary;
|
|
652
|
+
lastContext = message.context;
|
|
653
|
+
await client.acknowledge(message.context, message.cursor, originToken);
|
|
654
|
+
lastCursor = message.cursor;
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
416
657
|
if ("context" in message)
|
|
417
658
|
lastContext = message.context;
|
|
418
659
|
if (message.type === "sessionTerminal") {
|
|
419
|
-
|
|
420
|
-
|
|
660
|
+
const admitted = admitAuthorityEvent(accepted, message);
|
|
661
|
+
/* A re-delivered terminal is this boundary's own last event - a resume
|
|
662
|
+
at the terminal cursor repeats it. There is nothing to apply and its
|
|
663
|
+
acknowledgement is spent, but the sitting is over either way and the
|
|
664
|
+
caller still needs what `consent` takes. */
|
|
665
|
+
if (admitted.disposition === "applied") {
|
|
666
|
+
accepted = admitted.boundary;
|
|
667
|
+
await client.acknowledge(message.context, message.cursor, originToken);
|
|
668
|
+
lastCursor = message.cursor;
|
|
669
|
+
}
|
|
421
670
|
return {
|
|
422
671
|
outcome: "terminal",
|
|
423
672
|
committedActions,
|
|
@@ -425,6 +674,7 @@ export async function playSeat(args) {
|
|
|
425
674
|
rejoins,
|
|
426
675
|
said,
|
|
427
676
|
saidRefused,
|
|
677
|
+
eliminated: elimination.eliminated,
|
|
428
678
|
terminalNonce: message.finalState.nonce.toString(),
|
|
429
679
|
terminalCommitment: toHex0x(message.finalState.commitment),
|
|
430
680
|
executionId: record.admission.execution_id,
|
|
@@ -433,33 +683,61 @@ export async function playSeat(args) {
|
|
|
433
683
|
}
|
|
434
684
|
let view = null;
|
|
435
685
|
let cursor = null;
|
|
436
|
-
if (message.type === "sessionJoined"
|
|
686
|
+
if (message.type === "sessionJoined" ||
|
|
687
|
+
message.type === "participantView" ||
|
|
688
|
+
message.type === "actionCommitted" ||
|
|
689
|
+
message.type === "actionRejected" ||
|
|
690
|
+
message.type === "sessionResumed") {
|
|
437
691
|
view = message.view;
|
|
438
692
|
cursor = message.cursor;
|
|
439
693
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
694
|
+
if (cursor !== null && lastContext) {
|
|
695
|
+
/* Accepted before anything else happens: a refusal here leaves the
|
|
696
|
+
retained boundary untouched and nothing acknowledged. */
|
|
697
|
+
const admitted = admitAuthorityEvent(accepted, message);
|
|
698
|
+
/* A re-delivery is the suffix a resume repeats. It was applied and
|
|
699
|
+
acknowledged once, so nothing here moves for it: no view, no pending
|
|
700
|
+
resolution, no commit count, no table read, no decision, no
|
|
701
|
+
acknowledgement. */
|
|
702
|
+
if (admitted.disposition !== "applied")
|
|
703
|
+
return null;
|
|
704
|
+
accepted = admitted.boundary;
|
|
705
|
+
/* Resolved only for the applied event, so a replayed commit cannot
|
|
706
|
+
count twice or drop a proposal that is still in flight. */
|
|
707
|
+
if (message.type === "actionCommitted") {
|
|
708
|
+
pending = null;
|
|
709
|
+
committedActions += 1;
|
|
710
|
+
}
|
|
711
|
+
else if (message.type === "actionRejected")
|
|
712
|
+
pending = null;
|
|
713
|
+
if (view &&
|
|
714
|
+
view.legalActions.length === 0 &&
|
|
715
|
+
!elimination.eliminated &&
|
|
716
|
+
Date.now() - lastIdleTableReadMs >= IDLE_TABLE_READ_MS) {
|
|
717
|
+
/* Nothing to decide: somebody else's turn, a hand this seat folded, or
|
|
718
|
+
a sitting this seat is out of. Read now and then, so the hand lines go
|
|
719
|
+
on and a seat that has lost its last chip says so once. */
|
|
720
|
+
lastIdleTableReadMs = Date.now();
|
|
721
|
+
const idle = await readPublicTable(fetchImpl, product, executionHex, names, tableCapability);
|
|
722
|
+
if (idle && idle.handNumber !== lastHandSeen) {
|
|
723
|
+
lastHandSeen = idle.handNumber;
|
|
724
|
+
args.onHand?.({
|
|
725
|
+
number: idle.handNumber,
|
|
726
|
+
stack: idle.seats.find((entry) => entry.seat === args.seat)?.stack ??
|
|
727
|
+
null,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
if (elimination.observe(idle))
|
|
731
|
+
args.onEliminated?.();
|
|
732
|
+
}
|
|
733
|
+
const nonceKey = view?.state.nonce.toString() ?? "";
|
|
461
734
|
const now = BigInt(Date.now());
|
|
462
|
-
if (
|
|
735
|
+
if (
|
|
736
|
+
/* A viewless admission is acknowledged and waited on: a held seat has
|
|
737
|
+
nothing to answer, and nothing to infer on, until its gate releases
|
|
738
|
+
and the real view arrives. */
|
|
739
|
+
view !== null &&
|
|
740
|
+
view.legalActions.length > 0 &&
|
|
463
741
|
!acted.has(nonceKey) &&
|
|
464
742
|
view.participantDeadlineMs > now) {
|
|
465
743
|
const legal = decodeLegalActions(view.legalActions);
|
|
@@ -467,7 +745,7 @@ export async function playSeat(args) {
|
|
|
467
745
|
played a seat wrote this poll itself; the SDK owns the transport, so
|
|
468
746
|
it owns this read too. Per turn, not per event: the table only
|
|
469
747
|
matters at the moment there is a decision to make. */
|
|
470
|
-
const table = await readPublicTable(fetchImpl, product, executionHex, names);
|
|
748
|
+
const table = await readPublicTable(fetchImpl, product, executionHex, names, tableCapability);
|
|
471
749
|
/* And what has been said in this hand, which the skill promised the
|
|
472
750
|
decision would see. Best-effort like the table: a seat that cannot
|
|
473
751
|
read the talk still has its own cards and the legal set. */
|
|
@@ -579,13 +857,15 @@ export async function playSeat(args) {
|
|
|
579
857
|
}
|
|
580
858
|
}
|
|
581
859
|
}
|
|
582
|
-
await client.acknowledge(lastContext, cursor);
|
|
860
|
+
await client.acknowledge(lastContext, cursor, originToken);
|
|
583
861
|
lastCursor = cursor;
|
|
584
862
|
if (!disconnectExerciseDone &&
|
|
585
863
|
args.disconnectAfterActions !== undefined &&
|
|
586
864
|
committedActions >= args.disconnectAfterActions) {
|
|
587
865
|
disconnectExerciseDone = true;
|
|
588
866
|
await client.resume(lastContext, lastCursor);
|
|
867
|
+
originToken = client.token ?? originToken;
|
|
868
|
+
inbox.length = 0;
|
|
589
869
|
reconnects += 1;
|
|
590
870
|
}
|
|
591
871
|
}
|
|
@@ -613,14 +893,27 @@ export async function playSeat(args) {
|
|
|
613
893
|
rejoins,
|
|
614
894
|
said,
|
|
615
895
|
saidRefused,
|
|
896
|
+
eliminated: elimination.eliminated,
|
|
616
897
|
executionId: record.admission.execution_id,
|
|
617
898
|
sessionBaseUrl: record.admission.session_base_url,
|
|
618
899
|
};
|
|
619
900
|
await client.resume(lastContext, lastCursor);
|
|
901
|
+
originToken = client.token ?? originToken;
|
|
620
902
|
reconnects += 1;
|
|
621
903
|
}
|
|
622
904
|
}
|
|
623
905
|
catch (error) {
|
|
906
|
+
/* A version this build cannot speak is never answered by resuming or
|
|
907
|
+
rejoining: the same refusal would come back, and a rejoin could even
|
|
908
|
+
look successful while the seat cannot read the gated stream. */
|
|
909
|
+
if (error instanceof UnsupportedSessionVersionError)
|
|
910
|
+
throw error;
|
|
911
|
+
/* Nor is a refused projection: the event the authority sent is one this
|
|
912
|
+
contract forbids, and resuming would fetch the same bytes again while
|
|
913
|
+
the loop reported it as flaky transport. */
|
|
914
|
+
if (error instanceof SessionBoundaryError ||
|
|
915
|
+
error instanceof PredictionGateConflictError)
|
|
916
|
+
throw error;
|
|
624
917
|
if (!lastContext || reconnects >= 5)
|
|
625
918
|
throw error;
|
|
626
919
|
/* Three refusals, three answers. A gone session (`UnknownSession`, or a
|
|
@@ -639,20 +932,32 @@ export async function playSeat(args) {
|
|
|
639
932
|
rejoins,
|
|
640
933
|
said,
|
|
641
934
|
saidRefused,
|
|
935
|
+
eliminated: elimination.eliminated,
|
|
642
936
|
executionId: record.admission.execution_id,
|
|
643
937
|
sessionBaseUrl: record.admission.session_base_url,
|
|
644
938
|
};
|
|
939
|
+
/* Whatever the old binding delivered and this loop had not handled yet
|
|
940
|
+
is delivered again from the cursor by the new one. */
|
|
941
|
+
inbox.length = 0;
|
|
645
942
|
if (answer === "rejoin") {
|
|
646
943
|
joined = await client.join();
|
|
944
|
+
originToken = client.token ?? originToken;
|
|
647
945
|
if (joined.context)
|
|
648
946
|
lastContext = joined.context;
|
|
649
947
|
lastCursor = { sequence: 0n, witnessedReceipt: null };
|
|
948
|
+
/* A rejoin is a new session: nothing the old one retained - its phase,
|
|
949
|
+
its floor, its sequence - describes this one, and only a boundary
|
|
950
|
+
this loop opened itself may be reset. The stale inbox belongs to
|
|
951
|
+
the session that is gone. */
|
|
952
|
+
accepted = freshSessionBoundary();
|
|
953
|
+
inbox.length = 0;
|
|
650
954
|
inbox.push(...joined.messages);
|
|
651
955
|
rejoins += 1;
|
|
652
956
|
reconnects += 1;
|
|
653
957
|
continue;
|
|
654
958
|
}
|
|
655
959
|
await client.resume(lastContext, lastCursor);
|
|
960
|
+
originToken = client.token ?? originToken;
|
|
656
961
|
reconnects += 1;
|
|
657
962
|
}
|
|
658
963
|
}
|
|
@@ -684,17 +989,96 @@ export function normaliseCardCode(code) {
|
|
|
684
989
|
const suit = trimmed.slice(-1).toLowerCase();
|
|
685
990
|
return `${rank}${suit}`;
|
|
686
991
|
}
|
|
687
|
-
/**
|
|
992
|
+
/** What the terminal disclosure says one seat takes off the table, read off
|
|
993
|
+
* the spectator snapshot's status.
|
|
994
|
+
*
|
|
995
|
+
* Read rather than assumed: consent checks the seat's chips against it before
|
|
996
|
+
* it signs, and a number a client invented would be refused there. The
|
|
997
|
+
* disclosure is published a moment after the seat sees the terminal, so this
|
|
998
|
+
* waits for it, bounded well inside the consent window.
|
|
999
|
+
*
|
|
1000
|
+
* Signed as the seat where a capability is given. A private room's snapshot
|
|
1001
|
+
* answers only its seats and owning wallets, so an anonymous read of it never
|
|
1002
|
+
* finds an entitlement at all, and a room whose seats read that way never
|
|
1003
|
+
* settles. A playground or tournament sitting ignores the capability. */
|
|
1004
|
+
export async function readDisclosedEntitlement(fetchImpl, product, executionId, seat, capability, options = {}) {
|
|
1005
|
+
const attempts = options.attempts ?? 15;
|
|
1006
|
+
const pauseMs = options.pauseMs ?? 1_000;
|
|
1007
|
+
const target = `/open/v1/spectator/executions/${executionId}`;
|
|
1008
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1009
|
+
try {
|
|
1010
|
+
const header = capability ? await capability("GET", target) : null;
|
|
1011
|
+
const response = await fetchImpl(`${product.replace(/\/$/, "")}${target}`, header === null
|
|
1012
|
+
? undefined
|
|
1013
|
+
: { headers: { [AGENT_HTTP_CAPABILITY_HEADER]: header } });
|
|
1014
|
+
if (response.ok) {
|
|
1015
|
+
const body = (await response.json());
|
|
1016
|
+
const chips = body.status?.entitlements?.find((entitlement) => entitlement.seat === seat)?.chips;
|
|
1017
|
+
if (chips !== undefined)
|
|
1018
|
+
return BigInt(chips);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
/* A read that did not answer is another attempt, not an end. */
|
|
1023
|
+
}
|
|
1024
|
+
await new Promise((resolve) => setTimeout(resolve, pauseMs));
|
|
1025
|
+
}
|
|
1026
|
+
return undefined;
|
|
1027
|
+
}
|
|
1028
|
+
/** Read a sitting's status off the public history surface.
|
|
688
1029
|
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
1030
|
+
* Its own read rather than part of `readPublicTable`, because that answers
|
|
1031
|
+
* what is on the table right now and returns nothing at all once the table is
|
|
1032
|
+
* gone -- which is precisely the moment this question is being asked. */
|
|
1033
|
+
export async function readSittingStatus(fetchImpl, product, executionHex) {
|
|
1034
|
+
try {
|
|
1035
|
+
const response = await fetchImpl(`${product}/open/v1/history/matches/${executionHex}`);
|
|
1036
|
+
if (!response.ok)
|
|
1037
|
+
return { state: "unknown" };
|
|
1038
|
+
const wire = (await response.json());
|
|
1039
|
+
switch (wire.status) {
|
|
1040
|
+
case "completed":
|
|
1041
|
+
case "settling":
|
|
1042
|
+
case "failed":
|
|
1043
|
+
return { state: "over", detail: wire.status };
|
|
1044
|
+
case "live":
|
|
1045
|
+
return { state: "live" };
|
|
1046
|
+
/* `unavailable` is the product saying it does not know, and a status
|
|
1047
|
+
this build has never heard of is the same thing. Neither is a reason
|
|
1048
|
+
to tell a seat its sitting ended. */
|
|
1049
|
+
default:
|
|
1050
|
+
return { state: "unknown" };
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
return { state: "unknown" };
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
/** The capability `readPublicTable` asks for, minted on this agent's own key.
|
|
692
1058
|
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
|
|
1059
|
+
* A private room's live view is owner-session-only unless the reader holds a
|
|
1060
|
+
* seat at that room, and the seat says so with the same signature every other
|
|
1061
|
+
* Product API call carries. A playground sitting ignores one, so a seat can
|
|
1062
|
+
* send it without having to know which kind of table it sat down at. */
|
|
1063
|
+
export function agentReadCapability(agent, agentId) {
|
|
1064
|
+
return async (method, requestTarget) => {
|
|
1065
|
+
const { header } = await mintAgentHttpCapability(agent, agentId, {
|
|
1066
|
+
method,
|
|
1067
|
+
requestTarget,
|
|
1068
|
+
body: new Uint8Array(),
|
|
1069
|
+
});
|
|
1070
|
+
return header;
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
export async function readPublicTable(fetchImpl, product, executionHex, names = new Map(), capability) {
|
|
1074
|
+
/* Bound to the target the server reconstructs from the request, which is the
|
|
1075
|
+
origin-form path and not the absolute URL the fetch is given. */
|
|
1076
|
+
const target = `/open/v1/spectator/executions/${executionHex}`;
|
|
696
1077
|
try {
|
|
697
|
-
const
|
|
1078
|
+
const header = capability ? await capability("GET", target) : null;
|
|
1079
|
+
const response = await fetchImpl(`${product}${target}`, header === null
|
|
1080
|
+
? undefined
|
|
1081
|
+
: { headers: { [AGENT_HTTP_CAPABILITY_HEADER]: header } });
|
|
698
1082
|
if (!response.ok)
|
|
699
1083
|
return null;
|
|
700
1084
|
const wire = (await response.json());
|