@homie-rocks/studio 0.15.0 → 0.16.1

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/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/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 }
@@ -91,6 +97,8 @@ export interface Room<B extends BodyBase, F = unknown, S = unknown> {
91
97
  viewSeat(): number | null;
92
98
  /** The body of viewSeat() as everyone draws it (host: the real one; others: interpolated), or null. */
93
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;
94
102
  /** Host: my own body. Replica: null (draw yours from your local state). */
95
103
  mine(): B | null;
96
104
  /** Host, every frame: the round clock and the snapshot. Replica: no-op. Call AFTER your rules moved the bodies. */
@@ -131,7 +139,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
131
139
  const angles = new Set(opts.angles ?? []);
132
140
  const discrete = new Set(opts.discrete ?? []);
133
141
 
134
- 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 });
135
145
  let bodies = new Map<number, B>();
136
146
  let round: RoundInfo | null = null;
137
147
  let hosting = false;
@@ -142,6 +152,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
142
152
  snapshotHz: 20, inputHz: 20, checkpointMs: 1000,
143
153
  checkpoint: () => ({ round, roster: roster.toJSON(), bodies: [...bodies.values()].map((b) => ({ ...b })), world: opts.saveWorld ? opts.saveWorld() : null, tick }),
144
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])],
145
157
  });
146
158
 
147
159
  const mySeat = (): number | null => (net.offline ? 0 : net.seat);
@@ -157,8 +169,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
157
169
  for (const s of roster.slots) {
158
170
  seen.add(s.slot);
159
171
  const b = bodies.get(s.slot);
160
- 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; }
161
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;
162
175
  }
163
176
  for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);
164
177
  }
@@ -185,6 +198,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
185
198
  for (const s of [...roster.slots].sort((a, c) => a.slot - c.slot)) {
186
199
  const fresh = opts.spawn(s, i++);
187
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 };
188
202
  bodies.set(s.slot, fresh);
189
203
  }
190
204
  round = { n, phase: 'live', startedAt: now, endsAt: now + roundMs };
@@ -197,7 +211,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
197
211
 
198
212
  function rank(): RoundResult[] {
199
213
  const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
200
- 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 } : {}) }));
201
215
  }
202
216
 
203
217
  function endRound(): void {
@@ -212,12 +226,12 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
212
226
  function restore(e: RoleChange<RoomSnap<F>, RoomCkpt<B, S>>): void {
213
227
  const ck = e.ckpt?.d ?? null;
214
228
  if (ck) {
215
- roster = Roster.from(ck.roster, { min, max, botName });
229
+ roster = Roster.from(ck.roster, { min, max, botName, policy });
216
230
  bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
217
231
  round = ck.round;
218
232
  tick = ck.tick;
219
233
  } else {
220
- roster = Roster.from(e.roster ?? [], { min, max, botName });
234
+ roster = Roster.from(e.roster ?? [], { min, max, botName, policy });
221
235
  bodies = new Map();
222
236
  syncBodies();
223
237
  }
@@ -252,14 +266,14 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
252
266
  hosting = true;
253
267
  if (e.promoted) restore(e);
254
268
  else {
255
- roster = new Roster({ min, max, botName });
269
+ roster = new Roster({ min, max, botName, policy });
256
270
  bodies = new Map();
257
271
  const seat = mySeat();
258
272
  if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
259
273
  syncBodies();
260
274
  startRound((e.round?.n ?? 0) + 1);
261
275
  }
262
- 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 }));
263
277
  const { claimed } = roster.reconcile(peers);
264
278
  syncBodies();
265
279
  for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) moved(b); }
@@ -274,7 +288,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
274
288
  });
275
289
  net.on('join', (p) => {
276
290
  if (!hosting || p.seat === null) return;
277
- 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);
278
293
  if (!c) return; // full: they watch
279
294
  syncBodies();
280
295
  const b = bodies.get(c.slot.slot);
@@ -291,6 +306,13 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
291
306
  syncBodies();
292
307
  net.roster(roster.toJSON());
293
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
+ });
294
316
  net.on('control', (e) => {
295
317
  if (!e.reset || !opts.adopt) return;
296
318
  const snap = e.snap as Snapshot<RoomSnap<F>> | null;
@@ -335,6 +357,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
335
357
  for (const b of room.view()) if (!b.bot && b.seat === s) return b;
336
358
  return null;
337
359
  },
360
+ skillOf: (body) => net.skillOf(body.slot),
338
361
  mine: () => (hosting ? bodyOfSeat(mySeat()) : null),
339
362
  update() {
340
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/site/SITE.md CHANGED
@@ -157,6 +157,8 @@ where the game has room, per device:
157
157
  - It shows the room's name ("Room 7") for a few seconds, then shrinks to a round icon until it is touched;
158
158
  `"label": false` keeps it the icon always (the code is in its sheet), for a corner with little room.
159
159
  - A button at the bottom left sends the "finding a room" chip to the bottom right.
160
+ - On a server, the server's pill sits beside the button, on its inner side in the same band, never under it: on a
161
+ phone held upright it is one more dot; elsewhere its name shows for a few seconds, then it is a dot too.
160
162
 
161
163
  Look at the play page on a computer and a phone (both ways up) once a round is on: the button must not sit on a
162
164
  score, a timer or a bar. A room code the relay cannot use (1 to 32 letters, digits, `-` or `_`) is refused
@@ -192,6 +194,53 @@ The page is the game itself, drawn by the watcher's own browser as a watcher: a
192
194
  that room as the whole room only, so a second tab is not a peek.
193
195
  - Each opening counts as a `watch` in the studio's stats.
194
196
 
197
+ ## Servers: `/<id>/servers/` and `/<id>/s/<server>/`
198
+
199
+ A game's **servers** are named, lasting pools of rooms with their own rules (`worker/servers.mjs`; NETPLAY.md
200
+ section 17 for the room's side). Strangers are matched only inside one server. The game's own public rooms are
201
+ its **Quick play** server (`public`), so every old link keeps working.
202
+
203
+ - **The landing's Servers band** shows when the game has a server besides Quick play: a card per server (its
204
+ name, its policy's badge and one line, who is on it now: "9 playing · 4 AI · 41 members", Play and About),
205
+ and "Quick play →" under it. `/<id>/servers/` lists them all (and every policy's line). A server that is
206
+ closed, not listed, or has a door (an account or an invite) is left out of the Rooms lists and `/api/rooms`.
207
+ - **The badge and its line**, everywhere a server is shown: *Open* ("anyone can play. AI players are always
208
+ marked AI."), *Humans only* ("every player here is a person. AI can't join."), *Hybrid · N* ("N seats in every
209
+ room are AI companions, always marked AI. Your party sets their level."), *Beginner* ("for new players. AI guides
210
+ help you learn. Chat is quick lines only."); a kids server adds "Players have handles, not names."
211
+ - **A server's page** (`/<id>/s/<server>/`): its name, blurb, badge and line; its live rooms (AI marked); how many
212
+ belong to it; "☆ Make this my home" for a signed-in player (and Leave); and a big **Play**
213
+ (`/<id>/s/<server>/play`). A player's home server is where a bare `/<id>/play` takes them; their account page
214
+ lists their servers.
215
+ - **The door**, then the play page: *open* lets anyone in; *accounts* asks the visitor to sign in with a passkey
216
+ ("Sign in to play on Night Shift"); *invite* shows the invite-code box (`homie-studio office invite <id> --server
217
+ <server>` makes codes and links for one server); *beginner* lets in guests and accounts younger than its days
218
+ (a veteran is told "First Light is for new players… try another server or Quick play"; a mentor gets in, with a
219
+ Mentor badge). A server whose rooms are all full (its `rooms`) sends the visitor to its fullest room, where they
220
+ wait for a seat ("First Light is full. You're next for a seat."). The owner always comes in. The watch door is
221
+ the same door, except that watching a beginner room needs no new account.
222
+ - **When the owner hides Quick play** (`homie-studio servers close <id> public`), Play shows the servers to pick
223
+ from, and a named `?room=` is off (except for the owner).
224
+ - **The play shell** on a server: a pill beside the room button, in the same band at the room button's place
225
+ (`screen.share`), never under it ("First Light · Beginner", then a dot after a few seconds; on a phone held
226
+ upright, the dot from the start; tap: the server's name, the policy's line, "AI level: Steady" with **Change**
227
+ when the game's bots read the dial, and **Quiet AI**, which hides AI chat on this screen only). A server room's
228
+ button says "Room 2", as Quick play's does. The chip says "First Light · 4 playing · 2 AI"; every AI row and result
229
+ carries an AI pill. A game's HUD keeps that band clear (Gem Rush starts its scores under it).
230
+ - **The vote card** ("How strong should the AI be?", five levels, "the middle vote wins") opens by itself at a
231
+ party's first round with AI seats, from the pill's Change, or from the game; a lone player's tap decides at once,
232
+ every seated person's vote closes it early, else 15 s. Keys 1-5 on a computer; a bottom sheet on a phone. The card
233
+ is opaque: nothing of the game shows through it. The
234
+ result is a toast ("The party set the AI to Steady (3 votes)."). A game that draws its own card says game.json
235
+ `"agents": { "vote": "game" }` (`false`: none).
236
+ - `/<id>/api/servers` is the servers with their live counts (cacheable 15 s). `/api/rooms` rows say their
237
+ `server`, `policy` and `ai`.
238
+
239
+ **An AI's seat** (an agent pass from `homie-studio agents pass`): `POST /<id>/api/agent` with `Authorization:
240
+ Bearer <pass>` and `{ "server": "<id>" }` answers a room with people in it (never an empty one), a ticket, its
241
+ socket and (an AI that runs the game itself) the frame to load. A humans-only server answers 403 "This server is
242
+ for humans only", and so does its socket.
243
+
195
244
  ## Player accounts: `/account/`
196
245
 
197
246
  A studio whose games keep saves (game.json `"saves": true`, [../saves/SAVES.md](../saves/SAVES.md)) has a player
@@ -18,8 +18,12 @@
18
18
  * A watcher (/<game>/watch, contract revision 5) has no hero and makes none: `room.viewSeat()` is the hero it
19
19
  * follows, drawn in gold as a hero's own browser draws itself, with that hero's panel; nobody followed, the vale.
20
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.
21
25
  */
22
- 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';
23
27
 
24
28
  /* ------------------------------------------------------------------ rules */
25
29
  const W = 1600;
@@ -39,7 +43,7 @@ const clamp = (v: number, a: number, b: number): number => Math.max(a, Math.min(
39
43
 
40
44
  /** The hero: the one thing that lasts. Kept in the save key 'hero'. */
41
45
  interface Hero { v: 1; name: string; level: number; xp: number; gold: number; hardcore: boolean; kills: number; deaths: number; born: number }
42
- 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 }
43
47
  interface Slime { id: number; x: number; y: number; hp: number; maxHp: number; size: number; hitAt: number; lastHit: number }
44
48
  type SlimeRow = [id: number, x: number, y: number, hp: number, size: number];
45
49
 
@@ -190,14 +194,23 @@ function stepHost(dt: number): void {
190
194
  }
191
195
  let wants = false;
192
196
  if (b.bot) {
193
- // 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);
194
201
  const near = nearestSlime(b.x, b.y);
195
- 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;
196
209
  const tx = t ? t.x : b.tx; const ty = t ? t.y : b.ty;
197
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); }
198
211
  const dx = tx - b.x; const dy = ty - b.y; const dist = Math.hypot(dx, dy) || 1;
199
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); }
200
- wants = Boolean(t && dist < STRIKE_RANGE && now - b.atkAt > 900);
213
+ wants = Boolean(t && dist < STRIKE_RANGE && now - b.atkAt > 1400 - 900 * s.aggression);
201
214
  } else if (b.seat === myS) {
202
215
  b.x = me.x; b.y = me.y; b.face = me.face; b.level = myLevel(); b.maxHp = maxHpOf(b.level);
203
216
  wants = me.strikes > 0; me.strikes = 0;
@@ -326,7 +339,8 @@ function draw(t: number): void {
326
339
  ctx.fillStyle = '#7ad35a'; ctx.fillRect(x - 24, y - R - 12, (48 * clamp(b.hp, 0, b.maxHp)) / Math.max(1, b.maxHp), 5);
327
340
  ctx.globalAlpha = 1;
328
341
  ctx.font = '600 18px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = self ? '#ffcf6e' : '#e7d9b4';
329
- 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);
330
344
  }
331
345
  ctx.restore();
332
346
  hud(t);
@@ -369,7 +383,7 @@ function hud(t: number): void {
369
383
  ctx.fillStyle = 'rgba(10,14,9,.78)'; ctx.fillRect(cw / 2 - 150, 44, 300, 30 + rows.length * 22);
370
384
  ctx.fillStyle = '#ffcf6e'; ctx.fillText('Tonight\'s hunters', cw / 2, 66);
371
385
  ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
372
- 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));
373
387
  }
374
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); }
375
389
  const st = saves.status();
@@ -22,9 +22,16 @@
22
22
  * nobody followed the whole arena shows. Waves call `net.spotlight(seat)`, so a
23
23
  * watcher on Auto cuts to whoever just bumped.
24
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
+ *
25
32
  * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
26
33
  */
27
- import { createNetplay, Roster, q, lerp, capMove, PALETTE, 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';
28
35
  // The port toolkit's probe: what `homie-studio port check` reads for the owner tests (and sandbox + audio shims).
29
36
  import { exposePort } from '@homie-rocks/studio/port';
30
37
 
@@ -48,7 +55,8 @@ const ZONE_MS = 12_000;
48
55
  const colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;
49
56
  const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
50
57
  const botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;
51
- const label = (name: string, bot: boolean): string => (bot ? `${name} · bot` : name);
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);
52
60
 
53
61
  /* ------------------------------------------------------------ wire shapes */
54
62
  /** Snapshot: compact arrays, quantized. ~40 B per body, ~16 B per gem. (Reset epochs ride in the helper's `c` table.) */
@@ -73,10 +81,13 @@ interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[];
73
81
  * Default `owner`: each browser moves its own body and the host bounds it.
74
82
  */
75
83
  const MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(location.search).get('movement') === 'host' ? 'host' : 'owner'; } catch { return 'owner'; } })();
76
- const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'gem-rush', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, checkpoint: () => checkpoint() });
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;
77
88
 
78
89
  /* ------------------------------------------------------------ host state */
79
- let roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName });
90
+ let roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
80
91
  let bodies = new Map<number, Body>();
81
92
  let gems: Gem[] = [];
82
93
  let gemSeq = 0;
@@ -87,6 +98,8 @@ let hosting = false;
87
98
  let cheatResets = 0;
88
99
  let controlResets = 0;
89
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>();
90
103
 
91
104
  /* ------------------------------------------------ this browser's avatar */
92
105
  const me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, knockUntil: 0, has: false };
@@ -152,6 +165,8 @@ function startRound(n: number): void {
152
165
  hostMoved(b);
153
166
  }
154
167
  gems = Array.from({ length: GEM_COUNT }, newGem);
168
+ pickups = new Map();
169
+ sight.clear();
155
170
  round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };
156
171
  setZone(newZone((zone?.n ?? 0) + 1));
157
172
  net.round(round);
@@ -163,7 +178,8 @@ function endRound(): void {
163
178
  if (!round) return;
164
179
  const now = net.now();
165
180
  const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
166
- 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 }));
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 } : {}) }));
167
183
  round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + BREAK_MS, results };
168
184
  net.round(round);
169
185
  }
@@ -173,7 +189,7 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
173
189
  hosting = true;
174
190
  if (e.promoted) restore(e);
175
191
  else {
176
- roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName });
192
+ roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
177
193
  bodies = new Map();
178
194
  const seat = mySeat();
179
195
  if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
@@ -181,7 +197,7 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
181
197
  startRound((e.round?.n ?? 0) + 1);
182
198
  }
183
199
  // Whoever is connected now is who plays: seats that left during the gap become bots.
184
- 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 }));
185
201
  const { claimed } = roster.reconcile(peers);
186
202
  syncBodiesFromRoster();
187
203
  for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) hostMoved(b); }
@@ -198,14 +214,14 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
198
214
  function restore(e: RoleChange<Snap, Ckpt>): void {
199
215
  const ck = e.ckpt?.d ?? null;
200
216
  if (ck) {
201
- 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 });
202
218
  bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
203
219
  gems = ck.gems.map((g) => ({ ...g }));
204
220
  gemSeq = ck.gemSeq;
205
221
  round = ck.round;
206
222
  tick = ck.tick;
207
223
  } else {
208
- 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 });
209
225
  bodies = new Map();
210
226
  syncBodiesFromRoster();
211
227
  }
@@ -250,7 +266,8 @@ net.on('role', (e) => {
250
266
  });
251
267
  net.on('join', (p) => {
252
268
  if (!hosting || p.seat === null) return;
253
- const c = roster.claim(p.seat, p.name);
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);
254
271
  if (!c) return; // full: they watch
255
272
  syncBodiesFromRoster();
256
273
  const b = bodies.get(c.slot.slot);
@@ -265,6 +282,8 @@ net.on('leave', (p) => {
265
282
  syncBodiesFromRoster();
266
283
  publishRoster();
267
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(); });
268
287
  net.on('event', (e) => {
269
288
  if (e.k === 'wave') addWave(e.d as { slot: number });
270
289
  if (e.k === 'knock') addWave(e.d as { slot: number }, true);
@@ -420,28 +439,69 @@ function stepKnocked(b: Body, dt: number): void {
420
439
  b.vx = b.kvx; b.vy = b.kvy;
421
440
  }
422
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
+
423
451
  function stepBots(dt: number): void {
424
452
  const taken = new Set<number>();
425
453
  const now = net.now();
426
454
  for (const b of bodies.values()) {
427
455
  if (!b.bot) continue;
428
456
  if (b.knockUntil > now) { stepKnocked(b, dt); continue; }
429
- let best: Gem | null = null; let bd = Infinity;
430
- for (const g of gems) {
431
- if (taken.has(g.id)) continue;
432
- const d = Math.hypot(g.x - b.x, g.y - b.y);
433
- if (d < bd) { bd = d; best = g; }
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);
434
465
  }
435
- if (best) { taken.add(best.id); b.tx = best.x; b.ty = best.y; }
436
- const dx = b.tx - b.x; const dy = b.ty - b.y; const len = Math.hypot(dx, dy) || 1;
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;
437
474
  const k = Math.min(1, dt * 5);
438
- b.vx += ((dx / len) * BOT_SPEED - b.vx) * k;
439
- b.vy += ((dy / len) * BOT_SPEED - b.vy) * k;
475
+ b.vx += ((dx / len) * speed - b.vx) * k;
476
+ b.vy += ((dy / len) * speed - b.vy) * k;
440
477
  b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.vx * dt));
441
478
  b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.vy * dt));
442
479
  }
443
480
  }
444
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
+
445
505
  function stepHost(dt: number): void {
446
506
  tick += 1;
447
507
  const seat = mySeat();
@@ -483,6 +543,7 @@ function stepHost(dt: number): void {
483
543
  const g = gems[i] as Gem;
484
544
  if (Math.hypot(g.x - b.x, g.y - b.y) < R_AV + R_GEM) {
485
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);
486
547
  gems[i] = newGem();
487
548
  }
488
549
  }
@@ -665,9 +726,12 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
665
726
  const rows = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0)).slice(0, phone ? 4 : 6);
666
727
  ctx.font = `600 ${phone ? 14 : 16}px ui-sans-serif, system-ui, sans-serif`;
667
728
  ctx.textAlign = 'right';
729
+ // The play page's room button (and a server's pill beside it) sit at the top right (game.json screen.share's
730
+ // default): the scores start under that band, so the buttons never cover a score.
731
+ const board = top + 18 + 44;
668
732
  rows.forEach((a, i) => {
669
733
  ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';
670
- 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));
734
+ ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, board + i * (phone ? 20 : 22));
671
735
  });
672
736
  // role badge
673
737
  ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';
@@ -719,6 +783,10 @@ net.expose({
719
783
  },
720
784
  frames: () => frames,
721
785
  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 }))),
786
+ /** Host: gems each slot picked up this round, the round's age, and the dial each bot plays at (the dial's numbers). */
787
+ 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),
788
+ /** Host: when each bot last looked (its reaction clock). */
789
+ sight: () => (hosting ? [...sight.entries()].map(([slot, e]) => ({ slot, at: e.at })) : null),
722
790
  waves: () => wavesSeen,
723
791
  knocks: () => knocksSeen,
724
792
  zone: () => zone,