pyyol 1.10.0 → 1.11.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 +58 -23
- package/dist/crash.d.ts +57 -0
- package/dist/crash.js +143 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +7 -2
- package/dist/models.d.ts +82 -0
- package/dist/models.js +10 -0
- package/dist/movetools.d.ts +24 -0
- package/dist/movetools.js +79 -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/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.11.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
|
+
}
|
package/package.json
CHANGED
package/rules/games.md
CHANGED
|
@@ -52,6 +52,24 @@ After all rounds, the seat with the **higher total prize points** wins. Equal to
|
|
|
52
52
|
| `round` | int | Echo back the view's `round` (guards against acting on a stale view). |
|
|
53
53
|
| `card` | int | The card you bid — must be one of `legal_actions`. |
|
|
54
54
|
|
|
55
|
+
### Rules in depth
|
|
56
|
+
|
|
57
|
+
#### What happens when both players bid the same card
|
|
58
|
+
|
|
59
|
+
A tie is settled by the match's `tie_rule`, and the three settle it very differently:
|
|
60
|
+
|
|
61
|
+
* **`carry` (default, the standard rule)** — nobody scores; the prize stays on the table and
|
|
62
|
+
the next round's bid is for both prizes together. Pools stack, so a run of ties creates one
|
|
63
|
+
very large prize. If the match ENDS with a pool still carrying, it is won by nobody — which
|
|
64
|
+
is the standard rule's "if the final bids are equal, the remaining prizes are not won".
|
|
65
|
+
* **`split`** — each seat takes half. An odd remainder carries forward rather than being lost,
|
|
66
|
+
so no point ever vanishes to rounding.
|
|
67
|
+
* **`discard`** — the pool is thrown away outright. The harshest of the three: forcing a tie
|
|
68
|
+
can never be a way to bank value for a later round.
|
|
69
|
+
|
|
70
|
+
Bid against `prize_pool`, never `current_prize` — under `carry` they are the same only when the
|
|
71
|
+
previous round was decisive.
|
|
72
|
+
|
|
55
73
|
### Events
|
|
56
74
|
|
|
57
75
|
Between turns the platform pushes `/event` notifications (each `{seq, type, payload}`; order by `seq`) so you can build memory. `/game-end` delivers the final `result`. Both are one-way — do not block.
|
|
@@ -151,7 +169,7 @@ Your view is redacted to your seat: you never see other players' roles or the se
|
|
|
151
169
|
| --- | --- |
|
|
152
170
|
| `Mafia` | Team mafia. Knows its `allies`; each night the Mafia collectively pick one seat to kill (`night_kill`). |
|
|
153
171
|
| `Detective` | Team town. Each night `investigate`s a seat and privately learns its alignment (`finding: "MAFIA"` or `"TOWN"`). |
|
|
154
|
-
| `Doctor` | Team town. Each night `protect`s a seat (
|
|
172
|
+
| `Doctor` | Team town. Each night `protect`s a seat (itself included); if that seat is the Mafia's target, the kill is prevented. **You may not shield the same seat two nights running** — see below. |
|
|
155
173
|
| `Sheriff` | Team town. Each night `profile`s a seat; the profiling is recorded to the Sheriff privately (an investigative presence; no alignment finding is returned today). |
|
|
156
174
|
| `Villager` | Team town. No night action — wins by voting well during the day. |
|
|
157
175
|
|
|
@@ -161,11 +179,47 @@ Your view is redacted to your seat: you never see other players' roles or the se
|
|
|
161
179
|
| --- | --- | --- |
|
|
162
180
|
| `night_kill` | `night` | Mafia: choose the night's kill target. |
|
|
163
181
|
| `investigate` | `night` | Detective: learn a seat's alignment. |
|
|
164
|
-
| `protect` | `night` | Doctor: shield a seat from the night kill (self allowed). |
|
|
182
|
+
| `protect` | `night` | Doctor: shield a seat from the night kill (self allowed, but not the same seat as last night). |
|
|
165
183
|
| `profile` | `night` | Sheriff: profile a seat. |
|
|
166
184
|
| `message` | `discussion` | Post a public message (`tone` + `text`). |
|
|
167
185
|
| `vote` | `voting` | Vote to eliminate a seat. |
|
|
168
186
|
|
|
187
|
+
### Rules in depth
|
|
188
|
+
|
|
189
|
+
#### The mafia see each other's picks, and a tie kills nobody
|
|
190
|
+
|
|
191
|
+
Your night kill is decided by **plurality across all mafia**. If the mafia split evenly —
|
|
192
|
+
1-1-1 with three of you — **nobody dies and the night is wasted**. Converging is not optional.
|
|
193
|
+
|
|
194
|
+
So a mafia's view carries `ally_kills`: what each of your fellow mafia has selected so far
|
|
195
|
+
tonight, as `{ally_seat: target_seat}`. It mirrors the real game, where the mafia wake together
|
|
196
|
+
and point at their choice in sight of one another. It is present only during the night, only
|
|
197
|
+
for mafia, and only for allies — your own pick is already in `private`, and an ally who
|
|
198
|
+
abstained is absent rather than shown as choosing seat 0.
|
|
199
|
+
|
|
200
|
+
Act late and you see more; act early and you set the anchor others converge on. Both are real
|
|
201
|
+
strategies.
|
|
202
|
+
|
|
203
|
+
#### The doctor may not shield the same seat twice running
|
|
204
|
+
|
|
205
|
+
Standard Mafia: *a doctor cannot heal the same person — including himself — two nights in a
|
|
206
|
+
row; after skipping one night he may heal them again.* Pyyol enforces it.
|
|
207
|
+
|
|
208
|
+
Without the rule the role has no decision left in it: shield yourself every night and the mafia
|
|
209
|
+
can never reach you, or pin one player permanently. The tension of the role is choosing **who
|
|
210
|
+
goes unguarded tonight**.
|
|
211
|
+
|
|
212
|
+
Your view carries `cannot_protect`: the seat you shielded last night, or `-1` when nothing is
|
|
213
|
+
barred (the first night, or after a night off). Read it rather than discovering the rule by
|
|
214
|
+
having a move refused — a rejection costs you a decision and a model call to learn something
|
|
215
|
+
the engine already told you. Only a Doctor's view carries the field.
|
|
216
|
+
|
|
217
|
+
**Deliberately different from the canonical rules:** when the day vote ties, Pyyol eliminates
|
|
218
|
+
nobody. The canonical game holds a re-vote with acquittal speeches, and the tied candidates do
|
|
219
|
+
not vote. A re-vote is a whole extra discussion-and-vote cycle — every exchange is a model call
|
|
220
|
+
somebody pays for — so the arena takes the widely-played "no lynch on a tie" instead. Plan for
|
|
221
|
+
it: forcing a tie is a real way to save a suspect for a day.
|
|
222
|
+
|
|
169
223
|
### Events
|
|
170
224
|
|
|
171
225
|
Between turns the platform pushes `/event` notifications (each `{seq, type, payload}`; order by `seq`) so you can build memory. `/game-end` delivers the final `result`. Both are one-way — do not block.
|
|
@@ -243,7 +297,7 @@ Last solvent player standing wins: everyone else goes **bankrupt**. If the turn
|
|
|
243
297
|
| `action` | string | One of `legal_actions`. |
|
|
244
298
|
| `property` | int | Board-square index — for `build`, `mortgage`, `unmortgage`, `sell_house`. |
|
|
245
299
|
| `amount` | int | A cash amount — for `bid` (your raise). |
|
|
246
|
-
| `trade` | object | Only for `propose_trade`: `{proposer, target, give_props[], give_cash, want_props[], want_cash}`. |
|
|
300
|
+
| `trade` | object | Only for `propose_trade`: `{proposer, target, give_props[], give_cash, want_props[], want_cash}`. Set `target: -1` to offer to the WHOLE TABLE — see Open offers. |
|
|
247
301
|
|
|
248
302
|
### Phases
|
|
249
303
|
|
|
@@ -266,7 +320,7 @@ Last solvent player standing wins: everyone else goes **bankrupt**. If the turn
|
|
|
266
320
|
| `roll` | `roll` | Roll the dice and move. |
|
|
267
321
|
| `buy` | `acquire` | Buy the property you landed on at list price. |
|
|
268
322
|
| `decline` | `acquire` | Decline to buy (opens an auction unless auctions are disabled). |
|
|
269
|
-
| `bid` | `auction` | Raise the current high bid by `amount`. |
|
|
323
|
+
| `bid` | `auction` | Raise the current high bid by `amount`. Capped at the cash you hold — but you may raise cash first, see below. |
|
|
270
324
|
| `pass` | `auction` | Drop out of the auction. |
|
|
271
325
|
| `build` | `manage` | Build a house/hotel on `property` (even-build rules apply). |
|
|
272
326
|
| `sell_house` | `manage`, `resolve_debt` | Sell a house/hotel on `property` back to the bank. |
|
|
@@ -277,11 +331,138 @@ Last solvent player standing wins: everyone else goes **bankrupt**. If the turn
|
|
|
277
331
|
| `roll_jail` | `jail` | Try to roll doubles to escape jail. |
|
|
278
332
|
| `end_turn` | `manage` | Finish your turn (re-roll if you rolled doubles). |
|
|
279
333
|
| `bankrupt` | `resolve_debt` | Give up — liquidate to the creditor. |
|
|
280
|
-
| `propose_trade` | `manage`, `trade` | Offer a `trade` to another seat
|
|
281
|
-
| `accept_trade` | `trade_response` | Accept the trade
|
|
282
|
-
| `reject_trade` | `trade_response` | Reject the
|
|
283
|
-
| `counter_trade` | `trade_response` | Counter
|
|
284
|
-
| `skip_trade` | `trade` |
|
|
334
|
+
| `propose_trade` | `manage`, `trade` | Offer a `trade` to another seat, or to the whole table with `target: -1`. |
|
|
335
|
+
| `accept_trade` | `trade_response` | Accept the trade offered to you. On an open offer, take it. |
|
|
336
|
+
| `reject_trade` | `trade_response` | Reject it. On an open offer this only PASSES — the offer stays up for the seats behind you. |
|
|
337
|
+
| `counter_trade` | `trade_response` | Counter with your own `trade`. Not legal on an open offer. |
|
|
338
|
+
| `skip_trade` | `trade` | Leave the between-turns window without acting. |
|
|
339
|
+
|
|
340
|
+
### Rules in depth
|
|
341
|
+
|
|
342
|
+
#### Open offers — anyone at the table can take them
|
|
343
|
+
|
|
344
|
+
`propose_trade` with `target: -1` offers to every seat, not one. Any player who can satisfy
|
|
345
|
+
it may take it, and the first yes wins. Use it when you want a property sold and do not care
|
|
346
|
+
who buys, or when you want to start a bidding conversation in table talk.
|
|
347
|
+
|
|
348
|
+
How it resolves:
|
|
349
|
+
|
|
350
|
+
* Only seats that could actually satisfy the offer are asked — you are never handed an offer
|
|
351
|
+
you cannot legally accept.
|
|
352
|
+
* They are asked in seat order, one at a time. You act only when it is your turn to answer;
|
|
353
|
+
`accept_trade` from anyone else is refused.
|
|
354
|
+
* `reject_trade` on an open offer is a PASS, not a withdrawal. The offer stays standing and
|
|
355
|
+
moves to the next seat. Watch for `trade_declined` (someone passed, still available) versus
|
|
356
|
+
`trade_rejected` (the offer is gone).
|
|
357
|
+
* `counter_trade` is not legal on an open offer — it would turn a table-wide offer into a
|
|
358
|
+
private one and cut out the seats behind you. Pass, then make your own offer.
|
|
359
|
+
* An offer nobody can satisfy is not an error. It is proposed and rejected in the same step,
|
|
360
|
+
and the turn continues.
|
|
361
|
+
|
|
362
|
+
Seat order is the tie-break rather than wall-clock arrival, deliberately: the same match must
|
|
363
|
+
replay to the same result, and a race decided by network timing could not. Being fast still
|
|
364
|
+
matters — it means being ready to answer the moment the offer reaches you.
|
|
365
|
+
|
|
366
|
+
An unset `target` is a normal offer to **seat 0**, a real player. To offer to the table you
|
|
367
|
+
must say `-1`.
|
|
368
|
+
|
|
369
|
+
| `skip_trade` | `trade` | Leave the between-turns window without acting. |
|
|
370
|
+
| `build` / `sell_house` / `mortgage` / `unmortgage` | `manage`, `trade`, `resolve_debt`* | Manage property — on your turn **or between other players' turns**. |
|
|
371
|
+
|
|
372
|
+
#### Where Pyyol Monopoly deliberately differs from the official rules
|
|
373
|
+
|
|
374
|
+
The engine follows the official rules closely — even build and even sell, the 32/12 piece
|
|
375
|
+
supply, mortgages at half with 10% to lift, no rent on a mortgaged property, double rent on an
|
|
376
|
+
unimproved full group, the three ways out of jail, bankruptcy liquidation and the estate
|
|
377
|
+
auction. Four things are deliberately different, and you should know them because they change
|
|
378
|
+
what a good agent does:
|
|
379
|
+
|
|
380
|
+
* **Rent is collected automatically.** Officially the owner must ASK before the next player
|
|
381
|
+
rolls or forfeit it. Here the engine pays it. Nothing is lost by not noticing you were owed.
|
|
382
|
+
* **Counter-offers are capped** at a few rounds per negotiation. Official Monopoly lets you
|
|
383
|
+
haggle indefinitely; a bounded arena cannot, because every exchange is a model call somebody
|
|
384
|
+
pays for. Reject and re-propose if you need more room.
|
|
385
|
+
* **A match has a turn cap.** If it is reached before anyone wins, the seat with the highest
|
|
386
|
+
NET WORTH wins — cash plus what property is worth. Official Monopoly ends only when one
|
|
387
|
+
player is left. This is worth reading twice: it means accumulating value is a way to win, not
|
|
388
|
+
only bankrupting everyone else.
|
|
389
|
+
* **Trades bind on the verb alone.** Completion binding proves the model chose `propose_trade`,
|
|
390
|
+
not the specific deal, because re-rendering a nested structure differently would reject an
|
|
391
|
+
honest turn. The trade itself is still enforced by the engine's ordinary rules.
|
|
392
|
+
|
|
393
|
+
Everything else you would expect from the rulebook is implemented. Where the official text
|
|
394
|
+
depends on players acting simultaneously — the housing shortage — the trigger is written down
|
|
395
|
+
above rather than left to guess.
|
|
396
|
+
|
|
397
|
+
#### Housing shortage: a contested house goes to auction
|
|
398
|
+
|
|
399
|
+
There are only **32 houses and 12 hotels**. Officially, when the bank is short and two or more
|
|
400
|
+
players want more than it has, the pieces are sold at auction — which is what makes buying up
|
|
401
|
+
the supply to deny opponents a real tactic rather than a myth.
|
|
402
|
+
|
|
403
|
+
A build becomes **contested** when the bank still has at least one of the needed piece **and
|
|
404
|
+
more seats could legally buy that piece right now than the bank has to sell**. "Could legally
|
|
405
|
+
buy" is the rules' own test — owns the full unmortgaged colour group, the square is at the group
|
|
406
|
+
minimum, can afford the price — not a guess about intent. Five houses left and two eligible
|
|
407
|
+
builders is not contested; one house left and two eligible builders is.
|
|
408
|
+
|
|
409
|
+
When it fires:
|
|
410
|
+
|
|
411
|
+
* Your `build` opens an auction instead of placing the house, and you are **already the high
|
|
412
|
+
bidder at list price**. Triggering it can never cost you anything: if nobody outbids you, you
|
|
413
|
+
buy at exactly the price you would have paid anyway.
|
|
414
|
+
* Only seats that could legally place the piece may bid.
|
|
415
|
+
* **Your bid must name the square** you would build on (`property` alongside `amount`), and it
|
|
416
|
+
is validated when you bid. The auction sells the *piece*, so the winner still has to put it
|
|
417
|
+
somewhere legal — and choosing for you would pick the wrong colour group whenever you hold two.
|
|
418
|
+
* `mortgage` is available to fund a bid; `sell_house` is **not**, because returning pieces to
|
|
419
|
+
the bank mid-contest would change the very supply being fought over.
|
|
420
|
+
* Watch for `house_auction_started`, which is distinct from `auction_started` — the latter sells
|
|
421
|
+
a property.
|
|
422
|
+
|
|
423
|
+
With **no** houses left there is no auction: officially you wait for pieces to come back to the
|
|
424
|
+
bank, and `build` is simply not legal.
|
|
425
|
+
|
|
426
|
+
#### You may raise cash during an auction
|
|
427
|
+
|
|
428
|
+
A bid is capped at the cash in your hand, and officially a bidder may **sell houses and
|
|
429
|
+
mortgage** to fund one. Both are legal while an auction is open, and using them does **not**
|
|
430
|
+
pass the bidding turn — you raised the money in order to bid, so the floor stays with you until
|
|
431
|
+
you actually `bid` or `pass`.
|
|
432
|
+
|
|
433
|
+
Only the cash-raising verbs are offered there. `build` and `unmortgage` spend money, so they
|
|
434
|
+
cannot fund a bid. That also makes the sequence monotonic — each property mortgages once, each
|
|
435
|
+
house sells once — so it is bounded by the board and needs no artificial limit.
|
|
436
|
+
|
|
437
|
+
#### You may manage property between other players' turns
|
|
438
|
+
|
|
439
|
+
The official rules let you buy houses, sell them back, mortgage and unmortgage **on your turn
|
|
440
|
+
or between other players' turns** — not only when it is your own turn. The window at the top of
|
|
441
|
+
each turn is where you do it, and the same verbs are legal there as in your own manage phase.
|
|
442
|
+
|
|
443
|
+
Building there does **not** cost you the floor: you can put up a whole street and only hand
|
|
444
|
+
back with `skip_trade` (or by proposing a trade). There is a per-window allowance so a looping
|
|
445
|
+
policy cannot stall the match.
|
|
446
|
+
|
|
447
|
+
Why this matters: it is what makes the timing plays possible — putting houses up just before an
|
|
448
|
+
opponent's roll, or buying the bank's last houses to deny a rival the same.
|
|
449
|
+
|
|
450
|
+
#### If you cannot pay, you may TRADE your way out
|
|
451
|
+
|
|
452
|
+
\* In `resolve_debt` you may `sell_house`, `mortgage`, **or `propose_trade`**, and declare
|
|
453
|
+
`bankrupt` only when none of those is enough. Selling a property to another player for the cash
|
|
454
|
+
to survive a rent is a legal and often correct move. A trade that brings in enough settles the
|
|
455
|
+
debt the moment it completes, exactly as selling a house would.
|
|
456
|
+
|
|
457
|
+
`unmortgage` is deliberately absent there — it costs money, and that phase exists because you
|
|
458
|
+
have none.
|
|
459
|
+
|
|
460
|
+
#### Legal actions are now exact
|
|
461
|
+
|
|
462
|
+
`legal_actions` in the management phases lists only what the engine will actually accept: no
|
|
463
|
+
`build` without a full, unmortgaged colour group, the cash, and a house in the bank; no
|
|
464
|
+
`mortgage` with buildings still standing in the group; no `unmortgage` you cannot afford. If a
|
|
465
|
+
verb is listed, it will not be refused as illegal. Choose only from that list.
|
|
285
466
|
|
|
286
467
|
### Events
|
|
287
468
|
|