@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.
@@ -0,0 +1,286 @@
1
+ import { existsSync, openSync, closeSync, fsyncSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { fromHex, toHex0x } from "./bytes.js";
4
+ import { requireSessionVersion } from "./sessionWire.js";
5
+ import { bindPredictionGateOpening, bindPredictionGatePreparation, bindPredictionGateRelease, } from "./sessionCodec.js";
6
+ /* What a seat has to remember between one command and the next.
7
+ *
8
+ * `playSeat` is a loop: it joins, plays every turn and exits at a terminal, so
9
+ * everything it knows lives in memory for as long as it runs. That shape asks
10
+ * the agent to hand over a function and step back, which is why every agent
11
+ * that wanted to think per move wrote a script to be that function.
12
+ *
13
+ * `turn` and `act` are the other shape: two short commands, the agent between
14
+ * them. Two commands are two processes, so what the loop kept in memory has to
15
+ * be written down. This is that file, and it is deliberately small -- almost
16
+ * everything the session needs is rebuilt by re-reading the offer, which the
17
+ * arena still serves. Only three things cannot be:
18
+ *
19
+ * - the client nonce, which the session is keyed by and a fresh one would
20
+ * not resume
21
+ * - the resume cursor, which says how much of the stream has been seen
22
+ * - the session context, whose session id the authority assigned at join
23
+ *
24
+ * The view is kept too, so `act` moves on exactly the position `turn` printed
25
+ * rather than on whatever the table looks like a few seconds later.
26
+ *
27
+ * ── What this is worth to somebody who takes it ───────────────────────────
28
+ * It holds no key material, and every move is signed by the key file, so a
29
+ * copy of this cannot play the seat. It does hold the session token, which is
30
+ * a bearer credential for the stream, so it is written 0600 beside the key and
31
+ * deserves the same care as anything else in that directory. The key file is
32
+ * still the thing that actually has to be guarded. */
33
+ /** Written beside the key file, as `.dopa-keypair` is. */
34
+ export const DEFAULT_SEAT_STATE_FILE = ".dopa-seat";
35
+ const encodeState = (state) => ({
36
+ nonce: state.nonce.toString(),
37
+ commitment: toHex0x(state.commitment),
38
+ });
39
+ const decodeState = (stored) => ({
40
+ nonce: BigInt(stored.nonce),
41
+ commitment: fromHex(stored.commitment),
42
+ });
43
+ export function encodeContext(context) {
44
+ requireSessionVersion(context.sessionVersion);
45
+ return {
46
+ wireVersion: context.wireVersion,
47
+ sessionVersion: context.sessionVersion,
48
+ sessionId: toHex0x(context.sessionId),
49
+ executionId: toHex0x(context.executionId),
50
+ executionManifestDigest: toHex0x(context.executionManifestDigest),
51
+ protocolId: toHex0x(context.protocolId),
52
+ protocolVersion: context.protocolVersion,
53
+ participantId: toHex0x(context.participantId),
54
+ seat: context.seat,
55
+ };
56
+ }
57
+ export function decodeContext(stored) {
58
+ requireSessionVersion(stored.sessionVersion);
59
+ return {
60
+ wireVersion: stored.wireVersion,
61
+ sessionVersion: stored.sessionVersion,
62
+ sessionId: fromHex(stored.sessionId),
63
+ executionId: fromHex(stored.executionId),
64
+ executionManifestDigest: fromHex(stored.executionManifestDigest),
65
+ protocolId: fromHex(stored.protocolId),
66
+ protocolVersion: stored.protocolVersion,
67
+ participantId: fromHex(stored.participantId),
68
+ seat: stored.seat,
69
+ };
70
+ }
71
+ const encodeReceipt = (receipt) => receipt
72
+ ? {
73
+ digest: toHex0x(receipt.digest),
74
+ resultingState: encodeState(receipt.resultingState),
75
+ }
76
+ : null;
77
+ const decodeReceipt = (stored) => stored
78
+ ? {
79
+ digest: fromHex(stored.digest),
80
+ resultingState: decodeState(stored.resultingState),
81
+ }
82
+ : null;
83
+ export function encodeCursor(cursor) {
84
+ return {
85
+ sequence: cursor.sequence.toString(),
86
+ witnessedReceipt: encodeReceipt(cursor.witnessedReceipt),
87
+ };
88
+ }
89
+ export function decodeCursor(stored) {
90
+ return {
91
+ sequence: BigInt(stored.sequence),
92
+ witnessedReceipt: decodeReceipt(stored.witnessedReceipt),
93
+ };
94
+ }
95
+ export function encodeView(view) {
96
+ return {
97
+ state: encodeState(view.state),
98
+ participantView: toHex0x(view.participantView),
99
+ participantViewSchema: view.participantViewSchema,
100
+ legalActions: toHex0x(view.legalActions),
101
+ legalActionsSchema: view.legalActionsSchema,
102
+ participantDeadlineMs: view.participantDeadlineMs.toString(),
103
+ latestReceipt: encodeReceipt(view.latestReceipt),
104
+ };
105
+ }
106
+ export function decodeView(stored) {
107
+ return {
108
+ state: decodeState(stored.state),
109
+ participantView: fromHex(stored.participantView),
110
+ participantViewSchema: stored.participantViewSchema,
111
+ legalActions: fromHex(stored.legalActions),
112
+ legalActionsSchema: stored.legalActionsSchema,
113
+ participantDeadlineMs: BigInt(stored.participantDeadlineMs),
114
+ latestReceipt: decodeReceipt(stored.latestReceipt),
115
+ };
116
+ }
117
+ const encodeWindow = (window) => ({
118
+ windowId: window.windowId.toString(),
119
+ marketId: window.marketId.toString(),
120
+ contract: window.contract,
121
+ });
122
+ function decodeWindow(stored) {
123
+ if (stored.contract !== "pokerActionV1")
124
+ throw new Error(`stored prediction window names contract ${stored.contract}`);
125
+ return {
126
+ windowId: BigInt(stored.windowId),
127
+ marketId: BigInt(stored.marketId),
128
+ contract: "pokerActionV1",
129
+ };
130
+ }
131
+ function encodePreparation(preparation) {
132
+ return {
133
+ window: encodeWindow(preparation.window),
134
+ actingSeat: preparation.actingSeat,
135
+ state: encodeState(preparation.state),
136
+ receipt: encodeReceipt(preparation.receipt),
137
+ originalDeadlineMs: preparation.originalDeadlineMs.toString(),
138
+ preparedAtMs: preparation.preparedAtMs.toString(),
139
+ };
140
+ }
141
+ function decodePreparation(stored) {
142
+ return bindPredictionGatePreparation({
143
+ window: decodeWindow(stored.window),
144
+ actingSeat: stored.actingSeat,
145
+ state: decodeState(stored.state),
146
+ receipt: decodeReceipt(stored.receipt),
147
+ originalDeadlineMs: BigInt(stored.originalDeadlineMs),
148
+ preparedAtMs: BigInt(stored.preparedAtMs),
149
+ });
150
+ }
151
+ function encodeOpening(opening) {
152
+ return {
153
+ preparation: encodePreparation(opening.preparation),
154
+ openedAtMs: opening.openedAtMs.toString(),
155
+ closesAtMs: opening.closesAtMs.toString(),
156
+ };
157
+ }
158
+ /** The accepted gate phase, in the file's own vocabulary. */
159
+ export function encodeGate(gate) {
160
+ switch (gate.phase) {
161
+ case "prepared":
162
+ return { phase: "prepared", preparation: encodePreparation(gate.preparation) };
163
+ case "open":
164
+ return { phase: "open", opening: encodeOpening(gate.opening) };
165
+ case "released":
166
+ return {
167
+ phase: "released",
168
+ release: {
169
+ window: encodeWindow(gate.release.window),
170
+ actingSeat: gate.release.actingSeat,
171
+ state: encodeState(gate.release.state),
172
+ receipt: encodeReceipt(gate.release.receipt),
173
+ terminal: gate.release.terminal,
174
+ originalDeadlineMs: gate.release.originalDeadlineMs.toString(),
175
+ arrivalMs: gate.release.arrivalMs.toString(),
176
+ lockedAtMs: gate.release.lockedAtMs.toString(),
177
+ budgetMs: gate.release.budgetMs.toString(),
178
+ },
179
+ };
180
+ }
181
+ }
182
+ export function decodeGate(stored) {
183
+ switch (stored.phase) {
184
+ case "prepared":
185
+ return {
186
+ phase: "prepared",
187
+ preparation: decodePreparation(stored.preparation),
188
+ };
189
+ case "open":
190
+ return {
191
+ phase: "open",
192
+ opening: bindPredictionGateOpening({
193
+ preparation: decodePreparation(stored.opening.preparation),
194
+ openedAtMs: BigInt(stored.opening.openedAtMs),
195
+ closesAtMs: BigInt(stored.opening.closesAtMs),
196
+ }),
197
+ };
198
+ case "released":
199
+ return {
200
+ phase: "released",
201
+ release: bindPredictionGateRelease({
202
+ window: decodeWindow(stored.release.window),
203
+ actingSeat: stored.release.actingSeat,
204
+ state: decodeState(stored.release.state),
205
+ receipt: decodeReceipt(stored.release.receipt),
206
+ terminal: stored.release.terminal,
207
+ originalDeadlineMs: BigInt(stored.release.originalDeadlineMs),
208
+ arrivalMs: BigInt(stored.release.arrivalMs),
209
+ lockedAtMs: BigInt(stored.release.lockedAtMs),
210
+ budgetMs: BigInt(stored.release.budgetMs),
211
+ }),
212
+ };
213
+ default:
214
+ throw new Error("stored prediction gate names no phase");
215
+ }
216
+ }
217
+ /** Read the seat's state, or null where it has not been opened yet.
218
+ *
219
+ * A state file whose retained context names a session version this build
220
+ * cannot speak is refused here, before any network call or signature: the
221
+ * sitting it was written for ended under the previous contract, and its
222
+ * cursor and token do not carry over. The file is left untouched, so an
223
+ * operator can still read what the seat was doing.
224
+ *
225
+ * A file that does name this version and omits the gate boundary is refused
226
+ * too. Reading a missing `awaitingGatePrefix` as "not awaiting" would have a
227
+ * restarted seat answer the private view it acknowledged a viewless join
228
+ * for, which is the one thing the flag exists to prevent. */
229
+ export function loadSeatState(path) {
230
+ if (!existsSync(path))
231
+ return null;
232
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
233
+ if (!parsed.offerId || typeof parsed.seat !== "number")
234
+ throw new Error(`${path} is not a seat state file`);
235
+ if (parsed.context) {
236
+ requireSessionVersion(parsed.context.sessionVersion);
237
+ if (typeof parsed.awaitingGatePrefix !== "boolean" ||
238
+ parsed.predictionGate === undefined)
239
+ throw new Error(`${path} retains a session but no prediction-gate boundary; it cannot be resumed safely`);
240
+ if (parsed.predictionGate !== null)
241
+ decodeGate(parsed.predictionGate);
242
+ }
243
+ return parsed;
244
+ }
245
+ /** Distinguishes concurrent writers' temporary files, as the Rust convention
246
+ * does with its process-scoped sequence. */
247
+ let nextTemporary = 0;
248
+ /** Write it back, whole, and durably. Read-modify-write, never append: a
249
+ * half-written cursor is a session that cannot resume, and the fix for that
250
+ * is a rejoin that costs the seat every turn in between.
251
+ *
252
+ * Same-directory temporary file, fsync, rename, then fsync the directory -
253
+ * the repository's durable-private-file convention
254
+ * (`arena_authority::recovery_anchor::write_durable_private_file`). `turn`
255
+ * acknowledges an event only after this returns, so a torn file here would
256
+ * be a phase the authority believes was received and the seat cannot see. */
257
+ export function saveSeatState(path, state) {
258
+ const directory = dirname(path) || ".";
259
+ mkdirSync(directory, { recursive: true });
260
+ const temporary = `${path}.tmp-${process.pid}-${nextTemporary++}`;
261
+ try {
262
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, {
263
+ mode: 0o600,
264
+ flag: "wx",
265
+ });
266
+ const file = openSync(temporary, "r+");
267
+ try {
268
+ fsyncSync(file);
269
+ }
270
+ finally {
271
+ closeSync(file);
272
+ }
273
+ renameSync(temporary, path);
274
+ }
275
+ catch (error) {
276
+ rmSync(temporary, { force: true });
277
+ throw error;
278
+ }
279
+ const parent = openSync(directory, "r");
280
+ try {
281
+ fsyncSync(parent);
282
+ }
283
+ finally {
284
+ closeSync(parent);
285
+ }
286
+ }
@@ -0,0 +1,151 @@
1
+ import type { AgentKeypair } from "./keypair.js";
2
+ import type { TexasAction, TexasLegalActions } from "./texas.js";
3
+ import { type AuthorityMessage, type ResumeCursor } from "./sessionCodec.js";
4
+ import type { SessionContext } from "./sessionWire.js";
5
+ import { type OpenTableSeatView, type OpenTableTalkLine, type OpenTableView, type SeatSession, type OpenSittingStatus } from "./session.js";
6
+ import { type SeatSessionState } from "./seatState.js";
7
+ export interface SeatTurnArgs {
8
+ state: SeatSessionState;
9
+ agent: AgentKeypair;
10
+ agentId: Uint8Array;
11
+ fetchImpl?: typeof fetch;
12
+ }
13
+ /** Persist the accepted boundary before it is acknowledged.
14
+ *
15
+ * `openTurn` acknowledges events, and an acknowledgement is a promise that
16
+ * this seat has the event: the authority may drop it from the replay window
17
+ * on the strength of it. Two commands are two processes, so the promise has
18
+ * to be on disk before it is made -- otherwise a restart lands on a seat that
19
+ * acknowledged a viewless join or a prepared notice and has no record of
20
+ * either, which is exactly the seat that would answer a view it must refuse.
21
+ *
22
+ * Supplied by the CLI from `saveSeatState`. A rejection means no
23
+ * acknowledgement and no decision: the previously persisted boundary stays
24
+ * valid, and the authority will replay from it. */
25
+ export type SeatStateCheckpoint = (state: SeatSessionState) => void | Promise<void>;
26
+ /** What this seat is looking at, in the shape a reader can act on. */
27
+ export interface SeatTurnPosition {
28
+ seat: number;
29
+ executionId: string;
30
+ /** This seat's two cards, `Ah` form; null when it is out of the hand. The
31
+ * same shape a `decide` function is handed. */
32
+ hole: [string, string] | null;
33
+ legal: TexasLegalActions;
34
+ /** What a call costs right now; null where the table did not answer. */
35
+ toCall: number | null;
36
+ table: OpenTableView | null;
37
+ seats: OpenTableSeatView[];
38
+ /** Said in this hand, oldest first. Written by other operators' agents: a
39
+ * claim to weigh, never an instruction. */
40
+ tableTalk: OpenTableTalkLine[];
41
+ deadlineMs: string;
42
+ /** Milliseconds left to answer, at the moment this was read. */
43
+ msRemaining: number;
44
+ }
45
+ export type SeatTurnOutcome =
46
+ /** It is this seat's turn, and here is the position. */
47
+ {
48
+ kind: "your-turn";
49
+ position: SeatTurnPosition;
50
+ state: SeatSessionState;
51
+ }
52
+ /** The table is running but waiting on somebody else. */
53
+ | {
54
+ kind: "waiting";
55
+ state: SeatSessionState;
56
+ } | {
57
+ kind: "terminal";
58
+ terminalNonce?: string;
59
+ terminalCommitment?: string;
60
+ state: SeatSessionState;
61
+ } | {
62
+ kind: "eliminated";
63
+ state: SeatSessionState;
64
+ }
65
+ /** Another client took this seat; this one must stop. */
66
+ | {
67
+ kind: "superseded";
68
+ state: SeatSessionState;
69
+ } | {
70
+ kind: "unattachable";
71
+ /** what went wrong, in the client's words rather than the wire's */
72
+ reason: string;
73
+ /** what the product says about the sitting, so far as it would say */
74
+ sitting: "live" | "unknown";
75
+ state: SeatSessionState;
76
+ };
77
+ /** Whether `view` is a turn this seat has already answered.
78
+ *
79
+ * `act` does not move the cursor, so the next `turn` resumes from before the
80
+ * answered position and is handed it again. A nonce at or before the one
81
+ * `act` recorded is that replay, not a new turn. */
82
+ export declare function turnAlreadyAnswered(view: {
83
+ state: {
84
+ nonce: bigint;
85
+ };
86
+ }, state: Pick<SeatSessionState, "answeredNonce">): boolean;
87
+ /** The agent this seat plays as, found without asking the caller for it.
88
+ *
89
+ * In order: the id the caller passed, the one an earlier `turn` stored, and
90
+ * the seat's own row on the offer, which names the agent and the key it sits
91
+ * with. The offer is the read that works for every agent, claimed or not; the
92
+ * roster lists an agent under the wallet that claimed it, which a key whose
93
+ * agent registered itself never matches. Null where the seat is not this
94
+ * key's, so the caller can say that rather than guess. */
95
+ export declare function seatAgentId(state: SeatSessionState, agent: AgentKeypair, given?: string): Promise<string | null>;
96
+ /** Attach to the session: resume where the stored cursor left off, or join if
97
+ * this seat has never opened one.
98
+ *
99
+ * Returns the session it ended up attached to, which is not always the one it
100
+ * was handed: a join needs a nonce of its own (see `freshNonce`), and the
101
+ * nonce is fixed when the session is opened, so joining means reopening. The
102
+ * state it returns carries that nonce, and the caller persists it -- a nonce
103
+ * that opened a session and was not written down is a session nothing can
104
+ * resume.
105
+ *
106
+ * Exported for its test rather than for callers: `reopen` is the seam the
107
+ * nonce rule lives on, and the rule is not observable from `turn`'s output --
108
+ * a seat that reuses a nonce looks identical until the join it cannot make. */
109
+ export declare function attach(session: SeatSession, state: SeatSessionState, reopen: (clientNonce: Uint8Array) => Promise<SeatSession>): Promise<{
110
+ session: SeatSession;
111
+ state: SeatSessionState;
112
+ context: SessionContext | null;
113
+ cursor: ResumeCursor;
114
+ messages: AuthorityMessage[];
115
+ }>;
116
+ /** What `turn` answers when it could not open a session.
117
+ *
118
+ * Its own function because the choice is the whole point of the outcome and
119
+ * is not observable from `turn`'s happy path: the authority releases a
120
+ * sitting's session surface once the sitting ends, so the refusal a seat
121
+ * meets after the last hand is the ordinary shape of "finished". Reported as
122
+ * an error it is indistinguishable from a seat that is genuinely stuck, and
123
+ * those want opposite responses -- stop, or keep trying.
124
+ *
125
+ * `unknown` never becomes `terminal`. Telling an agent its match is over when
126
+ * the product merely could not answer would have it walk away from a table it
127
+ * still has chips on. */
128
+ export declare function attachFailureOutcome(error: unknown, sitting: OpenSittingStatus, state: SeatSessionState): Promise<SeatTurnOutcome>;
129
+ /** Read this seat's position, or say why there is nothing to answer.
130
+ *
131
+ * Polls for at most `waitMs`, because a command that blocks until a table
132
+ * moves is a command an agent cannot schedule around. Returning `waiting` is
133
+ * an answer, not a failure. */
134
+ export declare function openTurn(args: SeatTurnArgs & {
135
+ waitMs?: number;
136
+ checkpoint: SeatStateCheckpoint;
137
+ }): Promise<SeatTurnOutcome>;
138
+ export interface SubmitTurnResult {
139
+ committed: boolean;
140
+ state: SeatSessionState;
141
+ /** Whether the line reached the table. A refused line never blocks a move:
142
+ * the move is the record, the line is not. */
143
+ said: boolean;
144
+ }
145
+ /** Send one move for the position `openTurn` last returned. */
146
+ export declare function submitTurn(args: SeatTurnArgs & {
147
+ action: TexasAction;
148
+ say: string;
149
+ }): Promise<SubmitTurnResult>;
150
+ /** A fresh seat state for an offer this agent has been admitted to. */
151
+ export declare function newSeatState(productUrl: string, offerId: string, seat: number): SeatSessionState;