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

forked from benjivers/val

workbenchthis machine
Files 71fdca9197015b0be806030e820b73359808605a
366 lines · 12.4 KB · ~3.6k tok Raw History
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
const BROADCAST_EVERY = 2;
33
34
export type Seat = { playerId: string; token: string; send: (data: string) => void };
35
36
let state: MatchState = newMatch();
37
const seats = new Map<string, Seat>();
38
let ticks = 0;
39
let lastResult: string | null = null;
40
// The kill feed is read off the match a tick at a time; the rules keep no
41
// list of their own, and this is a view, not a rule.
42
let marks: Mark[] = marksOf(state.players);
43
let feed: Fall[] = [];
44
45
export const match = (): MatchState => state;
46
47
export const findPlayer = (id: string): Player | undefined => state.players.find((p) => p.id === id);
48
49
/**
50
 * A name nobody else in this match is using.
51
 *
52
 * The kill feed, the round log and the scoreboard all address people by
53
 * name: two players called Kit make a line that reads as a team kill this
54
 * game cannot have. The bots are dealt distinct names; a person who picks
55
 * one that is taken gets a number after it, inside the sixteen characters a
56
 * name is allowed.
57
 */
58
function freeName(want: string, exceptId: string): string {
59
  const taken = new Set(state.players.filter((p) => p.id !== exceptId).map((p) => p.name));
60
  if (!taken.has(want)) return want;
61
  for (let n = 2; n < 100; n++) {
62
    const tried = `${want.slice(0, 13)} ${n}`;
63
    if (!taken.has(tried)) return tried;
64
  }
65
  return want;
66
}
67
68
/**
69
 * Sit somebody down in the emptiest team, replacing a bot where it stands.
70
 *
71
 * The claim goes into `seats` right here, synchronously, before the caller
72
 * ever opens a socket. A websocket upgrade completes on a later tick, so two
73
 * requests for the same session arriving close together both used to read
74
 * `seats` before either had written to it, and both walked away with their
75
 * own seat on opposite teams — one client, two sides, the vision of both.
76
 * Claiming inline closes that window: the second call sees the first's claim.
77
 */
78
export function takeSeat(session: string, name: string): Player | null {
79
  const existing = seats.get(session);
80
  const held = existing ? findPlayer(existing.playerId) : undefined;
81
  if (held) {
82
    held.name = freeName(name, held.id);
83
    return held;
84
  }
85
  const humans = { red: 0, blue: 0 };
86
  for (const player of state.players) if (!player.bot) humans[player.team] += 1;
87
  const wanted = humans.red <= humans.blue ? "red" : "blue";
88
  const seat =
89
    state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot);
90
  if (!seat) return null;
91
  seat.bot = false;
92
  seat.name = freeName(name, seat.id);
93
  seat.think = 0;
94
  seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null };
95
  seats.set(session, { playerId: seat.id, token: "", send: () => {} });
96
  return seat;
97
}
98
99
/** The socket that actually opened fills in how to reach it and its token. */
100
export function attach(session: string, playerId: string, token: string, send: (data: string) => void): void {
101
  seats.set(session, { playerId, token, send });
102
}
103
104
/**
105
 * A person leaving gives the seat back to a bot, mid-round and all — but only
106
 * if this is still the socket the session is on. A reload opens the new
107
 * socket before the old one's close event arrives; without the token, that
108
 * late close would delete the reconnect's seat entry and hand a live human's
109
 * seat to a bot mid-round.
110
 */
111
export function release(session: string, token: string): void {
112
  const seat = seats.get(session);
113
  if (!seat || seat.token !== token) return;
114
  seats.delete(session);
115
  const player = findPlayer(seat.playerId);
116
  if (player && !stillHeld(seat.playerId)) {
117
    player.bot = true;
118
    player.think = 0;
119
    player.input.fire = false;
120
    player.input.use = false;
121
  }
122
}
123
124
const stillHeld = (playerId: string): boolean =>
125
  [...seats.values()].some((s) => s.playerId === playerId);
126
127
export const seatCount = (): number => seats.size;
128
129
// -------------------------------------------------------------- spectators
130
131
// A connection that never carried a session the page minted gets no side and
132
// no seat: only the public state everyone already sees on the page itself.
133
const spectators = new Map<string, (data: string) => void>();
134
135
export function spectatorSnapshot(): string {
136
  return JSON.stringify({
137
    t: "s",
138
    spectator: true,
139
    phase: state.phase,
140
    clock: round2(Math.max(0, state.clock)),
141
    round: state.round,
142
    score: state.score,
143
    attackers: state.attackers,
144
    result: state.result,
145
    events: state.events.slice(0, 5).map((e) => e.text),
146
  });
147
}
148
149
export function watch(token: string, send: (data: string) => void): void {
150
  spectators.set(token, send);
151
  send(spectatorSnapshot());
152
}
153
154
export function unwatch(token: string): void {
155
  spectators.delete(token);
156
}
157
158
// ------------------------------------------------------------- the commands
159
160
export type Command = { t: string; [key: string]: unknown };
161
162
const num = (value: unknown): number | undefined =>
163
  typeof value === "number" && Number.isFinite(value) ? value : undefined;
164
165
/** Everything a client may ask for, validated here and nowhere else. */
166
export function command(playerId: string, message: Command): void {
167
  const player = findPlayer(playerId);
168
  if (!player || player.bot) return;
169
  switch (message.t) {
170
    case "i":
171
      setInput(player, {
172
        mx: num(message.mx),
173
        my: num(message.my),
174
        angle: num(message.a),
175
        fire: typeof message.f === "boolean" ? message.f : undefined,
176
        use: typeof message.u === "boolean" ? message.u : undefined,
177
        reload: typeof message.r === "boolean" ? message.r : undefined,
178
        cast: message.c === "c" || message.c === "q" || message.c === "e" || message.c === "x" ? message.c : undefined,
179
      });
180
      break;
181
    case "buy": {
182
      const what = String(message.what ?? "");
183
      if (isWeaponId(what)) buyWeapon(state, player, what);
184
      else if (what === "light" || what === "heavy") buyShield(state, player, what);
185
      else if (what === "c" || what === "q") buyAbility(state, player, what);
186
      break;
187
    }
188
    case "agent":
189
      if (typeof message.id === "string") pickAgent(state, player, message.id);
190
      break;
191
  }
192
}
193
194
// ------------------------------------------------------------- the snapshot
195
196
const round2 = (n: number): number => Math.round(n * 10) / 10;
197
198
export function snapshot(viewerId: string): string {
199
  const me = findPlayer(viewerId) ?? state.players[0]!;
200
  const agent = agentById(me.agent);
201
  const seen = state.players.filter((p) => visible(state, me, p));
202
  const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones);
203
204
  return JSON.stringify({
205
    t: "s",
206
    phase: state.phase,
207
    clock: round2(Math.max(0, state.clock)),
208
    round: state.round,
209
    score: state.score,
210
    attackers: state.attackers,
211
    result: state.result,
212
    me: {
213
      id: me.id,
214
      side: me.side,
215
      team: me.team,
216
      agent: me.agent,
217
      alive: me.alive,
218
      x: round2(me.x),
219
      y: round2(me.y),
220
      angle: round2(me.angle),
221
      hp: Math.round(me.hp),
222
      armour: Math.round(me.armour),
223
      credits: me.credits,
224
      weapon: me.weapon,
225
      ammo: me.ammo,
226
      reloading: me.reloadIn > 0,
227
      charges: me.charges,
228
      cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 },
229
      ult: me.ult,
230
      ultNeeds: agent.abilities.x.points,
231
      blind: round2(me.blind),
232
      slow: round2(me.slow),
233
      haste: round2(me.haste),
234
      spike: me.spike,
235
      planting: round2(me.planting),
236
      bot: me.bot,
237
    },
238
    players: seen.map((p) => ({
239
      id: p.id,
240
      name: p.name,
241
      agent: p.agent,
242
      x: round2(p.x),
243
      y: round2(p.y),
244
      angle: round2(p.angle),
245
      hp: Math.round(p.hp),
246
      armour: Math.round(p.armour),
247
      alive: p.alive,
248
      mate: p.side === me.side,
249
      spike: p.spike,
250
      blind: p.blind > 0,
251
      revealed: p.revealed > 0,
252
    })),
253
    zones: state.zones
254
      .filter((z) => (z.kind === "trip" ? z.side === me.side : true))
255
      .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })),
256
    spike: {
257
      planted: state.spike.planted,
258
      carried: state.spike.carried !== null,
259
      fuse: round2(state.spike.fuse),
260
      defuse: round2(state.spike.defuse),
261
      checkpoint: state.spike.checkpoint,
262
      // A loose spike is the attackers' business until it is armed.
263
      x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null,
264
      y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null,
265
    },
266
    orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })),
267
    shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })),
268
    board: state.players.map((p) => ({
269
      id: p.id,
270
      name: p.name,
271
      team: p.team,
272
      side: p.side,
273
      agent: p.agent,
274
      bot: p.bot,
275
      alive: p.alive,
276
      kills: p.kills,
277
      deaths: p.deaths,
278
      damage: Math.round(p.damageDone),
279
      credits: p.team === me.team ? p.credits : null,
280
      ult: p.ult,
281
      ultNeeds: agentById(p.agent).abilities.x.points,
282
    })),
283
    events: state.events.slice(0, 5).map((e) => e.text),
284
    // Names and a gun, never a position: the feed says what happened, not
285
    // where, so it can never be read as a call-out.
286
    feed: feed.map((f) => ({
287
      k: f.killer,
288
      v: f.victim,
289
      w: f.weapon ? WEAPONS[f.weapon].name : null,
290
      h: f.headshot,
291
      t: round2(Math.max(0, state.now - f.at)),
292
    })),
293
  });
294
}
295
296
/**
297
 * Who fell on this tick. A shot that landed this tick and started where the
298
 * killer stands is what tells a gun kill from an ability kill — the position
299
 * is used here and goes no further.
300
 */
301
function readFalls(): void {
302
  const after = marksOf(state.players);
303
  const shooters = new Set<string>();
304
  for (const shot of state.shots) {
305
    if (!shot.hit || shot.age > 0) continue;
306
    for (const player of state.players)
307
      if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id);
308
  }
309
  const falls = fallsBetween(marks, after, {
310
    at: state.now,
311
    events: state.events.slice(0, 6).map((e) => e.text),
312
    shooters,
313
  });
314
  marks = after;
315
  if (falls.length) feed = [...feed, ...falls];
316
  feed = trimFeed(feed, state.now);
317
}
318
319
// ----------------------------------------------------------------- the loop
320
321
function step(): void {
322
  runBots(state, TICK);
323
  tick(state, TICK);
324
  readFalls();
325
  if (state.result && state.result !== lastResult) {
326
    lastResult = state.result;
327
    recordResult(state.result, state.score.red, state.score.blue, state.round);
328
  }
329
  if (state.result === null) lastResult = null;
330
  ticks += 1;
331
  if (ticks % BROADCAST_EVERY !== 0) return;
332
  for (const seat of seats.values()) {
333
    try {
334
      seat.send(snapshot(seat.playerId));
335
    } catch {
336
      // A socket that will not take a frame is closed already; the close
337
      // handler gives the seat back.
338
    }
339
  }
340
  for (const send of spectators.values()) {
341
    try {
342
      send(spectatorSnapshot());
343
    } catch {
344
      // Same story: the close handler drops it from the set.
345
    }
346
  }
347
}
348
349
let timer: ReturnType<typeof setInterval> | null = null;
350
351
export function startArena(): void {
352
  if (timer) return;
353
  timer = setInterval(step, TICK * 1000);
354
  // The clock must never hold the process open on its own.
355
  (timer as unknown as { unref?: () => void }).unref?.();
356
}
357
358
export function stopArena(): void {
359
  if (timer) clearInterval(timer);
360
  timer = null;
361
}
362
363
/** Used by the tests: run the arena forward without waiting for real time. */
364
export function advance(seconds: number): void {
365
  for (let i = 0; i < Math.round(seconds / TICK); i++) step();
366
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts