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

forked from benjivers/val

workbenchthis machine
Files b02c1ef0dc92246d3da3567c343126686c5677c3
1
/**
2
 * The arena: one match, always running, and the seats people sit in.
3
 *
4
 * The match ticks whether anybody is watching or not, so a visitor never
5
 * arrives at a frozen board — they get the round that is happening. Taking a
6
 * seat replaces a bot in place: same body, same credits, same position, so
7
 * joining mid-round hands you a live player rather than a lobby.
8
 *
9
 * Every snapshot is built for one viewer: enemies they cannot see are not in
10
 * it. The client never learns a position it has no line of sight to.
11
 */
12
13
import { agentById, sightBlocked } from "./abilities.ts";
14
import { runBots } from "./bots.ts";
15
import { recordResult } from "./db.ts";
16
import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts";
17
import type { Point } from "./map.ts";
18
import {
19
  buyAbility,
20
  buyShield,
21
  buyWeapon,
22
  newMatch,
23
  pickAgent,
24
  setInput,
25
  tick,
26
  visible,
27
} from "./match.ts";
28
import type { MatchState, Player } from "./state.ts";
29
import { isWeaponId, WEAPONS } from "./weapons.ts";
30
31
export const TICK = 1 / 30;
32
33
/**
34
 * How many ticks apart the snapshots go out.
35
 *
36
 * The simulation runs at 30Hz and used to broadcast every other tick, which
37
 * gave the browser fifteen positions a second to draw sixty frames from —
38
 * not many, and with interpolation it also doubles the delay the drawing has
39
 * to sit behind the present. A small room gets every tick: it is the case
40
 * that matters (one or two people playing each other), the payload is a
41
 * couple of kilobytes of JSON a seat, and the arena only runs at all while
42
 * somebody is connected. Above that the cost multiplies by seats and the
43
 * older cadence comes back.
44
 */
45
export const broadcastEvery = (filled: number): number => (filled <= 2 ? 1 : 2);
46
47
export type Seat = {
48
  playerId: string;
49
  token: string;
50
  address: string;
51
  send: (data: string) => void;
52
  /** The scoreboard as this seat last received it, so it is only resent when it changes. */
53
  board: string;
54
};
55
56
let state: MatchState = newMatch();
57
const seats = new Map<string, Seat>();
58
// A session is a cookie, and a cookie costs one HTTP request — free for
59
// anyone to mint as many of as they like. The address is the one thing here
60
// a single machine cannot hand itself two of, so it is what a playing seat
61
// is actually rationed by; the session on top of it is only what lets a
62
// reload find its way back to the same seat rather than a fresh one.
63
const addressSeats = new Map<string, string>();
64
let ticks = 0;
65
let lastResult: string | null = null;
66
// The kill feed is read off the match a tick at a time; the rules keep no
67
// list of their own, and this is a view, not a rule.
68
let marks: Mark[] = marksOf(state.players);
69
let feed: Fall[] = [];
70
71
export const match = (): MatchState => state;
72
73
export const findPlayer = (id: string): Player | undefined => state.players.find((p) => p.id === id);
74
75
/**
76
 * A name nobody else in this match is using.
77
 *
78
 * The kill feed, the round log and the scoreboard all address people by
79
 * name: two players called Kit make a line that reads as a team kill this
80
 * game cannot have. The bots are dealt distinct names; a person who picks
81
 * one that is taken gets a number after it, inside the sixteen characters a
82
 * name is allowed.
83
 */
84
function freeName(want: string, exceptId: string): string {
85
  const taken = new Set(state.players.filter((p) => p.id !== exceptId).map((p) => p.name));
86
  if (!taken.has(want)) return want;
87
  for (let n = 2; n < 100; n++) {
88
    const tried = `${want.slice(0, 13)} ${n}`;
89
    if (!taken.has(tried)) return tried;
90
  }
91
  return want;
92
}
93
94
/**
95
 * Whether this address already plays under a different session. Checked
96
 * before a socket is even offered a seat, so the caller can hand it a
97
 * spectator view instead of upgrading it into a second side's vision.
98
 */
99
export function addressCapped(address: string, session: string): boolean {
100
  const holder = addressSeats.get(address);
101
  return holder !== undefined && holder !== session;
102
}
103
104
/**
105
 * Sit somebody down in the emptiest team, replacing a bot where it stands.
106
 *
107
 * Both claims go in right here, synchronously, before the caller ever opens
108
 * a socket. A websocket upgrade completes on a later tick, so two requests
109
 * arriving close together both used to read the maps before either had
110
 * written to them — one client, two seats, opposite sides, the vision of
111
 * both. Claiming inline closes that window regardless of how the two
112
 * requests were related.
113
 */
114
export function takeSeat(address: string, session: string, name: string): Player | null {
115
  const existing = seats.get(session);
116
  const held = existing ? findPlayer(existing.playerId) : undefined;
117
  if (held) {
118
    held.name = freeName(name, held.id);
119
    return held;
120
  }
121
  if (addressCapped(address, session)) return null;
122
  const humans = { red: 0, blue: 0 };
123
  for (const player of state.players) if (!player.bot) humans[player.team] += 1;
124
  const wanted = humans.red <= humans.blue ? "red" : "blue";
125
  const seat =
126
    state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot);
127
  if (!seat) return null;
128
  seat.bot = false;
129
  seat.name = freeName(name, seat.id);
130
  seat.think = 0;
131
  seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null };
132
  addressSeats.set(address, session);
133
  seats.set(session, { playerId: seat.id, token: "", address, send: () => {}, board: "" });
134
  return seat;
135
}
136
137
/** The socket that actually opened fills in how to reach it and its token. */
138
export function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void {
139
  seats.set(session, { playerId, token, address, send, board: "" });
140
}
141
142
/**
143
 * A person leaving gives the seat back to a bot, mid-round and all — but only
144
 * if this is still the socket the session is on. A reload opens the new
145
 * socket before the old one's close event arrives; without the token, that
146
 * late close would delete the reconnect's seat entry and hand a live human's
147
 * seat to a bot mid-round.
148
 */
149
export function release(session: string, token: string): void {
150
  const seat = seats.get(session);
151
  if (!seat || seat.token !== token) return;
152
  seats.delete(session);
153
  if (addressSeats.get(seat.address) === session) addressSeats.delete(seat.address);
154
  const player = findPlayer(seat.playerId);
155
  if (player && !stillHeld(seat.playerId)) {
156
    player.bot = true;
157
    player.think = 0;
158
    player.input.fire = false;
159
    player.input.use = false;
160
  }
161
}
162
163
/**
164
 * Test-only hygiene: give back whatever this address currently holds. The
165
 * arena is one match shared by the whole test run, over real HTTP, from one
166
 * loopback address — this is how a test that needs that address free gets it
167
 * free, rather than depending on every other test in the file to clean up
168
 * after itself.
169
 */
170
export function releaseAddress(address: string): void {
171
  const session = addressSeats.get(address);
172
  if (session !== undefined) release(session, seats.get(session)?.token ?? "");
173
}
174
175
const stillHeld = (playerId: string): boolean =>
176
  [...seats.values()].some((s) => s.playerId === playerId);
177
178
export const seatCount = (): number => seats.size;
179
180
// -------------------------------------------------------------- spectators
181
182
// A connection that never carried a session the page minted gets no side and
183
// no seat: only the public state everyone already sees on the page itself.
184
const spectators = new Map<string, { send: (data: string) => void; why: Why }>();
185
186
/**
187
 * Why this connection is watching. The client says so in words, and the two
188
 * have different answers — open the tab that is playing, or let the page keep
189
 * its cookie — so which one it is travels with the snapshot.
190
 */
191
export type Why = "address" | "cookie" | "unsaid";
192
193
export function spectatorSnapshot(why: Why = "unsaid"): string {
194
  return JSON.stringify({
195
    t: "s",
196
    spectator: true,
197
    why,
198
    phase: state.phase,
199
    clock: round2(Math.max(0, state.clock)),
200
    round: state.round,
201
    score: state.score,
202
    attackers: state.attackers,
203
    result: state.result,
204
    last: state.last,
205
    events: state.events.slice(0, 5).map((e) => e.text),
206
    // Names and a gun, the same as a seat is sent, and for the same reason it
207
    // is safe there: the feed says what happened, never where.
208
    feed: feed.map((f) => ({
209
      k: f.killer,
210
      v: f.victim,
211
      w: f.weapon ? WEAPONS[f.weapon].name : null,
212
      h: f.headshot,
213
      t: round2(Math.max(0, state.now - f.at)),
214
    })),
215
  });
216
}
217
218
export function watch(token: string, send: (data: string) => void, why: Why = "unsaid"): void {
219
  spectators.set(token, { send, why });
220
  send(spectatorSnapshot(why));
221
}
222
223
export function unwatch(token: string): void {
224
  spectators.delete(token);
225
}
226
227
// ------------------------------------------------------------- the commands
228
229
export type Command = { t: string; [key: string]: unknown };
230
231
const num = (value: unknown): number | undefined =>
232
  typeof value === "number" && Number.isFinite(value) ? value : undefined;
233
234
/** Everything a client may ask for, validated here and nowhere else. */
235
export function command(playerId: string, message: Command): void {
236
  const player = findPlayer(playerId);
237
  if (!player || player.bot) return;
238
  switch (message.t) {
239
    case "i":
240
      setInput(player, {
241
        mx: num(message.mx),
242
        my: num(message.my),
243
        angle: num(message.a),
244
        fire: typeof message.f === "boolean" ? message.f : undefined,
245
        use: typeof message.u === "boolean" ? message.u : undefined,
246
        reload: typeof message.r === "boolean" ? message.r : undefined,
247
        cast: message.c === "c" || message.c === "q" || message.c === "e" || message.c === "x" ? message.c : undefined,
248
      });
249
      break;
250
    case "buy": {
251
      const what = String(message.what ?? "");
252
      if (isWeaponId(what)) buyWeapon(state, player, what);
253
      else if (what === "light" || what === "heavy") buyShield(state, player, what);
254
      else if (what === "c" || what === "q") buyAbility(state, player, what);
255
      break;
256
    }
257
    case "agent":
258
      if (typeof message.id === "string") pickAgent(state, player, message.id);
259
      break;
260
  }
261
}
262
263
// ------------------------------------------------------------- the snapshot
264
265
const round2 = (n: number): number => Math.round(n * 10) / 10;
266
267
/** The full snapshot for one viewer, as one string. */
268
export const snapshot = (viewerId: string): string => JSON.stringify(snapshotFor(viewerId));
269
270
/**
271
 * The snapshot a seat is actually sent on this tick. The scoreboard — ten
272
 * names, agents and tallies — was almost half of every snapshot and changes
273
 * a few times a round, so it goes out only when it differs from what this
274
 * seat last received; the client keeps the one it has. Everything else is
275
 * the full snapshot, every time.
276
 */
277
export function snapshotForSeat(seat: Seat): string {
278
  const full = snapshotFor(seat.playerId);
279
  const board = JSON.stringify(full.board);
280
  if (board === seat.board) delete (full as { board?: unknown }).board;
281
  else seat.board = board;
282
  return JSON.stringify(full);
283
}
284
285
function snapshotFor(viewerId: string) {
286
  const me = findPlayer(viewerId) ?? state.players[0]!;
287
  const agent = agentById(me.agent);
288
  const seen = state.players.filter((p) => visible(state, me, p));
289
  const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones);
290
291
  return {
292
    t: "s",
293
    phase: state.phase,
294
    clock: round2(Math.max(0, state.clock)),
295
    round: state.round,
296
    score: state.score,
297
    attackers: state.attackers,
298
    result: state.result,
299
    last: state.last,
300
    me: {
301
      id: me.id,
302
      side: me.side,
303
      team: me.team,
304
      agent: me.agent,
305
      alive: me.alive,
306
      x: round2(me.x),
307
      y: round2(me.y),
308
      angle: round2(me.angle),
309
      hp: Math.round(me.hp),
310
      armour: Math.round(me.armour),
311
      credits: me.credits,
312
      weapon: me.weapon,
313
      ammo: me.ammo,
314
      reloading: me.reloadIn > 0,
315
      charges: me.charges,
316
      cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 },
317
      ult: me.ult,
318
      ultNeeds: agent.abilities.x.points,
319
      blind: round2(me.blind),
320
      slow: round2(me.slow),
321
      haste: round2(me.haste),
322
      spike: me.spike,
323
      planting: round2(me.planting),
324
      // Your own two ways of being held still, so the browser's prediction
325
      // of your own body runs at the speed the server is about to use.
326
      defusing: me.defusing > 0,
327
      bot: me.bot,
328
    },
329
    players: seen.map((p) => ({
330
      id: p.id,
331
      name: p.name,
332
      agent: p.agent,
333
      x: round2(p.x),
334
      y: round2(p.y),
335
      angle: round2(p.angle),
336
      hp: Math.round(p.hp),
337
      armour: Math.round(p.armour),
338
      alive: p.alive,
339
      mate: p.side === me.side,
340
      spike: p.spike,
341
      blind: p.blind > 0,
342
      revealed: p.revealed > 0,
343
    })),
344
    zones: state.zones
345
      .filter((z) => (z.kind === "trip" ? z.side === me.side : true))
346
      .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })),
347
    spike: {
348
      planted: state.spike.planted,
349
      carried: state.spike.carried !== null,
350
      fuse: round2(state.spike.fuse),
351
      defuse: round2(state.spike.defuse),
352
      checkpoint: state.spike.checkpoint,
353
      // A loose spike is the attackers' business until it is armed.
354
      x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null,
355
      y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null,
356
    },
357
    orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })),
358
    shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })),
359
    board: state.players.map((p) => ({
360
      id: p.id,
361
      name: p.name,
362
      team: p.team,
363
      side: p.side,
364
      agent: p.agent,
365
      bot: p.bot,
366
      alive: p.alive,
367
      kills: p.kills,
368
      deaths: p.deaths,
369
      damage: Math.round(p.damageDone),
370
      credits: p.team === me.team ? p.credits : null,
371
      ult: p.ult,
372
      ultNeeds: agentById(p.agent).abilities.x.points,
373
    })),
374
    events: state.events.slice(0, 5).map((e) => e.text),
375
    // Names and a gun, never a position: the feed says what happened, not
376
    // where, so it can never be read as a call-out.
377
    feed: feed.map((f) => ({
378
      k: f.killer,
379
      v: f.victim,
380
      w: f.weapon ? WEAPONS[f.weapon].name : null,
381
      h: f.headshot,
382
      t: round2(Math.max(0, state.now - f.at)),
383
    })),
384
  };
385
}
386
387
/**
388
 * Who fell on this tick. A shot that landed this tick and started where the
389
 * killer stands is what tells a gun kill from an ability kill — the position
390
 * is used here and goes no further.
391
 */
392
function readFalls(): void {
393
  const after = marksOf(state.players);
394
  const shooters = new Set<string>();
395
  for (const shot of state.shots) {
396
    if (!shot.hit || shot.age > 0) continue;
397
    for (const player of state.players)
398
      if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id);
399
  }
400
  const falls = fallsBetween(marks, after, {
401
    at: state.now,
402
    events: state.events.slice(0, 6).map((e) => e.text),
403
    shooters,
404
  });
405
  marks = after;
406
  if (falls.length) feed = [...feed, ...falls];
407
  feed = trimFeed(feed, state.now);
408
}
409
410
// ----------------------------------------------------------------- the loop
411
412
/**
413
 * Nobody watching, nothing happens.
414
 *
415
 * The arena used to tick around the clock, so bots played out rounds for an
416
 * empty room — burning a share of a small box's CPU and filling the record
417
 * with matches no one saw. The world now freezes when the last connection
418
 * goes and carries on from exactly where it stopped when someone arrives, so
419
 * a visitor never finds a score that ran up while they were away.
420
 *
421
 * A spectator counts as somebody: watching is watching.
422
 */
423
const attended = (): boolean => seats.size > 0 || spectators.size > 0;
424
425
/** How many connections the arena is playing for: seats plus watchers. */
426
export const attendance = (): number => seats.size + spectators.size;
427
428
function step(): void {
429
  runBots(state, TICK);
430
  tick(state, TICK);
431
  readFalls();
432
  if (state.result && state.result !== lastResult) {
433
    lastResult = state.result;
434
    recordResult(state.result, state.score.red, state.score.blue, state.round);
435
  }
436
  if (state.result === null) lastResult = null;
437
  ticks += 1;
438
  if (ticks % broadcastEvery(seats.size) !== 0) return;
439
  for (const seat of seats.values()) {
440
    try {
441
      seat.send(snapshotForSeat(seat));
442
    } catch {
443
      // A socket that will not take a frame is closed already; the close
444
      // handler gives the seat back.
445
    }
446
  }
447
  for (const spectator of spectators.values()) {
448
    try {
449
      spectator.send(spectatorSnapshot(spectator.why));
450
    } catch {
451
      // Same story: the close handler drops it from the set.
452
    }
453
  }
454
}
455
456
let timer: ReturnType<typeof setInterval> | null = null;
457
458
/**
459
 * One beat of real time. The gate lives here rather than inside `step` so the
460
 * tests can still drive the simulation directly with `advance` — a test is an
461
 * attendance of its own, and making it prove otherwise would only teach the
462
 * suite to work around the rule.
463
 */
464
export function beat(): void {
465
  if (attended()) step();
466
}
467
468
export function startArena(): void {
469
  if (timer) return;
470
  timer = setInterval(beat, TICK * 1000);
471
  // The clock must never hold the process open on its own.
472
  (timer as unknown as { unref?: () => void }).unref?.();
473
}
474
475
export function stopArena(): void {
476
  if (timer) clearInterval(timer);
477
  timer = null;
478
}
479
480
/** Used by the tests: run the arena forward without waiting for real time. */
481
export function advance(seconds: number): void {
482
  for (let i = 0; i < Math.round(seconds / TICK); i++) step();
483
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts