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

forked from benjivers/val

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

Keyboard shortcuts