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