Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

The round's rules stop being one 553-line file: the economy and the bomb get their own modules, and the digest does not move #188

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/113-the-round-s-rules-stop-being-one-553-lin → main 1 commit fork

  • Merged. vesper/113-the-round-s-rules-stop-being-one-553-lin was merged into main as 2efc380.

Review

Sign in to review this change.
15 files changed +684 −225
README.md +9
@@ -380,6 +380,15 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
380380 reaches only what `traceWorld` says it can see),
381381 `match`
382382 (the rules),
383 `economy` (the money: what an item costs the player in front of you, whether
384 the round will let them have it, and what handing it over does to their wallet
385 and their kit — `priceOf`, `canBuy`, `buy`, the pocket limits in `CARRY` and
386 the `Ammo` a fresh gun comes with),
387 `bomb` (the objective: `giveBomb`, `dropBomb`, `updateBomb` and the
388 `useProgress` a readout draws its bar from — carrying it, planting it on a
389 site, cutting the wire with a kit or without one; it hands back what the
390 *world* must do about a plant or a defuse, because paying ten people for a
391 round takes all ten and this file has one player),
383392 `world` (the simulation, and the two seconds of what just happened it keeps in
384393 the engine's ring: `EFFECT_CAPACITY` events at most, each stamped, pruned at
385394 the end of every tick by `EFFECT_LIFE` without allocating),
src/client/bomb.test.ts added +184
@@ -0,0 +1,184 @@
1 /**
2 * The objective's own tests, on a round made of three plain objects: the bomb,
3 * the match clock and a level. No world, no players, no bots — `updateBomb`
4 * takes a carrier and gives back at most one thing for the world to do, which
5 * is exactly what can be checked here without any of that.
6 *
7 * `world.test.ts` keeps the two that are the world's rather than the bomb's: a
8 * plant that keeps the round alive past the clock, and a defuse that wins it and
9 * pays ten people.
10 */
11
12 import { expect, test } from "bun:test";
13 import {
14 defuseTime, dropBomb, giveBomb, newBomb, updateBomb, useProgress,
15 type Carrier, type Round,
16 } from "./bomb.ts";
17 import { DE_FORGE } from "./map.ts";
18 import { newMatch, PLANT_REWARD, SHORT, type Phase } from "./match.ts";
19
20 const TICK = 1 / 64;
21
22 const at = (site: "A" | "B"): [number, number, number] =>
23 [DE_FORGE.sites[site][0], DE_FORGE.sites[site][1], 2];
24
25 /** A round mid-play, with the bomb wherever this test wants it. */
26 const round = (phase: Phase = "live"): Round => {
27 const match = newMatch(SHORT, "t");
28 match.phase = phase;
29 return { bomb: newBomb(), match, level: DE_FORGE };
30 };
31
32 const carrier = (over: Partial<Carrier> = {}): Carrier => ({
33 team: "t", pos: at("A"), onGround: true, money: 1000, defuseKit: false, hasBomb: false, ...over,
34 });
35
36 /** Hold use (or not) for `seconds`, and hand back whatever the bomb said. */
37 function hold(r: Round, p: Carrier, seconds: number, use = true): ReturnType<typeof updateBomb> {
38 let last: ReturnType<typeof updateBomb> = null;
39 for (let t = 0; t < seconds; t += TICK) last = updateBomb(r, p, use, TICK) ?? last;
40 return last;
41 }
42
43 test("a plant needs the site and not just the bomb", () => {
44 const r = round();
45 const p = carrier({ hasBomb: true, pos: [0, 0, 2] }); // holding it, standing nowhere
46 expect(hold(r, p, SHORT.plantTime + 0.2)).toBe(null);
47 expect(r.bomb.state).toBe("carried");
48 expect(r.bomb.plantProgress).toBe(0);
49
50 // The same key, the same seconds, on the site.
51 p.pos = at("A");
52 const event = hold(r, p, SHORT.plantTime + 0.2);
53 expect(event).toEqual({ type: "planted", site: "A" });
54 expect(r.bomb.state).toBe("planted");
55 expect(r.bomb.site).toBe("A");
56 });
57
58 test("a plant needs the bomb and not just the site, and needs the round live", () => {
59 const empty = round();
60 expect(hold(empty, carrier({ hasBomb: false }), SHORT.plantTime + 0.2)).toBe(null);
61 expect(empty.bomb.state).toBe("carried");
62
63 const frozen = round("freeze");
64 expect(hold(frozen, carrier({ hasBomb: true }), SHORT.plantTime + 0.2)).toBe(null);
65 expect(frozen.bomb.plantProgress).toBe(0);
66 });
67
68 test("a plant pays the planter, empties their hands and puts the clock on the fuse", () => {
69 const r = round();
70 const p = carrier({ hasBomb: true, pos: at("B") });
71 expect(hold(r, p, SHORT.plantTime + 0.2)?.type).toBe("planted");
72 expect(p.money).toBe(1000 + PLANT_REWARD);
73 expect(p.hasBomb).toBe(false);
74 expect(r.match.phase).toBe("planted");
75 expect(r.match.timer).toBe(SHORT.bombTime);
76 expect(r.bomb.pos[2]).toBe(4); // on the floor at their feet, not inside them
77 });
78
79 test("a plant interrupted by letting go starts again from nothing", () => {
80 const r = round();
81 const p = carrier({ hasBomb: true });
82 hold(r, p, SHORT.plantTime / 2);
83 expect(r.bomb.plantProgress).toBeGreaterThan(0);
84 expect(useProgress(r, p)).toEqual({ label: "Planting", fraction: expect.any(Number) });
85 hold(r, p, TICK, false);
86 expect(r.bomb.plantProgress).toBe(0);
87 expect(useProgress(r, p)).toBe(null);
88 });
89
90 test("the bomb the carrier dropped is picked up by the next terrorist past it", () => {
91 const r = round();
92 const dead = carrier({ hasBomb: true, pos: [100, 100, 2] });
93 dropBomb(r.bomb, dead);
94 expect(r.bomb.state).toBe("dropped");
95 expect(dead.hasBomb).toBe(false);
96
97 const ct = carrier({ team: "ct", pos: [100, 100, 2] });
98 updateBomb(r, ct, false, TICK);
99 expect(r.bomb.state).toBe("dropped"); // not theirs to carry
100 const far = carrier({ pos: [1000, 1000, 2] });
101 updateBomb(r, far, false, TICK);
102 expect(r.bomb.state).toBe("dropped");
103
104 const near = carrier({ pos: [120, 100, 2] });
105 updateBomb(r, near, false, TICK);
106 expect(r.bomb.state).toBe("carried");
107 expect(near.hasBomb).toBe(true);
108 });
109
110 test("a defuse interrupted goes back to nothing, and resuming starts the wire again", () => {
111 const r = round("planted");
112 r.bomb.state = "planted";
113 r.bomb.pos = at("B");
114 const ct = carrier({ team: "ct", pos: at("B") });
115
116 hold(r, ct, SHORT.defuseTime * 0.75);
117 expect(r.bomb.defuser).toBe(ct);
118 expect(useProgress(r, ct)?.label).toBe("Defusing");
119
120 hold(r, ct, TICK, false); // the key came up
121 expect(r.bomb.defuseProgress).toBe(0);
122 expect(r.bomb.defuser).toBe(null);
123 expect(useProgress(r, ct)).toBe(null);
124
125 // Three quarters of the way was worth nothing: it takes the whole of it again.
126 expect(hold(r, ct, SHORT.defuseTime * 0.9)).toBe(null);
127 expect(r.bomb.state).toBe("planted");
128 expect(hold(r, ct, SHORT.defuseTime * 0.2)).toEqual({ type: "defused" });
129 expect(r.bomb.state).toBe("defused");
130 });
131
132 test("a defuse needs to be stood next to, and is the CT's alone", () => {
133 const r = round("planted");
134 r.bomb.state = "planted";
135 r.bomb.pos = at("A");
136 const far = carrier({ team: "ct", pos: [DE_FORGE.sites.A[0] + 200, DE_FORGE.sites.A[1], 2] });
137 expect(hold(r, far, SHORT.defuseTime + 0.2)).toBe(null);
138 expect(r.bomb.defuseProgress).toBe(0);
139
140 const t = carrier({ team: "t", pos: at("A") });
141 expect(hold(r, t, SHORT.defuseTime + 0.2)).toBe(null);
142 expect(r.bomb.state).toBe("planted");
143 });
144
145 test("a kit halves the defuse, and the readout says which one it is", () => {
146 const withKit = round("planted");
147 withKit.bomb.state = "planted";
148 withKit.bomb.pos = at("A");
149 const kitted = carrier({ team: "ct", pos: at("A"), defuseKit: true });
150 expect(defuseTime(withKit, kitted)).toBe(SHORT.defuseTimeWithKit);
151 expect(SHORT.defuseTimeWithKit).toBeLessThan(SHORT.defuseTime);
152
153 // The seconds a bare-handed CT would still be cutting at.
154 expect(hold(withKit, kitted, SHORT.defuseTimeWithKit - 0.1)).toBe(null);
155 expect(useProgress(withKit, kitted)?.label).toBe("Defusing (kit)");
156 expect(hold(withKit, kitted, 0.2)).toEqual({ type: "defused" });
157
158 const bare = round("planted");
159 bare.bomb.state = "planted";
160 bare.bomb.pos = at("A");
161 const empty = carrier({ team: "ct", pos: at("A") });
162 expect(defuseTime(bare, empty)).toBe(SHORT.defuseTime);
163 expect(hold(bare, empty, SHORT.defuseTimeWithKit + 0.1)).toBe(null);
164 expect(bare.bomb.state).toBe("planted");
165 });
166
167 test("a defuse pays the hands that did it", () => {
168 const r = round("planted");
169 r.bomb.state = "planted";
170 r.bomb.pos = at("A");
171 const ct = carrier({ team: "ct", pos: at("A"), defuseKit: true });
172 hold(r, ct, SHORT.defuseTimeWithKit + 0.1);
173 expect(ct.money).toBe(1300);
174 });
175
176 test("the bomb starts in the hands of whoever was given it", () => {
177 const p = carrier({ pos: [12, 34, 2] });
178 const bomb = giveBomb(p);
179 expect(p.hasBomb).toBe(true);
180 expect(bomb.state).toBe("carried");
181 expect(bomb.pos).toEqual([12, 34, 2]);
182 p.pos[0] = 999; // a copy, not the player's own array
183 expect(bomb.pos[0]).toBe(12);
184 });
src/client/bomb.ts added +141
@@ -0,0 +1,141 @@
1 /**
2 * The objective: who is carrying it, where it landed when they died, what
3 * pressing use on a site does, and what pressing use on a planted one undoes.
4 * The kit lives here too, because the only thing a defuse kit is is half a
5 * defuse, and so does the fraction a readout draws a bar from.
6 *
7 * It knows nothing about the world it is called from. `updateBomb` reads the
8 * round's clock and the level's sites, writes the bomb and the one player in
9 * front of it, and hands back what the *world* has to do about it — a line on
10 * the screen, a round that just ended — because paying ten people for a round
11 * needs all ten and this file has one. So it takes a slice of the world named
12 * by the members it touches, the way `combat.ts` names an `Actor`, and cannot
13 * reach back for `world.ts`.
14 */
15
16 import { siteAt, type Level } from "./level.ts";
17 import { clamp, dist, type Vec3 } from "../engine/index.ts";
18 import { capMoney, plantBomb, DEFUSE_REWARD, PLANT_REWARD, type MatchState, type Team } from "./match.ts";
19
20 /** Everything the bomb reads or writes on the person handling it. */
21 export type Carrier = {
22 team: Team;
23 pos: Vec3;
24 onGround: boolean;
25 money: number;
26 defuseKit: boolean;
27 hasBomb: boolean;
28 };
29
30 export type Bomb = {
31 state: "carried" | "dropped" | "planted" | "defused" | "detonated";
32 pos: Vec3;
33 site: "A" | "B" | null;
34 plantProgress: number;
35 defuseProgress: number;
36 defuser: Carrier | null;
37 };
38
39 /** The round as the bomb sees it: itself, the clock, and the ground it is on. */
40 export type Round = { bomb: Bomb; match: MatchState; level: Level };
41
42 /**
43 * What the world must do about a tick of the bomb that it could not do here:
44 * say a line about the plant, and end the round on the defuse. The state the
45 * bomb owns — the fuse, the site, the money for the hands that did it — is
46 * already written by the time one of these comes back.
47 */
48 export type BombEvent = { type: "planted"; site: "A" | "B" } | { type: "defused" };
49
50 /** How close a defuser has to stand. Counter-Strike's is generous; so is this. */
51 export const DEFUSE_RANGE = 64;
52
53 /** How close you walk past a dropped bomb before it is in your hands. */
54 export const PICKUP_RANGE = 48;
55
56 /** A fresh bomb, in somebody's hands, at `pos`. */
57 export const newBomb = (pos: Vec3 = [0, 0, 0]): Bomb => ({
58 state: "carried", pos: [pos[0], pos[1], pos[2]],
59 site: null, plantProgress: 0, defuseProgress: 0, defuser: null,
60 });
61
62 /** Hand the bomb to this player at the top of a round: it starts where they do. */
63 export function giveBomb(carrier: Carrier): Bomb {
64 carrier.hasBomb = true;
65 return newBomb(carrier.pos);
66 }
67
68 /** The carrier died: the bomb is on the floor where the body is, for anyone. */
69 export function dropBomb(bomb: Bomb, carrier: Carrier): void {
70 carrier.hasBomb = false;
71 bomb.state = "dropped";
72 bomb.pos = [carrier.pos[0], carrier.pos[1], carrier.pos[2] + 4];
73 }
74
75 /** How long this player's defuse takes: a kit is the whole of the difference. */
76 export const defuseTime = (round: Round, p: Carrier): number =>
77 p.defuseKit ? round.match.rules.defuseTimeWithKit : round.match.rules.defuseTime;
78
79 /**
80 * One tick of the bomb for one player: pick it up, carry it, plant it on a
81 * site, or cut the wire on one that is down. Everything a plant or a defuse is
82 * — the progress that resets the moment the key comes up or you step off, the
83 * reward, the clock going onto the fuse — happens here; what the round does
84 * about it comes back to the caller.
85 */
86 export function updateBomb(round: Round, p: Carrier, use: boolean, dt: number): BombEvent | null {
87 const { bomb, match } = round;
88 let event: BombEvent | null = null;
89 if (bomb.state === "carried" && p.hasBomb && match.phase === "live") {
90 bomb.pos = [p.pos[0], p.pos[1], p.pos[2]];
91 const site = siteAt(round.level, p.pos);
92 if (use && site !== null && p.onGround) {
93 bomb.plantProgress += dt;
94 if (bomb.plantProgress >= match.rules.plantTime) {
95 bomb.state = "planted";
96 bomb.site = site;
97 bomb.pos = [p.pos[0], p.pos[1], p.pos[2] + 2];
98 p.hasBomb = false;
99 p.money = capMoney(p.money + PLANT_REWARD, match.rules);
100 plantBomb(match);
101 event = { type: "planted", site };
102 }
103 } else {
104 bomb.plantProgress = 0;
105 }
106 }
107 if (bomb.state === "dropped" && p.team === "t" && dist(p.pos, bomb.pos) < PICKUP_RANGE) {
108 bomb.state = "carried";
109 p.hasBomb = true;
110 }
111 if (bomb.state === "planted" && p.team === "ct") {
112 const near = dist(p.pos, bomb.pos) < DEFUSE_RANGE;
113 if (use && near && p.onGround) {
114 bomb.defuser = p;
115 bomb.defuseProgress += dt;
116 if (bomb.defuseProgress >= defuseTime(round, p)) {
117 bomb.state = "defused";
118 p.money = capMoney(p.money + DEFUSE_REWARD, match.rules);
119 event = { type: "defused" };
120 }
121 } else if (bomb.defuser === p) {
122 bomb.defuser = null;
123 bomb.defuseProgress = 0;
124 }
125 }
126 return event;
127 }
128
129 /** How far along the thing this player is doing is, 0..1, for the HUD bar. */
130 export function useProgress(round: Round, p: Carrier): { label: string; fraction: number } | null {
131 const bomb = round.bomb;
132 if (p.team === "t" && p.hasBomb && bomb.plantProgress > 0)
133 return { label: "Planting", fraction: clamp(bomb.plantProgress / round.match.rules.plantTime, 0, 1) };
134 if (p.team === "ct" && bomb.defuser === p && bomb.defuseProgress > 0) {
135 return {
136 label: p.defuseKit ? "Defusing (kit)" : "Defusing",
137 fraction: clamp(bomb.defuseProgress / defuseTime(round, p), 0, 1),
138 };
139 }
140 return null;
141 }
src/client/bots.ts +2 −1
@@ -20,8 +20,9 @@ import { colOf, findPath, rowOf, siteAt, walkable, type Level } from "./level.ts
2020 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
2121 import { CARD, PACKED } from "./weights.ts";
2222 import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts";
23 import { buy } from "./economy.ts";
2324 import type { Input, Player, World } from "./world.ts";
24 import { aimAt, bodyCentre, buy, canSee, NO_INPUT } from "./world.ts";
25 import { aimAt, bodyCentre, canSee, NO_INPUT } from "./world.ts";
2526
2627 export { CARD };
2728
src/client/economy.test.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * The money's own tests. Most of them need no world at all: a buy reads one
3 * field off the round and the rest off the player, so a `Market` here is an
4 * object with a phase in it and a `Buyer` is the kit and the wallet. The last
5 * test is the wiring — the same calls through a real `World` — because a module
6 * that only works against its own fixtures has proved nothing.
7 */
8
9 import { expect, test } from "bun:test";
10 import { buy, canBuy, CARRY, emptyAmmo, priceOf, type Buyer, type Market } from "./economy.ts";
11 import { DE_FORGE } from "./map.ts";
12 import { SHORT } from "./match.ts";
13 import { createWorld, NO_INPUT, update } from "./world.ts";
14 import { ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS } from "./weapons.ts";
15
16 const freeze: Market = { match: { phase: "freeze" } };
17 const live: Market = { match: { phase: "live" } };
18
19 const buyer = (over: Partial<Buyer> = {}): Buyer => ({
20 team: "ct", alive: true, money: 5000,
21 armor: 0, helmet: false, defuseKit: false,
22 weapon: "usp", primary: null, secondary: "usp",
23 ammo: { knife: { mag: 0, reserve: 0 }, usp: emptyAmmo("usp") },
24 pocket: { he: 0, flash: 0 },
25 ...over,
26 });
27
28 test("a gun is paid for, put in your hands, and loaded", () => {
29 const me = buyer();
30 expect(buy(freeze, me, "m4a4")).toBe(true);
31 expect(me.money).toBe(5000 - WEAPONS.m4a4.price);
32 expect(me.primary).toBe("m4a4");
33 expect(me.weapon).toBe("m4a4");
34 expect(me.ammo.m4a4).toEqual({ mag: WEAPONS.m4a4.magazine, reserve: WEAPONS.m4a4.reserve });
35 // A pistol is the other slot, and it does not take the rifle out of your hands
36 // by being cheaper — it takes it out by being the thing you just bought.
37 expect(buy(freeze, me, "deagle")).toBe(true);
38 expect(me.secondary).toBe("deagle");
39 expect(me.primary).toBe("m4a4");
40 });
41
42 test("a gun is the side's or it is nobody's, and the knife is not for sale", () => {
43 const ct = buyer();
44 expect(buy(freeze, ct, "ak47")).toBe(false);
45 expect(buy(freeze, ct, "knife")).toBe(false);
46 expect(ct.money).toBe(5000);
47 expect(buy(freeze, buyer({ team: "t" }), "ak47")).toBe(true);
48 });
49
50 test("armour bought twice is not paid for twice", () => {
51 const me = buyer();
52 expect(buy(freeze, me, "armor")).toBe(true);
53 expect(me.armor).toBe(100);
54 expect(me.money).toBe(5000 - ARMOR_PRICE);
55 // The vest is on: the second press is refused and costs nothing.
56 expect(priceOf("armor", me)).toBe(0);
57 expect(buy(freeze, me, "armor")).toBe(false);
58 expect(me.money).toBe(5000 - ARMOR_PRICE);
59 });
60
61 test("a helmet on a bare chest is the vest as well, and on a vest it is the helmet alone", () => {
62 const bare = buyer();
63 expect(priceOf("helmet", bare)).toBe(ARMOR_PRICE + HELMET_PRICE);
64 expect(buy(freeze, bare, "helmet")).toBe(true);
65 expect(bare.armor).toBe(100);
66 expect(bare.helmet).toBe(true);
67 expect(bare.money).toBe(5000 - ARMOR_PRICE - HELMET_PRICE);
68 expect(buy(freeze, bare, "helmet")).toBe(false); // and not a third time
69
70 const vested = buyer({ armor: 100 });
71 expect(priceOf("helmet", vested)).toBe(HELMET_PRICE);
72 expect(buy(freeze, vested, "helmet")).toBe(true);
73 expect(vested.money).toBe(5000 - HELMET_PRICE);
74 });
75
76 test("the kit is the CT's alone, and one each", () => {
77 const ct = buyer();
78 expect(priceOf("kit", ct)).toBe(DEFUSE_KIT_PRICE);
79 expect(buy(freeze, ct, "kit")).toBe(true);
80 expect(ct.defuseKit).toBe(true);
81 expect(buy(freeze, ct, "kit")).toBe(false);
82 expect(ct.money).toBe(5000 - DEFUSE_KIT_PRICE);
83 expect(buy(freeze, buyer({ team: "t" }), "kit")).toBe(false);
84 });
85
86 test("a grenade goes in the pocket, up to what a pocket holds", () => {
87 const me = buyer();
88 expect(buy(freeze, me, "he")).toBe(true);
89 expect(me.pocket.he).toBe(CARRY.he);
90 expect(buy(freeze, me, "he")).toBe(false);
91 // It is a pocket and not a slot: the gun in your hands is untouched.
92 expect(me.weapon).toBe("usp");
93 expect(me.money).toBe(5000 - WEAPONS.he.price);
94 });
95
96 test("a wallet that cannot reach it is refused, to the dollar", () => {
97 const me = buyer({ money: WEAPONS.awp.price - 1 });
98 expect(buy(freeze, me, "awp")).toBe(false);
99 me.money = WEAPONS.awp.price;
100 expect(buy(freeze, me, "awp")).toBe(true);
101 expect(me.money).toBe(0);
102 });
103
104 test("a buy is refused once the round is live, and to a body", () => {
105 const me = buyer();
106 expect(canBuy(live, me)).toBe(false);
107 expect(buy(live, me, "m4a4")).toBe(false);
108 expect(me.money).toBe(5000);
109 expect(me.primary).toBe(null);
110
111 const dead = buyer({ alive: false });
112 expect(canBuy(freeze, dead)).toBe(false);
113 expect(buy(freeze, dead, "m4a4")).toBe(false);
114 });
115
116 test("the same calls are what the world makes: a buy in the freeze, refused once it is live", () => {
117 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 3);
118 world.me.money = 5000;
119 expect(canBuy(world, world.me)).toBe(true);
120 expect(buy(world, world.me, "m4a4")).toBe(true);
121 expect(world.me.money).toBe(5000 - WEAPONS.m4a4.price);
122 expect(world.me.weapon).toBe("m4a4");
123 expect(buy(world, world.me, "ak47")).toBe(false); // the other side's rifle
124 expect(buy(world, world.me, "helmet")).toBe(true);
125 expect(world.me.helmet).toBe(true);
126 for (let t = 0; t < SHORT.freezeTime + 0.5; t += 1 / 64) update(world, 1 / 64, NO_INPUT);
127 expect(world.match.phase).toBe("live");
128 expect(canBuy(world, world.me)).toBe(false);
129 expect(buy(world, world.me, "deagle")).toBe(false);
130 });
src/client/economy.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * The money: what an item costs the player in front of you, whether the round
3 * will let them have it, and what handing it over does to their wallet and
4 * their kit. `match.ts` decides what a round *pays* — the win, the loss bonus,
5 * the cap — and this decides what they can spend it on.
6 *
7 * It knows nothing about the world it is called from. A buy reads one thing off
8 * the round (which phase it is in) and everything else off the player, so both
9 * are named here by the members a buy actually touches, the way `combat.ts`
10 * names an `Actor`: a `World` and a `Player` satisfy them, and this file cannot
11 * reach back for either.
12 */
13
14 import type { Phase, Team } from "./match.ts";
15 import {
16 ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, isThrown,
17 type ThrownId, type WeaponId,
18 } from "./weapons.ts";
19
20 /** One gun's rounds: what is in the magazine, and what is left to fill it with. */
21 export type Ammo = { mag: number; reserve: number };
22
23 /** A gun as it comes out of the shop, and as a fresh round hands it back. */
24 export const emptyAmmo = (id: WeaponId): Ammo =>
25 ({ mag: WEAPONS[id].magazine, reserve: WEAPONS[id].reserve });
26
27 /**
28 * How many of each thrown weapon a player may carry into a round. One HE and
29 * one flashbang — two grenades, bought separately in the freeze, each refused
30 * once the pocket holds its limit, and both gone with the body that held them.
31 */
32 export const CARRY: Record<ThrownId, number> = { he: 1, flash: 1 };
33
34 export type BuyItem = WeaponId | "armor" | "helmet" | "kit";
35
36 /** Everything a buy reads or writes on the person doing it, and nothing else. */
37 export type Buyer = {
38 team: Team;
39 alive: boolean;
40 money: number;
41 armor: number;
42 helmet: boolean;
43 defuseKit: boolean;
44 weapon: WeaponId;
45 primary: WeaponId | null;
46 secondary: WeaponId;
47 ammo: Record<string, Ammo>;
48 pocket: Record<ThrownId, number>;
49 };
50
51 /**
52 * The round as a buy sees it: which phase it is in. That is the whole of what
53 * the world has to say about whether the shop is open.
54 */
55 export type Market = { match: { phase: Phase } };
56
57 export function priceOf(item: BuyItem, player: Buyer): number {
58 if (item === "armor") return player.armor > 0 ? 0 : ARMOR_PRICE;
59 if (item === "helmet") return player.armor > 0 && !player.helmet ? HELMET_PRICE : ARMOR_PRICE + HELMET_PRICE;
60 if (item === "kit") return DEFUSE_KIT_PRICE;
61 return WEAPONS[item]?.price ?? Infinity;
62 }
63
64 export const canBuy = (market: Market, player: Buyer): boolean =>
65 market.match.phase === "freeze" && player.alive;
66
67 /** Buy one thing. Refuses anything the round, the side or the wallet says no to. */
68 export function buy(market: Market, player: Buyer, item: BuyItem): boolean {
69 if (!canBuy(market, player)) return false;
70 const price = priceOf(item, player);
71 if (!Number.isFinite(price) || price > player.money) return false;
72 if (item === "armor") {
73 if (player.armor >= 100) return false;
74 player.armor = 100;
75 } else if (item === "helmet") {
76 if (player.helmet) return false;
77 player.armor = 100;
78 player.helmet = true;
79 } else if (item === "kit") {
80 if (player.team !== "ct" || player.defuseKit) return false;
81 player.defuseKit = true;
82 } else {
83 const weapon = WEAPONS[item];
84 if (weapon === undefined || weapon.kind === "knife") return false;
85 if (weapon.team !== "both" && weapon.team !== player.team) return false;
86 // A grenade goes in a pocket, not in your hands: it does not become the
87 // weapon you are holding and it does not displace the gun you bought, and
88 // one kind in there does not crowd out the other.
89 if (isThrown(item)) {
90 if (player.pocket[item] >= CARRY[item]) return false;
91 player.pocket[item] += 1;
92 player.money -= price;
93 return true;
94 }
95 if (weapon.kind === "pistol") player.secondary = item;
96 else player.primary = item;
97 player.ammo[item] = emptyAmmo(item);
98 player.weapon = item;
99 }
100 player.money -= price;
101 return true;
102 }
src/client/grenade.test.ts +2 −1
@@ -26,8 +26,9 @@ import { blastDamage, blindSeconds, FLASH, HE, WEAPONS } from "./weapons.ts";
2626 import type { Actor } from "./combat.ts";
2727 import type { Box, Vec3 } from "../engine/index.ts";
2828 import { digest, TICK, withoutEntropy } from "./replay.ts";
29 import { buy, CARRY } from "./economy.ts";
2930 import {
30 buy, CARRY, createWorld, eyePos, NO_INPUT, throwOne, update,
31 createWorld, eyePos, NO_INPUT, throwOne, update,
3132 type Effect, type Input, type Player, type World,
3233 } from "./world.ts";
3334 import { effectAt, eyeHeight } from "../engine/index.ts";
src/client/hud.ts +2 −1
@@ -28,7 +28,8 @@ import { drawMinimap } from "./minimap.ts";
2828 import { CS_MOVE } from "./movement.ts";
2929 import { BLUE, CROSSHAIR, CT_COLOR, GREY, RED, T_COLOR, teamColor, WHITE } from "./palette.ts";
3030 import { inaccuracy, WEAPONS } from "./weapons.ts";
31 import { currentWeapon, useProgress, type Player, type World } from "./world.ts";
31 import { useProgress } from "./bomb.ts";
32 import { currentWeapon, type Player, type World } from "./world.ts";
3233
3334 /**
3435 * Everything this module asks of the thing it draws on. Nothing else: it is the
src/client/main.ts +2 −3
@@ -39,9 +39,8 @@ import { meshFor } from "./models.ts";
3939 import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
4040 import { createSpectator, cycle, follow } from "./spectate.ts";
4141 import { buyable, WEAPONS } from "./weapons.ts";
42 import {
43 buy, canBuy, createWorld, eyePos, priceOf, update, type BuyItem, type Player, type World,
44 } from "./world.ts";
42 import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts";
43 import { createWorld, eyePos, update, type Player, type World } from "./world.ts";
4544
4645 const TICK = 1 / 64;
4746
src/client/replay.ts +2 −1
@@ -21,7 +21,8 @@
2121 */
2222
2323 import { compare as complain, fold as foldTicks, quantise } from "../engine/index.ts";
24 import { buy, NO_INPUT, update, type BuyItem, type Input, type Player, type World } from "./world.ts";
24 import { buy, type BuyItem } from "./economy.ts";
25 import { NO_INPUT, update, type Input, type Player, type World } from "./world.ts";
2526 import type { Grenade } from "./grenade.ts";
2627 import type { MatchState, Phase } from "./match.ts";
2728
src/client/world.test.ts +3 −15
@@ -3,7 +3,8 @@ import { siteAt } from "./level.ts";
33 import { DE_FORGE } from "./map.ts";
44 import { SHORT } from "./match.ts";
55 import { WEAPONS } from "./weapons.ts";
6 import { buy, createWorld, currentWeapon, NO_INPUT, shoot, startRound, update, type Input, type World } from "./world.ts";
6 import { buy } from "./economy.ts";
7 import { createWorld, currentWeapon, NO_INPUT, shoot, startRound, update, type Input, type World } from "./world.ts";
78
89 const TICK = 1 / 64;
910 const run = (world: World, seconds: number, input: Input = NO_INPUT): void => {
@@ -30,25 +31,12 @@ test("nobody moves or shoots during the freeze", () => {
3031 expect(world.players.every((p) => p.health === 100)).toBe(true);
3132 });
3233
33 test("buying takes the money and hands over the gun, and only in the freeze", () => {
34 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 3);
35 world.me.money = 5000;
36 expect(buy(world, world.me, "m4a4")).toBe(true);
37 expect(world.me.money).toBe(5000 - WEAPONS.m4a4.price);
38 expect(currentWeapon(world.me).id).toBe("m4a4");
39 expect(buy(world, world.me, "ak47")).toBe(false); // the other side's rifle
40 expect(buy(world, world.me, "helmet")).toBe(true);
41 expect(world.me.helmet).toBe(true);
42 run(world, SHORT.freezeTime + 0.5);
43 expect(world.match.phase).toBe("live");
44 expect(buy(world, world.me, "deagle")).toBe(false); // the buy is over
45 });
46
4734 test("a shot costs a bullet and a reload puts it back", () => {
4835 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 4);
4936 world.me.money = 5000;
5037 buy(world, world.me, "m4a4");
5138 run(world, SHORT.freezeTime + 0.5);
39 expect(currentWeapon(world.me).id).toBe("m4a4");
5240 const ammo = world.me.ammo.m4a4;
5341 expect(shoot(world, world.me)).toBe(true);
5442 expect(ammo.mag).toBe(WEAPONS.m4a4.magazine - 1);
src/client/world.ts +36 −145
@@ -1,22 +1,27 @@
11 /**
22 * The world: ten players, one bomb, one round at a time. Everything that can
3 * happen in a round happens here — buying, moving, shooting, throwing,
4 * planting, defusing, dying — and the match rules in match.ts decide what it
5 * means. A thrown grenade is the one thing in a round that is not a person:
6 * `src/client/grenade.ts` says how one flies and what its blast reaches, and
7 * what a blast *means* — the damage, the hit marker, the line in the feed — is
8 * here with every other kind of harm.
3 * happen in a round happens here — moving, shooting, throwing, dying — and the
4 * match rules in match.ts decide what it means. Two of the round's rules have
5 * their own files and are called from this one: `src/client/economy.ts` is the
6 * money and the kit it buys, and `src/client/bomb.ts` is the objective —
7 * carrying it, dropping it, planting it, cutting the wire. A thrown grenade is
8 * the one thing in a round that is not a person: `src/client/grenade.ts` says
9 * how one flies and what its blast reaches, and what a blast *means* — the
10 * damage, the hit marker, the line in the feed — is here with every other kind
11 * of harm.
912 *
1013 * The simulation runs at a fixed 64 ticks a second and knows nothing about
1114 * WebGL, so it can be stepped in a test without a browser.
1215 */
1316
17 import { dropBomb, giveBomb, newBomb, updateBomb, type Bomb } from "./bomb.ts";
1418 import { applyDamage, applyHit, traceBullet, type Actor } from "./combat.ts";
19 import { emptyAmmo, type Ammo } from "./economy.ts";
1520 import {
1621 blastCentre, blastHits, flashHits, stepGrenade, throwGrenade, THROW_LIFT,
1722 type Grenade, type GrenadeSound,
1823 } from "./grenade.ts";
19 import { siteAt, type Level } from "./level.ts";
24 import type { Level } from "./level.ts";
2025 import {
2126 angleVectors, angleDelta, clamp, createEffects, dist, expireEffects, eyeHeight, hullHeight, move,
2227 normalize, pushEffect, rng, sub, visible,
@@ -25,15 +30,13 @@ import {
2530 import * as rules from "./match.ts";
2631 import { CS_HULL, CS_MOVE } from "./movement.ts";
2732 import {
28 ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, isThrown, recoilOffset,
33 WEAPONS, inaccuracy, recoilOffset,
2934 type ThrownId, type WeaponId, type Weapon,
3035 } from "./weapons.ts";
3136 import { botBuy, botThink, type BotBrain } from "./bots.ts";
3237
3338 export type Team = rules.Team;
3439
35 export type Ammo = { mag: number; reserve: number };
36
3740 export type Player = Actor & Body & {
3841 name: string;
3942 bot: boolean;
@@ -75,15 +78,6 @@ export type Player = Actor & Body & {
7578 spawnAt: number;
7679 };
7780
78 export type Bomb = {
79 state: "carried" | "dropped" | "planted" | "defused" | "detonated";
80 pos: Vec3;
81 site: "A" | "B" | null;
82 plantProgress: number;
83 defuseProgress: number;
84 defuser: Player | null;
85 };
86
8781 export type Effect =
8882 | { kind: "tracer"; from: Vec3; to: Vec3; at: number }
8983 | { kind: "impact"; at: number; pos: Vec3; normal: Vec3 }
@@ -166,7 +160,11 @@ export type World = {
166160 * room and what a second map will need.
167161 */
168162 level: Level;
169 bombPlanted: boolean; // in this round, for the economy
163 /**
164 * Whether the bomb went down in this round, which outlives the bomb being
165 * defused: `roundReward` in match.ts pays a losing T side extra for it.
166 */
167 bombPlanted: boolean;
170168 pendingBuy: boolean;
171169 };
172170
@@ -175,8 +173,6 @@ const BOT_NAMES = [
175173 "Cinder", "Draco", "Ember", "Flint", "Glaive", "Harrow", "Ibex", "Juno",
176174 ];
177175
178 const emptyAmmo = (id: WeaponId): Ammo => ({ mag: WEAPONS[id].magazine, reserve: WEAPONS[id].reserve });
179
180176 function makePlayer(id: number, team: Team, name: string, bot: boolean): Player {
181177 const secondary: WeaponId = team === "t" ? "glock" : "usp";
182178 return {
@@ -234,7 +230,7 @@ export function createWorld(
234230 }
235231 const world: World = {
236232 match, players, me, time: 0,
237 bomb: { state: "carried", pos: [0, 0, 0], site: null, plantProgress: 0, defuseProgress: 0, defuser: null },
233 bomb: newBomb(),
238234 effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], feed: [], messages: [],
239235 random, level, bombPlanted: false, pendingBuy: false,
240236 };
@@ -295,10 +291,7 @@ export function startRound(world: World): void {
295291 }
296292 const ts = teammates(world, "t");
297293 const carrier = ts[Math.floor(world.random() * ts.length)];
298 if (carrier !== undefined) {
299 carrier.hasBomb = true;
300 world.bomb = { state: "carried", pos: [...carrier.pos] as Vec3, site: null, plantProgress: 0, defuseProgress: 0, defuser: null };
301 }
294 if (carrier !== undefined) world.bomb = giveBomb(carrier);
302295 world.bombPlanted = false;
303296 world.grenades = [];
304297 world.feed = [];
@@ -336,64 +329,6 @@ function checkRoundEnd(world: World): void {
336329 if (ts === 0 && world.bomb.state !== "planted") return finishRound(world, "ct", "elimination");
337330 }
338331
339 // --- buying ---------------------------------------------------------------
340
341 export type BuyItem = WeaponId | "armor" | "helmet" | "kit";
342
343 export function priceOf(item: BuyItem, player: Player): number {
344 if (item === "armor") return player.armor > 0 ? 0 : ARMOR_PRICE;
345 if (item === "helmet") return player.armor > 0 && !player.helmet ? HELMET_PRICE : ARMOR_PRICE + HELMET_PRICE;
346 if (item === "kit") return DEFUSE_KIT_PRICE;
347 return WEAPONS[item]?.price ?? Infinity;
348 }
349
350 /**
351 * How many of each thrown weapon a player may carry into a round. One HE and
352 * one flashbang — two grenades, bought separately in the freeze, each refused
353 * once the pocket holds its limit, and both gone with the body that held them.
354 */
355 export const CARRY: Record<ThrownId, number> = { he: 1, flash: 1 };
356
357 export const canBuy = (world: World, player: Player): boolean =>
358 world.match.phase === "freeze" && player.alive;
359
360 /** Buy one thing. Refuses anything the round, the side or the wallet says no to. */
361 export function buy(world: World, player: Player, item: BuyItem): boolean {
362 if (!canBuy(world, player)) return false;
363 const price = priceOf(item, player);
364 if (!Number.isFinite(price) || price > player.money) return false;
365 if (item === "armor") {
366 if (player.armor >= 100) return false;
367 player.armor = 100;
368 } else if (item === "helmet") {
369 if (player.helmet) return false;
370 player.armor = 100;
371 player.helmet = true;
372 } else if (item === "kit") {
373 if (player.team !== "ct" || player.defuseKit) return false;
374 player.defuseKit = true;
375 } else {
376 const weapon = WEAPONS[item];
377 if (weapon === undefined || weapon.kind === "knife") return false;
378 if (weapon.team !== "both" && weapon.team !== player.team) return false;
379 // A grenade goes in a pocket, not in your hands: it does not become the
380 // weapon you are holding and it does not displace the gun you bought, and
381 // one kind in there does not crowd out the other.
382 if (isThrown(item)) {
383 if (player.pocket[item] >= CARRY[item]) return false;
384 player.pocket[item] += 1;
385 player.money -= price;
386 return true;
387 }
388 if (weapon.kind === "pistol") player.secondary = item;
389 else player.primary = item;
390 player.ammo[item] = emptyAmmo(item);
391 player.weapon = item;
392 }
393 player.money -= price;
394 return true;
395 }
396
397332 // --- shooting -------------------------------------------------------------
398333
399334 const RANGE = 8192;
@@ -482,11 +417,7 @@ function onKill(world: World, killer: Player, victim: Player, weapon: WeaponId,
482417 killer.money = rules.capMoney(killer.money + WEAPONS[weapon].killReward, world.match.rules);
483418 world.feed.push({ at: world.time, killer: killer.name, victim: victim.name, weapon, headshot, killerTeam: killer.team });
484419 if (world.feed.length > 5) world.feed.shift();
485 if (victim.hasBomb) {
486 victim.hasBomb = false;
487 world.bomb.state = "dropped";
488 world.bomb.pos = [victim.pos[0], victim.pos[1], victim.pos[2] + 4];
489 }
420 if (victim.hasBomb) dropBomb(world.bomb, victim);
490421 checkRoundEnd(world);
491422 }
492423
@@ -577,61 +508,21 @@ function updateGrenades(world: World, dt: number): void {
577508
578509 // --- the bomb -------------------------------------------------------------
579510
580 const PLANT_RANGE = 64;
581
582 function updateBomb(world: World, p: Player, input: Input, dt: number): void {
583 const bomb = world.bomb;
584 if (bomb.state === "carried" && p.hasBomb && world.match.phase === "live") {
585 bomb.pos = [p.pos[0], p.pos[1], p.pos[2]];
586 const site = siteAt(world.level, p.pos);
587 if (input.use && site !== null && p.onGround) {
588 bomb.plantProgress += dt;
589 if (bomb.plantProgress >= world.match.rules.plantTime) {
590 bomb.state = "planted";
591 bomb.site = site;
592 bomb.pos = [p.pos[0], p.pos[1], p.pos[2] + 2];
593 p.hasBomb = false;
594 world.bombPlanted = true;
595 p.money = rules.capMoney(p.money + rules.PLANT_REWARD, world.match.rules);
596 rules.plantBomb(world.match);
597 say(world, `The bomb has been planted at ${site}.`);
598 }
599 } else {
600 bomb.plantProgress = 0;
601 }
511 /**
512 * A tick of the bomb for one player, and what the world owes it afterwards:
513 * `bomb.ts` writes the fuse, the site and the money it took to get there, and
514 * hands back the two things that need all ten players — the line on everyone's
515 * screen, and the round a cut wire just ended.
516 */
517 function bombTick(world: World, p: Player, use: boolean, dt: number): void {
518 const event = updateBomb(world, p, use, dt);
519 if (event === null) return;
520 if (event.type === "planted") {
521 world.bombPlanted = true;
522 say(world, `The bomb has been planted at ${event.site}.`);
523 } else {
524 finishRound(world, "ct", "defused");
602525 }
603 if (bomb.state === "dropped" && p.team === "t" && dist(p.pos, bomb.pos) < 48) {
604 bomb.state = "carried";
605 p.hasBomb = true;
606 }
607 if (bomb.state === "planted" && p.team === "ct") {
608 const near = dist(p.pos, bomb.pos) < PLANT_RANGE;
609 if (input.use && near && p.onGround) {
610 bomb.defuser = p;
611 bomb.defuseProgress += dt;
612 const need = p.defuseKit ? world.match.rules.defuseTimeWithKit : world.match.rules.defuseTime;
613 if (bomb.defuseProgress >= need) {
614 bomb.state = "defused";
615 p.money = rules.capMoney(p.money + rules.DEFUSE_REWARD, world.match.rules);
616 finishRound(world, "ct", "defused");
617 }
618 } else if (bomb.defuser === p) {
619 bomb.defuser = null;
620 bomb.defuseProgress = 0;
621 }
622 }
623 }
624
625 /** How far along the thing this player is doing is, 0..1, for the HUD bar. */
626 export function useProgress(world: World, p: Player): { label: string; fraction: number } | null {
627 const bomb = world.bomb;
628 if (p.team === "t" && p.hasBomb && bomb.plantProgress > 0)
629 return { label: "Planting", fraction: clamp(bomb.plantProgress / world.match.rules.plantTime, 0, 1) };
630 if (p.team === "ct" && bomb.defuser === p && bomb.defuseProgress > 0) {
631 const need = p.defuseKit ? world.match.rules.defuseTimeWithKit : world.match.rules.defuseTime;
632 return { label: p.defuseKit ? "Defusing (kit)" : "Defusing", fraction: clamp(bomb.defuseProgress / need, 0, 1) };
633 }
634 return null;
635526 }
636527
637528 // --- the tick -------------------------------------------------------------
@@ -676,7 +567,7 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
676567 p.threwThisPress = input.throwing;
677568 if (world.time - p.lastShotAt > 0.35) p.shotIndex = 0;
678569 p.punch = [p.punch[0] * Math.pow(0.02, dt), p.punch[1] * Math.pow(0.02, dt)];
679 updateBomb(world, p, input, dt);
570 bombTick(world, p, input.use, dt);
680571 }
681572
682573 /** One fixed simulation tick. `input` is the person's; bots think for themselves. */
src/games.ts +11
@@ -248,6 +248,17 @@ export type Game = {
248248 * room for a stray import of something large. Neither other bundle moved a
249249 * byte: nothing outside `src/client/` and `src/pages/game.ts` changed here, and
250250 * neither of them imports either. Measured 2026-09-20, with #176 merged in.
251 * #113 split the round's rules out of `src/client/world.ts` — the money into
252 * `src/client/economy.ts` and the objective into `src/client/bomb.ts` — and the
253 * app grew 80 raw / 132 gzipped bytes for it: 90,429 → 90,509 raw and 40,801 →
254 * 40,933 gzipped. Nothing was added and nothing removed; the bytes are two more
255 * module wrappers and the short names the minifier hands out over a module graph
256 * that changed shape, the same thing that happened for #106, #107, #110 and
257 * #123. It fits the 92,000 / 42,000 #164 set with 1,491 raw and 1,067 gzipped to
258 * spare, so no ceiling moved, and the golden digests in
259 * `src/client/replay.test.ts` are unchanged to the byte, which is the real
260 * measurement here. Neither other bundle moved: nothing outside `src/client/`
261 * changed. Measured 2026-09-20 after #113.
251262 */
252263 export const GAMES: readonly Game[] = [
253264 {
static/app.4b18a0517b.js deleted −58

Diff not shown — a line 71,533 characters wide (limit 2,000). −58

View file
static/app.537d8dffb3.js added +58

Diff not shown — a line 71,613 characters wide (limit 2,000). +58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs