@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/seatTurn.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { toHex0x } from "./bytes.js";
|
|
3
|
+
import { acceptAndAwaitAdmission, readOffer } from "./offer.js";
|
|
4
|
+
import { decodeLegalActions, decodeParticipantView, encodeAction, } from "./texas.js";
|
|
5
|
+
import { admitAuthorityEvent, equalReceiptRef, freshSessionBoundary, predictionGatePreparationOf, predictionGateTargetReceipt, } from "./sessionCodec.js";
|
|
6
|
+
import { agentReadCapability, openSeatSession, readPublicTable, readTableTalk, turnIsStillOpen, SessionRefusal, afterRefusal, readSittingStatus, } from "./session.js";
|
|
7
|
+
import { decodeContext, decodeCursor, decodeGate, decodeView, encodeContext, encodeCursor, encodeGate, encodeView, } from "./seatState.js";
|
|
8
|
+
/** Whether `view` is a turn this seat has already answered.
|
|
9
|
+
*
|
|
10
|
+
* `act` does not move the cursor, so the next `turn` resumes from before the
|
|
11
|
+
* answered position and is handed it again. A nonce at or before the one
|
|
12
|
+
* `act` recorded is that replay, not a new turn. */
|
|
13
|
+
export function turnAlreadyAnswered(view, state) {
|
|
14
|
+
return (state.answeredNonce != null && view.state.nonce <= BigInt(state.answeredNonce));
|
|
15
|
+
}
|
|
16
|
+
/** The agent this seat plays as, found without asking the caller for it.
|
|
17
|
+
*
|
|
18
|
+
* In order: the id the caller passed, the one an earlier `turn` stored, and
|
|
19
|
+
* the seat's own row on the offer, which names the agent and the key it sits
|
|
20
|
+
* with. The offer is the read that works for every agent, claimed or not; the
|
|
21
|
+
* roster lists an agent under the wallet that claimed it, which a key whose
|
|
22
|
+
* agent registered itself never matches. Null where the seat is not this
|
|
23
|
+
* key's, so the caller can say that rather than guess. */
|
|
24
|
+
export async function seatAgentId(state, agent, given) {
|
|
25
|
+
if (given)
|
|
26
|
+
return given;
|
|
27
|
+
if (state.agentId)
|
|
28
|
+
return state.agentId;
|
|
29
|
+
const offer = await readOffer(state.productUrl, state.offerId);
|
|
30
|
+
const row = offer.seats.find((seat) => seat.seat === state.seat);
|
|
31
|
+
if (!row?.agentId || !row.agentPublicKey)
|
|
32
|
+
return null;
|
|
33
|
+
const own = toHex0x(agent.publicKey).toLowerCase();
|
|
34
|
+
const seated = `0x${row.agentPublicKey.replace(/^0x/i, "")}`.toLowerCase();
|
|
35
|
+
return own === seated ? row.agentId : null;
|
|
36
|
+
}
|
|
37
|
+
/** What a fresh join costs: a nonce this seat has not opened a session with.
|
|
38
|
+
*
|
|
39
|
+
* The session id is `blake2b256(execution ‖ seat ‖ client_nonce)`, so a nonce
|
|
40
|
+
* identifies a session rather than a client. Reusing the stored one to open a
|
|
41
|
+
* NEW session therefore asks the authority for an id it may already hold, and
|
|
42
|
+
* that refusal is permanent for the life of the execution: the collision
|
|
43
|
+
* check in the authority's `join` sits before the code that would supersede
|
|
44
|
+
* the old session, so the id can never be freed by asking again. It is
|
|
45
|
+
* answered with `ServiceUnavailable`, which reads as "busy, try later", and a
|
|
46
|
+
* seat that believed that retried into the same wall until its table timed
|
|
47
|
+
* out waiting for it.
|
|
48
|
+
*
|
|
49
|
+
* So: resume addresses the session the stored nonce names, and every join
|
|
50
|
+
* mints a new one. The first byte carries the seat, as `newSeatState` does it.
|
|
51
|
+
*/
|
|
52
|
+
function freshNonce(seat) {
|
|
53
|
+
const nonce = new Uint8Array(randomBytes(32));
|
|
54
|
+
nonce[0] = seat & 0xff;
|
|
55
|
+
return nonce;
|
|
56
|
+
}
|
|
57
|
+
/** Attach to the session: resume where the stored cursor left off, or join if
|
|
58
|
+
* this seat has never opened one.
|
|
59
|
+
*
|
|
60
|
+
* Returns the session it ended up attached to, which is not always the one it
|
|
61
|
+
* was handed: a join needs a nonce of its own (see `freshNonce`), and the
|
|
62
|
+
* nonce is fixed when the session is opened, so joining means reopening. The
|
|
63
|
+
* state it returns carries that nonce, and the caller persists it -- a nonce
|
|
64
|
+
* that opened a session and was not written down is a session nothing can
|
|
65
|
+
* resume.
|
|
66
|
+
*
|
|
67
|
+
* Exported for its test rather than for callers: `reopen` is the seam the
|
|
68
|
+
* nonce rule lives on, and the rule is not observable from `turn`'s output --
|
|
69
|
+
* a seat that reuses a nonce looks identical until the join it cannot make. */
|
|
70
|
+
export async function attach(session, state, reopen) {
|
|
71
|
+
const stored = state.context ? decodeContext(state.context) : null;
|
|
72
|
+
const cursor = decodeCursor(state.cursor);
|
|
73
|
+
/* Hand the stored token back before resuming: the authority authenticates
|
|
74
|
+
the resume itself with it, and only sometimes issues a fresh one. */
|
|
75
|
+
if (state.token)
|
|
76
|
+
session.client.token = state.token;
|
|
77
|
+
const joinFresh = async () => {
|
|
78
|
+
const nonce = freshNonce(state.seat);
|
|
79
|
+
const opened = await reopen(nonce);
|
|
80
|
+
const joined = await opened.client.join();
|
|
81
|
+
return {
|
|
82
|
+
session: opened,
|
|
83
|
+
/* The token and context of the session just opened replace the old
|
|
84
|
+
ones wholesale. Keeping either alongside a new nonce would leave the
|
|
85
|
+
state describing two different sessions. */
|
|
86
|
+
state: {
|
|
87
|
+
...state,
|
|
88
|
+
clientNonce: toHex0x(nonce),
|
|
89
|
+
context: joined.context ? encodeContext(joined.context) : null,
|
|
90
|
+
token: opened.client.token ?? null,
|
|
91
|
+
/* A fresh admission resets the gate boundary with everything else:
|
|
92
|
+
the phase belonged to the session that just ended. */
|
|
93
|
+
cursor: { sequence: "0", witnessedReceipt: null },
|
|
94
|
+
view: null,
|
|
95
|
+
predictionGate: null,
|
|
96
|
+
awaitingGatePrefix: false,
|
|
97
|
+
},
|
|
98
|
+
context: joined.context ?? null,
|
|
99
|
+
cursor: { sequence: 0n, witnessedReceipt: null },
|
|
100
|
+
messages: joined.messages,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
if (!stored)
|
|
104
|
+
return joinFresh();
|
|
105
|
+
try {
|
|
106
|
+
await session.client.resume(stored, cursor);
|
|
107
|
+
return { session, state, context: stored, cursor, messages: [] };
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
/* A session the authority no longer knows, or a cursor it will not accept,
|
|
111
|
+
is a rejoin rather than a failure -- the seat is bound to the agent's
|
|
112
|
+
key, not to the process that opened it. A superseded session is not:
|
|
113
|
+
somebody else is holding this seat and two clients on one key is the one
|
|
114
|
+
thing that must not be papered over. */
|
|
115
|
+
if (error instanceof SessionRefusal && afterRefusal(error) === "rejoin")
|
|
116
|
+
return joinFresh();
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const holeFromView = (view) => {
|
|
121
|
+
try {
|
|
122
|
+
const seen = decodeParticipantView(view.participantView);
|
|
123
|
+
return seen.holeCards
|
|
124
|
+
? [seen.holeCards[0].label, seen.holeCards[1].label]
|
|
125
|
+
: null;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* A view this build cannot decode is not a reason to refuse the turn: the
|
|
129
|
+
legal actions still say what may be done, and a seat that folds because
|
|
130
|
+
its own cards would not parse has lost more than a decoder bug. */
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
/** The file as it stands after one accepted event: the session it belongs to,
|
|
135
|
+
* the boundary that was accepted, and the position to answer if there is one.
|
|
136
|
+
* The gate phase travels with the cursor because they are one promise - a
|
|
137
|
+
* cursor that outran its phase is a seat that forgot it was held. */
|
|
138
|
+
function persisted(session, state, context, cursor, view, boundary) {
|
|
139
|
+
return {
|
|
140
|
+
...state,
|
|
141
|
+
context: context ? encodeContext(context) : state.context,
|
|
142
|
+
token: session.client.token ?? state.token,
|
|
143
|
+
cursor: encodeCursor(cursor),
|
|
144
|
+
view: view ? encodeView(view) : state.view,
|
|
145
|
+
predictionGate: boundary.predictionGate
|
|
146
|
+
? encodeGate(boundary.predictionGate)
|
|
147
|
+
: null,
|
|
148
|
+
awaitingGatePrefix: boundary.awaitingGatePrefix,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** What `turn` answers when it could not open a session.
|
|
152
|
+
*
|
|
153
|
+
* Its own function because the choice is the whole point of the outcome and
|
|
154
|
+
* is not observable from `turn`'s happy path: the authority releases a
|
|
155
|
+
* sitting's session surface once the sitting ends, so the refusal a seat
|
|
156
|
+
* meets after the last hand is the ordinary shape of "finished". Reported as
|
|
157
|
+
* an error it is indistinguishable from a seat that is genuinely stuck, and
|
|
158
|
+
* those want opposite responses -- stop, or keep trying.
|
|
159
|
+
*
|
|
160
|
+
* `unknown` never becomes `terminal`. Telling an agent its match is over when
|
|
161
|
+
* the product merely could not answer would have it walk away from a table it
|
|
162
|
+
* still has chips on. */
|
|
163
|
+
export async function attachFailureOutcome(error, sitting, state) {
|
|
164
|
+
if (sitting.state === "over")
|
|
165
|
+
return { kind: "terminal", state };
|
|
166
|
+
return {
|
|
167
|
+
kind: "unattachable",
|
|
168
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
169
|
+
sitting: sitting.state,
|
|
170
|
+
state,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/** Read this seat's position, or say why there is nothing to answer.
|
|
174
|
+
*
|
|
175
|
+
* Polls for at most `waitMs`, because a command that blocks until a table
|
|
176
|
+
* moves is a command an agent cannot schedule around. Returning `waiting` is
|
|
177
|
+
* an answer, not a failure. */
|
|
178
|
+
export async function openTurn(args) {
|
|
179
|
+
const fetchImpl = args.fetchImpl ?? fetch;
|
|
180
|
+
/* The seat has to be accepted before there is anything to see, and until
|
|
181
|
+
every seat accepts there is no admission to build a session from. Doing it
|
|
182
|
+
here rather than asking for a separate `accept` command is the difference
|
|
183
|
+
between an agent that reads one instruction and an agent that has to know
|
|
184
|
+
the offer lifecycle: the first `turn` opens the seat, and every `turn`
|
|
185
|
+
after it finds an admission already there and returns immediately.
|
|
186
|
+
|
|
187
|
+
Found by running this against a real authority, which answered "offer is
|
|
188
|
+
not admitted" -- the loop path accepted on the agent's behalf and this
|
|
189
|
+
path had inherited none of that. */
|
|
190
|
+
if (!args.state.context)
|
|
191
|
+
await acceptAndAwaitAdmission(args.state.productUrl, args.state.offerId, args.agentId, args.agent, { timeoutMs: Math.max(30_000, args.waitMs ?? 0) });
|
|
192
|
+
const openWith = (clientNonce) => openSeatSession({
|
|
193
|
+
productUrl: args.state.productUrl,
|
|
194
|
+
offerId: args.state.offerId,
|
|
195
|
+
seat: args.state.seat,
|
|
196
|
+
agent: args.agent,
|
|
197
|
+
agentId: args.agentId,
|
|
198
|
+
clientNonce,
|
|
199
|
+
fetchImpl,
|
|
200
|
+
});
|
|
201
|
+
let session = await openWith(hexBytes(args.state.clientNonce));
|
|
202
|
+
let attached;
|
|
203
|
+
try {
|
|
204
|
+
attached = await attach(session, args.state, openWith);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
/* A seat that cannot attach is asking the wrong question if its match is
|
|
208
|
+
over: the authority releases the session surface once a sitting ends, so
|
|
209
|
+
the refusal an agent meets then is the ordinary shape of "finished", not
|
|
210
|
+
a fault. Ask the product which it is before saying anything. */
|
|
211
|
+
return attachFailureOutcome(error, await readSittingStatus(fetchImpl, session.product, session.executionHex), args.state);
|
|
212
|
+
}
|
|
213
|
+
/* `session` and the state are rebound: a join opens its own session under a
|
|
214
|
+
new nonce, and everything below -- the poll, the acknowledge, what gets
|
|
215
|
+
written back -- has to be about that one. */
|
|
216
|
+
session = attached.session;
|
|
217
|
+
args = { ...args, state: attached.state };
|
|
218
|
+
let { context, cursor, messages } = attached;
|
|
219
|
+
/* What this seat has already accepted, restored from the file: the sequence
|
|
220
|
+
and receipt floor its cursor names, the gate phase it holds, and whether a
|
|
221
|
+
viewless admission still owes its prepared prefix. The view is not
|
|
222
|
+
restored - a restart drops it exactly as
|
|
223
|
+
`ParticipantSessionState::restore` does, and the committed floor carries
|
|
224
|
+
what the boundary still needs. */
|
|
225
|
+
let boundary = {
|
|
226
|
+
...freshSessionBoundary(),
|
|
227
|
+
context,
|
|
228
|
+
sequence: cursor.sequence,
|
|
229
|
+
receiptFloor: cursor.witnessedReceipt,
|
|
230
|
+
predictionGate: args.state.predictionGate
|
|
231
|
+
? decodeGate(args.state.predictionGate)
|
|
232
|
+
: null,
|
|
233
|
+
awaitingGatePrefix: args.state.awaitingGatePrefix,
|
|
234
|
+
};
|
|
235
|
+
const until = Date.now() + (args.waitMs ?? 0);
|
|
236
|
+
/* Accept, persist, acknowledge - in that order, for every event this door
|
|
237
|
+
takes. The acknowledgement tells the authority this seat holds the event,
|
|
238
|
+
and it may drop the event from the replay window on the strength of it; a
|
|
239
|
+
promise made before the file is on disk is one a restart cannot keep.
|
|
240
|
+
|
|
241
|
+
A refusal from `admitAuthorityEvent` throws out of here with nothing
|
|
242
|
+
written and nothing acknowledged, which is what leaves the previously
|
|
243
|
+
persisted boundary resumable. A re-delivery answers `null`: it was
|
|
244
|
+
persisted and acknowledged by the process that first applied it, and
|
|
245
|
+
writing its cursor again would walk this seat's boundary backwards. */
|
|
246
|
+
const accept = async (message, view) => {
|
|
247
|
+
const admitted = admitAuthorityEvent(boundary, message);
|
|
248
|
+
if (admitted.disposition !== "applied")
|
|
249
|
+
return null;
|
|
250
|
+
const originToken = session.client.token;
|
|
251
|
+
if (!originToken)
|
|
252
|
+
throw new Error("session token missing");
|
|
253
|
+
/* A fresh join is a new session: the view the previous one printed
|
|
254
|
+
describes a turn that no longer exists. */
|
|
255
|
+
const previous = message.type === "sessionJoined"
|
|
256
|
+
? { ...args.state, view: null }
|
|
257
|
+
: args.state;
|
|
258
|
+
const state = persisted(session, previous, message.context, message.cursor, view, admitted.boundary);
|
|
259
|
+
await args.checkpoint(state);
|
|
260
|
+
boundary = admitted.boundary;
|
|
261
|
+
context = message.context;
|
|
262
|
+
cursor = message.cursor;
|
|
263
|
+
args = { ...args, state };
|
|
264
|
+
await session.client.acknowledge(message.context, message.cursor, originToken);
|
|
265
|
+
return state;
|
|
266
|
+
};
|
|
267
|
+
for (;;) {
|
|
268
|
+
for (const message of messages) {
|
|
269
|
+
if (message.type === "error" && !message.retryable)
|
|
270
|
+
throw new SessionRefusal(message);
|
|
271
|
+
/* Acknowledgements, pendings, errors and challenges carry no boundary. */
|
|
272
|
+
if (!("cursor" in message))
|
|
273
|
+
continue;
|
|
274
|
+
if (message.type === "sessionTerminal") {
|
|
275
|
+
const state = await accept(message, null);
|
|
276
|
+
/* A re-delivered terminal is this file's own last event: a resume at
|
|
277
|
+
the terminal cursor repeats it, so there is nothing to write or
|
|
278
|
+
promise again - and the sitting is still over. */
|
|
279
|
+
return {
|
|
280
|
+
kind: "terminal",
|
|
281
|
+
/* Carried out rather than dropped: `consent` takes exactly these two,
|
|
282
|
+
and the hand-driven loop had no other way to learn them, so the
|
|
283
|
+
two-command tour had no settle at all. */
|
|
284
|
+
terminalNonce: message.finalState.nonce.toString(),
|
|
285
|
+
terminalCommitment: toHex0x(message.finalState.commitment),
|
|
286
|
+
state: state ?? args.state,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/* A named notice and a viewless admission are acknowledged and waited
|
|
290
|
+
on: no private view arrived, so there is nothing here to answer and
|
|
291
|
+
nothing to decide. The phase they carry is persisted first. */
|
|
292
|
+
const view = "view" in message ? message.view : null;
|
|
293
|
+
const state = await accept(message, view);
|
|
294
|
+
/* A re-delivery is not a turn: the view it carries was answered or
|
|
295
|
+
waited out by the process that applied it, and nothing was written
|
|
296
|
+
here to hand back. */
|
|
297
|
+
if (!state)
|
|
298
|
+
continue;
|
|
299
|
+
if (!view)
|
|
300
|
+
continue;
|
|
301
|
+
/* A view with no legal actions is the table moving without this seat.
|
|
302
|
+
One whose deadline has passed is this seat's turn already lost, and
|
|
303
|
+
answering it would sign for a turn that is closed. And one at or before
|
|
304
|
+
the position `act` last answered is a turn this seat has already
|
|
305
|
+
played: the resume replays it, and printing it again as `yours` is how
|
|
306
|
+
the two-command loop answered one turn dozens of times and missed the
|
|
307
|
+
next. */
|
|
308
|
+
if (view.legalActions.length === 0)
|
|
309
|
+
continue;
|
|
310
|
+
if (!turnIsStillOpen(view, Date.now()))
|
|
311
|
+
continue;
|
|
312
|
+
if (turnAlreadyAnswered(view, args.state))
|
|
313
|
+
continue;
|
|
314
|
+
const table = await readPublicTable(fetchImpl, session.product, session.executionHex,
|
|
315
|
+
/* A naming cache of its own: this door is one turn per process, so
|
|
316
|
+
there is no second read for a longer-lived one to save. */
|
|
317
|
+
new Map(),
|
|
318
|
+
/* Signed as this seat, so a private room shows it the table it is
|
|
319
|
+
playing at rather than refusing it as a stranger. */
|
|
320
|
+
agentReadCapability(args.agent, args.agentId)).catch(() => null);
|
|
321
|
+
const tableTalk = table === null
|
|
322
|
+
? []
|
|
323
|
+
: await readTableTalk(fetchImpl, session.product, session.executionHex, table.handNumber).catch(() => []);
|
|
324
|
+
const mine = table?.seats.find((row) => row.seat === args.state.seat);
|
|
325
|
+
return {
|
|
326
|
+
kind: "your-turn",
|
|
327
|
+
position: {
|
|
328
|
+
seat: args.state.seat,
|
|
329
|
+
executionId: session.executionId,
|
|
330
|
+
hole: holeFromView(view),
|
|
331
|
+
legal: decodeLegalActions(view.legalActions),
|
|
332
|
+
toCall: table && mine
|
|
333
|
+
? Math.max(0, table.currentWager - mine.streetContribution)
|
|
334
|
+
: null,
|
|
335
|
+
table,
|
|
336
|
+
seats: table?.seats ?? [],
|
|
337
|
+
tableTalk,
|
|
338
|
+
deadlineMs: view.participantDeadlineMs.toString(),
|
|
339
|
+
msRemaining: Number(view.participantDeadlineMs) - Date.now(),
|
|
340
|
+
},
|
|
341
|
+
state,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (Date.now() >= until) {
|
|
345
|
+
const waiting = persisted(session, args.state, context, cursor, null, boundary);
|
|
346
|
+
/* Asked only here, where the loop is about to say "waiting" anyway: a
|
|
347
|
+
seat with no chips is not waiting for a turn, it is out, and a caller
|
|
348
|
+
polling `waiting` at the default `--wait 0` would spin at full rate for
|
|
349
|
+
the rest of somebody else's sitting. The stack is on the public record
|
|
350
|
+
rather than the seat's own view, so it costs the one read this branch
|
|
351
|
+
can afford. */
|
|
352
|
+
const busted = await seatIsBusted(fetchImpl, session, args.state.seat);
|
|
353
|
+
return busted
|
|
354
|
+
? { kind: "eliminated", state: waiting }
|
|
355
|
+
: { kind: "waiting", state: waiting };
|
|
356
|
+
}
|
|
357
|
+
const batch = await session.client.pollEvents();
|
|
358
|
+
if (batch.context)
|
|
359
|
+
context = batch.context;
|
|
360
|
+
messages = batch.messages;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const hexBytes = (value) => {
|
|
364
|
+
const bare = value.replace(/^0x/i, "");
|
|
365
|
+
return Uint8Array.from(bare.match(/.{2}/g).map((pair) => Number.parseInt(pair, 16)));
|
|
366
|
+
};
|
|
367
|
+
/** Send one move for the position `openTurn` last returned. */
|
|
368
|
+
export async function submitTurn(args) {
|
|
369
|
+
const fetchImpl = args.fetchImpl ?? fetch;
|
|
370
|
+
if (!args.state.view)
|
|
371
|
+
throw new Error("no turn is open; run `turn` first");
|
|
372
|
+
const view = decodeView(args.state.view);
|
|
373
|
+
if (!turnIsStillOpen(view, Date.now()))
|
|
374
|
+
throw new Error("the deadline for that turn has passed; run `turn` again for the next one");
|
|
375
|
+
/* A retained gate that still holds this seat's own turn means the authority
|
|
376
|
+
has not released it: answering the stored position would be inferring on
|
|
377
|
+
a turn nobody may act in yet. `turn` refuses to hand such a view out, so
|
|
378
|
+
this only fires on a stored position the gate later reclaimed. */
|
|
379
|
+
const retained = args.state.predictionGate
|
|
380
|
+
? decodeGate(args.state.predictionGate)
|
|
381
|
+
: null;
|
|
382
|
+
if (retained &&
|
|
383
|
+
retained.phase !== "released" &&
|
|
384
|
+
equalReceiptRef(predictionGateTargetReceipt(retained), view.latestReceipt) &&
|
|
385
|
+
predictionGatePreparationOf(retained)?.actingSeat === args.state.seat)
|
|
386
|
+
throw new Error("this turn is held behind a prediction gate; run `turn` again once it releases");
|
|
387
|
+
const openWith = (clientNonce) => openSeatSession({
|
|
388
|
+
productUrl: args.state.productUrl,
|
|
389
|
+
offerId: args.state.offerId,
|
|
390
|
+
seat: args.state.seat,
|
|
391
|
+
agent: args.agent,
|
|
392
|
+
agentId: args.agentId,
|
|
393
|
+
clientNonce,
|
|
394
|
+
fetchImpl,
|
|
395
|
+
});
|
|
396
|
+
let session = await openWith(hexBytes(args.state.clientNonce));
|
|
397
|
+
let attached;
|
|
398
|
+
try {
|
|
399
|
+
attached = await attach(session, args.state, openWith);
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
/* `act` answers with a move or throws, so it cannot carry an outcome the
|
|
403
|
+
way `turn` does. What it can do is say which of the two this is, rather
|
|
404
|
+
than handing on a status code. */
|
|
405
|
+
const sitting = await readSittingStatus(fetchImpl, session.product, session.executionHex);
|
|
406
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
407
|
+
throw new Error(sitting.state === "over"
|
|
408
|
+
? `this sitting is ${sitting.detail}; there is no turn left to answer`
|
|
409
|
+
: `this seat cannot attach and the sitting is ${sitting.state === "live" ? "still running" : "of unknown state"}: ${raw}`);
|
|
410
|
+
}
|
|
411
|
+
session = attached.session;
|
|
412
|
+
args = { ...args, state: attached.state };
|
|
413
|
+
const { context } = attached;
|
|
414
|
+
if (!context)
|
|
415
|
+
throw new Error("the session answered with no context");
|
|
416
|
+
const prepared = await session.client.prepareAction(context, view, encodeAction(args.action));
|
|
417
|
+
const submitted = await raceSubmit(session, prepared);
|
|
418
|
+
if (!submitted.ok)
|
|
419
|
+
throw new Error(`action refused (${submitted.status}): ${await submitted.text()}`);
|
|
420
|
+
const said = await sayAtTable(fetchImpl, session, args.agent, args.agentId, args.say);
|
|
421
|
+
/* The cursor moves on the next read, not here: this process is about to
|
|
422
|
+
exit, and a cursor claiming to have seen the commit it has not read would
|
|
423
|
+
resume past it. */
|
|
424
|
+
return {
|
|
425
|
+
committed: true,
|
|
426
|
+
said,
|
|
427
|
+
state: {
|
|
428
|
+
...args.state,
|
|
429
|
+
token: session.client.token ?? args.state.token,
|
|
430
|
+
view: null,
|
|
431
|
+
answeredNonce: view.state.nonce.toString(),
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/** Submit, answering any seat-authorization challenge that arrives while the
|
|
436
|
+
* submit is in flight. The challenge is not optional: an action whose
|
|
437
|
+
* challenge went unanswered is an action the authority never authorised. */
|
|
438
|
+
/** Whether the public record shows this seat holding no chips.
|
|
439
|
+
*
|
|
440
|
+
* Unknown counts as not busted: a read that did not answer is a reason to keep
|
|
441
|
+
* waiting, not a reason to tell a seat it is out of a sitting it may still be
|
|
442
|
+
* in.
|
|
443
|
+
*/
|
|
444
|
+
async function seatIsBusted(fetchImpl, session, seat) {
|
|
445
|
+
const table = await readPublicTable(fetchImpl, session.product, session.executionHex).catch(() => null);
|
|
446
|
+
const mine = table?.seats.find((row) => row.seat === seat);
|
|
447
|
+
return mine !== undefined && mine.stack === 0;
|
|
448
|
+
}
|
|
449
|
+
async function raceSubmit(session, prepared) {
|
|
450
|
+
const submitP = session.client.startSubmit(prepared.wire);
|
|
451
|
+
const abort = new AbortController();
|
|
452
|
+
let pollP = session.client.pollEvents(abort.signal);
|
|
453
|
+
for (;;) {
|
|
454
|
+
const raced = await Promise.race([
|
|
455
|
+
submitP.then((response) => ({ kind: "submit", response })),
|
|
456
|
+
pollP.then((batch) => ({ kind: "events", batch })),
|
|
457
|
+
]);
|
|
458
|
+
if (raced.kind === "submit") {
|
|
459
|
+
abort.abort();
|
|
460
|
+
const leftover = await pollP.catch(() => null);
|
|
461
|
+
for (const nested of leftover?.messages ?? [])
|
|
462
|
+
if (nested.type === "seatAuthorization")
|
|
463
|
+
await session.client
|
|
464
|
+
.answerSeatAuth(nested.challenge, session.coordinatorKey, session.timeAuthorityKey, prepared.pending)
|
|
465
|
+
.catch(() => undefined);
|
|
466
|
+
return raced.response;
|
|
467
|
+
}
|
|
468
|
+
for (const nested of raced.batch.messages)
|
|
469
|
+
if (nested.type === "seatAuthorization")
|
|
470
|
+
await session.client
|
|
471
|
+
.answerSeatAuth(nested.challenge, session.coordinatorKey, session.timeAuthorityKey, prepared.pending)
|
|
472
|
+
.catch(() => undefined);
|
|
473
|
+
pollP = session.client.pollEvents(abort.signal);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async function sayAtTable(fetchImpl, session, agent, agentId, say) {
|
|
477
|
+
const trimmed = say.trim();
|
|
478
|
+
if (!trimmed)
|
|
479
|
+
return false;
|
|
480
|
+
const { mintAgentHttpCapability, AGENT_HTTP_CAPABILITY_HEADER } = await import("./agentHttp.js");
|
|
481
|
+
const target = `/open/v1/executions/${session.executionHex}/talk`;
|
|
482
|
+
const body = new TextEncoder().encode(JSON.stringify({ say: trimmed }));
|
|
483
|
+
try {
|
|
484
|
+
const { header } = await mintAgentHttpCapability(agent, agentId, {
|
|
485
|
+
method: "POST",
|
|
486
|
+
requestTarget: target,
|
|
487
|
+
body,
|
|
488
|
+
});
|
|
489
|
+
const response = await fetchImpl(`${session.product}${target}`, {
|
|
490
|
+
method: "POST",
|
|
491
|
+
headers: {
|
|
492
|
+
"content-type": "application/json",
|
|
493
|
+
[AGENT_HTTP_CAPABILITY_HEADER]: header,
|
|
494
|
+
},
|
|
495
|
+
body,
|
|
496
|
+
});
|
|
497
|
+
return response.ok;
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** A fresh seat state for an offer this agent has been admitted to. */
|
|
504
|
+
export function newSeatState(productUrl, offerId, seat) {
|
|
505
|
+
const nonce = new Uint8Array(randomBytes(32));
|
|
506
|
+
nonce[0] = seat & 0xff;
|
|
507
|
+
return {
|
|
508
|
+
productUrl,
|
|
509
|
+
offerId,
|
|
510
|
+
seat,
|
|
511
|
+
clientNonce: toHex0x(nonce),
|
|
512
|
+
context: null,
|
|
513
|
+
token: null,
|
|
514
|
+
cursor: { sequence: "0", witnessedReceipt: null },
|
|
515
|
+
view: null,
|
|
516
|
+
predictionGate: null,
|
|
517
|
+
awaitingGatePrefix: false,
|
|
518
|
+
};
|
|
519
|
+
}
|