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

forked from benjivers/val

workbenchthis machine
Files af76783003008f39b49ed099fd4a39b3eb6afc55
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
  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
  /** 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: () => {}, stickyCache: "" });
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, stickyCache: "" });
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 me = findPlayer(viewerId);
220
    // No seat, no eyes: an id that names nobody gets exactly what a seatless
221
    // connection already gets, never another player's vision by accident.
222
    if (!me) return { t: "s", spectator: true, why: "unsaid", ...game.spectatorSnapshot(state, "unsaid") };
223
    const players = game.players(state);
224
    const seen = players.filter((p) => game.visible(state, me, p));
225
    return { t: "s", ...game.snapshot(state, me, seen) };
226
  }
227
228
  /** The full snapshot for one viewer, as one string. */
229
  const snapshot = (viewerId: string): string => JSON.stringify(snapshotFor(viewerId));
230
231
  /**
232
   * The snapshot a seat is actually sent on this tick: everything, every time,
233
   * except the sticky field, which goes out only when it differs from what
234
   * this seat last received.
235
   */
236
  function snapshotForSeat(seat: Seat): string {
237
    const full = snapshotFor(seat.playerId);
238
    if (game.sticky !== null) {
239
      const part = JSON.stringify(full[game.sticky]);
240
      if (part === seat.stickyCache) delete full[game.sticky];
241
      else seat.stickyCache = part;
242
    }
243
    return JSON.stringify(full);
244
  }
245
246
  // --------------------------------------------------------------- the loop
247
248
  /**
249
   * Nobody watching, nothing happens.
250
   *
251
   * An arena that ticks around the clock plays out rounds for an empty room —
252
   * burning a share of a small box's CPU and filling the record with matches
253
   * no one saw. The world freezes when the last connection goes and carries on
254
   * from exactly where it stopped when someone arrives, so a visitor never
255
   * finds a score that ran up while they were away.
256
   *
257
   * A spectator counts as somebody: watching is watching.
258
   */
259
  const attended = (): boolean => seats.size > 0 || spectators.size > 0;
260
261
  /** How many connections the arena is playing for: seats plus watchers. */
262
  const attendance = (): number => seats.size + spectators.size;
263
264
  function step(): void {
265
    game.step(state, TICK);
266
    ticks += 1;
267
    if (ticks % broadcastEvery(seats.size) !== 0) return;
268
    for (const seat of seats.values()) {
269
      try {
270
        seat.send(snapshotForSeat(seat));
271
      } catch {
272
        // A socket that will not take a frame is closed already; the close
273
        // handler gives the seat back.
274
      }
275
    }
276
    for (const spectator of spectators.values()) {
277
      try {
278
        spectator.send(spectatorSnapshot(spectator.why));
279
      } catch {
280
        // Same story: the close handler drops it from the set.
281
      }
282
    }
283
  }
284
285
  let timer: ReturnType<typeof setInterval> | null = null;
286
287
  /**
288
   * One beat of real time. The gate lives here rather than inside `step` so the
289
   * tests can still drive the simulation directly with `advance` — a test is an
290
   * attendance of its own, and making it prove otherwise would only teach the
291
   * suite to work around the rule.
292
   */
293
  function beat(): void {
294
    if (attended()) step();
295
  }
296
297
  function start(): void {
298
    if (timer) return;
299
    timer = setInterval(beat, TICK * 1000);
300
    // The clock must never hold the process open on its own.
301
    (timer as unknown as { unref?: () => void }).unref?.();
302
  }
303
304
  function stop(): void {
305
    if (timer) clearInterval(timer);
306
    timer = null;
307
  }
308
309
  /** Used by the tests: run the arena forward without waiting for real time. */
310
  function advance(seconds: number): void {
311
    for (let i = 0; i < Math.round(seconds / TICK); i++) step();
312
  }
313
314
  return {
315
    match,
316
    findPlayer,
317
    addressCapped,
318
    takeSeat,
319
    attach,
320
    release,
321
    releaseAddress,
322
    seatCount,
323
    watch,
324
    unwatch,
325
    spectatorSnapshot,
326
    command,
327
    snapshot,
328
    snapshotForSeat,
329
    attendance,
330
    beat,
331
    start,
332
    stop,
333
    advance,
334
  };
335
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts