@homie-rocks/studio 0.15.0 → 0.16.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.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * AGENT SEATS (@homie-rocks/studio 0.16.0, NETPLAY.md section 17): an AI that sits in a room's seat the way a phone
3
+ * does, always marked AI, and the skill dial every game's bots can read.
4
+ *
5
+ * SKILLS / skillPreset the dial: five levels, one shape everywhere ({ level, name, reactionMs, aimNoise,
6
+ * aggression, positioning }); netplay/netplay.ts has the same table (a test keeps them equal)
7
+ * AI_MARK / aiName every agent's name ends in exactly " · AI"; a person's name never ends in an AI or bot mark
8
+ * passes an agent pass is one AI's way into a seat: `hap_<id>_<secret>`, shown once; D1 keeps only
9
+ * the SHA-256 of the whole pass (agent_passes). A ticket names it as the holder `a-<id>`.
10
+ * sitRoute POST /<game>/api/agent with `Authorization: Bearer hap_…`: a room with people in it, a
11
+ * ticket, the socket and (hands `self`) the game's frame to load
12
+ * fillSpot the fill-a-spot hook: nothing issues service passes yet (a money decision), so it is null
13
+ *
14
+ * Nothing here imports the rest of the Worker, so the relay (room.mjs) can use the dial and the names as they are.
15
+ */
16
+
17
+ /** The skill dial (DESIGN D8; NETPLAY.md section 17). Level 3, Fair, is what the port kit's bots always were. */
18
+ export const SKILLS = Object.freeze([
19
+ Object.freeze({ level: 1, name: 'Rookie', reactionMs: 650, aimNoise: 0.55, aggression: 0.1, positioning: 0.1, card: 'stays at the back, misses a lot' }),
20
+ Object.freeze({ level: 2, name: 'Steady', reactionMs: 420, aimNoise: 0.3, aggression: 0.3, positioning: 0.35, card: 'helps, never steals the show' }),
21
+ Object.freeze({ level: 3, name: 'Fair', reactionMs: 250, aimNoise: 0.15, aggression: 0.5, positioning: 0.5, card: 'plays like a regular' }),
22
+ Object.freeze({ level: 4, name: 'Strong', reactionMs: 170, aimNoise: 0.07, aggression: 0.7, positioning: 0.75, card: 'keeps up with good players' }),
23
+ Object.freeze({ level: 5, name: 'Maxed', reactionMs: 110, aimNoise: 0.02, aggression: 0.9, positioning: 0.95, card: 'front-line tank, rarely misses' }),
24
+ ]);
25
+ /** Kids servers: the dial's ceiling and the most aggression any AI may show. */
26
+ export const KIDS = Object.freeze({ levelMax: 3, aggression: 0.3 });
27
+
28
+ /** A level as its preset (a copy): 1..5, Fair when it is not a number; a kids room caps it at 3 and aggression at 0.3. */
29
+ export function skillPreset(n, { kids = false } = {}) {
30
+ const top = kids ? KIDS.levelMax : 5;
31
+ const level = Math.max(1, Math.min(top, Math.round(Number(n)) || 3));
32
+ const s = { ...SKILLS[level - 1] };
33
+ if (kids) s.aggression = Math.min(s.aggression, KIDS.aggression);
34
+ return s;
35
+ }
36
+
37
+ /* ------------------------------------------------------------------ names */
38
+
39
+ /** The exact mark every agent's name ends with (DESIGN D7): it survives an old game that only draws `name`. */
40
+ export const AI_MARK = ' · AI';
41
+ /**
42
+ * An AI or bot mark at the end of a name, as its own word after a space or a separator: "Ada · AI", "Bo (AI)",
43
+ * "Cy [bot]", "Di - A.I.". A name that is only "Ai" (a person's name) is not one.
44
+ */
45
+ const AI_TAIL = /[\s·•∙⋅・|:_\-–—(\[{]+(?:a\.?\s?i\.?|bots?)[\s)\]}.!·•∙⋅・|:_\-–—]*$/iu;
46
+ const oneLine = (v, max) => String(v ?? '').normalize('NFKC').replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
47
+
48
+ /** Whether a name ends in an AI or bot mark. */
49
+ export const isAiName = (name) => AI_TAIL.test(String(name ?? '').trim());
50
+
51
+ /** A name with every AI or bot mark taken off its end: what a person's typed name becomes (they cannot claim one). */
52
+ export function stripAi(name) {
53
+ let s = oneLine(name, 64);
54
+ for (let i = 0; i < 6 && AI_TAIL.test(s); i += 1) s = s.replace(AI_TAIL, '').trim();
55
+ return s.replace(/[\s·•∙⋅・|:_\-–—(\[{]+$/u, '').trim();
56
+ }
57
+
58
+ /** An agent's name as every room shows it: its label (at most 16 characters, no mark of its own) and " · AI". */
59
+ export function aiName(label) {
60
+ const base = [...stripAi(label)].slice(0, 16).join('').trim();
61
+ return `${base || 'Agent'}${AI_MARK}`;
62
+ }
63
+
64
+ /* ------------------------------------------------------------------ the wire's facts (Worker → Table) */
65
+
66
+ /** JSON as base64url (no padding), for the Worker's own `pol` and `ag` values on the Table's address. */
67
+ export function encodeFacts(value) {
68
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
69
+ let s = '';
70
+ for (const b of bytes) s += String.fromCharCode(b);
71
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
72
+ }
73
+ /** The reverse, or null for anything that is not one (at most 4 KB). */
74
+ export function decodeFacts(text) {
75
+ const t = String(text ?? '');
76
+ if (!t || t.length > 4096 || !/^[A-Za-z0-9_-]+$/.test(t)) return null;
77
+ try {
78
+ const s = atob(t.replace(/-/g, '+').replace(/_/g, '/'));
79
+ const bytes = Uint8Array.from(s, (c) => c.charCodeAt(0));
80
+ return JSON.parse(new TextDecoder().decode(bytes));
81
+ } catch { return null; }
82
+ }
83
+
84
+ /* ------------------------------------------------------------------ passes */
85
+
86
+ export const PASS_KINDS = Object.freeze(['owner', 'guest', 'service']);
87
+ export const PASS_ROLES = Object.freeze(['party', 'guide', 'player']);
88
+ export const PASS_HANDS = Object.freeze(['self', 'host']);
89
+ export const PASS_ID = /^[a-f0-9]{10}$/;
90
+ const PASS_TOKEN = /^hap_([a-f0-9]{10})_([A-Za-z0-9_-]{40})$/;
91
+ const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
92
+ const SERVER_ID = /^[a-z0-9][a-z0-9-]{1,19}$/;
93
+ const DAY = 86_400_000;
94
+ /** An agent's ticket lasts as long as a player's (12 hours); a pass lasts what the owner said, or until revoked. */
95
+ export const AGENT_TICKET_MS = 12 * 3600_000;
96
+
97
+ function randomText(bytes) {
98
+ const raw = new Uint8Array(bytes);
99
+ crypto.getRandomValues(raw);
100
+ let s = '';
101
+ for (const b of raw) s += String.fromCharCode(b);
102
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
103
+ }
104
+ function randomHex(bytes) {
105
+ const raw = new Uint8Array(bytes);
106
+ crypto.getRandomValues(raw);
107
+ return [...raw].map((b) => b.toString(16).padStart(2, '0')).join('');
108
+ }
109
+ async function sha256(text) {
110
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
111
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
112
+ }
113
+
114
+ /** A pass as the office, the CLI and the MCP see it: never its secret. */
115
+ export function passView(r, now = Date.now()) {
116
+ if (!r) return null;
117
+ const expiresAt = r.expires_at === null || r.expires_at === undefined ? null : Number(r.expires_at);
118
+ const revoked = Number(r.revoked) === 1;
119
+ return {
120
+ id: r.id, label: r.label, name: aiName(r.label), kind: r.kind, role: r.role, hands: r.hands,
121
+ game: r.game ?? null, server: r.server ?? null, issuer: r.issuer ?? 'owner', expiresAt, revoked, createdAt: Number(r.created_at),
122
+ live: !revoked && (expiresAt === null || expiresAt > now),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * A new pass (the owner's office): `{ ok, pass, secret }`, the secret shown this once. A label is what the relay
128
+ * shows ("Claude" plays as "Claude · AI"). A fill-a-spot service has no issuer yet (DESIGN section 12: money).
129
+ */
130
+ export async function passCreate(env, { label, kind = 'owner', role = 'party', hands = 'self', game = null, server = null, days = null, issuer = 'owner' } = {}) {
131
+ if (!env?.DB) return { ok: false, error: 'no-db', message: 'This studio has no D1 for agent passes.' };
132
+ const name = [...stripAi(label)].slice(0, 16).join('').trim();
133
+ if (!name || !/[\p{L}\p{N}]/u.test(name)) return { ok: false, error: 'label', message: 'label is the name the AI plays under (1 to 16 letters or digits; " · AI" is added)' };
134
+ if (kind === 'service') return { ok: false, error: 'service', message: 'Passes for a fill-a-spot service come later; nothing issues them yet.' };
135
+ if (!PASS_KINDS.includes(kind)) return { ok: false, error: 'kind', message: 'kind is owner or guest' };
136
+ if (!PASS_ROLES.includes(role)) return { ok: false, error: 'role', message: 'role is party, guide or player' };
137
+ if (!PASS_HANDS.includes(hands)) return { ok: false, error: 'hands', message: 'hands is self (the AI runs the game itself) or host (the host\'s bot code moves it)' };
138
+ if (game !== null && !GAME_ID.test(String(game))) return { ok: false, error: 'game', message: 'game is one of this studio\'s game ids, or leave it out for any' };
139
+ if (server !== null && !SERVER_ID.test(String(server))) return { ok: false, error: 'server', message: 'server is a server id, or leave it out for any that lets AI in' };
140
+ const id = randomHex(5);
141
+ const secret = `hap_${id}_${randomText(30)}`;
142
+ const now = Date.now();
143
+ const d = days === null || days === undefined ? null : Math.max(1, Math.min(365, Math.floor(Number(days)) || 7));
144
+ const row = { id, label: name, kind, role, hands, game, server, issuer: String(issuer ?? 'owner').slice(0, 40), expires_at: d ? now + d * DAY : null, revoked: 0, created_at: now };
145
+ await env.DB.prepare('INSERT INTO agent_passes (id, hash, label, kind, role, hands, game, server, issuer, expires_at, revoked, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 0, ?11)')
146
+ .bind(id, await sha256(secret), name, kind, role, hands, game, server, row.issuer, row.expires_at, now).run();
147
+ return { ok: true, pass: passView(row, now), secret };
148
+ }
149
+
150
+ /** The pass a `hap_…` secret is, when it is this studio's and still good; else null. */
151
+ export async function passOf(env, token) {
152
+ const m = PASS_TOKEN.exec(String(token ?? '').trim());
153
+ if (!m || !env?.DB) return null;
154
+ let r = null;
155
+ try { r = await env.DB.prepare('SELECT * FROM agent_passes WHERE hash = ?1').bind(await sha256(m[0])).first(); } catch { return null; }
156
+ if (!r || r.id !== m[1]) return null;
157
+ const v = passView(r);
158
+ return v.live ? v : null;
159
+ }
160
+
161
+ /** A pass by its id (a ticket's `a-<id>`), live or not: null when there is none. */
162
+ export async function passById(env, id) {
163
+ if (!PASS_ID.test(String(id ?? '')) || !env?.DB) return null;
164
+ try { return passView(await env.DB.prepare('SELECT * FROM agent_passes WHERE id = ?1').bind(id).first()); } catch { return null; }
165
+ }
166
+
167
+ export async function passRevoke(env, id) {
168
+ if (!PASS_ID.test(String(id ?? ''))) return { ok: false, error: 'id', message: 'id is the pass\'s id (10 letters and digits)' };
169
+ const r = await env.DB.prepare('UPDATE agent_passes SET revoked = 1 WHERE id = ?1 RETURNING id').bind(id).first();
170
+ return r ? { ok: true, revoked: id } : { ok: false, error: 'no-pass', message: 'No such pass.' };
171
+ }
172
+
173
+ /** Every pass (newest first), for one game or all: { id, label, name, kind, role, hands, game, server, live, … }. */
174
+ export async function passList(env, { game = null } = {}) {
175
+ try {
176
+ const { results } = game
177
+ ? await env.DB.prepare('SELECT * FROM agent_passes WHERE game = ?1 OR game IS NULL ORDER BY created_at DESC LIMIT 200').bind(game).all()
178
+ : await env.DB.prepare('SELECT * FROM agent_passes ORDER BY created_at DESC LIMIT 200').all();
179
+ return (results ?? []).map((r) => passView(r));
180
+ } catch { return []; }
181
+ }
182
+
183
+ /** What a room is told about an agent (`peer.agent`): its pass, role, hands, and who it is for. */
184
+ export const agentFacts = (pass) => ({ pass: pass.id, role: pass.role, hands: pass.hands, by: pass.kind === 'owner' ? 'studio' : pass.kind });
185
+
186
+ /**
187
+ * Why this pass may not sit here, or null when it may: the server's policy (humans-only lets no AI in), the pass's
188
+ * own game and server, and a game that is not public (only the owner's own passes open it).
189
+ */
190
+ export function passRefusal(pass, { game, server, policy, launch = 'public' }) {
191
+ if (!pass || !pass.live) return { status: 403, error: 'agent-pass', message: 'That agent pass is revoked, ended, or not this studio\'s.' };
192
+ if (pass.game && pass.game !== game) return { status: 403, error: 'agent-scope', message: `This pass is for ${pass.game}, not ${game}.` };
193
+ if (pass.server && pass.server !== server) return { status: 403, error: 'agent-scope', message: `This pass is for the server ${pass.server}.` };
194
+ if (policy?.kind === 'humans-only') return { status: 403, error: 'agents-off', message: 'This server is for humans only' };
195
+ if (launch !== 'public' && pass.kind !== 'owner') return { status: 403, error: 'not-open', message: 'This game is not open to you.' };
196
+ return null;
197
+ }
198
+
199
+ /**
200
+ * POST /<game>/api/agent (Bearer pass): a seat for an AI. Strangers are never started a room by an AI (DESIGN D9):
201
+ * the Lobby offers only a room of the server's pool that has people in it. Hands `self`: load `frame` (the game,
202
+ * which plays as the AI); hands `host`: open `ws` and say hello with `agent: { hands: 'host' }` (the host's bot code
203
+ * moves the body; NETPLAY.md section 17).
204
+ *
205
+ * `deps`: { meta, launch, serverOf(id) -> server | null, policyOf(server) -> Policy, join(server, policy) -> room | null,
206
+ * ticketFor(sub) -> ticket }
207
+ */
208
+ export async function sitRoute(request, env, url, deps) {
209
+ const json = (body, status = 200) => new Response(`${JSON.stringify(body)}\n`, { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store, private' } });
210
+ if (request.method !== 'POST') return json({ ok: false, error: 'method', message: 'POST, with Authorization: Bearer <agent pass>' }, 405);
211
+ const bearer = /^Bearer\s+(\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1] ?? '';
212
+ const pass = await passOf(env, bearer);
213
+ if (!pass) return json({ ok: false, error: 'agent-pass', message: 'An AI sits with an agent pass from the studio\'s owner (homie-studio agents pass).' }, 401);
214
+ let body = {};
215
+ try { const t = await request.text(); body = t ? JSON.parse(t) : {}; } catch { return json({ ok: false, error: 'json' }, 400); }
216
+ const wanted = typeof body?.server === 'string' && SERVER_ID.test(body.server) ? body.server : pass.server ?? 'public';
217
+ const server = deps.serverOf(wanted);
218
+ if (!server || server.state !== 'open') return json({ ok: false, error: 'no-server', message: `No open server called ${wanted} here.` }, 404);
219
+ const policy = deps.policyOf(server);
220
+ const why = passRefusal(pass, { game: deps.meta.id, server: server.id, policy, launch: deps.launch });
221
+ if (why) return json({ ok: false, ...why }, why.status);
222
+ const room = await deps.join(server, policy);
223
+ if (!room) return json({ ok: false, error: 'no-room', message: 'Nobody is playing on this server right now: an AI never starts a room. Try again when people are here.' }, 409);
224
+ const ticket = await deps.ticketFor(`a-${pass.id}`);
225
+ if (!ticket) return json({ ok: false, error: 'no-ticket', message: 'This studio cannot mint tickets yet (its D1).' }, 503);
226
+ const ws = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}/${deps.meta.id}/__net?room=${encodeURIComponent(room)}&t=${encodeURIComponent(ticket)}`;
227
+ return json({
228
+ ok: true, game: deps.meta.id, server: server.id, room, ticket, name: aiName(pass.label), role: pass.role, hands: pass.hands, policy: { kind: policy.kind, level: policy.level, levelMax: policy.levelMax },
229
+ ws, ...(pass.hands === 'self' ? { frame: `${url.origin}/${deps.meta.id}/__game/?room=${encodeURIComponent(room)}&t=${encodeURIComponent(ticket)}` } : { hello: { t: 'hello', v: 1, rev: 6, want: 'play', canHost: false, agent: { hands: 'host', role: pass.role } } }),
230
+ expiresAt: Date.now() + AGENT_TICKET_MS,
231
+ });
232
+ }
233
+
234
+ /**
235
+ * FILL A SPOT (DESIGN section 12): a service that seats an AI in an empty spot for a price is the owner's money
236
+ * decision, so nothing issues `service` passes and this hook answers null. A later version decides who, what and how.
237
+ */
238
+ export function fillSpot() { return null; }