@homie-rocks/studio 0.14.4 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -6
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +48 -0
- package/lib/office.mjs +7 -6
- package/lib/scaffold.mjs +50 -3
- package/lib/servers.mjs +133 -0
- package/lib/template-history.json +55 -0
- package/netplay/NETPLAY.md +317 -21
- package/netplay/netplay.ts +649 -33
- package/package.json +1 -1
- package/port/bots.ts +25 -2
- package/port/hud.ts +10 -7
- package/port/index.ts +5 -2
- package/port/room.ts +48 -10
- package/port/skill.ts +28 -0
- package/port/touch.ts +4 -1
- package/site/SITE.md +71 -0
- package/starters/ember-vale/src/main.ts +58 -12
- package/starters/gem-rush/src/main.ts +128 -33
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +353 -68
- package/worker/office-page.mjs +178 -10
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +622 -18
- package/worker/players.mjs +11 -3
- package/worker/room.mjs +547 -41
- package/worker/saves.mjs +4 -0
- package/worker/servers.mjs +445 -0
- package/worker/site.mjs +166 -10
- package/worker/stats.mjs +9 -3
- package/worker/version.mjs +1 -1
package/netplay/netplay.ts
CHANGED
|
@@ -27,6 +27,18 @@
|
|
|
27
27
|
* that went quiet, reconnects with its seat token.
|
|
28
28
|
* - Reports role/seat/rtt/snapshot rate to the parent shell by postMessage
|
|
29
29
|
* (for the debug strip) and on `window.__homieNet` (for e2e probes).
|
|
30
|
+
* - Watching (revision 5, NETPLAY.md section 16): a watcher is a screen that
|
|
31
|
+
* never takes a seat. `net.viewSeat` is whose view to draw (a player's own
|
|
32
|
+
* seat; for a watcher, the player it follows, or null for the overview),
|
|
33
|
+
* `net.follow(seat | 'auto' | null)` changes it (the watch page's strip and
|
|
34
|
+
* keys 1-9, A and O call it), and Auto follows the action: the newest
|
|
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".
|
|
30
42
|
*
|
|
31
43
|
* WHAT IT DOES NOT DO: rendering, physics, input devices, bots. `Roster` below
|
|
32
44
|
* is the bot-yield bookkeeping a host needs; the bots themselves are the game's.
|
|
@@ -34,10 +46,30 @@
|
|
|
34
46
|
*/
|
|
35
47
|
|
|
36
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';
|
|
37
53
|
|
|
38
54
|
export type Role = 'host' | 'replica' | 'screen';
|
|
39
55
|
export type Device = 'phone' | 'desk' | 'tv';
|
|
40
56
|
export type Want = 'play' | 'screen';
|
|
57
|
+
/** Whose view a watcher draws: a seat, `'auto'` (the action), or `null` (the overview camera). */
|
|
58
|
+
export type Follow = number | 'auto' | null;
|
|
59
|
+
/**
|
|
60
|
+
* Why the view changed: `start` (the first), `seat` (a player's own seat), `asked` (the watcher or its page chose),
|
|
61
|
+
* `auto` (Auto moved to the action), `left` (the followed player left; Auto took over), `back` (they came back),
|
|
62
|
+
* `policy` (the room or the game allows the overview only now, or again more).
|
|
63
|
+
*/
|
|
64
|
+
export type ViewWhy = 'start' | 'seat' | 'asked' | 'auto' | 'left' | 'back' | 'policy';
|
|
65
|
+
export interface ViewChange {
|
|
66
|
+
/** The seat whose camera and HUD to draw; null: the overview camera. */
|
|
67
|
+
seat: number | null;
|
|
68
|
+
/** What was asked: a seat, 'auto', or null (the whole room). */
|
|
69
|
+
following: Follow;
|
|
70
|
+
prev: number | null;
|
|
71
|
+
why: ViewWhy;
|
|
72
|
+
}
|
|
41
73
|
/** Who moves a seated body by default. `owner`: the seat's own browser (instant). `host`: the host's rules. */
|
|
42
74
|
export type Movement = 'owner' | 'host';
|
|
43
75
|
|
|
@@ -53,8 +85,99 @@ export interface NetConfig {
|
|
|
53
85
|
device?: Device;
|
|
54
86
|
want?: Want;
|
|
55
87
|
debug?: boolean;
|
|
88
|
+
/** Revision 5: this browser watches (a screen that never takes a seat), from the game's watch door. */
|
|
89
|
+
watch?: boolean;
|
|
90
|
+
/** Whose view the watcher starts on: a seat number, 'auto' (the default) or 'overview'. */
|
|
91
|
+
follow?: number | 'auto' | 'overview';
|
|
92
|
+
/** What the game lets its watchers see (game.json "watch"): 'follow' (the default) or 'overview'. */
|
|
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;
|
|
56
98
|
}
|
|
57
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
|
+
|
|
58
181
|
export interface HostRef { id: string; seat: number | null }
|
|
59
182
|
|
|
60
183
|
export interface Peer {
|
|
@@ -67,6 +190,10 @@ export interface Peer {
|
|
|
67
190
|
role: Role;
|
|
68
191
|
/** Muted by the studio's owner (section 15): hide this player's chat and emotes. */
|
|
69
192
|
muted?: boolean;
|
|
193
|
+
/** A watcher (section 16): a screen that came to watch; it never takes a seat. */
|
|
194
|
+
watch?: boolean;
|
|
195
|
+
/** Revision 6: an AI in a seat (always named "<label> · AI"). */
|
|
196
|
+
agent?: AgentFacts;
|
|
70
197
|
}
|
|
71
198
|
|
|
72
199
|
/** One seat's entry in the body-control table: [seat, rs, own (1|0), ack]. */
|
|
@@ -120,7 +247,7 @@ export interface ControlChange<S = unknown> extends Control {
|
|
|
120
247
|
}
|
|
121
248
|
export interface PendingInput<A = unknown> { q: number; a: A; h: string[]; at: number; dt: number }
|
|
122
249
|
|
|
123
|
-
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 }
|
|
124
251
|
export interface RoundInfo {
|
|
125
252
|
n: number;
|
|
126
253
|
phase: 'live' | 'over';
|
|
@@ -130,7 +257,11 @@ export interface RoundInfo {
|
|
|
130
257
|
endsAt: number;
|
|
131
258
|
results?: RoundResult[];
|
|
132
259
|
}
|
|
133
|
-
|
|
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' } }
|
|
134
265
|
|
|
135
266
|
export interface RoleChange<S = unknown, C = unknown> {
|
|
136
267
|
role: Role;
|
|
@@ -205,6 +336,10 @@ export interface NetplayOptions<C = unknown> {
|
|
|
205
336
|
/** Defaults to `window.HOMIE_NET`. `null` forces offline. */
|
|
206
337
|
config?: NetConfig | null;
|
|
207
338
|
want?: Want;
|
|
339
|
+
/** Revision 5: watch (a screen that never takes a seat). Defaults to the shell's `HOMIE_NET.watch`. */
|
|
340
|
+
watch?: boolean;
|
|
341
|
+
/** A watcher's keys (1-9 a player, A Auto, O or 0 the whole room, arrows the next player). Default true. */
|
|
342
|
+
watchKeys?: boolean;
|
|
208
343
|
/** May this browser be elected host? Default true. */
|
|
209
344
|
canHost?: boolean;
|
|
210
345
|
/** Game id, for the relay's logs. */
|
|
@@ -228,6 +363,11 @@ export interface NetplayOptions<C = unknown> {
|
|
|
228
363
|
WebSocketImpl?: WebSocketCtor;
|
|
229
364
|
/** Tests / custom shells: where parent notifications go. Default `parent.postMessage` when framed. */
|
|
230
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')[];
|
|
231
371
|
}
|
|
232
372
|
|
|
233
373
|
type WebSocketCtor = new (url: string) => WebSocketLike;
|
|
@@ -260,6 +400,12 @@ export interface NetHandlers<S, A, C> {
|
|
|
260
400
|
announce: (a: Announcement) => void;
|
|
261
401
|
/** The studio's owner muted or unmuted a player in this room. */
|
|
262
402
|
mute: (m: Mute) => void;
|
|
403
|
+
/** Whose view to draw changed (a watcher's follow, or a player's own seat). Listening says the game draws it. */
|
|
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;
|
|
263
409
|
}
|
|
264
410
|
|
|
265
411
|
export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
@@ -300,6 +446,55 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
300
446
|
* it (opens that player's card with Mute and Kick); for everyone else it is nothing.
|
|
301
447
|
*/
|
|
302
448
|
pickPlayer(seat: number | null): void;
|
|
449
|
+
/**
|
|
450
|
+
* WATCHING (revision 5, NETPLAY.md section 16). `watching`: this browser is a watcher, a screen that never takes a
|
|
451
|
+
* seat. `viewSeat`: whose camera and HUD to draw: a player's own seat, the seat a watcher follows, or null for the
|
|
452
|
+
* overview camera. Reading it (or listening for `view`) tells the watch page that this game draws the followed
|
|
453
|
+
* player; a game that never does is shown as its overview, and the page says so.
|
|
454
|
+
*/
|
|
455
|
+
readonly watching: boolean;
|
|
456
|
+
readonly viewSeat: number | null;
|
|
457
|
+
/** What the watcher asked for: a seat, 'auto' or null (the whole room). */
|
|
458
|
+
readonly following: Follow;
|
|
459
|
+
/**
|
|
460
|
+
* `viewSeat` without saying the game draws it: for an overlay that only marks the followed player (the port's
|
|
461
|
+
* HUD), so a game whose camera does not follow is still shown to its watchers as its overview.
|
|
462
|
+
*/
|
|
463
|
+
readonly watchedSeat: number | null;
|
|
464
|
+
/** Whether this watcher may follow one player (false: the game or the room shows it the overview only). */
|
|
465
|
+
readonly canFollow: boolean;
|
|
466
|
+
/** Watcher: follow a seat, 'auto' (the action) or null (the whole room). False when not a watcher or not allowed. */
|
|
467
|
+
follow(target: Follow | 'overview'): boolean;
|
|
468
|
+
/**
|
|
469
|
+
* Anyone: something happened to this player (a hit, a goal, a pickup). Auto cuts to the newest one once the player
|
|
470
|
+
* it shows has had a few seconds. A cheap no-op except on a watcher in Auto.
|
|
471
|
+
*/
|
|
472
|
+
spotlight(seat: number | null): void;
|
|
473
|
+
/** The seated players, in seat order (the watch strip's order: key 1 is the first). */
|
|
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;
|
|
303
498
|
/** Resolves with the first role (welcome, or offline fallback). */
|
|
304
499
|
readonly ready: Promise<RoleChange<S, C>>;
|
|
305
500
|
on<K extends keyof NetHandlers<S, A, C>>(kind: K, fn: NetHandlers<S, A, C>[K]): () => void;
|
|
@@ -352,6 +547,12 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
352
547
|
|
|
353
548
|
/* ------------------------------------------------------------------ helpers */
|
|
354
549
|
|
|
550
|
+
/**
|
|
551
|
+
* The contract's 12 player colours (NETPLAY.md section 3): a peer's `colour` (its seat % 12) indexes this list. The
|
|
552
|
+
* watch page's player strip draws them, so a game that colours its players by seat matches it.
|
|
553
|
+
*/
|
|
554
|
+
export const PALETTE: readonly string[] = Object.freeze(['#8fe36a', '#ffd166', '#ef6f6c', '#6cb4ee', '#c792ea', '#f4a261', '#2ec4b6', '#ff8fab', '#a7c957', '#e9c46a', '#90e0ef', '#f28482']);
|
|
555
|
+
|
|
355
556
|
export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
|
356
557
|
export function lerpAngle(a: number, b: number, t: number): number {
|
|
357
558
|
let d = (b - a) % (Math.PI * 2);
|
|
@@ -398,8 +599,15 @@ export interface RosterOptions {
|
|
|
398
599
|
/** Hard cap on slots. */
|
|
399
600
|
max: number;
|
|
400
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;
|
|
401
607
|
}
|
|
402
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
|
+
|
|
403
611
|
/**
|
|
404
612
|
* Host-side bookkeeping for bots that yield their slot to an arriving human.
|
|
405
613
|
*
|
|
@@ -409,12 +617,18 @@ export interface RosterOptions {
|
|
|
409
617
|
* bot is left is a new slot added (up to `max`). A human who leaves turns back
|
|
410
618
|
* into a bot in the same slot, so the round never loses a body mid-play.
|
|
411
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.
|
|
412
625
|
*/
|
|
413
626
|
export class Roster {
|
|
414
627
|
slots: Slot[] = [];
|
|
415
628
|
readonly min: number;
|
|
416
629
|
readonly max: number;
|
|
417
630
|
readonly botName: (slot: number) => string;
|
|
631
|
+
private readonly policyFn: (() => Policy | null) | null;
|
|
418
632
|
/** seat → the slot it held when it last left */
|
|
419
633
|
private readonly lastSlot = new Map<number, number>();
|
|
420
634
|
|
|
@@ -422,20 +636,49 @@ export class Roster {
|
|
|
422
636
|
this.min = Math.max(0, opts.min | 0);
|
|
423
637
|
this.max = Math.max(this.min, opts.max | 0);
|
|
424
638
|
this.botName = opts.botName ?? ((slot: number) => `Bot ${slot + 1}`);
|
|
639
|
+
this.policyFn = opts.policy ?? null;
|
|
425
640
|
this.fill();
|
|
426
641
|
}
|
|
427
642
|
|
|
428
643
|
static from(slots: readonly Slot[], opts: RosterOptions): Roster {
|
|
429
644
|
const r = new Roster(opts);
|
|
430
|
-
r.slots = slots.map(
|
|
645
|
+
r.slots = slots.map(copySlot);
|
|
431
646
|
r.fill();
|
|
432
647
|
return r;
|
|
433
648
|
}
|
|
434
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
|
+
|
|
435
667
|
fill(): void {
|
|
436
|
-
|
|
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) {
|
|
437
672
|
const slot = this.nextSlotId();
|
|
438
|
-
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
|
+
}
|
|
439
682
|
}
|
|
440
683
|
this.slots.sort((a, b) => a.slot - b.slot);
|
|
441
684
|
}
|
|
@@ -447,16 +690,42 @@ export class Roster {
|
|
|
447
690
|
return id;
|
|
448
691
|
}
|
|
449
692
|
|
|
450
|
-
bySeat(seat: number): Slot | undefined { return this.slots.find((s) => s.seat === seat && !s.bot); }
|
|
451
|
-
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); }
|
|
452
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); }
|
|
453
698
|
|
|
454
|
-
/**
|
|
455
|
-
|
|
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 {
|
|
456
704
|
const mine = this.bySeat(seat);
|
|
457
705
|
if (mine) { mine.name = name || mine.name; return { slot: mine, yielded: false, added: false }; }
|
|
458
706
|
const last = this.lastSlot.get(seat);
|
|
459
|
-
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);
|
|
460
729
|
if (bot) {
|
|
461
730
|
bot.bot = false; bot.seat = seat; bot.name = name || `Player ${seat + 1}`;
|
|
462
731
|
return { slot: bot, yielded: true, added: false };
|
|
@@ -469,44 +738,59 @@ export class Roster {
|
|
|
469
738
|
return { slot: s, yielded: false, added: true };
|
|
470
739
|
}
|
|
471
740
|
|
|
472
|
-
/** 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. */
|
|
473
742
|
release(seat: number): Slot | null {
|
|
474
743
|
const s = this.bySeat(seat);
|
|
475
744
|
if (!s) return null;
|
|
476
745
|
this.lastSlot.set(seat, s.slot);
|
|
477
|
-
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);
|
|
478
751
|
return s;
|
|
479
752
|
}
|
|
480
753
|
|
|
481
|
-
/** 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. */
|
|
482
755
|
trim(): number[] {
|
|
483
756
|
const removed: number[] = [];
|
|
484
|
-
|
|
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) {
|
|
485
761
|
const s = this.slots[i];
|
|
486
|
-
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
|
+
}
|
|
487
769
|
}
|
|
770
|
+
this.fill();
|
|
488
771
|
return removed;
|
|
489
772
|
}
|
|
490
773
|
|
|
491
|
-
/** After a promotion: make the roster agree with who is actually connected. */
|
|
492
|
-
reconcile(peers: Iterable<{ seat: number | null; name: string }>): { claimed: Slot[]; released: Slot[] } {
|
|
493
|
-
const here = new Map<number, string>();
|
|
494
|
-
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 });
|
|
495
778
|
const released: Slot[] = [];
|
|
496
779
|
const claimed: Slot[] = [];
|
|
497
|
-
for (const s of this.
|
|
498
|
-
|
|
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); }
|
|
499
783
|
}
|
|
500
|
-
for (const [seat,
|
|
784
|
+
for (const [seat, p] of here) {
|
|
501
785
|
if (this.bySeat(seat)) continue;
|
|
502
|
-
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);
|
|
503
787
|
if (c) claimed.push(c.slot);
|
|
504
788
|
}
|
|
505
789
|
this.fill();
|
|
506
790
|
return { claimed, released };
|
|
507
791
|
}
|
|
508
792
|
|
|
509
|
-
toJSON(): Slot[] { return this.slots.map(
|
|
793
|
+
toJSON(): Slot[] { return this.slots.map(copySlot); }
|
|
510
794
|
}
|
|
511
795
|
|
|
512
796
|
/* --------------------------------------------------------------- the client */
|
|
@@ -518,7 +802,9 @@ const LADDER = [250, 500, 1000, 2000, 4000];
|
|
|
518
802
|
* second of frames at once), and a kick that ended the page's play for good left a phone frozen with no word.
|
|
519
803
|
* After one it comes back (its seat token keeps its body), sending at the slow rate below.
|
|
520
804
|
*/
|
|
521
|
-
const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed']);
|
|
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;
|
|
522
808
|
/**
|
|
523
809
|
* Input pacing (NETPLAY.md §5): at most this many `in` frames in any rolling second from this browser, whatever
|
|
524
810
|
* the frame rate or the press rate: two thirds of the relay's cap of 60, so frames that reach it bunched (a
|
|
@@ -539,6 +825,14 @@ const IN_BACKLOG_BYTES = 2048;
|
|
|
539
825
|
* (billed 20:1), and in a 32-seat room the replicas' inputs are nearly all of them (NETPLAY.md §11).
|
|
540
826
|
*/
|
|
541
827
|
const IDLE_INPUT_MS = 250;
|
|
828
|
+
/** Auto (section 16): a player shown is shown at least this long before the action cuts to another. */
|
|
829
|
+
const AUTO_MIN_MS = 3500;
|
|
830
|
+
/** Auto, with no action: look again (the leader, or the next player) this often. */
|
|
831
|
+
const AUTO_HOLD_MS = 12_000;
|
|
832
|
+
/** A followed player who left and comes back within this long is followed again. */
|
|
833
|
+
const WISH_MS = 30_000;
|
|
834
|
+
/** The watch page's live scores, from the game's `scores` probe, at most this often. */
|
|
835
|
+
const SCORES_MS = 1000;
|
|
542
836
|
|
|
543
837
|
function heldList(held: Iterable<string> | Record<string, boolean> | undefined): string[] {
|
|
544
838
|
if (!held) return [];
|
|
@@ -570,7 +864,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
570
864
|
const connectTimeoutMs = opts.connectTimeoutMs ?? 4000;
|
|
571
865
|
const staleMs = Math.max(2500, opts.staleMs ?? 6000);
|
|
572
866
|
const device: Device = cfg?.device ?? guessDevice();
|
|
573
|
-
|
|
867
|
+
// A watcher (section 16) is a screen that never takes a seat, whatever else it is told.
|
|
868
|
+
const watching = Boolean(opts.watch ?? cfg?.watch);
|
|
869
|
+
const want: Want = watching ? 'screen' : (opts.want ?? cfg?.want ?? 'play');
|
|
574
870
|
const canHost = opts.canHost ?? true;
|
|
575
871
|
const defaultOwn = (opts.movement ?? 'owner') === 'owner';
|
|
576
872
|
|
|
@@ -588,7 +884,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
588
884
|
|
|
589
885
|
const handlers: { [K in keyof NetHandlers<S, A, C>]: Set<NetHandlers<S, A, C>[K]> } = {
|
|
590
886
|
role: new Set(), join: new Set(), leave: new Set(), input: new Set(), event: new Set(),
|
|
591
|
-
snapshot: new Set(), control: new Set(), state: new Set(), round: new Set(), roster: new Set(), status: new Set(), announce: new Set(), mute: new Set(),
|
|
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(),
|
|
592
889
|
};
|
|
593
890
|
const emit = <K extends keyof NetHandlers<S, A, C>>(kind: K, arg: Parameters<NetHandlers<S, A, C>[K]>[0]): void => {
|
|
594
891
|
for (const fn of [...handlers[kind]]) {
|
|
@@ -686,6 +983,37 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
686
983
|
|
|
687
984
|
let probes: Probes = {};
|
|
688
985
|
|
|
986
|
+
// watching (section 16)
|
|
987
|
+
const firstFollow = cfg?.follow;
|
|
988
|
+
let following: Follow = typeof firstFollow === 'number' && Number.isInteger(firstFollow) && firstFollow >= 0 ? firstFollow : firstFollow === 'overview' ? null : 'auto';
|
|
989
|
+
/** The seat drawn now, and whether the game has shown it draws a followed player (it read viewSeat or listens). */
|
|
990
|
+
let viewNow: number | null = null;
|
|
991
|
+
let viewKnown = false;
|
|
992
|
+
let follows = false;
|
|
993
|
+
/** The page's word (game.json "watch": "overview") and the relay's (welcome.watch / watch frames). */
|
|
994
|
+
const policyFollow = cfg?.watchPolicy !== 'overview';
|
|
995
|
+
let relayFollow = true;
|
|
996
|
+
let relayWhy: string | null = null;
|
|
997
|
+
/** Auto's choice and when it was made; the newest spotlight; a followed player who left, and when. */
|
|
998
|
+
let autoSeat: number | null = null;
|
|
999
|
+
let autoAt = 0;
|
|
1000
|
+
let spot = { seat: -1, at: 0 };
|
|
1001
|
+
/** A spotlight waiting for the player shown to have had their moment: Auto looks again exactly then. */
|
|
1002
|
+
let spotTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1003
|
+
let wish: number | null = null;
|
|
1004
|
+
let wishAt = 0;
|
|
1005
|
+
let lastViewPost = '';
|
|
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
|
+
|
|
689
1017
|
const wall = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
690
1018
|
const now = (): number => (offline ? Date.now() : Date.now() + offset);
|
|
691
1019
|
|
|
@@ -761,6 +1089,101 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
761
1089
|
for (const [k, d] of stateMap) stateSent.set(k, JSON.stringify(d));
|
|
762
1090
|
}
|
|
763
1091
|
|
|
1092
|
+
/* ------------------------------------------------------------ watching (section 16) */
|
|
1093
|
+
const canFollowNow = (): boolean => watching && policyFollow && relayFollow;
|
|
1094
|
+
/** The seated players, in seat order (never a watcher, never a waiting screen). */
|
|
1095
|
+
function seated(): Peer[] {
|
|
1096
|
+
return [...peers.values()].filter((p) => typeof p.seat === 'number' && !p.watch).sort((a, b) => (a.seat as number) - (b.seat as number));
|
|
1097
|
+
}
|
|
1098
|
+
const presentSeat = (s: number | null): boolean => s !== null && seated().some((p) => p.seat === s);
|
|
1099
|
+
/** The leader of the game's `scores` probe ([{ seat, score }]), among the seated players; null without one. */
|
|
1100
|
+
function leader(list: Peer[]): { seat: number; score: number } | null {
|
|
1101
|
+
const fn = probes['scores'];
|
|
1102
|
+
if (typeof fn !== 'function') return null;
|
|
1103
|
+
let rows: unknown;
|
|
1104
|
+
try { rows = (fn as () => unknown)(); } catch { return null; }
|
|
1105
|
+
if (!Array.isArray(rows)) return null;
|
|
1106
|
+
let best: { seat: number; score: number } | null = null;
|
|
1107
|
+
for (const r of rows as { seat?: unknown; score?: unknown }[]) {
|
|
1108
|
+
if (!r || typeof r.seat !== 'number' || !Number.isFinite(Number(r.score)) || !list.some((p) => p.seat === r.seat)) continue;
|
|
1109
|
+
if (!best || Number(r.score) > best.score) best = { seat: r.seat, score: Number(r.score) };
|
|
1110
|
+
}
|
|
1111
|
+
return best;
|
|
1112
|
+
}
|
|
1113
|
+
const scoreOf = (s: number): number => {
|
|
1114
|
+
const fn = probes['scores'];
|
|
1115
|
+
try { const r = typeof fn === 'function' ? ((fn as () => { seat?: number; score?: number }[])() ?? []).find((x) => x && x.seat === s) : null; return r ? Number(r.score) || 0 : 0; } catch { return 0; }
|
|
1116
|
+
};
|
|
1117
|
+
/**
|
|
1118
|
+
* AUTO: the newest spotlight on a present player (once the one shown has had AUTO_MIN_MS); with no action, every
|
|
1119
|
+
* AUTO_HOLD_MS the leader of the game's scores (ties keep who is shown); with no scores, the next player in seat order.
|
|
1120
|
+
*/
|
|
1121
|
+
function pickAuto(): number | null {
|
|
1122
|
+
const list = seated();
|
|
1123
|
+
if (!list.length) return null;
|
|
1124
|
+
const has = (s: number): boolean => list.some((p) => p.seat === s);
|
|
1125
|
+
const t = wall();
|
|
1126
|
+
const cur = autoSeat !== null && has(autoSeat) ? autoSeat : null;
|
|
1127
|
+
const dwell = t - autoAt;
|
|
1128
|
+
let next: number | null = cur;
|
|
1129
|
+
if (spot.seat >= 0 && spot.at > autoAt && has(spot.seat) && spot.seat !== cur && (cur === null || dwell >= AUTO_MIN_MS)) next = spot.seat;
|
|
1130
|
+
else if (cur === null || dwell >= AUTO_HOLD_MS) {
|
|
1131
|
+
const lead = leader(list);
|
|
1132
|
+
if (lead) next = cur !== null && scoreOf(cur) >= lead.score ? cur : lead.seat;
|
|
1133
|
+
else if (cur === null) next = (list[0] as Peer).seat;
|
|
1134
|
+
else { const i = list.findIndex((p) => p.seat === cur); next = (list[(i + 1) % list.length] as Peer).seat; }
|
|
1135
|
+
if (next === cur) autoAt = t; // looked again, and stayed: the next look is a hold from now
|
|
1136
|
+
}
|
|
1137
|
+
if (next !== autoSeat) { autoSeat = next; autoAt = t; }
|
|
1138
|
+
// Action is waiting on the player shown: cut the moment their AUTO_MIN_MS is up, not on the next look.
|
|
1139
|
+
if (spot.seat >= 0 && spot.at > autoAt && spot.seat !== next && has(spot.seat) && !spotTimer && !closed) {
|
|
1140
|
+
spotTimer = setTimeout(() => { spotTimer = null; if (following === 'auto') resolveView('auto'); }, Math.max(0, AUTO_MIN_MS - (t - autoAt)) + 5);
|
|
1141
|
+
}
|
|
1142
|
+
return next;
|
|
1143
|
+
}
|
|
1144
|
+
/** Work out whose view to draw now, and tell the game (a `view` event) and the watch page when it changed. */
|
|
1145
|
+
function resolveView(why: ViewWhy): void {
|
|
1146
|
+
let next: number | null;
|
|
1147
|
+
if (!watching) next = offline ? null : seat;
|
|
1148
|
+
else if (!canFollowNow()) next = null;
|
|
1149
|
+
else if (following === null) next = null;
|
|
1150
|
+
else if (typeof following === 'number') {
|
|
1151
|
+
if (presentSeat(following)) next = following;
|
|
1152
|
+
else if (!roleKnown || !peers.size) next = null; // not in the room yet: the welcome says who is here
|
|
1153
|
+
else {
|
|
1154
|
+
// The followed player left: Auto takes over, and their view comes back if they do (a reload, a blip).
|
|
1155
|
+
wish = following; wishAt = wall(); following = 'auto'; why = 'left';
|
|
1156
|
+
autoSeat = null; next = pickAuto();
|
|
1157
|
+
}
|
|
1158
|
+
} else {
|
|
1159
|
+
if (wish !== null && presentSeat(wish) && wall() - wishAt < WISH_MS) { following = wish; wish = null; why = 'back'; next = following; }
|
|
1160
|
+
else { if (wish !== null && wall() - wishAt >= WISH_MS) wish = null; next = pickAuto(); }
|
|
1161
|
+
}
|
|
1162
|
+
if (!viewKnown || next !== viewNow) {
|
|
1163
|
+
const prev = viewNow;
|
|
1164
|
+
viewNow = next;
|
|
1165
|
+
const first = !viewKnown;
|
|
1166
|
+
viewKnown = true;
|
|
1167
|
+
emit('view', { seat: next, following: watching ? following : next, prev, why: first ? 'start' : why });
|
|
1168
|
+
}
|
|
1169
|
+
postView(why);
|
|
1170
|
+
}
|
|
1171
|
+
function postView(why: ViewWhy | string = 'start'): void {
|
|
1172
|
+
if (!post || !watching) return;
|
|
1173
|
+
const whyNot = !policyFollow ? 'overview' : !relayFollow ? (relayWhy ?? 'overview') : null;
|
|
1174
|
+
const msg = { what: 'view', seat: viewNow, following, follows, canFollow: canFollowNow(), whyNot, why };
|
|
1175
|
+
const sig = JSON.stringify([msg.seat, msg.following, msg.follows, msg.canFollow, msg.whyNot]);
|
|
1176
|
+
if (sig === lastViewPost) return;
|
|
1177
|
+
lastViewPost = sig;
|
|
1178
|
+
post(msg);
|
|
1179
|
+
}
|
|
1180
|
+
/** The game shows it draws a followed player: tell the watch page, so its strip lets the watcher choose. */
|
|
1181
|
+
function markFollows(): void {
|
|
1182
|
+
if (follows || !watching) return;
|
|
1183
|
+
follows = true;
|
|
1184
|
+
postView('start');
|
|
1185
|
+
}
|
|
1186
|
+
|
|
764
1187
|
function setRole(next: Role, why: string, extra: Partial<RoleChange<S, C>> = {}): void {
|
|
765
1188
|
const prev = roleKnown ? role : null;
|
|
766
1189
|
const was = role;
|
|
@@ -784,8 +1207,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
784
1207
|
sentHist.length = 0;
|
|
785
1208
|
}
|
|
786
1209
|
post?.({ what: 'role', role: next, prev, why, seat });
|
|
1210
|
+
if (next === 'host') { capsSent = ''; sendCaps(); }
|
|
787
1211
|
resolveReady(e);
|
|
788
1212
|
emit('role', e);
|
|
1213
|
+
resolveView('seat');
|
|
789
1214
|
notifyStats(true);
|
|
790
1215
|
}
|
|
791
1216
|
|
|
@@ -807,7 +1232,23 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
807
1232
|
case 'welcome': return onWelcome(m);
|
|
808
1233
|
case 'snap': return onSnap(m, text.length);
|
|
809
1234
|
case 'in': return onInput(m);
|
|
810
|
-
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
|
+
}
|
|
811
1252
|
case 'state': if (typeof m['k'] === 'string') applyState(m['k'], m['d']); return;
|
|
812
1253
|
case 'pong': return onPong(m);
|
|
813
1254
|
case 'announce': {
|
|
@@ -827,7 +1268,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
827
1268
|
}
|
|
828
1269
|
case 'join': {
|
|
829
1270
|
const p = m['peer'] as Peer | undefined;
|
|
830
|
-
if (p && typeof p.id === 'string') { peers.set(p.id, p); if (role === 'host' && p.seat !== null) ctlOf(p.seat); emit('join', p); }
|
|
1271
|
+
if (p && typeof p.id === 'string') { peers.set(p.id, p); if (role === 'host' && p.seat !== null) ctlOf(p.seat); emit('join', p); if (watching) resolveView('auto'); }
|
|
831
1272
|
return;
|
|
832
1273
|
}
|
|
833
1274
|
case 'leave': {
|
|
@@ -837,6 +1278,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
837
1278
|
const s = typeof m['seat'] === 'number' ? m['seat'] : (p?.seat ?? null);
|
|
838
1279
|
if (s !== null) { inputs.delete(s); presses.delete(s); }
|
|
839
1280
|
emit('leave', { id: pid, seat: s, why: String(m['why'] ?? 'closed') });
|
|
1281
|
+
if (watching) resolveView('auto');
|
|
840
1282
|
return;
|
|
841
1283
|
}
|
|
842
1284
|
case 'round': {
|
|
@@ -885,10 +1327,20 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
885
1327
|
if (next === 'host') { lastSnapSentAt = -Infinity; if (snap) tick = Math.max(tick, snap.k); }
|
|
886
1328
|
return;
|
|
887
1329
|
}
|
|
1330
|
+
case 'watch': {
|
|
1331
|
+
// The relay's word on following (section 16): a seat this browser took in another tab, or taken back.
|
|
1332
|
+
relayFollow = m['follow'] !== false;
|
|
1333
|
+
relayWhy = typeof m['why'] === 'string' ? m['why'] : null;
|
|
1334
|
+
resolveView('policy');
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
888
1337
|
case 'error': {
|
|
889
1338
|
const code = String(m['code'] ?? '');
|
|
890
1339
|
// Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
|
|
891
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; }
|
|
892
1344
|
if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
|
|
893
1345
|
console.warn('[netplay] relay refused:', code, m['message']);
|
|
894
1346
|
// The same seat opened in another tab, a full room, a version the relay does not speak: reconnecting
|
|
@@ -926,6 +1378,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
926
1378
|
peers.clear();
|
|
927
1379
|
if (Array.isArray(m['peers'])) for (const p of m['peers'] as Peer[]) peers.set(p.id, p);
|
|
928
1380
|
if (typeof m['st'] === 'number' && rtt === null) offset = (m['st'] as number) - Date.now();
|
|
1381
|
+
if (watching) {
|
|
1382
|
+
// A relay from before revision 5 says nothing: following is the page's to allow.
|
|
1383
|
+
const w = m['watch'] as { follow?: boolean; why?: string } | undefined;
|
|
1384
|
+
relayFollow = !w || w.follow !== false;
|
|
1385
|
+
relayWhy = w && typeof w.why === 'string' ? w.why : null;
|
|
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>);
|
|
929
1390
|
const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? null;
|
|
930
1391
|
let snap = (m['snap'] as Snapshot<S> | null) ?? null;
|
|
931
1392
|
const a = m['announce'] as Announcement | undefined;
|
|
@@ -951,6 +1412,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
951
1412
|
if (next === 'host' && snap) tick = Math.max(tick, snap.k);
|
|
952
1413
|
}
|
|
953
1414
|
if (continuing) reannounce();
|
|
1415
|
+
resolveView('seat');
|
|
954
1416
|
ping(); setTimeout(ping, 120); setTimeout(ping, 260);
|
|
955
1417
|
}
|
|
956
1418
|
|
|
@@ -1203,7 +1665,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1203
1665
|
ws = sock;
|
|
1204
1666
|
sock.onopen = () => {
|
|
1205
1667
|
lastMsgAt = wall();
|
|
1206
|
-
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
|
+
});
|
|
1207
1672
|
};
|
|
1208
1673
|
sock.onmessage = (ev) => onMessage(ev.data);
|
|
1209
1674
|
sock.onclose = () => lost(sock, 'closed');
|
|
@@ -1214,7 +1679,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1214
1679
|
if (closed) return;
|
|
1215
1680
|
// A hidden tab does not play and its timers crawl: reconnect when it is looked at again.
|
|
1216
1681
|
if (hidden()) { waitingVisible = true; return; }
|
|
1217
|
-
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());
|
|
1218
1683
|
attempt += 1;
|
|
1219
1684
|
reconnects += 1;
|
|
1220
1685
|
setTimeout(open, wait);
|
|
@@ -1224,7 +1689,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1224
1689
|
// No shell (a plain file, a dev server): the game is its own host, offline.
|
|
1225
1690
|
queueMicrotask(() => goOfflineHost('offline'));
|
|
1226
1691
|
} else {
|
|
1227
|
-
post?.({ what: 'attached', v: NETPLAY_VERSION });
|
|
1692
|
+
post?.({ what: 'attached', v: NETPLAY_VERSION, rev: NETPLAY_REVISION, mark: NETPLAY_MARK });
|
|
1228
1693
|
open();
|
|
1229
1694
|
// A relay that never answers must not leave a game on a black screen. But the wait is counted only while
|
|
1230
1695
|
// this page is able to listen: a phone compiling shaders under load is blocked for seconds at a time, and
|
|
@@ -1261,6 +1726,96 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1261
1726
|
} catch { /* not a browser */ }
|
|
1262
1727
|
}
|
|
1263
1728
|
|
|
1729
|
+
// ---------------------------------------------------------------- watching
|
|
1730
|
+
let watchTimer: ReturnType<typeof setInterval> | null = null;
|
|
1731
|
+
if (watching && !offline) {
|
|
1732
|
+
// Auto looks again twice a second; the watch page gets live scores from the game's `scores` probe.
|
|
1733
|
+
let lastScores = 0;
|
|
1734
|
+
watchTimer = setInterval(() => {
|
|
1735
|
+
if (closed) return;
|
|
1736
|
+
if (following === 'auto' || wish !== null) resolveView('auto');
|
|
1737
|
+
const fn = probes['scores'];
|
|
1738
|
+
const t = wall();
|
|
1739
|
+
if (post && typeof fn === 'function' && t - lastScores >= SCORES_MS) {
|
|
1740
|
+
lastScores = t;
|
|
1741
|
+
try {
|
|
1742
|
+
const rows = ((fn as () => { seat?: unknown; score?: unknown }[])() ?? []).filter((r) => r && typeof r.seat === 'number' && Number.isFinite(Number(r.score)));
|
|
1743
|
+
post({ what: 'scores', scores: rows.map((r) => ({ seat: r.seat, score: Number(r.score) })) });
|
|
1744
|
+
} catch { /* a probe that throws reports nothing */ }
|
|
1745
|
+
}
|
|
1746
|
+
}, 500);
|
|
1747
|
+
try {
|
|
1748
|
+
// The watch page's strip and keys: { t: 'homie-watch', follow: seat | 'auto' | null }, from the page around the frame only.
|
|
1749
|
+
g.addEventListener?.('message', (ev: MessageEvent) => {
|
|
1750
|
+
if (ev.source !== g.parent || !ev.data || typeof ev.data !== 'object') return;
|
|
1751
|
+
const d = ev.data as { t?: unknown; follow?: unknown };
|
|
1752
|
+
if (d.t !== 'homie-watch') return;
|
|
1753
|
+
const f = d.follow;
|
|
1754
|
+
if (f === 'auto' || f === null || f === 'overview' || (typeof f === 'number' && Number.isInteger(f))) api.follow(f as Follow | 'overview');
|
|
1755
|
+
});
|
|
1756
|
+
// The same keys inside the frame, for a watcher who clicked into the game.
|
|
1757
|
+
if (opts.watchKeys !== false) {
|
|
1758
|
+
g.addEventListener?.('keydown', (ev: KeyboardEvent) => {
|
|
1759
|
+
if (ev.metaKey || ev.ctrlKey || ev.altKey || !follows) return;
|
|
1760
|
+
const list = seated();
|
|
1761
|
+
const k = ev.key;
|
|
1762
|
+
if (/^[1-9]$/.test(k)) { const p = list[Number(k) - 1]; if (p) api.follow(p.seat); return; }
|
|
1763
|
+
if (k === 'a' || k === 'A') { api.follow('auto'); return; }
|
|
1764
|
+
if (k === 'o' || k === 'O' || k === '0') { api.follow(null); return; }
|
|
1765
|
+
if ((k === 'ArrowRight' || k === 'ArrowLeft') && list.length) {
|
|
1766
|
+
const i = list.findIndex((p) => p.seat === viewNow);
|
|
1767
|
+
const n = k === 'ArrowRight' ? (i + 1) % list.length : (i <= 0 ? list.length - 1 : i - 1);
|
|
1768
|
+
api.follow((list[n] as Peer).seat);
|
|
1769
|
+
}
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
} catch { /* not a browser */ }
|
|
1773
|
+
}
|
|
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
|
+
|
|
1264
1819
|
// ---------------------------------------------------------------- API
|
|
1265
1820
|
const api: Netplay<S, A, C> = {
|
|
1266
1821
|
get role() { return role; },
|
|
@@ -1283,9 +1838,61 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1283
1838
|
get announcement() { return announcement && (announcement.until === undefined || announcement.until > now()) ? announcement : null; },
|
|
1284
1839
|
isMuted(s: number | null): boolean { return s !== null && (muted.get(s) ?? 0) > now(); },
|
|
1285
1840
|
pickPlayer(s: number | null): void { post?.({ what: 'pick', seat: typeof s === 'number' ? s : null }); },
|
|
1841
|
+
get watching() { return watching; },
|
|
1842
|
+
get viewSeat() { markFollows(); return watching ? viewNow : (offline ? null : seat); },
|
|
1843
|
+
get following() { return watching ? following : seat; },
|
|
1844
|
+
get watchedSeat() { return watching ? viewNow : (offline ? null : seat); },
|
|
1845
|
+
get canFollow() { return canFollowNow(); },
|
|
1846
|
+
follow(target) {
|
|
1847
|
+
if (!watching) return false;
|
|
1848
|
+
const next: Follow | undefined = target === 'auto' ? 'auto' : target === null || target === 'overview' ? null
|
|
1849
|
+
: typeof target === 'number' && Number.isInteger(target) && target >= 0 && target < 64 ? target : undefined;
|
|
1850
|
+
if (next === undefined || !canFollowNow()) return false;
|
|
1851
|
+
following = next;
|
|
1852
|
+
wish = null;
|
|
1853
|
+
// Auto starts from the player on screen (no cut for its own sake); a pick is shown at once.
|
|
1854
|
+
if (next === 'auto') { autoSeat = viewNow; autoAt = wall(); }
|
|
1855
|
+
resolveView('asked');
|
|
1856
|
+
postView('asked');
|
|
1857
|
+
return true;
|
|
1858
|
+
},
|
|
1859
|
+
spotlight(s) {
|
|
1860
|
+
if (!watching || typeof s !== 'number') return;
|
|
1861
|
+
spot = { seat: s, at: wall() };
|
|
1862
|
+
if (following === 'auto') resolveView('auto');
|
|
1863
|
+
},
|
|
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); },
|
|
1286
1892
|
ready,
|
|
1287
1893
|
on(kind, fn) {
|
|
1288
1894
|
(handlers[kind] as Set<unknown>).add(fn);
|
|
1895
|
+
if (kind === 'view') markFollows();
|
|
1289
1896
|
return () => { (handlers[kind] as Set<unknown>).delete(fn); };
|
|
1290
1897
|
},
|
|
1291
1898
|
now,
|
|
@@ -1328,7 +1935,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1328
1935
|
emit('round', r);
|
|
1329
1936
|
},
|
|
1330
1937
|
roster(s) {
|
|
1331
|
-
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 } } : {}) }));
|
|
1332
1939
|
post?.({ what: 'roster', slots });
|
|
1333
1940
|
if (role === 'host') raw({ t: 'roster', slots });
|
|
1334
1941
|
},
|
|
@@ -1426,6 +2033,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1426
2033
|
if (pingTimer) clearInterval(pingTimer);
|
|
1427
2034
|
if (ckptTimer) clearInterval(ckptTimer);
|
|
1428
2035
|
if (statsTimer) clearInterval(statsTimer);
|
|
2036
|
+
if (watchTimer) clearInterval(watchTimer);
|
|
2037
|
+
if (spotTimer) clearTimeout(spotTimer);
|
|
1429
2038
|
clearTimeout(connectTimer);
|
|
1430
2039
|
try { raw({ t: 'bye' }); ws?.close(1000, 'bye'); } catch { /* gone */ }
|
|
1431
2040
|
},
|
|
@@ -1443,6 +2052,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1443
2052
|
get peers() { return [...peers.values()]; },
|
|
1444
2053
|
get round() { return roundInfo; },
|
|
1445
2054
|
get roster() { return slots; },
|
|
2055
|
+
get watching() { return watching; },
|
|
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; },
|
|
2061
|
+
get following() { return following; },
|
|
1446
2062
|
get state() { return Object.fromEntries(stateMap); },
|
|
1447
2063
|
get lastCheckpoint() { return lastCkpt ? { k: lastCkpt.k, st: lastCkpt.st } : null; },
|
|
1448
2064
|
/** The newest snapshot this browser received: tick, server time, sender seat, local Date.now() of arrival. */
|