fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files fc929c52f70a5ac50a58539dd3a4f5f52f763122
| 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 | }); |