rook/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]>
16 files
changed +389
−51
README.md +16 −3
| @@ -92,8 +92,11 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 92 | 92 | body drawn slow, then fast. The moment being drawn advances at real time |
| 93 | 93 | and creeps toward its delay at no more than two percent of it, so the |
| 94 | 94 | 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. | |
| 97 | 100 | - The view is a raycaster: walls are one tile tall and flat-shaded, there is |
| 98 | 101 | no looking up or down, and everything that is not a wall — bodies, the |
| 99 | 102 | spike, ability zones — is a flat billboard scaled by distance and clipped |
| @@ -125,7 +128,16 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 125 | 128 | the page draws the score, the round, the clock and the kill feed, says |
| 126 | 129 | which of the two reasons it is watching for, and draws no map and no |
| 127 | 130 | 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. | |
| 129 | 141 | - Sight is a full circle rather than a view cone — you are drawn what is in |
| 130 | 142 | front of you, but the server reveals anything you have line of sight to, |
| 131 | 143 | 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 | ||
| 175 | 187 | columns, `resolution.js` and `budget.js` decide how wide to draw it, |
| 176 | 188 | `presence.js` fades bodies into sight and marks where they left it, |
| 177 | 189 | `topdown.js` the minimap, |
| 190 | `banner.js` the words on the view when a round turns, | |
| 178 | 191 | `gun.js`, `beep.js`, `steer.js`, `roster.js`, `yaw.js`, `interp.js` and `prompt.js` the rest; the server bundles |
| 179 | 192 | them into one content-named file at start-up. The rules are pure functions and the tests play whole matches |
| 180 | 193 | headlessly. |
src/arena.ts +34 −7
| @@ -44,7 +44,14 @@ export const TICK = 1 / 30; | ||
| 44 | 44 | */ |
| 45 | 45 | export const broadcastEvery = (filled: number): number => (filled <= 2 ? 1 : 2); |
| 46 | 46 | |
| 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 | }; | |
| 48 | 55 | |
| 49 | 56 | let state: MatchState = newMatch(); |
| 50 | 57 | const seats = new Map<string, Seat>(); |
| @@ -123,13 +130,13 @@ export function takeSeat(address: string, session: string, name: string): Player | ||
| 123 | 130 | seat.think = 0; |
| 124 | 131 | seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null }; |
| 125 | 132 | 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: "" }); | |
| 127 | 134 | return seat; |
| 128 | 135 | } |
| 129 | 136 | |
| 130 | 137 | /** The socket that actually opened fills in how to reach it and its token. */ |
| 131 | 138 | 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: "" }); | |
| 133 | 140 | } |
| 134 | 141 | |
| 135 | 142 | /** |
| @@ -194,6 +201,7 @@ export function spectatorSnapshot(why: Why = "unsaid"): string { | ||
| 194 | 201 | score: state.score, |
| 195 | 202 | attackers: state.attackers, |
| 196 | 203 | result: state.result, |
| 204 | last: state.last, | |
| 197 | 205 | events: state.events.slice(0, 5).map((e) => e.text), |
| 198 | 206 | // Names and a gun, the same as a seat is sent, and for the same reason it |
| 199 | 207 | // is safe there: the feed says what happened, never where. |
| @@ -256,13 +264,31 @@ export function command(playerId: string, message: Command): void { | ||
| 256 | 264 | |
| 257 | 265 | const round2 = (n: number): number => Math.round(n * 10) / 10; |
| 258 | 266 | |
| 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) { | |
| 260 | 286 | const me = findPlayer(viewerId) ?? state.players[0]!; |
| 261 | 287 | const agent = agentById(me.agent); |
| 262 | 288 | const seen = state.players.filter((p) => visible(state, me, p)); |
| 263 | 289 | const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones); |
| 264 | 290 | |
| 265 | return JSON.stringify({ | |
| 291 | return { | |
| 266 | 292 | t: "s", |
| 267 | 293 | phase: state.phase, |
| 268 | 294 | clock: round2(Math.max(0, state.clock)), |
| @@ -270,6 +296,7 @@ export function snapshot(viewerId: string): string { | ||
| 270 | 296 | score: state.score, |
| 271 | 297 | attackers: state.attackers, |
| 272 | 298 | result: state.result, |
| 299 | last: state.last, | |
| 273 | 300 | me: { |
| 274 | 301 | id: me.id, |
| 275 | 302 | side: me.side, |
| @@ -354,7 +381,7 @@ export function snapshot(viewerId: string): string { | ||
| 354 | 381 | h: f.headshot, |
| 355 | 382 | t: round2(Math.max(0, state.now - f.at)), |
| 356 | 383 | })), |
| 357 | }); | |
| 384 | }; | |
| 358 | 385 | } |
| 359 | 386 | |
| 360 | 387 | /** |
| @@ -411,7 +438,7 @@ function step(): void { | ||
| 411 | 438 | if (ticks % broadcastEvery(seats.size) !== 0) return; |
| 412 | 439 | for (const seat of seats.values()) { |
| 413 | 440 | try { |
| 414 | seat.send(snapshot(seat.playerId)); | |
| 441 | seat.send(snapshotForSeat(seat)); | |
| 415 | 442 | } catch { |
| 416 | 443 | // A socket that will not take a frame is closed already; the close |
| 417 | 444 | // 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 [ | ||
| 116 | 116 | ]) |
| 117 | 117 | nodes.set(id, element(id)); |
| 118 | 118 | |
| 119 | const GUNS = ["classic", "ghost", "sheriff", "spectre", "phantom", "vandal", "operator"]; | |
| 119 | 120 | const buys = [ |
| 120 | element("b1", { buy: "vandal", cost: "2900" }), | |
| 121 | ...GUNS.map((gun, i) => element(`g${i}`, { buy: gun, cost: gun === "vandal" ? "2900" : "0" })), | |
| 121 | 122 | element("b2", { buy: "c", cost: "0" }), |
| 122 | 123 | element("b3", { buy: "q", cost: "0" }), |
| 123 | 124 | ]; |
| @@ -219,7 +220,13 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 219 | 220 | // One prompt, in words, about the round that is running. |
| 220 | 221 | expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8); |
| 221 | 222 | // 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]); | |
| 223 | 230 | |
| 224 | 231 | // The team is on screen whether anybody is in sight or not: five slots. |
| 225 | 232 | 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", () => { | ||
| 150 | 150 | test("time running out with no plant is a defence win, but the clock stops mattering once it is down", () => { |
| 151 | 151 | const timeout = live(); |
| 152 | 152 | timeout.clock = 0.5; |
| 153 | expect(timeout.last).toBe(null); | |
| 153 | 154 | run(timeout, 1); |
| 154 | 155 | expect(timeout.phase).toBe("end"); |
| 155 | 156 | 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" }); | |
| 156 | 161 | |
| 157 | 162 | const planted = live(); |
| 158 | 163 | const carrier = onSite(planted); |
src/match.ts +2
| @@ -165,6 +165,7 @@ export function newMatch(seed = Date.now() % 100000): MatchState { | ||
| 165 | 165 | shots: [], |
| 166 | 166 | events: [], |
| 167 | 167 | result: null, |
| 168 | last: null, | |
| 168 | 169 | seed, |
| 169 | 170 | now: 0, |
| 170 | 171 | }; |
| @@ -648,6 +649,7 @@ function finishRound(state: MatchState, winners: Side, why: string): void { | ||
| 648 | 649 | } |
| 649 | 650 | state.phase = "end"; |
| 650 | 651 | state.clock = END_TIME; |
| 652 | state.last = { round: state.round, winner: winningTeam, why }; | |
| 651 | 653 | say(state, `${why} — ${winningTeam} take round ${state.round} (${state.score.red}–${state.score.blue})`); |
| 652 | 654 | if (state.score[winningTeam] >= WIN_ROUNDS) { |
| 653 | 655 | state.phase = "over"; |
src/pages/game.ts +19 −9
| @@ -21,7 +21,9 @@ const phaseWord = (state: MatchState): string => | ||
| 21 | 21 | ? `spike down · ${Math.ceil(state.spike.fuse)}s` |
| 22 | 22 | : "live" |
| 23 | 23 | : 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" | |
| 25 | 27 | : "match over"; |
| 26 | 28 | |
| 27 | 29 | export function gamePage(opts: { |
| @@ -118,11 +120,15 @@ export function gamePage(opts: { | ||
| 118 | 120 | background: #1b1e25; color: var(--ink); border: 1px solid #333a47; border-radius: .45rem; |
| 119 | 121 | font: inherit; font-size: .8rem; cursor: pointer; } |
| 120 | 122 | .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; } | |
| 121 | 125 | .kit b { display: block; font-size: .7rem; opacity: .7; } |
| 122 | 126 | #buy { display: none; margin: .5rem 0; padding: .6rem; background: #151820; border: 1px solid #2a2f3a; |
| 123 | 127 | border-radius: .6rem; } |
| 124 | 128 | #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; } | |
| 126 | 132 | #log { margin: .5rem 0; font-size: .82rem; opacity: .8; min-height: 3.2em; } |
| 127 | 133 | #log div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| 128 | 134 | table { width: 100%; border-collapse: collapse; font-size: .85rem; } |
| @@ -207,22 +213,26 @@ export function gamePage(opts: { | ||
| 207 | 213 | </div> |
| 208 | 214 | |
| 209 | 215 | <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( | |
| 212 | 220 | (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() | |
| 215 | 223 | .map( |
| 216 | 224 | (id) => |
| 217 | 225 | `<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>`, |
| 218 | 226 | ) |
| 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"> | |
| 221 | 229 | <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> |
| 222 | 230 | <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"> | |
| 223 | 233 | <button type="button" data-buy="c" data-cost="0">C ability <b>—</b></button> |
| 224 | 234 | <button type="button" data-buy="q" data-cost="0">Q ability <b>—</b></button> |
| 225 | </div> | |
| 235 | </span></div> | |
| 226 | 236 | </div> |
| 227 | 237 | |
| 228 | 238 | <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 | ||
| 13 | 13 | }); |
| 14 | 14 | |
| 15 | 15 | 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", () => { | |
| 17 | 17 | 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."); | |
| 19 | 20 | }); |
| 20 | 21 | |
| 21 | 22 | test("the terminal is told about keys, not clicks", () => { |
| 22 | 23 | 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."); | |
| 24 | 25 | }); |
| 25 | 26 | |
| 26 | 27 | test("carrying the spike off a site sends you to one", () => { |
| @@ -58,9 +59,23 @@ describe("the one prompt on screen", () => { | ||
| 58 | 59 | expect(choosePrompt(snapshot({}, { alive: false }))).toBe("Waiting for the next round — you're in it"); |
| 59 | 60 | }); |
| 60 | 61 | |
| 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"); | |
| 64 | 79 | }); |
| 65 | 80 | |
| 66 | 81 | 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 | ||
| 167 | 167 | const spot = siteCentre("B"); |
| 168 | 168 | const carrier = state.players.find((p) => p.side === "atk")!; |
| 169 | 169 | // 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. | |
| 171 | 176 | for (const player of state.players) { |
| 172 | 177 | player.bot = false; |
| 173 | 178 | player.alive = true; |
| 174 | 179 | player.hp = 100; |
| 180 | player.spike = false; | |
| 181 | player.planting = 0; | |
| 175 | 182 | player.input.fire = false; |
| 183 | player.input.use = false; | |
| 184 | player.input.mx = 0; | |
| 185 | player.input.my = 0; | |
| 176 | 186 | } |
| 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); | |
| 190 | 228 | }); |
| 191 | 229 | |
| 192 | 230 | 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 () => { | ||
| 217 | 255 | expect(body).toContain('id="help"'); |
| 218 | 256 | expect(body).toContain("How to play"); |
| 219 | 257 | 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. | |
| 221 | 261 | 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"); | |
| 222 | 264 | expect(body).toContain('id="viewtoggle"'); |
| 223 | 265 | }); |
| 224 | 266 | |
| 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 | ||
| 225 | 283 | test("a stale socket closing does not evict a live reconnect's seat", async () => { |
| 226 | 284 | // A reload opens the new socket before the old one's close event lands. |
| 227 | 285 | // 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 = { | ||
| 124 | 124 | shots: { x1: number; y1: number; x2: number; y2: number; hit: boolean; age: number }[]; |
| 125 | 125 | events: { text: string; age: number }[]; |
| 126 | 126 | 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; | |
| 127 | 133 | seed: number; |
| 128 | 134 | now: number; |
| 129 | 135 | }; |
src/tty.ts +4 −1
| @@ -64,7 +64,10 @@ async function connect(): Promise<void> { | ||
| 64 | 64 | setTimeout(connect, 2000); |
| 65 | 65 | }; |
| 66 | 66 | 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; | |
| 68 | 71 | }; |
| 69 | 72 | } catch (error) { |
| 70 | 73 | status = `cannot reach ${base}`; |
src/watch.test.ts +7
| @@ -43,6 +43,13 @@ describe("what the watching view says", () => { | ||
| 43 | 43 | expect(lines.phase).toBe("round 7 · live · red attacking"); |
| 44 | 44 | }); |
| 45 | 45 | |
| 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 | ||
| 46 | 53 | test("the reason is named: the seat is held at this address", () => { |
| 47 | 54 | expect(watchLines(spectator()).why).toContain("address"); |
| 48 | 55 | 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; | ||
| 12 | 12 | |
| 13 | 13 | const secs = (n) => Math.max(0, Math.ceil(n)); |
| 14 | 14 | |
| 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 | ||
| 15 | 31 | export function choosePrompt(snap, world = {}) { |
| 16 | 32 | if (!snap) return ""; |
| 17 | 33 | const me = snap.me; |
| @@ -22,14 +38,22 @@ export function choosePrompt(snap, world = {}) { | ||
| 22 | 38 | // names the input, so it says the thing the reader can actually do. |
| 23 | 39 | const buyWith = world.input === "keys" ? "Press b and buy a gun you can afford." : "Click a gun you can afford."; |
| 24 | 40 | |
| 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`; | |
| 26 | 43 | 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 | } | |
| 27 | 49 | if (!me.alive) |
| 28 | 50 | return snap.phase === "buy" |
| 29 | 51 | ? "Buy phase — you are back next round" |
| 30 | 52 | : "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 | } | |
| 33 | 57 | |
| 34 | 58 | if (me.planting > 0) return `Planting — hold F for ${secs(4 - me.planting)}s more`; |
| 35 | 59 | if (spike.planted) { |
static/val.js +19 −4
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | "use strict"; |
| 13 | 13 | |
| 14 | import { banner, drawBanner } from "./banner.js"; | |
| 14 | 15 | import { makeBeeper } from "./beep.js"; |
| 15 | 16 | import { makeBudget, measure } from "./budget.js"; |
| 16 | 17 | import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "./interp.js"; |
| @@ -109,6 +110,9 @@ function connect() { | ||
| 109 | 110 | socket.onmessage = (event) => { |
| 110 | 111 | const data = JSON.parse(event.data); |
| 111 | 112 | 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; | |
| 112 | 116 | snap = data; |
| 113 | 117 | const landed = performance.now(); |
| 114 | 118 | // No seat, no `me`, no players. Everything below reads a body out of the |
| @@ -488,8 +492,13 @@ function frame(now) { | ||
| 488 | 492 | if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting"); |
| 489 | 493 | else if (snap.spike.planted && snap.spike.defuse > 0 && snap.me.side === "def") |
| 490 | 494 | 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); | |
| 491 | 500 | $("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"; | |
| 493 | 502 | $("aimhint").style.display = mode === "fp" && !locked && !COARSE ? "grid" : "none"; |
| 494 | 503 | |
| 495 | 504 | // What this frame cost to draw, not how long the browser waited for the |
| @@ -639,8 +648,9 @@ function paintHud(s) { | ||
| 639 | 648 | $("clock").textContent = Math.ceil(s.phase === "live" && s.spike.planted ? s.spike.fuse : s.clock); |
| 640 | 649 | document.querySelector("#scoreline .red").textContent = "red " + s.score.red; |
| 641 | 650 | 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; | |
| 642 | 652 | $("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 : ""}`; | |
| 644 | 654 | $("hp").textContent = `${s.me.hp} hp${s.me.armour ? " +" + s.me.armour : ""}`; |
| 645 | 655 | $("gun").textContent = `${s.me.weapon}${s.me.reloading ? " reloading" : " " + s.me.ammo}`; |
| 646 | 656 | $("creds").textContent = `${s.me.credits} cr`; |
| @@ -662,7 +672,7 @@ function paintHud(s) { | ||
| 662 | 672 | |
| 663 | 673 | for (const side of ["atk", "def"]) { |
| 664 | 674 | 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); | |
| 666 | 676 | const key = rows.map((p) => `${p.name}${p.kills}${p.deaths}${p.alive}${p.credits}`).join(); |
| 667 | 677 | if (body.dataset.key === key) continue; |
| 668 | 678 | body.dataset.key = key; |
| @@ -860,12 +870,17 @@ function paintKit(s) { | ||
| 860 | 870 | } |
| 861 | 871 | } |
| 862 | 872 | |
| 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. */ | |
| 864 | 878 | function paintBuy(s) { |
| 865 | 879 | const agent = kitOf(s.me.agent); |
| 866 | 880 | for (const button of document.querySelectorAll("[data-buy]")) { |
| 867 | 881 | const what = button.dataset.buy; |
| 868 | 882 | let cost = Number(button.dataset.cost ?? 0); |
| 883 | button.classList.toggle("owned", owned(s.me, what)); | |
| 869 | 884 | if ((what === "c" || what === "q") && agent) { |
| 870 | 885 | const ability = agent.abilities[what]; |
| 871 | 886 | cost = ability.cost; |
static/watch.js +7 −1
| @@ -9,6 +9,8 @@ | ||
| 9 | 9 | * says what the match is doing and why this connection is only watching. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { roundOutcome } from "./prompt.js"; | |
| 13 | ||
| 12 | 14 | /** Is this a snapshot for a connection that has no seat? */ |
| 13 | 15 | export const watching = (snap) => Boolean(snap) && (snap.spectator === true || !snap.me); |
| 14 | 16 | |
| @@ -26,10 +28,14 @@ const PHASE = { buy: "buy phase", live: "live", end: "round over" }; | ||
| 26 | 28 | /** What the watching view says, in order. Plain strings, nothing drawn. */ |
| 27 | 29 | export function watchLines(snap) { |
| 28 | 30 | 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}` : ""; | |
| 29 | 35 | return { |
| 30 | 36 | title: "watching", |
| 31 | 37 | 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 : ""}`, | |
| 33 | 39 | score: `red ${score.red ?? 0} — ${score.blue ?? 0} blue`, |
| 34 | 40 | clock: `${Math.ceil(Math.max(0, Number(snap.clock) || 0))}s`, |
| 35 | 41 | note: "No map and no bodies: a watching connection is never told where anybody is.", |