@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/dist/cli.js CHANGED
@@ -12,16 +12,21 @@ import { randomBytes } from "node:crypto";
12
12
  import { dirname, join, resolve } from "node:path";
13
13
  import { pathToFileURL } from "node:url";
14
14
  import { fromHex, toHex } from "./bytes.js";
15
+ import { DEFAULT_SEAT_STATE_FILE, loadSeatState, saveSeatState, } from "./seatState.js";
16
+ import { newSeatState, openTurn, seatAgentId, submitTurn, } from "./seatTurn.js";
15
17
  import { nameAgent } from "./identity.js";
16
18
  import { AGENT_HTTP_CAPABILITY_HEADER, mintAgentHttpCapability, } from "./agentHttp.js";
17
19
  import { DEFAULT_KEY_FILE, generateKeypair, loadKeypair, saveKeypair, signOwnerAuthenticator, signRaw, } from "./keypair.js";
18
20
  import { deriveAgentId, registerCanonicalPayload, revokeCanonicalPayload, rotateCanonicalPayload, } from "./registration.js";
19
- import { playReportLines, playSeat } from "./session.js";
20
- import { claimInviteLink, encodeClaimInvite, mintClaimInvite, } from "./claim.js";
21
+ import { agentReadCapability, playReportLines, playSeat, readDisclosedEntitlement, } from "./session.js";
22
+ import { claimInviteLink, encodeClaimInvite, encodeClaimInviteCompact, mintClaimInvite, } from "./claim.js";
21
23
  import { acceptAndAwaitAdmission } from "./offer.js";
22
24
  import { playTour, queueUntilSeated } from "./tour.js";
25
+ import { joinRoomWhenComposed, MIN_ROOM_SEATS, openRoom, roomInvitePrompt, } from "./room.js";
26
+ import { disputeHolding, JoinRefused, joinTransaction, leaveTransaction, listTournaments, matchmakingOverLine, planJoin, presentToTournament, readAgentEntry, playsHeldBy, giveBackTransaction, readTournament, sponsorAndExecute, tournamentIdArg, } from "./openTournament.js";
23
27
  import { authorityOriginFromSessionBase, buildConsentRequest, digestForPrompt, settlementConsentPath, verifyConsentDisclosure, } from "./settlement.js";
24
- import { actionSigningBytes, joinSigningBytes, resumeSigningBytes, } from "./sessionWire.js";
28
+ import { actionSigningBytes, joinSigningBytes, requireSessionVersion, resumeSigningBytes, SESSION_VERSION, } from "./sessionWire.js";
29
+ import { describeNext, refusalMessageFromText } from "./refusal.js";
25
30
  function fail(message) {
26
31
  console.error(`dopa-open: ${message}`);
27
32
  process.exit(1);
@@ -50,9 +55,11 @@ function identity(agent) {
50
55
  };
51
56
  }
52
57
  function readContext(raw) {
58
+ const sessionVersion = num(raw.session_version, "session_version");
59
+ requireSessionVersion(sessionVersion);
53
60
  return {
54
61
  wireVersion: num(raw.wire_version, "wire_version"),
55
- sessionVersion: num(raw.session_version, "session_version"),
62
+ sessionVersion,
56
63
  sessionId: hex(raw.session_id, "session_id"),
57
64
  executionId: hex(raw.execution_id, "execution_id"),
58
65
  executionManifestDigest: hex(raw.execution_manifest_digest, "execution_manifest_digest"),
@@ -91,10 +98,11 @@ async function commandRegister(args) {
91
98
  options: {
92
99
  key: { type: "string", default: DEFAULT_KEY_FILE },
93
100
  "product-url": { type: "string" },
94
- // versions accepted as comma-separated lists; every deployment today
95
- // speaks version 1 on all three axes
101
+ // Versions accepted as comma-separated lists. The three axes are
102
+ // independent: the Product API and the DOPA-OPEN protocol are at 1,
103
+ // while the participant session axis is the overlay-bearing contract.
96
104
  "product-api-versions": { type: "string", default: "1" },
97
- "session-versions": { type: "string", default: "1" },
105
+ "session-versions": { type: "string", default: `${SESSION_VERSION}` },
98
106
  "protocol-versions": { type: "string", default: "1" },
99
107
  "expires-at-ms": { type: "string" },
100
108
  /* What the agent is called at a table. Optional, and set right after
@@ -355,7 +363,7 @@ async function commandKey(args) {
355
363
  });
356
364
  const body = await response.text();
357
365
  if (!response.ok)
358
- fail(`${retire ? "retirement" : "rotation"} refused (${response.status}): ${body}`);
366
+ fail(refusalMessageFromText(retire ? "retirement" : "rotation", response.status, body));
359
367
  console.log(body);
360
368
  }
361
369
  async function commandSign(args) {
@@ -378,7 +386,10 @@ async function commandSign(args) {
378
386
  if (kind === "join") {
379
387
  preimage = joinSigningBytes({
380
388
  wireVersion: num(raw.wire_version, "wire_version"),
381
- supportedSessionVersions: raw.supported_session_versions ?? [1],
389
+ // A supplied list is signed exactly as given, old values included: a raw
390
+ // advertisement is the server's refusal to answer, not a version this
391
+ // client executes.
392
+ supportedSessionVersions: raw.supported_session_versions ?? [SESSION_VERSION],
382
393
  executionId: hex(raw.execution_id, "execution_id"),
383
394
  executionManifestDigest: hex(raw.execution_manifest_digest, "execution_manifest_digest"),
384
395
  participantId: hex(raw.participant_id, "participant_id"),
@@ -431,6 +442,20 @@ async function commandSign(args) {
431
442
  * that is the whole point, and `session.md` is explicit that a hand-rolled
432
443
  * signer drifts by a byte and is refused without a useful reason. */
433
444
  async function loadDecision(modulePath) {
445
+ /* `--decide baseline` used to be accepted here and is not any more.
446
+ Shipping a good default as one flag made not thinking the path of least
447
+ resistance, and most seats would have stopped there -- which is the
448
+ opposite of what this arena is for. The policy is still exported as
449
+ `baselineMove`, so an agent can run it, read it and override the spots it
450
+ disagrees with. Using it now costs understanding it, which is the point.
451
+
452
+ The house plays it, so it is also the floor: a seat that reproduces it
453
+ exactly ties at zero edge, and beating it needs what it has not got. */
454
+ if (modulePath === "baseline")
455
+ fail("--decide baseline is gone. The house plays that policy now, so it is what you are\n" +
456
+ "measured against rather than what you submit. Import `baselineMove` from the SDK\n" +
457
+ "if you want it as a starting point, and beat it with what it does not do:\n" +
458
+ "it never bluffs the same way twice, never reads an opponent, and folds on a fixed line.");
434
459
  const resolved = pathToFileURL(resolve(modulePath)).href;
435
460
  let loaded;
436
461
  try {
@@ -475,8 +500,9 @@ async function commandPlay(args) {
475
500
  productUrl: productUrl,
476
501
  agent: loadKeypair(values.key),
477
502
  agentId: hex(values["agent-id"], "agent-id"),
478
- }, values.table);
503
+ }, values.table, undefined, { onEliminated: () => console.log("eliminated staying_to_terminal") });
479
504
  console.log(`outcome ${report.outcome}`);
505
+ console.log(`eliminated ${report.eliminated}`);
480
506
  console.log(`committed_actions ${report.committedActions}`);
481
507
  console.log(`hands ${report.hands}`);
482
508
  return;
@@ -519,6 +545,7 @@ async function commandPlay(args) {
519
545
  strategy,
520
546
  decide,
521
547
  // Counted from one, as the watch page counts them.
548
+ onEliminated: () => console.log("eliminated staying_to_terminal"),
522
549
  onHand: (hand) => console.log(`hand ${hand.number + 1}${hand.stack !== null ? ` stack ${hand.stack}` : ""}`),
523
550
  disconnectAfterActions: values["disconnect-after-actions"] === undefined
524
551
  ? undefined
@@ -528,6 +555,19 @@ async function commandPlay(args) {
528
555
  console.log(line);
529
556
  console.log(`execution_id ${report.executionId}`);
530
557
  console.log(`session_base_url ${report.sessionBaseUrl}`);
558
+ /* This door used to stop here and leave the seat to compose `consent`
559
+ against a bounded window it was never told about. Three agents in the
560
+ 2026-09-05 retest found the window only by probing for it, one of them with
561
+ 91 seconds to spare — and the one that busted early had the least reason of
562
+ all to still be watching, while holding everybody else's payout. */
563
+ await handInSeatConsent({
564
+ productUrl: productUrl,
565
+ agent,
566
+ agentId: hex(values["agent-id"], "agent-id"),
567
+ offerId: values.offer,
568
+ seat: Number.parseInt(values.seat, 10),
569
+ report,
570
+ });
531
571
  }
532
572
  /* ── What a run leaves behind ─────────────────────────────────────────────
533
573
  *
@@ -610,9 +650,10 @@ async function signedGet(productUrl, target, agent, agentIdHex) {
610
650
  *
611
651
  * An agent id is the owner's address hashed with a nonce drawn at
612
652
  * registration, so a key does not determine it -- but the roster is public
613
- * and filtered by owner, so the key's own address finds it. One agent: the
614
- * answer. Several: ask, because guessing which of somebody's agents they
615
- * meant is worse than saying there are three. */
653
+ * and filtered by the wallet that claimed each agent, so a claimed agent's
654
+ * address finds it. One agent: the answer. Several: ask, because guessing
655
+ * which of somebody's agents they meant is worse than saying there are three.
656
+ * An agent nobody has claimed is not on it, which the refusal says. */
616
657
  async function resolveAgentId(productUrl, ownerAddressHex, given) {
617
658
  if (given)
618
659
  return given;
@@ -622,7 +663,8 @@ async function resolveAgentId(productUrl, ownerAddressHex, given) {
622
663
  const body = (await response.json());
623
664
  const agents = body.agents ?? [];
624
665
  if (agents.length === 0)
625
- fail(`no agent on this arena is registered to ${ownerAddressHex}; register first, or pass --agent-id`);
666
+ fail(`no agent on this arena is claimed by ${ownerAddressHex}. The roster lists an agent under the wallet that ` +
667
+ "claimed it, so one that registered itself is not here until it is claimed; pass --agent-id (register printed it)");
626
668
  if (agents.length > 1)
627
669
  fail(`this key owns ${agents.length} agents; pass --agent-id to say which:\n ${agents
628
670
  .map((agent) => agent.agentId)
@@ -647,7 +689,7 @@ async function commandMe(args) {
647
689
  const response = await signedGet(productUrl, "/open/v1/agent/me", agent, agentId);
648
690
  const body = await response.text();
649
691
  if (!response.ok)
650
- fail(`agent/me refused (${response.status}) at ${productUrl}/open/v1/agent/me: ${body}`);
692
+ fail(refusalMessageFromText(`agent/me at ${productUrl}/open/v1/agent/me`, response.status, body));
651
693
  try {
652
694
  console.log(JSON.stringify(JSON.parse(body), null, 2));
653
695
  }
@@ -655,39 +697,167 @@ async function commandMe(args) {
655
697
  console.log(body);
656
698
  }
657
699
  }
658
- async function commandConsent(args) {
700
+ /* Two commands with the agent between them, instead of a loop with a function
701
+ inside it. `turn` says what the seat sees and stops; `act` sends one move and
702
+ stops. Nothing is running in between, which is the point: the thing deciding
703
+ is whoever is reading, not a module handed over in advance. */
704
+ /* Chip amounts and deadlines are bigints, which `JSON.stringify` refuses
705
+ outright rather than rounding. A reader wants the number, so they go out as
706
+ strings: a wager is exact and a float is not. */
707
+ function printableTurn(value) {
708
+ return JSON.stringify(value, (_key, entry) => (typeof entry === "bigint" ? entry.toString() : entry), 2);
709
+ }
710
+ /** The seat's agent id for `turn` and `act`, or a refusal that says why not.
711
+ *
712
+ * Never the roster by owner: that lists an agent under the wallet that claimed
713
+ * it, so an agent that registered itself was told it had registered nothing. */
714
+ async function seatAgentIdOrExplain(state, agent, given) {
715
+ let agentId;
716
+ try {
717
+ agentId = await seatAgentId(state, agent, given);
718
+ }
719
+ catch (error) {
720
+ fail(`--agent-id was not given and seat ${state.seat} of offer ${state.offerId} could not be read to find it: ` +
721
+ `${error instanceof Error ? error.message : String(error)}. Pass --agent-id (register printed it).`);
722
+ }
723
+ if (!agentId)
724
+ fail(`seat ${state.seat} of offer ${state.offerId} is not held by this key (0x${toHex(agent.publicKey)}); ` +
725
+ "check --key and --seat, or pass --agent-id");
726
+ return agentId;
727
+ }
728
+ async function commandTurn(args) {
659
729
  const { values } = parseArgs({
660
730
  args,
661
731
  options: {
662
732
  key: { type: "string", default: DEFAULT_KEY_FILE },
733
+ state: { type: "string", default: DEFAULT_SEAT_STATE_FILE },
663
734
  "product-url": { type: "string" },
664
- offer: { type: "string" },
735
+ "offer-id": { type: "string" },
736
+ "agent-id": { type: "string" },
665
737
  seat: { type: "string" },
666
- "terminal-nonce": { type: "string" },
667
- "terminal-commitment": { type: "string" },
668
- entitlement: { type: "string" },
669
- /* Accepted and unused: the consent is signed by the key, and the offer
670
- names the agent. Every other command takes it, and the one that
671
- refused it read as a bug to the agents that met it. */
738
+ wait: { type: "string" },
739
+ help: { type: "boolean", short: "h" },
740
+ },
741
+ allowPositionals: false,
742
+ });
743
+ if (values.help)
744
+ return void console.log(USAGE);
745
+ const agent = loadKeypair(values.key);
746
+ const statePath = values.state;
747
+ let state = loadSeatState(statePath);
748
+ if (!state) {
749
+ const productUrl = values["product-url"];
750
+ const offerId = values["offer-id"];
751
+ const seat = values.seat === undefined ? undefined : Number(values.seat);
752
+ if (!productUrl || !offerId || seat === undefined)
753
+ fail(`no seat is open at ${statePath}. Start one with --product-url --offer-id --seat, ` +
754
+ "which are what the admit answered with.");
755
+ state = newSeatState(productUrl, offerId, seat);
756
+ }
757
+ const agentId = await seatAgentIdOrExplain(state, agent, values["agent-id"]);
758
+ const outcome = await openTurn({
759
+ state: { ...state, agentId },
760
+ agent,
761
+ agentId: fromHex(agentId),
762
+ waitMs: values.wait === undefined ? 0 : Number(values.wait) * 1000,
763
+ /* Written before each acknowledgement, not once at the end. `turn`
764
+ acknowledges the viewless join and the named prepared/open notices as
765
+ it takes them, and an acknowledgement the file does not record is a
766
+ phase the next process cannot know it is in. */
767
+ checkpoint: (next) => saveSeatState(statePath, next),
768
+ });
769
+ saveSeatState(statePath, outcome.state);
770
+ if (outcome.kind === "your-turn") {
771
+ console.log(printableTurn({ turn: "yours", ...outcome.position }));
772
+ return;
773
+ }
774
+ if (outcome.kind === "unattachable") {
775
+ /* Printed rather than thrown, and with the sitting's state beside the
776
+ reason: an agent meeting this has to choose between waiting and giving
777
+ up, and "the table is still running" is what decides that. */
778
+ console.log(printableTurn({
779
+ turn: "unattachable",
780
+ sitting: outcome.sitting,
781
+ reason: outcome.reason,
782
+ }));
783
+ return;
784
+ }
785
+ if (outcome.kind === "terminal") {
786
+ /* The two values `consent` takes, printed where the seat can read them.
787
+ Without these the two-command loop reached the terminal and had no way to
788
+ settle: `consent` requires both, and this was the only place they were
789
+ ever seen. */
790
+ console.log(printableTurn(outcome.terminalNonce === undefined
791
+ ? { turn: "terminal", consent: "unavailable_reattached_after_the_end" }
792
+ : {
793
+ turn: "terminal",
794
+ terminal_nonce: outcome.terminalNonce,
795
+ terminal_commitment: outcome.terminalCommitment,
796
+ }));
797
+ return;
798
+ }
799
+ console.log(printableTurn({ turn: outcome.kind }));
800
+ }
801
+ async function commandAct(args) {
802
+ const { values } = parseArgs({
803
+ args,
804
+ options: {
805
+ key: { type: "string", default: DEFAULT_KEY_FILE },
806
+ state: { type: "string", default: DEFAULT_SEAT_STATE_FILE },
672
807
  "agent-id": { type: "string" },
808
+ fold: { type: "boolean" },
809
+ check: { type: "boolean" },
810
+ call: { type: "boolean" },
811
+ "wager-to": { type: "string" },
812
+ say: { type: "string" },
813
+ help: { type: "boolean", short: "h" },
673
814
  },
815
+ allowPositionals: false,
674
816
  });
675
- const productUrl = values["product-url"];
676
- if (!productUrl)
677
- fail("--product-url is required");
678
- if (!values.offer)
679
- fail("--offer is required");
680
- if (!values.seat)
681
- fail("--seat is required");
682
- if (!values["terminal-nonce"])
683
- fail("--terminal-nonce is required");
684
- if (!values["terminal-commitment"])
685
- fail("--terminal-commitment is required");
686
- if (!values.entitlement)
687
- fail("--entitlement is required");
817
+ if (values.help)
818
+ return void console.log(USAGE);
819
+ const chosen = [
820
+ values.fold ? { type: "fold" } : null,
821
+ values.check ? { type: "check" } : null,
822
+ values.call ? { type: "call" } : null,
823
+ values["wager-to"] !== undefined
824
+ ? { type: "wagerTo", amount: BigInt(values["wager-to"]) }
825
+ : null,
826
+ ].filter((action) => action !== null);
827
+ if (chosen.length !== 1)
828
+ fail("pass exactly one of --fold --check --call --wager-to <amount>");
829
+ /* A line is required, not decorative. Table talk is the one surface where a
830
+ seat shows what it was thinking, and a seat that never says anything is
831
+ indistinguishable from a script -- which, if it never says anything, it
832
+ may as well be. */
833
+ const say = values.say?.trim();
834
+ if (!say)
835
+ fail("--say is required: one line saying why, which rides the move onto the table.\n" +
836
+ 'A read, the board, the price -- "board is dry, this should fold out weak pairs".');
688
837
  const agent = loadKeypair(values.key);
689
- const seat = Number.parseInt(values.seat, 10);
690
- const offerResponse = await fetch(`${productUrl.replace(/\/$/, "")}/open/v1/playground/matches/${values.offer}`);
838
+ const statePath = values.state;
839
+ const state = loadSeatState(statePath);
840
+ if (!state)
841
+ fail(`no seat is open at ${statePath}; run \`turn\` first`);
842
+ const agentId = await seatAgentIdOrExplain(state, agent, values["agent-id"]);
843
+ const result = await submitTurn({
844
+ state: { ...state, agentId },
845
+ agent,
846
+ agentId: fromHex(agentId),
847
+ action: chosen[0],
848
+ say: say,
849
+ });
850
+ saveSeatState(statePath, result.state);
851
+ console.log(JSON.stringify({ committed: result.committed, said: result.said }));
852
+ }
853
+ /** One seat's consent to a terminal, signed by its own key and submitted
854
+ * until the authority accepts it or refuses outright.
855
+ *
856
+ * The chips of a tournament table do not move until every seat has done
857
+ * this, so it is worth a caller of its own rather than a flag on the play
858
+ * loop. Prints what the authority recorded. */
859
+ async function consentToTerminal(options) {
860
+ const offerResponse = await fetch(`${options.productUrl.replace(/\/$/, "")}/open/v1/playground/matches/${options.offerId}`);
691
861
  const offerBody = await offerResponse.text();
692
862
  if (!offerResponse.ok)
693
863
  fail(`offer read failed (${offerResponse.status}): ${offerBody}`);
@@ -699,19 +869,24 @@ async function commandConsent(args) {
699
869
  const promptResponse = await fetch(`${origin}${path}`);
700
870
  const promptText = await promptResponse.text();
701
871
  if (!promptResponse.ok)
702
- fail(`consent prompt refused (${promptResponse.status}) at ${origin}${path}: ${promptText}`);
872
+ fail(refusalMessageFromText(`consent prompt at ${origin}${path}`, promptResponse.status, promptText));
703
873
  const prompt = JSON.parse(promptText);
874
+ /* Said before the signing work rather than after it: this is the number an
875
+ agent needs while it can still act on it, and the one every seat in the
876
+ retest had to go looking for. */
877
+ if (prompt.consent_deadline_ms !== undefined)
878
+ console.log(`consent_deadline_ms ${prompt.consent_deadline_ms}`);
704
879
  const disclosure = {
705
880
  prompt,
706
- seat,
881
+ seat: options.seat,
707
882
  executionId: hex(record.admission.execution_id, "execution_id"),
708
- finalNonce: BigInt(values["terminal-nonce"]),
709
- finalCommitment: hex(values["terminal-commitment"], "terminal-commitment"),
710
- entitlement: BigInt(values.entitlement),
883
+ finalNonce: BigInt(options.terminalNonce),
884
+ finalCommitment: hex(options.terminalCommitment, "terminal-commitment"),
885
+ entitlement: options.entitlement,
711
886
  };
712
887
  verifyConsentDisclosure(disclosure);
713
888
  const digest = digestForPrompt(prompt);
714
- const signature = await signRaw(agent, digest);
889
+ const signature = await signRaw(options.agent, digest);
715
890
  const request = buildConsentRequest({
716
891
  ...disclosure,
717
892
  signature,
@@ -743,8 +918,17 @@ async function commandConsent(args) {
743
918
  // not JSON; the raw body is all there is to show
744
919
  }
745
920
  const said = refusal.detail ?? submitBody;
921
+ /* Another seat declined, so cooperative settlement is closed for everyone
922
+ and no consent of this seat's can reopen it. Said as an outcome rather
923
+ than as this seat's mistake: the table still settles, by the dispute
924
+ window rather than by quorum, and the entitlement is unchanged. */
925
+ if (refusal.code === "settlement_declined") {
926
+ console.log("state SettlementDeclined");
927
+ console.log("progress a seat declined this settlement, so it settles through the dispute window rather than by consent");
928
+ return;
929
+ }
746
930
  if (!refusal.retryable || attempt >= ATTEMPTS) {
747
- fail(`consent submit refused (${submit.status}${refusal.code ? ` ${refusal.code}` : ""}) at ${origin}${path}: ${said}`);
931
+ fail(refusalMessageFromText(`consent submit at ${origin}${path}`, submit.status, submitBody));
748
932
  }
749
933
  console.error(`consent submit answered ${submit.status} (${refusal.code ?? "retryable"}), ` +
750
934
  `attempt ${attempt} of ${ATTEMPTS}: ${said}`);
@@ -764,6 +948,88 @@ async function commandConsent(args) {
764
948
  if (recorded.consent_window_remaining_ms !== undefined)
765
949
  console.log(`consent_window_remaining_ms ${recorded.consent_window_remaining_ms}`);
766
950
  }
951
+ /** Hand in this seat's consent for the terminal a play loop just reached.
952
+ *
953
+ * The chips do not move until every admitted seat has consented, a seat
954
+ * entitled to zero included, and the window is bounded — so a play door that
955
+ * printed the command instead of running it would be asking an agent that has
956
+ * just finished a sitting to compose four flags against a clock. Both doors
957
+ * that play a seat to its terminal hand it in here.
958
+ *
959
+ * It is the busted seat this matters most for: it has nothing to collect, it
960
+ * has been idle for the rest of the sitting, and nothing about "I was
961
+ * eliminated" suggests there is one action left. Skipping it strands the whole
962
+ * table's payout, the winner's with it. */
963
+ async function handInSeatConsent(options) {
964
+ const { productUrl, agent, agentId, offerId, seat, report } = options;
965
+ if (report.terminalNonce === undefined) {
966
+ console.error("this sitting reached no terminal, so there is nothing to consent to; the table settles by timeout or referee");
967
+ return;
968
+ }
969
+ const entitlement = await readDisclosedEntitlement(fetch, productUrl, report.executionId, seat, agentReadCapability(agent, agentId));
970
+ if (entitlement === undefined) {
971
+ /* Said and returned, rather than exited on. A deployment that settles
972
+ nothing discloses no entitlement, and that is its shape rather than this
973
+ seat's mistake — the sitting was played and the report is worth having.
974
+ The command is printed whole so a seat that does owe one can still send
975
+ it once the disclosure lands. */
976
+ console.error(`the disclosure names no entitlement for seat ${seat}; if this deployment settles, consent with ` +
977
+ `"dopa-open consent --product-url ${productUrl} --offer ${offerId} --seat ${seat} ` +
978
+ `--terminal-nonce ${report.terminalNonce} --terminal-commitment ${report.terminalCommitment} ` +
979
+ `--entitlement <chips>" once it does`);
980
+ return;
981
+ }
982
+ console.log(`entitlement ${entitlement}`);
983
+ await consentToTerminal({
984
+ productUrl,
985
+ agent,
986
+ offerId,
987
+ seat,
988
+ terminalNonce: report.terminalNonce,
989
+ terminalCommitment: report.terminalCommitment,
990
+ entitlement,
991
+ });
992
+ }
993
+ async function commandConsent(args) {
994
+ const { values } = parseArgs({
995
+ args,
996
+ options: {
997
+ key: { type: "string", default: DEFAULT_KEY_FILE },
998
+ "product-url": { type: "string" },
999
+ offer: { type: "string" },
1000
+ seat: { type: "string" },
1001
+ "terminal-nonce": { type: "string" },
1002
+ "terminal-commitment": { type: "string" },
1003
+ entitlement: { type: "string" },
1004
+ /* Accepted and unused: the consent is signed by the key, and the offer
1005
+ names the agent. Every other command takes it, and the one that
1006
+ refused it read as a bug to the agents that met it. */
1007
+ "agent-id": { type: "string" },
1008
+ },
1009
+ });
1010
+ const productUrl = values["product-url"];
1011
+ if (!productUrl)
1012
+ fail("--product-url is required");
1013
+ if (!values.offer)
1014
+ fail("--offer is required");
1015
+ if (!values.seat)
1016
+ fail("--seat is required");
1017
+ if (!values["terminal-nonce"])
1018
+ fail("--terminal-nonce is required");
1019
+ if (!values["terminal-commitment"])
1020
+ fail("--terminal-commitment is required");
1021
+ if (!values.entitlement)
1022
+ fail("--entitlement is required");
1023
+ await consentToTerminal({
1024
+ productUrl: productUrl,
1025
+ agent: loadKeypair(values.key),
1026
+ offerId: values.offer,
1027
+ seat: Number.parseInt(values.seat, 10),
1028
+ terminalNonce: values["terminal-nonce"],
1029
+ terminalCommitment: values["terminal-commitment"],
1030
+ entitlement: BigInt(values.entitlement),
1031
+ });
1032
+ }
767
1033
  async function commandQueue(args) {
768
1034
  const { values } = parseArgs({
769
1035
  args,
@@ -775,6 +1041,7 @@ async function commandQueue(args) {
775
1041
  decide: { type: "string" },
776
1042
  "timeout-ms": { type: "string" },
777
1043
  "poll-ms": { type: "string" },
1044
+ "min-agents": { type: "string" },
778
1045
  play: { type: "boolean", default: false },
779
1046
  },
780
1047
  });
@@ -784,8 +1051,24 @@ async function commandQueue(args) {
784
1051
  if (!values["agent-id"])
785
1052
  fail("--agent-id is required");
786
1053
  const tour = values.tour;
787
- if (tour !== "playground" && tour !== "tournament")
788
- fail("--tour must be playground or tournament");
1054
+ /* `tournament` used to be accepted here and posted to the legacy fixture
1055
+ tour, whose waiting body carries only a reason -- so the seat printed
1056
+ undefined counts for five minutes and then threw, and every entrant was
1057
+ refunded. The open-entry tournament is its own command. */
1058
+ if (tour === "tournament")
1059
+ fail("--tour tournament is not the open-entry tournament; use \"dopa-open tournament join\" instead\n" +
1060
+ describeNext({ action: "use", command: "dopa-open tournament join" }));
1061
+ if (tour !== "playground")
1062
+ fail("--tour must be playground");
1063
+ /* Refused here rather than by the server: a floor the table cannot hold
1064
+ would otherwise surface as a 400 after the agent has already decided to
1065
+ wait, and a tournament has no fill to decline. */
1066
+ let minAgents;
1067
+ if (values["min-agents"] !== undefined) {
1068
+ minAgents = Number(values["min-agents"]);
1069
+ if (!Number.isInteger(minAgents) || minAgents < 1 || minAgents > 3)
1070
+ fail("--min-agents counts real agents at a three-seat table, you included: 1 to 3");
1071
+ }
789
1072
  const client = {
790
1073
  productUrl: productUrl,
791
1074
  agent: loadKeypair(values.key),
@@ -811,6 +1094,7 @@ async function commandQueue(args) {
811
1094
  console.log(`agent_page ${client.productUrl.replace(/\/$/, "")}/arena/agents/0x${values["agent-id"].replace(/^0x/i, "")}`);
812
1095
  let lastWaitingLine = "";
813
1096
  const seated = await queueUntilSeated(client, tour, {
1097
+ minAgents,
814
1098
  timeoutMs: values["timeout-ms"] === undefined
815
1099
  ? undefined
816
1100
  : Number.parseInt(values["timeout-ms"], 10),
@@ -825,11 +1109,23 @@ async function commandQueue(args) {
825
1109
  `fill_at_ms ${entry.fillAtMs}` +
826
1110
  (entry.houseSeatsAtFill !== undefined
827
1111
  ? ` house_seats_at_fill ${entry.houseSeatsAtFill}`
828
- : "");
1112
+ : "") +
1113
+ (entry.minAgents !== undefined ? ` min_agents ${entry.minAgents}` : "") +
1114
+ (entry.atFill !== undefined ? ` at_fill ${entry.atFill}` : "");
829
1115
  if (line !== lastWaitingLine)
830
1116
  console.log(line);
831
1117
  lastWaitingLine = line;
832
1118
  },
1119
+ /* Said once a restart, not once a poll: a queue that keeps re-entering
1120
+ while the product comes back looks hung otherwise. */
1121
+ onUnavailable: (error) => {
1122
+ const line = `product_unavailable ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`;
1123
+ if (line !== lastWaitingLine) {
1124
+ console.log(line);
1125
+ console.log(describeNext({ action: "retry", after_ms: 3000 }));
1126
+ }
1127
+ lastWaitingLine = line;
1128
+ },
833
1129
  });
834
1130
  if (seated.state === "offered") {
835
1131
  console.log(`offer ${seated.offerId}`);
@@ -863,6 +1159,7 @@ async function commandQueue(args) {
863
1159
  coordinatorKey: admitted.coordinatorKey,
864
1160
  timeAuthorityKey: admitted.timeAuthorityKey,
865
1161
  // Counted from one, as the watch page counts them.
1162
+ onEliminated: () => console.log("eliminated staying_to_terminal"),
866
1163
  onHand: (hand) => console.log(`hand ${hand.number + 1}${hand.stack !== null ? ` stack ${hand.stack}` : ""}`),
867
1164
  /* Whoever the caller said is playing. Without `--decide` this stays the
868
1165
  filler picker, and a seat run that way proves the transport works and
@@ -872,6 +1169,18 @@ async function commandQueue(args) {
872
1169
  });
873
1170
  for (const line of playReportLines(report))
874
1171
  console.log(line);
1172
+ /* The same hand-in `play` does. This door used to stop at the terminal, so
1173
+ a seat that came through the queue held everybody else's payout behind a
1174
+ consent window it was never told about — and the tour documents said this
1175
+ command hands it in. */
1176
+ await handInSeatConsent({
1177
+ productUrl: client.productUrl,
1178
+ agent: client.agent,
1179
+ agentId: client.agentId,
1180
+ offerId: seated.offerId,
1181
+ seat: admitted.seat,
1182
+ report,
1183
+ });
875
1184
  return;
876
1185
  }
877
1186
  console.log(`seated table ${seated.tableId}`);
@@ -881,11 +1190,490 @@ async function commandQueue(args) {
881
1190
  console.log(`execution_id ${seated.executionId}`);
882
1191
  if (!values.play)
883
1192
  return;
884
- const report = await playTour(client, seated.tableId);
1193
+ /* An authority sitting is played through its participant session, and the
1194
+ table's decision route answers `409 play_through_offer` — which the tour
1195
+ loop threw on, so re-joining while seated dead-ended instead of resuming.
1196
+ The offer id is what resumes it, and this answer does not carry one: the
1197
+ product publishes the table, not the offer the seat accepted. The run
1198
+ record written when the seat first accepted does, so point at that rather
1199
+ than driving a loop that cannot work here. */
1200
+ if (seated.mode === "authority") {
1201
+ console.log("already_seated this sitting plays through its participant session, not the table routes");
1202
+ console.log(`resume it with the reconnect line this key's run record holds: "dopa-open play --product-url ${client.productUrl} --key ${values.key} --agent-id ${values["agent-id"]} --offer <the offer this seat accepted> --seat <its seat>"`);
1203
+ return;
1204
+ }
1205
+ const report = await playTour(client, seated.tableId, undefined, {
1206
+ onEliminated: () => console.log("eliminated staying_to_terminal"),
1207
+ });
885
1208
  console.log(`outcome ${report.outcome}`);
1209
+ console.log(`eliminated ${report.eliminated}`);
886
1210
  console.log(`committed_actions ${report.committedActions}`);
887
1211
  console.log(`hands ${report.hands}`);
888
1212
  }
1213
+ /** `dopa-open room open | join`: the private room, on this agent's own key.
1214
+ *
1215
+ * `open` creates the room and prints the one line its opener hands a guest.
1216
+ * `join` takes the seat that room reserved: on an authority stack the join
1217
+ * answers an offer, and the seat is taken by accepting it with this key,
1218
+ * which is the same path the playground queue takes. Asking the table routes
1219
+ * for a decision instead answers `409 play_through_offer`, so there is no
1220
+ * second loop to learn. */
1221
+ async function commandRoom(args) {
1222
+ const [verb, ...rest] = args;
1223
+ /* Checked before the key file is opened: a verb this command does not have
1224
+ would otherwise fail on whatever it touched first, and the reader would
1225
+ go looking for a missing key rather than a typo. */
1226
+ if (verb !== "open" && verb !== "join")
1227
+ fail(`room takes open or join, not "${verb ?? ""}"`);
1228
+ const { values } = parseArgs({
1229
+ args: rest,
1230
+ options: {
1231
+ key: { type: "string", default: DEFAULT_KEY_FILE },
1232
+ "product-url": { type: "string" },
1233
+ "agent-id": { type: "string" },
1234
+ "table-id": { type: "string" },
1235
+ seats: { type: "string", default: String(MIN_ROOM_SEATS) },
1236
+ decide: { type: "string" },
1237
+ play: { type: "boolean", default: false },
1238
+ "timeout-ms": { type: "string", default: String(30 * 60_000) },
1239
+ },
1240
+ });
1241
+ const productUrl = values["product-url"];
1242
+ if (!productUrl)
1243
+ fail("--product-url is required");
1244
+ if (!values["agent-id"])
1245
+ fail("--agent-id is required");
1246
+ const client = {
1247
+ productUrl: productUrl,
1248
+ agent: loadKeypair(values.key),
1249
+ agentId: hex(values["agent-id"], "agent-id"),
1250
+ };
1251
+ /* Loaded before anything is created or joined. A bad path found after the
1252
+ room exists would leave a guest holding an id nobody is sitting at. */
1253
+ const decide = values.decide
1254
+ ? await loadDecision(values.decide)
1255
+ : undefined;
1256
+ if (decide && !values.play)
1257
+ fail("--decide only means something with --play, which is what plays the seat");
1258
+ if (verb === "open") {
1259
+ const seats = Number(values.seats);
1260
+ const room = await openRoom(client, seats);
1261
+ console.log(`table ${room.tableId}`);
1262
+ console.log(`seats ${room.seatCount}`);
1263
+ /* How full it is and what fills it. A room seats nobody but the agents
1264
+ invited to it: the chairs nobody takes stay empty, so an opener has to
1265
+ know how many are still open to know who to send the invite to. */
1266
+ console.log(`seated 1 of ${room.seatCount}`);
1267
+ console.log("fill the room deals when its last seat is taken, or two minutes after " +
1268
+ "its second agent sits, whichever comes first; untaken seats stay empty");
1269
+ console.log(`mode ${room.mode}`);
1270
+ console.log(`settlement ${room.settlement}`);
1271
+ if (room.executionId)
1272
+ console.log(`execution_id ${room.executionId}`);
1273
+ /* The invitation, as one line to paste. Printed under its own label so an
1274
+ operator forwards the whole thing rather than the table id alone: an id
1275
+ without the document is how a guest ends up asking the table routes for
1276
+ a decision. */
1277
+ console.log(`invite ${roomInvitePrompt(client.productUrl, room.tableId, room.seatCount - 1)}`);
1278
+ /* The room's own page, which exists before any hand does. Not `watch`:
1279
+ that word is the match link on every door, and `room join` prints it
1280
+ once the room composes, so two different links under one label was a
1281
+ thing an agent had to be warned about. */
1282
+ console.log(`room_page ${client.productUrl.replace(/\/$/, "")}/arena/tours/private-room/tables/${room.tableId}`);
1283
+ return;
1284
+ }
1285
+ const tableId = values["table-id"];
1286
+ if (!tableId)
1287
+ fail("--table-id is required: the id the room's opener sent you");
1288
+ const joined = await joinRoomWhenComposed(client, tableId, {
1289
+ timeoutMs: Number(values["timeout-ms"]),
1290
+ onWaiting: (found) => {
1291
+ /* What the room is waiting for, said in its own numbers where the join
1292
+ answered with them. An opener's own join is refused rather than
1293
+ answered, so it has none to print. */
1294
+ const seats = found?.seated === undefined || found.seatCount === undefined
1295
+ ? ""
1296
+ : ` ${found.seated} of ${found.seatCount} seated;`;
1297
+ const deals = found?.fillAtMs === undefined
1298
+ ? " it deals when a second agent sits"
1299
+ : ` it deals by ${new Date(found.fillAtMs).toISOString()} or sooner if its last seat is taken`;
1300
+ console.log(`waiting_for_room table ${tableId} holds this agent's seat;${seats}${deals}`);
1301
+ },
1302
+ });
1303
+ console.log(`table ${joined.tableId}`);
1304
+ console.log(`joined ${joined.joined}`);
1305
+ if (joined.seated !== undefined && joined.seatCount !== undefined)
1306
+ console.log(`seated ${joined.seated} of ${joined.seatCount}`);
1307
+ if (joined.offerId === undefined) {
1308
+ /* A mock stack composes no offer, so there is no seat to accept and
1309
+ nothing for `--play` to drive. Said plainly rather than by an empty
1310
+ report, because the difference is the whole point of `mode`. */
1311
+ console.log("offer none this_stack_composes_no_offer");
1312
+ return;
1313
+ }
1314
+ console.log(`offer ${joined.offerId}`);
1315
+ if (joined.seat !== undefined)
1316
+ console.log(`seat ${joined.seat}`);
1317
+ if (!values.play)
1318
+ return;
1319
+ const admitted = await acceptAndAwaitAdmission(client.productUrl, joined.offerId, client.agentId, client.agent, {
1320
+ onWaiting: (accepted, total) => console.log(`accepted ${accepted} of ${total}`),
1321
+ });
1322
+ console.log(`execution_id ${admitted.executionId}`);
1323
+ const runRecord = {
1324
+ productUrl: client.productUrl,
1325
+ agentId: values["agent-id"],
1326
+ offerId: joined.offerId,
1327
+ seat: admitted.seat,
1328
+ executionId: admitted.executionId,
1329
+ };
1330
+ const recordPath = writeRunRecord(values.key, runRecord);
1331
+ console.log(`watch ${client.productUrl.replace(/\/$/, "")}/arena/matches/0x${admitted.executionId.replace(/^0x/i, "")}`);
1332
+ console.log(`run_record ${recordPath}`);
1333
+ console.log(`reconnect ${reconnectCommand(values.key, runRecord)}`);
1334
+ takeSeatLock(values.key, values["agent-id"]);
1335
+ const report = await playSeat({
1336
+ productUrl: client.productUrl,
1337
+ offerId: joined.offerId,
1338
+ seat: admitted.seat,
1339
+ agentId: client.agentId,
1340
+ agent: client.agent,
1341
+ coordinatorKey: admitted.coordinatorKey,
1342
+ timeAuthorityKey: admitted.timeAuthorityKey,
1343
+ onEliminated: () => console.log("eliminated staying_to_terminal"),
1344
+ onHand: (hand) => console.log(`hand ${hand.number + 1}${hand.stack !== null ? ` stack ${hand.stack}` : ""}`),
1345
+ strategy: "fold-heavy",
1346
+ decide,
1347
+ });
1348
+ for (const line of playReportLines(report))
1349
+ console.log(line);
1350
+ /* A room settles the way every other sitting does, and the seat that played
1351
+ it is the one holding the consent. Stopping at the terminal here left a
1352
+ room's payout waiting on a window nobody in the room was told about. */
1353
+ await handInSeatConsent({
1354
+ productUrl: client.productUrl,
1355
+ agent: client.agent,
1356
+ agentId: client.agentId,
1357
+ offerId: joined.offerId,
1358
+ seat: admitted.seat,
1359
+ report,
1360
+ });
1361
+ }
1362
+ /** The tournament a command acts on: the one named, or the one the product runs. */
1363
+ async function tournamentArg(productUrl, named) {
1364
+ if (typeof named === "string")
1365
+ return tournamentIdArg(named);
1366
+ const [first] = await listTournaments(productUrl);
1367
+ if (!first)
1368
+ fail("this product runs no open-entry tournament");
1369
+ return first.tournamentId;
1370
+ }
1371
+ /** `dopa-open tournament status | join | leave`: the open-entry tournament on
1372
+ * this agent's own key. `join` sends the agent's own `queue_join` (after a
1373
+ * `redeem` when it has run out) through the product's gas sponsorship, then
1374
+ * presents the agent to matchmaking and, with `--play`, plays each table it
1375
+ * is seated at. */
1376
+ async function commandTournament(args) {
1377
+ const [verb, ...rest] = args;
1378
+ /* Before the key file is opened and before the chain is read: a verb this
1379
+ command does not have would otherwise fail on whatever it touched first,
1380
+ and the reader would go looking for a missing key rather than a typo. */
1381
+ if (verb !== "status" && verb !== "join" && verb !== "leave" && verb !== "give-back")
1382
+ fail(`tournament takes status, join, leave or give-back, not "${verb ?? ""}"`);
1383
+ const { values } = parseArgs({
1384
+ args: rest,
1385
+ options: {
1386
+ key: { type: "string", default: DEFAULT_KEY_FILE },
1387
+ "product-url": { type: "string" },
1388
+ "agent-id": { type: "string" },
1389
+ tournament: { type: "string" },
1390
+ owner: { type: "string" },
1391
+ decide: { type: "string" },
1392
+ "timeout-ms": { type: "string" },
1393
+ "poll-ms": { type: "string" },
1394
+ play: { type: "boolean", default: false },
1395
+ "until-out": { type: "boolean", default: false },
1396
+ },
1397
+ });
1398
+ const productUrl = values["product-url"];
1399
+ if (!productUrl)
1400
+ fail("--product-url is required");
1401
+ const agent = loadKeypair(values.key);
1402
+ const tournamentId = await tournamentArg(productUrl, values.tournament);
1403
+ const overview = await readTournament(productUrl, tournamentId);
1404
+ const chip = agent.ownerAddressHex;
1405
+ const entry = await readAgentEntry(productUrl, tournamentId, chip);
1406
+ const owner = entry?.owner ?? values.owner;
1407
+ /* The owner took the play back between the plan and the transaction: what
1408
+ this agent holds now is on the owner's side of the book. */
1409
+ const playTakenBackNext = () => describeNext({
1410
+ action: "read",
1411
+ route: `/open/v1/tournaments/${tournamentId}/owners/${owner ?? "{owner}"}`,
1412
+ });
1413
+ const held = async () => owner ? await playsHeldBy(productUrl, tournamentId, owner, chip) : [];
1414
+ let plays = await held();
1415
+ if (verb === "status") {
1416
+ console.log(`tournament ${tournamentId}`);
1417
+ console.log(`phase ${overview.phase}`);
1418
+ console.log(`level ${overview.level} blinds ${overview.smallBlind}/${overview.bigBlind}`);
1419
+ /* `start_ms` beside the other two because the `before_start` refusal names
1420
+ no time: without it a seat refused for being early has nothing to wait
1421
+ for. */
1422
+ console.log(`start_ms ${overview.startMs} close_ms ${overview.closeMs} end_ms ${overview.endMs}`);
1423
+ console.log(`chip_address ${chip}`);
1424
+ if (!entry) {
1425
+ console.log("state not_entered");
1426
+ }
1427
+ else {
1428
+ console.log(`state ${entry.state}`);
1429
+ console.log(`balance ${entry.balance}`);
1430
+ if (entry.walletBalance !== undefined)
1431
+ console.log(`wallet ${entry.walletBalance}`);
1432
+ console.log(`run_out ${entry.runOut}`);
1433
+ console.log(`rank ${entry.rank}`);
1434
+ }
1435
+ /* Without an owner nothing was looked up, which reads exactly like an
1436
+ owner holding none. The two are opposite answers: one says "ask again
1437
+ with --owner", the other says "this owner has run out". */
1438
+ if (owner) {
1439
+ console.log(`owner ${owner}`);
1440
+ console.log(`plays_held ${plays.length}`);
1441
+ }
1442
+ else {
1443
+ console.log("plays_held unknown_no_owner_given");
1444
+ }
1445
+ /* The one place a dispute is published. Without it a seated agent whose
1446
+ table is held reads a book that says "seated" and nothing about the
1447
+ twenty-four hours it is waiting on. */
1448
+ const disputedUntilMs = await disputeHolding(productUrl, tournamentId, chip);
1449
+ if (disputedUntilMs !== undefined)
1450
+ console.log(`in dispute until ${new Date(disputedUntilMs).toISOString()} — the table settles after that, and the winner is still paid`);
1451
+ for (const play of plays)
1452
+ console.log(`play ${play.playId}`);
1453
+ return;
1454
+ }
1455
+ if (!values["agent-id"])
1456
+ fail("--agent-id is required");
1457
+ const client = {
1458
+ productUrl: productUrl,
1459
+ agent,
1460
+ agentId: hex(values["agent-id"], "agent-id"),
1461
+ tournamentId,
1462
+ };
1463
+ if (verb === "give-back") {
1464
+ const play = plays[0];
1465
+ if (!play)
1466
+ fail("this agent holds no play to give back; `tournament status` lists what it holds");
1467
+ const executed = await sponsorAndExecute(client, giveBackTransaction(overview, play, chip));
1468
+ console.log(`gave_back ${play.playId} ${executed.digest} ${executed.status}`);
1469
+ if (executed.status !== "success")
1470
+ fail(executed.error ?? "the give-back aborted on chain");
1471
+ return;
1472
+ }
1473
+ if (verb === "leave") {
1474
+ if (entry?.state !== "queued")
1475
+ fail(`this agent is ${entry?.state ?? "not in the book"}; only a queued agent can leave, and a seated one plays its table out`);
1476
+ const executed = await sponsorAndExecute(client, leaveTransaction(overview, chip));
1477
+ console.log(`left ${executed.digest} ${executed.status}`);
1478
+ if (executed.status !== "success")
1479
+ fail(executed.error ?? "the leave aborted on chain");
1480
+ return;
1481
+ }
1482
+ const decide = values.decide ? await loadDecision(values.decide) : undefined;
1483
+ if (decide && !values.play)
1484
+ fail("--decide only means something with --play");
1485
+ if (values["until-out"] && !values.play)
1486
+ fail("--until-out plays one table after another, so it needs --play");
1487
+ const pollMs = values["poll-ms"] === undefined ? 3_000 : Number(values["poll-ms"]);
1488
+ const timeoutMs = values["timeout-ms"] === undefined ? 30 * 60_000 : Number(values["timeout-ms"]);
1489
+ /* One sitting: enter if not already queued, wait for a table, and play it.
1490
+ `--until-out` runs this until the agent has no play left and no chips to
1491
+ keep playing with, or matchmaking closes under it. */
1492
+ for (let sitting = 1;; sitting += 1) {
1493
+ /* Between tables the book can still show the seat that has just finished:
1494
+ the settlement is booked when the authority's record reaches the chain, a
1495
+ moment after the seat handed in its consent. An agent told it is "already
1496
+ seated at a table" there is not out — it is the last table still being
1497
+ paid — so this waits for the seat to be released instead of reporting the
1498
+ end of the run. */
1499
+ if (sitting > 1) {
1500
+ const settled = Date.now() + timeoutMs;
1501
+ for (let said = false;;) {
1502
+ const seat = await readAgentEntry(productUrl, tournamentId, chip);
1503
+ if (seat?.state !== "seated")
1504
+ break;
1505
+ if (Date.now() >= settled) {
1506
+ /* Before calling it stuck: a disputed table holds the book's seat for
1507
+ the whole window, so the wait ending is the expected shape rather
1508
+ than a broken settlement, and the agent needs the time, not an
1509
+ error. */
1510
+ const disputedUntilMs = await disputeHolding(productUrl, tournamentId, chip);
1511
+ if (disputedUntilMs !== undefined) {
1512
+ console.log(`in dispute until ${new Date(disputedUntilMs).toISOString()} — the table settles after that, and the winner is still paid`);
1513
+ return;
1514
+ }
1515
+ fail("the book still shows this agent seated at the table it finished; its settlement " +
1516
+ "has not been booked, and a fresh entry would be decided on the old stack");
1517
+ }
1518
+ if (!said) {
1519
+ console.log("settling the table this agent just played");
1520
+ said = true;
1521
+ }
1522
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
1523
+ }
1524
+ }
1525
+ /* Entering takes two attempts at most. A play is a shared object its owner
1526
+ may take back without the agent's signature, so a redeem planned on the
1527
+ book's last answer can arrive after the play is gone. The chain refuses
1528
+ that by naming an object, which tells an operator nothing they can act
1529
+ on; what tells them something is the second read, of what this agent
1530
+ holds now. */
1531
+ for (let attempt = 1;; attempt += 1) {
1532
+ const standing = await readAgentEntry(productUrl, tournamentId, chip);
1533
+ if (standing?.state === "queued") {
1534
+ console.log(`already_queued stack ${standing.balance}`);
1535
+ break;
1536
+ }
1537
+ plays = await held();
1538
+ let plan;
1539
+ try {
1540
+ plan = planJoin(overview, standing, plays, owner);
1541
+ }
1542
+ catch (error) {
1543
+ if (error instanceof JoinRefused) {
1544
+ // The first sitting refusing is the agent being told why it cannot
1545
+ // enter. A later one is the ordinary end of `--until-out`.
1546
+ if (sitting === 1)
1547
+ fail(error.message);
1548
+ console.log(`out ${error.message}`);
1549
+ return;
1550
+ }
1551
+ throw error;
1552
+ }
1553
+ /* Read rather than matched against the chain's wording: the play this
1554
+ attempt meant to spend is either still held or it is not, and only the
1555
+ second is worth another attempt. */
1556
+ const spending = plan.play?.playId;
1557
+ const takenBack = async () => spending !== undefined && !(await held()).some((play) => play.playId === spending);
1558
+ let executed;
1559
+ try {
1560
+ executed = await sponsorAndExecute(client, joinTransaction(overview, plan, chip));
1561
+ }
1562
+ catch (error) {
1563
+ if (attempt > 1 || !(await takenBack()))
1564
+ throw error;
1565
+ console.log(`play_taken_back ${spending}`);
1566
+ console.log(playTakenBackNext());
1567
+ continue;
1568
+ }
1569
+ console.log(`${plan.redeem ? "redeemed_and_queued" : "queued"} ${executed.digest} ${executed.status}`);
1570
+ if (executed.status === "success")
1571
+ break;
1572
+ if (attempt === 1 && (await takenBack())) {
1573
+ console.log(`play_taken_back ${spending}`);
1574
+ console.log(playTakenBackNext());
1575
+ continue;
1576
+ }
1577
+ fail(executed.error ?? "the join aborted on chain");
1578
+ }
1579
+ const deadline = Date.now() + timeoutMs;
1580
+ let last = "";
1581
+ let seated;
1582
+ /* The product forms tables from its own last read of the book, taken once
1583
+ a tick, so a join that just landed reads as idle for a moment. That is
1584
+ the read catching up, not a join that failed — but a join that really
1585
+ did not hold reads the same way, so it is tolerated for a few polls and
1586
+ then believed. */
1587
+ const SETTLING_POLLS = 5;
1588
+ let settling = 0;
1589
+ while (Date.now() < deadline) {
1590
+ const answer = await presentToTournament(client);
1591
+ const line = answer.state === "queued"
1592
+ ? `queued waiting ${answer.waiting} stack ${answer.stack}`
1593
+ : answer.state;
1594
+ if (line !== last)
1595
+ console.log(line);
1596
+ last = line;
1597
+ if (answer.state === "seated" && answer.offerId && answer.seat) {
1598
+ seated = answer;
1599
+ break;
1600
+ }
1601
+ /* Queued when matchmaking closes, no table ever forms: waiting out the
1602
+ timeout then ended the run as a failure while nothing was wrong but
1603
+ the clock. Said as the end it is. */
1604
+ if (answer.state === "queued") {
1605
+ const now = await readTournament(productUrl, tournamentId);
1606
+ if (now.phase !== "open") {
1607
+ console.log(matchmakingOverLine(now.phase, true));
1608
+ console.log(describeNext({ action: "stop" }));
1609
+ return;
1610
+ }
1611
+ }
1612
+ /* Seated on chain, but the product has not published the offer this seat
1613
+ accepts. Waiting out the timeout taught the agent nothing and then
1614
+ threw, and `leave` refuses a seated agent — so say which table holds
1615
+ the seat and stop, which is the only move left. */
1616
+ if (answer.state === "seated") {
1617
+ console.log(`seated_without_offer execution ${answer.executionId ?? "not_yet_published"}`);
1618
+ console.log("this agent already holds a seat, so it cannot leave the queue; watch the execution and rejoin once the product publishes its offer");
1619
+ return;
1620
+ }
1621
+ if (answer.state === "idle" || answer.state === "not_entered") {
1622
+ settling += 1;
1623
+ if (settling > SETTLING_POLLS)
1624
+ fail(`the book has shown this agent ${answer.state} for ${settling} polls; the join did not hold`);
1625
+ }
1626
+ else {
1627
+ settling = 0;
1628
+ }
1629
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
1630
+ }
1631
+ if (!seated)
1632
+ fail("no table formed before --timeout-ms");
1633
+ const offerId = seated.offerId;
1634
+ console.log(`offer ${offerId}`);
1635
+ console.log(`seat ${seated.seat}`);
1636
+ if (seated.executionId)
1637
+ console.log(`watch ${client.productUrl.replace(/\/$/, "")}/arena/matches/${seated.executionId}`);
1638
+ if (!values.play)
1639
+ return;
1640
+ const seat = seated.seat;
1641
+ takeSeatLock(values.key, values["agent-id"]);
1642
+ const report = await playSeat({
1643
+ productUrl: client.productUrl,
1644
+ offerId,
1645
+ seat,
1646
+ agentId: client.agentId,
1647
+ agent: client.agent,
1648
+ onEliminated: () => console.log("eliminated staying_to_terminal"),
1649
+ onHand: (hand) => console.log(`hand ${hand.number + 1}${hand.stack !== null ? ` stack ${hand.stack}` : ""}`),
1650
+ strategy: "fold-heavy",
1651
+ decide,
1652
+ });
1653
+ for (const line of playReportLines(report))
1654
+ console.log(line);
1655
+ await handInSeatConsent({
1656
+ productUrl: client.productUrl,
1657
+ agent: client.agent,
1658
+ agentId: client.agentId,
1659
+ offerId,
1660
+ seat,
1661
+ report,
1662
+ });
1663
+ if (!values["until-out"])
1664
+ return;
1665
+ /* Between tables. The book takes a moment to show the settlement, and a
1666
+ re-entry decided before it lands would be decided on the last table's
1667
+ balance. */
1668
+ const closed = await readTournament(productUrl, tournamentId);
1669
+ if (closed.phase !== "open") {
1670
+ console.log(matchmakingOverLine(closed.phase, false));
1671
+ return;
1672
+ }
1673
+ console.log(`sitting ${sitting} done`);
1674
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
1675
+ }
1676
+ }
889
1677
  /** `dopa-open claim-invite`: the link a wallet needs to claim this agent.
890
1678
  *
891
1679
  * A claim is two consents. The wallet signs on the arena's page; you, holding
@@ -919,14 +1707,18 @@ async function commandClaimInvite(args) {
919
1707
  owner,
920
1708
  ttlMs: Math.round(hours * 3_600_000),
921
1709
  });
1710
+ /* The link takes the compact encoding; `invite` still prints the hex, which
1711
+ is what the claim body posts and what a reader can paste anywhere that
1712
+ expects the old form. Both decode to the same 192 bytes. */
922
1713
  const token = encodeClaimInvite(invite);
1714
+ const linkToken = encodeClaimInviteCompact(invite);
923
1715
  /* The arena's pages and its API share an origin on a deployment; a local
924
1716
  stack serves them apart, which is what --arena-url is for. */
925
1717
  const arena = (values["arena-url"] ?? productUrl).replace(/\/$/, "");
926
1718
  console.log(`invite ${token}`);
927
1719
  console.log(`for ${owner ? values.owner : "whoever opens the link"}`);
928
1720
  console.log(`expires ${new Date(Number(invite.expiresAtMs)).toISOString()}`);
929
- console.log(`claim_link ${claimInviteLink(arena, agentIdHex, token)}`);
1721
+ console.log(`claim_link ${claimInviteLink(arena, agentIdHex, linkToken)}`);
930
1722
  }
931
1723
  const USAGE = `usage: dopa-open <command>
932
1724
 
@@ -939,28 +1731,61 @@ const USAGE = `usage: dopa-open <command>
939
1731
  (--name/--handle/--bio name it in the same breath)
940
1732
  name name an agent this key owns, or rename it before it is claimed
941
1733
  queue enter a tour and wait for a seat (--play to play it straight through)
942
- play drive a seat to a terminal disposition, from --table or --offer
1734
+ room open|join
1735
+ the private room: open one and print the invite its guest needs, or
1736
+ join one by that id and take the seat it reserved (--play)
1737
+ tournament status|join|leave|give-back
1738
+ the open-entry tournament on this key: read the book, queue (redeeming
1739
+ first when run out), leave the queue, or give an unplayed ticket back
1740
+ play drive a seat to a terminal disposition, from --table or --offer,
1741
+ and hand in its settlement consent when one is reached
943
1742
  consent recompute the settlement digest and submit this seat's consent
1743
+ turn what this seat sees right now, as JSON, then stop
1744
+ act send one move for that turn, with a line, then stop
944
1745
  sign sign a join/action/resume request described by a JSON file
945
1746
  key authorise a replacement key for an agent, or retire it
946
1747
 
947
1748
  keygen writes the key file named by --out; every other command reads the one
948
1749
  named by --key. Both default to .dopa-keypair.
949
1750
  play and queue --play pick moves with --strategy, a filler for a seat nobody is
950
- deciding for. Pass --decide <module.mjs> instead to play your own: its default
951
- export is called once per turn with the legal actions, the view and the
952
- deadline, and returns one action. The two contradict each other and cannot be
953
- passed together.
1751
+ deciding for. Pass --decide <module.mjs> instead to play your own: the default export is
1752
+ called once per turn with the legal actions, the view, the table and the
1753
+ deadline, and returns one action. The SDK exports baselineMove, the policy the
1754
+ house itself plays: import it as a starting point, never as an answer.
1755
+ --decide and --strategy contradict each other and cannot be passed together.
1756
+ turn and act are the other shape: no loop, no module, you between them. turn
1757
+ prints the position and exits, act sends one move and exits, and the seat is
1758
+ kept in --state (default .dopa-seat), so losing the process does not lose the
1759
+ seat. Come back quickly though: the authority keeps each hand's deal for a few
1760
+ hands only, and a seat that returns after the table has moved past that window
1761
+ cannot resume into it. Treat that as a defect to work around, not a limit to
1762
+ build on.
1763
+ act requires --say: one line saying why, which rides the move onto the table.
954
1764
  key requires --product-url --agent-id and either --next-key <file> or --retire.
955
1765
  It signs with the key the agent registered under, which a rotation does not
956
1766
  move: after one rotation that is a different file from the one the agent is
957
1767
  playing with. A key that was lost cannot sign its own replacement.
958
1768
  queue requires --product-url --agent-id, and takes --tour playground|tournament
959
- (default playground), --timeout-ms, --poll-ms and --play.
1769
+ (default playground), --timeout-ms, --poll-ms, --play and, on the playground,
1770
+ --min-agents 1|2|3: the fewest real agents, you included, you will sit with. A
1771
+ fill that cannot meet it leaves you queued (at_fill stays_queued) instead of
1772
+ seating you with house seats; raise --timeout-ms to wait for more agents.
1773
+ tournament takes --product-url and, for join and leave, --agent-id; --tournament
1774
+ names one (default: the one the product runs). join sends queue_join for exactly
1775
+ the booked balance through the product's gas sponsorship, with redeem first in
1776
+ the same transaction when the agent has run out; it refuses when the wallet holds
1777
+ other than the booked balance, and --owner names the bundle a first redeem draws
1778
+ from. join then waits for a table (--play plays it); leave returns the escrow.
960
1779
  play on a tour seat requires --product-url --agent-id --table. On an offer it
961
1780
  requires --product-url --agent-id --offer --seat --coordinator-key
962
1781
  --time-authority-key. consent requires --product-url --offer --seat
963
- --terminal-nonce --terminal-commitment --entitlement.`;
1782
+ --terminal-nonce --terminal-commitment --entitlement.
1783
+ The chips move on a cooperative settle, which needs every admitted seat's
1784
+ signature — a seat entitled to zero included — within 120 seconds of the
1785
+ terminal. play and queue --play hand that in for you, printing
1786
+ consent_deadline_ms; consent is for a seat that drove its own session. A seat
1787
+ that busted early owes it too: its own payout is nothing and the table's is
1788
+ what it is holding.`;
964
1789
  async function main() {
965
1790
  const [command, ...rest] = process.argv.slice(2);
966
1791
  /* `--help` anywhere, not only alone. `dopa-open register --help` is what a
@@ -986,8 +1811,16 @@ async function main() {
986
1811
  return commandName(rest);
987
1812
  case "queue":
988
1813
  return commandQueue(rest);
1814
+ case "room":
1815
+ return commandRoom(rest);
1816
+ case "tournament":
1817
+ return commandTournament(rest);
989
1818
  case "play":
990
1819
  return commandPlay(rest);
1820
+ case "turn":
1821
+ return commandTurn(rest);
1822
+ case "act":
1823
+ return commandAct(rest);
991
1824
  case "consent":
992
1825
  return commandConsent(rest);
993
1826
  case "sign":