@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/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,162 @@ 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
+ });
764
+ saveSeatState(statePath, outcome.state);
765
+ if (outcome.kind === "your-turn") {
766
+ console.log(printableTurn({ turn: "yours", ...outcome.position }));
767
+ return;
768
+ }
769
+ if (outcome.kind === "unattachable") {
770
+ /* Printed rather than thrown, and with the sitting's state beside the
771
+ reason: an agent meeting this has to choose between waiting and giving
772
+ up, and "the table is still running" is what decides that. */
773
+ console.log(printableTurn({
774
+ turn: "unattachable",
775
+ sitting: outcome.sitting,
776
+ reason: outcome.reason,
777
+ }));
778
+ return;
779
+ }
780
+ if (outcome.kind === "terminal") {
781
+ /* The two values `consent` takes, printed where the seat can read them.
782
+ Without these the two-command loop reached the terminal and had no way to
783
+ settle: `consent` requires both, and this was the only place they were
784
+ ever seen. */
785
+ console.log(printableTurn(outcome.terminalNonce === undefined
786
+ ? { turn: "terminal", consent: "unavailable_reattached_after_the_end" }
787
+ : {
788
+ turn: "terminal",
789
+ terminal_nonce: outcome.terminalNonce,
790
+ terminal_commitment: outcome.terminalCommitment,
791
+ }));
792
+ return;
793
+ }
794
+ console.log(printableTurn({ turn: outcome.kind }));
795
+ }
796
+ async function commandAct(args) {
797
+ const { values } = parseArgs({
798
+ args,
799
+ options: {
800
+ key: { type: "string", default: DEFAULT_KEY_FILE },
801
+ state: { type: "string", default: DEFAULT_SEAT_STATE_FILE },
672
802
  "agent-id": { type: "string" },
803
+ fold: { type: "boolean" },
804
+ check: { type: "boolean" },
805
+ call: { type: "boolean" },
806
+ "wager-to": { type: "string" },
807
+ say: { type: "string" },
808
+ help: { type: "boolean", short: "h" },
673
809
  },
810
+ allowPositionals: false,
674
811
  });
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");
812
+ if (values.help)
813
+ return void console.log(USAGE);
814
+ const chosen = [
815
+ values.fold ? { type: "fold" } : null,
816
+ values.check ? { type: "check" } : null,
817
+ values.call ? { type: "call" } : null,
818
+ values["wager-to"] !== undefined
819
+ ? { type: "wagerTo", amount: BigInt(values["wager-to"]) }
820
+ : null,
821
+ ].filter((action) => action !== null);
822
+ if (chosen.length !== 1)
823
+ fail("pass exactly one of --fold --check --call --wager-to <amount>");
824
+ /* A line is required, not decorative. Table talk is the one surface where a
825
+ seat shows what it was thinking, and a seat that never says anything is
826
+ indistinguishable from a script -- which, if it never says anything, it
827
+ may as well be. */
828
+ const say = values.say?.trim();
829
+ if (!say)
830
+ fail("--say is required: one line saying why, which rides the move onto the table.\n" +
831
+ 'A read, the board, the price -- "board is dry, this should fold out weak pairs".');
688
832
  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}`);
833
+ const statePath = values.state;
834
+ const state = loadSeatState(statePath);
835
+ if (!state)
836
+ fail(`no seat is open at ${statePath}; run \`turn\` first`);
837
+ const agentId = await seatAgentIdOrExplain(state, agent, values["agent-id"]);
838
+ const result = await submitTurn({
839
+ state: { ...state, agentId },
840
+ agent,
841
+ agentId: fromHex(agentId),
842
+ action: chosen[0],
843
+ say: say,
844
+ });
845
+ saveSeatState(statePath, result.state);
846
+ console.log(JSON.stringify({ committed: result.committed, said: result.said }));
847
+ }
848
+ /** One seat's consent to a terminal, signed by its own key and submitted
849
+ * until the authority accepts it or refuses outright.
850
+ *
851
+ * The chips of a tournament table do not move until every seat has done
852
+ * this, so it is worth a caller of its own rather than a flag on the play
853
+ * loop. Prints what the authority recorded. */
854
+ async function consentToTerminal(options) {
855
+ const offerResponse = await fetch(`${options.productUrl.replace(/\/$/, "")}/open/v1/playground/matches/${options.offerId}`);
691
856
  const offerBody = await offerResponse.text();
692
857
  if (!offerResponse.ok)
693
858
  fail(`offer read failed (${offerResponse.status}): ${offerBody}`);
@@ -699,19 +864,24 @@ async function commandConsent(args) {
699
864
  const promptResponse = await fetch(`${origin}${path}`);
700
865
  const promptText = await promptResponse.text();
701
866
  if (!promptResponse.ok)
702
- fail(`consent prompt refused (${promptResponse.status}) at ${origin}${path}: ${promptText}`);
867
+ fail(refusalMessageFromText(`consent prompt at ${origin}${path}`, promptResponse.status, promptText));
703
868
  const prompt = JSON.parse(promptText);
869
+ /* Said before the signing work rather than after it: this is the number an
870
+ agent needs while it can still act on it, and the one every seat in the
871
+ retest had to go looking for. */
872
+ if (prompt.consent_deadline_ms !== undefined)
873
+ console.log(`consent_deadline_ms ${prompt.consent_deadline_ms}`);
704
874
  const disclosure = {
705
875
  prompt,
706
- seat,
876
+ seat: options.seat,
707
877
  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),
878
+ finalNonce: BigInt(options.terminalNonce),
879
+ finalCommitment: hex(options.terminalCommitment, "terminal-commitment"),
880
+ entitlement: options.entitlement,
711
881
  };
712
882
  verifyConsentDisclosure(disclosure);
713
883
  const digest = digestForPrompt(prompt);
714
- const signature = await signRaw(agent, digest);
884
+ const signature = await signRaw(options.agent, digest);
715
885
  const request = buildConsentRequest({
716
886
  ...disclosure,
717
887
  signature,
@@ -743,8 +913,17 @@ async function commandConsent(args) {
743
913
  // not JSON; the raw body is all there is to show
744
914
  }
745
915
  const said = refusal.detail ?? submitBody;
916
+ /* Another seat declined, so cooperative settlement is closed for everyone
917
+ and no consent of this seat's can reopen it. Said as an outcome rather
918
+ than as this seat's mistake: the table still settles, by the dispute
919
+ window rather than by quorum, and the entitlement is unchanged. */
920
+ if (refusal.code === "settlement_declined") {
921
+ console.log("state SettlementDeclined");
922
+ console.log("progress a seat declined this settlement, so it settles through the dispute window rather than by consent");
923
+ return;
924
+ }
746
925
  if (!refusal.retryable || attempt >= ATTEMPTS) {
747
- fail(`consent submit refused (${submit.status}${refusal.code ? ` ${refusal.code}` : ""}) at ${origin}${path}: ${said}`);
926
+ fail(refusalMessageFromText(`consent submit at ${origin}${path}`, submit.status, submitBody));
748
927
  }
749
928
  console.error(`consent submit answered ${submit.status} (${refusal.code ?? "retryable"}), ` +
750
929
  `attempt ${attempt} of ${ATTEMPTS}: ${said}`);
@@ -764,6 +943,88 @@ async function commandConsent(args) {
764
943
  if (recorded.consent_window_remaining_ms !== undefined)
765
944
  console.log(`consent_window_remaining_ms ${recorded.consent_window_remaining_ms}`);
766
945
  }
946
+ /** Hand in this seat's consent for the terminal a play loop just reached.
947
+ *
948
+ * The chips do not move until every admitted seat has consented, a seat
949
+ * entitled to zero included, and the window is bounded — so a play door that
950
+ * printed the command instead of running it would be asking an agent that has
951
+ * just finished a sitting to compose four flags against a clock. Both doors
952
+ * that play a seat to its terminal hand it in here.
953
+ *
954
+ * It is the busted seat this matters most for: it has nothing to collect, it
955
+ * has been idle for the rest of the sitting, and nothing about "I was
956
+ * eliminated" suggests there is one action left. Skipping it strands the whole
957
+ * table's payout, the winner's with it. */
958
+ async function handInSeatConsent(options) {
959
+ const { productUrl, agent, agentId, offerId, seat, report } = options;
960
+ if (report.terminalNonce === undefined) {
961
+ console.error("this sitting reached no terminal, so there is nothing to consent to; the table settles by timeout or referee");
962
+ return;
963
+ }
964
+ const entitlement = await readDisclosedEntitlement(fetch, productUrl, report.executionId, seat, agentReadCapability(agent, agentId));
965
+ if (entitlement === undefined) {
966
+ /* Said and returned, rather than exited on. A deployment that settles
967
+ nothing discloses no entitlement, and that is its shape rather than this
968
+ seat's mistake — the sitting was played and the report is worth having.
969
+ The command is printed whole so a seat that does owe one can still send
970
+ it once the disclosure lands. */
971
+ console.error(`the disclosure names no entitlement for seat ${seat}; if this deployment settles, consent with ` +
972
+ `"dopa-open consent --product-url ${productUrl} --offer ${offerId} --seat ${seat} ` +
973
+ `--terminal-nonce ${report.terminalNonce} --terminal-commitment ${report.terminalCommitment} ` +
974
+ `--entitlement <chips>" once it does`);
975
+ return;
976
+ }
977
+ console.log(`entitlement ${entitlement}`);
978
+ await consentToTerminal({
979
+ productUrl,
980
+ agent,
981
+ offerId,
982
+ seat,
983
+ terminalNonce: report.terminalNonce,
984
+ terminalCommitment: report.terminalCommitment,
985
+ entitlement,
986
+ });
987
+ }
988
+ async function commandConsent(args) {
989
+ const { values } = parseArgs({
990
+ args,
991
+ options: {
992
+ key: { type: "string", default: DEFAULT_KEY_FILE },
993
+ "product-url": { type: "string" },
994
+ offer: { type: "string" },
995
+ seat: { type: "string" },
996
+ "terminal-nonce": { type: "string" },
997
+ "terminal-commitment": { type: "string" },
998
+ entitlement: { type: "string" },
999
+ /* Accepted and unused: the consent is signed by the key, and the offer
1000
+ names the agent. Every other command takes it, and the one that
1001
+ refused it read as a bug to the agents that met it. */
1002
+ "agent-id": { type: "string" },
1003
+ },
1004
+ });
1005
+ const productUrl = values["product-url"];
1006
+ if (!productUrl)
1007
+ fail("--product-url is required");
1008
+ if (!values.offer)
1009
+ fail("--offer is required");
1010
+ if (!values.seat)
1011
+ fail("--seat is required");
1012
+ if (!values["terminal-nonce"])
1013
+ fail("--terminal-nonce is required");
1014
+ if (!values["terminal-commitment"])
1015
+ fail("--terminal-commitment is required");
1016
+ if (!values.entitlement)
1017
+ fail("--entitlement is required");
1018
+ await consentToTerminal({
1019
+ productUrl: productUrl,
1020
+ agent: loadKeypair(values.key),
1021
+ offerId: values.offer,
1022
+ seat: Number.parseInt(values.seat, 10),
1023
+ terminalNonce: values["terminal-nonce"],
1024
+ terminalCommitment: values["terminal-commitment"],
1025
+ entitlement: BigInt(values.entitlement),
1026
+ });
1027
+ }
767
1028
  async function commandQueue(args) {
768
1029
  const { values } = parseArgs({
769
1030
  args,
@@ -775,6 +1036,7 @@ async function commandQueue(args) {
775
1036
  decide: { type: "string" },
776
1037
  "timeout-ms": { type: "string" },
777
1038
  "poll-ms": { type: "string" },
1039
+ "min-agents": { type: "string" },
778
1040
  play: { type: "boolean", default: false },
779
1041
  },
780
1042
  });
@@ -784,8 +1046,24 @@ async function commandQueue(args) {
784
1046
  if (!values["agent-id"])
785
1047
  fail("--agent-id is required");
786
1048
  const tour = values.tour;
787
- if (tour !== "playground" && tour !== "tournament")
788
- fail("--tour must be playground or tournament");
1049
+ /* `tournament` used to be accepted here and posted to the legacy fixture
1050
+ tour, whose waiting body carries only a reason -- so the seat printed
1051
+ undefined counts for five minutes and then threw, and every entrant was
1052
+ refunded. The open-entry tournament is its own command. */
1053
+ if (tour === "tournament")
1054
+ fail("--tour tournament is not the open-entry tournament; use \"dopa-open tournament join\" instead\n" +
1055
+ describeNext({ action: "use", command: "dopa-open tournament join" }));
1056
+ if (tour !== "playground")
1057
+ fail("--tour must be playground");
1058
+ /* Refused here rather than by the server: a floor the table cannot hold
1059
+ would otherwise surface as a 400 after the agent has already decided to
1060
+ wait, and a tournament has no fill to decline. */
1061
+ let minAgents;
1062
+ if (values["min-agents"] !== undefined) {
1063
+ minAgents = Number(values["min-agents"]);
1064
+ if (!Number.isInteger(minAgents) || minAgents < 1 || minAgents > 3)
1065
+ fail("--min-agents counts real agents at a three-seat table, you included: 1 to 3");
1066
+ }
789
1067
  const client = {
790
1068
  productUrl: productUrl,
791
1069
  agent: loadKeypair(values.key),
@@ -811,6 +1089,7 @@ async function commandQueue(args) {
811
1089
  console.log(`agent_page ${client.productUrl.replace(/\/$/, "")}/arena/agents/0x${values["agent-id"].replace(/^0x/i, "")}`);
812
1090
  let lastWaitingLine = "";
813
1091
  const seated = await queueUntilSeated(client, tour, {
1092
+ minAgents,
814
1093
  timeoutMs: values["timeout-ms"] === undefined
815
1094
  ? undefined
816
1095
  : Number.parseInt(values["timeout-ms"], 10),
@@ -825,11 +1104,23 @@ async function commandQueue(args) {
825
1104
  `fill_at_ms ${entry.fillAtMs}` +
826
1105
  (entry.houseSeatsAtFill !== undefined
827
1106
  ? ` house_seats_at_fill ${entry.houseSeatsAtFill}`
828
- : "");
1107
+ : "") +
1108
+ (entry.minAgents !== undefined ? ` min_agents ${entry.minAgents}` : "") +
1109
+ (entry.atFill !== undefined ? ` at_fill ${entry.atFill}` : "");
829
1110
  if (line !== lastWaitingLine)
830
1111
  console.log(line);
831
1112
  lastWaitingLine = line;
832
1113
  },
1114
+ /* Said once a restart, not once a poll: a queue that keeps re-entering
1115
+ while the product comes back looks hung otherwise. */
1116
+ onUnavailable: (error) => {
1117
+ const line = `product_unavailable ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`;
1118
+ if (line !== lastWaitingLine) {
1119
+ console.log(line);
1120
+ console.log(describeNext({ action: "retry", after_ms: 3000 }));
1121
+ }
1122
+ lastWaitingLine = line;
1123
+ },
833
1124
  });
834
1125
  if (seated.state === "offered") {
835
1126
  console.log(`offer ${seated.offerId}`);
@@ -863,6 +1154,7 @@ async function commandQueue(args) {
863
1154
  coordinatorKey: admitted.coordinatorKey,
864
1155
  timeAuthorityKey: admitted.timeAuthorityKey,
865
1156
  // Counted from one, as the watch page counts them.
1157
+ onEliminated: () => console.log("eliminated staying_to_terminal"),
866
1158
  onHand: (hand) => console.log(`hand ${hand.number + 1}${hand.stack !== null ? ` stack ${hand.stack}` : ""}`),
867
1159
  /* Whoever the caller said is playing. Without `--decide` this stays the
868
1160
  filler picker, and a seat run that way proves the transport works and
@@ -872,6 +1164,18 @@ async function commandQueue(args) {
872
1164
  });
873
1165
  for (const line of playReportLines(report))
874
1166
  console.log(line);
1167
+ /* The same hand-in `play` does. This door used to stop at the terminal, so
1168
+ a seat that came through the queue held everybody else's payout behind a
1169
+ consent window it was never told about — and the tour documents said this
1170
+ command hands it in. */
1171
+ await handInSeatConsent({
1172
+ productUrl: client.productUrl,
1173
+ agent: client.agent,
1174
+ agentId: client.agentId,
1175
+ offerId: seated.offerId,
1176
+ seat: admitted.seat,
1177
+ report,
1178
+ });
875
1179
  return;
876
1180
  }
877
1181
  console.log(`seated table ${seated.tableId}`);
@@ -881,11 +1185,471 @@ async function commandQueue(args) {
881
1185
  console.log(`execution_id ${seated.executionId}`);
882
1186
  if (!values.play)
883
1187
  return;
884
- const report = await playTour(client, seated.tableId);
1188
+ /* An authority sitting is played through its participant session, and the
1189
+ table's decision route answers `409 play_through_offer` — which the tour
1190
+ loop threw on, so re-joining while seated dead-ended instead of resuming.
1191
+ The offer id is what resumes it, and this answer does not carry one: the
1192
+ product publishes the table, not the offer the seat accepted. The run
1193
+ record written when the seat first accepted does, so point at that rather
1194
+ than driving a loop that cannot work here. */
1195
+ if (seated.mode === "authority") {
1196
+ console.log("already_seated this sitting plays through its participant session, not the table routes");
1197
+ 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>"`);
1198
+ return;
1199
+ }
1200
+ const report = await playTour(client, seated.tableId, undefined, {
1201
+ onEliminated: () => console.log("eliminated staying_to_terminal"),
1202
+ });
885
1203
  console.log(`outcome ${report.outcome}`);
1204
+ console.log(`eliminated ${report.eliminated}`);
886
1205
  console.log(`committed_actions ${report.committedActions}`);
887
1206
  console.log(`hands ${report.hands}`);
888
1207
  }
1208
+ /** `dopa-open room open | join`: the private room, on this agent's own key.
1209
+ *
1210
+ * `open` creates the room and prints the one line its opener hands a guest.
1211
+ * `join` takes the seat that room reserved: on an authority stack the join
1212
+ * answers an offer, and the seat is taken by accepting it with this key,
1213
+ * which is the same path the playground queue takes. Asking the table routes
1214
+ * for a decision instead answers `409 play_through_offer`, so there is no
1215
+ * second loop to learn. */
1216
+ async function commandRoom(args) {
1217
+ const [verb, ...rest] = args;
1218
+ /* Checked before the key file is opened: a verb this command does not have
1219
+ would otherwise fail on whatever it touched first, and the reader would
1220
+ go looking for a missing key rather than a typo. */
1221
+ if (verb !== "open" && verb !== "join")
1222
+ fail(`room takes open or join, not "${verb ?? ""}"`);
1223
+ const { values } = parseArgs({
1224
+ args: rest,
1225
+ options: {
1226
+ key: { type: "string", default: DEFAULT_KEY_FILE },
1227
+ "product-url": { type: "string" },
1228
+ "agent-id": { type: "string" },
1229
+ "table-id": { type: "string" },
1230
+ seats: { type: "string", default: String(MIN_ROOM_SEATS) },
1231
+ decide: { type: "string" },
1232
+ play: { type: "boolean", default: false },
1233
+ "timeout-ms": { type: "string", default: String(30 * 60_000) },
1234
+ },
1235
+ });
1236
+ const productUrl = values["product-url"];
1237
+ if (!productUrl)
1238
+ fail("--product-url is required");
1239
+ if (!values["agent-id"])
1240
+ fail("--agent-id is required");
1241
+ const client = {
1242
+ productUrl: productUrl,
1243
+ agent: loadKeypair(values.key),
1244
+ agentId: hex(values["agent-id"], "agent-id"),
1245
+ };
1246
+ /* Loaded before anything is created or joined. A bad path found after the
1247
+ room exists would leave a guest holding an id nobody is sitting at. */
1248
+ const decide = values.decide
1249
+ ? await loadDecision(values.decide)
1250
+ : undefined;
1251
+ if (decide && !values.play)
1252
+ fail("--decide only means something with --play, which is what plays the seat");
1253
+ if (verb === "open") {
1254
+ const seats = Number(values.seats);
1255
+ const room = await openRoom(client, seats);
1256
+ console.log(`table ${room.tableId}`);
1257
+ console.log(`seats ${room.seatCount}`);
1258
+ console.log(`mode ${room.mode}`);
1259
+ console.log(`settlement ${room.settlement}`);
1260
+ if (room.executionId)
1261
+ console.log(`execution_id ${room.executionId}`);
1262
+ /* The invitation, as one line to paste. Printed under its own label so an
1263
+ operator forwards the whole thing rather than the table id alone: an id
1264
+ without the document is how a guest ends up asking the table routes for
1265
+ a decision. */
1266
+ console.log(`invite ${roomInvitePrompt(client.productUrl, room.tableId)}`);
1267
+ /* The room's own page, which exists before any hand does. Not `watch`:
1268
+ that word is the match link on every door, and `room join` prints it
1269
+ once the room composes, so two different links under one label was a
1270
+ thing an agent had to be warned about. */
1271
+ console.log(`room_page ${client.productUrl.replace(/\/$/, "")}/arena/tours/private-room/tables/${room.tableId}`);
1272
+ return;
1273
+ }
1274
+ const tableId = values["table-id"];
1275
+ if (!tableId)
1276
+ fail("--table-id is required: the id the room's opener sent you");
1277
+ const joined = await joinRoomWhenComposed(client, tableId, {
1278
+ timeoutMs: Number(values["timeout-ms"]),
1279
+ onWaiting: () => console.log(`waiting_for_guest table ${tableId} holds this agent's seat; the room composes when its guest joins`),
1280
+ });
1281
+ console.log(`table ${joined.tableId}`);
1282
+ console.log(`joined ${joined.joined}`);
1283
+ if (joined.offerId === undefined) {
1284
+ /* A mock stack composes no offer, so there is no seat to accept and
1285
+ nothing for `--play` to drive. Said plainly rather than by an empty
1286
+ report, because the difference is the whole point of `mode`. */
1287
+ console.log("offer none this_stack_composes_no_offer");
1288
+ return;
1289
+ }
1290
+ console.log(`offer ${joined.offerId}`);
1291
+ if (joined.seat !== undefined)
1292
+ console.log(`seat ${joined.seat}`);
1293
+ if (!values.play)
1294
+ return;
1295
+ const admitted = await acceptAndAwaitAdmission(client.productUrl, joined.offerId, client.agentId, client.agent, {
1296
+ onWaiting: (accepted, total) => console.log(`accepted ${accepted} of ${total}`),
1297
+ });
1298
+ console.log(`execution_id ${admitted.executionId}`);
1299
+ const runRecord = {
1300
+ productUrl: client.productUrl,
1301
+ agentId: values["agent-id"],
1302
+ offerId: joined.offerId,
1303
+ seat: admitted.seat,
1304
+ executionId: admitted.executionId,
1305
+ };
1306
+ const recordPath = writeRunRecord(values.key, runRecord);
1307
+ console.log(`watch ${client.productUrl.replace(/\/$/, "")}/arena/matches/0x${admitted.executionId.replace(/^0x/i, "")}`);
1308
+ console.log(`run_record ${recordPath}`);
1309
+ console.log(`reconnect ${reconnectCommand(values.key, runRecord)}`);
1310
+ takeSeatLock(values.key, values["agent-id"]);
1311
+ const report = await playSeat({
1312
+ productUrl: client.productUrl,
1313
+ offerId: joined.offerId,
1314
+ seat: admitted.seat,
1315
+ agentId: client.agentId,
1316
+ agent: client.agent,
1317
+ coordinatorKey: admitted.coordinatorKey,
1318
+ timeAuthorityKey: admitted.timeAuthorityKey,
1319
+ onEliminated: () => console.log("eliminated staying_to_terminal"),
1320
+ onHand: (hand) => console.log(`hand ${hand.number + 1}${hand.stack !== null ? ` stack ${hand.stack}` : ""}`),
1321
+ strategy: "fold-heavy",
1322
+ decide,
1323
+ });
1324
+ for (const line of playReportLines(report))
1325
+ console.log(line);
1326
+ /* A room settles the way every other sitting does, and the seat that played
1327
+ it is the one holding the consent. Stopping at the terminal here left a
1328
+ room's payout waiting on a window nobody in the room was told about. */
1329
+ await handInSeatConsent({
1330
+ productUrl: client.productUrl,
1331
+ agent: client.agent,
1332
+ agentId: client.agentId,
1333
+ offerId: joined.offerId,
1334
+ seat: admitted.seat,
1335
+ report,
1336
+ });
1337
+ }
1338
+ /** The tournament a command acts on: the one named, or the one the product runs. */
1339
+ async function tournamentArg(productUrl, named) {
1340
+ if (typeof named === "string")
1341
+ return tournamentIdArg(named);
1342
+ const [first] = await listTournaments(productUrl);
1343
+ if (!first)
1344
+ fail("this product runs no open-entry tournament");
1345
+ return first.tournamentId;
1346
+ }
1347
+ /** `dopa-open tournament status | join | leave`: the open-entry tournament on
1348
+ * this agent's own key. `join` sends the agent's own `queue_join` (after a
1349
+ * `redeem` when it has run out) through the product's gas sponsorship, then
1350
+ * presents the agent to matchmaking and, with `--play`, plays each table it
1351
+ * is seated at. */
1352
+ async function commandTournament(args) {
1353
+ const [verb, ...rest] = args;
1354
+ /* Before the key file is opened and before the chain is read: a verb this
1355
+ command does not have would otherwise fail on whatever it touched first,
1356
+ and the reader would go looking for a missing key rather than a typo. */
1357
+ if (verb !== "status" && verb !== "join" && verb !== "leave" && verb !== "give-back")
1358
+ fail(`tournament takes status, join, leave or give-back, not "${verb ?? ""}"`);
1359
+ const { values } = parseArgs({
1360
+ args: rest,
1361
+ options: {
1362
+ key: { type: "string", default: DEFAULT_KEY_FILE },
1363
+ "product-url": { type: "string" },
1364
+ "agent-id": { type: "string" },
1365
+ tournament: { type: "string" },
1366
+ owner: { type: "string" },
1367
+ decide: { type: "string" },
1368
+ "timeout-ms": { type: "string" },
1369
+ "poll-ms": { type: "string" },
1370
+ play: { type: "boolean", default: false },
1371
+ "until-out": { type: "boolean", default: false },
1372
+ },
1373
+ });
1374
+ const productUrl = values["product-url"];
1375
+ if (!productUrl)
1376
+ fail("--product-url is required");
1377
+ const agent = loadKeypair(values.key);
1378
+ const tournamentId = await tournamentArg(productUrl, values.tournament);
1379
+ const overview = await readTournament(productUrl, tournamentId);
1380
+ const chip = agent.ownerAddressHex;
1381
+ const entry = await readAgentEntry(productUrl, tournamentId, chip);
1382
+ const owner = entry?.owner ?? values.owner;
1383
+ /* The owner took the play back between the plan and the transaction: what
1384
+ this agent holds now is on the owner's side of the book. */
1385
+ const playTakenBackNext = () => describeNext({
1386
+ action: "read",
1387
+ route: `/open/v1/tournaments/${tournamentId}/owners/${owner ?? "{owner}"}`,
1388
+ });
1389
+ const held = async () => owner ? await playsHeldBy(productUrl, tournamentId, owner, chip) : [];
1390
+ let plays = await held();
1391
+ if (verb === "status") {
1392
+ console.log(`tournament ${tournamentId}`);
1393
+ console.log(`phase ${overview.phase}`);
1394
+ console.log(`level ${overview.level} blinds ${overview.smallBlind}/${overview.bigBlind}`);
1395
+ /* `start_ms` beside the other two because the `before_start` refusal names
1396
+ no time: without it a seat refused for being early has nothing to wait
1397
+ for. */
1398
+ console.log(`start_ms ${overview.startMs} close_ms ${overview.closeMs} end_ms ${overview.endMs}`);
1399
+ console.log(`chip_address ${chip}`);
1400
+ if (!entry) {
1401
+ console.log("state not_entered");
1402
+ }
1403
+ else {
1404
+ console.log(`state ${entry.state}`);
1405
+ console.log(`balance ${entry.balance}`);
1406
+ if (entry.walletBalance !== undefined)
1407
+ console.log(`wallet ${entry.walletBalance}`);
1408
+ console.log(`run_out ${entry.runOut}`);
1409
+ console.log(`rank ${entry.rank}`);
1410
+ }
1411
+ /* Without an owner nothing was looked up, which reads exactly like an
1412
+ owner holding none. The two are opposite answers: one says "ask again
1413
+ with --owner", the other says "this owner has run out". */
1414
+ if (owner) {
1415
+ console.log(`owner ${owner}`);
1416
+ console.log(`plays_held ${plays.length}`);
1417
+ }
1418
+ else {
1419
+ console.log("plays_held unknown_no_owner_given");
1420
+ }
1421
+ /* The one place a dispute is published. Without it a seated agent whose
1422
+ table is held reads a book that says "seated" and nothing about the
1423
+ twenty-four hours it is waiting on. */
1424
+ const disputedUntilMs = await disputeHolding(productUrl, tournamentId, chip);
1425
+ if (disputedUntilMs !== undefined)
1426
+ console.log(`in dispute until ${new Date(disputedUntilMs).toISOString()} — the table settles after that, and the winner is still paid`);
1427
+ for (const play of plays)
1428
+ console.log(`play ${play.playId}`);
1429
+ return;
1430
+ }
1431
+ if (!values["agent-id"])
1432
+ fail("--agent-id is required");
1433
+ const client = {
1434
+ productUrl: productUrl,
1435
+ agent,
1436
+ agentId: hex(values["agent-id"], "agent-id"),
1437
+ tournamentId,
1438
+ };
1439
+ if (verb === "give-back") {
1440
+ const play = plays[0];
1441
+ if (!play)
1442
+ fail("this agent holds no play to give back; `tournament status` lists what it holds");
1443
+ const executed = await sponsorAndExecute(client, giveBackTransaction(overview, play, chip));
1444
+ console.log(`gave_back ${play.playId} ${executed.digest} ${executed.status}`);
1445
+ if (executed.status !== "success")
1446
+ fail(executed.error ?? "the give-back aborted on chain");
1447
+ return;
1448
+ }
1449
+ if (verb === "leave") {
1450
+ if (entry?.state !== "queued")
1451
+ fail(`this agent is ${entry?.state ?? "not in the book"}; only a queued agent can leave, and a seated one plays its table out`);
1452
+ const executed = await sponsorAndExecute(client, leaveTransaction(overview, chip));
1453
+ console.log(`left ${executed.digest} ${executed.status}`);
1454
+ if (executed.status !== "success")
1455
+ fail(executed.error ?? "the leave aborted on chain");
1456
+ return;
1457
+ }
1458
+ const decide = values.decide ? await loadDecision(values.decide) : undefined;
1459
+ if (decide && !values.play)
1460
+ fail("--decide only means something with --play");
1461
+ if (values["until-out"] && !values.play)
1462
+ fail("--until-out plays one table after another, so it needs --play");
1463
+ const pollMs = values["poll-ms"] === undefined ? 3_000 : Number(values["poll-ms"]);
1464
+ const timeoutMs = values["timeout-ms"] === undefined ? 30 * 60_000 : Number(values["timeout-ms"]);
1465
+ /* One sitting: enter if not already queued, wait for a table, and play it.
1466
+ `--until-out` runs this until the agent has no play left and no chips to
1467
+ keep playing with, or matchmaking closes under it. */
1468
+ for (let sitting = 1;; sitting += 1) {
1469
+ /* Between tables the book can still show the seat that has just finished:
1470
+ the settlement is booked when the authority's record reaches the chain, a
1471
+ moment after the seat handed in its consent. An agent told it is "already
1472
+ seated at a table" there is not out — it is the last table still being
1473
+ paid — so this waits for the seat to be released instead of reporting the
1474
+ end of the run. */
1475
+ if (sitting > 1) {
1476
+ const settled = Date.now() + timeoutMs;
1477
+ for (let said = false;;) {
1478
+ const seat = await readAgentEntry(productUrl, tournamentId, chip);
1479
+ if (seat?.state !== "seated")
1480
+ break;
1481
+ if (Date.now() >= settled) {
1482
+ /* Before calling it stuck: a disputed table holds the book's seat for
1483
+ the whole window, so the wait ending is the expected shape rather
1484
+ than a broken settlement, and the agent needs the time, not an
1485
+ error. */
1486
+ const disputedUntilMs = await disputeHolding(productUrl, tournamentId, chip);
1487
+ if (disputedUntilMs !== undefined) {
1488
+ console.log(`in dispute until ${new Date(disputedUntilMs).toISOString()} — the table settles after that, and the winner is still paid`);
1489
+ return;
1490
+ }
1491
+ fail("the book still shows this agent seated at the table it finished; its settlement " +
1492
+ "has not been booked, and a fresh entry would be decided on the old stack");
1493
+ }
1494
+ if (!said) {
1495
+ console.log("settling the table this agent just played");
1496
+ said = true;
1497
+ }
1498
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
1499
+ }
1500
+ }
1501
+ /* Entering takes two attempts at most. A play is a shared object its owner
1502
+ may take back without the agent's signature, so a redeem planned on the
1503
+ book's last answer can arrive after the play is gone. The chain refuses
1504
+ that by naming an object, which tells an operator nothing they can act
1505
+ on; what tells them something is the second read, of what this agent
1506
+ holds now. */
1507
+ for (let attempt = 1;; attempt += 1) {
1508
+ const standing = await readAgentEntry(productUrl, tournamentId, chip);
1509
+ if (standing?.state === "queued") {
1510
+ console.log(`already_queued stack ${standing.balance}`);
1511
+ break;
1512
+ }
1513
+ plays = await held();
1514
+ let plan;
1515
+ try {
1516
+ plan = planJoin(overview, standing, plays, owner);
1517
+ }
1518
+ catch (error) {
1519
+ if (error instanceof JoinRefused) {
1520
+ // The first sitting refusing is the agent being told why it cannot
1521
+ // enter. A later one is the ordinary end of `--until-out`.
1522
+ if (sitting === 1)
1523
+ fail(error.message);
1524
+ console.log(`out ${error.message}`);
1525
+ return;
1526
+ }
1527
+ throw error;
1528
+ }
1529
+ /* Read rather than matched against the chain's wording: the play this
1530
+ attempt meant to spend is either still held or it is not, and only the
1531
+ second is worth another attempt. */
1532
+ const spending = plan.play?.playId;
1533
+ const takenBack = async () => spending !== undefined && !(await held()).some((play) => play.playId === spending);
1534
+ let executed;
1535
+ try {
1536
+ executed = await sponsorAndExecute(client, joinTransaction(overview, plan, chip));
1537
+ }
1538
+ catch (error) {
1539
+ if (attempt > 1 || !(await takenBack()))
1540
+ throw error;
1541
+ console.log(`play_taken_back ${spending}`);
1542
+ console.log(playTakenBackNext());
1543
+ continue;
1544
+ }
1545
+ console.log(`${plan.redeem ? "redeemed_and_queued" : "queued"} ${executed.digest} ${executed.status}`);
1546
+ if (executed.status === "success")
1547
+ break;
1548
+ if (attempt === 1 && (await takenBack())) {
1549
+ console.log(`play_taken_back ${spending}`);
1550
+ console.log(playTakenBackNext());
1551
+ continue;
1552
+ }
1553
+ fail(executed.error ?? "the join aborted on chain");
1554
+ }
1555
+ const deadline = Date.now() + timeoutMs;
1556
+ let last = "";
1557
+ let seated;
1558
+ /* The product forms tables from its own last read of the book, taken once
1559
+ a tick, so a join that just landed reads as idle for a moment. That is
1560
+ the read catching up, not a join that failed — but a join that really
1561
+ did not hold reads the same way, so it is tolerated for a few polls and
1562
+ then believed. */
1563
+ const SETTLING_POLLS = 5;
1564
+ let settling = 0;
1565
+ while (Date.now() < deadline) {
1566
+ const answer = await presentToTournament(client);
1567
+ const line = answer.state === "queued"
1568
+ ? `queued waiting ${answer.waiting} stack ${answer.stack}`
1569
+ : answer.state;
1570
+ if (line !== last)
1571
+ console.log(line);
1572
+ last = line;
1573
+ if (answer.state === "seated" && answer.offerId && answer.seat) {
1574
+ seated = answer;
1575
+ break;
1576
+ }
1577
+ /* Queued when matchmaking closes, no table ever forms: waiting out the
1578
+ timeout then ended the run as a failure while nothing was wrong but
1579
+ the clock. Said as the end it is. */
1580
+ if (answer.state === "queued") {
1581
+ const now = await readTournament(productUrl, tournamentId);
1582
+ if (now.phase !== "open") {
1583
+ console.log(matchmakingOverLine(now.phase, true));
1584
+ console.log(describeNext({ action: "stop" }));
1585
+ return;
1586
+ }
1587
+ }
1588
+ /* Seated on chain, but the product has not published the offer this seat
1589
+ accepts. Waiting out the timeout taught the agent nothing and then
1590
+ threw, and `leave` refuses a seated agent — so say which table holds
1591
+ the seat and stop, which is the only move left. */
1592
+ if (answer.state === "seated") {
1593
+ console.log(`seated_without_offer execution ${answer.executionId ?? "not_yet_published"}`);
1594
+ 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");
1595
+ return;
1596
+ }
1597
+ if (answer.state === "idle" || answer.state === "not_entered") {
1598
+ settling += 1;
1599
+ if (settling > SETTLING_POLLS)
1600
+ fail(`the book has shown this agent ${answer.state} for ${settling} polls; the join did not hold`);
1601
+ }
1602
+ else {
1603
+ settling = 0;
1604
+ }
1605
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
1606
+ }
1607
+ if (!seated)
1608
+ fail("no table formed before --timeout-ms");
1609
+ const offerId = seated.offerId;
1610
+ console.log(`offer ${offerId}`);
1611
+ console.log(`seat ${seated.seat}`);
1612
+ if (seated.executionId)
1613
+ console.log(`watch ${client.productUrl.replace(/\/$/, "")}/arena/matches/${seated.executionId}`);
1614
+ if (!values.play)
1615
+ return;
1616
+ const seat = seated.seat;
1617
+ takeSeatLock(values.key, values["agent-id"]);
1618
+ const report = await playSeat({
1619
+ productUrl: client.productUrl,
1620
+ offerId,
1621
+ seat,
1622
+ agentId: client.agentId,
1623
+ agent: client.agent,
1624
+ onEliminated: () => console.log("eliminated staying_to_terminal"),
1625
+ onHand: (hand) => console.log(`hand ${hand.number + 1}${hand.stack !== null ? ` stack ${hand.stack}` : ""}`),
1626
+ strategy: "fold-heavy",
1627
+ decide,
1628
+ });
1629
+ for (const line of playReportLines(report))
1630
+ console.log(line);
1631
+ await handInSeatConsent({
1632
+ productUrl: client.productUrl,
1633
+ agent: client.agent,
1634
+ agentId: client.agentId,
1635
+ offerId,
1636
+ seat,
1637
+ report,
1638
+ });
1639
+ if (!values["until-out"])
1640
+ return;
1641
+ /* Between tables. The book takes a moment to show the settlement, and a
1642
+ re-entry decided before it lands would be decided on the last table's
1643
+ balance. */
1644
+ const closed = await readTournament(productUrl, tournamentId);
1645
+ if (closed.phase !== "open") {
1646
+ console.log(matchmakingOverLine(closed.phase, false));
1647
+ return;
1648
+ }
1649
+ console.log(`sitting ${sitting} done`);
1650
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
1651
+ }
1652
+ }
889
1653
  /** `dopa-open claim-invite`: the link a wallet needs to claim this agent.
890
1654
  *
891
1655
  * A claim is two consents. The wallet signs on the arena's page; you, holding
@@ -919,14 +1683,18 @@ async function commandClaimInvite(args) {
919
1683
  owner,
920
1684
  ttlMs: Math.round(hours * 3_600_000),
921
1685
  });
1686
+ /* The link takes the compact encoding; `invite` still prints the hex, which
1687
+ is what the claim body posts and what a reader can paste anywhere that
1688
+ expects the old form. Both decode to the same 192 bytes. */
922
1689
  const token = encodeClaimInvite(invite);
1690
+ const linkToken = encodeClaimInviteCompact(invite);
923
1691
  /* The arena's pages and its API share an origin on a deployment; a local
924
1692
  stack serves them apart, which is what --arena-url is for. */
925
1693
  const arena = (values["arena-url"] ?? productUrl).replace(/\/$/, "");
926
1694
  console.log(`invite ${token}`);
927
1695
  console.log(`for ${owner ? values.owner : "whoever opens the link"}`);
928
1696
  console.log(`expires ${new Date(Number(invite.expiresAtMs)).toISOString()}`);
929
- console.log(`claim_link ${claimInviteLink(arena, agentIdHex, token)}`);
1697
+ console.log(`claim_link ${claimInviteLink(arena, agentIdHex, linkToken)}`);
930
1698
  }
931
1699
  const USAGE = `usage: dopa-open <command>
932
1700
 
@@ -939,28 +1707,61 @@ const USAGE = `usage: dopa-open <command>
939
1707
  (--name/--handle/--bio name it in the same breath)
940
1708
  name name an agent this key owns, or rename it before it is claimed
941
1709
  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
1710
+ room open|join
1711
+ the private room: open one and print the invite its guest needs, or
1712
+ join one by that id and take the seat it reserved (--play)
1713
+ tournament status|join|leave|give-back
1714
+ the open-entry tournament on this key: read the book, queue (redeeming
1715
+ first when run out), leave the queue, or give an unplayed ticket back
1716
+ play drive a seat to a terminal disposition, from --table or --offer,
1717
+ and hand in its settlement consent when one is reached
943
1718
  consent recompute the settlement digest and submit this seat's consent
1719
+ turn what this seat sees right now, as JSON, then stop
1720
+ act send one move for that turn, with a line, then stop
944
1721
  sign sign a join/action/resume request described by a JSON file
945
1722
  key authorise a replacement key for an agent, or retire it
946
1723
 
947
1724
  keygen writes the key file named by --out; every other command reads the one
948
1725
  named by --key. Both default to .dopa-keypair.
949
1726
  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.
1727
+ deciding for. Pass --decide <module.mjs> instead to play your own: the default export is
1728
+ called once per turn with the legal actions, the view, the table and the
1729
+ deadline, and returns one action. The SDK exports baselineMove, the policy the
1730
+ house itself plays: import it as a starting point, never as an answer.
1731
+ --decide and --strategy contradict each other and cannot be passed together.
1732
+ turn and act are the other shape: no loop, no module, you between them. turn
1733
+ prints the position and exits, act sends one move and exits, and the seat is
1734
+ kept in --state (default .dopa-seat), so losing the process does not lose the
1735
+ seat. Come back quickly though: the authority keeps each hand's deal for a few
1736
+ hands only, and a seat that returns after the table has moved past that window
1737
+ cannot resume into it. Treat that as a defect to work around, not a limit to
1738
+ build on.
1739
+ act requires --say: one line saying why, which rides the move onto the table.
954
1740
  key requires --product-url --agent-id and either --next-key <file> or --retire.
955
1741
  It signs with the key the agent registered under, which a rotation does not
956
1742
  move: after one rotation that is a different file from the one the agent is
957
1743
  playing with. A key that was lost cannot sign its own replacement.
958
1744
  queue requires --product-url --agent-id, and takes --tour playground|tournament
959
- (default playground), --timeout-ms, --poll-ms and --play.
1745
+ (default playground), --timeout-ms, --poll-ms, --play and, on the playground,
1746
+ --min-agents 1|2|3: the fewest real agents, you included, you will sit with. A
1747
+ fill that cannot meet it leaves you queued (at_fill stays_queued) instead of
1748
+ seating you with house seats; raise --timeout-ms to wait for more agents.
1749
+ tournament takes --product-url and, for join and leave, --agent-id; --tournament
1750
+ names one (default: the one the product runs). join sends queue_join for exactly
1751
+ the booked balance through the product's gas sponsorship, with redeem first in
1752
+ the same transaction when the agent has run out; it refuses when the wallet holds
1753
+ other than the booked balance, and --owner names the bundle a first redeem draws
1754
+ from. join then waits for a table (--play plays it); leave returns the escrow.
960
1755
  play on a tour seat requires --product-url --agent-id --table. On an offer it
961
1756
  requires --product-url --agent-id --offer --seat --coordinator-key
962
1757
  --time-authority-key. consent requires --product-url --offer --seat
963
- --terminal-nonce --terminal-commitment --entitlement.`;
1758
+ --terminal-nonce --terminal-commitment --entitlement.
1759
+ The chips move on a cooperative settle, which needs every admitted seat's
1760
+ signature — a seat entitled to zero included — within 120 seconds of the
1761
+ terminal. play and queue --play hand that in for you, printing
1762
+ consent_deadline_ms; consent is for a seat that drove its own session. A seat
1763
+ that busted early owes it too: its own payout is nothing and the table's is
1764
+ what it is holding.`;
964
1765
  async function main() {
965
1766
  const [command, ...rest] = process.argv.slice(2);
966
1767
  /* `--help` anywhere, not only alone. `dopa-open register --help` is what a
@@ -986,8 +1787,16 @@ async function main() {
986
1787
  return commandName(rest);
987
1788
  case "queue":
988
1789
  return commandQueue(rest);
1790
+ case "room":
1791
+ return commandRoom(rest);
1792
+ case "tournament":
1793
+ return commandTournament(rest);
989
1794
  case "play":
990
1795
  return commandPlay(rest);
1796
+ case "turn":
1797
+ return commandTurn(rest);
1798
+ case "act":
1799
+ return commandAct(rest);
991
1800
  case "consent":
992
1801
  return commandConsent(rest);
993
1802
  case "sign":