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

forked from benjivers/val

workbenchthis machine
Files a07c6a614323699908d79cb29213e15bff012908
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 type { Point } from "./map.ts";
17
import {
18
  buyAbility,
19
  buyShield,
20
  buyWeapon,
21
  newMatch,
22
  pickAgent,
23
  setInput,
24
  tick,
25
  visible,
26
} from "./match.ts";
27
import type { MatchState, Player } from "./state.ts";
28
import { isWeaponId } from "./weapons.ts";
29
30
export const TICK = 1 / 30;
31
const BROADCAST_EVERY = 2;
32
33
export type Seat = { playerId: string; send: (data: string) => void };
34
35
let state: MatchState = newMatch();
36
const seats = new Map<string, Seat>();
37
let ticks = 0;
38
let lastResult: string | null = null;
39
40
export const match = (): MatchState => state;
41
42
export const findPlayer = (id: string): Player | undefined => state.players.find((p) => p.id === id);
43
44
/** Sit somebody down in the emptiest team, replacing a bot where it stands. */
45
export function takeSeat(session: string, name: string): Player | null {
46
  const existing = seats.get(session);
47
  const held = existing ? findPlayer(existing.playerId) : undefined;
48
  if (held) {
49
    held.name = name;
50
    return held;
51
  }
52
  const humans = { red: 0, blue: 0 };
53
  for (const player of state.players) if (!player.bot) humans[player.team] += 1;
54
  const wanted = humans.red <= humans.blue ? "red" : "blue";
55
  const seat =
56
    state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot);
57
  if (!seat) return null;
58
  seat.bot = false;
59
  seat.name = name;
60
  seat.think = 0;
61
  seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null };
62
  return seat;
63
}
64
65
export function attach(session: string, playerId: string, send: (data: string) => void): void {
66
  seats.set(session, { playerId, send });
67
}
68
69
/** A person leaving gives the seat back to a bot, mid-round and all. */
70
export function release(session: string): void {
71
  const seat = seats.get(session);
72
  seats.delete(session);
73
  if (!seat) return;
74
  const player = findPlayer(seat.playerId);
75
  if (player && !stillHeld(seat.playerId)) {
76
    player.bot = true;
77
    player.think = 0;
78
    player.input.fire = false;
79
    player.input.use = false;
80
  }
81
}
82
83
const stillHeld = (playerId: string): boolean =>
84
  [...seats.values()].some((s) => s.playerId === playerId);
85
86
export const seatCount = (): number => seats.size;
87
88
// ------------------------------------------------------------- the commands
89
90
export type Command = { t: string; [key: string]: unknown };
91
92
const num = (value: unknown): number | undefined =>
93
  typeof value === "number" && Number.isFinite(value) ? value : undefined;
94
95
/** Everything a client may ask for, validated here and nowhere else. */
96
export function command(playerId: string, message: Command): void {
97
  const player = findPlayer(playerId);
98
  if (!player || player.bot) return;
99
  switch (message.t) {
100
    case "i":
101
      setInput(player, {
102
        mx: num(message.mx),
103
        my: num(message.my),
104
        angle: num(message.a),
105
        fire: typeof message.f === "boolean" ? message.f : undefined,
106
        use: typeof message.u === "boolean" ? message.u : undefined,
107
        reload: typeof message.r === "boolean" ? message.r : undefined,
108
        cast: message.c === "c" || message.c === "q" || message.c === "e" || message.c === "x" ? message.c : undefined,
109
      });
110
      break;
111
    case "buy": {
112
      const what = String(message.what ?? "");
113
      if (isWeaponId(what)) buyWeapon(state, player, what);
114
      else if (what === "light" || what === "heavy") buyShield(state, player, what);
115
      else if (what === "c" || what === "q") buyAbility(state, player, what);
116
      break;
117
    }
118
    case "agent":
119
      if (typeof message.id === "string") pickAgent(state, player, message.id);
120
      break;
121
  }
122
}
123
124
// ------------------------------------------------------------- the snapshot
125
126
const round2 = (n: number): number => Math.round(n * 10) / 10;
127
128
export function snapshot(viewerId: string): string {
129
  const me = findPlayer(viewerId) ?? state.players[0]!;
130
  const agent = agentById(me.agent);
131
  const seen = state.players.filter((p) => visible(state, me, p));
132
  const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones);
133
134
  return JSON.stringify({
135
    t: "s",
136
    phase: state.phase,
137
    clock: round2(Math.max(0, state.clock)),
138
    round: state.round,
139
    score: state.score,
140
    attackers: state.attackers,
141
    result: state.result,
142
    me: {
143
      id: me.id,
144
      side: me.side,
145
      team: me.team,
146
      agent: me.agent,
147
      alive: me.alive,
148
      x: round2(me.x),
149
      y: round2(me.y),
150
      angle: round2(me.angle),
151
      hp: Math.round(me.hp),
152
      armour: Math.round(me.armour),
153
      credits: me.credits,
154
      weapon: me.weapon,
155
      ammo: me.ammo,
156
      reloading: me.reloadIn > 0,
157
      charges: me.charges,
158
      cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 },
159
      ult: me.ult,
160
      ultNeeds: agent.abilities.x.points,
161
      blind: round2(me.blind),
162
      slow: round2(me.slow),
163
      haste: round2(me.haste),
164
      spike: me.spike,
165
      planting: round2(me.planting),
166
      bot: me.bot,
167
    },
168
    players: seen.map((p) => ({
169
      id: p.id,
170
      name: p.name,
171
      agent: p.agent,
172
      x: round2(p.x),
173
      y: round2(p.y),
174
      angle: round2(p.angle),
175
      hp: Math.round(p.hp),
176
      armour: Math.round(p.armour),
177
      alive: p.alive,
178
      mate: p.side === me.side,
179
      spike: p.spike,
180
      blind: p.blind > 0,
181
      revealed: p.revealed > 0,
182
    })),
183
    zones: state.zones
184
      .filter((z) => (z.kind === "trip" ? z.side === me.side : true))
185
      .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })),
186
    spike: {
187
      planted: state.spike.planted,
188
      carried: state.spike.carried !== null,
189
      fuse: round2(state.spike.fuse),
190
      defuse: round2(state.spike.defuse),
191
      checkpoint: state.spike.checkpoint,
192
      // A loose spike is the attackers' business until it is armed.
193
      x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null,
194
      y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null,
195
    },
196
    orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })),
197
    shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })),
198
    board: state.players.map((p) => ({
199
      id: p.id,
200
      name: p.name,
201
      team: p.team,
202
      side: p.side,
203
      agent: p.agent,
204
      bot: p.bot,
205
      alive: p.alive,
206
      kills: p.kills,
207
      deaths: p.deaths,
208
      damage: Math.round(p.damageDone),
209
      credits: p.team === me.team ? p.credits : null,
210
      ult: p.ult,
211
      ultNeeds: agentById(p.agent).abilities.x.points,
212
    })),
213
    events: state.events.slice(0, 5).map((e) => e.text),
214
  });
215
}
216
217
// ----------------------------------------------------------------- the loop
218
219
function step(): void {
220
  runBots(state, TICK);
221
  tick(state, TICK);
222
  if (state.result && state.result !== lastResult) {
223
    lastResult = state.result;
224
    recordResult(state.result, state.score.red, state.score.blue, state.round);
225
  }
226
  if (state.result === null) lastResult = null;
227
  ticks += 1;
228
  if (ticks % BROADCAST_EVERY !== 0) return;
229
  for (const seat of seats.values()) {
230
    try {
231
      seat.send(snapshot(seat.playerId));
232
    } catch {
233
      // A socket that will not take a frame is closed already; the close
234
      // handler gives the seat back.
235
    }
236
  }
237
}
238
239
let timer: ReturnType<typeof setInterval> | null = null;
240
241
export function startArena(): void {
242
  if (timer) return;
243
  timer = setInterval(step, TICK * 1000);
244
  // The clock must never hold the process open on its own.
245
  (timer as unknown as { unref?: () => void }).unref?.();
246
}
247
248
export function stopArena(): void {
249
  if (timer) clearInterval(timer);
250
  timer = null;
251
}
252
253
/** Used by the tests: run the arena forward without waiting for real time. */
254
export function advance(seconds: number): void {
255
  for (let i = 0; i < Math.round(seconds / TICK); i++) step();
256
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts