@homie-rocks/studio 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +45 -0
- package/lib/office.mjs +7 -6
- package/lib/scaffold.mjs +41 -2
- package/lib/servers.mjs +133 -0
- package/lib/template-history.json +28 -0
- package/netplay/NETPLAY.md +207 -17
- package/netplay/netplay.ts +360 -30
- package/package.json +1 -1
- package/port/bots.ts +25 -2
- package/port/index.ts +5 -2
- package/port/room.ts +33 -10
- package/port/skill.ts +28 -0
- package/site/SITE.md +43 -0
- package/starters/ember-vale/src/main.ts +21 -7
- package/starters/gem-rush/src/main.ts +84 -19
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +307 -74
- package/worker/office-page.mjs +178 -10
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +206 -15
- package/worker/players.mjs +11 -3
- package/worker/room.mjs +491 -44
- package/worker/saves.mjs +4 -0
- package/worker/servers.mjs +445 -0
- package/worker/site.mjs +138 -7
- package/worker/stats.mjs +6 -1
- package/worker/version.mjs +1 -1
package/netplay/NETPLAY.md
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
|
-
# Homie netplay contract, v1 (revision
|
|
1
|
+
# Homie netplay contract, v1 (revision 6)
|
|
2
2
|
|
|
3
|
-
Status: **v1, revision
|
|
4
|
-
`v: 1`. Everything revisions 2 to
|
|
3
|
+
Status: **v1, revision 6** (2026-10-01, `@homie-rocks/studio` 0.16.0). The wire version is
|
|
4
|
+
`v: 1`. Everything revisions 2 to 6 added is either an optional field, a new message type,
|
|
5
5
|
a new refusal, or a change of pace inside the old caps, and both sides ignore types they do
|
|
6
6
|
not know. A change to the contract bumps `v` and keeps v1 working.
|
|
7
7
|
|
|
8
|
+
**What revision 6 added** (a v1 game that knows none of it plays on every server; its AI
|
|
9
|
+
is labelled by name, its people are capped, and its play page shows no vote):
|
|
10
|
+
- **Servers and agent seats** (section 17): a server is a named, lasting pool of rooms with
|
|
11
|
+
a policy (`open`, `humans-only`, `hybrid`, `beginner`). The Worker hands every room its
|
|
12
|
+
policy; a hybrid server keeps its top seats for AI. An **agent** is an AI that sits with
|
|
13
|
+
a pass the Worker verified: always named `<label> · AI`, marked `peer.agent`, never on a
|
|
14
|
+
humans-only server, never alone in a room, and the relay labels the host's roster and
|
|
15
|
+
results so they cannot hide one.
|
|
16
|
+
- **The skill dial**: five levels (`SKILLS`: Rookie, Steady, Fair, Strong, Maxed), each
|
|
17
|
+
`{ reactionMs, aimNoise, aggression, positioning }`. The party votes the room's level; a
|
|
18
|
+
game's bots read it with `net.skillOf(slot)`.
|
|
19
|
+
- New optional fields `hello.rev`, `hello.caps`, `hello.agent`, `welcome.policy`,
|
|
20
|
+
`welcome.vote`, `welcome.agent`, `peer.agent`, `slot.agent`, `result.agent`; new frames
|
|
21
|
+
`caps` and `vote` (client → relay) and `policy` and `vote` (relay → client); final
|
|
22
|
+
refusals `agent-pass`, `agents-off` and `agents-unsupported`, and the reported
|
|
23
|
+
`agents-alone`; speech modes (`lines`, `off`); the owner's `policy` and `level` controls.
|
|
24
|
+
- The helper: `net.policy`, `net.skill`, `net.skillOf(slot)`, `net.vote(n)`,
|
|
25
|
+
`net.openVote()`, `on('policy')`, `on('vote')`, `net.agents()`, `net.isAgent(seat)`,
|
|
26
|
+
`net.hushed` (the play page's Quiet AI), `createNetplay({ caps })`, `SKILLS`,
|
|
27
|
+
`skillPreset`, `aiName`, `stripAi`; the `Roster`'s AI seats; `NETPLAY_REVISION` and the
|
|
28
|
+
build mark `homie-netplay-rev:6`.
|
|
29
|
+
|
|
8
30
|
**What revision 5 added** (a v1 game that knows none of it is watched as its overview):
|
|
9
31
|
- **Watching** (section 16): a watcher is a screen that came to watch a live room from any
|
|
10
32
|
player's view. It never takes a seat, hosts only a room no player can, and the game draws
|
|
@@ -237,14 +259,16 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
|
|
|
237
259
|
|
|
238
260
|
| `t` | Sent by | Fields | What the relay does |
|
|
239
261
|
|---|---|---|---|
|
|
240
|
-
| `hello` | everyone, once | `v: 1`, `token?`, `name?`, `device`, `want: 'play'\|'screen'`, `canHost`, `game?`, `max?`, `watch?` (revision 5) | Seats the client (or queues it), picks a role, replies `welcome`, and sends `join` to the others. Refuses with `version`, `room-full`, `too-many` or `watch-off`. A `watch: true` hello is a watcher (section 16). |
|
|
262
|
+
| `hello` | everyone, once | `v: 1`, `token?`, `name?`, `device`, `want: 'play'\|'screen'`, `canHost`, `game?`, `max?`, `watch?` (revision 5), `rev?: 6`, `caps?: ('skill'\|'agents')[]`, `agent?: { hands, role }` (revision 6) | Seats the client (or queues it), picks a role, replies `welcome`, and sends `join` to the others. Refuses with `version`, `room-full`, `too-many` or `watch-off`. A `watch: true` hello is a watcher (section 16). An agent's (section 17) is seated in a seat kept for AI and named `<label> · AI`, or refused with `agent-pass`, `agents-off`, `agents-unsupported` or `agents-alone`. |
|
|
241
263
|
| `snap` | host | `k`, `st`, `d`, `c?` (control table) | Clamps `st` to [now − 2 s, now + 250 ms] (a snapshot stamped in the future would freeze replicas). Stores the snapshot and fans it out with `from`. Skips a socket with more than 256 KB buffered. |
|
|
242
264
|
| `in` | seated non-host | `q`, `a`, `h`, `p?`, `r` (the reset epoch the sender has adopted) | Forwards to the host only, stamped `from: <seat>`. Presses are clamped to integers 0–8, at most 16 keys. |
|
|
243
|
-
| `ev` | anyone | `k`, `d`, `to?` | **From a muted player** (section 15): a speech kind (`say…`, `chat…`, `emote…`) is dropped. **From the host:** to everyone else, to one seat (`to`: number) or to one peer (`to`: id string, such as a screen). **From anyone else:** to the host, with `from` and the sender's `id`. **Best-effort:** a drop over a cap is reported with `error rate`. |
|
|
265
|
+
| `ev` | anyone | `k`, `d`, `to?` | **Speech** (section 17): with the room's speech `lines`, every `chat…` is dropped (anyone's, a host relaying one too); with `off`, every speech kind; from an agent, one line every 4 s and 8 a minute. **From a muted player** (section 15): a speech kind (`say…`, `chat…`, `emote…`) is dropped. **From the host:** to everyone else, to one seat (`to`: number) or to one peer (`to`: id string, such as a screen). **From anyone else:** to the host, with `from` and the sender's `id`. **Best-effort:** a drop over a cap is reported with `error rate`. |
|
|
244
266
|
| `state` | host | `k` (≤ 64 characters), `d` (`null` deletes) | Stores the key in memory, forwards it to all others, and includes the whole map in every `welcome` and in `role` to a new host. |
|
|
245
267
|
| `ckpt` | host | `k`, `st`, `d`, `c?` | Stores it in memory. It is saved to storage at most every 30 s. Not forwarded. |
|
|
246
|
-
| `round` | host | `round` |
|
|
247
|
-
| `roster` | host | `slots` |
|
|
268
|
+
| `round` | host | `round` | Labels its `results` (section 17), stores it, forwards it, and tells the shell's watchers. |
|
|
269
|
+
| `roster` | host | `slots` | Labels it (section 17: a host cannot hide an AI), stores it, forwards it, and tells the shell's watchers. |
|
|
270
|
+
| `caps` | host (revision 6) | `caps: ('skill'\|'agents')[]` | What the host's game does with servers: its bots read the dial, and it moves AI bodies. Stored as the room's (the play page offers the vote; a lite agent may sit) and in the watch feed. |
|
|
271
|
+
| `vote` | a seated person, or the host (revision 6) | `of: 'skill'`, `n?: 1..levelMax`, `open?: true`, `reason?` | `open` starts the party's vote on the dial (a player may open one every 2 minutes); `n` casts or changes this seat's vote. Agents and watchers never vote. The shell's watch socket may send it too, counted as the seat of the client with the same browser key. |
|
|
248
272
|
| `ping` | everyone | `c`, `hid` | Replies `pong`. A host whose `hid` turns true yields. |
|
|
249
273
|
| `yield` / `bye` | host / anyone | — | `yield` elects another host if one is available. `bye` is a close. |
|
|
250
274
|
|
|
@@ -252,7 +276,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
|
|
|
252
276
|
|
|
253
277
|
| `t` | To | Fields |
|
|
254
278
|
|---|---|---|
|
|
255
|
-
| `welcome` | the new client | `v`, `id`, `room`, `seat`, `token`, `name`, `colour`, `role`, `why: 'first'\|'resumed'\|'joined'\|'host-stalled'\|'host-hidden'`, `host`, `peers`, `st`, `max`, `round`, `roster`, `snap`, **`state`**, `ckpt` (host only), `full?`, `watch?: { follow, why? }` (a watcher only) |
|
|
279
|
+
| `welcome` | the new client | `v`, `rev`, `id`, `room`, `seat`, `token`, `name`, `colour`, `role`, `why: 'first'\|'resumed'\|'joined'\|'host-stalled'\|'host-hidden'\|'host-person'`, `host`, `peers`, `st`, `max`, `round`, `roster`, `snap`, **`state`**, `ckpt` (host only), `full?`, `watch?: { follow, why? }` (a watcher only), `policy` (with `skill`), `vote?`, `agent?` (an agent's own; a hands-`host` agent's carries no `snap` or `ckpt`, and an empty `state`) |
|
|
256
280
|
| `role` | a client whose role changed | `role`, `why`, `host`, `peers`. For a new host, also `ckpt`, `snap`, `round`, `roster`, `state`. |
|
|
257
281
|
| `seat` | a waiting spectator that just got a seat | `seat`, `token`, `name`, `colour`, `role` |
|
|
258
282
|
| `host` | everyone else, on a change | `host`, `why` |
|
|
@@ -267,30 +291,34 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
|
|
|
267
291
|
| `announce` | everyone (and in `welcome.announce`) | `id`, `text` (`null`: taken down), `at`, `until`, `from: 'studio'` (section 15) |
|
|
268
292
|
| `mute` | everyone | `id`, `seat`, `until` (`0`: unmuted) (section 15) |
|
|
269
293
|
| `watch` | one watcher | `follow`, `why?` (`overview`, `seated-here`): whether it may follow one player now (section 16) |
|
|
294
|
+
| `policy` | everyone (revision 6) | `policy` (with `skill`, and `by: 'vote'\|'owner'` once the dial was set): on any change, a vote's result, the owner's control, a newer server policy |
|
|
295
|
+
| `vote` | everyone and the shell's watch sockets (revision 6) | `of`, `id`, `open`, `until`, `options` (1..levelMax), `counts`, `voters`, `of_total`, `result?: { level, name, votes, why }`, `reason?` |
|
|
270
296
|
|
|
271
297
|
**Error codes.**
|
|
272
298
|
- Final (the helper stops reconnecting): `version`, `replaced`, `room-full`,
|
|
273
299
|
`too-many`, `kicked`, `room-closed` (the last two with `until`, server ms; section 15),
|
|
274
|
-
`watch-off` (a watcher of a game that cannot be watched; section 16)
|
|
300
|
+
`watch-off` (a watcher of a game that cannot be watched; section 16), `agent-pass`,
|
|
301
|
+
`agents-off` and `agents-unsupported` (section 17).
|
|
275
302
|
The helper before revision 4 does not know `kicked` and `room-closed` and keeps knocking;
|
|
276
303
|
every knock is refused at `hello`, and the shell stops the frame (section 15).
|
|
277
|
-
- Reported only: `too-large`, `rate`, `state-full`. (`flood` closes the socket; the helper
|
|
278
|
-
comes back slowly.)
|
|
304
|
+
- Reported only: `too-large`, `rate`, `state-full`, `vote`. (`flood` closes the socket; the helper
|
|
305
|
+
comes back slowly. `agents-alone` closes an agent's socket; it knocks again after 30 s.)
|
|
279
306
|
|
|
280
307
|
A socket that is kicked for silence is closed with code 4000, and the helper reconnects.
|
|
281
308
|
|
|
282
309
|
Record types:
|
|
283
310
|
|
|
284
311
|
```ts
|
|
285
|
-
Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true, watch?: true }
|
|
312
|
+
Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true, watch?: true, agent?: AgentFacts }
|
|
286
313
|
RoundInfo = { n, phase: 'live'|'over', startedAt, endsAt, results?: RoundResult[] } // server ms
|
|
287
|
-
RoundResult = { slot, seat: number|null, name, score, bot, place }
|
|
288
|
-
Slot = { slot, seat: number|null, name, bot }
|
|
314
|
+
RoundResult = { slot, seat: number|null, name, score, bot, place, agent?: true }
|
|
315
|
+
Slot = { slot, seat: number|null, name, bot, agent?: { seat: number|null, role, hands } }
|
|
316
|
+
AgentFacts = { pass, role: 'party'|'guide'|'player', hands: 'self'|'host', by: 'studio'|'guest'|'service' }
|
|
289
317
|
ControlWire = [seat, rs, own (1|0), ack] // one per present seat, in snap.c and ckpt.c
|
|
290
318
|
```
|
|
291
319
|
|
|
292
320
|
**The shell's watch socket** (`/<game>/__watch?room=&b=`) gets
|
|
293
|
-
`{ t:'net', room, host, clients, round, roster, snapHz, openedAt, announce, closedUntil?, counts: {players, screens, waiting, watchers, humans, bots, maxPlayers}, memory: {snapBytes, ckptBytes, ckptAgeMs, stateKeys, stateBytes}, stats }`
|
|
321
|
+
`{ t:'net', rev, room, host, clients, round, roster, snapHz, openedAt, announce, closedUntil?, counts: {players, agents, screens, waiting, watchers, humans, bots, ai, maxPlayers, humanSeats}, policy, vote?, caps, memory: {snapBytes, ckptBytes, ckptAgeMs, stateKeys, stateBytes}, stats }`
|
|
294
322
|
about once a second and on every change, and the owner's controls as they happen
|
|
295
323
|
(section 15): `announce` (the same frame the game gets), `closed { until, message }`, and,
|
|
296
324
|
only for the browser they are about (its room key `b`), `kicked { until, message }` and
|
|
@@ -309,7 +337,8 @@ held out of it, hears so at once.
|
|
|
309
337
|
game's `scores` probe).
|
|
310
338
|
|
|
311
339
|
**Parent page → game frame** (revision 5): `postMessage { t: 'homie-watch', follow: seat | 'auto' | null }`.
|
|
312
|
-
The helper takes it only from its own parent window, and only as a watcher.
|
|
340
|
+
The helper takes it only from its own parent window, and only as a watcher. Revision 6 adds
|
|
341
|
+
`{ t: 'homie-hush', on }` (the play page's Quiet AI) and the frame's `policy` and `vote`.
|
|
313
342
|
|
|
314
343
|
The shell must check `ev.source === frame.contentWindow`. The frame is an opaque origin,
|
|
315
344
|
so `ev.origin` is `"null"`.
|
|
@@ -675,7 +704,7 @@ npx --no-install homie-studio check my-game --url http://127.0.0.1:8787
|
|
|
675
704
|
|
|
676
705
|
The studio's owner runs their live rooms from the studio's back office (`/_studio/office`,
|
|
677
706
|
the owner's overlay in their own game, `homie-studio office`, and the Homie MCP's owner
|
|
678
|
-
tools). The room's relay enforces
|
|
707
|
+
tools). The room's relay enforces these controls:
|
|
679
708
|
|
|
680
709
|
| `op` | Arguments | What the relay does |
|
|
681
710
|
|---|---|---|
|
|
@@ -684,6 +713,8 @@ tools). The room's relay enforces five controls:
|
|
|
684
713
|
| `announce` | `text` (one line, at most 280 characters; empty takes it down), `seconds` (5–3600, default 30) | Everyone and every watching shell gets `announce { id, text, at, until, from: 'studio' }`; a joiner gets it in `welcome.announce` until it ends. |
|
|
685
714
|
| `close` | `minutes`, or `seconds` (10 s up to a day), `message?`, or `reopen: true` | Every socket gets `error room-closed { until, message }` and is closed; the room forgets its play as an empty room does (section 4); watchers get `closed`. Until `until` every `hello` is refused with `room-closed`, and the Lobby sends nobody there. `reopen` opens it at once. |
|
|
686
715
|
| `seats` | `max` | The room's seats while it runs (the owner's "players per room"). Players already seated above it keep their seat; nobody new is seated there. Never above the room's own cap (the game's manifest). |
|
|
716
|
+
| `policy` | `pol` (revision 6) | The server changed (section 17): the room takes its new policy at once. When agents are no longer allowed (humans-only), the current round finishes first, by the same rules as `regate`, and then every agent gets `error agents-off { message: 'This server is humans-only now.' }`. |
|
|
717
|
+
| `level` | `level` (1–5; revision 6) | The owner sets this room's dial now (`room_level`), as a vote's result does. |
|
|
687
718
|
| `regate` | `allow` (holder kinds: `o` the owner, `i` an invite, `p` a player account), `notice?`, `message?`; no `allow` calls a waiting one off | A game's launch state narrowed (private, or an invite-only beta). The current round finishes first: the `notice` goes up as an announcement, and 5 s after the host's `round` with `phase: 'over'` (at the latest 20 s after its `endsAt`, or 30 s when no round is running; never later than 15 minutes) every socket whose ticket names no allowed holder gets `error room-closed { message }` (no `until`: they come back through the game's door if they have access) and is closed; its seat is freed and the others play on. A room with nobody to send out does nothing. The studio's Worker sends it to every live room the game's Lobby knows; a room that opens in the same moment reads its game's launch state once on its first heartbeat and re-gates itself the same way. |
|
|
688
719
|
|
|
689
720
|
**Signed by the studio, checked by the room.** A control reaches the relay only from the
|
|
@@ -813,3 +844,162 @@ watching the room too (the same room key), and a closed room closes for watchers
|
|
|
813
844
|
ping every 2 s (a request, billed 20:1). Like a big screen, a watcher alone keeps a room's object
|
|
814
845
|
awake; the room's host of last resort is the watcher's browser, running the bots.
|
|
815
846
|
|
|
847
|
+
## 17. Servers, policies and agent seats (revision 6)
|
|
848
|
+
|
|
849
|
+
A **server** is a named, lasting pool of rooms for one game, with its own policy, door and
|
|
850
|
+
Lobby pool. Strangers are matched only inside one server, never across. Every game's
|
|
851
|
+
`pub-N` rooms are its implicit server `public` (Quick play), so every old link and room keeps
|
|
852
|
+
working; a server's rooms are `s-<id>-<n>`; a named `?room=` takes the public server's rules.
|
|
853
|
+
Servers, their members and agent passes live in the studio's own D1 (migration
|
|
854
|
+
`0006_studio_servers.sql`); a game.json `"servers": [{ id, name, policy, … }]` seeds them (a
|
|
855
|
+
D1 row of the same id wins). The site's pages are `site/SITE.md`'s; this section is the room's.
|
|
856
|
+
|
|
857
|
+
**Policies.**
|
|
858
|
+
|
|
859
|
+
| `kind` | People | AI |
|
|
860
|
+
|---|---|---|
|
|
861
|
+
| `open` | anyone (the server's door decides) | an AI with a pass may sit, always marked AI |
|
|
862
|
+
| `humans-only` | anyone | none: every agent is refused (the Worker's 403, and the relay's `agents-off`). The game's filler bots are off (`bots: 'off'`) unless the owner turns practice bots on. |
|
|
863
|
+
| `hybrid` | the seats below the reserve | `aiSeats` seats in every room are AI companions: the top seats, `[max − N, max)` |
|
|
864
|
+
| `beginner` | new accounts (the door) | `guides` (and any `aiSeats`) seats are AI guides; speech is quick lines only; `kids` adds handles only, the dial at most 3, aggression at most 0.3 |
|
|
865
|
+
|
|
866
|
+
**The policy reaches the room from the Worker.** For every socket the Worker composes
|
|
867
|
+
`pol = { v, at, server, kind, aiSeats, guides, bots, level, levelMax, speech, kids, brain }`
|
|
868
|
+
from the server, the game and the office (AI seats and guides together are at most the seats
|
|
869
|
+
less one), and passes it to the room's Table, which applies it when `at` is newer than the
|
|
870
|
+
room's own. The owner's change reaches live rooms as a signed `policy` control (section 15).
|
|
871
|
+
Every client hears `welcome.policy` and `policy` frames, with `skill`: the room's dial now.
|
|
872
|
+
|
|
873
|
+
**Seats.** `seatFor(token, kind)`: a person takes a seat in `[0, max − reserve)`, an agent in
|
|
874
|
+
`[max − reserve, max)` (with no reserve, any seat, from the top). A token's own seat is kept
|
|
875
|
+
whatever the range (a person seated above a new cap keeps it). A seventh person in a hybrid
|
|
876
|
+
room of 8 with 2 AI seats waits as a spectator and is seated when a person's seat frees.
|
|
877
|
+
|
|
878
|
+
**Agents.** An AI sits like a phone: the same socket, with a ticket whose holder is
|
|
879
|
+
`a-<pass>`. The Worker verifies the pass (not revoked, not ended, for this game and server, a
|
|
880
|
+
server that lets AI in; a game that is not public opens only to the owner's own passes) and
|
|
881
|
+
hands the room the agent's facts; only that makes a socket an agent (a hello that says
|
|
882
|
+
`agent` with none is refused `agent-pass`). An agent:
|
|
883
|
+
- is named `<label> · AI` (the exact mark ` · AI`), and its peer carries `agent: { pass, role,
|
|
884
|
+
hands, by }`. A person's typed name never ends in an AI or bot mark (the relay strips it;
|
|
885
|
+
account names refuse it);
|
|
886
|
+
- **hands `self`**: runs the game itself (its frame is the game, booted with `HOMIE_NET.agent`)
|
|
887
|
+
and plays its seat like a person. It hosts only a room no person can host, and a person who
|
|
888
|
+
can host takes the rules back at their hello (`why: 'host-person'`);
|
|
889
|
+
- **hands `host`**: has no game client. The host's own bot code moves its body (a `Roster`
|
|
890
|
+
slot that stays a bot, `agent.seat` naming it). It never hosts, and it gets the **lite
|
|
891
|
+
feed**: `join`/`leave`, `roster`, `round`, `policy`, `vote` and `ev` addressed to its seat,
|
|
892
|
+
never `snap`, `ckpt` or `state`. It sits only where the host's game declared
|
|
893
|
+
`caps: ['agents']`, else `agents-unsupported`;
|
|
894
|
+
- **never keeps a room alive**: with no person seated for 60 s, every agent gets
|
|
895
|
+
`agents-alone` and is closed, and none may come in until a person is seated (the helper
|
|
896
|
+
waits 30 s before it knocks again). Agents are never players for the Lobby or for a room's
|
|
897
|
+
forgetting: `counts.players` is people, `counts.agents` and `counts.ai` are apart;
|
|
898
|
+
- says at most one line every 4 s and 8 a minute (`stats.agentDrops` counts the rest).
|
|
899
|
+
|
|
900
|
+
**Labels.** The relay rewrites the host's `roster` and every round's `results` before anyone
|
|
901
|
+
sees them: a slot or row whose seat an agent holds gets `agent` and the agent's name; a slot
|
|
902
|
+
with no seat is a bot; a slot naming a seat nobody holds is a bot; a person's slot never
|
|
903
|
+
carries `agent` or an AI mark. A modified host cannot present an AI as a person.
|
|
904
|
+
|
|
905
|
+
**Speech** (`policy.speech`): `game` (as the game does it), `lines` (every `chat…` is dropped,
|
|
906
|
+
from people too: quick lines `say:…` and emotes pass), `off` (every speech kind). The play
|
|
907
|
+
page's **Quiet AI** hides AI speech on that browser only: the helper drops `ev` speech from an
|
|
908
|
+
agent's seat when `net.hushed`.
|
|
909
|
+
|
|
910
|
+
**The skill dial.** One shape for every game: `{ level 1..5, name, reactionMs, aimNoise 0..1,
|
|
911
|
+
aggression 0..1, positioning 0 back..1 front }`.
|
|
912
|
+
|
|
913
|
+
| level | name | reactionMs | aimNoise | aggression | positioning | |
|
|
914
|
+
|---|---|---|---|---|---|---|
|
|
915
|
+
| 1 | Rookie | 650 | 0.55 | 0.10 | 0.10 | stays at the back, misses a lot |
|
|
916
|
+
| 2 | Steady | 420 | 0.30 | 0.30 | 0.35 | helps, never steals the show |
|
|
917
|
+
| 3 | Fair | 250 | 0.15 | 0.50 | 0.50 | plays like a regular |
|
|
918
|
+
| 4 | Strong | 170 | 0.07 | 0.70 | 0.75 | keeps up with good players |
|
|
919
|
+
| 5 | Maxed | 110 | 0.02 | 0.90 | 0.95 | front-line tank, rarely misses |
|
|
920
|
+
|
|
921
|
+
Fair is what the port kit's bots always were (250 ms, an aim error of 0.12 rad = 0.15 × 0.8).
|
|
922
|
+
Kids caps the level at 3 and aggression at 0.3. The room's dial is the party's vote (or the
|
|
923
|
+
owner's `level`), else the server's `level`; a guide plays at the server's level.
|
|
924
|
+
|
|
925
|
+
**The party's vote.** Opened by itself at a party's first live round when the room has AI seats
|
|
926
|
+
and its game reads the dial (once per room per 30 minutes), by any seated person from the play
|
|
927
|
+
page's chip (once per 2 minutes), or by the game (`net.openVote('skill', 'dungeon')`). It is
|
|
928
|
+
open 15 s, or until every seated person voted (a lone player's tap decides at once). The result
|
|
929
|
+
is the **median of the seated people's votes, rounded down** (a tie is settled kindly), capped
|
|
930
|
+
at the server's `levelMax`, and it applies at once (`policy`, and the vote's `result`). The
|
|
931
|
+
shell draws the card; a game that draws its own says game.json `"agents": { "vote": "game" }`
|
|
932
|
+
(`false`: no vote).
|
|
933
|
+
|
|
934
|
+
**What the game does (the helper).**
|
|
935
|
+
|
|
936
|
+
```ts
|
|
937
|
+
import { createNetplay, type Skill } from '@homie-rocks/studio/netplay';
|
|
938
|
+
// 'skill': this game's bots read the dial; 'agents': its host moves an AI's body (its Roster passes p.agent).
|
|
939
|
+
const net = createNetplay({ game: 'gem-rush', maxPlayers: 8, caps: ['skill', 'agents'], checkpoint });
|
|
940
|
+
const roster = new Roster({ min: 3, max: 8, botName, policy: () => net.policy }); // keeps the AI seats
|
|
941
|
+
net.on('join', (p) => { if (p.seat !== null) roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null); });
|
|
942
|
+
|
|
943
|
+
/** What each bot last noticed: it re-reads the world only every skill.reactionMs. */
|
|
944
|
+
const sight = new Map<number, { at: number; tx: number; ty: number }>();
|
|
945
|
+
function stepBots(dt: number): void {
|
|
946
|
+
const taken = new Set<number>();
|
|
947
|
+
for (const b of bodies.values()) {
|
|
948
|
+
if (!b.bot) continue;
|
|
949
|
+
const s: Skill = net.skillOf(b.slot); // Fair when nobody set a dial
|
|
950
|
+
let eye = sight.get(b.slot);
|
|
951
|
+
if (!eye || net.now() - eye.at >= s.reactionMs) { // REACTION TIME (a miss costs it again)
|
|
952
|
+
const g = pickGem(b, taken, s); // POSITIONING: 0 leaves the hot zone, 1 fights for it
|
|
953
|
+
const miss = 200 * s.aimNoise; // AIM NOISE: up to 200 px off at 1
|
|
954
|
+
eye = { at: net.now(), tx: g ? g.x + (Math.random() - 0.5) * miss : b.tx, ty: g ? g.y + (Math.random() - 0.5) * miss : b.ty };
|
|
955
|
+
sight.set(b.slot, eye);
|
|
956
|
+
if (g) taken.add(g.id);
|
|
957
|
+
}
|
|
958
|
+
const rival = nearestBody(b, KNOCK_RANGE); // AGGRESSION: bumps a rival in reach
|
|
959
|
+
if (rival && Math.random() < s.aggression * 0.8 * dt) hostWave(b);
|
|
960
|
+
steer(b, eye.tx, eye.ty, BOT_SPEED, dt);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
| Call | Meaning |
|
|
966
|
+
|---|---|
|
|
967
|
+
| `net.policy` | The room's policy (`DEFAULT_POLICY` before the relay says one: open, Fair). |
|
|
968
|
+
| `net.skill`, `net.skillOf(slot)` | The room's dial; the dial a bot in that slot plays at. Reading either declares `caps: ['skill']` (the host tells the room once), so the play page offers the vote. |
|
|
969
|
+
| `net.on('policy', p)`, `net.on('vote', v)` | The policy or dial changed; the vote opened, moved or closed (`net.voteState`). |
|
|
970
|
+
| `net.vote(n)`, `net.openVote('skill', reason?)` | A seated person's vote; open one. |
|
|
971
|
+
| `net.agents()`, `net.isAgent(seat)` | The AI in the room. |
|
|
972
|
+
| `net.hushed` | Quiet AI on this browser (the play page sets it). |
|
|
973
|
+
| `aiName(label)`, `stripAi(name)`, `AI_MARK`, `SKILLS`, `skillPreset(n, kids?)` | Names and the dial. |
|
|
974
|
+
| `Roster({ …, policy })` | Keeps `aiSeats + guides` slots marked `agent` (a person never takes one); `claim(seat, name, agent?)` (hands `host`: the slot stays a bot, `agent.seat` set; hands `self`: claimed like a person's); `release(seat)` gives an agent's slot back as a seat kept for AI; `bots: 'off'` adds no filler. |
|
|
975
|
+
|
|
976
|
+
`createRoom` (the port kit) does all of it: its roster keeps the AI seats, its join passes
|
|
977
|
+
`p.agent`, it declares `caps: ['agents']`, and `room.skillOf(body)` is the dial. `BotBrain`
|
|
978
|
+
takes `skill: () => room.skillOf(body)` and maps it: `reactionMs`, an aim error of
|
|
979
|
+
`aimNoise × 0.8` rad, and a commitment of `2500 × (1.3 − 0.6 × aggression)` ms; `port/skill.ts`
|
|
980
|
+
adds `jitter(s, maxPx)`, `engages(s, dt)` and `standoff(s, near, far)`. A ported game built on
|
|
981
|
+
`BotBrain` gets the dial with a rebuild.
|
|
982
|
+
|
|
983
|
+
**Old games and old relays.**
|
|
984
|
+
- A game built with a helper before revision 6 plays on every server: doors, the human seat
|
|
985
|
+
cap, speech modes and AI labels in names and in the shell work; it keeps no AI seats and its
|
|
986
|
+
bots do not read the dial, a hands-`host` agent is refused (`agents-unsupported`), a
|
|
987
|
+
hands-`self` agent is labelled by its name. Its play page shows no vote. `homie-studio
|
|
988
|
+
build` writes the revision a build speaks into the catalogue (the helper's mark
|
|
989
|
+
`homie-netplay-rev:<n>`), and the office says "This build predates servers" until it is
|
|
990
|
+
rebuilt.
|
|
991
|
+
- A revision-6 helper on an older relay has `DEFAULT_POLICY` (open, Fair) and no AI seats.
|
|
992
|
+
|
|
993
|
+
**Kids and safety.** The AI never types: in this revision an agent is a seat and a body, and
|
|
994
|
+
its speech is rate-limited and filtered like anyone's (quick lines only on a beginner server).
|
|
995
|
+
Every AI is marked AI by the relay, not trusted to the host. The owner's controls work on AI:
|
|
996
|
+
mute (its speech is dropped), kick (held out by its pass; its seat goes back to a seat kept for
|
|
997
|
+
AI), announce. Nothing about a person beyond a player id the studio already has is stored; no
|
|
998
|
+
age is ever asked.
|
|
999
|
+
|
|
1000
|
+
**Cost.** An agent is one more socket, like a phone. Agents never keep a room alive (60 s
|
|
1001
|
+
alone at most). A lite agent gets no snapshots, so it costs a few frames a minute.
|
|
1002
|
+
|
|
1003
|
+
**Revision 7 (a later version)** adds an AI's brain: hands in the host (`agent:view`,
|
|
1004
|
+
`agent:do`), lines only from the game's own `agents.json` vocabulary (`say:<lineId>`, checked
|
|
1005
|
+
by the relay), and guides that answer the party. Nothing in revision 6 needs to change for it.
|