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

forked from benjivers/val

workbenchthis machine
Files 67215f710113bc2875d484e3beaed9d381f49946
152 lines · 6.3 KB · ~1.8k tok Raw History
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() {
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
    spectatorSnapshot: (s) => ({ ticks: s.ticks, names: s.players.map((p) => p.name) }),
44
    command: (s, _p, message) => void s.heard.push(message),
45
  });
46
}
47
48
test("nobody watching, nothing happens; a watcher is somebody; a test drives it regardless", () => {
49
  const arena = toyGame();
50
  arena.beat();
51
  arena.beat();
52
  expect(arena.match().ticks).toBe(0);
53
  arena.watch("w", () => {});
54
  arena.beat();
55
  expect(arena.match().ticks).toBe(1);
56
  arena.unwatch("w");
57
  arena.beat();
58
  expect(arena.match().ticks).toBe(1);
59
  arena.advance(1);
60
  expect(arena.match().ticks).toBe(1 + Math.round(1 / TICK));
61
  expect(arena.attendance()).toBe(0);
62
});
63
64
test("a playing seat is rationed by address, and a session finds its own seat back", () => {
65
  const arena = toyGame();
66
  const first = arena.takeSeat("10.0.0.1", "session-1", "Kit");
67
  expect(first?.bot).toBe(false);
68
  expect(first?.name).toBe("Kit");
69
  // A second cookie from the same machine is not a second seat.
70
  expect(arena.addressCapped("10.0.0.1", "session-2")).toBe(true);
71
  expect(arena.takeSeat("10.0.0.1", "session-2", "Kit 2")).toBeNull();
72
  // The same session asking again is handed the seat it holds, renamed.
73
  const again = arena.takeSeat("10.0.0.1", "session-1", "Kat");
74
  expect(again?.id).toBe(first!.id);
75
  expect(again?.name).toBe("Kat");
76
  expect(arena.seatCount()).toBe(1);
77
  // Another machine sits down beside them.
78
  const other = arena.takeSeat("10.0.0.2", "session-3", "Kat");
79
  expect(other?.id).not.toBe(first!.id);
80
  // And two people may not share a name: the second gets a number.
81
  expect(other?.name).toBe("Kat 2");
82
});
83
84
test("a late close from a reload does not take the live seat with it", () => {
85
  const arena = toyGame();
86
  const seat = arena.takeSeat("10.0.0.1", "s", "Kit")!;
87
  arena.attach("s", seat.id, "10.0.0.1", "old-token", () => {});
88
  arena.attach("s", seat.id, "10.0.0.1", "new-token", () => {});
89
  arena.release("s", "old-token");
90
  expect(seat.bot).toBe(false);
91
  expect(arena.seatCount()).toBe(1);
92
  arena.release("s", "new-token");
93
  expect(seat.bot).toBe(true);
94
  expect(arena.seatCount()).toBe(0);
95
  expect(arena.addressCapped("10.0.0.1", "elsewhere")).toBe(false);
96
});
97
98
test("a viewer is sent only the bodies the game says they can see", () => {
99
  const arena = toyGame();
100
  const snap = JSON.parse(arena.snapshot("a1")) as { t: string; players: { id: string }[] };
101
  expect(snap.t).toBe("s");
102
  expect(snap.players.map((p) => p.id).sort()).toEqual(["a1", "a2", "b2"]);
103
  expect(JSON.stringify(snap.players)).not.toContain('"b1"');
104
});
105
106
test("an unknown viewer gets the spectator view, never another player's eyes", () => {
107
  const arena = toyGame();
108
  const snap = JSON.parse(arena.snapshot("nobody-here")) as Record<string, unknown>;
109
  expect(snap).toMatchObject({ t: "s", spectator: true });
110
  expect(snap.me).toBeUndefined();
111
  expect(snap.players).toBeUndefined();
112
  expect(JSON.stringify(snap)).not.toContain('"x"');
113
});
114
115
test("a spectator is sent the public state and never a position", () => {
116
  const arena = toyGame();
117
  const got: string[] = [];
118
  arena.watch("w", (data) => got.push(data), "cookie");
119
  expect(got.length).toBe(1);
120
  const snap = JSON.parse(got[0]!) as Record<string, unknown>;
121
  expect(snap).toMatchObject({ t: "s", spectator: true, why: "cookie", ticks: 0 });
122
  expect(snap.players).toBeUndefined();
123
  expect(snap.me).toBeUndefined();
124
  expect(got[0]).not.toContain('"x"');
125
  arena.unwatch("w");
126
});
127
128
test("the sticky field goes out once, then only when it changes", () => {
129
  const arena = toyGame();
130
  const seat = { playerId: "a1", token: "t", address: "10.0.0.1", send: () => {}, stickyCache: "" };
131
  const first = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] };
132
  expect(first.board?.length).toBe(4);
133
  const second = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] };
134
  expect(second.board).toBeUndefined();
135
  arena.match().players[3]!.name = "Eve";
136
  const third = JSON.parse(arena.snapshotForSeat(seat)) as { board?: { name: string }[] };
137
  expect(third.board?.[3]?.name).toBe("Eve");
138
  // The full snapshot is always the full snapshot.
139
  expect((JSON.parse(arena.snapshot("a1")) as { board: unknown[] }).board.length).toBe(4);
140
});
141
142
test("the loop sends at the cadence the room asks for, and a bot's commands are dropped", () => {
143
  const arena = toyGame();
144
  const got: string[] = [];
145
  const seat = arena.takeSeat("10.0.0.1", "s", "Kit")!;
146
  arena.attach("s", seat.id, "10.0.0.1", "t", (data) => got.push(data));
147
  arena.advance(1);
148
  expect(got.length).toBe(Math.round(1 / TICK) / broadcastEvery(1));
149
  arena.command(seat.id, { t: "i" });
150
  arena.command("b2", { t: "i" }); // a bot: nobody may drive it from outside
151
  expect(arena.match().heard.length).toBe(1);
152
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts