| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 78 |
const other = arena.takeSeat("10.0.0.2", "session-3", "Kat"); |
| 79 |
expect(other?.id).not.toBe(first!.id); |
| 80 |
|
| 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 |
|
| 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" }); |
| 151 |
expect(arena.match().heard.length).toBe(1); |
| 152 |
}); |