quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Play the terminal by the same instructions, and say so in the README
The page now tells a first-time player what to do; the terminal should not be the place where that knowledge is missing. It shows the same contextual line, from the same pure function the browser uses, and h opens a how-to panel. The one prompt that names an input says "press b" there and "click" on the page. README: the view is a raycaster now, the controls changed, and the client is several modules the server bundles into one file. Co-Authored-By: Claude Opus 5 <[email protected]>
5 files
changed +287
−15
README.md +26 −12
| @@ -2,11 +2,12 @@ | ||
| 2 | 2 | |
| 3 | 3 | Five attackers with the spike, five defenders, abilities on cooldown. First to 13. |
| 4 | 4 | |
| 5 | A top-down 5v5 played in a browser. One match is always running on the | |
| 6 | server: open the page and you are given a seat in the round that is | |
| 7 | happening, taken from a bot where it stands. When a match ends it shows the | |
| 8 | result for a few seconds and starts a new one, so nobody ever arrives at a | |
| 9 | frozen board. | |
| 5 | A 5v5 played in a browser, from inside the player's head — the map is a tile | |
| 6 | grid and the view is a raycaster over it, so there is no 3D library and no | |
| 7 | dependency at all. One match is always running on the server: open the page | |
| 8 | and you are given a seat in the round that is happening, taken from a bot | |
| 9 | where it stands. When a match ends it shows the result for a few seconds and | |
| 10 | starts a new one, so nobody ever arrives at a frozen board. | |
| 10 | 11 | |
| 11 | 12 | ## What is real |
| 12 | 13 | |
| @@ -42,20 +43,30 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 42 | 43 | |
| 43 | 44 | ## What is not |
| 44 | 45 | |
| 46 | - The view is a raycaster: walls are one tile tall and flat-shaded, there is | |
| 47 | no looking up or down, and everything that is not a wall — bodies, the | |
| 48 | spike, ability zones — is a flat billboard scaled by distance and clipped | |
| 49 | against the walls. | |
| 45 | 50 | - One map, one match at a time, ten seats. There is no matchmaking, no |
| 46 | 51 | ranking and no account: your seat belongs to a session cookie. |
| 47 | 52 | - No overtime — first to 13 ends it at 12–12 too. |
| 48 | - Sight is a full circle rather than a view cone; top-down has no vertical, | |
| 49 | so the hit location comes from how precisely the shot passes the target. | |
| 53 | - Sight is a full circle rather than a view cone — you are drawn what is in | |
| 54 | front of you, but the server reveals anything you have line of sight to, | |
| 55 | so a team mate behind you still appears on the minimap. The world has no | |
| 56 | vertical, so the hit location comes from how precisely the shot passes | |
| 57 | the target. | |
| 50 | 58 | - Assists are counted as a field but never awarded. |
| 51 | 59 | - The live match lives in memory and is gone if the process restarts; only |
| 52 | 60 | finished match results are written to SQLite under `FORGE_APP_DATA`. |
| 53 | 61 | |
| 54 | 62 | ## Playing |
| 55 | 63 | |
| 56 | WASD move, the mouse aims, click fires, **R** reloads, **F** plants and | |
| 57 | defuses (hold it), **C / Q / E / X** cast, **B** opens the buy. On a phone | |
| 58 | the left thumb walks and the right thumb aims and fires. | |
| 64 | Click the view to capture the mouse, then WASD move and the mouse looks; | |
| 65 | **Esc** gives the pointer back. Click fires, **R** reloads, **F** plants and | |
| 66 | defuses (hold it), **C / Q / E / X** cast, **B** opens the buy, **Tab** | |
| 67 | holds the big map open, **V** swaps to the top-down view and **H** or **?** | |
| 68 | brings back the how-to-play card. On a phone it stays top-down: the left | |
| 69 | thumb walks and the right thumb aims and fires. | |
| 59 | 70 | |
| 60 | 71 | sh |
| 61 | 72 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here |
| @@ -77,6 +88,9 @@ says how. | ||
| 77 | 88 | `src/economy.ts` credits · `src/abilities.ts` the agent kits · |
| 78 | 89 | `src/match.ts` the round loop (`tick(state, dt)` is the whole game and takes |
| 79 | 90 | no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one |
| 80 | live match and its sockets · `src/server.ts` the door · `static/val.js` the | |
| 81 | browser. The rules are pure functions and the tests play whole matches | |
| 91 | live match and its sockets · `src/server.ts` the door · `static/` the browser client — | |
| 92 | `val.js` wires the socket, the keys and the HUD, `raycast.js` is the ray | |
| 93 | maths, `scene.js` the first-person view, `topdown.js` the minimap, | |
| 94 | `gun.js`, `beep.js`, `steer.js` and `prompt.js` the rest; the server bundles | |
| 95 | them into one content-named file at start-up. The rules are pure functions and the tests play whole matches | |
| 82 | 96 | headlessly. |
src/client.test.ts added +223
| @@ -0,0 +1,223 @@ | ||
| 1 | /** | |
| 2 | * The client, played. | |
| 3 | * | |
| 4 | * The bundle the browser is served is loaded here against a stub document | |
| 5 | * and a canvas that only writes down what it was told to draw, and pointed | |
| 6 | * at the running server over a real socket. It is not a browser, but it is | |
| 7 | * the whole path a person takes: connect, take a snapshot, paint the HUD, | |
| 8 | * draw a frame, press a key, send an input back. | |
| 9 | */ | |
| 10 | ||
| 11 | import { expect, test } from "bun:test"; | |
| 12 | import { mkdtempSync } from "node:fs"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | ||
| 16 | process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-")); | |
| 17 | process.env.PORT ??= "0"; | |
| 18 | const { server } = await import("./server.ts"); | |
| 19 | const base = `http://${server.hostname}:${server.port}`; | |
| 20 | ||
| 21 | // ------------------------------------------------------------ the stub DOM | |
| 22 | ||
| 23 | const drawn: string[] = []; | |
| 24 | const listeners = new Map<string, ((event: unknown) => void)[]>(); | |
| 25 | const frames: ((now: number) => void)[] = []; | |
| 26 | ||
| 27 | const noop = () => {}; | |
| 28 | const context = () => | |
| 29 | new Proxy( | |
| 30 | { | |
| 31 | measureText: (text: string) => ({ width: text.length * 6 }), | |
| 32 | createLinearGradient: () => ({ addColorStop: noop }), | |
| 33 | } as Record<string, unknown>, | |
| 34 | { | |
| 35 | get(target, prop: string) { | |
| 36 | if (prop in target) return target[prop]; | |
| 37 | if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return ""; | |
| 38 | if (prop === "globalAlpha" || prop === "lineWidth") return 1; | |
| 39 | return (...args: unknown[]) => drawn.push(`${prop}(${args.length})`); | |
| 40 | }, | |
| 41 | set: () => true, | |
| 42 | }, | |
| 43 | ); | |
| 44 | ||
| 45 | function element(id: string, attrs: Record<string, string> = {}) { | |
| 46 | const node: Record<string, unknown> = { | |
| 47 | id, | |
| 48 | dataset: { ...attrs }, | |
| 49 | style: {}, | |
| 50 | textContent: "", | |
| 51 | title: "", | |
| 52 | disabled: false, | |
| 53 | type: "", | |
| 54 | children: [] as unknown[], | |
| 55 | classList: { | |
| 56 | on: new Set<string>(), | |
| 57 | add(name: string) { | |
| 58 | (this.on as Set<string>).add(name); | |
| 59 | }, | |
| 60 | remove(name: string) { | |
| 61 | (this.on as Set<string>).delete(name); | |
| 62 | }, | |
| 63 | toggle(name: string, force?: boolean) { | |
| 64 | const on = force ?? !(this.on as Set<string>).has(name); | |
| 65 | if (on) (this.on as Set<string>).add(name); | |
| 66 | else (this.on as Set<string>).delete(name); | |
| 67 | return on; | |
| 68 | }, | |
| 69 | contains(name: string) { | |
| 70 | return (this.on as Set<string>).has(name); | |
| 71 | }, | |
| 72 | }, | |
| 73 | events: new Map<string, ((event: unknown) => void)[]>(), | |
| 74 | addEventListener(kind: string, fn: (event: unknown) => void) { | |
| 75 | const map = node.events as Map<string, ((event: unknown) => void)[]>; | |
| 76 | map.set(kind, [...(map.get(kind) ?? []), fn]); | |
| 77 | }, | |
| 78 | append(...kids: unknown[]) { | |
| 79 | (node.children as unknown[]).push(...kids); | |
| 80 | }, | |
| 81 | replaceChildren(...kids: unknown[]) { | |
| 82 | node.children = kids; | |
| 83 | }, | |
| 84 | getBoundingClientRect: () => ({ left: 0, top: 0, width: 900, height: 570 }), | |
| 85 | getContext: () => context(), | |
| 86 | requestPointerLock: noop, | |
| 87 | setPointerCapture: noop, | |
| 88 | width: 900, | |
| 89 | height: 570, | |
| 90 | }; | |
| 91 | return node; | |
| 92 | } | |
| 93 | ||
| 94 | const click = (node: Record<string, unknown>) => { | |
| 95 | for (const fn of (node.events as Map<string, ((e: unknown) => void)[]>).get("click") ?? []) fn({}); | |
| 96 | }; | |
| 97 | const press = (code: string, key = "") => { | |
| 98 | for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop }); | |
| 99 | }; | |
| 100 | ||
| 101 | const nodes = new Map<string, Record<string, unknown>>(); | |
| 102 | for (const id of [ | |
| 103 | "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle", | |
| 104 | "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage", | |
| 105 | "log", "atkboard", "defboard", "helpagents", | |
| 106 | ]) | |
| 107 | nodes.set(id, element(id)); | |
| 108 | ||
| 109 | const buys = [ | |
| 110 | element("b1", { buy: "vandal", cost: "2900" }), | |
| 111 | element("b2", { buy: "c", cost: "0" }), | |
| 112 | element("b3", { buy: "q", cost: "0" }), | |
| 113 | ]; | |
| 114 | const agentButtons = [element("a1", { agent: "veil" })]; | |
| 115 | const scoreRed = element("red"); | |
| 116 | const scoreBlue = element("blue"); | |
| 117 | ||
| 118 | Object.assign(globalThis, { | |
| 119 | document: { | |
| 120 | getElementById: (id: string) => nodes.get(id) ?? null, | |
| 121 | querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue), | |
| 122 | querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons), | |
| 123 | createElement: (tag: string) => element(tag), | |
| 124 | addEventListener: noop, | |
| 125 | pointerLockElement: null, | |
| 126 | exitPointerLock: noop, | |
| 127 | }, | |
| 128 | HTMLInputElement: class {}, | |
| 129 | matchMedia: () => ({ matches: false }), | |
| 130 | devicePixelRatio: 1, | |
| 131 | localStorage: { | |
| 132 | store: new Map<string, string>(), | |
| 133 | getItem(key: string) { | |
| 134 | return this.store.get(key) ?? null; | |
| 135 | }, | |
| 136 | setItem(key: string, value: string) { | |
| 137 | this.store.set(key, value); | |
| 138 | }, | |
| 139 | }, | |
| 140 | requestAnimationFrame: (fn: (now: number) => void) => frames.push(fn), | |
| 141 | addEventListener: (kind: string, fn: (event: unknown) => void) => { | |
| 142 | listeners.set(kind, [...(listeners.get(kind) ?? []), fn]); | |
| 143 | }, | |
| 144 | location: new URL(base), | |
| 145 | }); | |
| 146 | ||
| 147 | // Relative fetches, the way a page makes them. | |
| 148 | const realFetch = globalThis.fetch; | |
| 149 | globalThis.fetch = ((input: string, init?: RequestInit) => | |
| 150 | realFetch(typeof input === "string" && input.startsWith("/") ? base + input : input, init)) as typeof fetch; | |
| 151 | ||
| 152 | const runFrame = () => { | |
| 153 | const pending = frames.splice(0, frames.length); | |
| 154 | for (const fn of pending) fn(performance.now()); | |
| 155 | }; | |
| 156 | const until = async (what: () => boolean, ms = 4000) => { | |
| 157 | const stop = Date.now() + ms; | |
| 158 | while (Date.now() < stop) { | |
| 159 | runFrame(); | |
| 160 | if (what()) return true; | |
| 161 | await Bun.sleep(20); | |
| 162 | } | |
| 163 | return false; | |
| 164 | }; | |
| 165 | ||
| 166 | // ------------------------------------------------------------------ the run | |
| 167 | ||
| 168 | test("the bundled client connects, paints the HUD and draws a frame", async () => { | |
| 169 | const page = await (await fetch(base + "/")).text(); | |
| 170 | const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(page)![1]!; | |
| 171 | const script = await (await fetch(base + src)).text(); | |
| 172 | const path = join(process.env.FORGE_APP_DATA!, "client.mjs"); | |
| 173 | await Bun.write(path, script); | |
| 174 | ||
| 175 | await import(path); | |
| 176 | expect(await until(() => nodes.get("conn")!.textContent === "live")).toBe(true); | |
| 177 | expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true); | |
| 178 | ||
| 179 | // The HUD says what the match says. | |
| 180 | expect(String(nodes.get("phase")!.textContent)).toContain("round"); | |
| 181 | expect(String(nodes.get("creds")!.textContent)).toContain("cr"); | |
| 182 | // The kit is four buttons, each named after the ability it casts. | |
| 183 | expect((nodes.get("kit")!.children as unknown[]).length).toBe(4); | |
| 184 | // One prompt, in words, about the round that is running. | |
| 185 | expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8); | |
| 186 | // And the buy panel prices itself off the agent's real kit. | |
| 187 | expect(String(buys[1]!.title)).toContain("charge"); | |
| 188 | ||
| 189 | // A frame of the first-person view actually draws walls. | |
| 190 | drawn.length = 0; | |
| 191 | runFrame(); | |
| 192 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(200); | |
| 193 | ||
| 194 | // V falls back to the top-down view, and it draws too. | |
| 195 | press("KeyV"); | |
| 196 | drawn.length = 0; | |
| 197 | runFrame(); | |
| 198 | expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true); | |
| 199 | expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" }); | |
| 200 | press("KeyV"); | |
| 201 | expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" }); | |
| 202 | ||
| 203 | // H toggles the card either way, and the button closes it for good. | |
| 204 | const help = nodes.get("help")!.style as Record<string, string>; | |
| 205 | const before = help.display; | |
| 206 | press("KeyH"); | |
| 207 | expect(help.display).not.toBe(before); | |
| 208 | if (help.display !== "grid") press("KeyH"); | |
| 209 | expect(help.display).toBe("grid"); | |
| 210 | click(nodes.get("helpclose")!); | |
| 211 | expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none"); | |
| 212 | expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1"); | |
| 213 | ||
| 214 | // B works the buy panel either way — it opens itself in the buy phase. | |
| 215 | const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean }; | |
| 216 | const wasOpen = panel.contains("on"); | |
| 217 | press("KeyB"); | |
| 218 | expect(panel.contains("on")).toBe(!wasOpen); | |
| 219 | ||
| 220 | // And the socket really took a seat in the running match. | |
| 221 | const { match } = await import("./arena.ts"); | |
| 222 | expect(match().players.some((p) => !p.bot)).toBe(true); | |
| 223 | }, 15000); |
src/prompt.test.ts +5
| @@ -18,6 +18,11 @@ describe("the one prompt on screen", () => { | ||
| 18 | 18 | expect(text).toBe("Buy phase — 15s left. Click a gun you can afford."); |
| 19 | 19 | }); |
| 20 | 20 | |
| 21 | test("the terminal is told about keys, not clicks", () => { | |
| 22 | 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 | }); | |
| 25 | ||
| 21 | 26 | test("carrying the spike off a site sends you to one", () => { |
| 22 | 27 | expect(choosePrompt(snapshot({}, { spike: true }), { onSite: false })).toBe( |
| 23 | 28 | "You have the spike — get to site A or B", |
src/tty.ts +29 −1
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | * The map is the same grid, one character a tile. Plain ANSI, no dependencies. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { choosePrompt, REACH } from "../static/prompt.js"; | |
| 11 | ||
| 10 | 12 | const base = |
| 11 | 13 | process.env.VAL_URL ?? |
| 12 | 14 | (process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined) ?? |
| @@ -38,6 +40,7 @@ let status = "connecting"; | ||
| 38 | 40 | let aim = -Math.PI / 2; |
| 39 | 41 | let move = { mx: 0, my: 0, until: 0 }; |
| 40 | 42 | let firing = 0; |
| 43 | let helping = false; | |
| 41 | 44 | let using = 0; |
| 42 | 45 | let buying = false; |
| 43 | 46 | |
| @@ -71,6 +74,28 @@ async function connect(): Promise<void> { | ||
| 71 | 74 | |
| 72 | 75 | const colour = (s: string, code: number): string => `${esc}${code}m${s}${esc}0m`; |
| 73 | 76 | |
| 77 | /** The same line the page shows, from the same function. */ | |
| 78 | function prompt(s: Snapshot): string { | |
| 79 | const row = map ? map.rows[Math.floor(s.me.y / map.tile)] : ""; | |
| 80 | const cell = row ? row[Math.floor(s.me.x / map.tile)] : ""; | |
| 81 | return choosePrompt(s, { | |
| 82 | input: "keys", | |
| 83 | onSite: cell === "A" || cell === "B", | |
| 84 | nearSpike: | |
| 85 | s.spike.x !== null && s.spike.y !== null && Math.hypot(s.spike.x - s.me.x, s.spike.y - s.me.y) < REACH, | |
| 86 | }); | |
| 87 | } | |
| 88 | ||
| 89 | const HOW_TO = [ | |
| 90 | "How to play — attackers carry the spike to site A or B and plant it (hold f);", | |
| 91 | "defenders stop them, or hold f on a planted spike to defuse it. 45s fuse, first to 13.", | |
| 92 | "· wasd walks, ←/→ turns the aim, space fires, r reloads", | |
| 93 | "· c q e x cast the kit: c and q are bought in the buy phase, e is free on a cooldown, x is the ultimate", | |
| 94 | "· b opens the buy — the same prices the page charges, refused server-side if you cannot afford them", | |
| 95 | "· A and B on the map are the sites, @ is you, cyan is a team mate, red is an enemy you can see", | |
| 96 | "h closes this.", | |
| 97 | ]; | |
| 98 | ||
| 74 | 99 | function draw(): void { |
| 75 | 100 | if (!map) { |
| 76 | 101 | out(`${esc}2J${esc}Hval — ${status}\r\n`); |
| @@ -101,6 +126,7 @@ function draw(): void { | ||
| 101 | 126 | lines.push( |
| 102 | 127 | `${me.alive ? colour(`${me.hp}hp${me.armour ? "+" + me.armour : ""}`, 32) : colour("dead", 90)} ${me.weapon} ${me.ammo} ${me.credits}cr ult ${me.ult}/${me.ultNeeds} C${me.charges.c} Q${me.charges.q} E${me.charges.e}${me.spike ? colour(" SPIKE", 33) : ""}${me.blind > 0 ? colour(" BLIND", 97) : ""} aim ${((aim * 180) / Math.PI).toFixed(0)}°`, |
| 103 | 128 | ); |
| 129 | lines.push(colour("› " + prompt(snap), 93)); | |
| 104 | 130 | } else lines.push(`val — ${status}`); |
| 105 | 131 | lines.push(""); |
| 106 | 132 | for (const row of rows) lines.push(" " + row.join("")); |
| @@ -117,10 +143,11 @@ function draw(): void { | ||
| 117 | 143 | for (const event of snap.events.slice(0, 3)) lines.push(colour("· " + event, 90)); |
| 118 | 144 | } |
| 119 | 145 | lines.push(""); |
| 146 | if (helping) for (const line of HOW_TO) lines.push(colour(line, 96)); | |
| 120 | 147 | lines.push( |
| 121 | 148 | buying |
| 122 | 149 | ? colour("buy: 1 Classic 2 Ghost 3 Sheriff 4 Spectre 5 Phantom 6 Vandal 7 Operator · 8 light 9 heavy · c/q ability · b closes", 33) |
| 123 | : colour("wasd move · ←/→ aim · space fire · f plant/defuse · c q e x cast · b buy · r reload · q quit", 90), | |
| 150 | : colour("wasd move · ←/→ aim · space fire · f plant/defuse · c q e x cast · b buy · r reload · h how to play · q quit", 90), | |
| 124 | 151 | ); |
| 125 | 152 | out(`${esc}H${esc}J` + lines.join("\r\n")); |
| 126 | 153 | } |
| @@ -146,6 +173,7 @@ process.stdin.on("data", (key: string) => { | ||
| 146 | 173 | return; |
| 147 | 174 | } |
| 148 | 175 | if (key === "b") return void (buying = true); |
| 176 | if (key === "h" || key === "?") return void (helping = !helping); | |
| 149 | 177 | if (key === "w") move = { mx: move.mx, my: -1, until: now + 400 }; |
| 150 | 178 | if (key === "s") move = { mx: move.mx, my: 1, until: now + 400 }; |
| 151 | 179 | if (key === "a") move = { mx: -1, my: move.my, until: now + 400 }; |
static/prompt.js +4 −2
| @@ -18,6 +18,9 @@ export function choosePrompt(snap, world = {}) { | ||
| 18 | 18 | const spike = snap.spike; |
| 19 | 19 | const onSite = world.onSite === true; |
| 20 | 20 | const nearSpike = world.nearSpike === true; |
| 21 | // The page is played with a mouse and the terminal with keys; one line | |
| 22 | // names the input, so it says the thing the reader can actually do. | |
| 23 | const buyWith = world.input === "keys" ? "Press b and buy a gun you can afford." : "Click a gun you can afford."; | |
| 21 | 24 | |
| 22 | 25 | if (snap.phase === "over") return snap.result ? `Match over — ${snap.result}` : "Match over"; |
| 23 | 26 | if (me.blind > 0.05) return "You are blind — back off until it clears"; |
| @@ -26,8 +29,7 @@ export function choosePrompt(snap, world = {}) { | ||
| 26 | 29 | ? "Buy phase — you are back next round" |
| 27 | 30 | : "Waiting for the next round — you're in it"; |
| 28 | 31 | if (snap.phase === "end") return snap.result ? `${snap.result} — next round in ${secs(snap.clock)}s` : "Round over"; |
| 29 | if (snap.phase === "buy") | |
| 30 | return `Buy phase — ${secs(snap.clock)}s left. Click a gun you can afford.`; | |
| 32 | if (snap.phase === "buy") return `Buy phase — ${secs(snap.clock)}s left. ${buyWith}`; | |
| 31 | 33 | |
| 32 | 34 | if (me.planting > 0) return `Planting — hold F for ${secs(4 - me.planting)}s more`; |
| 33 | 35 | if (spike.planted) { |