pyyol 1.10.1 → 1.12.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 +164 -23
- package/dist/crash.d.ts +57 -0
- package/dist/crash.js +143 -0
- package/dist/models.d.ts +82 -0
- package/dist/models.js +10 -0
- package/dist/movetools.d.ts +11 -0
- package/dist/movetools.js +35 -1
- package/dist/runtime.d.ts +18 -16
- package/dist/runtime.js +116 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/watch.d.ts +47 -0
- package/dist/watch.js +122 -0
- package/package.json +1 -1
- package/rules/games.md +190 -9
- package/rules/llms-full.txt +372 -33
- package/skill/references/games/_engine_reference.md +190 -9
- package/skill/references/templates/_shared.mjs +71 -0
- package/skill/references/templates/goofspiel_agent.mjs +67 -0
- package/skill/references/templates/mafia_agent.mjs +72 -0
- package/skill/references/templates/monopoly_agent.mjs +124 -0
- package/skill/references/templates/_shared.py +0 -62
- package/skill/references/templates/goofspiel_agent.py +0 -66
- package/skill/references/templates/mafia_agent.py +0 -77
- package/skill/references/templates/monopoly_agent.py +0 -82
package/dist/movetools.js
CHANGED
|
@@ -92,9 +92,32 @@ const SCHEMAS = {
|
|
|
92
92
|
},
|
|
93
93
|
property: {
|
|
94
94
|
type: "integer",
|
|
95
|
-
description: "Board index of the property this action concerns, or 0."
|
|
95
|
+
description: "Board index of the property this action concerns, or 0. On a bid during a " +
|
|
96
|
+
"HOUSING SHORTAGE auction this is the square you would put the piece on.",
|
|
96
97
|
},
|
|
97
98
|
amount: { type: "integer", description: "Coin amount this action carries, or 0." },
|
|
99
|
+
// The trade payload. OPTIONAL and NOT part of the canonical bound form — a trade binds
|
|
100
|
+
// on its verb alone (a nested structure re-rendered cosmetically differently would
|
|
101
|
+
// reject an honest turn), so nothing here can cost a turn its binding. Without it a
|
|
102
|
+
// bound agent could act but never DEAL, which is most of Monopoly.
|
|
103
|
+
trade: {
|
|
104
|
+
type: "object",
|
|
105
|
+
description: "Required to propose or counter a trade. Ignored for other actions.",
|
|
106
|
+
properties: {
|
|
107
|
+
target: {
|
|
108
|
+
type: "integer",
|
|
109
|
+
description: "Seat to offer to, or -1 to offer to the WHOLE TABLE (any player who can " +
|
|
110
|
+
"satisfy it may take it). Never 0 for 'everyone' — seat 0 is a real player.",
|
|
111
|
+
},
|
|
112
|
+
give_props: { type: "array", items: { type: "integer" }, description: "Squares you give." },
|
|
113
|
+
give_cash: { type: "integer", description: "Cash you give." },
|
|
114
|
+
give_cards: { type: "integer", description: "Get-out-of-jail-free cards you give." },
|
|
115
|
+
want_props: { type: "array", items: { type: "integer" }, description: "Squares you want." },
|
|
116
|
+
want_cash: { type: "integer", description: "Cash you want." },
|
|
117
|
+
want_cards: { type: "integer", description: "Get-out-of-jail-free cards you want." },
|
|
118
|
+
},
|
|
119
|
+
required: ["target"],
|
|
120
|
+
},
|
|
98
121
|
},
|
|
99
122
|
required: ["kind"],
|
|
100
123
|
},
|
|
@@ -183,6 +206,17 @@ export function moveTool(game, provider = "openai", planRounds) {
|
|
|
183
206
|
* Worth using. Without it a model may answer in prose, and a turn with no tool call is
|
|
184
207
|
* unverified — the agent keeps playing but earns no completion binding.
|
|
185
208
|
*/
|
|
209
|
+
/**
|
|
210
|
+
* The tool_choice value that FORCES the model to answer with the move tool.
|
|
211
|
+
*
|
|
212
|
+
* NOT every model accepts forcing. Some advertise tool support and still reject a
|
|
213
|
+
* required/named tool_choice — observed live: OpenRouter's `openai/gpt-oss-20b:free` answers
|
|
214
|
+
* `inference-enforced tool_choice (required/named) is not supported`, HTTP 400, on every call.
|
|
215
|
+
*
|
|
216
|
+
* If you see that, send `"auto"` instead. Binding reads the RESPONSE, so forcing is only a way
|
|
217
|
+
* to raise the hit rate — a model that emits the tool call on its own binds exactly the same.
|
|
218
|
+
* Forcing is the default because most models take it and it wastes fewer turns.
|
|
219
|
+
*/
|
|
186
220
|
export function moveToolChoice(game, provider = "openai") {
|
|
187
221
|
const name = moveToolName(game);
|
|
188
222
|
if (!name)
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Pyyol Runtime Connector — the local-runtime transport for JS/TS.
|
|
3
|
-
*
|
|
4
|
-
* Your agent runs on your own machine and dials OUT over a single persistent
|
|
5
|
-
* WebSocket to the platform. The platform pushes match lifecycle down that socket
|
|
6
|
-
* and reads decisions back over it, so a laptop behind NAT works with zero
|
|
7
|
-
* networking config — you never host an inbound endpoint. This is the Beta model.
|
|
8
|
-
*
|
|
9
|
-
* The connector owns registration, heartbeats, automatic reconnection with
|
|
10
|
-
* exponential backoff, and request/response correlation; it dispatches frames to
|
|
11
|
-
* the handlers you registered on your `Agent`. No game logic lives here.
|
|
12
|
-
*
|
|
13
|
-
* const agent = new Agent({ supportedGames: ["goofspiel"], name: "OlympAI" });
|
|
14
|
-
* agent.onTurn("goofspiel", (v) => ({ round: v.round, card: Math.max(...v.legal_actions) }));
|
|
15
|
-
* await agent.run({ url: "wss://pyyol.example/v1/agent/connect", agentId: "ag_…", token: "…" });
|
|
16
|
-
*/
|
|
17
1
|
import type { Agent } from "./server.js";
|
|
18
2
|
export declare const PROTOCOL_VERSION = "1.0";
|
|
19
3
|
/** A minimal structural type for a WHATWG WebSocket (Node 22+ global, or `ws`). */
|
|
@@ -75,6 +59,7 @@ export declare class RuntimeConnector {
|
|
|
75
59
|
private registered;
|
|
76
60
|
private refreshAttempts;
|
|
77
61
|
private turnNo;
|
|
62
|
+
private watchOffered;
|
|
78
63
|
private readonly tracer;
|
|
79
64
|
constructor(agent: Agent, opts: RuntimeOptions);
|
|
80
65
|
/** The gateway echoes the newest published version on the registered frame.
|
|
@@ -84,6 +69,23 @@ export declare class RuntimeConnector {
|
|
|
84
69
|
run(): Promise<void>;
|
|
85
70
|
stop(): void;
|
|
86
71
|
/** Emit a live-feed line to the CLI/console, if a sink was provided. */
|
|
72
|
+
/**
|
|
73
|
+
* Offer "browser or terminal?" when a match is found, without blocking anything.
|
|
74
|
+
*
|
|
75
|
+
* `pyyol run` is the path a developer is actually on when they type a command and a
|
|
76
|
+
* staked match appears, and until now that match simply began — no choice, and no way
|
|
77
|
+
* to reach the live table except finding it yourself.
|
|
78
|
+
*
|
|
79
|
+
* Never awaited by the caller. The prompt waits up to ten seconds for a keystroke, and
|
|
80
|
+
* the dispatch loop cannot afford that: the heartbeat that keeps the connection alive
|
|
81
|
+
* and the first turn of the match are both queued behind it. A developer who stepped
|
|
82
|
+
* away for coffee would come back to a forfeited stake.
|
|
83
|
+
*
|
|
84
|
+
* Opt out with PYYOL_WATCH=terminal (or browser to skip straight to opening it).
|
|
85
|
+
* Anything non-interactive is already handled inside askWatch, which prints nothing at
|
|
86
|
+
* all without a TTY on both ends.
|
|
87
|
+
*/
|
|
88
|
+
private offerWatch;
|
|
87
89
|
private feed;
|
|
88
90
|
/** Warn (once) when connecting over cleartext ws:// to a non-local host — the
|
|
89
91
|
* register token is sent in the clear (mirrors the Python connector). */
|
package/dist/runtime.js
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Pyyol Runtime Connector — the local-runtime transport for JS/TS.
|
|
3
|
+
*
|
|
4
|
+
* Your agent runs on your own machine and dials OUT over a single persistent
|
|
5
|
+
* WebSocket to the platform. The platform pushes match lifecycle down that socket
|
|
6
|
+
* and reads decisions back over it, so a laptop behind NAT works with zero
|
|
7
|
+
* networking config — you never host an inbound endpoint. This is the Beta model.
|
|
8
|
+
*
|
|
9
|
+
* The connector owns registration, heartbeats, automatic reconnection with
|
|
10
|
+
* exponential backoff, and request/response correlation; it dispatches frames to
|
|
11
|
+
* the handlers you registered on your `Agent`. No game logic lives here.
|
|
12
|
+
*
|
|
13
|
+
* const agent = new Agent({ supportedGames: ["goofspiel"], name: "OlympAI" });
|
|
14
|
+
* agent.onTurn("goofspiel", (v) => ({ round: v.round, card: Math.max(...v.legal_actions) }));
|
|
15
|
+
* await agent.run({ url: "wss://pyyol.example/v1/agent/connect", agentId: "ag_…", token: "…" });
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
1
18
|
import { SDK_VERSION } from "./server.js";
|
|
2
19
|
import { Tracer, runTurnUsage } from "./telemetry.js";
|
|
20
|
+
import { askWatch, watchUrl, WATCH_BROWSER, WATCH_TERMINAL } from "./watch.js";
|
|
3
21
|
// Frame types — byte-identical to the Go gateway (internal/agentgw/frame.go).
|
|
4
22
|
const HELLO = "hello", REGISTERED = "registered", PONG = "pong";
|
|
5
23
|
const INITIALIZE = "initialize", TURN = "turn", EVENT = "event", GAME_END = "game_end", ERROR = "error";
|
|
@@ -89,6 +107,10 @@ export class RuntimeConnector {
|
|
|
89
107
|
registered = false; // true once this session's register succeeded
|
|
90
108
|
refreshAttempts = 0; // per-connection guard against a refresh loop
|
|
91
109
|
turnNo = 0; // monotonic per-connection turn counter (telemetry attribution fallback)
|
|
110
|
+
// The watch prompt is offered ONCE per connection, not once per match: being asked
|
|
111
|
+
// before every match of a long run is the thing you learn to dread, and a timed-out
|
|
112
|
+
// prompt has left a reader on stdin that would swallow the next one.
|
|
113
|
+
watchOffered = false;
|
|
92
114
|
// Opt-in Pyyol Lens telemetry (no-op unless PYYOL_LENS_ENDPOINT+KEY set).
|
|
93
115
|
// Correlated to the match trace so the agent's model/tool calls render with
|
|
94
116
|
// the platform's authoritative gateway spans.
|
|
@@ -145,6 +167,48 @@ export class RuntimeConnector {
|
|
|
145
167
|
void this.tracer.close();
|
|
146
168
|
}
|
|
147
169
|
/** Emit a live-feed line to the CLI/console, if a sink was provided. */
|
|
170
|
+
/**
|
|
171
|
+
* Offer "browser or terminal?" when a match is found, without blocking anything.
|
|
172
|
+
*
|
|
173
|
+
* `pyyol run` is the path a developer is actually on when they type a command and a
|
|
174
|
+
* staked match appears, and until now that match simply began — no choice, and no way
|
|
175
|
+
* to reach the live table except finding it yourself.
|
|
176
|
+
*
|
|
177
|
+
* Never awaited by the caller. The prompt waits up to ten seconds for a keystroke, and
|
|
178
|
+
* the dispatch loop cannot afford that: the heartbeat that keeps the connection alive
|
|
179
|
+
* and the first turn of the match are both queued behind it. A developer who stepped
|
|
180
|
+
* away for coffee would come back to a forfeited stake.
|
|
181
|
+
*
|
|
182
|
+
* Opt out with PYYOL_WATCH=terminal (or browser to skip straight to opening it).
|
|
183
|
+
* Anything non-interactive is already handled inside askWatch, which prints nothing at
|
|
184
|
+
* all without a TTY on both ends.
|
|
185
|
+
*/
|
|
186
|
+
async offerWatch(payload) {
|
|
187
|
+
if (this.watchOffered)
|
|
188
|
+
return;
|
|
189
|
+
this.watchOffered = true;
|
|
190
|
+
try {
|
|
191
|
+
const matchId = String(payload.match_id ?? "");
|
|
192
|
+
const game = String(payload.game ?? "");
|
|
193
|
+
if (!matchId || !game)
|
|
194
|
+
return;
|
|
195
|
+
const url = watchUrl(game, matchId);
|
|
196
|
+
if (!url)
|
|
197
|
+
return; // no link rather than one onto someone else's match
|
|
198
|
+
const env = String(process.env.PYYOL_WATCH ?? "").trim().toLowerCase();
|
|
199
|
+
const preset = env === WATCH_BROWSER || env === WATCH_TERMINAL ? env : "";
|
|
200
|
+
// Bounded by the countdown: the platform starts play whether or not this was
|
|
201
|
+
// answered, so a longer wait asks about a decision that has already passed.
|
|
202
|
+
const choice = preset || (await askWatch(`${game} · ${matchId}`, url));
|
|
203
|
+
if (choice !== WATCH_BROWSER)
|
|
204
|
+
return;
|
|
205
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
206
|
+
spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Watching is a courtesy; the match is not. Nothing here may reach the run loop.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
148
212
|
feed(kind, detail) {
|
|
149
213
|
this.opts.onFeed?.(kind, detail);
|
|
150
214
|
}
|
|
@@ -384,6 +448,12 @@ export class RuntimeConnector {
|
|
|
384
448
|
case INITIALIZE: {
|
|
385
449
|
const ack = await this.agent.ackInitialize(frame.payload ?? {});
|
|
386
450
|
send({ t: RESPONSE, id: frame.id ?? "", payload: ack });
|
|
451
|
+
// AFTER the ack, and deliberately NOT awaited. On the socket path the platform
|
|
452
|
+
// treats a delivered initialize frame as the acknowledgement, so anything before
|
|
453
|
+
// the ack delays the answer that keeps this seat in the match — and awaiting the
|
|
454
|
+
// prompt here would stall every frame queued behind it, including the heartbeat
|
|
455
|
+
// holding the connection open and the first turn of the match.
|
|
456
|
+
void this.offerWatch(frame.payload ?? {});
|
|
387
457
|
break;
|
|
388
458
|
}
|
|
389
459
|
case EVENT:
|
|
@@ -391,7 +461,12 @@ export class RuntimeConnector {
|
|
|
391
461
|
match_id: frame.match_id ?? "", game: frame.game ?? "",
|
|
392
462
|
seq: frame.seq ?? 0, type: frame.kind ?? "", payload: frame.payload,
|
|
393
463
|
});
|
|
394
|
-
|
|
464
|
+
if (frame.kind === "match_start") {
|
|
465
|
+
this.feed("match_start", countdownLine(frame.payload));
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
this.feed("event", `${frame.kind ?? "event"}${frame.seq !== undefined ? ` seq=${frame.seq}` : ""}`);
|
|
469
|
+
}
|
|
395
470
|
break;
|
|
396
471
|
case GAME_END:
|
|
397
472
|
await this.agent.notifyGameEnd({
|
|
@@ -404,3 +479,43 @@ export class RuntimeConnector {
|
|
|
404
479
|
}
|
|
405
480
|
}
|
|
406
481
|
}
|
|
482
|
+
/** How long until play begins, as a line for the terminal.
|
|
483
|
+
*
|
|
484
|
+
* Mirrors _countdown_line in the Python SDK, and must keep mirroring it: the two SDKs
|
|
485
|
+
* disagreeing about when a match starts is the same class of divergence sdk/conformance
|
|
486
|
+
* exists to prevent.
|
|
487
|
+
*
|
|
488
|
+
* The platform sends an ABSOLUTE `starts_at` and its own `server_now`, never a duration.
|
|
489
|
+
* Both are needed. The instant is what the browser counts to as well, so the two surfaces
|
|
490
|
+
* agree instead of each counting down from ten and drifting apart; `server_now` is what
|
|
491
|
+
* makes this line correct on a machine whose clock is wrong.
|
|
492
|
+
*
|
|
493
|
+
* So the remaining time is measured against the SERVER's clock:
|
|
494
|
+
*
|
|
495
|
+
* remaining = starts_at - server_now
|
|
496
|
+
*
|
|
497
|
+
* Reading Date.now() here would reintroduce exactly the skew that pair exists to remove — a
|
|
498
|
+
* developer whose laptop is two minutes fast would see a countdown that had already ended on
|
|
499
|
+
* a match that has not started.
|
|
500
|
+
*
|
|
501
|
+
* Fails soft to "match starting": a malformed timestamp must never stop an agent playing.
|
|
502
|
+
* The countdown is a courtesy, the match is not.
|
|
503
|
+
*/
|
|
504
|
+
function countdownLine(payload) {
|
|
505
|
+
if (!payload || typeof payload !== "object")
|
|
506
|
+
return "match starting";
|
|
507
|
+
const p = payload;
|
|
508
|
+
const starts = parseTs(p.starts_at);
|
|
509
|
+
const now = parseTs(p.server_now);
|
|
510
|
+
if (starts === null || now === null)
|
|
511
|
+
return "match starting";
|
|
512
|
+
const secs = Math.max(0, Math.round((starts - now) / 1000));
|
|
513
|
+
return `match starts in ${secs}s`;
|
|
514
|
+
}
|
|
515
|
+
/** Parse an RFC3339 timestamp to epoch ms, or null. Go emits a trailing Z, which Date handles. */
|
|
516
|
+
function parseTs(v) {
|
|
517
|
+
if (typeof v !== "string" || !v)
|
|
518
|
+
return null;
|
|
519
|
+
const t = Date.parse(v);
|
|
520
|
+
return Number.isNaN(t) ? null : t;
|
|
521
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "1.
|
|
1
|
+
export declare const SDK_VERSION = "1.12.0";
|
package/dist/version.js
CHANGED
package/dist/watch.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The watch prompt: where a developer chooses to follow a match.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `pyyol/console.py::ask_watch` in the Python SDK. The two must behave
|
|
5
|
+
* identically — a developer who switches language should not discover that one of them
|
|
6
|
+
* hangs their CI and the other does not.
|
|
7
|
+
*
|
|
8
|
+
* A match used to open a browser tab on its own the moment it started. That is the wrong
|
|
9
|
+
* default in both directions: on a remote box or in tmux the tab goes nowhere, and a
|
|
10
|
+
* developer who ran a command in a terminal did not necessarily ask to have their screen
|
|
11
|
+
* taken over. So we ask, once, and remember.
|
|
12
|
+
*/
|
|
13
|
+
export declare const WATCH_BROWSER = "browser";
|
|
14
|
+
export declare const WATCH_TERMINAL = "terminal";
|
|
15
|
+
export type WatchChoice = typeof WATCH_BROWSER | typeof WATCH_TERMINAL;
|
|
16
|
+
export interface AskWatchOpts {
|
|
17
|
+
/** Seconds before the default is taken. Must not outlive the start countdown. */
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
stdin?: NodeJS.ReadableStream & {
|
|
20
|
+
isTTY?: boolean;
|
|
21
|
+
};
|
|
22
|
+
stdout?: NodeJS.WritableStream & {
|
|
23
|
+
isTTY?: boolean;
|
|
24
|
+
};
|
|
25
|
+
color?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare const DEFAULT_DASHBOARD: string;
|
|
28
|
+
/** Browser URL for a specific live match, or "" when it cannot be named exactly. */
|
|
29
|
+
export declare function watchUrl(arena: string, matchId: string, dashboard?: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Ask where to watch this match.
|
|
32
|
+
*
|
|
33
|
+
* Three rules keep this from becoming a liability:
|
|
34
|
+
*
|
|
35
|
+
* **It never blocks a machine.** No TTY on stdin OR stdout means nobody is there to
|
|
36
|
+
* answer — CI, a pipe, a systemd unit — so it resolves to the terminal default without
|
|
37
|
+
* printing a prompt at all. A prompt that can hang a pipeline is worse than no prompt.
|
|
38
|
+
*
|
|
39
|
+
* **It never outlives the countdown.** The read is bounded and defaults on expiry. The
|
|
40
|
+
* match begins whether or not this question was answered, and a prompt still on screen
|
|
41
|
+
* after play started is asking about a decision that is already gone.
|
|
42
|
+
*
|
|
43
|
+
* **It never keeps the process alive.** The readline interface is closed and stdin is
|
|
44
|
+
* paused on every path, including the timeout. A lingering stdin listener would hold the
|
|
45
|
+
* event loop open and a finished `pyyol play` would simply never exit.
|
|
46
|
+
*/
|
|
47
|
+
export declare function askWatch(label: string, url: string, opts?: AskWatchOpts): Promise<WatchChoice>;
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The watch prompt: where a developer chooses to follow a match.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `pyyol/console.py::ask_watch` in the Python SDK. The two must behave
|
|
5
|
+
* identically — a developer who switches language should not discover that one of them
|
|
6
|
+
* hangs their CI and the other does not.
|
|
7
|
+
*
|
|
8
|
+
* A match used to open a browser tab on its own the moment it started. That is the wrong
|
|
9
|
+
* default in both directions: on a remote box or in tmux the tab goes nowhere, and a
|
|
10
|
+
* developer who ran a command in a terminal did not necessarily ask to have their screen
|
|
11
|
+
* taken over. So we ask, once, and remember.
|
|
12
|
+
*/
|
|
13
|
+
import { createInterface } from "node:readline";
|
|
14
|
+
export const WATCH_BROWSER = "browser";
|
|
15
|
+
export const WATCH_TERMINAL = "terminal";
|
|
16
|
+
// Stripping ANSI escapes is how a row's true on-screen width is measured.
|
|
17
|
+
// eslint-disable-next-line no-control-regex -- matching them is the point
|
|
18
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
19
|
+
const visibleLen = (s) => s.replace(ANSI, "").length;
|
|
20
|
+
export const DEFAULT_DASHBOARD = (process.env.PYYOL_DASHBOARD || "").replace(/\/$/, "") || "https://pyyol.com";
|
|
21
|
+
/**
|
|
22
|
+
* Where a running match is watched in the browser, per game. Verified against the
|
|
23
|
+
* client's routes: Goofspiel and Monopoly take ?match= at the top level; Mafia's viewer
|
|
24
|
+
* lives under /arena. A wrong path is worse than no link — it lands the developer on a
|
|
25
|
+
* DIFFERENT live match and everything they see is someone else's game.
|
|
26
|
+
*
|
|
27
|
+
* Lives here rather than in cli.ts because the runtime needs it too, and the runtime
|
|
28
|
+
* cannot import the CLI (the CLI imports the runtime). One copy, so the two paths cannot
|
|
29
|
+
* drift into disagreeing about where a match is watched.
|
|
30
|
+
*/
|
|
31
|
+
const WATCH_ROUTE = {
|
|
32
|
+
goofspiel: "/goofspiel",
|
|
33
|
+
mafia: "/arena/mafia",
|
|
34
|
+
monopoly: "/monopoly",
|
|
35
|
+
};
|
|
36
|
+
/** Browser URL for a specific live match, or "" when it cannot be named exactly. */
|
|
37
|
+
export function watchUrl(arena, matchId, dashboard = DEFAULT_DASHBOARD) {
|
|
38
|
+
const route = WATCH_ROUTE[arena];
|
|
39
|
+
if (!route || !matchId || !dashboard)
|
|
40
|
+
return "";
|
|
41
|
+
// encodeURIComponent (not encodeURI) so a slash is escaped too, and cannot alter the
|
|
42
|
+
// path instead of the query.
|
|
43
|
+
return `${dashboard.replace(/\/$/, "")}${route}?match=${encodeURIComponent(matchId)}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Ask where to watch this match.
|
|
47
|
+
*
|
|
48
|
+
* Three rules keep this from becoming a liability:
|
|
49
|
+
*
|
|
50
|
+
* **It never blocks a machine.** No TTY on stdin OR stdout means nobody is there to
|
|
51
|
+
* answer — CI, a pipe, a systemd unit — so it resolves to the terminal default without
|
|
52
|
+
* printing a prompt at all. A prompt that can hang a pipeline is worse than no prompt.
|
|
53
|
+
*
|
|
54
|
+
* **It never outlives the countdown.** The read is bounded and defaults on expiry. The
|
|
55
|
+
* match begins whether or not this question was answered, and a prompt still on screen
|
|
56
|
+
* after play started is asking about a decision that is already gone.
|
|
57
|
+
*
|
|
58
|
+
* **It never keeps the process alive.** The readline interface is closed and stdin is
|
|
59
|
+
* paused on every path, including the timeout. A lingering stdin listener would hold the
|
|
60
|
+
* event loop open and a finished `pyyol play` would simply never exit.
|
|
61
|
+
*/
|
|
62
|
+
export async function askWatch(label, url, opts = {}) {
|
|
63
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
64
|
+
const stdin = opts.stdin ?? process.stdin;
|
|
65
|
+
const stdout = opts.stdout ?? process.stdout;
|
|
66
|
+
if (!stdin.isTTY || !stdout.isTTY)
|
|
67
|
+
return WATCH_TERMINAL;
|
|
68
|
+
const color = opts.color ?? process.env.NO_COLOR === undefined;
|
|
69
|
+
const c = (text, code) => (color ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
70
|
+
const rows = [
|
|
71
|
+
c(label, "36"),
|
|
72
|
+
"",
|
|
73
|
+
`${c("[b]", "1")} watch the live table in your browser`,
|
|
74
|
+
`${c("[t]", "1")} follow the logs here ${c("· default", "90")}`,
|
|
75
|
+
];
|
|
76
|
+
// Sized to the content, measured on the UNCOLOURED text: an escape sequence takes
|
|
77
|
+
// columns in a string and none on screen, so padding by raw length draws a box that is
|
|
78
|
+
// crooked exactly when colour is on.
|
|
79
|
+
const width = Math.max(...rows.map(visibleLen)) + 2;
|
|
80
|
+
stdout.write("\n" + c("╭─ match found " + "─".repeat(Math.max(0, width - 13)) + "╮", "90") + "\n");
|
|
81
|
+
for (const r of rows) {
|
|
82
|
+
stdout.write(c("│", "90") + " " + r + " ".repeat(width - visibleLen(r)) + c("│", "90") + "\n");
|
|
83
|
+
}
|
|
84
|
+
stdout.write(c("╰" + "─".repeat(width + 1) + "╯", "90") + "\n");
|
|
85
|
+
if (url)
|
|
86
|
+
stdout.write(" " + c(url, "90") + "\n");
|
|
87
|
+
stdout.write(" " + c("›", "36") + " ");
|
|
88
|
+
const answer = await readLine(stdin, timeoutMs);
|
|
89
|
+
if (answer === null) {
|
|
90
|
+
// Say the default was taken. An unexplained newline reads as a dropped keystroke.
|
|
91
|
+
stdout.write("\n " + c(`no answer in ${Math.round(timeoutMs / 1000)}s — following here`, "90") + "\n");
|
|
92
|
+
return WATCH_TERMINAL;
|
|
93
|
+
}
|
|
94
|
+
return answer.trim().toLowerCase().startsWith("b") ? WATCH_BROWSER : WATCH_TERMINAL;
|
|
95
|
+
}
|
|
96
|
+
/** One line from stdin, or null if the timeout wins. Always tears the reader down. */
|
|
97
|
+
function readLine(stdin, timeoutMs) {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
const rl = createInterface({ input: stdin });
|
|
100
|
+
let done = false;
|
|
101
|
+
const finish = (v) => {
|
|
102
|
+
if (done)
|
|
103
|
+
return;
|
|
104
|
+
done = true;
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
rl.close();
|
|
107
|
+
// pause(), or a resumed stdin keeps the event loop alive and the CLI never exits.
|
|
108
|
+
if (typeof stdin.pause === "function")
|
|
109
|
+
stdin.pause();
|
|
110
|
+
resolve(v);
|
|
111
|
+
};
|
|
112
|
+
// Deliberately NOT unref'd. An unref'd timer may never fire, and this timer firing
|
|
113
|
+
// is what RESOLVES the promise — unref turned "defaults after ten seconds" into
|
|
114
|
+
// "hangs forever" whenever nothing else kept the event loop alive. It is short and
|
|
115
|
+
// cleared on every answered path, so it cannot hold the process open for long.
|
|
116
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
117
|
+
rl.once("line", (line) => finish(line));
|
|
118
|
+
// A closed stdin is a non-answer, not a crash mid-match.
|
|
119
|
+
rl.once("close", () => finish(null));
|
|
120
|
+
rl.once("error", () => finish(null));
|
|
121
|
+
});
|
|
122
|
+
}
|