@homie-rocks/studio 0.14.4 → 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.
- package/README.md +43 -6
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +48 -0
- package/lib/office.mjs +7 -6
- package/lib/scaffold.mjs +50 -3
- package/lib/servers.mjs +133 -0
- package/lib/template-history.json +55 -0
- package/netplay/NETPLAY.md +317 -21
- package/netplay/netplay.ts +649 -33
- package/package.json +1 -1
- package/port/bots.ts +25 -2
- package/port/hud.ts +10 -7
- package/port/index.ts +5 -2
- package/port/room.ts +48 -10
- package/port/skill.ts +28 -0
- package/port/touch.ts +4 -1
- package/site/SITE.md +71 -0
- package/starters/ember-vale/src/main.ts +58 -12
- package/starters/gem-rush/src/main.ts +128 -33
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +353 -68
- package/worker/office-page.mjs +178 -10
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +622 -18
- package/worker/players.mjs +11 -3
- package/worker/room.mjs +547 -41
- package/worker/saves.mjs +4 -0
- package/worker/servers.mjs +445 -0
- package/worker/site.mjs +166 -10
- package/worker/stats.mjs +9 -3
- package/worker/version.mjs +1 -1
|
@@ -16,9 +16,22 @@
|
|
|
16
16
|
* zone (gems inside it score double) is slow state on the keyed state channel,
|
|
17
17
|
* so a joiner has it in its welcome and a promoted host inherits it.
|
|
18
18
|
*
|
|
19
|
+
* WATCH ANY PLAYER (contract revision 5): a watcher (/<game>/watch) never
|
|
20
|
+
* takes a seat. `net.viewSeat` says whose view to draw; the camera follows that
|
|
21
|
+
* player's body as their own browser does, the board marks their row, and with
|
|
22
|
+
* nobody followed the whole arena shows. Waves call `net.spotlight(seat)`, so a
|
|
23
|
+
* watcher on Auto cuts to whoever just bumped.
|
|
24
|
+
*
|
|
25
|
+
* SERVERS AND THE SKILL DIAL (contract revision 6): the bots play at the room's
|
|
26
|
+
* dial (`net.skillOf(slot)`: how late they see, how well they aim, whether they
|
|
27
|
+
* fight for the hot zone, how often they bump), which the party votes in the
|
|
28
|
+
* play page; a hybrid server's AI seats are kept as AI bodies (the Roster's
|
|
29
|
+
* reserve, marked " · AI"), an AI with a pass takes one, and a humans-only
|
|
30
|
+
* server's bots are off. The game says so: `caps: ['skill', 'agents']`.
|
|
31
|
+
*
|
|
19
32
|
* Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
|
|
20
33
|
*/
|
|
21
|
-
import { createNetplay, Roster, q, lerp, capMove, type RoleChange, type RoundInfo, type RoundResult, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
34
|
+
import { createNetplay, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
22
35
|
// The port toolkit's probe: what `homie-studio port check` reads for the owner tests (and sandbox + audio shims).
|
|
23
36
|
import { exposePort } from '@homie-rocks/studio/port';
|
|
24
37
|
|
|
@@ -38,10 +51,12 @@ const KNOCK_RANGE = 110;
|
|
|
38
51
|
const KNOCK_SPEED = 950;
|
|
39
52
|
const KNOCK_MS = 420;
|
|
40
53
|
const ZONE_MS = 12_000;
|
|
41
|
-
|
|
54
|
+
/** The contract's 12 colours (PALETTE, NETPLAY.md section 3): a person wears their seat's, so the watch page's strip matches. */
|
|
55
|
+
const colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;
|
|
42
56
|
const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
|
|
43
57
|
const botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;
|
|
44
|
-
|
|
58
|
+
/** An AI's name already ends in " · AI" (the relay sees to it); a plain bot says bot. */
|
|
59
|
+
const label = (name: string, bot: boolean): string => (name.endsWith(AI_MARK) ? name : bot ? `${name} · bot` : name);
|
|
45
60
|
|
|
46
61
|
/* ------------------------------------------------------------ wire shapes */
|
|
47
62
|
/** Snapshot: compact arrays, quantized. ~40 B per body, ~16 B per gem. (Reset epochs ride in the helper's `c` table.) */
|
|
@@ -66,10 +81,13 @@ interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[];
|
|
|
66
81
|
* Default `owner`: each browser moves its own body and the host bounds it.
|
|
67
82
|
*/
|
|
68
83
|
const MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(location.search).get('movement') === 'host' ? 'host' : 'owner'; } catch { return 'owner'; } })();
|
|
69
|
-
|
|
84
|
+
// caps: its bots read the dial ('skill'), and its Roster takes an AI's slot for it ('agents': the join passes p.agent).
|
|
85
|
+
const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'gem-rush', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], checkpoint: () => checkpoint() });
|
|
86
|
+
/** The Roster keeps the server's AI seats (revision 6): the policy is read whenever it fills. */
|
|
87
|
+
const policy = () => net.policy;
|
|
70
88
|
|
|
71
89
|
/* ------------------------------------------------------------ host state */
|
|
72
|
-
let roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName });
|
|
90
|
+
let roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
73
91
|
let bodies = new Map<number, Body>();
|
|
74
92
|
let gems: Gem[] = [];
|
|
75
93
|
let gemSeq = 0;
|
|
@@ -80,11 +98,15 @@ let hosting = false;
|
|
|
80
98
|
let cheatResets = 0;
|
|
81
99
|
let controlResets = 0;
|
|
82
100
|
let predictionError = 0;
|
|
101
|
+
/** Gems each slot picked up this round (a gem, whatever it scored): the dial's numbers (`pickups` probe). */
|
|
102
|
+
let pickups = new Map<number, number>();
|
|
83
103
|
|
|
84
104
|
/* ------------------------------------------------ this browser's avatar */
|
|
85
105
|
const me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, knockUntil: 0, has: false };
|
|
86
106
|
/** Offline (no shell) plays as a local seat 0, with keys or touch. */
|
|
87
107
|
const mySeat = (): number | null => (net.offline ? 0 : net.seat);
|
|
108
|
+
/** Whose view to draw: my own seat, or for a watcher the player it follows (null: the whole arena). */
|
|
109
|
+
const viewSeat = (): number | null => (net.offline ? 0 : net.viewSeat);
|
|
88
110
|
|
|
89
111
|
/* ------------------------------------------------------- replica view */
|
|
90
112
|
const drawn = new Map<number, { x: number; y: number; seat: number; score: number; slot: number }>();
|
|
@@ -143,6 +165,8 @@ function startRound(n: number): void {
|
|
|
143
165
|
hostMoved(b);
|
|
144
166
|
}
|
|
145
167
|
gems = Array.from({ length: GEM_COUNT }, newGem);
|
|
168
|
+
pickups = new Map();
|
|
169
|
+
sight.clear();
|
|
146
170
|
round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };
|
|
147
171
|
setZone(newZone((zone?.n ?? 0) + 1));
|
|
148
172
|
net.round(round);
|
|
@@ -154,7 +178,8 @@ function endRound(): void {
|
|
|
154
178
|
if (!round) return;
|
|
155
179
|
const now = net.now();
|
|
156
180
|
const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
|
|
157
|
-
const
|
|
181
|
+
const agentSlot = (slot: number): boolean => Boolean(roster.slots.find((x) => x.slot === slot)?.agent);
|
|
182
|
+
const results: RoundResult[] = ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1, ...(agentSlot(b.slot) ? { agent: true as const } : {}) }));
|
|
158
183
|
round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + BREAK_MS, results };
|
|
159
184
|
net.round(round);
|
|
160
185
|
}
|
|
@@ -164,7 +189,7 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
|
|
|
164
189
|
hosting = true;
|
|
165
190
|
if (e.promoted) restore(e);
|
|
166
191
|
else {
|
|
167
|
-
roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName });
|
|
192
|
+
roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
168
193
|
bodies = new Map();
|
|
169
194
|
const seat = mySeat();
|
|
170
195
|
if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
|
|
@@ -172,7 +197,7 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
|
|
|
172
197
|
startRound((e.round?.n ?? 0) + 1);
|
|
173
198
|
}
|
|
174
199
|
// Whoever is connected now is who plays: seats that left during the gap become bots.
|
|
175
|
-
const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null);
|
|
200
|
+
const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null).map((p) => ({ seat: p.seat, name: p.name, agent: p.agent ?? null }));
|
|
176
201
|
const { claimed } = roster.reconcile(peers);
|
|
177
202
|
syncBodiesFromRoster();
|
|
178
203
|
for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) hostMoved(b); }
|
|
@@ -189,14 +214,14 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
|
|
|
189
214
|
function restore(e: RoleChange<Snap, Ckpt>): void {
|
|
190
215
|
const ck = e.ckpt?.d ?? null;
|
|
191
216
|
if (ck) {
|
|
192
|
-
roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName });
|
|
217
|
+
roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
193
218
|
bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
|
|
194
219
|
gems = ck.gems.map((g) => ({ ...g }));
|
|
195
220
|
gemSeq = ck.gemSeq;
|
|
196
221
|
round = ck.round;
|
|
197
222
|
tick = ck.tick;
|
|
198
223
|
} else {
|
|
199
|
-
roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName });
|
|
224
|
+
roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
200
225
|
bodies = new Map();
|
|
201
226
|
syncBodiesFromRoster();
|
|
202
227
|
}
|
|
@@ -241,7 +266,8 @@ net.on('role', (e) => {
|
|
|
241
266
|
});
|
|
242
267
|
net.on('join', (p) => {
|
|
243
268
|
if (!hosting || p.seat === null) return;
|
|
244
|
-
|
|
269
|
+
// An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
|
|
270
|
+
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);
|
|
245
271
|
if (!c) return; // full: they watch
|
|
246
272
|
syncBodiesFromRoster();
|
|
247
273
|
const b = bodies.get(c.slot.slot);
|
|
@@ -256,6 +282,8 @@ net.on('leave', (p) => {
|
|
|
256
282
|
syncBodiesFromRoster();
|
|
257
283
|
publishRoster();
|
|
258
284
|
});
|
|
285
|
+
// The server's policy changed: its AI seats come (or go between rounds) at once.
|
|
286
|
+
net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
|
|
259
287
|
net.on('event', (e) => {
|
|
260
288
|
if (e.k === 'wave') addWave(e.d as { slot: number });
|
|
261
289
|
if (e.k === 'knock') addWave(e.d as { slot: number }, true);
|
|
@@ -342,6 +370,9 @@ function knock(b: Body, fx: number, fy: number): void {
|
|
|
342
370
|
}
|
|
343
371
|
function addWave(d: { slot: number }, isKnock = false): void {
|
|
344
372
|
const pos = hosting ? bodies.get(d.slot) : [...drawn.values()].find((x) => x.slot === d.slot);
|
|
373
|
+
// The action, for a watcher on Auto: whoever waved (a person, never a bot).
|
|
374
|
+
const waver = pos && 'bot' in pos ? (pos.bot ? null : pos.seat) : pos && pos.seat >= 0 ? pos.seat : null;
|
|
375
|
+
if (!isKnock) net.spotlight(waver);
|
|
345
376
|
const mineSlot = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
|
|
346
377
|
const at = mineSlot === d.slot && me.has ? me : pos;
|
|
347
378
|
if (!at) return;
|
|
@@ -408,28 +439,69 @@ function stepKnocked(b: Body, dt: number): void {
|
|
|
408
439
|
b.vx = b.kvx; b.vy = b.kvy;
|
|
409
440
|
}
|
|
410
441
|
|
|
442
|
+
/**
|
|
443
|
+
* THE BOTS, AT THE ROOM'S DIAL (NETPLAY.md section 17). A bot re-reads the world only every `reactionMs` (what it
|
|
444
|
+
* last noticed is where it heads, and a miss costs it that long again), aims up to 200 px off at aimNoise 1, leaves
|
|
445
|
+
* the hot zone to the people at positioning 0 and fights for it at 1, and waves at a rival in reach about
|
|
446
|
+
* `aggression x 0.8` times a second. Rookie is beatable by anyone, Maxed by few: measured with the
|
|
447
|
+
* `pickups` probe over 60 s rounds, a Maxed bot picks up a little over twice the gems of a Rookie.
|
|
448
|
+
*/
|
|
449
|
+
const sight = new Map<number, { at: number; tx: number; ty: number; arrived?: boolean }>();
|
|
450
|
+
|
|
411
451
|
function stepBots(dt: number): void {
|
|
412
452
|
const taken = new Set<number>();
|
|
413
453
|
const now = net.now();
|
|
414
454
|
for (const b of bodies.values()) {
|
|
415
455
|
if (!b.bot) continue;
|
|
416
456
|
if (b.knockUntil > now) { stepKnocked(b, dt); continue; }
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
const
|
|
421
|
-
|
|
457
|
+
const s: Skill = net.skillOf(b.slot); // Fair when nobody set a dial
|
|
458
|
+
let eye = sight.get(b.slot);
|
|
459
|
+
if (!eye || now - eye.at >= s.reactionMs) { // REACTION TIME
|
|
460
|
+
const g = pickGem(b, taken, s); // POSITIONING
|
|
461
|
+
const miss = 200 * s.aimNoise; // AIM NOISE: up to 200 px off at 1
|
|
462
|
+
eye = { at: now, tx: g ? g.x + (Math.random() - 0.5) * miss : b.tx, ty: g ? g.y + (Math.random() - 0.5) * miss : b.ty };
|
|
463
|
+
sight.set(b.slot, eye);
|
|
464
|
+
if (g) taken.add(g.id);
|
|
422
465
|
}
|
|
423
|
-
|
|
424
|
-
const
|
|
466
|
+
b.tx = eye.tx; b.ty = eye.ty;
|
|
467
|
+
const rival = nearestBody(b, KNOCK_RANGE); // AGGRESSION: waves at a rival in reach
|
|
468
|
+
if (rival && round?.phase === 'live' && Math.random() < s.aggression * 0.8 * dt) hostWave(b);
|
|
469
|
+
const dx = b.tx - b.x; const dy = b.ty - b.y; const dist = Math.hypot(dx, dy);
|
|
470
|
+
const len = dist || 1;
|
|
471
|
+
// Arrived where it aimed: it stands there, and notices what it missed only a reaction later.
|
|
472
|
+
if (dist < 6 && !eye.arrived) { eye.arrived = true; eye.at = now; }
|
|
473
|
+
const speed = dist < 6 ? 0 : BOT_SPEED;
|
|
425
474
|
const k = Math.min(1, dt * 5);
|
|
426
|
-
b.vx += ((dx / len) *
|
|
427
|
-
b.vy += ((dy / len) *
|
|
475
|
+
b.vx += ((dx / len) * speed - b.vx) * k;
|
|
476
|
+
b.vy += ((dy / len) * speed - b.vy) * k;
|
|
428
477
|
b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.vx * dt));
|
|
429
478
|
b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.vy * dt));
|
|
430
479
|
}
|
|
431
480
|
}
|
|
432
481
|
|
|
482
|
+
/** Positioning: 0 leaves the hot zone to the people, 1 fights for it. */
|
|
483
|
+
function pickGem(b: Body, taken: Set<number>, s: Skill): Gem | null {
|
|
484
|
+
let best: Gem | null = null; let bd = Infinity;
|
|
485
|
+
for (const g of gems) {
|
|
486
|
+
if (taken.has(g.id)) continue;
|
|
487
|
+
const hot = zone !== null && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r;
|
|
488
|
+
const d = Math.hypot(g.x - b.x, g.y - b.y) * (hot ? 1.5 - s.positioning : 1);
|
|
489
|
+
if (d < bd) { bd = d; best = g; }
|
|
490
|
+
}
|
|
491
|
+
return best;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** The nearest other body within `range` (a bot's rival), or null. */
|
|
495
|
+
function nearestBody(b: Body, range: number): Body | null {
|
|
496
|
+
let best: Body | null = null; let bd = range;
|
|
497
|
+
for (const o of bodies.values()) {
|
|
498
|
+
if (o === b) continue;
|
|
499
|
+
const d = Math.hypot(o.x - b.x, o.y - b.y);
|
|
500
|
+
if (d < bd) { bd = d; best = o; }
|
|
501
|
+
}
|
|
502
|
+
return best;
|
|
503
|
+
}
|
|
504
|
+
|
|
433
505
|
function stepHost(dt: number): void {
|
|
434
506
|
tick += 1;
|
|
435
507
|
const seat = mySeat();
|
|
@@ -471,6 +543,7 @@ function stepHost(dt: number): void {
|
|
|
471
543
|
const g = gems[i] as Gem;
|
|
472
544
|
if (Math.hypot(g.x - b.x, g.y - b.y) < R_AV + R_GEM) {
|
|
473
545
|
b.score += zone && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r ? 2 : 1;
|
|
546
|
+
pickups.set(b.slot, (pickups.get(b.slot) ?? 0) + 1);
|
|
474
547
|
gems[i] = newGem();
|
|
475
548
|
}
|
|
476
549
|
}
|
|
@@ -527,7 +600,14 @@ function resize(): void {
|
|
|
527
600
|
addEventListener('resize', resize);
|
|
528
601
|
resize();
|
|
529
602
|
|
|
530
|
-
const cam = { x: W / 2, y: H / 2 };
|
|
603
|
+
const cam = { x: W / 2, y: H / 2, scale: 0 };
|
|
604
|
+
/** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
|
|
605
|
+
function seatPos(seat: number): { x: number; y: number } | null {
|
|
606
|
+
if (seat === mySeat() && me.has && !net.watching) return me;
|
|
607
|
+
if (hosting) { const b = [...bodies.values()].find((x) => !x.bot && x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
|
|
608
|
+
const d = [...drawn.values()].find((x) => x.seat === seat);
|
|
609
|
+
return d ? { x: d.x, y: d.y } : null;
|
|
610
|
+
}
|
|
531
611
|
const gemGlow = (() => {
|
|
532
612
|
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, R_GEM * 2.8);
|
|
533
613
|
g.addColorStop(0, 'rgba(255,209,102,0.55)'); g.addColorStop(0.4, 'rgba(255,170,60,0.18)'); g.addColorStop(1, 'rgba(255,150,40,0)');
|
|
@@ -536,9 +616,15 @@ const gemGlow = (() => {
|
|
|
536
616
|
function draw(t: number): void {
|
|
537
617
|
const cw = innerWidth; const ch = innerHeight;
|
|
538
618
|
const phone = Math.min(cw, ch) <= 540;
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
const
|
|
619
|
+
// The view: my own body, or (a watcher) the followed player's, drawn exactly as their own browser frames it.
|
|
620
|
+
const view = viewSeat();
|
|
621
|
+
const followed = net.watching && view !== null ? seatPos(view) : null;
|
|
622
|
+
const overview = net.watching ? !followed : mySeat() === null;
|
|
623
|
+
const wantScale = overview ? Math.min(cw / (W + 80), ch / (H + 80)) : Math.min(cw, ch) / (phone ? 560 : 820);
|
|
624
|
+
// A watcher's switch glides: the zoom and the pan ease over a few frames, never a cut.
|
|
625
|
+
cam.scale = cam.scale ? cam.scale + (wantScale - cam.scale) * 0.12 : wantScale;
|
|
626
|
+
const scale = cam.scale;
|
|
627
|
+
const target = net.watching ? (followed ?? { x: W / 2, y: H / 2 }) : overview || !me.has ? { x: W / 2, y: H / 2 } : me;
|
|
542
628
|
cam.x += (target.x - cam.x) * 0.2; cam.y += (target.y - cam.y) * 0.2;
|
|
543
629
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
544
630
|
const bg = ctx.createRadialGradient(cw / 2, ch / 2, 0, cw / 2, ch / 2, Math.max(cw, ch) * 0.8);
|
|
@@ -588,18 +674,20 @@ function draw(t: number): void {
|
|
|
588
674
|
|
|
589
675
|
// avatars
|
|
590
676
|
const names = new Map<number, Slot>((net.slots ?? roster.slots).map((s) => [s.slot, s]));
|
|
677
|
+
// `mine`: the body whose view this is: my own, or the player a watcher follows (named, never "You").
|
|
591
678
|
const list: { slot: number; seat: number | null; x: number; y: number; bot: boolean; name: string; mine: boolean }[] = [];
|
|
679
|
+
const isView = (seat: number | null, bot: boolean): boolean => !bot && seat !== null && seat === view;
|
|
592
680
|
if (hosting) {
|
|
593
|
-
for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: b.seat
|
|
681
|
+
for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: isView(b.seat, b.bot) });
|
|
594
682
|
} else {
|
|
595
683
|
for (const d of drawn.values()) {
|
|
596
|
-
const
|
|
684
|
+
const own = !net.watching && d.seat >= 0 && d.seat === net.seat;
|
|
597
685
|
const s = names.get(d.slot);
|
|
598
|
-
list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x:
|
|
686
|
+
list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x: own && me.has ? me.x : d.x, y: own && me.has ? me.y : d.y, bot: d.seat < 0, name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine: isView(d.seat >= 0 ? d.seat : null, d.seat < 0) });
|
|
599
687
|
}
|
|
600
688
|
}
|
|
601
689
|
for (const a of list) {
|
|
602
|
-
const colour =
|
|
690
|
+
const colour = colourOf(a.slot, a.bot ? null : a.seat);
|
|
603
691
|
ctx.globalAlpha = a.bot ? 0.62 : 1;
|
|
604
692
|
ctx.fillStyle = colour; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV, 0, Math.PI * 2); ctx.fill();
|
|
605
693
|
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV * 0.45, 0, Math.PI * 2); ctx.fill();
|
|
@@ -607,7 +695,7 @@ function draw(t: number): void {
|
|
|
607
695
|
ctx.globalAlpha = 1;
|
|
608
696
|
ctx.font = `600 ${Math.round(15 / Math.max(0.6, scale))}px ui-sans-serif, system-ui, sans-serif`;
|
|
609
697
|
ctx.textAlign = 'center'; ctx.fillStyle = a.mine ? '#ffffff' : 'rgba(232,236,245,0.8)';
|
|
610
|
-
ctx.fillText(a.mine ? 'You' : label(a.name, a.bot), a.x, a.y - R_AV - 12);
|
|
698
|
+
ctx.fillText(a.mine && !net.watching ? 'You' : label(a.name, a.bot), a.x, a.y - R_AV - 12);
|
|
611
699
|
}
|
|
612
700
|
ctx.restore();
|
|
613
701
|
|
|
@@ -640,11 +728,11 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
|
|
|
640
728
|
ctx.textAlign = 'right';
|
|
641
729
|
rows.forEach((a, i) => {
|
|
642
730
|
ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';
|
|
643
|
-
ctx.fillText(`${a.mine ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, top + 18 + i * (phone ? 20 : 22));
|
|
731
|
+
ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, top + 18 + i * (phone ? 20 : 22));
|
|
644
732
|
});
|
|
645
733
|
// role badge
|
|
646
734
|
ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';
|
|
647
|
-
ctx.fillText(net.offline ? 'OFFLINE HOST' : net.role.toUpperCase(), pad, ch - pad);
|
|
735
|
+
ctx.fillText(net.offline ? 'OFFLINE HOST' : net.watching ? 'WATCHING' : net.role.toUpperCase(), pad, ch - pad);
|
|
648
736
|
if (r && r.phase === 'over' && r.results) {
|
|
649
737
|
const w = Math.min(360, cw - 40); const h = 44 + Math.min(6, r.results.length) * 26;
|
|
650
738
|
const x = (cw - w) / 2; const y = (ch - h) / 2;
|
|
@@ -653,10 +741,11 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
|
|
|
653
741
|
ctx.textAlign = 'center'; ctx.fillStyle = '#7df0ff'; ctx.font = '700 18px ui-sans-serif, system-ui, sans-serif';
|
|
654
742
|
ctx.fillText(`Round ${r.n} results`, cw / 2, y + 28);
|
|
655
743
|
ctx.font = '600 16px ui-sans-serif, system-ui, sans-serif';
|
|
744
|
+
const view = viewSeat();
|
|
656
745
|
r.results.slice(0, 6).forEach((row, i) => {
|
|
657
|
-
const mine = row.seat !== null && row.seat ===
|
|
746
|
+
const mine = !row.bot && row.seat !== null && row.seat === view;
|
|
658
747
|
ctx.fillStyle = mine ? '#ffffff' : row.bot ? 'rgba(232,236,245,0.6)' : '#e8ecf5';
|
|
659
|
-
ctx.fillText(`${row.place}. ${mine ? 'You' : label(row.name, row.bot)} — ${row.score}`, cw / 2, y + 56 + i * 26);
|
|
748
|
+
ctx.fillText(`${row.place}. ${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, y + 56 + i * 26);
|
|
660
749
|
});
|
|
661
750
|
}
|
|
662
751
|
if (!net.offline && !net.connected && net.role !== 'host') {
|
|
@@ -691,6 +780,10 @@ net.expose({
|
|
|
691
780
|
},
|
|
692
781
|
frames: () => frames,
|
|
693
782
|
scores: () => (hosting ? [...bodies.values()].map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) : [...drawn.values()].map((d) => ({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, bot: d.seat < 0, score: d.score }))),
|
|
783
|
+
/** Host: gems each slot picked up this round, the round's age, and the dial each bot plays at (the dial's numbers). */
|
|
784
|
+
pickups: () => (hosting ? { round: round ? { n: round.n, phase: round.phase, ageMs: net.now() - round.startedAt } : null, slots: [...bodies.values()].map((b) => ({ slot: b.slot, bot: b.bot, gems: pickups.get(b.slot) ?? 0, level: b.bot ? net.skillOf(b.slot).level : null })) } : null),
|
|
785
|
+
/** Host: when each bot last looked (its reaction clock). */
|
|
786
|
+
sight: () => (hosting ? [...sight.entries()].map(([slot, e]) => ({ slot, at: e.at })) : null),
|
|
694
787
|
waves: () => wavesSeen,
|
|
695
788
|
knocks: () => knocksSeen,
|
|
696
789
|
zone: () => zone,
|
|
@@ -699,6 +792,8 @@ net.expose({
|
|
|
699
792
|
movement: () => MOVEMENT,
|
|
700
793
|
controlResets: () => controlResets,
|
|
701
794
|
predictionError: () => predictionError,
|
|
795
|
+
/** Where the camera looks and whose view it is (a watcher following a player: their body, under the camera). */
|
|
796
|
+
camera: () => ({ x: cam.x, y: cam.y, scale: cam.scale, view: viewSeat() }),
|
|
702
797
|
/** performance.now() of the first keyed-state value and the first live snapshot this browser received. */
|
|
703
798
|
arrivals: () => ({ firstStateAt, firstSnapAt }),
|
|
704
799
|
/** Harness hook (host only): knock the body of `seat` back, toward the middle of the arena. */
|
package/worker/account-page.mjs
CHANGED
|
@@ -160,6 +160,11 @@ export function accountPage(cat, { origin = '', next = '/account/', owner = null
|
|
|
160
160
|
<label for="acct-email">Email</label><input id="acct-email" data-email type="email" autocomplete="email">
|
|
161
161
|
<div class="row"><button class="ghost" type="button" data-email-save>Send a confirmation</button><button class="ghost" type="button" data-email-remove hidden>Remove it</button></div>
|
|
162
162
|
</div>` : ''}
|
|
163
|
+
<div class="card" data-servers-card hidden>
|
|
164
|
+
<h2>Your servers</h2>
|
|
165
|
+
<p>The servers you belong to in this studio's games. Your home server is where Play takes you.</p>
|
|
166
|
+
<ul class="keys-list" data-servers></ul>
|
|
167
|
+
</div>
|
|
163
168
|
<div class="card">
|
|
164
169
|
<h2>Your data</h2>
|
|
165
170
|
<p>Download everything this studio keeps about you (your saves in every game, your stats, your memorials), or delete it all.</p>
|
|
@@ -230,6 +235,27 @@ export const ACCOUNT_JS = `${PASSKEY_JS}
|
|
|
230
235
|
$('[data-email-remove]').hidden = !me.email;
|
|
231
236
|
}
|
|
232
237
|
keys();
|
|
238
|
+
servers();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Your servers (0.16.0): each one, ★ for home, and Leave (a player's own choice; the door may let them back in).
|
|
242
|
+
function servers() {
|
|
243
|
+
var list = Array.isArray(me.servers) ? me.servers : [];
|
|
244
|
+
var card = $('[data-servers-card]'); var ul = $('[data-servers]');
|
|
245
|
+
if (!card || !ul) return;
|
|
246
|
+
card.hidden = !list.length; ul.textContent = '';
|
|
247
|
+
list.forEach(function (m) {
|
|
248
|
+
var li = document.createElement('li');
|
|
249
|
+
var a = document.createElement('a'); a.href = '/' + m.game + '/s/' + m.server + '/'; a.textContent = (m.home ? '★ ' : '') + m.server + ' · ' + m.game + (m.role !== 'member' ? ' · ' + m.role : '');
|
|
250
|
+
li.appendChild(a);
|
|
251
|
+
var b = document.createElement('button'); b.type = 'button'; b.className = 'ghost'; b.textContent = 'Leave';
|
|
252
|
+
b.addEventListener('click', function () {
|
|
253
|
+
fetch('/' + m.game + '/s/' + m.server + '/home', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ join: false }) })
|
|
254
|
+
.then(function (r) { return r.json(); }).then(function (x) { if (!x.ok) throw new Error(x.message || 'That did not work.'); me.servers = list.filter(function (y) { return y !== m; }); servers(); say('You left ' + m.server + '.', 'good'); }).catch(fail);
|
|
255
|
+
});
|
|
256
|
+
if (m.role === 'member') li.appendChild(b);
|
|
257
|
+
ul.appendChild(li);
|
|
258
|
+
});
|
|
233
259
|
}
|
|
234
260
|
|
|
235
261
|
function keys() {
|
|
@@ -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; }
|