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

forked from benjivers/val

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

Keyboard shortcuts