pike/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

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

Keyboard shortcuts