@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.
@@ -33,6 +33,12 @@
33
33
  * `net.follow(seat | 'auto' | null)` changes it (the watch page's strip and
34
34
  * keys 1-9, A and O call it), and Auto follows the action: the newest
35
35
  * `net.spotlight(seat)`, else the leader of the game's `scores` probe.
36
+ * - Servers and agent seats (revision 6, NETPLAY.md section 17): `net.policy`
37
+ * is the room's server policy and `net.skillOf(slot)` the skill dial a bot
38
+ * should play at (the party votes it: `net.vote(n)`, `net.openVote()`);
39
+ * an AI's seat is marked (`peer.agent`, `net.isAgent(seat)`, every name
40
+ * ends in " · AI"); `Roster` keeps a hybrid server's AI seats; `net.hushed`
41
+ * is the player's "Quiet AI".
36
42
  *
37
43
  * WHAT IT DOES NOT DO: rendering, physics, input devices, bots. `Roster` below
38
44
  * is the bot-yield bookkeeping a host needs; the bots themselves are the game's.
@@ -40,6 +46,10 @@
40
46
  */
41
47
 
42
48
  export const NETPLAY_VERSION = 1;
49
+ /** The contract revision this helper speaks (NETPLAY.md): its hello says so (`rev`), and so does every build of it. */
50
+ export const NETPLAY_REVISION = 6;
51
+ /** In every bundle that includes the helper: `homie-studio build` reads it to tell the office which revision a build speaks. */
52
+ export const NETPLAY_MARK = 'homie-netplay-rev:6';
43
53
 
44
54
  export type Role = 'host' | 'replica' | 'screen';
45
55
  export type Device = 'phone' | 'desk' | 'tv';
@@ -81,8 +91,93 @@ export interface NetConfig {
81
91
  follow?: number | 'auto' | 'overview';
82
92
  /** What the game lets its watchers see (game.json "watch"): 'follow' (the default) or 'overview'. */
83
93
  watchPolicy?: 'follow' | 'overview';
94
+ /** Revision 6: this frame plays as an AI (an agent pass; the Worker's word). Its hello says so. */
95
+ agent?: { hands: 'self' | 'host'; role: AgentRole };
96
+ /** Revision 6: the play page's "Quiet AI" is on: AI speech is not shown on this browser. */
97
+ hush?: boolean;
84
98
  }
85
99
 
100
+ /* ------------------------------------------------- servers and agent seats (revision 6, section 17) */
101
+
102
+ export type PolicyKind = 'open' | 'humans-only' | 'hybrid' | 'beginner';
103
+ export type AgentRole = 'party' | 'guide' | 'player';
104
+ /** The skill dial: one shape everywhere. Level 3, Fair, is what bots always were. */
105
+ export interface Skill { level: number; name: string; reactionMs: number; aimNoise: number; aggression: number; positioning: number; card?: string }
106
+ /** The room's server policy (the Worker's, applied by the relay) with `skill`: the room's dial now. */
107
+ export interface Policy {
108
+ v: 1;
109
+ at: number;
110
+ /** null: a named room (it takes the public server's rules). */
111
+ server: { id: string; name: string } | null;
112
+ kind: PolicyKind;
113
+ /** Seats kept for AI companions in every room (hybrid; a beginner server may add some). */
114
+ aiSeats: number;
115
+ /** AI guides in every room (beginner). */
116
+ guides: number;
117
+ /** The game's filler bots: 'off' on a humans-only server unless the owner turned practice bots on. */
118
+ bots: 'fill' | 'off';
119
+ /** The server's level (guides play at it); `skill` is the room's dial (the party's vote, or the owner's). */
120
+ level: number;
121
+ levelMax: number;
122
+ /** 'lines': free chat is dropped (quick lines and emotes pass); 'off': all speech is dropped. */
123
+ speech: 'game' | 'lines' | 'off';
124
+ kids: boolean;
125
+ brain: string;
126
+ skill: Skill;
127
+ by?: 'vote' | 'owner';
128
+ }
129
+ /** What a room says about an AI in a seat (`peer.agent`). */
130
+ export interface AgentFacts { pass: string; role: AgentRole; hands: 'self' | 'host'; by: 'studio' | 'guest' | 'service' }
131
+ /** The party's vote on the dial (a `vote` frame): options 1..levelMax, how many chose each, the result once closed. */
132
+ export interface VoteState {
133
+ of: 'skill';
134
+ id: string;
135
+ open: boolean;
136
+ until: number;
137
+ options: number[];
138
+ counts: Record<string, number>;
139
+ voters: number;
140
+ of_total: number;
141
+ result?: { level: number; name: string; votes: number; why?: string };
142
+ reason?: string;
143
+ }
144
+
145
+ /** The dial's five levels (worker/agents.mjs has the same table; a test keeps them equal). */
146
+ export const SKILLS: readonly Skill[] = Object.freeze([
147
+ Object.freeze({ level: 1, name: 'Rookie', reactionMs: 650, aimNoise: 0.55, aggression: 0.1, positioning: 0.1, card: 'stays at the back, misses a lot' }),
148
+ Object.freeze({ level: 2, name: 'Steady', reactionMs: 420, aimNoise: 0.3, aggression: 0.3, positioning: 0.35, card: 'helps, never steals the show' }),
149
+ Object.freeze({ level: 3, name: 'Fair', reactionMs: 250, aimNoise: 0.15, aggression: 0.5, positioning: 0.5, card: 'plays like a regular' }),
150
+ Object.freeze({ level: 4, name: 'Strong', reactionMs: 170, aimNoise: 0.07, aggression: 0.7, positioning: 0.75, card: 'keeps up with good players' }),
151
+ Object.freeze({ level: 5, name: 'Maxed', reactionMs: 110, aimNoise: 0.02, aggression: 0.9, positioning: 0.95, card: 'front-line tank, rarely misses' }),
152
+ ]);
153
+ /** A level as its preset (a copy): 1..5, Fair for anything else; a kids room caps it at 3 and aggression at 0.3. */
154
+ export function skillPreset(n: number, kids = false): Skill {
155
+ const level = Math.max(1, Math.min(kids ? 3 : 5, Math.round(Number(n)) || 3));
156
+ const s = { ...(SKILLS[level - 1] as Skill) };
157
+ if (kids) s.aggression = Math.min(s.aggression, 0.3);
158
+ return s;
159
+ }
160
+ /** The exact mark at the end of every agent's name. */
161
+ export const AI_MARK = ' · AI';
162
+ const AI_TAIL = /[\s·•∙⋅・|:_\-–—(\[{]+(?:a\.?\s?i\.?|bots?)[\s)\]}.!·•∙⋅・|:_\-–—]*$/iu;
163
+ /** A name with every AI or bot mark taken off its end (a person cannot claim one). */
164
+ export function stripAi(name: string): string {
165
+ let s = String(name ?? '').replace(/\s+/g, ' ').trim();
166
+ for (let i = 0; i < 6 && AI_TAIL.test(s); i += 1) s = s.replace(AI_TAIL, '').trim();
167
+ return s.replace(/[\s·•∙⋅・|:_\-–—(\[{]+$/u, '').trim();
168
+ }
169
+ /** An agent's name as every room shows it: its label (16 characters at most) and " · AI". */
170
+ export function aiName(label: string): string {
171
+ const base = [...stripAi(label)].slice(0, 16).join('').trim();
172
+ return `${base || 'Agent'}${AI_MARK}`;
173
+ }
174
+ /** A room's policy before the relay says one (or an older relay that never will): open, bots fill, Fair. */
175
+ export const DEFAULT_POLICY: Policy = Object.freeze({
176
+ v: 1, at: 0, server: null, kind: 'open', aiSeats: 0, guides: 0, bots: 'fill', level: 3, levelMax: 5, speech: 'game', kids: false, brain: 'script',
177
+ skill: SKILLS[2] as Skill,
178
+ }) as Policy;
179
+ const SPEECH_KIND = /^(?:say|chat|emote)/i;
180
+
86
181
  export interface HostRef { id: string; seat: number | null }
87
182
 
88
183
  export interface Peer {
@@ -97,6 +192,8 @@ export interface Peer {
97
192
  muted?: boolean;
98
193
  /** A watcher (section 16): a screen that came to watch; it never takes a seat. */
99
194
  watch?: boolean;
195
+ /** Revision 6: an AI in a seat (always named "<label> · AI"). */
196
+ agent?: AgentFacts;
100
197
  }
101
198
 
102
199
  /** One seat's entry in the body-control table: [seat, rs, own (1|0), ack]. */
@@ -150,7 +247,7 @@ export interface ControlChange<S = unknown> extends Control {
150
247
  }
151
248
  export interface PendingInput<A = unknown> { q: number; a: A; h: string[]; at: number; dt: number }
152
249
 
153
- export interface RoundResult { slot: number; seat: number | null; name: string; score: number; bot: boolean; place: number }
250
+ export interface RoundResult { slot: number; seat: number | null; name: string; score: number; bot: boolean; place: number; /** Revision 6: an AI's row (the relay marks it). */ agent?: true }
154
251
  export interface RoundInfo {
155
252
  n: number;
156
253
  phase: 'live' | 'over';
@@ -160,7 +257,11 @@ export interface RoundInfo {
160
257
  endsAt: number;
161
258
  results?: RoundResult[];
162
259
  }
163
- export interface Slot { slot: number; seat: number | null; name: string; bot: boolean }
260
+ /**
261
+ * A body in the round. Revision 6: `agent` marks an AI's slot: a seat kept for AI (`agent.seat` null: nobody's brain
262
+ * yet, the host's bots move it), or an agent's (its seat; hands `host`: the host's bot code moves it, `bot` stays true).
263
+ */
264
+ export interface Slot { slot: number; seat: number | null; name: string; bot: boolean; agent?: { seat: number | null; role: AgentRole; hands: 'self' | 'host' } }
164
265
 
165
266
  export interface RoleChange<S = unknown, C = unknown> {
166
267
  role: Role;
@@ -262,6 +363,11 @@ export interface NetplayOptions<C = unknown> {
262
363
  WebSocketImpl?: WebSocketCtor;
263
364
  /** Tests / custom shells: where parent notifications go. Default `parent.postMessage` when framed. */
264
365
  post?: ((msg: Record<string, unknown>) => void) | null;
366
+ /**
367
+ * Revision 6: what this game does with servers. 'skill': its bots read the dial (`net.skillOf`); 'agents': its host
368
+ * moves an AI's body with its own bot code (a Roster that passes `p.agent`). Reading the dial declares 'skill' too.
369
+ */
370
+ caps?: ('skill' | 'agents')[];
265
371
  }
266
372
 
267
373
  type WebSocketCtor = new (url: string) => WebSocketLike;
@@ -296,6 +402,10 @@ export interface NetHandlers<S, A, C> {
296
402
  mute: (m: Mute) => void;
297
403
  /** Whose view to draw changed (a watcher's follow, or a player's own seat). Listening says the game draws it. */
298
404
  view: (v: ViewChange) => void;
405
+ /** Revision 6: the room's policy or dial changed (a vote's result, the owner, the server). */
406
+ policy: (p: Policy) => void;
407
+ /** Revision 6: the party's vote on the dial opened, moved or closed. */
408
+ vote: (v: VoteState) => void;
299
409
  }
300
410
 
301
411
  export interface Netplay<S = unknown, A = unknown, C = unknown> {
@@ -362,6 +472,29 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
362
472
  spotlight(seat: number | null): void;
363
473
  /** The seated players, in seat order (the watch strip's order: key 1 is the first). */
364
474
  players(): Peer[];
475
+ /**
476
+ * SERVERS AND AGENT SEATS (revision 6, NETPLAY.md section 17). `policy`: the room's server policy (DEFAULT_POLICY
477
+ * before the relay says one). `skill`: the room's dial now. `skillOf(slot)`: the dial a bot in that slot plays at
478
+ * (a guide plays at the server's level; everyone else at the party's). Reading either tells the room this game's
479
+ * bots read the dial (caps 'skill'), so its play page offers the vote.
480
+ */
481
+ readonly policy: Policy;
482
+ readonly skill: Skill;
483
+ skillOf(slot: number): Skill;
484
+ /** A seated player's vote on the dial (1..levelMax); opens a vote when none is open. False when not sent. */
485
+ vote(n: number): boolean;
486
+ /** Open the party's vote (a dungeon door, a new level): at most once every 2 minutes a room. */
487
+ openVote(of?: 'skill', reason?: string): boolean;
488
+ /** The vote open now (or the last result), or null. */
489
+ readonly voteState: VoteState | null;
490
+ /** The AI peers in the room. */
491
+ agents(): Peer[];
492
+ /** Whether an AI sits in this seat. */
493
+ isAgent(seat: number | null): boolean;
494
+ /** This browser's "Quiet AI": AI speech is dropped before the game hears it. Settable. */
495
+ hushed: boolean;
496
+ /** This browser plays as an AI (the frame of an agent pass). */
497
+ readonly asAgent: boolean;
365
498
  /** Resolves with the first role (welcome, or offline fallback). */
366
499
  readonly ready: Promise<RoleChange<S, C>>;
367
500
  on<K extends keyof NetHandlers<S, A, C>>(kind: K, fn: NetHandlers<S, A, C>[K]): () => void;
@@ -466,8 +599,15 @@ export interface RosterOptions {
466
599
  /** Hard cap on slots. */
467
600
  max: number;
468
601
  botName?: (slot: number) => string;
602
+ /**
603
+ * Revision 6: the room's policy (createRoom passes `() => net.policy`). A hybrid or beginner server keeps
604
+ * `aiSeats + guides` slots for AI (marked `agent`, never given to a person); `bots: 'off'` adds no other filler.
605
+ */
606
+ policy?: () => Policy | null;
469
607
  }
470
608
 
609
+ const copySlot = (s: Slot): Slot => ({ slot: s.slot, seat: s.seat, name: s.name, bot: s.bot, ...(s.agent ? { agent: { seat: s.agent.seat, role: s.agent.role, hands: s.agent.hands } } : {}) });
610
+
471
611
  /**
472
612
  * Host-side bookkeeping for bots that yield their slot to an arriving human.
473
613
  *
@@ -477,12 +617,18 @@ export interface RosterOptions {
477
617
  * bot is left is a new slot added (up to `max`). A human who leaves turns back
478
618
  * into a bot in the same slot, so the round never loses a body mid-play.
479
619
  * `trim()` drops surplus bots between rounds.
620
+ *
621
+ * Revision 6 (section 17): with a `policy`, the seats a server keeps for AI are slots marked `agent` (a bot body
622
+ * until an AI sits; a person never takes one). An agent claims one: hands `host` keeps the body a bot that the
623
+ * host's bot code moves (`agent.seat` says whose), hands `self` drives it like a person. An agent who leaves hands
624
+ * the slot back as a seat kept for AI. `bots: 'off'` (a humans-only server) adds no filler bots at all.
480
625
  */
481
626
  export class Roster {
482
627
  slots: Slot[] = [];
483
628
  readonly min: number;
484
629
  readonly max: number;
485
630
  readonly botName: (slot: number) => string;
631
+ private readonly policyFn: (() => Policy | null) | null;
486
632
  /** seat → the slot it held when it last left */
487
633
  private readonly lastSlot = new Map<number, number>();
488
634
 
@@ -490,20 +636,49 @@ export class Roster {
490
636
  this.min = Math.max(0, opts.min | 0);
491
637
  this.max = Math.max(this.min, opts.max | 0);
492
638
  this.botName = opts.botName ?? ((slot: number) => `Bot ${slot + 1}`);
639
+ this.policyFn = opts.policy ?? null;
493
640
  this.fill();
494
641
  }
495
642
 
496
643
  static from(slots: readonly Slot[], opts: RosterOptions): Roster {
497
644
  const r = new Roster(opts);
498
- r.slots = slots.map((s) => ({ slot: s.slot, seat: s.seat, name: s.name, bot: s.bot }));
645
+ r.slots = slots.map(copySlot);
499
646
  r.fill();
500
647
  return r;
501
648
  }
502
649
 
650
+ private policy(): Policy | null {
651
+ try { return this.policyFn ? this.policyFn() : null; } catch { return null; }
652
+ }
653
+
654
+ /** How many slots this room keeps for AI (revision 6): aiSeats + guides, never every slot. */
655
+ reserved(): number {
656
+ const p = this.policy();
657
+ if (!p || (p.kind !== 'hybrid' && p.kind !== 'beginner')) return 0;
658
+ return Math.max(0, Math.min((p.aiSeats | 0) + (p.guides | 0), this.max - 1));
659
+ }
660
+
661
+ /** Each kept slot's role: the guides first, then the companions. */
662
+ private reserveRole(i: number): AgentRole {
663
+ const p = this.policy();
664
+ return p && i < (p.guides | 0) ? 'guide' : 'party';
665
+ }
666
+
503
667
  fill(): void {
504
- while (this.slots.length < this.min) {
668
+ // The seats kept for AI: bot bodies marked `agent` until an AI sits in one.
669
+ const want = this.reserved();
670
+ let have = this.slots.filter((s) => s.agent).length;
671
+ while (have < want && this.slots.length < this.max) {
505
672
  const slot = this.nextSlotId();
506
- this.slots.push({ slot, seat: null, name: this.botName(slot), bot: true });
673
+ this.slots.push({ slot, seat: null, name: aiName(this.botName(slot)), bot: true, agent: { seat: null, role: this.reserveRole(have), hands: 'host' } });
674
+ have += 1;
675
+ }
676
+ // Filler bots, unless the server turned them off.
677
+ if (this.policy()?.bots !== 'off') {
678
+ while (this.slots.length < this.min) {
679
+ const slot = this.nextSlotId();
680
+ this.slots.push({ slot, seat: null, name: this.botName(slot), bot: true });
681
+ }
507
682
  }
508
683
  this.slots.sort((a, b) => a.slot - b.slot);
509
684
  }
@@ -515,16 +690,42 @@ export class Roster {
515
690
  return id;
516
691
  }
517
692
 
518
- bySeat(seat: number): Slot | undefined { return this.slots.find((s) => s.seat === seat && !s.bot); }
519
- humans(): Slot[] { return this.slots.filter((s) => !s.bot); }
693
+ bySeat(seat: number): Slot | undefined { return this.slots.find((s) => (s.seat === seat && !s.bot) || (s.agent && s.agent.seat === seat)); }
694
+ humans(): Slot[] { return this.slots.filter((s) => !s.bot && !s.agent); }
520
695
  bots(): Slot[] { return this.slots.filter((s) => s.bot); }
696
+ /** Revision 6: the slots of AI (an agent's, or a seat kept for one). */
697
+ agents(): Slot[] { return this.slots.filter((s) => s.agent); }
521
698
 
522
- /** A human takes a slot. Returns the slot, whether it yielded a bot, or null (full: spectate). */
523
- claim(seat: number, name: string): { slot: Slot; yielded: boolean; added: boolean } | null {
699
+ /**
700
+ * A human (or, revision 6, an agent) takes a slot. Returns the slot, whether it yielded a bot, or null (full:
701
+ * spectate). A person never takes a slot kept for AI; an agent takes one first.
702
+ */
703
+ claim(seat: number, name: string, agent?: { role?: AgentRole; hands?: 'self' | 'host' } | null): { slot: Slot; yielded: boolean; added: boolean } | null {
524
704
  const mine = this.bySeat(seat);
525
705
  if (mine) { mine.name = name || mine.name; return { slot: mine, yielded: false, added: false }; }
526
706
  const last = this.lastSlot.get(seat);
527
- const bot = (last !== undefined ? this.slots.find((s) => s.slot === last && s.bot) : undefined) ?? this.slots.find((s) => s.bot);
707
+ const kept = (s: Slot): boolean => Boolean(s.agent && s.agent.seat === null);
708
+ const plainBot = (s: Slot): boolean => s.bot && !s.agent;
709
+ if (agent) {
710
+ const role: AgentRole = agent.role === 'guide' || agent.role === 'player' ? agent.role : 'party';
711
+ const hands = agent.hands === 'host' ? 'host' : 'self';
712
+ const slot = (last !== undefined ? this.slots.find((s) => s.slot === last && (kept(s) || plainBot(s))) : undefined)
713
+ ?? this.slots.find(kept) ?? this.slots.find(plainBot);
714
+ const take = (s: Slot): Slot => {
715
+ s.agent = { seat, role: s.agent?.role ?? role, hands };
716
+ s.name = name || aiName(this.botName(s.slot));
717
+ // hands `host`: still a bot body, moved by the host's bot code; hands `self`: driven like a person's.
718
+ if (hands === 'host') { s.bot = true; s.seat = null; } else { s.bot = false; s.seat = seat; }
719
+ return s;
720
+ };
721
+ if (slot) return { slot: take(slot), yielded: true, added: false };
722
+ if (this.slots.length >= this.max) return null;
723
+ const s = take({ slot: this.nextSlotId(), seat: null, name: '', bot: true });
724
+ this.slots.push(s);
725
+ this.slots.sort((a, b) => a.slot - b.slot);
726
+ return { slot: s, yielded: false, added: true };
727
+ }
728
+ const bot = (last !== undefined ? this.slots.find((s) => s.slot === last && plainBot(s)) : undefined) ?? this.slots.find(plainBot);
528
729
  if (bot) {
529
730
  bot.bot = false; bot.seat = seat; bot.name = name || `Player ${seat + 1}`;
530
731
  return { slot: bot, yielded: true, added: false };
@@ -537,44 +738,59 @@ export class Roster {
537
738
  return { slot: s, yielded: false, added: true };
538
739
  }
539
740
 
540
- /** A human leaves: their slot becomes a bot where it stands. */
741
+ /** A human leaves: their slot becomes a bot where it stands. An agent's goes back to a seat kept for AI. */
541
742
  release(seat: number): Slot | null {
542
743
  const s = this.bySeat(seat);
543
744
  if (!s) return null;
544
745
  this.lastSlot.set(seat, s.slot);
545
- s.bot = true; s.seat = null; s.name = this.botName(s.slot);
746
+ s.bot = true; s.seat = null;
747
+ if (s.agent) {
748
+ const keep = this.slots.filter((x) => x.agent && x !== s).length < this.reserved();
749
+ if (keep) { s.agent = { seat: null, role: s.agent.role, hands: 'host' }; s.name = aiName(this.botName(s.slot)); } else { delete s.agent; s.name = this.botName(s.slot); }
750
+ } else s.name = this.botName(s.slot);
546
751
  return s;
547
752
  }
548
753
 
549
- /** Between rounds: remove bots beyond `min` slots. Returns the removed slot ids. */
754
+ /** Between rounds: remove bots beyond `min` slots (the seats kept for AI stay). Returns the removed slot ids. */
550
755
  trim(): number[] {
551
756
  const removed: number[] = [];
552
- for (let i = this.slots.length - 1; i >= 0 && this.slots.length > this.min; i -= 1) {
757
+ const floor = this.policy()?.bots === 'off' ? 0 : this.min;
758
+ let kept = this.slots.filter((s) => s.agent && s.agent.seat === null).length;
759
+ const surplusKept = (): boolean => kept > this.reserved();
760
+ for (let i = this.slots.length - 1; i >= 0; i -= 1) {
553
761
  const s = this.slots[i];
554
- if (s && s.bot) { removed.push(s.slot); this.slots.splice(i, 1); }
762
+ if (!s || !s.bot) continue;
763
+ const isKept = Boolean(s.agent && s.agent.seat === null);
764
+ if (s.agent && !isKept) continue; // an agent's own body (hands host) stays while it is here
765
+ if (isKept ? surplusKept() : this.slots.length > Math.max(floor, this.reserved())) {
766
+ removed.push(s.slot); this.slots.splice(i, 1);
767
+ if (isKept) kept -= 1;
768
+ }
555
769
  }
770
+ this.fill();
556
771
  return removed;
557
772
  }
558
773
 
559
- /** After a promotion: make the roster agree with who is actually connected. */
560
- reconcile(peers: Iterable<{ seat: number | null; name: string }>): { claimed: Slot[]; released: Slot[] } {
561
- const here = new Map<number, string>();
562
- for (const p of peers) if (p.seat !== null && p.seat !== undefined) here.set(p.seat, p.name);
774
+ /** After a promotion: make the roster agree with who is actually connected (an agent with its facts). */
775
+ reconcile(peers: Iterable<{ seat: number | null; name: string; agent?: AgentFacts | null }>): { claimed: Slot[]; released: Slot[] } {
776
+ const here = new Map<number, { name: string; agent?: AgentFacts | null }>();
777
+ for (const p of peers) if (p.seat !== null && p.seat !== undefined) here.set(p.seat, { name: p.name, agent: p.agent ?? null });
563
778
  const released: Slot[] = [];
564
779
  const claimed: Slot[] = [];
565
- for (const s of this.humans()) {
566
- if (s.seat !== null && !here.has(s.seat)) { const r = this.release(s.seat); if (r) released.push(r); }
780
+ for (const s of this.slots.filter((x) => !x.bot || (x.agent && x.agent.seat !== null))) {
781
+ const seat = s.agent && s.agent.seat !== null ? s.agent.seat : s.seat;
782
+ if (seat !== null && !here.has(seat)) { const r = this.release(seat); if (r) released.push(r); }
567
783
  }
568
- for (const [seat, name] of here) {
784
+ for (const [seat, p] of here) {
569
785
  if (this.bySeat(seat)) continue;
570
- const c = this.claim(seat, name);
786
+ const c = this.claim(seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);
571
787
  if (c) claimed.push(c.slot);
572
788
  }
573
789
  this.fill();
574
790
  return { claimed, released };
575
791
  }
576
792
 
577
- toJSON(): Slot[] { return this.slots.map((s) => ({ ...s })); }
793
+ toJSON(): Slot[] { return this.slots.map(copySlot); }
578
794
  }
579
795
 
580
796
  /* --------------------------------------------------------------- the client */
@@ -586,7 +802,9 @@ const LADDER = [250, 500, 1000, 2000, 4000];
586
802
  * second of frames at once), and a kick that ended the page's play for good left a phone frozen with no word.
587
803
  * After one it comes back (its seat token keeps its body), sending at the slow rate below.
588
804
  */
589
- const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed', 'watch-off']);
805
+ const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed', 'watch-off', 'agent-pass', 'agents-off', 'agents-unsupported']);
806
+ /** An AI closed for being alone in a room (`agents-alone`, section 17) waits this long before it knocks again. */
807
+ const AGENTS_ALONE_WAIT_MS = 30_000;
590
808
  /**
591
809
  * Input pacing (NETPLAY.md §5): at most this many `in` frames in any rolling second from this browser, whatever
592
810
  * the frame rate or the press rate: two thirds of the relay's cap of 60, so frames that reach it bunched (a
@@ -667,6 +885,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
667
885
  const handlers: { [K in keyof NetHandlers<S, A, C>]: Set<NetHandlers<S, A, C>[K]> } = {
668
886
  role: new Set(), join: new Set(), leave: new Set(), input: new Set(), event: new Set(),
669
887
  snapshot: new Set(), control: new Set(), state: new Set(), round: new Set(), roster: new Set(), status: new Set(), announce: new Set(), mute: new Set(), view: new Set(),
888
+ policy: new Set(), vote: new Set(),
670
889
  };
671
890
  const emit = <K extends keyof NetHandlers<S, A, C>>(kind: K, arg: Parameters<NetHandlers<S, A, C>[K]>[0]): void => {
672
891
  for (const fn of [...handlers[kind]]) {
@@ -785,6 +1004,16 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
785
1004
  let wishAt = 0;
786
1005
  let lastViewPost = '';
787
1006
 
1007
+ // servers and agent seats (section 17)
1008
+ let policy: Policy = DEFAULT_POLICY;
1009
+ let voteState: VoteState | null = null;
1010
+ /** What this game does with servers: its own word, and 'skill' once it reads the dial. Sent to the relay when hosting. */
1011
+ const caps = new Set<string>((opts.caps ?? []).filter((k) => k === 'skill' || k === 'agents'));
1012
+ let capsSent = '';
1013
+ const asAgent = cfg?.agent && typeof cfg.agent === 'object' ? { hands: cfg.agent.hands === 'host' ? 'host' : 'self', role: cfg.agent.role ?? 'party' } : null;
1014
+ let hushed = cfg?.hush === true;
1015
+ let aloneUntil = 0;
1016
+
788
1017
  const wall = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
789
1018
  const now = (): number => (offline ? Date.now() : Date.now() + offset);
790
1019
 
@@ -978,6 +1207,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
978
1207
  sentHist.length = 0;
979
1208
  }
980
1209
  post?.({ what: 'role', role: next, prev, why, seat });
1210
+ if (next === 'host') { capsSent = ''; sendCaps(); }
981
1211
  resolveReady(e);
982
1212
  emit('role', e);
983
1213
  resolveView('seat');
@@ -1002,7 +1232,23 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1002
1232
  case 'welcome': return onWelcome(m);
1003
1233
  case 'snap': return onSnap(m, text.length);
1004
1234
  case 'in': return onInput(m);
1005
- case 'ev': emit('event', { k: String(m['k'] ?? ''), d: m['d'], from: typeof m['from'] === 'number' ? m['from'] : null, ...(typeof m['id'] === 'string' ? { id: m['id'] } : {}) }); return;
1235
+ case 'ev': {
1236
+ const k = String(m['k'] ?? '');
1237
+ const from = typeof m['from'] === 'number' ? m['from'] : null;
1238
+ // Quiet AI (section 17): this browser does not hear an AI's speech.
1239
+ if (hushed && SPEECH_KIND.test(k) && from !== null && api.isAgent(from)) return;
1240
+ emit('event', { k, d: m['d'], from, ...(typeof m['id'] === 'string' ? { id: m['id'] } : {}) });
1241
+ return;
1242
+ }
1243
+ case 'policy': {
1244
+ if (m['policy'] && typeof m['policy'] === 'object') { policy = readPolicy(m['policy']); emit('policy', policy); post?.({ what: 'policy', policy }); }
1245
+ return;
1246
+ }
1247
+ case 'vote': {
1248
+ voteState = readVote(m);
1249
+ if (voteState) { emit('vote', voteState); post?.({ what: 'vote', vote: voteState }); }
1250
+ return;
1251
+ }
1006
1252
  case 'state': if (typeof m['k'] === 'string') applyState(m['k'], m['d']); return;
1007
1253
  case 'pong': return onPong(m);
1008
1254
  case 'announce': {
@@ -1092,6 +1338,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1092
1338
  const code = String(m['code'] ?? '');
1093
1339
  // Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
1094
1340
  if ((code === 'rate' && m['of'] === 'in') || code === 'flood') inSlowUntil = wall() + IN_SLOW_MS;
1341
+ // An AI alone in a room (nobody seated): it knocks again only after a while (section 17).
1342
+ if (code === 'agents-alone') aloneUntil = wall() + AGENTS_ALONE_WAIT_MS;
1343
+ if (code === 'vote') { warnOnce('vote', m['message']); return; }
1095
1344
  if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
1096
1345
  console.warn('[netplay] relay refused:', code, m['message']);
1097
1346
  // The same seat opened in another tab, a full room, a version the relay does not speak: reconnecting
@@ -1135,6 +1384,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1135
1384
  relayFollow = !w || w.follow !== false;
1136
1385
  relayWhy = w && typeof w.why === 'string' ? w.why : null;
1137
1386
  }
1387
+ // Revision 6: the room's policy and an open vote (an older relay says neither: open, Fair).
1388
+ if (m['policy'] && typeof m['policy'] === 'object') { policy = readPolicy(m['policy']); queueMicrotask(() => { emit('policy', policy); post?.({ what: 'policy', policy }); }); }
1389
+ if (m['vote'] && typeof m['vote'] === 'object') voteState = readVote(m['vote'] as Record<string, unknown>);
1138
1390
  const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? null;
1139
1391
  let snap = (m['snap'] as Snapshot<S> | null) ?? null;
1140
1392
  const a = m['announce'] as Announcement | undefined;
@@ -1413,7 +1665,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1413
1665
  ws = sock;
1414
1666
  sock.onopen = () => {
1415
1667
  lastMsgAt = wall();
1416
- raw({ t: 'hello', v: NETPLAY_VERSION, token, name: name || undefined, device, want, canHost, game: opts.game, max: opts.maxPlayers, ...(watching ? { watch: true } : {}) });
1668
+ raw({
1669
+ t: 'hello', v: NETPLAY_VERSION, rev: NETPLAY_REVISION, token, name: name || undefined, device, want, canHost: asAgent && asAgent.hands === 'host' ? false : canHost, game: opts.game, max: opts.maxPlayers,
1670
+ ...(watching ? { watch: true } : {}), ...(caps.size ? { caps: [...caps] } : {}), ...(asAgent ? { agent: asAgent } : {}),
1671
+ });
1417
1672
  };
1418
1673
  sock.onmessage = (ev) => onMessage(ev.data);
1419
1674
  sock.onclose = () => lost(sock, 'closed');
@@ -1424,7 +1679,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1424
1679
  if (closed) return;
1425
1680
  // A hidden tab does not play and its timers crawl: reconnect when it is looked at again.
1426
1681
  if (hidden()) { waitingVisible = true; return; }
1427
- const wait = LADDER[Math.min(attempt, LADDER.length - 1)] as number;
1682
+ const wait = Math.max(LADDER[Math.min(attempt, LADDER.length - 1)] as number, aloneUntil - wall());
1428
1683
  attempt += 1;
1429
1684
  reconnects += 1;
1430
1685
  setTimeout(open, wait);
@@ -1434,7 +1689,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1434
1689
  // No shell (a plain file, a dev server): the game is its own host, offline.
1435
1690
  queueMicrotask(() => goOfflineHost('offline'));
1436
1691
  } else {
1437
- post?.({ what: 'attached', v: NETPLAY_VERSION });
1692
+ post?.({ what: 'attached', v: NETPLAY_VERSION, rev: NETPLAY_REVISION, mark: NETPLAY_MARK });
1438
1693
  open();
1439
1694
  // A relay that never answers must not leave a game on a black screen. But the wait is counted only while
1440
1695
  // this page is able to listen: a phone compiling shaders under load is blocked for seconds at a time, and
@@ -1517,6 +1772,50 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1517
1772
  } catch { /* not a browser */ }
1518
1773
  }
1519
1774
 
1775
+ // The play page's "Quiet AI" (section 17): { t: 'homie-hush', on }, from the page around the frame only.
1776
+ try {
1777
+ g.addEventListener?.('message', (ev: MessageEvent) => {
1778
+ if (ev.source !== g.parent || !ev.data || typeof ev.data !== 'object') return;
1779
+ const d = ev.data as { t?: unknown; on?: unknown };
1780
+ if (d.t === 'homie-hush') hushed = d.on === true;
1781
+ });
1782
+ } catch { /* not a browser */ }
1783
+
1784
+ /** Tell the relay what this game does with servers (only a host's word counts), once per change. */
1785
+ function sendCaps(): void {
1786
+ if (role !== 'host' || offline || !connected || !caps.size) return;
1787
+ const list = [...caps].sort();
1788
+ const sig = list.join(',');
1789
+ if (sig === capsSent) return;
1790
+ if (raw({ t: 'caps', caps: list })) capsSent = sig;
1791
+ }
1792
+ function declare(k: 'skill' | 'agents'): void {
1793
+ if (caps.has(k)) { if (role === 'host' && capsSent === '') sendCaps(); return; }
1794
+ caps.add(k);
1795
+ sendCaps();
1796
+ }
1797
+ function readPolicy(raw0: unknown): Policy {
1798
+ const p = (raw0 ?? {}) as Partial<Policy>;
1799
+ const kids = p.kids === true;
1800
+ const levelMax = Math.max(1, Math.min(kids ? 3 : 5, Number(p.levelMax) || 5));
1801
+ const sk = p.skill && typeof p.skill === 'object' ? (p.skill as Skill) : skillPreset(Number(p.level) || 3, kids);
1802
+ return {
1803
+ ...DEFAULT_POLICY, ...p,
1804
+ kind: p.kind === 'humans-only' || p.kind === 'hybrid' || p.kind === 'beginner' ? p.kind : 'open',
1805
+ aiSeats: Math.max(0, Number(p.aiSeats) || 0), guides: Math.max(0, Number(p.guides) || 0), bots: p.bots === 'off' ? 'off' : 'fill',
1806
+ level: Math.max(1, Math.min(levelMax, Number(p.level) || 3)), levelMax, kids,
1807
+ skill: { ...skillPreset(Number(sk.level) || 3, kids), ...sk },
1808
+ } as Policy;
1809
+ }
1810
+ function readVote(m: Record<string, unknown>): VoteState | null {
1811
+ if (!Array.isArray(m['options']) || typeof m['id'] !== 'string') return null;
1812
+ return {
1813
+ of: 'skill', id: String(m['id']), open: m['open'] === true, until: Number(m['until']) || 0, options: (m['options'] as unknown[]).map(Number).filter(Number.isFinite),
1814
+ counts: (m['counts'] && typeof m['counts'] === 'object' ? m['counts'] : {}) as Record<string, number>, voters: Number(m['voters']) || 0, of_total: Number(m['of_total']) || 0,
1815
+ ...(m['result'] && typeof m['result'] === 'object' ? { result: m['result'] as VoteState['result'] } : {}), ...(typeof m['reason'] === 'string' ? { reason: m['reason'] } : {}),
1816
+ };
1817
+ }
1818
+
1520
1819
  // ---------------------------------------------------------------- API
1521
1820
  const api: Netplay<S, A, C> = {
1522
1821
  get role() { return role; },
@@ -1563,6 +1862,33 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1563
1862
  if (following === 'auto') resolveView('auto');
1564
1863
  },
1565
1864
  players: seated,
1865
+ get policy() { return policy; },
1866
+ get skill() { declare('skill'); return policy.skill; },
1867
+ skillOf(slot: number): Skill {
1868
+ declare('skill');
1869
+ const s = (slots ?? []).find((x) => x.slot === slot);
1870
+ // A guide plays at the server's own level; everyone else at the party's dial.
1871
+ if (s && s.agent && s.agent.role === 'guide') return skillPreset(policy.level, policy.kids);
1872
+ return policy.skill;
1873
+ },
1874
+ vote(n: number): boolean {
1875
+ if (offline || seat === null || watching || asAgent) return false;
1876
+ return raw({ t: 'vote', of: 'skill', n: Math.floor(Number(n)) });
1877
+ },
1878
+ openVote(_of = 'skill', reason?: string): boolean {
1879
+ if (offline || (seat === null && role !== 'host') || watching || asAgent) return false;
1880
+ return raw({ t: 'vote', of: 'skill', open: true, ...(reason ? { reason: String(reason).slice(0, 40) } : {}) });
1881
+ },
1882
+ get voteState() { return voteState; },
1883
+ agents(): Peer[] { return [...peers.values()].filter((p) => Boolean(p.agent)); },
1884
+ isAgent(s: number | null): boolean {
1885
+ if (s === null || s === undefined) return false;
1886
+ for (const p of peers.values()) if (p.seat === s && p.agent) return true;
1887
+ return false;
1888
+ },
1889
+ get hushed() { return hushed; },
1890
+ set hushed(on: boolean) { hushed = Boolean(on); },
1891
+ get asAgent() { return Boolean(asAgent); },
1566
1892
  ready,
1567
1893
  on(kind, fn) {
1568
1894
  (handlers[kind] as Set<unknown>).add(fn);
@@ -1609,7 +1935,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1609
1935
  emit('round', r);
1610
1936
  },
1611
1937
  roster(s) {
1612
- slots = s.map((x) => ({ slot: x.slot, seat: x.seat, name: x.name, bot: x.bot }));
1938
+ slots = s.map((x) => ({ slot: x.slot, seat: x.seat, name: x.name, bot: x.bot, ...(x.agent ? { agent: { seat: x.agent.seat, role: x.agent.role, hands: x.agent.hands } } : {}) }));
1613
1939
  post?.({ what: 'roster', slots });
1614
1940
  if (role === 'host') raw({ t: 'roster', slots });
1615
1941
  },
@@ -1728,6 +2054,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1728
2054
  get roster() { return slots; },
1729
2055
  get watching() { return watching; },
1730
2056
  get viewSeat() { return viewNow; },
2057
+ get policy() { return policy; },
2058
+ get vote() { return voteState; },
2059
+ get revision() { return NETPLAY_REVISION; },
2060
+ get agent() { return asAgent; },
1731
2061
  get following() { return following; },
1732
2062
  get state() { return Object.fromEntries(stateMap); },
1733
2063
  get lastCheckpoint() { return lastCkpt ? { k: lastCkpt.k, st: lastCkpt.st } : null; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
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",