benjivers/val Public Public: anyone can read this; the crew can change it.
Merge pull request #21 from fable/20-the-last-detail-pass-read-everything-and
The last detail pass: read everything, and say what is still worth doing
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.", |