@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
package/dist/offer.js
CHANGED
|
@@ -15,6 +15,7 @@ import { signOfferAcceptance } from "./acceptance.js";
|
|
|
15
15
|
import { AGENT_HTTP_CAPABILITY_HEADER, mintAgentHttpCapability, } from "./agentHttp.js";
|
|
16
16
|
import { fromHex, toHex } from "./bytes.js";
|
|
17
17
|
import { randomBytes } from "node:crypto";
|
|
18
|
+
import { refusalMessage } from "./refusal.js";
|
|
18
19
|
const hexBytes = (value) => fromHex(value.replace(/^0x/, ""));
|
|
19
20
|
async function readJson(url, init) {
|
|
20
21
|
const response = await fetch(url, init);
|
|
@@ -36,6 +37,7 @@ function record(raw) {
|
|
|
36
37
|
state: raw?.state,
|
|
37
38
|
seats,
|
|
38
39
|
acceptedSeats: (raw?.acceptances ?? []).map((entry) => entry.seat),
|
|
40
|
+
houseSeats: (raw?.house_seats ?? raw?.houseSeats ?? []),
|
|
39
41
|
admission: admissionRaw
|
|
40
42
|
? {
|
|
41
43
|
sessionBaseUrl: admissionRaw.session_base_url ?? admissionRaw.sessionBaseUrl,
|
|
@@ -52,7 +54,7 @@ function record(raw) {
|
|
|
52
54
|
export async function readOffer(productUrl, offerId) {
|
|
53
55
|
const { status, json } = await readJson(`${productUrl.replace(/\/$/, "")}/open/v1/playground/matches/${offerId}`);
|
|
54
56
|
if (status !== 200)
|
|
55
|
-
throw new Error(
|
|
57
|
+
throw new Error(refusalMessage("offer read", status, json));
|
|
56
58
|
return record(json);
|
|
57
59
|
}
|
|
58
60
|
/** Sign and submit this seat's acceptance. Already-accepted is not an error:
|
|
@@ -77,13 +79,14 @@ export async function acceptOffer(productUrl, offerId, seat, agent) {
|
|
|
77
79
|
accepted: true,
|
|
78
80
|
acceptance_nonce: Array.from(signed.acceptanceNonce),
|
|
79
81
|
signature: Array.from(signed.signature),
|
|
82
|
+
seat_possession_signature: Array.from(signed.seatPossessionSignature),
|
|
80
83
|
});
|
|
81
84
|
const { status, json } = await readJson(`${productUrl.replace(/\/$/, "")}/open/v1/playground/matches/${offerId}/acceptances`, { method: "POST", headers: { "content-type": "application/json" }, body });
|
|
82
85
|
if (status === 200 || status === 201)
|
|
83
86
|
return;
|
|
84
87
|
if (status === 409)
|
|
85
88
|
return;
|
|
86
|
-
throw new Error(
|
|
89
|
+
throw new Error(refusalMessage("acceptance", status, json));
|
|
87
90
|
}
|
|
88
91
|
async function admit(productUrl, offerId, agentId, agent) {
|
|
89
92
|
const target = `/open/v1/playground/matches/${offerId}/admit`;
|
|
@@ -97,8 +100,28 @@ async function admit(productUrl, offerId, agentId, agent) {
|
|
|
97
100
|
// either way, and the next read returns the same admission.
|
|
98
101
|
if (status === 200 || status === 201 || status === 409)
|
|
99
102
|
return;
|
|
100
|
-
|
|
103
|
+
/* A refusal the product marked retryable is a "not yet", and the caller is
|
|
104
|
+
already in a polling loop that will read the offer and ask again. Treated
|
|
105
|
+
as a refusal instead, it took the seat out: on a fresh stack two of three
|
|
106
|
+
seats died on `503 seat_registration_refused` -- the registry had not
|
|
107
|
+
accepted their registrations on the first pass -- while the third admitted
|
|
108
|
+
and played the table alone.
|
|
109
|
+
|
|
110
|
+
Only when the product says so, by `next` or, from a product older than
|
|
111
|
+
`next`, by `retryable`. A `503` that is not retryable is one an
|
|
112
|
+
operator has to act on (`settlement_funder_exhausted` is the case: a
|
|
113
|
+
funder's balance cannot pay for the sitting until somebody funds it), and
|
|
114
|
+
looping on that would spend the timeout hiding the one thing worth
|
|
115
|
+
reporting. */
|
|
116
|
+
if (json?.next?.action === "retry" || (status === 503 && json?.retryable === true))
|
|
117
|
+
return;
|
|
118
|
+
throw new Error(refusalMessage("admit", status, json));
|
|
101
119
|
}
|
|
120
|
+
/* The states the product will never move off. `expired` belongs here with the
|
|
121
|
+
other two: a playground offer carries a TTL, and once it lapses no seat can
|
|
122
|
+
accept and nothing can admit, so a wait that read on past it would spend its
|
|
123
|
+
whole timeout to report the wrong reason. */
|
|
124
|
+
const ENDED_STATES = new Set(["cancelled", "declined", "expired"]);
|
|
102
125
|
/** Accept this agent's seat, then wait for the offer to admit, admitting it
|
|
103
126
|
* as soon as every seat has accepted. */
|
|
104
127
|
export async function acceptAndAwaitAdmission(productUrl, offerId, agentId, agent, options = {}) {
|
|
@@ -124,13 +147,21 @@ export async function acceptAndAwaitAdmission(productUrl, offerId, agentId, agen
|
|
|
124
147
|
executionId,
|
|
125
148
|
};
|
|
126
149
|
}
|
|
150
|
+
/* Ahead of the admit, because an offer the product has closed is not one
|
|
151
|
+
to ask about; the state that closed it is the answer the caller wants. */
|
|
152
|
+
if (ENDED_STATES.has(offer.state))
|
|
153
|
+
throw new Error(`the offer is ${offer.state}`);
|
|
127
154
|
if (offer.acceptedSeats.length >= offer.seats.length) {
|
|
128
155
|
await admit(productUrl, offerId, agentId, agent);
|
|
129
|
-
continue;
|
|
130
156
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
157
|
+
else {
|
|
158
|
+
options.onWaiting?.(offer.acceptedSeats.length, offer.seats.length);
|
|
159
|
+
}
|
|
160
|
+
/* The deadline and the sleep are on the admit path too. A retryable
|
|
161
|
+
refusal reads as a "not yet" and is swallowed above, so a pass that
|
|
162
|
+
jumped straight back to the read would spin a core against a deployment
|
|
163
|
+
that answers `503 retryable` forever, and would never give up. The price
|
|
164
|
+
is one poll interval between admitting and reading the admission back. */
|
|
134
165
|
if (Date.now() >= deadline)
|
|
135
166
|
throw new Error(`offer ${offerId} did not admit; ${offer.acceptedSeats.length} of ${offer.seats.length} seats accepted`);
|
|
136
167
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { Transaction } from "@mysten/sui/transactions";
|
|
2
|
+
import { type AgentKeypair } from "./keypair.js";
|
|
3
|
+
import { ClientRefusal } from "./refusal.js";
|
|
4
|
+
/** The seat a tournament possession proof is made for. One proof serves every
|
|
5
|
+
* table: the settlement registry matches a registration on the key alone. */
|
|
6
|
+
export declare const TOURNAMENT_POSSESSION_SEAT = 1;
|
|
7
|
+
export interface TournamentOverview {
|
|
8
|
+
tournamentId: string;
|
|
9
|
+
packageId: string;
|
|
10
|
+
funder: string;
|
|
11
|
+
phase: "before_start" | "open" | "matchmaking_closed" | "finalizable" | "finalized";
|
|
12
|
+
nowMs: number;
|
|
13
|
+
startMs: number;
|
|
14
|
+
endMs: number;
|
|
15
|
+
closeMs: number;
|
|
16
|
+
level: number;
|
|
17
|
+
smallBlind: number;
|
|
18
|
+
bigBlind: number;
|
|
19
|
+
nextLevelAtMs?: number;
|
|
20
|
+
chipsPerTicket: number;
|
|
21
|
+
ticketsPerBundle: number;
|
|
22
|
+
/** What an agent's transaction needs to name the tournament's objects. */
|
|
23
|
+
chain?: TournamentChainObjects;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
/** The shared objects a tournament transaction takes, with the versions they
|
|
27
|
+
* were shared at, and the chip's coin type. */
|
|
28
|
+
export interface TournamentChainObjects {
|
|
29
|
+
tournamentInitialSharedVersion: number;
|
|
30
|
+
chipTreasuryId: string;
|
|
31
|
+
chipTreasuryInitialSharedVersion: number;
|
|
32
|
+
chipType: string;
|
|
33
|
+
}
|
|
34
|
+
export interface TournamentAgentEntry {
|
|
35
|
+
chipAddress: string;
|
|
36
|
+
agentId?: string;
|
|
37
|
+
owner: string;
|
|
38
|
+
state: "idle" | "queued" | "seated";
|
|
39
|
+
balance: number;
|
|
40
|
+
ticketsRedeemed: number;
|
|
41
|
+
lastSettleMs: number;
|
|
42
|
+
joinedAtMs: number;
|
|
43
|
+
tunnelId?: string;
|
|
44
|
+
runOut: boolean;
|
|
45
|
+
rank: number;
|
|
46
|
+
/** The chips the agent's address holds, which `queue_join` must match. */
|
|
47
|
+
walletBalance?: number;
|
|
48
|
+
}
|
|
49
|
+
/** An owner's entry pass, the NFT its wallet holds. */
|
|
50
|
+
export interface TournamentPass {
|
|
51
|
+
owner: string;
|
|
52
|
+
passId: string;
|
|
53
|
+
playsLeft: number;
|
|
54
|
+
minted: number;
|
|
55
|
+
}
|
|
56
|
+
/** One of an owner's agents, as the owner route answers it. */
|
|
57
|
+
export interface TournamentOwnerAgent {
|
|
58
|
+
agentId: string;
|
|
59
|
+
name?: string;
|
|
60
|
+
chipAddress: string;
|
|
61
|
+
playsHeld: number;
|
|
62
|
+
/** The plays it holds. An agent enters by naming one as a shared object. */
|
|
63
|
+
plays: TournamentPlay[];
|
|
64
|
+
}
|
|
65
|
+
/** One play, named the way a transaction must name it. */
|
|
66
|
+
export interface TournamentPlay {
|
|
67
|
+
playId: string;
|
|
68
|
+
initialSharedVersion: number;
|
|
69
|
+
}
|
|
70
|
+
export interface TournamentOwnerView {
|
|
71
|
+
owner: string;
|
|
72
|
+
pass?: TournamentPass;
|
|
73
|
+
agents: TournamentOwnerAgent[];
|
|
74
|
+
}
|
|
75
|
+
export type TournamentQueueEntry = {
|
|
76
|
+
state: "not_entered";
|
|
77
|
+
chipAddress: string;
|
|
78
|
+
} | {
|
|
79
|
+
state: "idle";
|
|
80
|
+
chipAddress: string;
|
|
81
|
+
balance: number;
|
|
82
|
+
} | {
|
|
83
|
+
state: "queued";
|
|
84
|
+
chipAddress: string;
|
|
85
|
+
stack: number;
|
|
86
|
+
joinedAtMs: number;
|
|
87
|
+
waiting: number;
|
|
88
|
+
} | {
|
|
89
|
+
state: "composing";
|
|
90
|
+
chipAddress: string;
|
|
91
|
+
stack: number;
|
|
92
|
+
} | {
|
|
93
|
+
state: "seated";
|
|
94
|
+
chipAddress: string;
|
|
95
|
+
offerId?: string;
|
|
96
|
+
seat?: number;
|
|
97
|
+
executionId?: string;
|
|
98
|
+
};
|
|
99
|
+
export interface TournamentClient {
|
|
100
|
+
productUrl: string;
|
|
101
|
+
agent: AgentKeypair;
|
|
102
|
+
/** 32 bytes */
|
|
103
|
+
agentId: Uint8Array;
|
|
104
|
+
tournamentId: string;
|
|
105
|
+
/** Injected in tests; the global fetch otherwise. */
|
|
106
|
+
fetchImpl?: typeof fetch;
|
|
107
|
+
}
|
|
108
|
+
/** A refusal the product named, kept whole so a caller can say why. */
|
|
109
|
+
export declare class TournamentRefusal extends Error {
|
|
110
|
+
readonly status: number;
|
|
111
|
+
readonly body: unknown;
|
|
112
|
+
constructor(status: number, body: unknown, what: string);
|
|
113
|
+
get code(): string | undefined;
|
|
114
|
+
}
|
|
115
|
+
export declare function readTournament(productUrl: string, tournamentId: string, fetchImpl?: typeof fetch): Promise<TournamentOverview>;
|
|
116
|
+
/** The open-entry tournaments the product runs. */
|
|
117
|
+
export declare function listTournaments(productUrl: string, fetchImpl?: typeof fetch): Promise<TournamentOverview[]>;
|
|
118
|
+
/** One of the tournament's retained tables. */
|
|
119
|
+
export interface TournamentMatch {
|
|
120
|
+
executionId: string;
|
|
121
|
+
offerId: string;
|
|
122
|
+
/** When a dispute against this table's tunnel can be closed, read from the
|
|
123
|
+
* tunnel. Absent on every table nobody disputed, which is the ordinary
|
|
124
|
+
* case. */
|
|
125
|
+
disputedUntilMs?: number;
|
|
126
|
+
seats: {
|
|
127
|
+
seat: number;
|
|
128
|
+
agentId: string;
|
|
129
|
+
chipAddress: string;
|
|
130
|
+
}[];
|
|
131
|
+
}
|
|
132
|
+
/** The tables this tournament has composed, newest state included.
|
|
133
|
+
*
|
|
134
|
+
* The only place `disputedUntilMs` is published: an agent whose table is under
|
|
135
|
+
* dispute learns it here or not at all, and the book still shows it seated
|
|
136
|
+
* meanwhile. */
|
|
137
|
+
export declare function readTournamentMatches(productUrl: string, tournamentId: string, fetchImpl?: typeof fetch): Promise<TournamentMatch[]>;
|
|
138
|
+
/** When this chip address's table is under dispute, and until when.
|
|
139
|
+
*
|
|
140
|
+
* Unknown counts as no dispute: a read that did not answer is no reason to
|
|
141
|
+
* tell an agent its payout is held.
|
|
142
|
+
*/
|
|
143
|
+
export declare function disputeHolding(productUrl: string, tournamentId: string, chipAddress: string, fetchImpl?: typeof fetch): Promise<number | undefined>;
|
|
144
|
+
/** The agent's entry in the chip book, or `null` before its first redeem. */
|
|
145
|
+
export declare function readAgentEntry(productUrl: string, tournamentId: string, chipAddress: string, fetchImpl?: typeof fetch): Promise<TournamentAgentEntry | null>;
|
|
146
|
+
/** An owner's pass, or `null` when the owner has claimed none. */
|
|
147
|
+
export declare function readPass(productUrl: string, tournamentId: string, owner: string, fetchImpl?: typeof fetch): Promise<TournamentPass | null>;
|
|
148
|
+
/** An owner's side: its pass and every agent it has claimed, each with the
|
|
149
|
+
* plays it holds. This is where an agent finds the play it enters on. */
|
|
150
|
+
export declare function readOwner(productUrl: string, tournamentId: string, owner: string, fetchImpl?: typeof fetch): Promise<TournamentOwnerView>;
|
|
151
|
+
/** The plays `chipAddress` holds under `owner`, oldest first. */
|
|
152
|
+
export declare function playsHeldBy(productUrl: string, tournamentId: string, owner: string, chipAddress: string, fetchImpl?: typeof fetch): Promise<TournamentPlay[]>;
|
|
153
|
+
/** The agent's possession proof: its key over the seat-1 possession message. */
|
|
154
|
+
export declare function tournamentPossessionSignature(agent: AgentKeypair): Promise<string>;
|
|
155
|
+
/** Present the agent to matchmaking and read where it stands. Cheap and
|
|
156
|
+
* idempotent: an agent polls this while it waits. */
|
|
157
|
+
export declare function presentToTournament(client: TournamentClient): Promise<TournamentQueueEntry>;
|
|
158
|
+
export interface JoinPlan {
|
|
159
|
+
/** Redeem first: the agent has run out and holds a play to spend. */
|
|
160
|
+
redeem: boolean;
|
|
161
|
+
/** The play a redeem spends. */
|
|
162
|
+
play?: TournamentPlay;
|
|
163
|
+
/** The chips taken from the agent's address: its booked balance, which is
|
|
164
|
+
* the dust a redeem burns or the stack `queue_join` escrows. */
|
|
165
|
+
withdraw: number;
|
|
166
|
+
}
|
|
167
|
+
/** Why `join` refuses to build anything, said before a transaction is sent
|
|
168
|
+
* the chain would abort, with the move that follows from it. */
|
|
169
|
+
export declare class JoinRefused extends ClientRefusal {
|
|
170
|
+
}
|
|
171
|
+
/** Decide what `join` sends, from the book and the agent's wallet.
|
|
172
|
+
*
|
|
173
|
+
* `queue_join` takes a coin of exactly the booked balance, and `redeem` burns
|
|
174
|
+
* a coin of exactly the booked balance. Chips the agent moved away are not
|
|
175
|
+
* lost to the book, only unplayable, so a wallet that differs from the book
|
|
176
|
+
* is refused here with the reason rather than on chain without one. */
|
|
177
|
+
export declare function planJoin(overview: TournamentOverview, entry: TournamentAgentEntry | null,
|
|
178
|
+
/** The plays this agent holds; the first is the one it spends. */
|
|
179
|
+
plays: readonly TournamentPlay[], owner: string | undefined): JoinPlan;
|
|
180
|
+
/** The line an `--until-out` run ends on once matchmaking is over.
|
|
181
|
+
*
|
|
182
|
+
* It used to put the phase after "matchmaking is", which read "matchmaking
|
|
183
|
+
* is matchmaking closed" for the phase that says so. The phase is kept as the
|
|
184
|
+
* wire spells it, so a reader can branch on it. */
|
|
185
|
+
export declare function matchmakingOverLine(phase: TournamentOverview["phase"], stillQueued: boolean): string;
|
|
186
|
+
/** The transaction `join` sends: optionally redeem, then `queue_join`, in one
|
|
187
|
+
* programmable transaction, the minted coin passed straight into the escrow. */
|
|
188
|
+
export declare function joinTransaction(overview: TournamentOverview, plan: JoinPlan, sender: string): Transaction;
|
|
189
|
+
/** The transaction `give-back` sends: the play stays in the world naming
|
|
190
|
+
* nobody, so its owner can place it again without a second signature from an
|
|
191
|
+
* agent that has already walked away. */
|
|
192
|
+
export declare function giveBackTransaction(overview: TournamentOverview, play: TournamentPlay, sender: string): Transaction;
|
|
193
|
+
/** The transaction `leave` sends: `queue_leave`, and the chips it returns sent
|
|
194
|
+
* back to the agent's own address balance. */
|
|
195
|
+
export declare function leaveTransaction(overview: TournamentOverview, sender: string): Transaction;
|
|
196
|
+
export interface SponsoredExecution {
|
|
197
|
+
digest: string;
|
|
198
|
+
status: "success" | "failure";
|
|
199
|
+
error?: string;
|
|
200
|
+
}
|
|
201
|
+
/** Have the product sponsor `tx` and execute it with the agent's signature.
|
|
202
|
+
*
|
|
203
|
+
* The product builds the gas around the agent's transaction kind, the agent
|
|
204
|
+
* signs the whole transaction, and the product adds its sponsor signature
|
|
205
|
+
* and submits. It refuses any transaction that does more than enter, redeem
|
|
206
|
+
* into, or leave this tournament. */
|
|
207
|
+
export declare function sponsorAndExecute(client: TournamentClient, tx: Transaction): Promise<SponsoredExecution>;
|
|
208
|
+
/** The agent's chip address as the book keys it. */
|
|
209
|
+
export declare function chipAddressOf(agent: AgentKeypair): string;
|
|
210
|
+
/** Parse a hex id argument, accepting it with or without `0x`. */
|
|
211
|
+
export declare function tournamentIdArg(value: string): string;
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/* The open-entry tournament from an agent's side, on its own key.
|
|
2
|
+
*
|
|
3
|
+
* Entering is not a request to the product. The agent's own transaction does
|
|
4
|
+
* it on chain: `queue_join` escrows exactly its booked balance, and when it has
|
|
5
|
+
* run out, `redeem` in the same transaction burns what is left and mints a
|
|
6
|
+
* fresh stack from its owner's bundle. The product pays the gas and co-signs
|
|
7
|
+
* as sponsor; it never holds the chips and cannot sign for the agent.
|
|
8
|
+
*
|
|
9
|
+
* What the product does need from the agent is a possession proof of its key,
|
|
10
|
+
* presented once, so the platform can register the key for settlement before
|
|
11
|
+
* each table. The same request answers where the agent stands, and names the
|
|
12
|
+
* offer once it is seated.
|
|
13
|
+
*/
|
|
14
|
+
import { Transaction } from "@mysten/sui/transactions";
|
|
15
|
+
import { AGENT_HTTP_CAPABILITY_HEADER, mintAgentHttpCapability, } from "./agentHttp.js";
|
|
16
|
+
import { seatPossessionMessage } from "./acceptance.js";
|
|
17
|
+
import { fromHex, textBytes, toHex0x } from "./bytes.js";
|
|
18
|
+
import { signRaw } from "./keypair.js";
|
|
19
|
+
import { ClientRefusal, refusalMessage } from "./refusal.js";
|
|
20
|
+
/** The seat a tournament possession proof is made for. One proof serves every
|
|
21
|
+
* table: the settlement registry matches a registration on the key alone. */
|
|
22
|
+
export const TOURNAMENT_POSSESSION_SEAT = 1;
|
|
23
|
+
/** The Sui system clock. */
|
|
24
|
+
const CLOCK = "0x6";
|
|
25
|
+
function base(productUrl) {
|
|
26
|
+
return productUrl.replace(/\/$/, "");
|
|
27
|
+
}
|
|
28
|
+
function tournamentPath(tournamentId, rest = "") {
|
|
29
|
+
return `/open/v1/tournaments/${tournamentId}${rest}`;
|
|
30
|
+
}
|
|
31
|
+
async function readJson(fetchImpl, url) {
|
|
32
|
+
const response = await fetchImpl(url);
|
|
33
|
+
const text = await response.text();
|
|
34
|
+
return {
|
|
35
|
+
status: response.status,
|
|
36
|
+
json: text.length === 0 ? undefined : JSON.parse(text),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** A refusal the product named, kept whole so a caller can say why. */
|
|
40
|
+
export class TournamentRefusal extends Error {
|
|
41
|
+
status;
|
|
42
|
+
body;
|
|
43
|
+
constructor(status, body, what) {
|
|
44
|
+
super(refusalMessage(what, status, body));
|
|
45
|
+
this.status = status;
|
|
46
|
+
this.body = body;
|
|
47
|
+
}
|
|
48
|
+
get code() {
|
|
49
|
+
return this.body?.code;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export async function readTournament(productUrl, tournamentId, fetchImpl = fetch) {
|
|
53
|
+
const { status, json } = await readJson(fetchImpl, `${base(productUrl)}${tournamentPath(tournamentId)}`);
|
|
54
|
+
if (status !== 200)
|
|
55
|
+
throw new TournamentRefusal(status, json, "tournament read");
|
|
56
|
+
return json;
|
|
57
|
+
}
|
|
58
|
+
/** The open-entry tournaments the product runs. */
|
|
59
|
+
export async function listTournaments(productUrl, fetchImpl = fetch) {
|
|
60
|
+
const { status, json } = await readJson(fetchImpl, `${base(productUrl)}/open/v1/tournaments`);
|
|
61
|
+
if (status === 404)
|
|
62
|
+
return [];
|
|
63
|
+
if (status !== 200)
|
|
64
|
+
throw new TournamentRefusal(status, json, "tournament list");
|
|
65
|
+
return json.tournaments;
|
|
66
|
+
}
|
|
67
|
+
/** The tables this tournament has composed, newest state included.
|
|
68
|
+
*
|
|
69
|
+
* The only place `disputedUntilMs` is published: an agent whose table is under
|
|
70
|
+
* dispute learns it here or not at all, and the book still shows it seated
|
|
71
|
+
* meanwhile. */
|
|
72
|
+
export async function readTournamentMatches(productUrl, tournamentId, fetchImpl = fetch) {
|
|
73
|
+
const { status, json } = await readJson(fetchImpl, `${base(productUrl)}${tournamentPath(tournamentId)}/matches`);
|
|
74
|
+
if (status !== 200)
|
|
75
|
+
throw new TournamentRefusal(status, json, "tournament matches read");
|
|
76
|
+
return json.matches;
|
|
77
|
+
}
|
|
78
|
+
/** When this chip address's table is under dispute, and until when.
|
|
79
|
+
*
|
|
80
|
+
* Unknown counts as no dispute: a read that did not answer is no reason to
|
|
81
|
+
* tell an agent its payout is held.
|
|
82
|
+
*/
|
|
83
|
+
export async function disputeHolding(productUrl, tournamentId, chipAddress, fetchImpl = fetch) {
|
|
84
|
+
try {
|
|
85
|
+
const matches = await readTournamentMatches(productUrl, tournamentId, fetchImpl);
|
|
86
|
+
const mine = matches.find((match) => match.disputedUntilMs !== undefined &&
|
|
87
|
+
match.seats.some((seat) => seat.chipAddress === chipAddress));
|
|
88
|
+
return mine?.disputedUntilMs;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** The agent's entry in the chip book, or `null` before its first redeem. */
|
|
95
|
+
export async function readAgentEntry(productUrl, tournamentId, chipAddress, fetchImpl = fetch) {
|
|
96
|
+
const { status, json } = await readJson(fetchImpl, `${base(productUrl)}${tournamentPath(tournamentId, `/agents/${chipAddress}`)}`);
|
|
97
|
+
if (status === 404 && json?.code === "not_in_tournament")
|
|
98
|
+
return null;
|
|
99
|
+
if (status !== 200)
|
|
100
|
+
throw new TournamentRefusal(status, json, "chip book read");
|
|
101
|
+
return json;
|
|
102
|
+
}
|
|
103
|
+
/** An owner's pass, or `null` when the owner has claimed none. */
|
|
104
|
+
export async function readPass(productUrl, tournamentId, owner, fetchImpl = fetch) {
|
|
105
|
+
const { status, json } = await readJson(fetchImpl, `${base(productUrl)}${tournamentPath(tournamentId, `/passes/${owner}`)}`);
|
|
106
|
+
if (status === 404 && json?.code === "not_in_tournament")
|
|
107
|
+
return null;
|
|
108
|
+
if (status !== 200)
|
|
109
|
+
throw new TournamentRefusal(status, json, "pass read");
|
|
110
|
+
return json;
|
|
111
|
+
}
|
|
112
|
+
/** An owner's side: its pass and every agent it has claimed, each with the
|
|
113
|
+
* plays it holds. This is where an agent finds the play it enters on. */
|
|
114
|
+
export async function readOwner(productUrl, tournamentId, owner, fetchImpl = fetch) {
|
|
115
|
+
const { status, json } = await readJson(fetchImpl, `${base(productUrl)}${tournamentPath(tournamentId, `/owners/${owner}`)}`);
|
|
116
|
+
if (status !== 200)
|
|
117
|
+
throw new TournamentRefusal(status, json, "owner read");
|
|
118
|
+
return json;
|
|
119
|
+
}
|
|
120
|
+
/** The plays `chipAddress` holds under `owner`, oldest first. */
|
|
121
|
+
export async function playsHeldBy(productUrl, tournamentId, owner, chipAddress, fetchImpl = fetch) {
|
|
122
|
+
const view = await readOwner(productUrl, tournamentId, owner, fetchImpl);
|
|
123
|
+
const mine = view.agents.find((agent) => agent.chipAddress.toLowerCase() === chipAddress.toLowerCase());
|
|
124
|
+
return mine?.plays ?? [];
|
|
125
|
+
}
|
|
126
|
+
/** One capability-signed POST. The signature covers the exact body bytes, so
|
|
127
|
+
* they are sent verbatim. */
|
|
128
|
+
async function signedPost(client, target, body) {
|
|
129
|
+
const payload = textBytes(JSON.stringify(body));
|
|
130
|
+
const { header } = await mintAgentHttpCapability(client.agent, client.agentId, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
requestTarget: target,
|
|
133
|
+
body: payload,
|
|
134
|
+
});
|
|
135
|
+
const response = await (client.fetchImpl ?? fetch)(`${base(client.productUrl)}${target}`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: {
|
|
138
|
+
[AGENT_HTTP_CAPABILITY_HEADER]: header,
|
|
139
|
+
"content-type": "application/json",
|
|
140
|
+
},
|
|
141
|
+
body: payload,
|
|
142
|
+
});
|
|
143
|
+
const text = await response.text();
|
|
144
|
+
return {
|
|
145
|
+
status: response.status,
|
|
146
|
+
json: text.length === 0 ? undefined : JSON.parse(text),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** The agent's possession proof: its key over the seat-1 possession message. */
|
|
150
|
+
export async function tournamentPossessionSignature(agent) {
|
|
151
|
+
return toHex0x(await signRaw(agent, seatPossessionMessage(TOURNAMENT_POSSESSION_SEAT)));
|
|
152
|
+
}
|
|
153
|
+
/** Present the agent to matchmaking and read where it stands. Cheap and
|
|
154
|
+
* idempotent: an agent polls this while it waits. */
|
|
155
|
+
export async function presentToTournament(client) {
|
|
156
|
+
const { status, json } = await signedPost(client, tournamentPath(client.tournamentId, "/entries"), { seatPossessionSignature: await tournamentPossessionSignature(client.agent) });
|
|
157
|
+
if (status !== 200)
|
|
158
|
+
throw new TournamentRefusal(status, json, "tournament entry");
|
|
159
|
+
return json;
|
|
160
|
+
}
|
|
161
|
+
/** Why `join` refuses to build anything, said before a transaction is sent
|
|
162
|
+
* the chain would abort, with the move that follows from it. */
|
|
163
|
+
export class JoinRefused extends ClientRefusal {
|
|
164
|
+
}
|
|
165
|
+
/** Decide what `join` sends, from the book and the agent's wallet.
|
|
166
|
+
*
|
|
167
|
+
* `queue_join` takes a coin of exactly the booked balance, and `redeem` burns
|
|
168
|
+
* a coin of exactly the booked balance. Chips the agent moved away are not
|
|
169
|
+
* lost to the book, only unplayable, so a wallet that differs from the book
|
|
170
|
+
* is refused here with the reason rather than on chain without one. */
|
|
171
|
+
export function planJoin(overview, entry,
|
|
172
|
+
/** The plays this agent holds; the first is the one it spends. */
|
|
173
|
+
plays, owner) {
|
|
174
|
+
if (overview.phase !== "open")
|
|
175
|
+
throw new JoinRefused(`the tournament is ${overview.phase.replace("_", " ")}; redeem and queue join are open from its start until matchmaking closes`, overview.phase === "before_start"
|
|
176
|
+
? { action: "wait", until_ms: overview.startMs }
|
|
177
|
+
: { action: "stop" });
|
|
178
|
+
if (entry && entry.state !== "idle")
|
|
179
|
+
throw new JoinRefused(`this agent is already ${entry.state}${entry.state === "seated" ? " at a table" : ""}; an agent plays one table at a time`, {
|
|
180
|
+
action: "wait",
|
|
181
|
+
poll: `/open/v1/tournaments/${overview.tournamentId}/agents/${entry.chipAddress}`,
|
|
182
|
+
});
|
|
183
|
+
const booked = entry?.balance ?? 0;
|
|
184
|
+
const wallet = entry?.walletBalance ?? booked;
|
|
185
|
+
if (entry && wallet !== booked)
|
|
186
|
+
throw new JoinRefused(`the wallet holds ${wallet} chips but the book holds ${booked}; queue_join and redeem both take exactly the booked balance, so move chips back to this address (or away) until the two agree`, { action: "stop" });
|
|
187
|
+
const runOut = entry === null || entry.runOut;
|
|
188
|
+
if (!runOut)
|
|
189
|
+
return { redeem: false, withdraw: booked };
|
|
190
|
+
const drawFrom = entry?.owner ?? owner;
|
|
191
|
+
if (!drawFrom)
|
|
192
|
+
throw new JoinRefused("this agent has no chips yet: name the owner that hands it plays (--owner 0x...)", { action: "use", command: "dopa-open tournament join --owner 0x<owner>" });
|
|
193
|
+
const play = plays[0];
|
|
194
|
+
if (!play)
|
|
195
|
+
throw new JoinRefused(`this agent has run out and holds no play from ${drawFrom}; ask its owner to hand it one`, { action: "ask_owner", what: "hand_out" });
|
|
196
|
+
return { redeem: true, play, withdraw: booked };
|
|
197
|
+
}
|
|
198
|
+
/** The line an `--until-out` run ends on once matchmaking is over.
|
|
199
|
+
*
|
|
200
|
+
* It used to put the phase after "matchmaking is", which read "matchmaking
|
|
201
|
+
* is matchmaking closed" for the phase that says so. The phase is kept as the
|
|
202
|
+
* wire spells it, so a reader can branch on it. */
|
|
203
|
+
export function matchmakingOverLine(phase, stillQueued) {
|
|
204
|
+
return `out matchmaking has closed (phase ${phase})${stillQueued ? " and no table formed for this agent" : ""}`;
|
|
205
|
+
}
|
|
206
|
+
function requireChain(overview) {
|
|
207
|
+
if (!overview.chain)
|
|
208
|
+
throw new Error("this product does not publish the tournament's chain objects, so no transaction can name them");
|
|
209
|
+
return overview.chain;
|
|
210
|
+
}
|
|
211
|
+
/** The transaction `join` sends: optionally redeem, then `queue_join`, in one
|
|
212
|
+
* programmable transaction, the minted coin passed straight into the escrow. */
|
|
213
|
+
export function joinTransaction(overview, plan, sender) {
|
|
214
|
+
const chain = requireChain(overview);
|
|
215
|
+
const tx = new Transaction();
|
|
216
|
+
tx.setSender(sender);
|
|
217
|
+
const tournament = tx.sharedObjectRef({
|
|
218
|
+
objectId: overview.tournamentId,
|
|
219
|
+
initialSharedVersion: chain.tournamentInitialSharedVersion,
|
|
220
|
+
mutable: true,
|
|
221
|
+
});
|
|
222
|
+
const clock = tx.sharedObjectRef({
|
|
223
|
+
objectId: CLOCK,
|
|
224
|
+
initialSharedVersion: 1,
|
|
225
|
+
mutable: false,
|
|
226
|
+
});
|
|
227
|
+
const booked = plan.withdraw > 0
|
|
228
|
+
? tx.moveCall({
|
|
229
|
+
target: "0x2::coin::redeem_funds",
|
|
230
|
+
typeArguments: [chain.chipType],
|
|
231
|
+
arguments: [tx.withdrawal({ amount: plan.withdraw, type: chain.chipType })],
|
|
232
|
+
})
|
|
233
|
+
: tx.moveCall({ target: "0x2::coin::zero", typeArguments: [chain.chipType] });
|
|
234
|
+
let stack = booked;
|
|
235
|
+
if (plan.redeem) {
|
|
236
|
+
if (!plan.play)
|
|
237
|
+
throw new Error("a redeem names the play it spends");
|
|
238
|
+
stack = tx.moveCall({
|
|
239
|
+
target: `${overview.packageId}::tournament::redeem`,
|
|
240
|
+
arguments: [
|
|
241
|
+
tournament,
|
|
242
|
+
tx.sharedObjectRef({
|
|
243
|
+
objectId: chain.chipTreasuryId,
|
|
244
|
+
initialSharedVersion: chain.chipTreasuryInitialSharedVersion,
|
|
245
|
+
mutable: true,
|
|
246
|
+
}),
|
|
247
|
+
// Taken by value: the call deletes it, so the reference is mutable.
|
|
248
|
+
tx.sharedObjectRef({
|
|
249
|
+
objectId: plan.play.playId,
|
|
250
|
+
initialSharedVersion: plan.play.initialSharedVersion,
|
|
251
|
+
mutable: true,
|
|
252
|
+
}),
|
|
253
|
+
booked,
|
|
254
|
+
clock,
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
tx.moveCall({
|
|
259
|
+
target: `${overview.packageId}::tournament::queue_join`,
|
|
260
|
+
arguments: [tournament, stack, clock],
|
|
261
|
+
});
|
|
262
|
+
return tx;
|
|
263
|
+
}
|
|
264
|
+
/** The transaction `give-back` sends: the play stays in the world naming
|
|
265
|
+
* nobody, so its owner can place it again without a second signature from an
|
|
266
|
+
* agent that has already walked away. */
|
|
267
|
+
export function giveBackTransaction(overview, play, sender) {
|
|
268
|
+
const tx = new Transaction();
|
|
269
|
+
tx.setSender(sender);
|
|
270
|
+
tx.moveCall({
|
|
271
|
+
target: `${overview.packageId}::tournament::give_back`,
|
|
272
|
+
arguments: [
|
|
273
|
+
tx.sharedObjectRef({
|
|
274
|
+
objectId: play.playId,
|
|
275
|
+
initialSharedVersion: play.initialSharedVersion,
|
|
276
|
+
mutable: true,
|
|
277
|
+
}),
|
|
278
|
+
],
|
|
279
|
+
});
|
|
280
|
+
return tx;
|
|
281
|
+
}
|
|
282
|
+
/** The transaction `leave` sends: `queue_leave`, and the chips it returns sent
|
|
283
|
+
* back to the agent's own address balance. */
|
|
284
|
+
export function leaveTransaction(overview, sender) {
|
|
285
|
+
const chain = requireChain(overview);
|
|
286
|
+
const tx = new Transaction();
|
|
287
|
+
tx.setSender(sender);
|
|
288
|
+
const coin = tx.moveCall({
|
|
289
|
+
target: `${overview.packageId}::tournament::queue_leave`,
|
|
290
|
+
arguments: [
|
|
291
|
+
tx.sharedObjectRef({
|
|
292
|
+
objectId: overview.tournamentId,
|
|
293
|
+
initialSharedVersion: chain.tournamentInitialSharedVersion,
|
|
294
|
+
mutable: true,
|
|
295
|
+
}),
|
|
296
|
+
],
|
|
297
|
+
});
|
|
298
|
+
tx.moveCall({
|
|
299
|
+
target: "0x2::coin::send_funds",
|
|
300
|
+
typeArguments: [chain.chipType],
|
|
301
|
+
arguments: [coin, tx.pure.address(sender)],
|
|
302
|
+
});
|
|
303
|
+
return tx;
|
|
304
|
+
}
|
|
305
|
+
function base64(bytes) {
|
|
306
|
+
return Buffer.from(bytes).toString("base64");
|
|
307
|
+
}
|
|
308
|
+
/** Have the product sponsor `tx` and execute it with the agent's signature.
|
|
309
|
+
*
|
|
310
|
+
* The product builds the gas around the agent's transaction kind, the agent
|
|
311
|
+
* signs the whole transaction, and the product adds its sponsor signature
|
|
312
|
+
* and submits. It refuses any transaction that does more than enter, redeem
|
|
313
|
+
* into, or leave this tournament. */
|
|
314
|
+
export async function sponsorAndExecute(client, tx) {
|
|
315
|
+
const kind = await tx.build({ onlyTransactionKind: true });
|
|
316
|
+
const sender = client.agent.ownerAddressHex;
|
|
317
|
+
const prepared = await signedPost(client, tournamentPath(client.tournamentId, "/sponsored-transactions"), { kind: base64(kind), sender });
|
|
318
|
+
if (prepared.status !== 200)
|
|
319
|
+
throw new TournamentRefusal(prepared.status, prepared.json, "sponsorship");
|
|
320
|
+
const transaction = prepared.json.transaction;
|
|
321
|
+
const { signature } = await client.agent.keypair.signTransaction(Uint8Array.from(Buffer.from(transaction, "base64")));
|
|
322
|
+
const executed = await signedPost(client, tournamentPath(client.tournamentId, "/sponsored-transactions/execute"), { transaction, signature });
|
|
323
|
+
if (executed.status !== 200)
|
|
324
|
+
throw new TournamentRefusal(executed.status, executed.json, "sponsored execution");
|
|
325
|
+
return executed.json;
|
|
326
|
+
}
|
|
327
|
+
/** The agent's chip address as the book keys it. */
|
|
328
|
+
export function chipAddressOf(agent) {
|
|
329
|
+
return agent.ownerAddressHex;
|
|
330
|
+
}
|
|
331
|
+
/** Parse a hex id argument, accepting it with or without `0x`. */
|
|
332
|
+
export function tournamentIdArg(value) {
|
|
333
|
+
const bytes = fromHex(value.replace(/^0x/, ""));
|
|
334
|
+
if (bytes.length !== 32)
|
|
335
|
+
throw new Error("a tournament id is 32 bytes of hex");
|
|
336
|
+
return toHex0x(bytes);
|
|
337
|
+
}
|