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

forked from benjivers/val

workbenchthis machine
Files pike/25-a-zone-s-position-is-sent-to-people-who
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("a spectator is sent the public state and never a position", () => {
107
  const arena = toyGame();
108
  const got: string[] = [];
109
  arena.watch("w", (data) => got.push(data), "cookie");
110
  expect(got.length).toBe(1);
111
  const snap = JSON.parse(got[0]!) as Record<string, unknown>;
112
  expect(snap).toMatchObject({ t: "s", spectator: true, why: "cookie", ticks: 0 });
113
  expect(snap.players).toBeUndefined();
114
  expect(snap.me).toBeUndefined();
115
  expect(got[0]).not.toContain('"x"');
116
  arena.unwatch("w");
117
});
118
119
test("the sticky field goes out once, then only when it changes", () => {
120
  const arena = toyGame();
121
  const seat = { playerId: "a1", token: "t", address: "10.0.0.1", send: () => {}, board: "" };
122
  const first = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] };
123
  expect(first.board?.length).toBe(4);
124
  const second = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] };
125
  expect(second.board).toBeUndefined();
126
  arena.match().players[3]!.name = "Eve";
127
  const third = JSON.parse(arena.snapshotForSeat(seat)) as { board?: { name: string }[] };
128
  expect(third.board?.[3]?.name).toBe("Eve");
129
  // The full snapshot is always the full snapshot.
130
  expect((JSON.parse(arena.snapshot("a1")) as { board: unknown[] }).board.length).toBe(4);
131
});
132
133
test("the loop sends at the cadence the room asks for, and a bot's commands are dropped", () => {
134
  const arena = toyGame();
135
  const got: string[] = [];
136
  const seat = arena.takeSeat("10.0.0.1", "s", "Kit")!;
137
  arena.attach("s", seat.id, "10.0.0.1", "t", (data) => got.push(data));
138
  arena.advance(1);
139
  expect(got.length).toBe(Math.round(1 / TICK) / broadcastEvery(1));
140
  arena.command(seat.id, { t: "i" });
141
  arena.command("b2", { t: "i" }); // a bot: nobody may drive it from outside
142
  expect(arena.match().heard.length).toBe(1);
143
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts