@trolleroof/tui 0.2.5 → 0.2.6
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/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +2 -2
- package/README.md +29 -7
- package/dist/index.js +284 -18
- package/overlay/dist/app.js +74 -0
- package/overlay/dist/index.html +1589 -0
- package/overlay-release.json +2 -2
- package/package.json +27 -4
- package/scripts/install-adapters.sh +6 -6
- package/scripts/install-adapters.test.sh +2 -2
- package/scripts/materialize-agents-plugin.sh +3 -3
- package/scripts/npm-pack.test.ts +51 -0
- package/scripts/overlay.ts +1 -1
- package/src/analytics/events.test.ts +77 -0
- package/src/analytics/events.ts +85 -0
- package/src/analytics/posthog.test.ts +85 -0
- package/src/analytics/posthog.ts +88 -0
- package/src/auth-pages.ts +108 -0
- package/src/auth.test.ts +255 -0
- package/src/auth.ts +511 -0
- package/src/core/game.ts +35 -0
- package/src/core/key.ts +12 -0
- package/src/core/rng.ts +41 -0
- package/src/data/bc-export.test.ts +153 -0
- package/src/data/bc-export.ts +125 -0
- package/src/data/trajectory.ts +103 -0
- package/src/games/connect4/connect4.ts +81 -0
- package/src/games/connect4/engine.test.ts +77 -0
- package/src/games/connect4/engine.ts +89 -0
- package/src/games/g2048/engine.test.ts +138 -0
- package/src/games/g2048/engine.ts +109 -0
- package/src/games/g2048/game2048.ts +104 -0
- package/src/games/minesweeper/engine.test.ts +83 -0
- package/src/games/minesweeper/engine.ts +112 -0
- package/src/games/minesweeper/minesweeper.ts +107 -0
- package/src/games/registry.test.ts +16 -0
- package/src/games/registry.ts +130 -0
- package/src/games/snake/engine.test.ts +89 -0
- package/src/games/snake/engine.ts +95 -0
- package/src/games/snake/snake.ts +77 -0
- package/src/games/sokoban/engine.test.ts +49 -0
- package/src/games/sokoban/engine.ts +152 -0
- package/src/games/sokoban/sokoban.ts +103 -0
- package/src/games/sudoku/engine.test.ts +88 -0
- package/src/games/sudoku/engine.ts +99 -0
- package/src/games/sudoku/sudoku.ts +100 -0
- package/src/games/tetris/engine.test.ts +378 -0
- package/src/games/tetris/engine.ts +236 -0
- package/src/games/tetris/tetris.ts +220 -0
- package/src/index.ts +82 -0
- package/src/recording/chunks.ts +93 -0
- package/src/recording/config.test.ts +31 -0
- package/src/recording/config.ts +37 -0
- package/src/recording/contracts.test.ts +71 -0
- package/src/recording/gameplay.test.ts +70 -0
- package/src/recording/gameplay.ts +18 -0
- package/src/recording/http-transport.test.ts +197 -0
- package/src/recording/identity.test.ts +26 -0
- package/src/recording/identity.ts +38 -0
- package/src/recording/ids.ts +27 -0
- package/src/recording/permissions.ts +10 -0
- package/src/recording/recorder.test.ts +388 -0
- package/src/recording/recorder.ts +464 -0
- package/src/recording/store.test.ts +231 -0
- package/src/recording/store.ts +305 -0
- package/src/recording/terminal-grid.test.ts +88 -0
- package/src/recording/terminal-grid.ts +248 -0
- package/src/recording/trace-recorder.test.ts +287 -0
- package/src/recording/trace-recorder.ts +560 -0
- package/src/recording/transport.ts +170 -0
- package/src/recording/types.test.ts +18 -0
- package/src/recording/types.ts +326 -0
- package/src/recording/uploader.test.ts +193 -0
- package/src/recording/uploader.ts +103 -0
- package/src/results/completion.ts +100 -0
- package/src/results/http-result-transport.test.ts +75 -0
- package/src/results/local-score.test.ts +32 -0
- package/src/results/local-score.ts +39 -0
- package/src/results/results.test.ts +383 -0
- package/src/results/store.ts +149 -0
- package/src/results/transport.ts +79 -0
- package/src/results/types.ts +20 -0
- package/src/results/uploader.ts +84 -0
- package/src/sync/config.test.ts +108 -0
- package/src/sync/config.ts +115 -0
- package/src/sync/coordinator.test.ts +423 -0
- package/src/sync/coordinator.ts +277 -0
- package/src/sync/game-start-queue.test.ts +93 -0
- package/src/sync/game-start-queue.ts +45 -0
- package/src/sync/identity-client.test.ts +34 -0
- package/src/sync/identity-client.ts +43 -0
package/src/auth.ts
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { AuthMethod } from "./analytics/events.js";
|
|
7
|
+
import { escapeHtml, signedInPage, signInFailedPage } from "./auth-pages.js";
|
|
8
|
+
import { clearBackendSession, saveBackendSession } from "./sync/config.js";
|
|
9
|
+
|
|
10
|
+
const oauthCallbackPort = 3210;
|
|
11
|
+
const oauthCallbackHost = "127.0.0.1";
|
|
12
|
+
|
|
13
|
+
function oauthCallbackUrlFor(port: number): string {
|
|
14
|
+
return `http://${oauthCallbackHost}:${port}/auth/callback`;
|
|
15
|
+
}
|
|
16
|
+
const defaultAuthBaseUrl = "https://auth.runretroarcade.com";
|
|
17
|
+
const defaultLoginUrl = "https://runretroarcade.com/login";
|
|
18
|
+
const oauthProviders = ["google", "github"] as const;
|
|
19
|
+
type OAuthProvider = (typeof oauthProviders)[number];
|
|
20
|
+
|
|
21
|
+
interface AuthUser {
|
|
22
|
+
id: string;
|
|
23
|
+
email: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface StoredSession {
|
|
27
|
+
sessionToken: string;
|
|
28
|
+
user: AuthUser;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface AuthJson {
|
|
32
|
+
user?: AuthUser;
|
|
33
|
+
session?: { token?: string } | null;
|
|
34
|
+
token?: string;
|
|
35
|
+
url?: string;
|
|
36
|
+
redirect?: boolean;
|
|
37
|
+
message?: string;
|
|
38
|
+
error?: string | { message?: string };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AuthSuccess {
|
|
42
|
+
kind: "signed_up" | "signed_in";
|
|
43
|
+
method: AuthMethod;
|
|
44
|
+
userId?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function configuredAuthBaseUrl(): string {
|
|
48
|
+
return (process.env.GAMEPIGEON_AUTH_URL ?? process.env.CLOUDFLARE_AUTH_URL ?? defaultAuthBaseUrl)
|
|
49
|
+
.replace(/\/$/, "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function configuredLoginUrl(): string {
|
|
53
|
+
const url = new URL(process.env.GAMEPIGEON_LOGIN_URL ?? defaultLoginUrl);
|
|
54
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
55
|
+
throw new Error("GAMEPIGEON_LOGIN_URL must use HTTP or HTTPS.");
|
|
56
|
+
}
|
|
57
|
+
return url.toString();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sessionFile(): string {
|
|
61
|
+
return join(process.env.HOME ?? homedir(), ".tui-gamepigeon", "auth.json");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readSession(): StoredSession | null {
|
|
65
|
+
try {
|
|
66
|
+
const raw = JSON.parse(readFileSync(sessionFile(), "utf8")) as Partial<StoredSession> & {
|
|
67
|
+
refreshToken?: string;
|
|
68
|
+
};
|
|
69
|
+
if (raw.sessionToken && raw.user?.id && raw.user.email) {
|
|
70
|
+
return { sessionToken: raw.sessionToken, user: { id: raw.user.id, email: raw.user.email } };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function saveSession(sessionToken: string, user: AuthUser): void {
|
|
79
|
+
if (!sessionToken) throw new Error("Auth did not return a session token.");
|
|
80
|
+
if (!user.id || !user.email) throw new Error("Auth did not return a user.");
|
|
81
|
+
const file = sessionFile();
|
|
82
|
+
mkdirSync(join(process.env.HOME ?? homedir(), ".tui-gamepigeon"), { recursive: true });
|
|
83
|
+
writeFileSync(file, JSON.stringify({ sessionToken, user: { id: user.id, email: user.email } }));
|
|
84
|
+
chmodSync(file, 0o600);
|
|
85
|
+
saveBackendSession(sessionToken, process.env.GAMEPIGEON_BACKEND_URL);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function requireAuthBaseUrl(): string {
|
|
89
|
+
const authBaseUrl = configuredAuthBaseUrl();
|
|
90
|
+
if (!authBaseUrl) {
|
|
91
|
+
throw new Error("Set GAMEPIGEON_AUTH_URL (Cloudflare Worker URL) before using auth.");
|
|
92
|
+
}
|
|
93
|
+
return authBaseUrl;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function authApi(path: string): string {
|
|
97
|
+
return `${requireAuthBaseUrl()}/api/auth${path}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function errorMessage(payload: AuthJson | null, fallback: string): string {
|
|
101
|
+
if (!payload) return fallback;
|
|
102
|
+
if (typeof payload.error === "string" && payload.error) return payload.error;
|
|
103
|
+
if (payload.error && typeof payload.error === "object" && payload.error.message) {
|
|
104
|
+
return payload.error.message;
|
|
105
|
+
}
|
|
106
|
+
if (payload.message) return payload.message;
|
|
107
|
+
return fallback;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function authRequest(
|
|
111
|
+
path: string,
|
|
112
|
+
init: RequestInit = {},
|
|
113
|
+
sessionToken?: string,
|
|
114
|
+
): Promise<{ response: Response; json: AuthJson | null; sessionToken: string | null }> {
|
|
115
|
+
const headers = new Headers(init.headers);
|
|
116
|
+
if (!headers.has("content-type") && init.body) headers.set("content-type", "application/json");
|
|
117
|
+
if (sessionToken) headers.set("authorization", `Bearer ${sessionToken}`);
|
|
118
|
+
|
|
119
|
+
const response = await fetch(authApi(path), { ...init, headers });
|
|
120
|
+
const text = await response.text();
|
|
121
|
+
let json: AuthJson | null = null;
|
|
122
|
+
if (text) {
|
|
123
|
+
try {
|
|
124
|
+
json = JSON.parse(text) as AuthJson;
|
|
125
|
+
} catch {
|
|
126
|
+
json = null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
response,
|
|
131
|
+
json,
|
|
132
|
+
sessionToken: response.headers.get("set-auth-token"),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isOAuthProvider(value: string | undefined): value is OAuthProvider {
|
|
137
|
+
return oauthProviders.includes(value as OAuthProvider);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function openBrowser(url: string): void {
|
|
141
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
142
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
143
|
+
const browser = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
144
|
+
browser.on("error", () => undefined);
|
|
145
|
+
browser.unref();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reads one query parameter, tolerating both `&` and `?` separators: some
|
|
150
|
+
* hosted redirects append `?token` onto a callback that already carried
|
|
151
|
+
* `?state`, yielding `state=..?token=..`, which `URLSearchParams` would fold
|
|
152
|
+
* into the state value. Mirrors `query_param` in `tauri/src/main.rs`.
|
|
153
|
+
*/
|
|
154
|
+
function callbackParam(query: string, key: string): string | null {
|
|
155
|
+
for (const pair of query.split(/[&?]/)) {
|
|
156
|
+
const separator = pair.indexOf("=");
|
|
157
|
+
if (separator === -1 || pair.slice(0, separator) !== key) continue;
|
|
158
|
+
const value = pair.slice(separator + 1).replace(/\+/g, " ");
|
|
159
|
+
try {
|
|
160
|
+
return decodeURIComponent(value);
|
|
161
|
+
} catch {
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Turns a failed handoff (`?error=...`) into a sentence worth showing. Capped
|
|
170
|
+
* because it lands in both the browser page and the CLI's error output.
|
|
171
|
+
*/
|
|
172
|
+
function callbackFailure(query: string): string | null {
|
|
173
|
+
const error = callbackParam(query, "error");
|
|
174
|
+
if (!error) return null;
|
|
175
|
+
if (error === "access_denied") return "Sign-in was cancelled before it finished.";
|
|
176
|
+
const description = callbackParam(query, "error_description");
|
|
177
|
+
return (description || `The login page reported an error: ${error}`).slice(0, 200);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function receiveOAuthCallback(port = oauthCallbackPort) {
|
|
181
|
+
let server: ReturnType<typeof createServer> | undefined;
|
|
182
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
183
|
+
let settled = false;
|
|
184
|
+
let resolveReady!: () => void;
|
|
185
|
+
let rejectReady!: (error: Error) => void;
|
|
186
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
187
|
+
resolveReady = resolve;
|
|
188
|
+
rejectReady = reject;
|
|
189
|
+
});
|
|
190
|
+
const close = () => {
|
|
191
|
+
if (timeout) clearTimeout(timeout);
|
|
192
|
+
server?.close();
|
|
193
|
+
};
|
|
194
|
+
const finish = (onSettle: () => void) => {
|
|
195
|
+
if (settled) return;
|
|
196
|
+
settled = true;
|
|
197
|
+
close();
|
|
198
|
+
onSettle();
|
|
199
|
+
};
|
|
200
|
+
const boundPort = { value: port };
|
|
201
|
+
const token = new Promise<string>((resolve, reject) => {
|
|
202
|
+
server = createServer((request, response) => {
|
|
203
|
+
const target = request.url ?? "/";
|
|
204
|
+
const separator = target.indexOf("?");
|
|
205
|
+
const path = separator === -1 ? target : target.slice(0, separator);
|
|
206
|
+
const query = separator === -1 ? "" : target.slice(separator + 1);
|
|
207
|
+
if (path === "/auth/ping") {
|
|
208
|
+
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("ok");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (path !== "/auth/callback") {
|
|
212
|
+
response.writeHead(404).end();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// The page carries a session token in its URL — keep it out of caches.
|
|
216
|
+
const page = (status: number, body: string) =>
|
|
217
|
+
response
|
|
218
|
+
.writeHead(status, {
|
|
219
|
+
"content-type": "text/html; charset=utf-8",
|
|
220
|
+
"cache-control": "no-store",
|
|
221
|
+
"referrer-policy": "no-referrer",
|
|
222
|
+
})
|
|
223
|
+
.end(body);
|
|
224
|
+
|
|
225
|
+
const failed = (message: string) => {
|
|
226
|
+
page(400, signInFailedPage(escapeHtml(message)));
|
|
227
|
+
finish(() => reject(new Error(message)));
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const failure = callbackFailure(query);
|
|
231
|
+
if (failure) {
|
|
232
|
+
failed(failure);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const sessionToken = callbackParam(query, "token");
|
|
236
|
+
if (!sessionToken) {
|
|
237
|
+
failed("The login page came back without a session token.");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
page(200, signedInPage());
|
|
241
|
+
finish(() => resolve(sessionToken));
|
|
242
|
+
});
|
|
243
|
+
let triedFallbackPort = false;
|
|
244
|
+
const listen = (listenPort: number) => {
|
|
245
|
+
server?.listen(listenPort, oauthCallbackHost, () => {
|
|
246
|
+
const address = server?.address();
|
|
247
|
+
if (address && typeof address === "object") boundPort.value = address.port;
|
|
248
|
+
resolveReady();
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
server.on("error", (error: NodeJS.ErrnoException) => {
|
|
252
|
+
// The preferred port may be held by a stale listener (e.g. an orphaned
|
|
253
|
+
// sign-in from a prior run). Fall back to an OS-assigned port rather
|
|
254
|
+
// than failing outright — the backend only checks the callback
|
|
255
|
+
// hostname, not the port, so any localhost port is accepted.
|
|
256
|
+
if (error.code === "EADDRINUSE" && port !== 0 && !triedFallbackPort) {
|
|
257
|
+
triedFallbackPort = true;
|
|
258
|
+
listen(0);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
rejectReady(error);
|
|
262
|
+
finish(() => reject(error));
|
|
263
|
+
});
|
|
264
|
+
listen(port);
|
|
265
|
+
timeout = setTimeout(
|
|
266
|
+
() => finish(() => reject(new Error("OAuth sign-in timed out after 5 minutes."))),
|
|
267
|
+
5 * 60_000,
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
void token.catch(() => undefined);
|
|
271
|
+
return {
|
|
272
|
+
token,
|
|
273
|
+
ready,
|
|
274
|
+
close,
|
|
275
|
+
get port() {
|
|
276
|
+
return boundPort.value;
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Starts the localhost OAuth callback listener. Exported for tests. */
|
|
282
|
+
function startOAuthCallbackServer(port = oauthCallbackPort) {
|
|
283
|
+
return receiveOAuthCallback(port);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function ask(label: string, secret = false): Promise<string> {
|
|
287
|
+
process.stdout.write(label);
|
|
288
|
+
if (!secret || !process.stdin.isTTY) return (prompt() ?? "").trim();
|
|
289
|
+
process.stdin.setRawMode(true);
|
|
290
|
+
process.stdin.resume();
|
|
291
|
+
return new Promise((resolve) => {
|
|
292
|
+
let value = "";
|
|
293
|
+
const onData = (chunk: Buffer) => {
|
|
294
|
+
for (const byte of chunk) {
|
|
295
|
+
if (byte === 3) process.exit(130);
|
|
296
|
+
if (byte === 10 || byte === 13) {
|
|
297
|
+
process.stdin.off("data", onData);
|
|
298
|
+
process.stdin.setRawMode(false);
|
|
299
|
+
process.stdin.pause();
|
|
300
|
+
process.stdout.write("\n");
|
|
301
|
+
resolve(value);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if ((byte === 8 || byte === 127) && value) value = value.slice(0, -1);
|
|
305
|
+
else if (byte >= 32 && byte <= 126) value += String.fromCharCode(byte);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
process.stdin.on("data", onData);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function resolveUser(sessionToken: string): Promise<AuthUser> {
|
|
313
|
+
const { response, json } = await authRequest("/get-session", { method: "GET" }, sessionToken);
|
|
314
|
+
if (!response.ok || !json?.user?.id || !json.user.email) {
|
|
315
|
+
throw new Error(errorMessage(json, "Could not load the signed-in user."));
|
|
316
|
+
}
|
|
317
|
+
return { id: json.user.id, email: json.user.email };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export type { AuthUser, OAuthProvider, StoredSession };
|
|
321
|
+
export { startOAuthCallbackServer };
|
|
322
|
+
|
|
323
|
+
export function authConfigured(): boolean {
|
|
324
|
+
return Boolean(configuredAuthBaseUrl());
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function storedSession(): StoredSession | null {
|
|
328
|
+
return readSession();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function storedUserId(): string | null {
|
|
332
|
+
return readSession()?.user.id ?? null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Browser OAuth for Google/GitHub. Used by the CLI and the Arcade login screen. */
|
|
336
|
+
export async function signInWithOAuth(
|
|
337
|
+
provider: OAuthProvider,
|
|
338
|
+
options: { onUrl?: (url: string) => void; signal?: AbortSignal } = {},
|
|
339
|
+
): Promise<AuthUser> {
|
|
340
|
+
if (!isOAuthProvider(provider)) {
|
|
341
|
+
throw new Error("usage: gamepigeon auth oauth <google|github>");
|
|
342
|
+
}
|
|
343
|
+
if (options.signal?.aborted) {
|
|
344
|
+
throw new Error("OAuth sign-in cancelled.");
|
|
345
|
+
}
|
|
346
|
+
const callback = receiveOAuthCallback();
|
|
347
|
+
const cancelled = () => new Error("OAuth sign-in cancelled.");
|
|
348
|
+
const whenAborted = <T>() =>
|
|
349
|
+
new Promise<T>((_, reject) => {
|
|
350
|
+
if (!options.signal) return;
|
|
351
|
+
if (options.signal.aborted) {
|
|
352
|
+
reject(cancelled());
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
options.signal.addEventListener("abort", () => reject(cancelled()), { once: true });
|
|
356
|
+
});
|
|
357
|
+
const onAbort = () => callback.close();
|
|
358
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
359
|
+
try {
|
|
360
|
+
await (options.signal
|
|
361
|
+
? Promise.race([callback.ready, whenAborted<void>()])
|
|
362
|
+
: callback.ready);
|
|
363
|
+
if (options.signal?.aborted) throw cancelled();
|
|
364
|
+
const start = new URL("/cli/oauth", `${requireAuthBaseUrl()}/`);
|
|
365
|
+
start.searchParams.set("provider", provider);
|
|
366
|
+
start.searchParams.set("next", oauthCallbackUrlFor(callback.port));
|
|
367
|
+
options.onUrl?.(start.toString());
|
|
368
|
+
openBrowser(start.toString());
|
|
369
|
+
const sessionToken = options.signal
|
|
370
|
+
? await Promise.race([callback.token, whenAborted<string>()])
|
|
371
|
+
: await callback.token;
|
|
372
|
+
const user = await resolveUser(sessionToken);
|
|
373
|
+
saveSession(sessionToken, user);
|
|
374
|
+
return user;
|
|
375
|
+
} finally {
|
|
376
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
377
|
+
callback.close();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Opens the shared Arcade login page for sign-in, sign-up, or OAuth. */
|
|
382
|
+
export async function signInOnWeb(
|
|
383
|
+
options: { onUrl?: (url: string) => void } = {},
|
|
384
|
+
): Promise<AuthUser> {
|
|
385
|
+
const callback = receiveOAuthCallback();
|
|
386
|
+
try {
|
|
387
|
+
await callback.ready;
|
|
388
|
+
const login = new URL(configuredLoginUrl());
|
|
389
|
+
login.searchParams.set("auth_url", requireAuthBaseUrl());
|
|
390
|
+
login.searchParams.set("callback", oauthCallbackUrlFor(callback.port));
|
|
391
|
+
login.searchParams.set("plugin", "1");
|
|
392
|
+
options.onUrl?.(login.toString());
|
|
393
|
+
openBrowser(login.toString());
|
|
394
|
+
const sessionToken = await callback.token;
|
|
395
|
+
const user = await resolveUser(sessionToken);
|
|
396
|
+
saveSession(sessionToken, user);
|
|
397
|
+
return user;
|
|
398
|
+
} finally {
|
|
399
|
+
callback.close();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Validates a persisted session against the backend, dropping it if it no
|
|
405
|
+
* longer resolves. Never opens a browser — safe to call from automated paths
|
|
406
|
+
* (e.g. a hook that runs on every prompt) without surprising the user with a
|
|
407
|
+
* sign-in tab. Returns null when there's no session to refresh.
|
|
408
|
+
*/
|
|
409
|
+
export async function refreshSessionIfPresent(): Promise<AuthUser | null> {
|
|
410
|
+
const session = readSession();
|
|
411
|
+
if (!session) return null;
|
|
412
|
+
try {
|
|
413
|
+
const user = await resolveUser(session.sessionToken);
|
|
414
|
+
saveSession(session.sessionToken, user);
|
|
415
|
+
return user;
|
|
416
|
+
} catch {
|
|
417
|
+
rmSync(sessionFile(), { force: true });
|
|
418
|
+
clearBackendSession();
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function ensureSignedIn(): Promise<AuthUser> {
|
|
424
|
+
const user = await refreshSessionIfPresent();
|
|
425
|
+
if (user) return user;
|
|
426
|
+
return signInOnWeb({
|
|
427
|
+
onUrl: (url) => console.log(`Opening Arcade sign-in. If your browser does not open, paste this URL:\n${url}`),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export async function signOutLocal(): Promise<void> {
|
|
432
|
+
const session = readSession();
|
|
433
|
+
try {
|
|
434
|
+
if (session) {
|
|
435
|
+
await authRequest("/sign-out", { method: "POST", body: "{}" }, session.sessionToken);
|
|
436
|
+
}
|
|
437
|
+
} finally {
|
|
438
|
+
rmSync(sessionFile(), { force: true });
|
|
439
|
+
clearBackendSession();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export async function runAuthCommand(action?: string): Promise<AuthSuccess | null> {
|
|
444
|
+
if (action === "status" || !action) {
|
|
445
|
+
const session = readSession();
|
|
446
|
+
console.log(session ? `Signed in as ${session.user.email}` : "Not signed in");
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (action === "logout") {
|
|
451
|
+
await signOutLocal();
|
|
452
|
+
console.log("Signed out. Local games were not changed.");
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (action === "oauth") {
|
|
457
|
+
const provider = process.argv[4];
|
|
458
|
+
if (!isOAuthProvider(provider)) {
|
|
459
|
+
throw new Error("usage: gamepigeon auth oauth <google|github>");
|
|
460
|
+
}
|
|
461
|
+
const user = await signInWithOAuth(provider, {
|
|
462
|
+
onUrl: (url) => {
|
|
463
|
+
console.log(`Opening ${provider} sign-in. If your browser does not open, paste this URL:\n${url}`);
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
console.log(`Signed in as ${user.email}`);
|
|
467
|
+
return { kind: "signed_in", method: provider, userId: user.id };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const email = await ask("Email: ");
|
|
471
|
+
const password = await ask("Password: ", true);
|
|
472
|
+
|
|
473
|
+
if (action === "login") {
|
|
474
|
+
const { response, json, sessionToken } = await authRequest("/sign-in/email", {
|
|
475
|
+
method: "POST",
|
|
476
|
+
body: JSON.stringify({ email, password }),
|
|
477
|
+
});
|
|
478
|
+
if (!response.ok || !sessionToken) {
|
|
479
|
+
throw new Error(errorMessage(json, "Sign in failed."));
|
|
480
|
+
}
|
|
481
|
+
const user = json?.user?.id && json.user.email
|
|
482
|
+
? { id: json.user.id, email: json.user.email }
|
|
483
|
+
: await resolveUser(sessionToken);
|
|
484
|
+
saveSession(sessionToken, user);
|
|
485
|
+
console.log(`Signed in as ${user.email}`);
|
|
486
|
+
return { kind: "signed_in", method: "password", userId: user.id };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (action === "signup") {
|
|
490
|
+
const name = await ask("Name (optional): ");
|
|
491
|
+
const { response, json, sessionToken } = await authRequest("/sign-up/email", {
|
|
492
|
+
method: "POST",
|
|
493
|
+
body: JSON.stringify({ email, password, name: name || email.split("@")[0] }),
|
|
494
|
+
});
|
|
495
|
+
if (!response.ok) {
|
|
496
|
+
throw new Error(errorMessage(json, "Sign up failed."));
|
|
497
|
+
}
|
|
498
|
+
if (!sessionToken) {
|
|
499
|
+
console.log("Account created. If email verification is enabled, verify then run: gamepigeon auth login");
|
|
500
|
+
return { kind: "signed_up", method: "password", userId: json?.user?.id };
|
|
501
|
+
}
|
|
502
|
+
const user = json?.user?.id && json.user.email
|
|
503
|
+
? { id: json.user.id, email: json.user.email }
|
|
504
|
+
: await resolveUser(sessionToken);
|
|
505
|
+
saveSession(sessionToken, user);
|
|
506
|
+
console.log(`Signed up as ${user.email}`);
|
|
507
|
+
return { kind: "signed_up", method: "password", userId: user.id };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
throw new Error("usage: gamepigeon auth [signup|login|oauth|status|logout]");
|
|
511
|
+
}
|
package/src/core/game.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared game contract. Every launch title implements this so the overlay shell
|
|
3
|
+
* and trajectory logger stay game-agnostic. Modeled after a gym-style
|
|
4
|
+
* env: reset → (step)* → done, with serializable state at every step.
|
|
5
|
+
*/
|
|
6
|
+
export interface StepResult {
|
|
7
|
+
/** Reward gained by this action (e.g. merge score in 2048). */
|
|
8
|
+
reward: number;
|
|
9
|
+
/** Whether the action actually changed the state (illegal moves don't). */
|
|
10
|
+
moved: boolean;
|
|
11
|
+
/** Episode finished. */
|
|
12
|
+
done: boolean;
|
|
13
|
+
/** Goal reached (e.g. made the 2048 tile). Distinct from `done`. */
|
|
14
|
+
success: boolean;
|
|
15
|
+
/** Random outcome produced while applying the action, when one occurred. */
|
|
16
|
+
randomEffects?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GameVariation {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
description: string;
|
|
23
|
+
goal: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Game<State, Action extends string> {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
/** Actions the agent/user can take, as stable string ids. */
|
|
29
|
+
readonly actions: readonly Action[];
|
|
30
|
+
reset(seed: number): void;
|
|
31
|
+
step(action: Action): StepResult;
|
|
32
|
+
/** JSON-serializable snapshot of the full observable state. */
|
|
33
|
+
state(): State;
|
|
34
|
+
score(): number;
|
|
35
|
+
}
|
package/src/core/key.ts
ADDED
package/src/core/rng.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small seedable PRNG (mulberry32). Seeded episodes make trajectories
|
|
3
|
+
* exactly reproducible: (seed, action sequence) → identical states, which
|
|
4
|
+
* lets the training pipeline re-simulate instead of trusting logged states.
|
|
5
|
+
*/
|
|
6
|
+
export class Rng {
|
|
7
|
+
private s: number;
|
|
8
|
+
|
|
9
|
+
private static assertState(state: number): void {
|
|
10
|
+
if (!Number.isSafeInteger(state) || state < 0 || state > 0xffffffff) {
|
|
11
|
+
throw new RangeError("RNG state must be an unsigned 32-bit integer");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
constructor(seed: number) {
|
|
16
|
+
this.s = seed >>> 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static fromState(state: number): Rng {
|
|
20
|
+
Rng.assertState(state);
|
|
21
|
+
return new Rng(state);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
exportState(): number {
|
|
25
|
+
return this.s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Float in [0, 1). */
|
|
29
|
+
next(): number {
|
|
30
|
+
this.s = (this.s + 0x6d2b79f5) >>> 0;
|
|
31
|
+
let t = this.s;
|
|
32
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
33
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
34
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Integer in [0, n). */
|
|
38
|
+
int(n: number): number {
|
|
39
|
+
return Math.floor(this.next() * n);
|
|
40
|
+
}
|
|
41
|
+
}
|