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

forked from benjivers/val

workbenchthis machine
Files main
1
/**
2
 * The shape of a served match, over a toy game: the rules every served game
3
 * needs and none should have to re-derive. These are the two holes that were
4
 * closed the hard way — a seat is rationed by address, and a client is sent
5
 * only what it can see — plus the idle gate and the reload race.
6
 */
7
8
import { expect, test } from "bun:test";
9
import { broadcastEvery, makeArena, TICK, type Command } from "./arena.ts";
10
11
type Toy = { id: string; name: string; bot: boolean; side: "a" | "b"; x: number; seen: boolean };
12
type World = { ticks: number; players: Toy[]; heard: Command[] };
13
14
/** Two sides of two; everybody on side "b" is out of side "a"'s sight unless marked seen. */
15
function toyGame(cadence?: (filled: number) => number) {
16
  const world: World = {
17
    ticks: 0,
18
    heard: [],
19
    players: [
20
      { id: "a1", name: "Ash", bot: true, side: "a", x: 10, seen: false },
21
      { id: "a2", name: "Bex", bot: true, side: "a", x: 20, seen: false },
22
      { id: "b1", name: "Cade", bot: true, side: "b", x: 30, seen: false },
23
      { id: "b2", name: "Dune", bot: true, side: "b", x: 40, seen: true },
24
    ],
25
  };
26
  return makeArena<World, Toy>({
27
    state: world,
28
    players: (s) => s.players,
29
    step: (s) => void (s.ticks += 1),
30
    sit: (s) => {
31
      const seat = s.players.find((p) => p.bot) ?? null;
32
      if (seat) seat.bot = false;
33
      return seat;
34
    },
35
    stand: (_s, p) => void (p.bot = true),
36
    visible: (_s, viewer, other) => viewer.side === other.side || other.seen,
37
    snapshot: (s, me, seen) => ({
38
      me: { id: me.id, x: me.x },
39
      players: seen.map((p) => ({ id: p.id, x: p.x })),
40
      board: s.players.map((p) => ({ id: p.id, name: p.name, bot: p.bot })),
41
    }),
42
    sticky: "board",
43
    cadence,
44
    spectatorSnapshot: (s) => ({ ticks: s.ticks, names: s.players.map((p) => p.name) }),
45
    command: (s, _p, message) => void s.heard.push(message),
46
  });
47
}
48
49
test("nobody watching, nothing happens; a watcher is somebody; a test drives it regardless", () => {
50
  const arena = toyGame();
51
  arena.beat();
52
  arena.beat();
53
  expect(arena.match().ticks).toBe(0);
54
  arena.watch("w", () => {});
55
  arena.beat();
56
  expect(arena.match().ticks).toBe(1);
57
  arena.unwatch("w");
58
  arena.beat();
59
  expect(arena.match().ticks).toBe(1);
60
  arena.advance(1);
61
  expect(arena.match().ticks).toBe(1 + Math.round(1 / TICK));
62
  expect(arena.attendance()).toBe(0);
63
});
64
65
test("a playing seat is rationed by address, and a session finds its own seat back", () => {
66
  const arena = toyGame();
67
  const first = arena.takeSeat("10.0.0.1", "session-1", "Kit");
68
  expect(first?.bot).toBe(false);
69
  expect(first?.name).toBe("Kit");
70
  // A second cookie from the same machine is not a second seat.
71
  expect(arena.addressCapped("10.0.0.1", "session-2")).toBe(true);
72
  expect(arena.takeSeat("10.0.0.1", "session-2", "Kit 2")).toBeNull();
73
  // The same session asking again is handed the seat it holds, renamed.
74
  const again = arena.takeSeat("10.0.0.1", "session-1", "Kat");
75
  expect(again?.id).toBe(first!.id);
76
  expect(again?.name).toBe("Kat");
77
  expect(arena.seatCount()).toBe(1);
78
  // Another machine sits down beside them.
79
  const other = arena.takeSeat("10.0.0.2", "session-3", "Kat");
80
  expect(other?.id).not.toBe(first!.id);
81
  // And two people may not share a name: the second gets a number.
82
  expect(other?.name).toBe("Kat 2");
83
});
84
85
test("a late close from a reload does not take the live seat with it", () => {
86
  const arena = toyGame();
87
  const seat = arena.takeSeat("10.0.0.1", "s", "Kit")!;
88
  arena.attach("s", seat.id, "10.0.0.1", "old-token", () => {});
89
  arena.attach("s", seat.id, "10.0.0.1", "new-token", () => {});
90
  arena.release("s", "old-token");
91
  expect(seat.bot).toBe(false);
92
  expect(arena.seatCount()).toBe(1);
93
  arena.release("s", "new-token");
94
  expect(seat.bot).toBe(true);
95
  expect(arena.seatCount()).toBe(0);
96
  expect(arena.addressCapped("10.0.0.1", "elsewhere")).toBe(false);
97
});
98
99
test("a viewer is sent only the bodies the game says they can see", () => {
100
  const arena = toyGame();
101
  const snap = JSON.parse(arena.snapshot("a1")) as { t: string; players: { id: string }[] };
102
  expect(snap.t).toBe("s");
103
  expect(snap.players.map((p) => p.id).sort()).toEqual(["a1", "a2", "b2"]);
104
  expect(JSON.stringify(snap.players)).not.toContain('"b1"');
105
});
106
107
test("an unknown viewer gets the spectator view, never another player's eyes", () => {
108
  const arena = toyGame();
109
  const snap = JSON.parse(arena.snapshot("nobody-here")) as Record<string, unknown>;
110
  expect(snap).toMatchObject({ t: "s", spectator: true });
111
  expect(snap.me).toBeUndefined();
112
  expect(snap.players).toBeUndefined();
113
  expect(JSON.stringify(snap)).not.toContain('"x"');
114
});
115
116
test("a spectator is sent the public state and never a position", () => {
117
  const arena = toyGame();
118
  const got: string[] = [];
119
  arena.watch("w", (data) => got.push(data), "cookie");
120
  expect(got.length).toBe(1);
121
  const snap = JSON.parse(got[0]!) as Record<string, unknown>;
122
  expect(snap).toMatchObject({ t: "s", spectator: true, why: "cookie", ticks: 0 });
123
  expect(snap.players).toBeUndefined();
124
  expect(snap.me).toBeUndefined();
125
  expect(got[0]).not.toContain('"x"');
126
  arena.unwatch("w");
127
});
128
129
test("the sticky field goes out once, then only when it changes", () => {
130
  const arena = toyGame();
131
  const seat = { playerId: "a1", token: "t", address: "10.0.0.1", send: () => {}, stickyCache: "" };
132
  const first = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] };
133
  expect(first.board?.length).toBe(4);
134
  const second = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] };
135
  expect(second.board).toBeUndefined();
136
  arena.match().players[3]!.name = "Eve";
137
  const third = JSON.parse(arena.snapshotForSeat(seat)) as { board?: { name: string }[] };
138
  expect(third.board?.[3]?.name).toBe("Eve");
139
  // The full snapshot is always the full snapshot.
140
  expect((JSON.parse(arena.snapshot("a1")) as { board: unknown[] }).board.length).toBe(4);
141
});
142
143
test("the loop sends at the cadence the room asks for, and a bot's commands are dropped", () => {
144
  const arena = toyGame();
145
  const got: string[] = [];
146
  const seat = arena.takeSeat("10.0.0.1", "s", "Kit")!;
147
  arena.attach("s", seat.id, "10.0.0.1", "t", (data) => got.push(data));
148
  arena.advance(1);
149
  expect(got.length).toBe(Math.round(1 / TICK) / broadcastEvery(1));
150
  arena.command(seat.id, { t: "i" });
151
  arena.command("b2", { t: "i" }); // a bot: nobody may drive it from outside
152
  expect(arena.match().heard.length).toBe(1);
153
});
154
155
test("a game that names its own cadence is sent at it, whatever the default says", () => {
156
  // Where the rate halves is a fact about one game's snapshot and one game's
157
  // room, so the game says; the engine's default is only for a game that has
158
  // not measured. Three ticks apart here, to be sure it is this and not the default.
159
  const arena = toyGame((filled) => (filled >= 2 ? 3 : 1));
160
  const got: string[] = [];
161
  const first = arena.takeSeat("10.0.0.1", "s1", "Kit")!;
162
  arena.attach("s1", first.id, "10.0.0.1", "t1", (data) => got.push(data));
163
  arena.advance(1);
164
  expect(got.length).toBe(Math.round(1 / TICK));
165
  const second = arena.takeSeat("10.0.0.2", "s2", "Kat")!;
166
  arena.attach("s2", second.id, "10.0.0.2", "t2", () => {});
167
  got.length = 0;
168
  arena.advance(1);
169
  expect(got.length).toBe(Math.round(1 / TICK) / 3);
170
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts