pyyol 1.3.0 → 1.5.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
@@ -5,6 +5,7 @@
5
5
  * publish, replay, profile, leaderboard, arenas, doctor, update. Zero runtime deps:
6
6
  * uses Node 22+ globals (fetch, WebSocket) and built-ins only.
7
7
  */
8
+ import { spawn } from "node:child_process";
8
9
  import { existsSync, realpathSync } from "node:fs";
9
10
  import { resolve } from "node:path";
10
11
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -233,6 +234,42 @@ async function loadAgentFromConfig(cfg) {
233
234
  return asAgent(obj);
234
235
  }
235
236
  // ── commands ───────────────────────────────────────────────────────────────────
237
+ async function loginAndSave(api, dashboard, connect, provider) {
238
+ const c = await runLoginFlow({ dashboardUrl: dashboard, apiUrl: api, provider });
239
+ if (connect)
240
+ c.connectUrl = connect;
241
+ if (!c.apiKey && c.agentId && c.accessToken) {
242
+ const [st, resp] = await apiPost(`${api}/v1/agent/keys`, c.accessToken, { agent_id: c.agentId });
243
+ if (st === 201 && resp.api_key)
244
+ c.apiKey = resp.api_key;
245
+ }
246
+ creds.save(c);
247
+ return c;
248
+ }
249
+ // Return valid creds for a game/sandbox command, launching the browser login when this
250
+ // DEVICE isn't logged in — so `pyyol dev`/`play`/`queue` just work after install.
251
+ // Non-interactive (CI) → null with guidance so the caller errors cleanly.
252
+ async function ensureLogin(a) {
253
+ const c = creds.load();
254
+ if (c && (c.accessToken || c.apiKey))
255
+ return c;
256
+ const api = (str(a, "api") || DEFAULT_API_BASE).replace(/\/$/, "");
257
+ const dashboard = (str(a, "dashboard") || DEFAULT_DASHBOARD).replace(/\/$/, "");
258
+ if (!(process.stdin.isTTY && process.stdout.isTTY)) {
259
+ console.error(`${BAD} not logged in on this device. Run \`pyyol login\` (opens the browser) or set PYYOL_TOKEN, then retry.`);
260
+ return null;
261
+ }
262
+ console.log("you're not logged in on this device — opening the browser to sign in…");
263
+ try {
264
+ const got = await loginAndSave(api, dashboard, str(a, "connect") || "", str(a, "with") || "");
265
+ console.log(`${OK} logged in as ${got.agentId || "(no agent yet)"}. continuing…`);
266
+ return got;
267
+ }
268
+ catch (e) {
269
+ console.error(`${BAD} login failed: ${e} — run \`pyyol login\` and retry.`);
270
+ return null;
271
+ }
272
+ }
236
273
  async function cmdLogin(a) {
237
274
  const token = str(a, "token");
238
275
  // API host serves /v1/*; dashboard host serves /cli-login — different in prod,
@@ -363,13 +400,51 @@ export const agent = new ${cls}();
363
400
  console.log(` pyyol play ${arena} # compete (sandbox); add --ranked for real`);
364
401
  return 0;
365
402
  }
403
+ // Where a running match is watched in the browser, per game. Mirrors the Python SDK.
404
+ // Verified against the client's routes: Goofspiel and Monopoly take ?match= at the
405
+ // top level; Mafia's viewer lives under /arena. A wrong path is worse than no link —
406
+ // it lands the developer on a DIFFERENT live match.
407
+ const WATCH_ROUTE = {
408
+ goofspiel: "/goofspiel",
409
+ mafia: "/arena/mafia",
410
+ monopoly: "/monopoly",
411
+ };
412
+ function watchUrl(arena, matchId) {
413
+ const route = WATCH_ROUTE[arena];
414
+ if (!route || !matchId)
415
+ return "";
416
+ // encodeURIComponent (not encodeURI) so a slash is escaped too, and cannot alter
417
+ // the path instead of the query.
418
+ return `${DEFAULT_DASHBOARD}${route}?match=${encodeURIComponent(matchId)}`;
419
+ }
420
+ // Only the first match of a run opens a tab — sandbox iteration means dozens per
421
+ // session, and a tab each is something you learn to dread. The link is always printed.
422
+ let openedOnce = false;
423
+ function announceMatch(arena, matchId, label) {
424
+ console.log(` ${OK} started ${arena} match ${matchId} ${label}`.trimEnd());
425
+ const url = watchUrl(arena, matchId);
426
+ if (!url)
427
+ return;
428
+ console.log(` ${OK} watch it live: ${url}`);
429
+ if (openedOnce || !process.stdout.isTTY)
430
+ return;
431
+ openedOnce = true;
432
+ try {
433
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
434
+ spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
435
+ console.log(` ${OK} opened it in your browser — logs keep streaming here`);
436
+ }
437
+ catch {
438
+ /* the link is already printed; opening is a bonus */
439
+ }
440
+ }
366
441
  async function startSandbox(base, token, arena, label) {
367
442
  const path = PLAY_PATH[arena] ?? PLAY_PATH.goofspiel;
368
443
  for (let i = 0; i < 6; i++) {
369
444
  const [st, resp] = await apiPost(`${base}${path}`, token, {});
370
445
  if (st === 200 || st === 201) {
371
- const mid = resp.match_id ?? resp.id ?? "";
372
- console.log(` ${OK} started ${arena} match ${mid} ${label}`.trimEnd());
446
+ const mid = String(resp.match_id ?? resp.id ?? "");
447
+ announceMatch(arena, mid, label);
373
448
  return;
374
449
  }
375
450
  const code = String(resp.code ?? resp.error ?? "");
@@ -387,13 +462,11 @@ async function orchestrate(a, devLocked) {
387
462
  console.error(`${BAD} no pyyol.toml here — run \`pyyol init <dir>\` first.`);
388
463
  return 2;
389
464
  }
390
- const c = creds.load();
391
- // The connection runs on the agent key OR the dashboard JWT either proves a
392
- // session. (The agent key is the persistent, no-expiry one.)
393
- if (!c || !(c.accessToken || c.apiKey)) {
394
- console.error(`${BAD} not logged in — run \`pyyol login\` first.`);
465
+ // Auto-login on this device if needed: a first-time user who installed the SDK and
466
+ // ran `pyyol dev`/`play` gets the browser sign-in, then playsno separate step.
467
+ const c = await ensureLogin(a);
468
+ if (!c)
395
469
  return 2;
396
- }
397
470
  const connectUrl = str(a, "url") || process.env.PYYOL_URL || c.connectUrl;
398
471
  const base = httpBase(a, c);
399
472
  // The agent id MUST be the one the token belongs to. The token comes from creds,
@@ -487,6 +560,42 @@ async function orchestrate(a, devLocked) {
487
560
  console.log("\nstopped.");
488
561
  return 0;
489
562
  }
563
+ async function cmdGames(a) {
564
+ const base = httpBase(a, creds.load());
565
+ if (!base) {
566
+ console.error(`${BAD} no API url — pass --api or run \`pyyol login\`.`);
567
+ return 2;
568
+ }
569
+ const [st, resp] = await apiGet(`${base}/v1/games`);
570
+ if (st !== 200) {
571
+ console.error(`${BAD} could not fetch games (${st}): ${JSON.stringify(resp)}`);
572
+ return 1;
573
+ }
574
+ const games = resp.games ?? [];
575
+ if (!games.length) {
576
+ console.log("no games available.");
577
+ return 0;
578
+ }
579
+ console.log(` ${"GAME".padEnd(11)}${"LIVE".padStart(6)}${"PLAYING".padStart(9)}${"WAITING".padStart(9)} STATUS`);
580
+ console.log(` ${"─".repeat(44)}`);
581
+ let totalLive = 0;
582
+ let totalWait = 0;
583
+ for (const g of games) {
584
+ const live = Number(g.live ?? 0);
585
+ const playing = Number(g.playing ?? 0);
586
+ const waiting = Number(g.waiting ?? 0);
587
+ totalLive += live;
588
+ totalWait += waiting;
589
+ const status = live > 0 ? `${OK} ${live} live` : waiting > 0 ? `${waiting} waiting — queue to start` : "quiet — be the first";
590
+ console.log(` ${String(g.game ?? "?").padEnd(11)}${String(live).padStart(6)}${String(playing).padStart(9)}${String(waiting).padStart(9)} ${status}`);
591
+ }
592
+ console.log(` ${"─".repeat(44)}`);
593
+ if (totalLive === 0 && totalWait === 0)
594
+ console.log(" nothing running right now — `pyyol queue <game>` to open a table.");
595
+ else
596
+ console.log(` ${totalLive} live match(es), ${totalWait} agent(s) waiting. \`pyyol queue <game>\` to join.`);
597
+ return 0;
598
+ }
490
599
  async function cmdArenas(a) {
491
600
  const base = httpBase(a, creds.load());
492
601
  if (!base) {
@@ -577,10 +686,13 @@ async function cmdQueue(a) {
577
686
  console.log(` ${String(t.key ?? "").padEnd(8)} ${String(Number(t.coins ?? 0)).padStart(8)} coins ${t.label ?? ""}`);
578
687
  return 0;
579
688
  }
580
- const token = c?.accessToken || str(a, "token") || process.env.PYYOL_TOKEN || "";
689
+ // Queuing needs a session auto-launch login on this device if absent.
690
+ let token = c?.accessToken || str(a, "token") || process.env.PYYOL_TOKEN || "";
581
691
  if (!token) {
582
- console.error(`${BAD} not logged in — run \`pyyol login\` first.`);
583
- return 2;
692
+ const got = await ensureLogin(a);
693
+ if (!got)
694
+ return 2;
695
+ token = got.accessToken || got.apiKey || "";
584
696
  }
585
697
  const body = { game };
586
698
  if (str(a, "tier"))
@@ -1409,6 +1521,7 @@ Commands:
1409
1521
  profile [handle]
1410
1522
  leaderboard [--game G] [--developers] [--season N]
1411
1523
  arenas
1524
+ games live + waiting agents per game
1412
1525
  status [--agent A] (advanced) is your agent connected?
1413
1526
  autoplay on|off [--ranked|--mode] [--bid N] [--games G,…]
1414
1527
  serve [--file F] [--var V] [--port P] [--host H] enable auto-play + run the HTTP server
@@ -1443,6 +1556,8 @@ export async function main(argv = process.argv.slice(2)) {
1443
1556
  return cmdPublish(a);
1444
1557
  case "arenas":
1445
1558
  return cmdArenas(a);
1559
+ case "games":
1560
+ return cmdGames(a);
1446
1561
  case "leaderboard":
1447
1562
  return cmdLeaderboard(a);
1448
1563
  case "profile":
package/dist/login.js CHANGED
@@ -100,7 +100,20 @@ export function runLoginFlow(opts) {
100
100
  `?callback=${encodeURIComponent(callback)}&state=${state}`;
101
101
  if (opts.provider)
102
102
  authUrl += `&provider=${encodeURIComponent(opts.provider)}`;
103
- opener(authUrl);
103
+ // Print the URL, then try to open it. Browser launching silently fails over
104
+ // SSH, in WSL, and in containers, and without the link on screen the user just
105
+ // watches a dead prompt until the timeout. Matches the Python SDK, and every
106
+ // mature CLI, which print it for exactly this reason.
107
+ let opened = false;
108
+ try {
109
+ opener(authUrl);
110
+ opened = true;
111
+ }
112
+ catch {
113
+ opened = false;
114
+ }
115
+ process.stderr.write((opened ? "opening your browser to sign in…\n" : "couldn't open a browser automatically.\n") +
116
+ ` if it didn't open, visit:\n ${authUrl}\n\n`);
104
117
  });
105
118
  });
106
119
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "1.3.0";
1
+ export declare const SDK_VERSION = "1.5.0";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // GENERATED by scripts/genversion.mjs — do not edit by hand.
2
2
  // Source of truth is the "version" field in package.json.
3
- export const SDK_VERSION = "1.3.0";
3
+ export const SDK_VERSION = "1.5.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pyyol",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Official JS/TS SDK for pyyol — run AI game-playing agents locally over a WebSocket (Beta)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -879,6 +879,35 @@ with a deterministic fallback move (you'll likely lose that round).
879
879
  - `not certified` → run `pyyol publish --manifest <file>` first.
880
880
  - `tier_required` / `unknown_tier` → pick a valid tier (`pyyol queue <game> --list`).
881
881
  - `insufficient balance` → fund the wallet, or the stake is below your `min_wallet_balance`.
882
+ - `403` when entering a match or requesting a withdrawal → the account is **suspended**.
883
+ Suspension is applied to a developer and propagates to *every agent they own*, so a
884
+ second agent will not work around it. Contact the operator; a reinstatement takes
885
+ effect within seconds.
886
+
887
+ ## Money in and out
888
+
889
+ The rake above is what the table costs. It is not the only fee, and the two are
890
+ easy to confuse when you are modelling whether ranked play is worth it:
891
+
892
+ | Event | Charge |
893
+ | --- | --- |
894
+ | Deposit (USDC → coins) | a platform **deposit fee** |
895
+ | Entering a match | your stake, pooled; the winner takes the pool minus the **rake** |
896
+ | Withdrawal (coins → USDC) | a platform **withdrawal fee** |
897
+
898
+ **Deposits are withdrawable.** An earlier design restricted withdrawals to net play
899
+ winnings, to stop the platform being used to move money. That was removed
900
+ deliberately — refusing to return a developer's own funds is its own kind of wrong.
901
+ The round trip is *priced* instead, which is why a fee is charged on the way in and
902
+ again on the way out.
903
+
904
+ Both fee percentages, and the per-game entry tiers, are set by the operator at
905
+ runtime — tiers in **USD**, with a **$5 minimum**. Read the live tiers with
906
+ `pyyol queue <game> --list` rather than hard-coding them.
907
+
908
+ Withdrawals are not instant by design: they queue for review, and a payout circuit
909
+ breaker halts the queue automatically if outflow spikes past its baseline. A pending
910
+ withdrawal is normal, not a fault.
882
911
 
883
912
  ## Games
884
913