@dopamint-fun/open-sdk 0.1.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 +42 -0
- package/dist/acceptance.d.ts +24 -0
- package/dist/acceptance.js +48 -0
- package/dist/agentHttp.d.ts +52 -0
- package/dist/agentHttp.js +139 -0
- package/dist/bytes.d.ts +40 -0
- package/dist/bytes.js +166 -0
- package/dist/channel.d.ts +38 -0
- package/dist/channel.js +86 -0
- package/dist/claim.d.ts +39 -0
- package/dist/claim.js +91 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1006 -0
- package/dist/crypto.d.ts +3 -0
- package/dist/crypto.js +20 -0
- package/dist/identity.d.ts +32 -0
- package/dist/identity.js +100 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +22 -0
- package/dist/keypair.d.ts +25 -0
- package/dist/keypair.js +69 -0
- package/dist/offer.d.ts +36 -0
- package/dist/offer.js +138 -0
- package/dist/registration.d.ts +53 -0
- package/dist/registration.js +104 -0
- package/dist/seatAuth.d.ts +30 -0
- package/dist/seatAuth.js +285 -0
- package/dist/session.d.ts +260 -0
- package/dist/session.js +840 -0
- package/dist/sessionCodec.d.ts +139 -0
- package/dist/sessionCodec.js +373 -0
- package/dist/sessionWire.d.ts +80 -0
- package/dist/sessionWire.js +118 -0
- package/dist/settlement.d.ts +56 -0
- package/dist/settlement.js +117 -0
- package/dist/texas.d.ts +52 -0
- package/dist/texas.js +217 -0
- package/dist/tour.d.ts +101 -0
- package/dist/tour.js +129 -0
- package/package.json +35 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { blake2b256 } from "./crypto.js";
|
|
3
|
+
import { equalBytes, fromHex, textBytes, toHex0x } from "./bytes.js";
|
|
4
|
+
import { AGENT_HTTP_CAPABILITY_HEADER, mintAgentHttpCapability, } from "./agentHttp.js";
|
|
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";
|
|
8
|
+
import { decodeLegalActions, decodeParticipantView, encodeAction, pickAction, } from "./texas.js";
|
|
9
|
+
import { authorizeSeatChallenge } from "./seatAuth.js";
|
|
10
|
+
const ACTION_IDENTITY_DOMAIN = textBytes("dopa_open::client::action_identity_v1");
|
|
11
|
+
/** The lines a play door reports for one seat's sitting.
|
|
12
|
+
*
|
|
13
|
+
* One function because there are two doors. `play` and `queue --play` each
|
|
14
|
+
* printed their own copy, and the copy in `queue` - the door the playground
|
|
15
|
+
* document recommends - omitted the terminal values `consent` requires, so
|
|
16
|
+
* the recommended path could not reach settlement. */
|
|
17
|
+
export function playReportLines(report) {
|
|
18
|
+
const lines = [`outcome ${report.outcome}`];
|
|
19
|
+
if (report.terminalNonce !== undefined) {
|
|
20
|
+
lines.push(`terminal_nonce ${report.terminalNonce}`);
|
|
21
|
+
lines.push(`terminal_commitment ${report.terminalCommitment}`);
|
|
22
|
+
}
|
|
23
|
+
lines.push(`committed_actions ${report.committedActions}`);
|
|
24
|
+
lines.push(`reconnects ${report.reconnects}`);
|
|
25
|
+
if (report.rejoins > 0)
|
|
26
|
+
lines.push(`rejoins ${report.rejoins}`);
|
|
27
|
+
if (report.said > 0 || report.saidRefused > 0)
|
|
28
|
+
lines.push(`said ${report.said} refused ${report.saidRefused}`);
|
|
29
|
+
return lines;
|
|
30
|
+
}
|
|
31
|
+
/* A session-protocol refusal, with the tag kept on it.
|
|
32
|
+
*
|
|
33
|
+
* `postWire` and the message loop used to throw a plain Error whose text named
|
|
34
|
+
* the tag; nothing downstream could act on it, so every refusal was answered
|
|
35
|
+
* the same way -- resume, five times, then give up. Two of the tags mean the
|
|
36
|
+
* session is gone (`UnknownSession`, `InvalidResumeCursor`) and one means
|
|
37
|
+
* another client holds the seat (`SessionSuperseded`); a resume can never
|
|
38
|
+
* answer any of them, and the loop needs to know which it met. */
|
|
39
|
+
export class SessionRefusal extends Error {
|
|
40
|
+
tag;
|
|
41
|
+
retryable;
|
|
42
|
+
constructor(refusal, detail) {
|
|
43
|
+
super(detail ?? describeSessionError(refusal));
|
|
44
|
+
this.name = "SessionRefusal";
|
|
45
|
+
this.tag = refusal.tag;
|
|
46
|
+
this.retryable = refusal.retryable;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** `SessionProtocolError` wire tags the loop acts on by name. */
|
|
50
|
+
const UNKNOWN_SESSION = 6;
|
|
51
|
+
const INVALID_RESUME_CURSOR = 7;
|
|
52
|
+
const SESSION_SUPERSEDED = 10;
|
|
53
|
+
/** Whether this turn is still open at `nowMs`.
|
|
54
|
+
*
|
|
55
|
+
* A predicate rather than a comparison in the loop, for the same reason
|
|
56
|
+
* `chooseSeatAction` is: reaching that point inside `playSeat` means answering
|
|
57
|
+
* a signed seat-auth challenge, and a test that forged one would be proving
|
|
58
|
+
* its own copy of the authority. The rule is small enough to state and check
|
|
59
|
+
* on its own -- and the failure it guards is a move submitted for a turn that
|
|
60
|
+
* has ended, which the product's own documentation tells agents not to do. */
|
|
61
|
+
export function turnIsStillOpen(view, nowMs) {
|
|
62
|
+
return view.participantDeadlineMs > BigInt(nowMs);
|
|
63
|
+
}
|
|
64
|
+
/** One line a person can act on: the name, the tag, and what to do. */
|
|
65
|
+
function describeSessionError(error) {
|
|
66
|
+
const name = error.name ?? sessionErrorName(error.tag);
|
|
67
|
+
const hint = sessionErrorHint(error.tag);
|
|
68
|
+
return `session error ${name} (tag ${error.tag})${hint ? `: ${hint}` : ""}`;
|
|
69
|
+
}
|
|
70
|
+
/** The seat's own move where one is offered, and the filler otherwise.
|
|
71
|
+
*
|
|
72
|
+
* Separated from the loop so the rule can be stated once and tested without a
|
|
73
|
+
* standing authority: reaching the decision inside `playSeat` means answering
|
|
74
|
+
* a signed seat-auth challenge, and a test that forged one would be proving
|
|
75
|
+
* its own copy of the authority rather than this.
|
|
76
|
+
*
|
|
77
|
+
* A supplied decision is never second-guessed. `strategy` is a filler for a
|
|
78
|
+
* seat nobody is deciding for -- fold whatever comes, or shove -- and a seat
|
|
79
|
+
* run that way proves authorship of the signature and nothing about
|
|
80
|
+
* authorship of the decision, which is the whole of what this arena is for.
|
|
81
|
+
* Silently falling back to it when a decision was supplied would be the one
|
|
82
|
+
* failure this seam exists to prevent, so the decision is awaited and used. */
|
|
83
|
+
export async function chooseSeatAction(legal, view, strategy, decide, context = {
|
|
84
|
+
executionId: "",
|
|
85
|
+
table: null,
|
|
86
|
+
}) {
|
|
87
|
+
if (!decide)
|
|
88
|
+
return { action: pickAction(legal, strategy) };
|
|
89
|
+
const own = decodeParticipantView(view.participantView);
|
|
90
|
+
const table = context.table;
|
|
91
|
+
const mine = table?.seats.find((seat) => seat.seat === own.receivingSeat);
|
|
92
|
+
const decided = await decide({
|
|
93
|
+
legal,
|
|
94
|
+
view,
|
|
95
|
+
seat: own.receivingSeat,
|
|
96
|
+
hole: own.holeCards,
|
|
97
|
+
executionId: context.executionId,
|
|
98
|
+
table,
|
|
99
|
+
seats: table?.seats ?? [],
|
|
100
|
+
tableTalk: context.tableTalk ?? [],
|
|
101
|
+
toCall: table && mine
|
|
102
|
+
? Math.max(0, table.currentWager - mine.streetContribution)
|
|
103
|
+
: null,
|
|
104
|
+
deadlineMs: view.participantDeadlineMs,
|
|
105
|
+
});
|
|
106
|
+
return "action" in decided ? decided : { action: decided };
|
|
107
|
+
}
|
|
108
|
+
function contentActionId(context, view, payloadSchema, payload) {
|
|
109
|
+
const writer = new Uint8Array(ACTION_IDENTITY_DOMAIN.length + 32 + 8 + 32 + 2 + payload.length);
|
|
110
|
+
let offset = 0;
|
|
111
|
+
writer.set(ACTION_IDENTITY_DOMAIN, offset);
|
|
112
|
+
offset += ACTION_IDENTITY_DOMAIN.length;
|
|
113
|
+
writer.set(context.sessionId, offset);
|
|
114
|
+
offset += 32;
|
|
115
|
+
const nonce = Buffer.alloc(8);
|
|
116
|
+
nonce.writeBigUInt64BE(view.state.nonce);
|
|
117
|
+
writer.set(nonce, offset);
|
|
118
|
+
offset += 8;
|
|
119
|
+
writer.set(view.state.commitment, offset);
|
|
120
|
+
offset += 32;
|
|
121
|
+
writer[offset++] = (payloadSchema >> 8) & 0xff;
|
|
122
|
+
writer[offset++] = payloadSchema & 0xff;
|
|
123
|
+
writer.set(payload, offset);
|
|
124
|
+
return blake2b256(writer);
|
|
125
|
+
}
|
|
126
|
+
export class SessionClient {
|
|
127
|
+
baseUrl;
|
|
128
|
+
agent;
|
|
129
|
+
seat;
|
|
130
|
+
participantId;
|
|
131
|
+
executionId;
|
|
132
|
+
executionManifestDigest;
|
|
133
|
+
clientNonce;
|
|
134
|
+
token = null;
|
|
135
|
+
policy = null;
|
|
136
|
+
eventsAfter = 0n;
|
|
137
|
+
fetchImpl;
|
|
138
|
+
constructor(baseUrl, agent, seat, participantId, executionId, executionManifestDigest, clientNonce, fetchImpl = fetch) {
|
|
139
|
+
this.baseUrl = baseUrl;
|
|
140
|
+
this.agent = agent;
|
|
141
|
+
this.seat = seat;
|
|
142
|
+
this.participantId = participantId;
|
|
143
|
+
this.executionId = executionId;
|
|
144
|
+
this.executionManifestDigest = executionManifestDigest;
|
|
145
|
+
this.clientNonce = clientNonce;
|
|
146
|
+
this.fetchImpl = fetchImpl;
|
|
147
|
+
}
|
|
148
|
+
url(path) {
|
|
149
|
+
return `${this.baseUrl.replace(/\/$/, "")}${path}`;
|
|
150
|
+
}
|
|
151
|
+
async challenge() {
|
|
152
|
+
const response = await this.fetchImpl(this.url("/v1/session/challenge"), {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { "content-type": "application/json" },
|
|
155
|
+
body: JSON.stringify({
|
|
156
|
+
seat: this.seat,
|
|
157
|
+
nonce: Buffer.from(this.clientNonce).toString("base64"),
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok)
|
|
161
|
+
throw new Error(`challenge failed (${response.status}): ${await response.text()}`);
|
|
162
|
+
const body = (await response.json());
|
|
163
|
+
const bytes = Buffer.from(body.challenge, "base64");
|
|
164
|
+
if (bytes.length !== 74)
|
|
165
|
+
throw new Error(`challenge must be 74 bytes, got ${bytes.length}`);
|
|
166
|
+
return new Uint8Array(bytes);
|
|
167
|
+
}
|
|
168
|
+
async postWire(path, wire, withToken) {
|
|
169
|
+
const headers = {
|
|
170
|
+
"content-type": "application/json",
|
|
171
|
+
};
|
|
172
|
+
if (withToken) {
|
|
173
|
+
if (!this.token)
|
|
174
|
+
throw new Error("session token missing");
|
|
175
|
+
headers["session-token"] = this.token;
|
|
176
|
+
}
|
|
177
|
+
let lastError = "";
|
|
178
|
+
let lastRefusal = null;
|
|
179
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
180
|
+
const response = await this.fetchImpl(this.url(path), {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers,
|
|
183
|
+
body: JSON.stringify(encodeEnvelope(wire)),
|
|
184
|
+
});
|
|
185
|
+
if (response.ok)
|
|
186
|
+
return response;
|
|
187
|
+
const body = await response.text();
|
|
188
|
+
lastError = `${path} failed (${response.status}): ${body}`;
|
|
189
|
+
let retryable = response.status >= 500;
|
|
190
|
+
try {
|
|
191
|
+
const envelope = JSON.parse(body);
|
|
192
|
+
if (envelope.message) {
|
|
193
|
+
const decoded = decodeAuthorityMessage(decodeEnvelope({ message: envelope.message }));
|
|
194
|
+
if (decoded.type === "error") {
|
|
195
|
+
lastError = `${path} failed: ${describeSessionError(decoded)}`;
|
|
196
|
+
retryable = decoded.retryable;
|
|
197
|
+
lastRefusal = decoded;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
/* keep raw body */
|
|
203
|
+
}
|
|
204
|
+
if (!retryable)
|
|
205
|
+
throw lastRefusal
|
|
206
|
+
? new SessionRefusal(lastRefusal, lastError)
|
|
207
|
+
: new Error(lastError);
|
|
208
|
+
await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt));
|
|
209
|
+
}
|
|
210
|
+
throw lastRefusal
|
|
211
|
+
? new SessionRefusal(lastRefusal, lastError)
|
|
212
|
+
: new Error(lastError);
|
|
213
|
+
}
|
|
214
|
+
async join() {
|
|
215
|
+
const challenge = await this.challenge();
|
|
216
|
+
const request = {
|
|
217
|
+
wireVersion: 1,
|
|
218
|
+
supportedSessionVersions: [1],
|
|
219
|
+
executionId: this.executionId,
|
|
220
|
+
executionManifestDigest: this.executionManifestDigest,
|
|
221
|
+
participantId: this.participantId,
|
|
222
|
+
seat: this.seat,
|
|
223
|
+
clientNonce: this.clientNonce,
|
|
224
|
+
challenge,
|
|
225
|
+
};
|
|
226
|
+
const signature = await signRaw(this.agent, joinSigningBytes(request));
|
|
227
|
+
const response = await this.postWire("/v1/session/join", encodeJoinFrame(request, signature), false);
|
|
228
|
+
if (!response.ok)
|
|
229
|
+
throw new Error(`join failed (${response.status}): ${await response.text()}`);
|
|
230
|
+
const headerToken = response.headers.get("session-token");
|
|
231
|
+
if (headerToken)
|
|
232
|
+
this.token = headerToken;
|
|
233
|
+
else {
|
|
234
|
+
const body = (await response.clone().json());
|
|
235
|
+
if (!body.token)
|
|
236
|
+
throw new Error("join did not return a session token");
|
|
237
|
+
this.token = body.token;
|
|
238
|
+
}
|
|
239
|
+
const batch = await this.pollEvents();
|
|
240
|
+
if (!batch.messages.some((message) => message.type === "sessionJoined"))
|
|
241
|
+
throw new Error("join did not deliver SessionJoined");
|
|
242
|
+
return batch;
|
|
243
|
+
}
|
|
244
|
+
async pollEvents(signal) {
|
|
245
|
+
if (!this.token)
|
|
246
|
+
throw new Error("session token missing");
|
|
247
|
+
const url = new URL(this.url("/v1/session/events"));
|
|
248
|
+
url.searchParams.set("after", this.eventsAfter.toString());
|
|
249
|
+
url.searchParams.set("wait_ms", "5000");
|
|
250
|
+
const response = await this.fetchImpl(url, {
|
|
251
|
+
headers: { "session-token": this.token },
|
|
252
|
+
signal,
|
|
253
|
+
});
|
|
254
|
+
if (!response.ok)
|
|
255
|
+
throw new Error(`events failed (${response.status}): ${await response.text()}`);
|
|
256
|
+
const closed = response.headers.get("session-stream-closed") === "true";
|
|
257
|
+
const startHeader = response.headers.get("session-events-base");
|
|
258
|
+
const start = startHeader ? BigInt(startHeader) : this.eventsAfter;
|
|
259
|
+
const envelopes = (await response.json());
|
|
260
|
+
const messages = envelopes.map((envelope) => decodeAuthorityMessage(decodeEnvelope(envelope)));
|
|
261
|
+
this.eventsAfter = start + BigInt(messages.length);
|
|
262
|
+
let context = null;
|
|
263
|
+
for (const message of messages) {
|
|
264
|
+
if ("context" in message)
|
|
265
|
+
context = message.context;
|
|
266
|
+
if (message.type === "sessionJoined")
|
|
267
|
+
this.policy = message.policy;
|
|
268
|
+
}
|
|
269
|
+
return { context, messages, closed };
|
|
270
|
+
}
|
|
271
|
+
async prepareAction(context, view, payload) {
|
|
272
|
+
const schema = view.legalActionsSchema;
|
|
273
|
+
const actionId = contentActionId(context, view, schema, payload);
|
|
274
|
+
const proposal = {
|
|
275
|
+
context,
|
|
276
|
+
actionId,
|
|
277
|
+
expectedStateNonce: view.state.nonce,
|
|
278
|
+
expectedStateCommitment: view.state.commitment,
|
|
279
|
+
participantDeadlineMs: view.participantDeadlineMs,
|
|
280
|
+
payloadSchemaVersion: schema,
|
|
281
|
+
payload,
|
|
282
|
+
};
|
|
283
|
+
const signingBytes = actionSigningBytes(proposal);
|
|
284
|
+
const signature = await signRaw(this.agent, signingBytes);
|
|
285
|
+
return {
|
|
286
|
+
pending: {
|
|
287
|
+
actionId,
|
|
288
|
+
actionPayload: payload,
|
|
289
|
+
signingBytes,
|
|
290
|
+
expectedStateNonce: view.state.nonce,
|
|
291
|
+
expectedStateCommitment: view.state.commitment,
|
|
292
|
+
participantDeadlineMs: view.participantDeadlineMs,
|
|
293
|
+
payloadSchemaVersion: schema,
|
|
294
|
+
executionId: context.executionId,
|
|
295
|
+
protocolId: context.protocolId,
|
|
296
|
+
protocolVersion: context.protocolVersion,
|
|
297
|
+
artifactReferences: [],
|
|
298
|
+
},
|
|
299
|
+
wire: encodeActionFrame(proposal, signature),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
startSubmit(wire) {
|
|
303
|
+
return this.postWire("/v1/session/actions", wire, true);
|
|
304
|
+
}
|
|
305
|
+
async acknowledge(context, cursor) {
|
|
306
|
+
const response = await this.postWire("/v1/session/acknowledgements", encodeAckFrame(context, cursor), true);
|
|
307
|
+
if (!response.ok)
|
|
308
|
+
throw new Error(`ack failed (${response.status}): ${await response.text()}`);
|
|
309
|
+
}
|
|
310
|
+
async resume(context, cursor) {
|
|
311
|
+
const challenge = await this.challenge();
|
|
312
|
+
const request = {
|
|
313
|
+
context,
|
|
314
|
+
cursorSequence: cursor.sequence,
|
|
315
|
+
witnessedReceipt: cursor.witnessedReceipt,
|
|
316
|
+
clientNonce: this.clientNonce,
|
|
317
|
+
challenge,
|
|
318
|
+
};
|
|
319
|
+
const signature = await signRaw(this.agent, resumeSigningBytes(request));
|
|
320
|
+
const response = await this.postWire("/v1/session/resume", encodeResumeFrame(request, signature), true);
|
|
321
|
+
if (!response.ok)
|
|
322
|
+
throw new Error(`resume failed (${response.status}): ${await response.text()}`);
|
|
323
|
+
const headerToken = response.headers.get("session-token");
|
|
324
|
+
if (headerToken)
|
|
325
|
+
this.token = headerToken;
|
|
326
|
+
}
|
|
327
|
+
async answerSeatAuth(challenge, coordinatorKey, timeAuthorityKey, pending) {
|
|
328
|
+
const preimage = authorizeSeatChallenge({
|
|
329
|
+
seat: this.seat,
|
|
330
|
+
coordinatorPublicKey: coordinatorKey,
|
|
331
|
+
challengeCoordinatorKey: challenge.coordinatorPublicKey,
|
|
332
|
+
coordinatorProof: challenge.coordinatorProof,
|
|
333
|
+
authorizationPayload: challenge.authorizationPayload,
|
|
334
|
+
timeAuthorityPublicKey: timeAuthorityKey,
|
|
335
|
+
pending,
|
|
336
|
+
});
|
|
337
|
+
const signature = await signRaw(this.agent, preimage);
|
|
338
|
+
const response = await this.postWire("/v1/session/seat-authorization", encodeSeatAuthSuccessFrame(challenge.context, challenge.actionId, signature), true);
|
|
339
|
+
if (!response.ok)
|
|
340
|
+
throw new Error(`seat-authorization failed (${response.status}): ${await response.text()}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
export async function playSeat(args) {
|
|
344
|
+
const fetchImpl = args.fetchImpl ?? fetch;
|
|
345
|
+
const product = args.productUrl.replace(/\/$/, "");
|
|
346
|
+
const offer = await fetchImpl(`${product}/open/v1/playground/matches/${args.offerId}`);
|
|
347
|
+
if (!offer.ok)
|
|
348
|
+
throw new Error(`offer read failed (${offer.status}): ${await offer.text()}`);
|
|
349
|
+
const record = (await offer.json());
|
|
350
|
+
if (!record.admission)
|
|
351
|
+
throw new Error("offer is not admitted; admit it before playing");
|
|
352
|
+
const admitted = record.offer.seats.find((seat) => seat.seat === args.seat &&
|
|
353
|
+
equalBytes(fromHex(seat.agent_id), args.agentId));
|
|
354
|
+
if (!admitted)
|
|
355
|
+
throw new Error(`seat ${args.seat} / agent are not in the offer`);
|
|
356
|
+
if (!equalBytes(fromHex(admitted.agent_public_key), args.agent.publicKey))
|
|
357
|
+
throw new Error(`agent key does not match the admitted key for seat ${args.seat}`);
|
|
358
|
+
/* The admission names both keys. A caller that passes them is asking for a
|
|
359
|
+
check; one that does not gets the admitted ones, which is all a seat
|
|
360
|
+
reconnecting from an offer id it kept actually has. */
|
|
361
|
+
const coordinator = fromHex(record.admission.coordinator_public_key);
|
|
362
|
+
if (args.coordinatorKey && !equalBytes(coordinator, args.coordinatorKey))
|
|
363
|
+
throw new Error("coordinator key does not match the admitted coordinator public key");
|
|
364
|
+
const coordinatorKey = args.coordinatorKey ?? coordinator;
|
|
365
|
+
const admittedTimeAuthority = record.admission.time_authority_public_key
|
|
366
|
+
? fromHex(record.admission.time_authority_public_key)
|
|
367
|
+
: null;
|
|
368
|
+
if (admittedTimeAuthority &&
|
|
369
|
+
args.timeAuthorityKey &&
|
|
370
|
+
!equalBytes(admittedTimeAuthority, args.timeAuthorityKey))
|
|
371
|
+
throw new Error("time-authority key does not match the admitted time-authority public key");
|
|
372
|
+
const timeAuthorityKey = args.timeAuthorityKey ?? admittedTimeAuthority;
|
|
373
|
+
if (!timeAuthorityKey)
|
|
374
|
+
throw new Error("the admission names no time-authority key; pass timeAuthorityKey");
|
|
375
|
+
const executionHex = record.admission.execution_id.replace(/^0x/i, "");
|
|
376
|
+
const executionId = `0x${executionHex}`;
|
|
377
|
+
const nonce = new Uint8Array(randomBytes(32));
|
|
378
|
+
nonce[0] = args.seat & 0xff;
|
|
379
|
+
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);
|
|
380
|
+
const strategy = args.strategy ?? "fold-heavy";
|
|
381
|
+
const decide = args.decide;
|
|
382
|
+
const acted = new Set();
|
|
383
|
+
let committedActions = 0;
|
|
384
|
+
let reconnects = 0;
|
|
385
|
+
let rejoins = 0;
|
|
386
|
+
let said = 0;
|
|
387
|
+
let saidRefused = 0;
|
|
388
|
+
let lastHandSeen = null;
|
|
389
|
+
/* Who the other seats are, read once per agent for the whole sitting: a
|
|
390
|
+
name does not change mid-match, and a read per turn would be a read per
|
|
391
|
+
turn per seat. */
|
|
392
|
+
const names = new Map();
|
|
393
|
+
let disconnectExerciseDone = false;
|
|
394
|
+
let pending = null;
|
|
395
|
+
let lastContext = null;
|
|
396
|
+
let lastCursor = { sequence: 0n, witnessedReceipt: null };
|
|
397
|
+
const inbox = [];
|
|
398
|
+
let joined = await client.join();
|
|
399
|
+
if (joined.context)
|
|
400
|
+
lastContext = joined.context;
|
|
401
|
+
const handle = async (message) => {
|
|
402
|
+
if (message.type === "error") {
|
|
403
|
+
if (message.retryable)
|
|
404
|
+
return null;
|
|
405
|
+
throw new SessionRefusal(message);
|
|
406
|
+
}
|
|
407
|
+
if (message.type === "seatAuthorization") {
|
|
408
|
+
if (!pending)
|
|
409
|
+
throw new Error("seat-authorization with no pending action");
|
|
410
|
+
await client.answerSeatAuth(message.challenge, coordinatorKey, timeAuthorityKey, pending);
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
if (message.type === "actionPending" ||
|
|
414
|
+
message.type === "actionAcknowledged")
|
|
415
|
+
return null;
|
|
416
|
+
if ("context" in message)
|
|
417
|
+
lastContext = message.context;
|
|
418
|
+
if (message.type === "sessionTerminal") {
|
|
419
|
+
await client.acknowledge(message.context, message.cursor);
|
|
420
|
+
lastCursor = message.cursor;
|
|
421
|
+
return {
|
|
422
|
+
outcome: "terminal",
|
|
423
|
+
committedActions,
|
|
424
|
+
reconnects,
|
|
425
|
+
rejoins,
|
|
426
|
+
said,
|
|
427
|
+
saidRefused,
|
|
428
|
+
terminalNonce: message.finalState.nonce.toString(),
|
|
429
|
+
terminalCommitment: toHex0x(message.finalState.commitment),
|
|
430
|
+
executionId: record.admission.execution_id,
|
|
431
|
+
sessionBaseUrl: record.admission.session_base_url,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
let view = null;
|
|
435
|
+
let cursor = null;
|
|
436
|
+
if (message.type === "sessionJoined") {
|
|
437
|
+
view = message.view;
|
|
438
|
+
cursor = message.cursor;
|
|
439
|
+
}
|
|
440
|
+
else if (message.type === "participantView") {
|
|
441
|
+
view = message.view;
|
|
442
|
+
cursor = message.cursor;
|
|
443
|
+
}
|
|
444
|
+
else if (message.type === "actionCommitted") {
|
|
445
|
+
pending = null;
|
|
446
|
+
committedActions += 1;
|
|
447
|
+
view = message.view;
|
|
448
|
+
cursor = message.cursor;
|
|
449
|
+
}
|
|
450
|
+
else if (message.type === "actionRejected") {
|
|
451
|
+
pending = null;
|
|
452
|
+
view = message.view;
|
|
453
|
+
cursor = message.cursor;
|
|
454
|
+
}
|
|
455
|
+
else if (message.type === "sessionResumed") {
|
|
456
|
+
view = message.view;
|
|
457
|
+
cursor = message.cursor;
|
|
458
|
+
}
|
|
459
|
+
if (view && cursor !== null && lastContext) {
|
|
460
|
+
const nonceKey = view.state.nonce.toString();
|
|
461
|
+
const now = BigInt(Date.now());
|
|
462
|
+
if (view.legalActions.length > 0 &&
|
|
463
|
+
!acted.has(nonceKey) &&
|
|
464
|
+
view.participantDeadlineMs > now) {
|
|
465
|
+
const legal = decodeLegalActions(view.legalActions);
|
|
466
|
+
/* The public table, read beside the seat's own view. Every agent that
|
|
467
|
+
played a seat wrote this poll itself; the SDK owns the transport, so
|
|
468
|
+
it owns this read too. Per turn, not per event: the table only
|
|
469
|
+
matters at the moment there is a decision to make. */
|
|
470
|
+
const table = await readPublicTable(fetchImpl, product, executionHex, names);
|
|
471
|
+
/* And what has been said in this hand, which the skill promised the
|
|
472
|
+
decision would see. Best-effort like the table: a seat that cannot
|
|
473
|
+
read the talk still has its own cards and the legal set. */
|
|
474
|
+
const tableTalk = table
|
|
475
|
+
? await readTableTalk(fetchImpl, product, executionHex, table.handNumber)
|
|
476
|
+
: [];
|
|
477
|
+
if (table && table.handNumber !== lastHandSeen) {
|
|
478
|
+
lastHandSeen = table.handNumber;
|
|
479
|
+
args.onHand?.({
|
|
480
|
+
number: table.handNumber,
|
|
481
|
+
stack: table.seats.find((entry) => entry.seat === args.seat)?.stack ??
|
|
482
|
+
null,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
/* The seat's own move where one is offered, and the filler otherwise.
|
|
486
|
+
*
|
|
487
|
+
* This is the one line that decides whether an agent is playing here or
|
|
488
|
+
* being played. `strategy` picks by a fixed rule -- fold whatever comes,
|
|
489
|
+
* or shove -- and the key signs it either way, so a seat run that way
|
|
490
|
+
* proves authorship of the signature and nothing about authorship of
|
|
491
|
+
* the decision, which is the whole of what this arena is for.
|
|
492
|
+
*
|
|
493
|
+
* Three agents pointed at the arena's documents each found the packaged
|
|
494
|
+
* command, refused it in their own words, and rewrote this loop to get
|
|
495
|
+
* at this line. What they rebuilt was transport, signing, seat-auth and
|
|
496
|
+
* resume -- none of which they wanted to own, and one of which lapsed a
|
|
497
|
+
* seat's turns to the clock while its author was still writing it. */
|
|
498
|
+
const { action, say } = await chooseSeatAction(legal, view, strategy, decide, { executionId, table, tableTalk });
|
|
499
|
+
/* The clock, read again now the decision is back.
|
|
500
|
+
*
|
|
501
|
+
* Sampling it once above was sound while `pickAction` was the only
|
|
502
|
+
* picker: it is synchronous and instant, so the check before the
|
|
503
|
+
* decision was the check at submit time. A supplied `decide` is async
|
|
504
|
+
* on purpose -- a model call is the ordinary case -- and a turn can end
|
|
505
|
+
* while it thinks. `session.md` gives every agent the rule for that: if
|
|
506
|
+
* the deadline has passed, acknowledge and do not submit. The
|
|
507
|
+
* acknowledgement below happens either way, so skipping the submit is
|
|
508
|
+
* the whole of following it. */
|
|
509
|
+
if (turnIsStillOpen(view, Date.now())) {
|
|
510
|
+
const prepared = await client.prepareAction(lastContext, view, encodeAction(action));
|
|
511
|
+
pending = prepared.pending;
|
|
512
|
+
const submitP = client.startSubmit(prepared.wire);
|
|
513
|
+
const deferred = [];
|
|
514
|
+
const abort = new AbortController();
|
|
515
|
+
let pollP = client.pollEvents(abort.signal);
|
|
516
|
+
let submitted = null;
|
|
517
|
+
while (!submitted) {
|
|
518
|
+
const raced = await Promise.race([
|
|
519
|
+
submitP.then((response) => ({
|
|
520
|
+
kind: "submit",
|
|
521
|
+
response,
|
|
522
|
+
})),
|
|
523
|
+
pollP.then((batch) => ({ kind: "events", batch })),
|
|
524
|
+
]);
|
|
525
|
+
if (raced.kind === "submit") {
|
|
526
|
+
submitted = raced.response;
|
|
527
|
+
abort.abort();
|
|
528
|
+
const leftover = await pollP.catch(() => null);
|
|
529
|
+
if (leftover) {
|
|
530
|
+
if (leftover.context)
|
|
531
|
+
lastContext = leftover.context;
|
|
532
|
+
for (const nested of leftover.messages) {
|
|
533
|
+
if (nested.type === "seatAuthorization") {
|
|
534
|
+
try {
|
|
535
|
+
await client.answerSeatAuth(nested.challenge, coordinatorKey, timeAuthorityKey, pending);
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
console.error(`seat-authorization answer refused: ${error instanceof Error ? error.message : String(error)}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
deferred.push(nested);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
if (raced.batch.context)
|
|
549
|
+
lastContext = raced.batch.context;
|
|
550
|
+
for (const nested of raced.batch.messages) {
|
|
551
|
+
if (nested.type === "seatAuthorization") {
|
|
552
|
+
try {
|
|
553
|
+
await client.answerSeatAuth(nested.challenge, coordinatorKey, timeAuthorityKey, pending);
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
console.error(`seat-authorization answer refused: ${error instanceof Error ? error.message : String(error)}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
deferred.push(nested);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
pollP = client.pollEvents(abort.signal);
|
|
564
|
+
}
|
|
565
|
+
if (!submitted.ok)
|
|
566
|
+
throw new Error(`submit failed (${submitted.status})`);
|
|
567
|
+
acted.add(nonceKey);
|
|
568
|
+
inbox.push(...deferred);
|
|
569
|
+
/* Said only once the move has gone in, the way the custodial table
|
|
570
|
+
files a line only on an accepted action: a line that rode a
|
|
571
|
+
refused move was never said at the table. Best-effort and
|
|
572
|
+
counted, so a seat learns from its report that its words were
|
|
573
|
+
refused rather than believing it had spoken. */
|
|
574
|
+
if (say) {
|
|
575
|
+
if (await sayAtTable(fetchImpl, product, executionHex, args, say))
|
|
576
|
+
said += 1;
|
|
577
|
+
else
|
|
578
|
+
saidRefused += 1;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
await client.acknowledge(lastContext, cursor);
|
|
583
|
+
lastCursor = cursor;
|
|
584
|
+
if (!disconnectExerciseDone &&
|
|
585
|
+
args.disconnectAfterActions !== undefined &&
|
|
586
|
+
committedActions >= args.disconnectAfterActions) {
|
|
587
|
+
disconnectExerciseDone = true;
|
|
588
|
+
await client.resume(lastContext, lastCursor);
|
|
589
|
+
reconnects += 1;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
};
|
|
594
|
+
inbox.push(...joined.messages);
|
|
595
|
+
for (;;) {
|
|
596
|
+
try {
|
|
597
|
+
while (inbox.length > 0) {
|
|
598
|
+
const message = inbox.shift();
|
|
599
|
+
const done = await handle(message);
|
|
600
|
+
if (done)
|
|
601
|
+
return done;
|
|
602
|
+
}
|
|
603
|
+
const batch = await client.pollEvents();
|
|
604
|
+
if (batch.context)
|
|
605
|
+
lastContext = batch.context;
|
|
606
|
+
inbox.push(...batch.messages);
|
|
607
|
+
if (batch.closed && inbox.length === 0) {
|
|
608
|
+
if (!lastContext || reconnects >= 5)
|
|
609
|
+
return {
|
|
610
|
+
outcome: "left",
|
|
611
|
+
committedActions,
|
|
612
|
+
reconnects,
|
|
613
|
+
rejoins,
|
|
614
|
+
said,
|
|
615
|
+
saidRefused,
|
|
616
|
+
executionId: record.admission.execution_id,
|
|
617
|
+
sessionBaseUrl: record.admission.session_base_url,
|
|
618
|
+
};
|
|
619
|
+
await client.resume(lastContext, lastCursor);
|
|
620
|
+
reconnects += 1;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
if (!lastContext || reconnects >= 5)
|
|
625
|
+
throw error;
|
|
626
|
+
/* Three refusals, three answers. A gone session (`UnknownSession`, or a
|
|
627
|
+
cursor outside the replay window) cannot be resumed, only joined
|
|
628
|
+
afresh -- this loop used to resume five times into the same refusal
|
|
629
|
+
and leave the seat to the clock. A superseded session means another
|
|
630
|
+
client joined this seat with this key; fighting it back would evict
|
|
631
|
+
a reconnect that may be the operator's own, so this one steps aside
|
|
632
|
+
and says so. Everything else is transient, and resume is right. */
|
|
633
|
+
const answer = afterRefusal(error);
|
|
634
|
+
if (answer === "step-aside")
|
|
635
|
+
return {
|
|
636
|
+
outcome: "superseded",
|
|
637
|
+
committedActions,
|
|
638
|
+
reconnects,
|
|
639
|
+
rejoins,
|
|
640
|
+
said,
|
|
641
|
+
saidRefused,
|
|
642
|
+
executionId: record.admission.execution_id,
|
|
643
|
+
sessionBaseUrl: record.admission.session_base_url,
|
|
644
|
+
};
|
|
645
|
+
if (answer === "rejoin") {
|
|
646
|
+
joined = await client.join();
|
|
647
|
+
if (joined.context)
|
|
648
|
+
lastContext = joined.context;
|
|
649
|
+
lastCursor = { sequence: 0n, witnessedReceipt: null };
|
|
650
|
+
inbox.push(...joined.messages);
|
|
651
|
+
rejoins += 1;
|
|
652
|
+
reconnects += 1;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
await client.resume(lastContext, lastCursor);
|
|
656
|
+
reconnects += 1;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/** What the play loop does about a refusal, by the refusal's tag.
|
|
661
|
+
*
|
|
662
|
+
* Apart from the loop so the three answers can be held to their tags without
|
|
663
|
+
* a session server: `rejoin` for a session that is gone (`UnknownSession`,
|
|
664
|
+
* or a cursor outside the replay window), `step-aside` for one another
|
|
665
|
+
* client took (`SessionSuperseded`), `resume` for everything else. */
|
|
666
|
+
export function afterRefusal(error) {
|
|
667
|
+
const refusal = error instanceof SessionRefusal ? error : null;
|
|
668
|
+
if (refusal?.tag === SESSION_SUPERSEDED)
|
|
669
|
+
return "step-aside";
|
|
670
|
+
if (refusal?.tag === UNKNOWN_SESSION ||
|
|
671
|
+
refusal?.tag === INVALID_RESUME_CURSOR)
|
|
672
|
+
return "rejoin";
|
|
673
|
+
return "resume";
|
|
674
|
+
}
|
|
675
|
+
/** A card as the wire prints it on the public surfaces -- `TC`, `8d` -- in
|
|
676
|
+
* the form the seat's own `hole` uses: rank then suit, suit in lower case.
|
|
677
|
+
* Two surfaces printed the same card two ways, and every agent normalised
|
|
678
|
+
* them itself. */
|
|
679
|
+
export function normaliseCardCode(code) {
|
|
680
|
+
const trimmed = code.trim();
|
|
681
|
+
if (trimmed.length < 2)
|
|
682
|
+
return trimmed;
|
|
683
|
+
const rank = trimmed.slice(0, -1).toUpperCase().replace(/^10$/, "T");
|
|
684
|
+
const suit = trimmed.slice(-1).toLowerCase();
|
|
685
|
+
return `${rank}${suit}`;
|
|
686
|
+
}
|
|
687
|
+
/** The public table for one execution, as the spectator snapshot shows it.
|
|
688
|
+
*
|
|
689
|
+
* Best-effort: a read that does not answer is null, never a throw -- the
|
|
690
|
+
* seat's own view is what the turn depends on, and a decision is better made
|
|
691
|
+
* without the board than not made at all.
|
|
692
|
+
*
|
|
693
|
+
* `names` is filled as agents are met and read from after that, so who is
|
|
694
|
+
* in a seat costs one custody read per agent per sitting. */
|
|
695
|
+
export async function readPublicTable(fetchImpl, product, executionHex, names = new Map()) {
|
|
696
|
+
try {
|
|
697
|
+
const response = await fetchImpl(`${product}/open/v1/spectator/executions/${executionHex}`);
|
|
698
|
+
if (!response.ok)
|
|
699
|
+
return null;
|
|
700
|
+
const wire = (await response.json());
|
|
701
|
+
const table = wire.table;
|
|
702
|
+
if (!table)
|
|
703
|
+
return null;
|
|
704
|
+
const who = new Map((wire.seats ?? []).map((seat) => [seat.seat, seat]));
|
|
705
|
+
const seats = [];
|
|
706
|
+
for (const seat of table.seats ?? []) {
|
|
707
|
+
const entry = who.get(seat.seat);
|
|
708
|
+
const occupant = entry?.occupant === "house"
|
|
709
|
+
? "house"
|
|
710
|
+
: entry?.occupant === "agent"
|
|
711
|
+
? "agent"
|
|
712
|
+
: null;
|
|
713
|
+
const agentId = occupant === "agent" && entry?.participant_id
|
|
714
|
+
? `0x${entry.participant_id.replace(/^0x/i, "")}`
|
|
715
|
+
: null;
|
|
716
|
+
const naming = agentId
|
|
717
|
+
? await readAgentNaming(fetchImpl, product, agentId, names)
|
|
718
|
+
: null;
|
|
719
|
+
seats.push({
|
|
720
|
+
seat: seat.seat,
|
|
721
|
+
stack: Number(seat.stack),
|
|
722
|
+
folded: Boolean(seat.folded),
|
|
723
|
+
allIn: Boolean(seat.all_in),
|
|
724
|
+
streetContribution: Number(seat.street_contribution ?? 0),
|
|
725
|
+
totalContribution: Number(seat.total_contribution ?? 0),
|
|
726
|
+
occupant,
|
|
727
|
+
agentId,
|
|
728
|
+
name: naming?.name ?? null,
|
|
729
|
+
handle: naming?.handle ?? null,
|
|
730
|
+
bio: naming?.bio ?? null,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
return {
|
|
734
|
+
handNumber: Number(table.hand_number ?? 0),
|
|
735
|
+
handLimit: Number(table.hand_limit ?? 0),
|
|
736
|
+
street: table.street ?? "",
|
|
737
|
+
board: (table.board ?? []).map(normaliseCardCode),
|
|
738
|
+
pot: (table.pots ?? []).reduce((sum, pot) => sum + potChips(pot), 0),
|
|
739
|
+
currentWager: Number(table.current_wager ?? 0),
|
|
740
|
+
actingSeat: table.current_actor ?? null,
|
|
741
|
+
button: table.button ?? null,
|
|
742
|
+
smallBlindSeat: table.small_blind_seat ?? null,
|
|
743
|
+
bigBlindSeat: table.big_blind_seat ?? null,
|
|
744
|
+
smallBlind: Number(table.small_blind ?? 0),
|
|
745
|
+
bigBlind: Number(table.big_blind ?? 0),
|
|
746
|
+
seats,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/** What an agent is called, from `GET /open/v1/agents/{id}/custody`, read
|
|
754
|
+
* once and remembered in `names`. Null where the read did not answer. */
|
|
755
|
+
async function readAgentNaming(fetchImpl, product, agentId, names) {
|
|
756
|
+
const known = names.get(agentId);
|
|
757
|
+
if (known !== undefined)
|
|
758
|
+
return known;
|
|
759
|
+
try {
|
|
760
|
+
const response = await fetchImpl(`${product}/open/v1/agents/${agentId}/custody`);
|
|
761
|
+
if (!response.ok) {
|
|
762
|
+
names.set(agentId, null);
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
const wire = (await response.json());
|
|
766
|
+
const naming = {
|
|
767
|
+
name: wire.name?.trim() || null,
|
|
768
|
+
handle: wire.handle?.trim() || null,
|
|
769
|
+
bio: wire.bio?.trim() || null,
|
|
770
|
+
};
|
|
771
|
+
names.set(agentId, naming);
|
|
772
|
+
return naming;
|
|
773
|
+
}
|
|
774
|
+
catch {
|
|
775
|
+
names.set(agentId, null);
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/** What has been said in one hand, oldest first. Best-effort, like the
|
|
780
|
+
* table: an empty list where the read did not answer. */
|
|
781
|
+
export async function readTableTalk(fetchImpl, product, executionHex, handIndex) {
|
|
782
|
+
try {
|
|
783
|
+
const response = await fetchImpl(`${product}/open/v1/executions/${executionHex}/talk`);
|
|
784
|
+
if (!response.ok)
|
|
785
|
+
return [];
|
|
786
|
+
const wire = (await response.json());
|
|
787
|
+
return (wire.said ?? [])
|
|
788
|
+
.filter((line) => typeof line.seat === "number" &&
|
|
789
|
+
typeof line.text === "string" &&
|
|
790
|
+
line.text.length > 0 &&
|
|
791
|
+
(line.handIndex ?? 0) === handIndex)
|
|
792
|
+
.map((line) => ({
|
|
793
|
+
seat: line.seat,
|
|
794
|
+
agentId: line.agentId ?? "",
|
|
795
|
+
handIndex: line.handIndex ?? 0,
|
|
796
|
+
text: line.text,
|
|
797
|
+
saidAtMs: line.saidAtMs ?? 0,
|
|
798
|
+
}))
|
|
799
|
+
.sort((a, b) => a.saidAtMs - b.saidAtMs);
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
return [];
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
/** One pot's chips, whichever of the shapes the wire uses for a pot. */
|
|
806
|
+
function potChips(pot) {
|
|
807
|
+
if (typeof pot === "number")
|
|
808
|
+
return pot;
|
|
809
|
+
if (pot && typeof pot === "object") {
|
|
810
|
+
const record = pot;
|
|
811
|
+
const value = record.amount ?? record.total ?? record.chips;
|
|
812
|
+
return typeof value === "number" ? value : Number(value ?? 0) || 0;
|
|
813
|
+
}
|
|
814
|
+
return 0;
|
|
815
|
+
}
|
|
816
|
+
/** File one line at the table, signed as this seat's agent. True when the
|
|
817
|
+
* product accepted it. */
|
|
818
|
+
async function sayAtTable(fetchImpl, product, executionHex, args, say) {
|
|
819
|
+
const target = `/open/v1/executions/${executionHex}/talk`;
|
|
820
|
+
const body = textBytes(JSON.stringify({ say }));
|
|
821
|
+
try {
|
|
822
|
+
const { header } = await mintAgentHttpCapability(args.agent, args.agentId, {
|
|
823
|
+
method: "POST",
|
|
824
|
+
requestTarget: target,
|
|
825
|
+
body,
|
|
826
|
+
});
|
|
827
|
+
const response = await fetchImpl(`${product}${target}`, {
|
|
828
|
+
method: "POST",
|
|
829
|
+
headers: {
|
|
830
|
+
"content-type": "application/json",
|
|
831
|
+
[AGENT_HTTP_CAPABILITY_HEADER]: header,
|
|
832
|
+
},
|
|
833
|
+
body,
|
|
834
|
+
});
|
|
835
|
+
return response.ok;
|
|
836
|
+
}
|
|
837
|
+
catch {
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
}
|