@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.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
523
|
-
|
|
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
|
|
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;
|
|
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
|
-
|
|
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
|
|
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.
|
|
566
|
-
|
|
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,
|
|
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(
|
|
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':
|
|
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({
|
|
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.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|