@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.14.4",
3
+ "version": "0.16.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/port/bots.ts CHANGED
@@ -13,7 +13,11 @@
13
13
  * - They are kind to newcomers: no bot targets a person in their first
14
14
  * ~15 s, and a person's first minute is never a pile-on.
15
15
  * - A body a person left keeps playing as a bot, where it stands.
16
+ * - They play at the room's skill dial (NETPLAY.md section 17): give BotBrain
17
+ * `skill: () => room.skillOf(body)` and the party's vote changes how fast it
18
+ * reacts, how well it aims and how long it commits, with no other change.
16
19
  */
20
+ import type { Skill } from '../netplay/netplay';
17
21
 
18
22
  export interface V2 { x: number; y: number }
19
23
 
@@ -45,9 +49,20 @@ export function nearest<T extends V2>(from: V2, items: Iterable<T>, taken?: Set<
45
49
  return best;
46
50
  }
47
51
 
52
+ /**
53
+ * What a skill level means to a bot's brain (section 17): it reacts `reactionMs` late, its aim errs by
54
+ * `aimNoise x 0.8` radians, and it commits to a target for `2500 x (1.3 - 0.6 x aggression)` ms (an aggressive bot
55
+ * changes its mind sooner). Level 3, Fair, is 250 ms, 0.12 rad and 2.5 s: what every port's bots always were.
56
+ */
57
+ export function brainOf(s: Skill): { reactionMs: number; aimError: number; commitMs: number } {
58
+ return { reactionMs: s.reactionMs, aimError: s.aimNoise * 0.8, commitMs: 2500 * (1.3 - 0.6 * s.aggression) };
59
+ }
60
+
48
61
  /**
49
62
  * A bot's intent with a person's limits: it sees the world `reactionMs` late, commits to a target for `commitMs`,
50
63
  * and notices when it is stuck. Call `think(now, pos, choose)` every frame; it returns the stick to push.
64
+ * `skill` (a Skill, or a function that says the current one): the room's dial sets reaction, aim and commitment;
65
+ * explicit `reactionMs`, `commitMs` or `aimError` still win.
51
66
  */
52
67
  export class BotBrain<T extends V2 = V2> {
53
68
  target: T | null = null;
@@ -56,10 +71,18 @@ export class BotBrain<T extends V2 = V2> {
56
71
  private unstickUntil = 0;
57
72
  private unstick: V2 = { x: 0, y: 0 };
58
73
  private queue: { at: number; v: V2 }[] = [];
59
- constructor(readonly opts: { reactionMs?: number; commitMs?: number; stuckMs?: number; stuckDist?: number; aimError?: number } = {}) {}
74
+ constructor(readonly opts: { reactionMs?: number; commitMs?: number; stuckMs?: number; stuckDist?: number; aimError?: number; skill?: Skill | (() => Skill) } = {}) {}
75
+
76
+ /** The dial this brain plays at now (Fair with none). */
77
+ skill(): Skill | null {
78
+ const k = this.opts.skill;
79
+ try { return typeof k === 'function' ? k() : k ?? null; } catch { return null; }
80
+ }
60
81
 
61
82
  think(now: number, pos: V2, choose: () => T | null, arrive = 0): V2 {
62
- const { reactionMs = 250, commitMs = 2500, stuckMs = 3000, stuckDist = 24, aimError = 0.12 } = this.opts;
83
+ const sk = this.skill();
84
+ const dial = sk ? brainOf(sk) : { reactionMs: 250, commitMs: 2500, aimError: 0.12 };
85
+ const { reactionMs = dial.reactionMs, commitMs = dial.commitMs, stuckMs = 3000, stuckDist = 24, aimError = dial.aimError } = this.opts;
63
86
  if (!this.target || now - this.chosenAt > commitMs) { this.target = choose(); this.chosenAt = now; }
64
87
  if (!this.lastCheck || now - this.lastCheck.at > stuckMs) {
65
88
  if (this.lastCheck && Math.hypot(pos.x - this.lastCheck.x, pos.y - this.lastCheck.y) < stuckDist && this.target) {
package/port/hud.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * results card between rounds. Everything is text on small translucent chips;
5
5
  * nothing opaque sits in the middle of the screen during play; names are set
6
6
  * with textContent (people type them). A big screen (a spectator) gets the
7
- * same board without any "you".
7
+ * same board without any "you"; a watcher following a player (NETPLAY.md
8
+ * section 16) sees that player's row and result marked the way "you" is.
8
9
  *
9
10
  * A game with its own HUD should draw the same facts itself (clock from
10
11
  * room.clock(), results from room.results()) instead.
@@ -59,19 +60,21 @@ export function createHud<B extends BodyBase>(room: Room<B, any, any>, opts: Hud
59
60
  const paint = (): void => {
60
61
  const c = room.clock();
61
62
  clock.textContent = c.phase === 'none' ? 'Joining…' : c.phase === 'live' ? `Round ${c.n} · ${Math.floor(c.secondsLeft / 60)}:${String(c.secondsLeft % 60).padStart(2, '0')}` : `Next round in ${c.secondsLeft}`;
62
- const me = room.mySeat();
63
+ // A watcher's followed player is marked as "you" is (net.watchedSeat says it without claiming the camera follows).
64
+ const me = room.net.watching ? room.net.watchedSeat : room.mySeat();
65
+ const watchingSeat = room.net.watching && me !== null;
63
66
  const rows = room.view().slice().sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot)).slice(0, phone ? 4 : 5);
64
67
  board.textContent = '';
65
68
  for (const b of rows) {
66
69
  const d = document.createElement('div');
67
- const mine = !spectator() && me !== null && b.seat === me && !b.bot;
70
+ const mine = (watchingSeat || !spectator()) && me !== null && b.seat === me && !b.bot;
68
71
  d.className = mine ? 'me' : b.bot ? 'bot' : '';
69
- d.textContent = `${mine ? 'You' : b.name}${b.bot ? ' · bot' : ''} ${b.score}`;
72
+ d.textContent = `${mine && !watchingSeat ? 'You' : b.name}${b.bot ? ' · bot' : ''} ${b.score}`;
70
73
  board.append(d);
71
74
  }
72
75
  const r = room.round;
73
76
  if (r && r.phase === 'over' && r.results) {
74
- const key = `${r.n}:${r.endsAt}`;
77
+ const key = `${r.n}:${r.endsAt}:${me}`;
75
78
  if (key !== lastResults) {
76
79
  lastResults = key;
77
80
  results.textContent = '';
@@ -79,8 +82,8 @@ export function createHud<B extends BodyBase>(room: Room<B, any, any>, opts: Hud
79
82
  const ol = document.createElement('ol');
80
83
  for (const row of r.results.slice(0, 6)) {
81
84
  const li = document.createElement('li');
82
- const mine = !spectator() && me !== null && row.seat === me && !row.bot;
83
- li.textContent = `${mine ? 'You' : row.name}${row.bot ? ' (bot)' : ''} — ${row.score}${opts.unit ? ` ${opts.unit}` : ''}`;
85
+ const mine = (watchingSeat || !spectator()) && me !== null && row.seat === me && !row.bot;
86
+ li.textContent = `${mine && !watchingSeat ? 'You' : row.name}${row.bot ? ' (bot)' : ''} — ${row.score}${opts.unit ? ` ${opts.unit}` : ''}`;
84
87
  if (mine) li.style.color = 'var(--hp-accent,#ffd166)';
85
88
  ol.append(li);
86
89
  }
package/port/index.ts CHANGED
@@ -11,7 +11,9 @@
11
11
  * exposePort what `homie-studio port check` reads (owner tests)
12
12
  * createHud a minimal clock / scores / results overlay
13
13
  * BotBrain, seek, nearest, rubberBand
14
- * bot scaffolds
14
+ * bot scaffolds; `skill` makes a bot play at the room's dial (NETPLAY.md section 17)
15
+ * jitter, engages, standoff
16
+ * the dial for the rest of a bot's decisions (port/skill.ts)
15
17
  * createSaves player accounts and cloud saves (a character that lasts days, on every device; saves/SAVES.md)
16
18
  * and everything from @homie-rocks/studio/netplay.
17
19
  */
@@ -27,5 +29,6 @@ export { groundBasis, screenToGround, PlayerYaw, type GroundBasis, type CameraLi
27
29
  export { exposePort, type PortProbeOptions, type View } from './probe';
28
30
  export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt } from './room';
29
31
  export { createHud, type HudOptions } from './hud';
30
- export { BotBrain, seek, flee, wanderer, nearest, rubberBand, protectedNewcomer, type V2 } from './bots';
32
+ export { BotBrain, brainOf, seek, flee, wanderer, nearest, rubberBand, protectedNewcomer, type V2 } from './bots';
33
+ export { jitter, engages, standoff } from './skill';
31
34
  export { createSaves, SAVES_VERSION, type Saves, type SavesOptions, type Player, type SaveEntry, type SetResult, type Memorial, type SavesStatus, type Conflict } from '../saves/saves';
package/port/room.ts CHANGED
@@ -17,15 +17,21 @@
17
17
  * - Snapshots: one small row per body [slot, seat (-1 = bot), score, ...your
18
18
  * fields], plus your fast world, at 20 Hz; replicas interpolate them.
19
19
  *
20
+ * - Servers and agent seats (NETPLAY.md section 17): a hybrid server's AI
21
+ * seats are kept as AI bodies (the game's bots move them, marked AI), an AI
22
+ * that sits takes one, the server's `bots: 'off'` adds no filler bots, and
23
+ * `room.skillOf(body)` is the dial a bot plays at (the party votes it).
24
+ *
20
25
  * What stays the game's: the rules (what a body does per frame), the bots'
21
26
  * brains, the drawing, and the camera. See the port skill's recipe.
22
27
  */
23
28
  import {
24
29
  capMove, createNetplay, lerp, lerpAngle, Roster,
25
- type Movement, type Netplay, type NetplayOptions, type RoleChange, type RoundInfo, type RoundResult, type Slot, type Snapshot,
30
+ type Movement, type Netplay, type NetplayOptions, type Policy, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot,
26
31
  } from '../netplay/netplay';
27
32
 
28
- export interface BodyBase { slot: number; seat: number | null; name: string; bot: boolean; score: number }
33
+ /** A body. `agent` (revision 6): this body is an AI's (a seat kept for AI, or an agent's), always marked AI. */
34
+ export interface BodyBase { slot: number; seat: number | null; name: string; bot: boolean; score: number; agent?: Slot['agent'] }
29
35
 
30
36
  export interface RoomSnap<F = unknown> { r: [n: number, phase: number, startedAt: number, endsAt: number]; b: number[][]; w?: F }
31
37
  export interface RoomCkpt<B, S> { round: RoundInfo | null; roster: Slot[]; bodies: B[]; world: S | null; tick: number }
@@ -83,6 +89,16 @@ export interface Room<B extends BodyBase, F = unknown, S = unknown> {
83
89
  readonly round: RoundInfo | null;
84
90
  /** My seat (0 when offline: a solo game with bots, the way a plain file or a dev server plays). */
85
91
  mySeat(): number | null;
92
+ /**
93
+ * WATCHING (NETPLAY.md section 16): whose camera and HUD to draw. My own seat when I play (0 offline); for a
94
+ * watcher, the seat it follows, or null for the overview camera. Point your camera at `viewBody()` (for your own
95
+ * seat, keep drawing your local body) and your HUD at its numbers; null: your spectator overview.
96
+ */
97
+ viewSeat(): number | null;
98
+ /** The body of viewSeat() as everyone draws it (host: the real one; others: interpolated), or null. */
99
+ viewBody(): B | null;
100
+ /** The skill dial a bot body plays at now (section 17): the party's vote, a guide's server level, else Fair. */
101
+ skillOf(body: { slot: number }): Skill;
86
102
  /** Host: my own body. Replica: null (draw yours from your local state). */
87
103
  mine(): B | null;
88
104
  /** Host, every frame: the round clock and the snapshot. Replica: no-op. Call AFTER your rules moved the bodies. */
@@ -123,7 +139,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
123
139
  const angles = new Set(opts.angles ?? []);
124
140
  const discrete = new Set(opts.discrete ?? []);
125
141
 
126
- let roster = new Roster({ min, max, botName });
142
+ // The roster keeps the server's AI seats (section 17); the policy is the room's, read when it fills.
143
+ const policy = (): Policy => net.policy;
144
+ let roster = new Roster({ min, max, botName, policy });
127
145
  let bodies = new Map<number, B>();
128
146
  let round: RoundInfo | null = null;
129
147
  let hosting = false;
@@ -134,6 +152,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
134
152
  snapshotHz: 20, inputHz: 20, checkpointMs: 1000,
135
153
  checkpoint: () => ({ round, roster: roster.toJSON(), bodies: [...bodies.values()].map((b) => ({ ...b })), world: opts.saveWorld ? opts.saveWorld() : null, tick }),
136
154
  ...(opts.netplay ?? {}),
155
+ // createRoom claims an AI's slot for it (its join passes `p.agent`), so its host can move an AI's body.
156
+ caps: [...new Set([...(opts.netplay?.caps ?? []), 'agents' as const])],
137
157
  });
138
158
 
139
159
  const mySeat = (): number | null => (net.offline ? 0 : net.seat);
@@ -149,8 +169,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
149
169
  for (const s of roster.slots) {
150
170
  seen.add(s.slot);
151
171
  const b = bodies.get(s.slot);
152
- if (!b) { const nb = opts.spawn(s, i++); nb.slot = s.slot; nb.seat = s.seat; nb.name = s.name; nb.bot = s.bot; nb.score = nb.score ?? 0; bodies.set(s.slot, nb); continue; }
172
+ if (!b) { const nb = opts.spawn(s, i++); nb.slot = s.slot; nb.seat = s.seat; nb.name = s.name; nb.bot = s.bot; nb.score = nb.score ?? 0; if (s.agent) nb.agent = { ...s.agent }; bodies.set(s.slot, nb); continue; }
153
173
  b.seat = s.seat; b.name = s.name; b.bot = s.bot;
174
+ if (s.agent) b.agent = { ...s.agent }; else delete b.agent;
154
175
  }
155
176
  for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);
156
177
  }
@@ -177,6 +198,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
177
198
  for (const s of [...roster.slots].sort((a, c) => a.slot - c.slot)) {
178
199
  const fresh = opts.spawn(s, i++);
179
200
  fresh.slot = s.slot; fresh.seat = s.seat; fresh.name = s.name; fresh.bot = s.bot; fresh.score = 0;
201
+ if (s.agent) fresh.agent = { ...s.agent };
180
202
  bodies.set(s.slot, fresh);
181
203
  }
182
204
  round = { n, phase: 'live', startedAt: now, endsAt: now + roundMs };
@@ -189,7 +211,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
189
211
 
190
212
  function rank(): RoundResult[] {
191
213
  const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
192
- return ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1 }));
214
+ return ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1, ...(b.agent ? { agent: true as const } : {}) }));
193
215
  }
194
216
 
195
217
  function endRound(): void {
@@ -204,12 +226,12 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
204
226
  function restore(e: RoleChange<RoomSnap<F>, RoomCkpt<B, S>>): void {
205
227
  const ck = e.ckpt?.d ?? null;
206
228
  if (ck) {
207
- roster = Roster.from(ck.roster, { min, max, botName });
229
+ roster = Roster.from(ck.roster, { min, max, botName, policy });
208
230
  bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
209
231
  round = ck.round;
210
232
  tick = ck.tick;
211
233
  } else {
212
- roster = Roster.from(e.roster ?? [], { min, max, botName });
234
+ roster = Roster.from(e.roster ?? [], { min, max, botName, policy });
213
235
  bodies = new Map();
214
236
  syncBodies();
215
237
  }
@@ -244,14 +266,14 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
244
266
  hosting = true;
245
267
  if (e.promoted) restore(e);
246
268
  else {
247
- roster = new Roster({ min, max, botName });
269
+ roster = new Roster({ min, max, botName, policy });
248
270
  bodies = new Map();
249
271
  const seat = mySeat();
250
272
  if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
251
273
  syncBodies();
252
274
  startRound((e.round?.n ?? 0) + 1);
253
275
  }
254
- const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null);
276
+ 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 }));
255
277
  const { claimed } = roster.reconcile(peers);
256
278
  syncBodies();
257
279
  for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) moved(b); }
@@ -266,7 +288,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
266
288
  });
267
289
  net.on('join', (p) => {
268
290
  if (!hosting || p.seat === null) return;
269
- const c = roster.claim(p.seat, p.name);
291
+ // An AI takes a seat kept for AI (its hands say who moves the body); a person never does (section 17).
292
+ const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);
270
293
  if (!c) return; // full: they watch
271
294
  syncBodies();
272
295
  const b = bodies.get(c.slot.slot);
@@ -283,6 +306,13 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
283
306
  syncBodies();
284
307
  net.roster(roster.toJSON());
285
308
  });
309
+ // The server's policy changed (a new server stamp, the owner): its AI seats come or go at once, filler at the round.
310
+ net.on('policy', () => {
311
+ if (!hosting) return;
312
+ roster.fill();
313
+ syncBodies();
314
+ net.roster(roster.toJSON());
315
+ });
286
316
  net.on('control', (e) => {
287
317
  if (!e.reset || !opts.adopt) return;
288
318
  const snap = e.snap as Snapshot<RoomSnap<F>> | null;
@@ -320,6 +350,14 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
320
350
  get bodies() { return bodies; },
321
351
  get round() { return hosting ? round : (net.roundInfo ?? round); },
322
352
  mySeat,
353
+ viewSeat: () => (net.offline ? 0 : net.viewSeat),
354
+ viewBody() {
355
+ const s = net.offline ? 0 : net.viewSeat;
356
+ if (s === null) return null;
357
+ for (const b of room.view()) if (!b.bot && b.seat === s) return b;
358
+ return null;
359
+ },
360
+ skillOf: (body) => net.skillOf(body.slot),
323
361
  mine: () => (hosting ? bodyOfSeat(mySeat()) : null),
324
362
  update() {
325
363
  if (!hosting) return;
package/port/skill.ts ADDED
@@ -0,0 +1,28 @@
1
+ /*
2
+ * The skill dial for a port's own bot code (NETPLAY.md section 17). A level is { reactionMs, aimNoise, aggression,
3
+ * positioning }; `room.skillOf(body)` (or `net.skillOf(slot)`) says which one a bot plays at now. BotBrain reads
4
+ * reaction, aim and commitment by itself; these three are for the rest of a bot's decisions:
5
+ *
6
+ * jitter(s, maxPx) how far off its aim lands (aimNoise of maxPx, either way)
7
+ * engages(s, dt) whether it starts a fight this frame (aggression, about once a second at most)
8
+ * standoff(s, near, far) how close to the action it stands: far at positioning 0, near at 1
9
+ *
10
+ * Level 1 (Rookie) stays at the back and misses a lot; 5 (Maxed) is a front-line tank that rarely misses.
11
+ */
12
+ import type { Skill } from '../netplay/netplay';
13
+
14
+ /** An aim offset: up to `maxPx x aimNoise` either way (0 at a perfect aim). */
15
+ export function jitter(s: Skill, maxPx: number): number {
16
+ return (Math.random() - 0.5) * 2 * maxPx * Math.max(0, Math.min(1, s.aimNoise));
17
+ }
18
+
19
+ /** Whether a bot starts a fight (or a wave, a shot, a tackle) this frame: about `aggression` times a second. */
20
+ export function engages(s: Skill, dt: number): boolean {
21
+ return Math.random() < Math.max(0, Math.min(1, s.aggression)) * Math.max(0, dt);
22
+ }
23
+
24
+ /** How far from the action to stand: `far` at positioning 0 (behind the party), `near` at 1 (the front line). */
25
+ export function standoff(s: Skill, near: number, far: number): number {
26
+ const t = Math.max(0, Math.min(1, s.positioning));
27
+ return far + (near - far) * t;
28
+ }
package/port/touch.ts CHANGED
@@ -113,7 +113,10 @@ function isTouchDevice(): boolean {
113
113
  }
114
114
 
115
115
  export function createTouchControls(opts: TouchOptions = {}): TouchControls {
116
- const enabled = Boolean(opts.force) || isTouchDevice();
116
+ // A spectator (a big screen, or a watcher: NETPLAY.md section 16) plays nothing: no stick, no buttons on its glass.
117
+ const shell = (globalThis as { HOMIE_NET?: { want?: string; watch?: boolean } }).HOMIE_NET;
118
+ const spectator = shell?.watch === true || shell?.want === 'screen';
119
+ const enabled = Boolean(opts.force) || (isTouchDevice() && !spectator);
117
120
  type StickCfg = { zone: [number, number, number, number]; radius: number; dead: number; keys: Partial<Record<Dir, string>> | false; fourWay: boolean };
118
121
  const stickOpt: StickCfg | null = opts.stick === false ? null : { zone: [0, 0.3, 0.55, 1], radius: 56, dead: 7, keys: false, fourWay: false, ...(opts.stick ?? {}) } as StickCfg;
119
122
  const lookOpt = opts.look ? { zone: [0.45, 0, 1, 1] as [number, number, number, number], ...opts.look } : null;
package/site/SITE.md CHANGED
@@ -29,6 +29,8 @@ songs (each with a `cover`), videos (each with a `poster`), the latest posts, an
29
29
  (`homie-studio stats share on`) it also says "played this week": `played` for the whole studio and each game's own
30
30
  (`games[].played`, `{ "days": 7, "plays", "rounds" }`). To keep the studio's rooms off the hub, studio.json takes
31
31
  `"rooms": { "share": false }`: the manifest then names no `rooms` (the studio's own pages still list them).
32
+ Each room in `/api/rooms` carries `play` and, unless its game says `"watch": false`, `watch` (its watch door), so
33
+ the hub offers Watch beside Join.
32
34
 
33
35
  ## A game's landing: `/<id>/`
34
36
 
@@ -164,6 +166,75 @@ The game runs in a sandboxed frame with an opaque origin, which is why its allow
164
166
  (`fullscreen *; autoplay *; gamepad *`): Safari refuses a bare `gamepad` there (`getGamepads()` throws a
165
167
  SecurityError), so a controller would not work.
166
168
 
169
+ ## The watch door: `/<id>/watch?room=<room>`
170
+
171
+ Anyone can watch a live room from any player's view (NETPLAY.md section 16). Every room row (Home, Rooms, a
172
+ game's landing) has a **Watch** button beside Join, and a landing offers "Watch a live room" while somebody plays.
173
+ The page is the game itself, drawn by the watcher's own browser as a watcher: a screen that never takes a seat.
174
+
175
+ - **The top bar:** LIVE, the game and the room, the round's clock, how many are watching, and **Play** (into
176
+ this room). **The caption** says whose view this is ("Watching Velvet Comet", "Auto · following the action").
177
+ - **The strip** at the bottom: **Auto**, each player (their colour, name and live score when the game exposes
178
+ `scores`; the leader's score lit), and **Whole room**. A tap switches; on a computer keys 1-9 pick the n-th
179
+ player, A is Auto, O (or 0) the whole room, the arrows the next or previous player, F full screen. The address
180
+ keeps the view (`&follow=<seat>|auto|overview`), so a copied link watches the same player.
181
+ - It fits a phone (the strip scrolls sideways), a computer, and a TV-sized screen (bigger type; `?hand=tv`). The
182
+ bars fade after five seconds without a touch, a move or a key, and come back with one.
183
+ - With no `room`, it watches the busiest public room (`/<id>/api/watch`, which reserves nothing); with nobody
184
+ playing it says so, offers Play, and finds a room the moment one starts. A room everyone left, a kick, a closed
185
+ room and a full one are each said plainly, with the way on.
186
+ - **What a game decides.** A game that draws `net.viewSeat` lets the watcher switch; one that does not is watched
187
+ as its overview, and the strip only names the players. game.json `"watch": "overview"` keeps watchers on the
188
+ whole room (hidden hands or roles); `"watch": false` removes the door (the page says the game is played, not
189
+ watched, and the socket is refused).
190
+ - **The same door as Play:** a private or invite-only game is watched only by those it lets in, with their
191
+ ticket; a kick holds the browser out of watching that room too. A browser that holds a seat in a room watches
192
+ that room as the whole room only, so a second tab is not a peek.
193
+ - Each opening counts as a `watch` in the studio's stats.
194
+
195
+ ## Servers: `/<id>/servers/` and `/<id>/s/<server>/`
196
+
197
+ A game's **servers** are named, lasting pools of rooms with their own rules (`worker/servers.mjs`; NETPLAY.md
198
+ section 17 for the room's side). Strangers are matched only inside one server. The game's own public rooms are
199
+ its **Quick play** server (`public`), so every old link keeps working.
200
+
201
+ - **The landing's Servers band** shows when the game has a server besides Quick play: a card per server (its
202
+ name, its policy's badge and one line, who is on it now: "9 playing · 4 AI · 41 members", Play and About),
203
+ and "Quick play →" under it. `/<id>/servers/` lists them all (and every policy's line). A server that is
204
+ closed, not listed, or has a door (an account or an invite) is left out of the Rooms lists and `/api/rooms`.
205
+ - **The badge and its line**, everywhere a server is shown: *Open* ("anyone can play. AI players are always
206
+ marked AI."), *Humans only* ("every player here is a person. AI can't join."), *Hybrid · N* ("N seats in every
207
+ room are AI companions, always marked AI. Your party sets their level."), *Beginner* ("for new players. AI guides
208
+ help you learn. Chat is quick lines only."); a kids server adds "Players have handles, not names."
209
+ - **A server's page** (`/<id>/s/<server>/`): its name, blurb, badge and line; its live rooms (AI marked); how many
210
+ belong to it; "☆ Make this my home" for a signed-in player (and Leave); and a big **Play**
211
+ (`/<id>/s/<server>/play`). A player's home server is where a bare `/<id>/play` takes them; their account page
212
+ lists their servers.
213
+ - **The door**, then the play page: *open* lets anyone in; *accounts* asks the visitor to sign in with a passkey
214
+ ("Sign in to play on Night Shift"); *invite* shows the invite-code box (`homie-studio office invite <id> --server
215
+ <server>` makes codes and links for one server); *beginner* lets in guests and accounts younger than its days
216
+ (a veteran is told "First Light is for new players… try another server or Quick play"; a mentor gets in, with a
217
+ Mentor badge). A server whose rooms are all full (its `rooms`) sends the visitor to its fullest room, where they
218
+ wait for a seat ("First Light is full. You're next for a seat."). The owner always comes in. The watch door is
219
+ the same door, except that watching a beginner room needs no new account.
220
+ - **When the owner hides Quick play** (`homie-studio servers close <id> public`), Play shows the servers to pick
221
+ from, and a named `?room=` is off (except for the owner).
222
+ - **The play shell** on a server: a pill beside the room button ("First Light · Beginner"; tap: the policy's line,
223
+ "AI level: Steady" with **Change** when the game's bots read the dial, and **Quiet AI**, which hides AI chat on
224
+ this screen only); the chip says "4 playing · 2 AI"; every AI row and result carries an AI pill.
225
+ - **The vote card** ("How strong should the AI be?", five levels, "the middle vote wins") opens by itself at a
226
+ party's first round with AI seats, from the pill's Change, or from the game; a lone player's tap decides at once,
227
+ every seated person's vote closes it early, else 15 s. Keys 1-5 on a computer; a bottom sheet on a phone. The
228
+ result is a toast ("The party set the AI to Steady (3 votes)."). A game that draws its own card says game.json
229
+ `"agents": { "vote": "game" }` (`false`: none).
230
+ - `/<id>/api/servers` is the servers with their live counts (cacheable 15 s). `/api/rooms` rows say their
231
+ `server`, `policy` and `ai`.
232
+
233
+ **An AI's seat** (an agent pass from `homie-studio agents pass`): `POST /<id>/api/agent` with `Authorization:
234
+ Bearer <pass>` and `{ "server": "<id>" }` answers a room with people in it (never an empty one), a ticket, its
235
+ socket and (an AI that runs the game itself) the frame to load. A humans-only server answers 403 "This server is
236
+ for humans only", and so does its socket.
237
+
167
238
  ## Player accounts: `/account/`
168
239
 
169
240
  A studio whose games keep saves (game.json `"saves": true`, [../saves/SAVES.md](../saves/SAVES.md)) has a player
@@ -14,8 +14,16 @@
14
14
  * The host decides who killed what and sends the killer a `loot` event; only the hero's own browser changes the
15
15
  * hero and saves it. A hardcore hero who falls becomes a memorial in the Hall of the Fallen, and its save is wiped
16
16
  * in the same step (saves.fall). Canvas 2D on purpose: the point is the pattern, in a few hundred lines.
17
+ *
18
+ * A watcher (/<game>/watch, contract revision 5) has no hero and makes none: `room.viewSeat()` is the hero it
19
+ * follows, drawn in gold as a hero's own browser draws itself, with that hero's panel; nobody followed, the vale.
20
+ * The host says who slew what (`slain`), so a watcher on Auto cuts to the kill.
21
+ *
22
+ * Servers and the skill dial (contract revision 6): the vale's bots hunt at the room's dial (`room.skillOf(body)`:
23
+ * how soon they notice a slime, how close they stand, how often they strike), and a beginner server's AI guide seats
24
+ * are kept as AI bodies, marked " · AI". createRoom does the seats; the bots below read the dial.
17
25
  */
18
- import { createControls, createRoom, createSaves, exposePort, q, type BodyBase, type NetEvent } from '@homie-rocks/studio/port';
26
+ import { AI_MARK, createControls, createRoom, createSaves, exposePort, jitter, q, standoff, type BodyBase, type NetEvent } from '@homie-rocks/studio/port';
19
27
 
20
28
  /* ------------------------------------------------------------------ rules */
21
29
  const W = 1600;
@@ -35,11 +43,14 @@ const clamp = (v: number, a: number, b: number): number => Math.max(a, Math.min(
35
43
 
36
44
  /** The hero: the one thing that lasts. Kept in the save key 'hero'. */
37
45
  interface Hero { v: 1; name: string; level: number; xp: number; gold: number; hardcore: boolean; kills: number; deaths: number; born: number }
38
- interface Body extends BodyBase { x: number; y: number; hp: number; maxHp: number; level: number; face: number; flags: number; downUntil: number; atkAt: number; strikeAt: number; tx: number; ty: number }
46
+ interface Body extends BodyBase { x: number; y: number; hp: number; maxHp: number; level: number; face: number; flags: number; downUntil: number; atkAt: number; strikeAt: number; tx: number; ty: number; seenAt?: number; ax?: number; ay?: number }
39
47
  interface Slime { id: number; x: number; y: number; hp: number; maxHp: number; size: number; hitAt: number; lastHit: number }
40
48
  type SlimeRow = [id: number, x: number, y: number, hp: number, size: number];
41
49
 
42
50
  /* ------------------------------------------------------------------ the save */
51
+ /** A spectator (a watcher, or a big screen) has no hero of its own: it never loads, makes or saves one. */
52
+ const shellCfg = (globalThis as { HOMIE_NET?: { want?: string; watch?: boolean } }).HOMIE_NET;
53
+ const lookOnly = shellCfg?.watch === true || shellCfg?.want === 'screen';
43
54
  const saves = createSaves({ game: 'ember-vale' });
44
55
  let hero: Hero | null = null;
45
56
  let heroLoaded = false;
@@ -50,6 +61,7 @@ function saveSoon(): void {
50
61
  saveTimer = setTimeout(() => { if (hero) void saves.set('hero', hero); }, 1000);
51
62
  }
52
63
  async function loadHero(): Promise<void> {
64
+ if (lookOnly) { heroLoaded = true; ui.paint(); return; }
53
65
  const h = await saves.get<Hero>('hero');
54
66
  hero = h && h.v === 1 ? h : null;
55
67
  heroLoaded = true;
@@ -140,6 +152,7 @@ net.on('event', (e: NetEvent) => {
140
152
  const d = (e.d ?? {}) as { xp?: number; gold?: number; cause?: string; to?: number };
141
153
  if (e.k === 'loot' && d.to === room.mySeat()) gain(Math.max(0, Number(d.xp) || 0), Math.max(0, Number(d.gold) || 0));
142
154
  if (e.k === 'down' && d.to === room.mySeat()) void fell(String(d.cause ?? 'a slime').slice(0, 40));
155
+ if (e.k === 'slain' && typeof (e.d as { seat?: unknown })?.seat === 'number') net.spotlight((e.d as { seat: number }).seat);
143
156
  });
144
157
  function tell(b: Body, k: 'loot' | 'down', d: Record<string, unknown>): void {
145
158
  if (b.bot || b.seat === null) return;
@@ -158,6 +171,8 @@ function strike(b: Body, now: number): void {
158
171
  const gold = s.size === 3 ? 50 : 2 + Math.floor(Math.random() * 4) * s.size;
159
172
  b.score += xp;
160
173
  tell(b, 'loot', { xp, gold });
174
+ // Everyone hears who slew it (a watcher on Auto cuts to them); the loot itself is the slayer's alone.
175
+ if (!b.bot && b.seat !== null) room.send('slain', { seat: b.seat });
161
176
  }
162
177
  }
163
178
  slimes = slimes.filter((s) => s.hp > 0);
@@ -179,14 +194,23 @@ function stepHost(dt: number): void {
179
194
  }
180
195
  let wants = false;
181
196
  if (b.bot) {
182
- // Bots are company, not carries: they hunt only what is near them, and strike slower than a person.
197
+ // Bots are company, not carries: they hunt only what is near them, and strike slower than a person. At the
198
+ // room's dial (section 17): they notice a slime `reactionMs` late, hunt farther afield the more they lean to
199
+ // the front (positioning), aim a little off (aimNoise), and strike sooner the more aggressive they are.
200
+ const s = room.skillOf(b);
183
201
  const near = nearestSlime(b.x, b.y);
184
- const t = near && Math.hypot(near.x - b.x, near.y - b.y) < 360 ? near : null;
202
+ const reach = standoff(s, 460, 220);
203
+ if (!b.seenAt || now - b.seenAt >= s.reactionMs) {
204
+ b.seenAt = now;
205
+ const t0 = near && Math.hypot(near.x - b.x, near.y - b.y) < reach ? near : null;
206
+ b.ax = t0 ? t0.x + jitter(s, 40) : undefined; b.ay = t0 ? t0.y + jitter(s, 40) : undefined;
207
+ }
208
+ const t = b.ax !== undefined && b.ay !== undefined ? { x: b.ax, y: b.ay } : null;
185
209
  const tx = t ? t.x : b.tx; const ty = t ? t.y : b.ty;
186
210
  if (!t && Math.hypot(b.tx - b.x, b.ty - b.y) < 30) { b.tx = 200 + Math.random() * (W - 400); b.ty = 150 + Math.random() * (H - 300); }
187
211
  const dx = tx - b.x; const dy = ty - b.y; const dist = Math.hypot(dx, dy) || 1;
188
212
  if (dist > 60) { b.x += (dx / dist) * SPEED * 0.75 * dt; b.y += (dy / dist) * SPEED * 0.75 * dt; b.face = Math.atan2(dy, dx); }
189
- wants = Boolean(t && dist < STRIKE_RANGE && now - b.atkAt > 900);
213
+ wants = Boolean(t && dist < STRIKE_RANGE && now - b.atkAt > 1400 - 900 * s.aggression);
190
214
  } else if (b.seat === myS) {
191
215
  b.x = me.x; b.y = me.y; b.face = me.face; b.level = myLevel(); b.maxHp = maxHpOf(b.level);
192
216
  wants = me.strikes > 0; me.strikes = 0;
@@ -299,10 +323,12 @@ function draw(t: number): void {
299
323
  ctx.fillStyle = 'rgba(0,0,0,.5)'; ctx.fillRect(x - r, y + r + 4, r * 2, 4);
300
324
  ctx.fillStyle = '#ff7a59'; ctx.fillRect(x - r, y + r + 4, (r * 2 * clamp(hp, 0, max)) / max, 4);
301
325
  }
302
- const mySeat = room.mySeat();
326
+ // The hero whose view this is: my own, or the one a watcher follows (null: the vale, nobody in gold).
327
+ const viewS = room.viewSeat();
303
328
  for (const b of room.view()) {
304
- const self = !b.bot && b.seat === mySeat;
305
- const x = self && !room.hosting ? me.x : b.x; const y = self && !room.hosting ? me.y : b.y;
329
+ const self = !b.bot && b.seat === viewS;
330
+ const local = self && !room.hosting && !net.watching;
331
+ const x = local ? me.x : b.x; const y = local ? me.y : b.y;
306
332
  const isDown = Boolean(b.flags & DOWN);
307
333
  ctx.globalAlpha = isDown ? 0.35 : 1;
308
334
  if ((b.flags & STRIKING) || (self && performance.now() - me.flashAt < 160)) { ctx.strokeStyle = 'rgba(255,207,110,.7)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(x, y, STRIKE_RANGE * 0.8, 0, Math.PI * 2); ctx.stroke(); }
@@ -313,7 +339,8 @@ function draw(t: number): void {
313
339
  ctx.fillStyle = '#7ad35a'; ctx.fillRect(x - 24, y - R - 12, (48 * clamp(b.hp, 0, b.maxHp)) / Math.max(1, b.maxHp), 5);
314
340
  ctx.globalAlpha = 1;
315
341
  ctx.font = '600 18px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = self ? '#ffcf6e' : '#e7d9b4';
316
- ctx.fillText(`${b.name}${b.bot ? ' · bot' : ''} · ${b.level}`, x, y - R - 18);
342
+ // An AI's name already ends in " · AI" (a guide, a companion); a plain bot says bot.
343
+ ctx.fillText(`${b.name}${b.bot && !b.name.endsWith(AI_MARK) ? ' · bot' : ''} · ${b.level}`, x, y - R - 18);
317
344
  }
318
345
  ctx.restore();
319
346
  hud(t);
@@ -325,8 +352,23 @@ function hud(t: number): void {
325
352
  const x0 = 14; const y0 = 14;
326
353
  ctx.fillStyle = 'rgba(10,14,9,.62)'; ctx.fillRect(x0, y0, 240, 62);
327
354
  ctx.textAlign = 'left'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#ffcf6e';
328
- ctx.fillText(hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…', x0 + 10, y0 + 22);
329
- if (hero) {
355
+ if (lookOnly) {
356
+ // A watcher's panel is the followed hero's, as the room has them (their save stays in their own browser).
357
+ const v = room.viewBody();
358
+ if (v) {
359
+ ctx.fillText(`${v.name} · Lv ${v.level}`, x0 + 10, y0 + 22);
360
+ ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(x0 + 10, y0 + 32, 140, 6);
361
+ ctx.fillStyle = '#7ad35a'; ctx.fillRect(x0 + 10, y0 + 32, (140 * clamp(v.hp, 0, v.maxHp)) / Math.max(1, v.maxHp), 6);
362
+ ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
363
+ ctx.fillText(`${v.score} xp tonight${v.flags & DOWN ? ' · down' : ''}`, x0 + 10, y0 + 54);
364
+ } else {
365
+ const heroes = room.view().filter((b) => !b.bot).length;
366
+ ctx.fillText('Watching the vale', x0 + 10, y0 + 22);
367
+ ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
368
+ ctx.fillText(`${heroes} ${heroes === 1 ? 'hero' : 'heroes'} · ${(room.fast() ?? []).length} slimes`, x0 + 10, y0 + 46);
369
+ }
370
+ } else ctx.fillText(hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…', x0 + 10, y0 + 22);
371
+ if (hero && !lookOnly) {
330
372
  ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(x0 + 10, y0 + 32, 140, 6);
331
373
  ctx.fillStyle = '#ffcf6e'; ctx.fillRect(x0 + 10, y0 + 32, (140 * hero.xp) / xpFor(hero.level), 6);
332
374
  ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
@@ -341,7 +383,7 @@ function hud(t: number): void {
341
383
  ctx.fillStyle = 'rgba(10,14,9,.78)'; ctx.fillRect(cw / 2 - 150, 44, 300, 30 + rows.length * 22);
342
384
  ctx.fillStyle = '#ffcf6e'; ctx.fillText('Tonight\'s hunters', cw / 2, 66);
343
385
  ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
344
- rows.forEach((r, i) => ctx.fillText(`${r.place}. ${r.name}${r.bot ? ' (bot)' : ''} — ${r.score} xp`, cw / 2, 90 + i * 22));
386
+ rows.forEach((r, i) => ctx.fillText(`${r.place}. ${r.name}${r.bot && !r.name.endsWith(AI_MARK) ? ' (bot)' : ''} — ${r.score} xp`, cw / 2, 90 + i * 22));
345
387
  }
346
388
  if (performance.now() < bannerUntil) { ctx.font = '800 22px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#ffcf6e'; ctx.fillText(bannerText, cw / 2, canvas.height / k - 90); }
347
389
  const st = saves.status();
@@ -359,6 +401,7 @@ const ui = {
359
401
  paint(): void {
360
402
  const p = saves.player;
361
403
  $('who').textContent = p.local ? 'Playing here only' : p.signedIn ? `${p.name}${p.owner ? ' · owner' : ''}` : 'Guest · keep my hero';
404
+ $('who').hidden = lookOnly;
362
405
  },
363
406
  make(): void {
364
407
  ($('hero-name') as HTMLInputElement).value = '';
@@ -416,6 +459,9 @@ function frame(t: number): void {
416
459
  requestAnimationFrame(frame);
417
460
  }
418
461
 
462
+ // Tonight's experience per hero: the watch page's live scores, and who Auto follows when nobody is slaying.
463
+ net.expose({ scores: () => room.view().map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) });
464
+
419
465
  exposePort(net, {
420
466
  view: 'top',
421
467
  self: () => (me.has && room.mySeat() !== null ? { x: me.x, y: me.y } : null),