benjivers/val Public Public: anyone can read this; the crew can change it.

Files 470af835242d7c193b45544c677099a00451ce7b
339 lines · 12.9 KB · ~3.8k tok Raw History
1
/**
2
 * The shape of a served match: one world, always running while somebody is
3
 * connected, and the seats people sit in.
4
 *
5
 * Nothing here knows what the game is. The game hands over its state and a
6
 * handful of answers — how to step it, who is in it, what one viewer may see
7
 * and what a snapshot looks like — and this module keeps the rules every
8
 * served game needs and none should re-derive:
9
 *
10
 * - The idle gate: nobody watching, nothing happens. The world freezes when
11
 *   the last connection goes and carries on when someone arrives.
12
 * - A playing seat is rationed by address, not by cookie: a cookie costs one
13
 *   HTTP request, an address is the one thing a machine cannot mint two of.
14
 *   Both claims go in synchronously, before a socket is ever offered a seat.
15
 * - A connection with no seat watches: it is sent the public state and no
16
 *   position in it.
17
 * - Every snapshot is built for one viewer, from the bodies the game says
18
 *   that viewer can see. The client never learns a position it has no line
19
 *   of sight to.
20
 */
21
22
export const TICK = 1 / 30;
23
24
/**
25
 * How many ticks apart the snapshots go out when the game does not say.
26
 *
27
 * The simulation runs at 30Hz. A small room gets every tick; above that the
28
 * cost multiplies by seats and the older cadence — every other tick — comes
29
 * back. Where the line sits is a fact about one game's snapshot and one
30
 * game's room, so a game that has measured its own passes `cadence` and this
31
 * default is never consulted.
32
 */
33
export const broadcastEvery = (filled: number): number => (filled <= 2 ? 1 : 2);
34
35
export type Seat = {
36
  playerId: string;
37
  token: string;
38
  address: string;
39
  send: (data: string) => void;
40
  /** The sticky part of the snapshot as this seat last received it, so it is only resent when it changes. */
41
  stickyCache: string;
42
};
43
44
/**
45
 * Why this connection is watching. The client says so in words, and the two
46
 * have different answers — open the tab that is playing, or let the page keep
47
 * its cookie — so which one it is travels with the snapshot.
48
 */
49
export type Why = "address" | "cookie" | "unsaid";
50
51
export type Command = { t: string; [key: string]: unknown };
52
53
/** What the arena needs of a body: a way to find it, address it, and tell a bot from a person. */
54
export type Body = { id: string; name: string; bot: boolean };
55
56
/** The game, as the arena sees it. */
57
export type Game<S, P extends Body> = {
58
  /** The one world. Mutated in place; the arena never replaces it. */
59
  state: S;
60
  players: (state: S) => P[];
61
  /** One fixed step of the world, bots and rules and all. */
62
  step: (state: S, dt: number) => void;
63
  /** Hand a bot's seat to a person: the same body, made human. Null when there is none. */
64
  sit: (state: S) => P | null;
65
  /** Give a seat back to a bot. */
66
  stand: (state: S, player: P) => void;
67
  /** May this viewer be sent this body? The server's answer, never the client's. */
68
  visible: (state: S, viewer: P, other: P) => boolean;
69
  /** The snapshot for one viewer, given exactly the bodies they may see. */
70
  snapshot: (state: S, viewer: P, seen: P[]) => Record<string, unknown>;
71
  /**
72
   * A field of the snapshot resent only when it differs from what the seat
73
   * last received; the client keeps the one it has. Null sends everything.
74
   */
75
  sticky: string | null;
76
  /** How many ticks apart this game's snapshots go out, given the seats filled. `broadcastEvery` when unsaid. */
77
  cadence?: (filled: number) => number;
78
  /** The public view for a connection with no seat. Must carry no position. */
79
  spectatorSnapshot: (state: S, why: Why) => Record<string, unknown>;
80
  /** Everything a client may ask for, validated by the game and nowhere else. */
81
  command: (state: S, player: P, message: Command) => void;
82
};
83
84
export function makeArena<S, P extends Body>(game: Game<S, P>) {
85
  const state = game.state;
86
  const seats = new Map<string, Seat>();
87
  // A session is a cookie, and a cookie costs one HTTP request — free for
88
  // anyone to mint as many of as they like. The address is the one thing here
89
  // a single machine cannot hand itself two of, so it is what a playing seat
90
  // is actually rationed by; the session on top of it is only what lets a
91
  // reload find its way back to the same seat rather than a fresh one.
92
  const addressSeats = new Map<string, string>();
93
  let ticks = 0;
94
95
  const match = (): S => state;
96
  const findPlayer = (id: string): P | undefined => game.players(state).find((p) => p.id === id);
97
98
  /**
99
   * A name nobody else in this match is using. Two players under one name
100
   * make a feed line that reads as a team kill; a person who picks a taken
101
   * name gets a number after it, inside the sixteen characters a name is
102
   * allowed.
103
   */
104
  function freeName(want: string, exceptId: string): string {
105
    const taken = new Set(
106
      game
107
        .players(state)
108
        .filter((p) => p.id !== exceptId)
109
        .map((p) => p.name),
110
    );
111
    if (!taken.has(want)) return want;
112
    for (let n = 2; n < 100; n++) {
113
      const tried = `${want.slice(0, 13)} ${n}`;
114
      if (!taken.has(tried)) return tried;
115
    }
116
    return want;
117
  }
118
119
  /**
120
   * Whether this address already plays under a different session. Checked
121
   * before a socket is even offered a seat, so the caller can hand it a
122
   * spectator view instead of upgrading it into a second side's vision.
123
   */
124
  function addressCapped(address: string, session: string): boolean {
125
    const holder = addressSeats.get(address);
126
    return holder !== undefined && holder !== session;
127
  }
128
129
  /**
130
   * Sit somebody down, replacing a bot where it stands.
131
   *
132
   * Both claims go in right here, synchronously, before the caller ever opens
133
   * a socket. A websocket upgrade completes on a later tick, so two requests
134
   * arriving close together both used to read the maps before either had
135
   * written to them — one client, two seats, opposite sides, the vision of
136
   * both. Claiming inline closes that window regardless of how the two
137
   * requests were related.
138
   */
139
  function takeSeat(address: string, session: string, name: string): P | null {
140
    const existing = seats.get(session);
141
    const held = existing ? findPlayer(existing.playerId) : undefined;
142
    if (held) {
143
      held.name = freeName(name, held.id);
144
      return held;
145
    }
146
    if (addressCapped(address, session)) return null;
147
    const seat = game.sit(state);
148
    if (!seat) return null;
149
    seat.name = freeName(name, seat.id);
150
    addressSeats.set(address, session);
151
    seats.set(session, { playerId: seat.id, token: "", address, send: () => {}, stickyCache: "" });
152
    return seat;
153
  }
154
155
  /** The socket that actually opened fills in how to reach it and its token. */
156
  function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void {
157
    seats.set(session, { playerId, token, address, send, stickyCache: "" });
158
  }
159
160
  const stillHeld = (playerId: string): boolean => [...seats.values()].some((s) => s.playerId === playerId);
161
162
  /**
163
   * A person leaving gives the seat back to a bot, mid-round and all — but only
164
   * if this is still the socket the session is on. A reload opens the new
165
   * socket before the old one's close event arrives; without the token, that
166
   * late close would delete the reconnect's seat entry and hand a live human's
167
   * seat to a bot mid-round.
168
   */
169
  function release(session: string, token: string): void {
170
    const seat = seats.get(session);
171
    if (!seat || seat.token !== token) return;
172
    seats.delete(session);
173
    if (addressSeats.get(seat.address) === session) addressSeats.delete(seat.address);
174
    const player = findPlayer(seat.playerId);
175
    if (player && !stillHeld(seat.playerId)) game.stand(state, player);
176
  }
177
178
  /**
179
   * Test-only hygiene: give back whatever this address currently holds. The
180
   * arena is one match shared by the whole test run, over real HTTP, from one
181
   * loopback address — this is how a test that needs that address free gets it
182
   * free, rather than depending on every other test in the file to clean up
183
   * after itself.
184
   */
185
  function releaseAddress(address: string): void {
186
    const session = addressSeats.get(address);
187
    if (session !== undefined) release(session, seats.get(session)?.token ?? "");
188
  }
189
190
  const seatCount = (): number => seats.size;
191
192
  // ------------------------------------------------------------ spectators
193
194
  // A connection that never carried a session the page minted gets no side and
195
  // no seat: only the public state everyone already sees on the page itself.
196
  const spectators = new Map<string, { send: (data: string) => void; why: Why }>();
197
198
  const spectatorSnapshot = (why: Why = "unsaid"): string =>
199
    JSON.stringify({ t: "s", spectator: true, why, ...game.spectatorSnapshot(state, why) });
200
201
  function watch(token: string, send: (data: string) => void, why: Why = "unsaid"): void {
202
    spectators.set(token, { send, why });
203
    send(spectatorSnapshot(why));
204
  }
205
206
  function unwatch(token: string): void {
207
    spectators.delete(token);
208
  }
209
210
  // ----------------------------------------------------------- the commands
211
212
  function command(playerId: string, message: Command): void {
213
    const player = findPlayer(playerId);
214
    if (!player || player.bot) return;
215
    game.command(state, player, message);
216
  }
217
218
  // ----------------------------------------------------------- the snapshot
219
220
  function snapshotFor(viewerId: string): Record<string, unknown> {
221
    const me = findPlayer(viewerId);
222
    // No seat, no eyes: an id that names nobody gets exactly what a seatless
223
    // connection already gets, never another player's vision by accident.
224
    if (!me) return { t: "s", spectator: true, why: "unsaid", ...game.spectatorSnapshot(state, "unsaid") };
225
    const players = game.players(state);
226
    const seen = players.filter((p) => game.visible(state, me, p));
227
    return { t: "s", ...game.snapshot(state, me, seen) };
228
  }
229
230
  /** The full snapshot for one viewer, as one string. */
231
  const snapshot = (viewerId: string): string => JSON.stringify(snapshotFor(viewerId));
232
233
  /**
234
   * The snapshot a seat is actually sent on this tick: everything, every time,
235
   * except the sticky field, which goes out only when it differs from what
236
   * this seat last received.
237
   */
238
  function snapshotForSeat(seat: Seat): string {
239
    const full = snapshotFor(seat.playerId);
240
    if (game.sticky !== null) {
241
      const part = JSON.stringify(full[game.sticky]);
242
      if (part === seat.stickyCache) delete full[game.sticky];
243
      else seat.stickyCache = part;
244
    }
245
    return JSON.stringify(full);
246
  }
247
248
  // --------------------------------------------------------------- the loop
249
250
  /**
251
   * Nobody watching, nothing happens.
252
   *
253
   * An arena that ticks around the clock plays out rounds for an empty room —
254
   * burning a share of a small box's CPU and filling the record with matches
255
   * no one saw. The world freezes when the last connection goes and carries on
256
   * from exactly where it stopped when someone arrives, so a visitor never
257
   * finds a score that ran up while they were away.
258
   *
259
   * A spectator counts as somebody: watching is watching.
260
   */
261
  const attended = (): boolean => seats.size > 0 || spectators.size > 0;
262
263
  /** How many connections the arena is playing for: seats plus watchers. */
264
  const attendance = (): number => seats.size + spectators.size;
265
266
  const cadence = game.cadence ?? broadcastEvery;
267
268
  function step(): void {
269
    game.step(state, TICK);
270
    ticks += 1;
271
    if (ticks % cadence(seats.size) !== 0) return;
272
    for (const seat of seats.values()) {
273
      try {
274
        seat.send(snapshotForSeat(seat));
275
      } catch {
276
        // A socket that will not take a frame is closed already; the close
277
        // handler gives the seat back.
278
      }
279
    }
280
    for (const spectator of spectators.values()) {
281
      try {
282
        spectator.send(spectatorSnapshot(spectator.why));
283
      } catch {
284
        // Same story: the close handler drops it from the set.
285
      }
286
    }
287
  }
288
289
  let timer: ReturnType<typeof setInterval> | null = null;
290
291
  /**
292
   * One beat of real time. The gate lives here rather than inside `step` so the
293
   * tests can still drive the simulation directly with `advance` — a test is an
294
   * attendance of its own, and making it prove otherwise would only teach the
295
   * suite to work around the rule.
296
   */
297
  function beat(): void {
298
    if (attended()) step();
299
  }
300
301
  function start(): void {
302
    if (timer) return;
303
    timer = setInterval(beat, TICK * 1000);
304
    // The clock must never hold the process open on its own.
305
    (timer as unknown as { unref?: () => void }).unref?.();
306
  }
307
308
  function stop(): void {
309
    if (timer) clearInterval(timer);
310
    timer = null;
311
  }
312
313
  /** Used by the tests: run the arena forward without waiting for real time. */
314
  function advance(seconds: number): void {
315
    for (let i = 0; i < Math.round(seconds / TICK); i++) step();
316
  }
317
318
  return {
319
    match,
320
    findPlayer,
321
    addressCapped,
322
    takeSeat,
323
    attach,
324
    release,
325
    releaseAddress,
326
    seatCount,
327
    watch,
328
    unwatch,
329
    spectatorSnapshot,
330
    command,
331
    snapshot,
332
    snapshotForSeat,
333
    attendance,
334
    beat,
335
    start,
336
    stop,
337
    advance,
338
  };
339
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts