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

forked from benjivers/val

workbenchthis machine

Say who took the round the moment it turns, and send the board only when it changes

The view used to freeze at the end of a round with "Round over" in the prompt
and nothing else; who took it and why sat three lines below the canvas at
eighty percent opacity. The match now keeps the outcome as data (`last`:
round, winner, why), the snapshot carries it, and a banner on the view says
"Round won — spike defused · next round in 4s" (or "lost"), the prompt and the
watching view say the same, and the first paint says it while the round is
over. The first three seconds of every buy phase carry a round card with the
round and your side, fading out rather than blinking off.

The buy panel is labelled for a first-time reader — agent, gun, armour,
abilities — the developer-speak header is gone, and the gun you hold is
outlined so it reads as yours and not a price list.

The scoreboard was 1673 of 3817 bytes in every snapshot, thirty times a
second, for a table that changes a few times a round. A seat is sent it only
when it differs from what it last received: 3633 → 1973 mean bytes a snapshot
over two minutes of match (46% less, 106 → 58 KB/s a seat), the board going out
on 72 of 3600 ticks. The page and the terminal keep the one they have.

The deploy gate's coin flip: the plant test in server.test.ts shared a live
arena and left every other player's `spike`, `planting` and held F as it found
them, so once in a dozen runs a bot mid-plant on A finished first and the spike
landed on A instead of B — and because the test never restored the bots after a
failure, seven seat tests after it failed too. The test now clears the field it
measures and restores the bots in a finally. The prompt test that fed
`result: "Spike defused"` in an `end` phase was also wrong: the server only
ever sets `result` when the match is over.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Fable committed 1c30034 Browse files
16 files changed +389 −51
README.md +16 −3
@@ -92,8 +92,11 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
9292 body drawn slow, then fast. The moment being drawn advances at real time
9393 and creeps toward its delay at no more than two percent of it, so the
9494 delay adapting never shows as a short frame. Snapshots go out every tick
95 (30Hz, some 3.4 KiB a seat) while two or fewer seats are filled, and every
96 other tick above that.
95 (30Hz, some 2 KiB a seat) while two or fewer seats are filled, and every
96 other tick above that. The scoreboard — ten names, agents and tallies,
97 almost half of every snapshot — is sent only when it differs from what
98 that seat last received, and the page keeps the one it has; everything
99 else is in every snapshot.
97100 - The view is a raycaster: walls are one tile tall and flat-shaded, there is
98101 no looking up or down, and everything that is not a wall — bodies, the
99102 spike, ability zones — is a flat billboard scaled by distance and clipped
@@ -125,7 +128,16 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
125128 the page draws the score, the round, the clock and the kill feed, says
126129 which of the two reasons it is watching for, and draws no map and no
127130 bodies, because it is never told where anybody is.
128 - No overtime — first to 13 ends it at 12–12 too.
131 - No overtime — first to 13 ends it at 12–12 too. A choice, not a gap: a
132 match is already some twenty-five rounds, the bots play both halves of it,
133 and a round that ends 13–12 reads as a result. Overtime would add rules a
134 visitor never sees and a match nobody sits through.
135 - The moment a round turns, the view says so: a banner with whether you won
136 the round and why (`static/banner.js`), the same words in the prompt line
137 and on the first paint, and a round card for the first three seconds of
138 the buy phase saying the round and your side. The server keeps the
139 outcome as data (`last` in the snapshot: who took it, why) — the HUD never
140 parses the log.
129141 - Sight is a full circle rather than a view cone — you are drawn what is in
130142 front of you, but the server reveals anything you have line of sight to,
131143 so a team mate behind you still appears on the minimap. The world has no
@@ -175,6 +187,7 @@ maths, `scene.js` the first-person view, `slices.js` batches its wall
175187 columns, `resolution.js` and `budget.js` decide how wide to draw it,
176188 `presence.js` fades bodies into sight and marks where they left it,
177189 `topdown.js` the minimap,
190 `banner.js` the words on the view when a round turns,
178191 `gun.js`, `beep.js`, `steer.js`, `roster.js`, `yaw.js`, `interp.js` and `prompt.js` the rest; the server bundles
179192 them into one content-named file at start-up. The rules are pure functions and the tests play whole matches
180193 headlessly.
src/arena.ts +34 −7
@@ -44,7 +44,14 @@ export const TICK = 1 / 30;
4444 */
4545 export const broadcastEvery = (filled: number): number => (filled <= 2 ? 1 : 2);
4646
47 export type Seat = { playerId: string; token: string; address: string; send: (data: string) => void };
47 export type Seat = {
48 playerId: string;
49 token: string;
50 address: string;
51 send: (data: string) => void;
52 /** The scoreboard as this seat last received it, so it is only resent when it changes. */
53 board: string;
54 };
4855
4956 let state: MatchState = newMatch();
5057 const seats = new Map<string, Seat>();
@@ -123,13 +130,13 @@ export function takeSeat(address: string, session: string, name: string): Player
123130 seat.think = 0;
124131 seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null };
125132 addressSeats.set(address, session);
126 seats.set(session, { playerId: seat.id, token: "", address, send: () => {} });
133 seats.set(session, { playerId: seat.id, token: "", address, send: () => {}, board: "" });
127134 return seat;
128135 }
129136
130137 /** The socket that actually opened fills in how to reach it and its token. */
131138 export function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void {
132 seats.set(session, { playerId, token, address, send });
139 seats.set(session, { playerId, token, address, send, board: "" });
133140 }
134141
135142 /**
@@ -194,6 +201,7 @@ export function spectatorSnapshot(why: Why = "unsaid"): string {
194201 score: state.score,
195202 attackers: state.attackers,
196203 result: state.result,
204 last: state.last,
197205 events: state.events.slice(0, 5).map((e) => e.text),
198206 // Names and a gun, the same as a seat is sent, and for the same reason it
199207 // is safe there: the feed says what happened, never where.
@@ -256,13 +264,31 @@ export function command(playerId: string, message: Command): void {
256264
257265 const round2 = (n: number): number => Math.round(n * 10) / 10;
258266
259 export function snapshot(viewerId: string): string {
267 /** The full snapshot for one viewer, as one string. */
268 export const snapshot = (viewerId: string): string => JSON.stringify(snapshotFor(viewerId));
269
270 /**
271 * The snapshot a seat is actually sent on this tick. The scoreboard — ten
272 * names, agents and tallies — was almost half of every snapshot and changes
273 * a few times a round, so it goes out only when it differs from what this
274 * seat last received; the client keeps the one it has. Everything else is
275 * the full snapshot, every time.
276 */
277 export function snapshotForSeat(seat: Seat): string {
278 const full = snapshotFor(seat.playerId);
279 const board = JSON.stringify(full.board);
280 if (board === seat.board) delete (full as { board?: unknown }).board;
281 else seat.board = board;
282 return JSON.stringify(full);
283 }
284
285 function snapshotFor(viewerId: string) {
260286 const me = findPlayer(viewerId) ?? state.players[0]!;
261287 const agent = agentById(me.agent);
262288 const seen = state.players.filter((p) => visible(state, me, p));
263289 const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones);
264290
265 return JSON.stringify({
291 return {
266292 t: "s",
267293 phase: state.phase,
268294 clock: round2(Math.max(0, state.clock)),
@@ -270,6 +296,7 @@ export function snapshot(viewerId: string): string {
270296 score: state.score,
271297 attackers: state.attackers,
272298 result: state.result,
299 last: state.last,
273300 me: {
274301 id: me.id,
275302 side: me.side,
@@ -354,7 +381,7 @@ export function snapshot(viewerId: string): string {
354381 h: f.headshot,
355382 t: round2(Math.max(0, state.now - f.at)),
356383 })),
357 });
384 };
358385 }
359386
360387 /**
@@ -411,7 +438,7 @@ function step(): void {
411438 if (ticks % broadcastEvery(seats.size) !== 0) return;
412439 for (const seat of seats.values()) {
413440 try {
414 seat.send(snapshot(seat.playerId));
441 seat.send(snapshotForSeat(seat));
415442 } catch {
416443 // A socket that will not take a frame is closed already; the close
417444 // handler gives the seat back.
src/banner.test.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * The banner: what the view says big when a round turns, and when it says nothing.
3 */
4
5 import { describe, expect, test } from "bun:test";
6 import { banner, drawBanner } from "../static/banner.js";
7
8 const snap = (over: Record<string, unknown> = {}, me: Record<string, unknown> | null = { team: "red", side: "atk" }) => ({
9 phase: "live",
10 clock: 40,
11 round: 5,
12 result: null,
13 last: { round: 4, winner: "blue", why: "Spike defused" },
14 me,
15 ...over,
16 });
17
18 describe("what the banner says", () => {
19 test("a live round says nothing: the view is the game", () => {
20 expect(banner(snap())).toBe(null);
21 expect(banner(null)).toBe(null);
22 });
23
24 test("a round over says whether you won, why, and when the next one starts", () => {
25 const lost = banner(snap({ phase: "end", clock: 3.2 }));
26 expect(lost).toEqual({ title: "Round lost", sub: "spike defused · next round in 4s", alpha: 1 });
27 const won = banner(snap({ phase: "end", clock: 3.2 }, { team: "blue", side: "def" }));
28 expect(won?.title).toBe("Round won");
29 });
30
31 test("a round over with no outcome yet still says the round is over", () => {
32 expect(banner(snap({ phase: "end", clock: 2, last: null }))).toEqual({ title: "Round over", sub: "next round in 2s", alpha: 1 });
33 });
34
35 test("a match over names the winner and the countdown to the next match", () => {
36 const words = banner(snap({ phase: "over", clock: 9.9, result: "red win 13–7" }));
37 expect(words?.title).toBe("red win 13–7");
38 expect(words?.sub).toBe("match over · a new match starts in 10s");
39 });
40
41 test("a round starting shows its number and your side for three seconds, then fades out", () => {
42 // The buy phase is fourteen seconds; the card is for its first three.
43 const fresh = banner(snap({ phase: "buy", clock: 14 }));
44 expect(fresh).toEqual({ title: "Round 5", sub: "you attack — plant the spike on A or B", alpha: 1 });
45 expect(banner(snap({ phase: "buy", clock: 13 }, { team: "blue", side: "def" }))?.sub).toBe("you defend — hold A and B");
46 // Half a second before it goes it is half gone: it leaves by fading, never by blinking off.
47 const leaving = banner(snap({ phase: "buy", clock: 11.25 }));
48 expect(leaving?.alpha).toBeCloseTo(0.5, 5);
49 expect(banner(snap({ phase: "buy", clock: 11 }))).toBe(null);
50 expect(banner(snap({ phase: "buy", clock: 3 }))).toBe(null);
51 });
52
53 test("a watcher with no side is told the team that took it", () => {
54 expect(banner(snap({ phase: "end", clock: 3 }, null))?.title).toBe("blue take the round");
55 });
56 });
57
58 test("drawing it is one band and two lines of text, and nothing when there is nothing to say", () => {
59 const ops: string[] = [];
60 const ctx = new Proxy({} as Record<string, unknown>, {
61 get: (_, prop: string) => (...args: unknown[]) => ops.push(`${prop}(${args.map(String).join(",")})`),
62 set: () => true,
63 });
64 drawBanner(ctx as never, banner(snap({ phase: "end", clock: 3 })), 900, 570);
65 expect(ops.filter((op) => op.startsWith("fillRect")).length).toBe(1);
66 expect(ops.some((op) => op.startsWith("fillText(Round lost"))).toBe(true);
67 expect(ops.some((op) => op.startsWith("fillText(spike defused"))).toBe(true);
68 ops.length = 0;
69 drawBanner(ctx as never, null, 900, 570);
70 drawBanner(ctx as never, { title: "x", sub: "y", alpha: 0 }, 900, 570);
71 expect(ops.length).toBe(0);
72 });
src/client.test.ts +9 −2
@@ -116,8 +116,9 @@ for (const id of [
116116 ])
117117 nodes.set(id, element(id));
118118
119 const GUNS = ["classic", "ghost", "sheriff", "spectre", "phantom", "vandal", "operator"];
119120 const buys = [
120 element("b1", { buy: "vandal", cost: "2900" }),
121 ...GUNS.map((gun, i) => element(`g${i}`, { buy: gun, cost: gun === "vandal" ? "2900" : "0" })),
121122 element("b2", { buy: "c", cost: "0" }),
122123 element("b3", { buy: "q", cost: "0" }),
123124 ];
@@ -219,7 +220,13 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
219220 // One prompt, in words, about the round that is running.
220221 expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8);
221222 // And the buy panel prices itself off the agent's real kit.
222 expect(String(buys[1]!.title)).toContain("charge");
223 expect(String(buys.find((b) => (b.dataset as { buy: string }).buy === "c")!.title)).toContain("charge");
224 // The gun you hold is the one gun button marked as yours.
225 const held = (await import("./arena.ts")).match().players.find((p) => !p.bot)!;
226 const ownedGuns = buys.filter(
227 (b) => GUNS.includes((b.dataset as { buy: string }).buy) && (b.classList as { contains: (n: string) => boolean }).contains("owned"),
228 );
229 expect(ownedGuns.map((b) => (b.dataset as { buy: string }).buy)).toEqual([held.weapon]);
223230
224231 // The team is on screen whether anybody is in sight or not: five slots.
225232 const slots = nodes.get("roster")!.children as { className: string }[];
src/match.test.ts +5
@@ -150,9 +150,14 @@ test("the spike detonating wins the round for the attackers", () => {
150150 test("time running out with no plant is a defence win, but the clock stops mattering once it is down", () => {
151151 const timeout = live();
152152 timeout.clock = 0.5;
153 expect(timeout.last).toBe(null);
153154 run(timeout, 1);
154155 expect(timeout.phase).toBe("end");
155156 expect(timeout.score[timeout.attackers]).toBe(0);
157 // The outcome is kept on the state in words, so the HUD can say who took
158 // the round and why at the moment it turns, not only that it is over.
159 const defenders = timeout.attackers === "red" ? "blue" : "red";
160 expect(timeout.last).toEqual({ round: 1, winner: defenders, why: "Time out" });
156161
157162 const planted = live();
158163 const carrier = onSite(planted);
src/match.ts +2
@@ -165,6 +165,7 @@ export function newMatch(seed = Date.now() % 100000): MatchState {
165165 shots: [],
166166 events: [],
167167 result: null,
168 last: null,
168169 seed,
169170 now: 0,
170171 };
@@ -648,6 +649,7 @@ function finishRound(state: MatchState, winners: Side, why: string): void {
648649 }
649650 state.phase = "end";
650651 state.clock = END_TIME;
652 state.last = { round: state.round, winner: winningTeam, why };
651653 say(state, `${why} — ${winningTeam} take round ${state.round} (${state.score.red}–${state.score.blue})`);
652654 if (state.score[winningTeam] >= WIN_ROUNDS) {
653655 state.phase = "over";
src/pages/game.ts +19 −9
@@ -21,7 +21,9 @@ const phaseWord = (state: MatchState): string =>
2121 ? `spike down · ${Math.ceil(state.spike.fuse)}s`
2222 : "live"
2323 : state.phase === "end"
24 ? "round over"
24 ? state.last
25 ? `round over — ${state.last.winner} took it, ${state.last.why.charAt(0).toLowerCase()}${state.last.why.slice(1)}`
26 : "round over"
2527 : "match over";
2628
2729 export function gamePage(opts: {
@@ -118,11 +120,15 @@ export function gamePage(opts: {
118120 background: #1b1e25; color: var(--ink); border: 1px solid #333a47; border-radius: .45rem;
119121 font: inherit; font-size: .8rem; cursor: pointer; }
120122 .kit button[disabled], .buys button[disabled] { opacity: .35; cursor: not-allowed; }
123 .buys button.owned { outline: 2px solid #6fcf9f; opacity: 1; }
124 .buys button.owned[disabled] { cursor: default; }
121125 .kit b { display: block; font-size: .7rem; opacity: .7; }
122126 #buy { display: none; margin: .5rem 0; padding: .6rem; background: #151820; border: 1px solid #2a2f3a;
123127 border-radius: .6rem; }
124128 #buy.on { display: block; }
125 .buys, .agents { display: flex; gap: .4rem; flex-wrap: wrap; margin: .35rem 0; }
129 #buy p { margin: 0 0 .3rem; font-size: .85rem; opacity: .85; }
130 .buys, .agents { display: flex; gap: .4rem; flex-wrap: wrap; margin: .35rem 0; align-items: center; }
131 .lbl { min-width: 4.2rem; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; opacity: .55; }
126132 #log { margin: .5rem 0; font-size: .82rem; opacity: .8; min-height: 3.2em; }
127133 #log div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
128134 table { width: 100%; border-collapse: collapse; font-size: .85rem; }
@@ -207,22 +213,26 @@ export function gamePage(opts: {
207213 </div>
208214
209215 <div id="buy">
210 <div><strong>Buy phase</strong> — credits are refused server-side if you cannot afford them.</div>
211 <div class="agents" id="agents">${AGENTS.map(
216 <p><strong>Buy phase.</strong> Spend your credits on a gun and armour for this round;
217 a greyed price is more than you have, and the outlined one is what you hold.
218 Two abilities are bought here too, and your agent can be swapped until you buy one.</p>
219 <div class="agents"><span class="lbl">agent</span><span class="agents" id="agents">${AGENTS.map(
212220 (a) => `<button type="button" data-agent="${escape(a.id)}" title="${escape(a.blurb)}">${escape(a.name)} <small>${escape(a.role)}</small></button>`,
213 ).join("")}</div>
214 <div class="buys" id="guns">${weaponIds()
221 ).join("")}</span></div>
222 <div class="buys"><span class="lbl">gun</span><span class="buys" id="guns">${weaponIds()
215223 .map(
216224 (id) =>
217225 `<button type="button" data-buy="${id}" data-cost="${WEAPONS[id].cost}" title="${escape(WEAPONS[id].name)} — ${WEAPONS[id].cost} credits, ${WEAPONS[id].magazine} rounds, ${WEAPONS[id].auto ? "automatic" : "semi-automatic"}">${escape(WEAPONS[id].name)} <b>${WEAPONS[id].cost}</b></button>`,
218226 )
219 .join("")}</div>
220 <div class="buys" id="gear">
227 .join("")}</span></div>
228 <div class="buys"><span class="lbl">armour</span><span class="buys" id="gear">
221229 <button type="button" data-buy="light" data-cost="400" title="25 armour that soaks a quarter of what hits you">Light shield <b>400</b></button>
222230 <button type="button" data-buy="heavy" data-cost="1000" title="50 armour that soaks half of what hits you">Heavy shield <b>1000</b></button>
231 </span></div>
232 <div class="buys"><span class="lbl">abilities</span><span class="buys" id="basics">
223233 <button type="button" data-buy="c" data-cost="0">C ability <b>—</b></button>
224234 <button type="button" data-buy="q" data-cost="0">Q ability <b>—</b></button>
225 </div>
235 </span></div>
226236 </div>
227237
228238 <div id="log">${state.events
src/prompt.test.ts +21 −6
@@ -13,14 +13,15 @@ const snapshot = (over: Record<string, unknown> = {}, me: Record<string, unknown
1313 });
1414
1515 describe("the one prompt on screen", () => {
16 test("the buy phase says what to do and how long there is", () => {
16 test("the buy phase says what to do, which side you are on, and how long there is", () => {
1717 const text = choosePrompt(snapshot({ phase: "buy", clock: 14.2 }));
18 expect(text).toBe("Buy phase — 15s left. Click a gun you can afford.");
18 expect(text).toBe("Buy phase — 15s left. You attack: plant the spike on A or B. Click a gun you can afford.");
19 expect(choosePrompt(snapshot({ phase: "buy", clock: 14.2 }, { side: "def" }))).toContain("You defend: hold A and B.");
1920 });
2021
2122 test("the terminal is told about keys, not clicks", () => {
2223 const text = choosePrompt(snapshot({ phase: "buy", clock: 14.2 }), { input: "keys" });
23 expect(text).toBe("Buy phase — 15s left. Press b and buy a gun you can afford.");
24 expect(text).toContain("Press b and buy a gun you can afford.");
2425 });
2526
2627 test("carrying the spike off a site sends you to one", () => {
@@ -58,9 +59,23 @@ describe("the one prompt on screen", () => {
5859 expect(choosePrompt(snapshot({}, { alive: false }))).toBe("Waiting for the next round — you're in it");
5960 });
6061
61 test("the end of a round shows the result and the countdown", () => {
62 const text = choosePrompt(snapshot({ phase: "end", clock: 4.1, result: "Spike defused" }));
63 expect(text).toBe("Spike defused — next round in 5s");
62 test("the end of a round says whether you won, why, and the countdown", () => {
63 // The server's `result` is only ever set when the MATCH is over; the round's
64 // outcome travels as `last`. The old test fed a snapshot the server never
65 // sends, and the real one read "Round over" and nothing else.
66 const last = { round: 3, winner: "blue", why: "Spike defused" };
67 const lost = choosePrompt(snapshot({ phase: "end", clock: 4.1, last }, { team: "red" }));
68 expect(lost).toBe("Round lost — spike defused · next round in 5s");
69 const won = choosePrompt(snapshot({ phase: "end", clock: 4.1, last }, { team: "blue" }));
70 expect(won).toBe("Round won — spike defused · next round in 5s");
71 // Dead or alive, the outcome is the thing to say at that moment.
72 expect(choosePrompt(snapshot({ phase: "end", clock: 4.1, last }, { team: "red", alive: false }))).toBe(lost);
73 expect(choosePrompt(snapshot({ phase: "end", clock: 4.1, last: null }))).toBe("Round over · next round in 5s");
74 });
75
76 test("the end of the match names the winner and says a new one is coming", () => {
77 const text = choosePrompt(snapshot({ phase: "over", clock: 11.2, result: "red win 13–7" }));
78 expect(text).toBe("Match over — red win 13–7 · a new match starts in 12s");
6479 });
6580
6681 test("a live defender is told what defending is", () => {
src/server.test.ts +73 −15
@@ -167,26 +167,64 @@ test("a command from a seat plants the spike, and nonsense from anyone else does
167167 const spot = siteCentre("B");
168168 const carrier = state.players.find((p) => p.side === "atk")!;
169169 // Nobody is a bot for these five seconds: no fight can end the round
170 // underneath the plant and make this depend on who shot whom.
170 // underneath the plant and make this depend on who shot whom. And nobody
171 // else is mid-plant: this arena is live and shared, and once in a dozen
172 // runs a bot was standing on A with the spike and F held when this test
173 // began. Left with `spike` set and `use` held, it planted on A first, and
174 // every seat test after this one failed because the bots were never
175 // restored — the deploy gate's coin flip.
171176 for (const player of state.players) {
172177 player.bot = false;
173178 player.alive = true;
174179 player.hp = 100;
180 player.spike = false;
181 player.planting = 0;
175182 player.input.fire = false;
183 player.input.use = false;
184 player.input.mx = 0;
185 player.input.my = 0;
176186 }
177 carrier.x = spot.x;
178 carrier.y = spot.y;
179 carrier.spike = true;
180 state.spike = { carried: carrier.id, x: spot.x, y: spot.y, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 };
181 // Garbage is ignored, and a seat nobody holds cannot be driven.
182 command(carrier.id, { t: "i", mx: "left" as unknown as number, a: Number.NaN, u: "yes" });
183 command("nobody", { t: "i", u: true });
184 expect(carrier.input.use).toBe(false);
185 command(carrier.id, { t: "i", u: true, mx: 0, my: 0 });
186 advance(5);
187 expect(state.spike.planted).toBe(true);
188 expect(state.spike.site).toBe("B");
189 for (const player of state.players) player.bot = true;
187 try {
188 carrier.x = spot.x;
189 carrier.y = spot.y;
190 carrier.spike = true;
191 state.spike = { carried: carrier.id, x: spot.x, y: spot.y, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 };
192 // Garbage is ignored, and a seat nobody holds cannot be driven.
193 command(carrier.id, { t: "i", mx: "left" as unknown as number, a: Number.NaN, u: "yes" });
194 command("nobody", { t: "i", u: true });
195 expect(carrier.input.use).toBe(false);
196 command(carrier.id, { t: "i", u: true, mx: 0, my: 0 });
197 advance(5);
198 expect(state.spike.planted).toBe(true);
199 expect(state.spike.site).toBe("B");
200 } finally {
201 for (const player of state.players) player.bot = true;
202 }
203 });
204
205 test("the scoreboard is sent when it changes and left out when it has not", async () => {
206 // Ten names, agents and tallies were almost half of every snapshot, thirty
207 // times a second, for a table that changes a few times a round. A seat is
208 // sent it the first time and again only when it differs.
209 const { snapshotForSeat } = await import("./arena.ts");
210 const state = match();
211 const player = state.players[0]!;
212 const seat = { playerId: player.id, token: "t", address: "198.51.100.200", send: () => {}, board: "" };
213 const first = JSON.parse(snapshotForSeat(seat)) as { board?: unknown[]; last?: unknown };
214 expect(first.board?.length).toBe(10);
215 const second = JSON.parse(snapshotForSeat(seat)) as { board?: unknown[] };
216 expect(second.board).toBeUndefined();
217 // Something on the board moves — a kill — and it comes back with the next tick.
218 player.kills += 1;
219 const third = JSON.parse(snapshotForSeat(seat)) as { board?: { kills: number }[] };
220 expect(third.board?.length).toBe(10);
221 expect(third.board?.find((p) => (p as { id?: string }).id === player.id)?.kills).toBe(player.kills);
222 // The full snapshot a socket is handed on open, and every test reads, is untouched.
223 expect((JSON.parse(snapshot(player.id)) as { board: unknown[] }).board.length).toBe(10);
224 // The bytes: the board is the bulk of what was going out.
225 const withBoard = Buffer.byteLength(snapshot(player.id));
226 const without = Buffer.byteLength(snapshotForSeat(seat));
227 expect(without).toBeLessThan(withBoard * 0.7);
190228 });
191229
192230 test("the match keeps running and is never parked", () => {
@@ -217,11 +255,31 @@ test("the page carries the first-run card and the prompt line", async () => {
217255 expect(body).toContain('id="help"');
218256 expect(body).toContain("How to play");
219257 expect(body).toContain('id="prompt"');
220 // Every buy button says what it costs, so the panel is readable at a glance.
258 // Every buy button says what it costs, so the panel is readable at a glance,
259 // and the rows are labelled for somebody meeting the panel for the first
260 // time: which of these is a gun, which is armour, which is an ability.
221261 expect(body).toContain('data-buy="vandal" data-cost="2900"');
262 for (const label of ["agent", "gun", "armour", "abilities"]) expect(body).toContain(`<span class="lbl">${label}</span>`);
263 expect(body).not.toContain("refused server-side");
222264 expect(body).toContain('id="viewtoggle"');
223265 });
224266
267 test("the first paint says who took the round while it is over", async () => {
268 // Somebody arriving between rounds used to read "round over" and nothing
269 // else; the outcome was in the log three lines down at eighty percent.
270 const state = match();
271 const was = { phase: state.phase, clock: state.clock, last: state.last };
272 state.phase = "end";
273 state.clock = 4;
274 state.last = { round: state.round, winner: "blue", why: "Spike defused" };
275 try {
276 const body = await (await fetch(base + "/")).text();
277 expect(body).toContain("round over — blue took it, spike defused");
278 } finally {
279 Object.assign(state, was);
280 }
281 });
282
225283 test("a stale socket closing does not evict a live reconnect's seat", async () => {
226284 // A reload opens the new socket before the old one's close event lands.
227285 // The old close must not turn a player who is still connected into a bot.
src/state.ts +6
@@ -124,6 +124,12 @@ export type MatchState = {
124124 shots: { x1: number; y1: number; x2: number; y2: number; hit: boolean; age: number }[];
125125 events: { text: string; age: number }[];
126126 result: string | null;
127 /**
128 * How the last round ended: who took it and why, in the words the round
129 * log uses. The HUD reads it the moment a round turns — "Round over" on
130 * its own answers neither question a person asks at that moment.
131 */
132 last: { round: number; winner: Team; why: string } | null;
127133 seed: number;
128134 now: number;
129135 };
src/tty.ts +4 −1
@@ -64,7 +64,10 @@ async function connect(): Promise<void> {
6464 setTimeout(connect, 2000);
6565 };
6666 ws.onmessage = (event) => {
67 snap = JSON.parse(String(event.data)) as Snapshot;
67 const next = JSON.parse(String(event.data)) as Snapshot;
68 // The scoreboard comes only when it changes; keep the last one otherwise.
69 if (next.board === undefined) next.board = snap?.board ?? [];
70 snap = next;
6871 };
6972 } catch (error) {
7073 status = `cannot reach ${base}`;
src/watch.test.ts +7
@@ -43,6 +43,13 @@ describe("what the watching view says", () => {
4343 expect(lines.phase).toBe("round 7 · live · red attacking");
4444 });
4545
46 test("a round that has just ended says who took it and why", () => {
47 const lines = watchLines(spectator({ phase: "end", last: { round: 7, winner: "blue", why: "Time out" } }));
48 expect(lines.phase).toBe("round 7 · round over — blue take it, time out · red attacking");
49 // Once the next round is on, the phase line is the phase again.
50 expect(watchLines(spectator({ phase: "buy", last: { round: 7, winner: "blue", why: "Time out" } })).phase).not.toContain("take it");
51 });
52
4653 test("the reason is named: the seat is held at this address", () => {
4754 expect(watchLines(spectator()).why).toContain("address");
4855 expect(watchLines(spectator({ why: "cookie" })).why).toContain("cookie");
static/banner.js added +68
@@ -0,0 +1,68 @@
1 /**
2 * The banner: the one moment a round turns, said big, on the view itself.
3 *
4 * Somebody sitting down in front of the game for two minutes meets a round
5 * ending before they meet anything else, and the view used to freeze with no
6 * word of who took it or why — the answer was three lines below the canvas at
7 * eighty percent opacity. So for the few seconds a round is over, the match is
8 * over, or a round is starting, the view carries a title and one line under
9 * it. A pure function decides the words; the drawing is a band and two texts.
10 */
11
12 import { roundOutcome } from "./prompt.js";
13
14 /** The buy phase is fourteen seconds; the round card shows for its first three. */
15 const BUY_TIME = 14;
16 const CARD_FOR = 3;
17 const FADE = 0.5;
18
19 const secs = (n) => Math.max(0, Math.ceil(n));
20
21 /**
22 * What the banner says right now, or null for nothing. `alpha` is how solid it
23 * is: a card that leaves by fading is not a card that blinked off.
24 */
25 export function banner(snap) {
26 if (!snap) return null;
27 if (snap.phase === "over")
28 return {
29 title: snap.result ? `${snap.result}` : "Match over",
30 sub: `match over · a new match starts in ${secs(snap.clock)}s`,
31 alpha: 1,
32 };
33 if (snap.phase === "end") {
34 const outcome = roundOutcome(snap);
35 const next = `next round in ${secs(snap.clock)}s`;
36 if (!outcome) return { title: "Round over", sub: next, alpha: 1 };
37 return { title: outcome.title, sub: `${outcome.why} · ${next}`, alpha: 1 };
38 }
39 if (snap.phase === "buy" && snap.me) {
40 const shownFor = BUY_TIME - Number(snap.clock);
41 if (!(shownFor < CARD_FOR)) return null;
42 const alpha = Math.max(0, Math.min(1, (CARD_FOR - shownFor) / FADE));
43 const side = snap.me.side === "atk" ? "you attack — plant the spike on A or B" : "you defend — hold A and B";
44 return { title: `Round ${snap.round}`, sub: side, alpha };
45 }
46 return null;
47 }
48
49 /** A dark band across the view with the title and the line under it. */
50 export function drawBanner(ctx, words, width, height) {
51 if (!words || words.alpha <= 0.01) return;
52 const unit = Math.min(width / 900, height / 570);
53 const mid = width / 2;
54 const top = height * 0.2;
55 const band = 92 * unit;
56 ctx.save();
57 ctx.globalAlpha = words.alpha;
58 ctx.fillStyle = "rgba(8,10,14,0.78)";
59 ctx.fillRect(0, top, width, band);
60 ctx.textAlign = "center";
61 ctx.fillStyle = "#f2f3f6";
62 ctx.font = `700 ${Math.round(34 * unit)}px system-ui, sans-serif`;
63 ctx.fillText(words.title, mid, top + 44 * unit);
64 ctx.fillStyle = "#b9c0cf";
65 ctx.font = `${Math.round(17 * unit)}px system-ui, sans-serif`;
66 ctx.fillText(words.sub, mid, top + 74 * unit);
67 ctx.restore();
68 }
static/prompt.js +27 −3
@@ -12,6 +12,22 @@ export const REACH = 46;
1212
1313 const secs = (n) => Math.max(0, Math.ceil(n));
1414
15 /** "Spike defused" the way the log says it, lower-cased to sit mid-sentence. */
16 export const lower = (text) => String(text).charAt(0).toLowerCase() + String(text).slice(1);
17
18 /**
19 * How the round just ended, from the viewer's side of it. The server says who
20 * took it and why; a person wants to know whether that was them. A viewer
21 * with no team (watching) is told the team.
22 */
23 export function roundOutcome(snap) {
24 const last = snap && snap.last;
25 if (!last) return null;
26 const team = snap.me && snap.me.team;
27 const title = team ? (last.winner === team ? "Round won" : "Round lost") : `${last.winner} take the round`;
28 return { title, why: lower(last.why), winner: last.winner };
29 }
30
1531 export function choosePrompt(snap, world = {}) {
1632 if (!snap) return "";
1733 const me = snap.me;
@@ -22,14 +38,22 @@ export function choosePrompt(snap, world = {}) {
2238 // names the input, so it says the thing the reader can actually do.
2339 const buyWith = world.input === "keys" ? "Press b and buy a gun you can afford." : "Click a gun you can afford.";
2440
25 if (snap.phase === "over") return snap.result ? `Match over — ${snap.result}` : "Match over";
41 if (snap.phase === "over")
42 return `Match over — ${snap.result ?? "done"} · a new match starts in ${secs(snap.clock)}s`;
2643 if (me.blind > 0.05) return "You are blind — back off until it clears";
44 if (snap.phase === "end") {
45 const outcome = roundOutcome(snap);
46 const next = `next round in ${secs(snap.clock)}s`;
47 return outcome ? `${outcome.title} — ${outcome.why} · ${next}` : `Round over · ${next}`;
48 }
2749 if (!me.alive)
2850 return snap.phase === "buy"
2951 ? "Buy phase — you are back next round"
3052 : "Waiting for the next round — you're in it";
31 if (snap.phase === "end") return snap.result ? `${snap.result} — next round in ${secs(snap.clock)}s` : "Round over";
32 if (snap.phase === "buy") return `Buy phase — ${secs(snap.clock)}s left. ${buyWith}`;
53 if (snap.phase === "buy") {
54 const side = me.side === "atk" ? "You attack: plant the spike on A or B." : "You defend: hold A and B.";
55 return `Buy phase — ${secs(snap.clock)}s left. ${side} ${buyWith}`;
56 }
3357
3458 if (me.planting > 0) return `Planting — hold F for ${secs(4 - me.planting)}s more`;
3559 if (spike.planted) {
static/val.js +19 −4
@@ -11,6 +11,7 @@
1111 */
1212 "use strict";
1313
14 import { banner, drawBanner } from "./banner.js";
1415 import { makeBeeper } from "./beep.js";
1516 import { makeBudget, measure } from "./budget.js";
1617 import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "./interp.js";
@@ -109,6 +110,9 @@ function connect() {
109110 socket.onmessage = (event) => {
110111 const data = JSON.parse(event.data);
111112 if (data.t !== "s") return;
113 // The scoreboard is sent only when it changes; between changes this is
114 // the one we have, and it is still the server's word, not a guess.
115 if (data.board === undefined && snap && snap.board) data.board = snap.board;
112116 snap = data;
113117 const landed = performance.now();
114118 // No seat, no `me`, no players. Everything below reads a body out of the
@@ -488,8 +492,13 @@ function frame(now) {
488492 if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting");
489493 else if (snap.spike.planted && snap.spike.defuse > 0 && snap.me.side === "def")
490494 bar(snap.spike.defuse / 7, "#4fd6a0", "defusing");
495 // The moment a round turns, the view says so: who took it, why, and when
496 // the next one starts. Between rounds the banner is the word, so the dead
497 // overlay only speaks while a round is actually being played without you.
498 const words = banner(snap);
499 if (words) drawBanner(ctx, words, view.width, view.height);
491500 $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8);
492 $("dead").style.display = snap.me.alive ? "none" : "grid";
501 $("dead").style.display = snap.me.alive || snap.phase !== "live" ? "none" : "grid";
493502 $("aimhint").style.display = mode === "fp" && !locked && !COARSE ? "grid" : "none";
494503
495504 // What this frame cost to draw, not how long the browser waited for the
@@ -639,8 +648,9 @@ function paintHud(s) {
639648 $("clock").textContent = Math.ceil(s.phase === "live" && s.spike.planted ? s.spike.fuse : s.clock);
640649 document.querySelector("#scoreline .red").textContent = "red " + s.score.red;
641650 document.querySelector("#scoreline .blue").textContent = s.score.blue + " blue";
651 const turned = s.phase === "end" && s.last ? `round over — ${s.last.winner} took it` : null;
642652 $("phase").textContent =
643 `round ${s.round} · ${s.phase === "buy" ? "buy phase" : s.phase === "live" ? (s.spike.planted ? "spike down" : "live") : s.phase === "end" ? "round over" : "match over"} · ${s.attackers} attacking · you are ${s.me.side === "atk" ? "attacking" : "defending"}${s.result ? " · " + s.result : ""}`;
653 `round ${s.round} · ${s.phase === "buy" ? "buy phase" : s.phase === "live" ? (s.spike.planted ? "spike down" : "live") : (turned ?? (s.phase === "end" ? "round over" : "match over"))} · ${s.attackers} attacking · you are ${s.me.side === "atk" ? "attacking" : "defending"}${s.result ? " · " + s.result : ""}`;
644654 $("hp").textContent = `${s.me.hp} hp${s.me.armour ? " +" + s.me.armour : ""}`;
645655 $("gun").textContent = `${s.me.weapon}${s.me.reloading ? " reloading" : " " + s.me.ammo}`;
646656 $("creds").textContent = `${s.me.credits} cr`;
@@ -662,7 +672,7 @@ function paintHud(s) {
662672
663673 for (const side of ["atk", "def"]) {
664674 const body = $(side + "board");
665 const rows = s.board.filter((p) => p.side === side).sort((a, b) => b.kills - a.kills);
675 const rows = (s.board ?? []).filter((p) => p.side === side).sort((a, b) => b.kills - a.kills);
666676 const key = rows.map((p) => `${p.name}${p.kills}${p.deaths}${p.alive}${p.credits}`).join();
667677 if (body.dataset.key === key) continue;
668678 body.dataset.key = key;
@@ -860,12 +870,17 @@ function paintKit(s) {
860870 }
861871 }
862872
863 /** Prices on every button, greyed when you cannot afford it. */
873 /** What you are holding right now, so the panel reads as yours and not a price list. */
874 const owned = (me, what) =>
875 what === me.weapon || (what === "light" && me.armour >= 25) || (what === "heavy" && me.armour >= 50);
876
877 /** Prices on every button, greyed when you cannot afford it, outlined when it is yours. */
864878 function paintBuy(s) {
865879 const agent = kitOf(s.me.agent);
866880 for (const button of document.querySelectorAll("[data-buy]")) {
867881 const what = button.dataset.buy;
868882 let cost = Number(button.dataset.cost ?? 0);
883 button.classList.toggle("owned", owned(s.me, what));
869884 if ((what === "c" || what === "q") && agent) {
870885 const ability = agent.abilities[what];
871886 cost = ability.cost;
static/watch.js +7 −1
@@ -9,6 +9,8 @@
99 * says what the match is doing and why this connection is only watching.
1010 */
1111
12 import { roundOutcome } from "./prompt.js";
13
1214 /** Is this a snapshot for a connection that has no seat? */
1315 export const watching = (snap) => Boolean(snap) && (snap.spectator === true || !snap.me);
1416
@@ -26,10 +28,14 @@ const PHASE = { buy: "buy phase", live: "live", end: "round over" };
2628 /** What the watching view says, in order. Plain strings, nothing drawn. */
2729 export function watchLines(snap) {
2830 const score = snap.score ?? { red: 0, blue: 0 };
31 // A round that has just ended says who took it and why, the same as a seat
32 // is told — it is the one thing a watcher most wants to know.
33 const outcome = snap.phase === "end" ? roundOutcome(snap) : null;
34 const turned = outcome ? ` — ${outcome.winner} take it, ${outcome.why}` : "";
2935 return {
3036 title: "watching",
3137 why: WHY[snap.why] ?? WHY_UNSAID,
32 phase: `round ${snap.round ?? 1} · ${PHASE[snap.phase] ?? "match over"} · ${snap.attackers ?? "—"} attacking${snap.result ? " · " + snap.result : ""}`,
38 phase: `round ${snap.round ?? 1} · ${PHASE[snap.phase] ?? "match over"}${turned} · ${snap.attackers ?? "—"} attacking${snap.result ? " · " + snap.result : ""}`,
3339 score: `red ${score.red ?? 0} — ${score.blue ?? 0} blue`,
3440 clock: `${Math.ceil(Math.max(0, Number(snap.clock) || 0))}s`,
3541 note: "No map and no bodies: a watching connection is never told where anybody is.",
↑↓ navigate ↵ open esc close

Keyboard shortcuts