wren/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]>
Wren committed 9437cb3 Browse files
5 files changed +287 −15
README.md +26 −12
@@ -2,11 +2,12 @@
22
33 Five attackers with the spike, five defenders, abilities on cooldown. First to 13.
44
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.
1011
1112 ## What is real
1213
@@ -42,20 +43,30 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
4243
4344 ## What is not
4445
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.
4550 - One map, one match at a time, ten seats. There is no matchmaking, no
4651 ranking and no account: your seat belongs to a session cookie.
4752 - 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.
5058 - Assists are counted as a field but never awarded.
5159 - The live match lives in memory and is gone if the process restarts; only
5260 finished match results are written to SQLite under `FORGE_APP_DATA`.
5361
5462 ## Playing
5563
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.
5970
6071 ```sh
6172 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
@@ -77,6 +88,9 @@ says how.
7788 `src/economy.ts` credits · `src/abilities.ts` the agent kits ·
7889 `src/match.ts` the round loop (`tick(state, dt)` is the whole game and takes
7990 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
8296 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", () => {
1818 expect(text).toBe("Buy phase — 15s left. Click a gun you can afford.");
1919 });
2020
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
2126 test("carrying the spike off a site sends you to one", () => {
2227 expect(choosePrompt(snapshot({}, { spike: true }), { onSite: false })).toBe(
2328 "You have the spike — get to site A or B",
src/tty.ts +29 −1
@@ -7,6 +7,8 @@
77 * The map is the same grid, one character a tile. Plain ANSI, no dependencies.
88 */
99
10 import { choosePrompt, REACH } from "../static/prompt.js";
11
1012 const base =
1113 process.env.VAL_URL ??
1214 (process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined) ??
@@ -38,6 +40,7 @@ let status = "connecting";
3840 let aim = -Math.PI / 2;
3941 let move = { mx: 0, my: 0, until: 0 };
4042 let firing = 0;
43 let helping = false;
4144 let using = 0;
4245 let buying = false;
4346
@@ -71,6 +74,28 @@ async function connect(): Promise<void> {
7174
7275 const colour = (s: string, code: number): string => `${esc}${code}m${s}${esc}0m`;
7376
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
7499 function draw(): void {
75100 if (!map) {
76101 out(`${esc}2J${esc}Hval — ${status}\r\n`);
@@ -101,6 +126,7 @@ function draw(): void {
101126 lines.push(
102127 `${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)}°`,
103128 );
129 lines.push(colour("› " + prompt(snap), 93));
104130 } else lines.push(`val — ${status}`);
105131 lines.push("");
106132 for (const row of rows) lines.push(" " + row.join(""));
@@ -117,10 +143,11 @@ function draw(): void {
117143 for (const event of snap.events.slice(0, 3)) lines.push(colour("· " + event, 90));
118144 }
119145 lines.push("");
146 if (helping) for (const line of HOW_TO) lines.push(colour(line, 96));
120147 lines.push(
121148 buying
122149 ? 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),
124151 );
125152 out(`${esc}H${esc}J` + lines.join("\r\n"));
126153 }
@@ -146,6 +173,7 @@ process.stdin.on("data", (key: string) => {
146173 return;
147174 }
148175 if (key === "b") return void (buying = true);
176 if (key === "h" || key === "?") return void (helping = !helping);
149177 if (key === "w") move = { mx: move.mx, my: -1, until: now + 400 };
150178 if (key === "s") move = { mx: move.mx, my: 1, until: now + 400 };
151179 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 = {}) {
1818 const spike = snap.spike;
1919 const onSite = world.onSite === true;
2020 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.";
2124
2225 if (snap.phase === "over") return snap.result ? `Match over — ${snap.result}` : "Match over";
2326 if (me.blind > 0.05) return "You are blind — back off until it clears";
@@ -26,8 +29,7 @@ export function choosePrompt(snap, world = {}) {
2629 ? "Buy phase — you are back next round"
2730 : "Waiting for the next round — you're in it";
2831 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}`;
3133
3234 if (me.planting > 0) return `Planting — hold F for ${secs(4 - me.planting)}s more`;
3335 if (spike.planted) {
↑↓ navigate ↵ open esc close

Keyboard shortcuts