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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/113-the-round-s-rules-stop-being-one-553-lin → main 1 commit fork
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 | ||
| 380 | 380 | reaches only what `traceWorld` says it can see), |
| 381 | 381 | `match` |
| 382 | 382 | (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), | |
| 383 | 392 | `world` (the simulation, and the two seconds of what just happened it keeps in |
| 384 | 393 | the engine's ring: `EFFECT_CAPACITY` events at most, each stamped, pruned at |
| 385 | 394 | 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 | ||
| 20 | 20 | import { observe, OBS_SIZE, type Memory } from "./observe.ts"; |
| 21 | 21 | import { CARD, PACKED } from "./weights.ts"; |
| 22 | 22 | import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts"; |
| 23 | import { buy } from "./economy.ts"; | |
| 23 | 24 | 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"; | |
| 25 | 26 | |
| 26 | 27 | export { CARD }; |
| 27 | 28 |
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"; | ||
| 26 | 26 | import type { Actor } from "./combat.ts"; |
| 27 | 27 | import type { Box, Vec3 } from "../engine/index.ts"; |
| 28 | 28 | import { digest, TICK, withoutEntropy } from "./replay.ts"; |
| 29 | import { buy, CARRY } from "./economy.ts"; | |
| 29 | 30 | import { |
| 30 | buy, CARRY, createWorld, eyePos, NO_INPUT, throwOne, update, | |
| 31 | createWorld, eyePos, NO_INPUT, throwOne, update, | |
| 31 | 32 | type Effect, type Input, type Player, type World, |
| 32 | 33 | } from "./world.ts"; |
| 33 | 34 | import { effectAt, eyeHeight } from "../engine/index.ts"; |
src/client/hud.ts +2 −1
| @@ -28,7 +28,8 @@ import { drawMinimap } from "./minimap.ts"; | ||
| 28 | 28 | import { CS_MOVE } from "./movement.ts"; |
| 29 | 29 | import { BLUE, CROSSHAIR, CT_COLOR, GREY, RED, T_COLOR, teamColor, WHITE } from "./palette.ts"; |
| 30 | 30 | 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"; | |
| 32 | 33 | |
| 33 | 34 | /** |
| 34 | 35 | * 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"; | ||
| 39 | 39 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; |
| 40 | 40 | import { createSpectator, cycle, follow } from "./spectate.ts"; |
| 41 | 41 | 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"; | |
| 45 | 44 | |
| 46 | 45 | const TICK = 1 / 64; |
| 47 | 46 |
src/client/replay.ts +2 −1
| @@ -21,7 +21,8 @@ | ||
| 21 | 21 | */ |
| 22 | 22 | |
| 23 | 23 | 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"; | |
| 25 | 26 | import type { Grenade } from "./grenade.ts"; |
| 26 | 27 | import type { MatchState, Phase } from "./match.ts"; |
| 27 | 28 |
src/client/world.test.ts +3 −15
| @@ -3,7 +3,8 @@ import { siteAt } from "./level.ts"; | ||
| 3 | 3 | import { DE_FORGE } from "./map.ts"; |
| 4 | 4 | import { SHORT } from "./match.ts"; |
| 5 | 5 | 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"; | |
| 7 | 8 | |
| 8 | 9 | const TICK = 1 / 64; |
| 9 | 10 | const run = (world: World, seconds: number, input: Input = NO_INPUT): void => { |
| @@ -30,25 +31,12 @@ test("nobody moves or shoots during the freeze", () => { | ||
| 30 | 31 | expect(world.players.every((p) => p.health === 100)).toBe(true); |
| 31 | 32 | }); |
| 32 | 33 | |
| 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 | ||
| 47 | 34 | test("a shot costs a bullet and a reload puts it back", () => { |
| 48 | 35 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 4); |
| 49 | 36 | world.me.money = 5000; |
| 50 | 37 | buy(world, world.me, "m4a4"); |
| 51 | 38 | run(world, SHORT.freezeTime + 0.5); |
| 39 | expect(currentWeapon(world.me).id).toBe("m4a4"); | |
| 52 | 40 | const ammo = world.me.ammo.m4a4; |
| 53 | 41 | expect(shoot(world, world.me)).toBe(true); |
| 54 | 42 | expect(ammo.mag).toBe(WEAPONS.m4a4.magazine - 1); |
src/client/world.ts +36 −145
| @@ -1,22 +1,27 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 9 | 12 | * |
| 10 | 13 | * The simulation runs at a fixed 64 ticks a second and knows nothing about |
| 11 | 14 | * WebGL, so it can be stepped in a test without a browser. |
| 12 | 15 | */ |
| 13 | 16 | |
| 17 | import { dropBomb, giveBomb, newBomb, updateBomb, type Bomb } from "./bomb.ts"; | |
| 14 | 18 | import { applyDamage, applyHit, traceBullet, type Actor } from "./combat.ts"; |
| 19 | import { emptyAmmo, type Ammo } from "./economy.ts"; | |
| 15 | 20 | import { |
| 16 | 21 | blastCentre, blastHits, flashHits, stepGrenade, throwGrenade, THROW_LIFT, |
| 17 | 22 | type Grenade, type GrenadeSound, |
| 18 | 23 | } from "./grenade.ts"; |
| 19 | import { siteAt, type Level } from "./level.ts"; | |
| 24 | import type { Level } from "./level.ts"; | |
| 20 | 25 | import { |
| 21 | 26 | angleVectors, angleDelta, clamp, createEffects, dist, expireEffects, eyeHeight, hullHeight, move, |
| 22 | 27 | normalize, pushEffect, rng, sub, visible, |
| @@ -25,15 +30,13 @@ import { | ||
| 25 | 30 | import * as rules from "./match.ts"; |
| 26 | 31 | import { CS_HULL, CS_MOVE } from "./movement.ts"; |
| 27 | 32 | import { |
| 28 | ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, isThrown, recoilOffset, | |
| 33 | WEAPONS, inaccuracy, recoilOffset, | |
| 29 | 34 | type ThrownId, type WeaponId, type Weapon, |
| 30 | 35 | } from "./weapons.ts"; |
| 31 | 36 | import { botBuy, botThink, type BotBrain } from "./bots.ts"; |
| 32 | 37 | |
| 33 | 38 | export type Team = rules.Team; |
| 34 | 39 | |
| 35 | export type Ammo = { mag: number; reserve: number }; | |
| 36 | ||
| 37 | 40 | export type Player = Actor & Body & { |
| 38 | 41 | name: string; |
| 39 | 42 | bot: boolean; |
| @@ -75,15 +78,6 @@ export type Player = Actor & Body & { | ||
| 75 | 78 | spawnAt: number; |
| 76 | 79 | }; |
| 77 | 80 | |
| 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 | ||
| 87 | 81 | export type Effect = |
| 88 | 82 | | { kind: "tracer"; from: Vec3; to: Vec3; at: number } |
| 89 | 83 | | { kind: "impact"; at: number; pos: Vec3; normal: Vec3 } |
| @@ -166,7 +160,11 @@ export type World = { | ||
| 166 | 160 | * room and what a second map will need. |
| 167 | 161 | */ |
| 168 | 162 | 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; | |
| 170 | 168 | pendingBuy: boolean; |
| 171 | 169 | }; |
| 172 | 170 | |
| @@ -175,8 +173,6 @@ const BOT_NAMES = [ | ||
| 175 | 173 | "Cinder", "Draco", "Ember", "Flint", "Glaive", "Harrow", "Ibex", "Juno", |
| 176 | 174 | ]; |
| 177 | 175 | |
| 178 | const emptyAmmo = (id: WeaponId): Ammo => ({ mag: WEAPONS[id].magazine, reserve: WEAPONS[id].reserve }); | |
| 179 | ||
| 180 | 176 | function makePlayer(id: number, team: Team, name: string, bot: boolean): Player { |
| 181 | 177 | const secondary: WeaponId = team === "t" ? "glock" : "usp"; |
| 182 | 178 | return { |
| @@ -234,7 +230,7 @@ export function createWorld( | ||
| 234 | 230 | } |
| 235 | 231 | const world: World = { |
| 236 | 232 | match, players, me, time: 0, |
| 237 | bomb: { state: "carried", pos: [0, 0, 0], site: null, plantProgress: 0, defuseProgress: 0, defuser: null }, | |
| 233 | bomb: newBomb(), | |
| 238 | 234 | effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], feed: [], messages: [], |
| 239 | 235 | random, level, bombPlanted: false, pendingBuy: false, |
| 240 | 236 | }; |
| @@ -295,10 +291,7 @@ export function startRound(world: World): void { | ||
| 295 | 291 | } |
| 296 | 292 | const ts = teammates(world, "t"); |
| 297 | 293 | 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); | |
| 302 | 295 | world.bombPlanted = false; |
| 303 | 296 | world.grenades = []; |
| 304 | 297 | world.feed = []; |
| @@ -336,64 +329,6 @@ function checkRoundEnd(world: World): void { | ||
| 336 | 329 | if (ts === 0 && world.bomb.state !== "planted") return finishRound(world, "ct", "elimination"); |
| 337 | 330 | } |
| 338 | 331 | |
| 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 | ||
| 397 | 332 | // --- shooting ------------------------------------------------------------- |
| 398 | 333 | |
| 399 | 334 | const RANGE = 8192; |
| @@ -482,11 +417,7 @@ function onKill(world: World, killer: Player, victim: Player, weapon: WeaponId, | ||
| 482 | 417 | killer.money = rules.capMoney(killer.money + WEAPONS[weapon].killReward, world.match.rules); |
| 483 | 418 | world.feed.push({ at: world.time, killer: killer.name, victim: victim.name, weapon, headshot, killerTeam: killer.team }); |
| 484 | 419 | 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); | |
| 490 | 421 | checkRoundEnd(world); |
| 491 | 422 | } |
| 492 | 423 | |
| @@ -577,61 +508,21 @@ function updateGrenades(world: World, dt: number): void { | ||
| 577 | 508 | |
| 578 | 509 | // --- the bomb ------------------------------------------------------------- |
| 579 | 510 | |
| 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"); | |
| 602 | 525 | } |
| 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; | |
| 635 | 526 | } |
| 636 | 527 | |
| 637 | 528 | // --- the tick ------------------------------------------------------------- |
| @@ -676,7 +567,7 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 676 | 567 | p.threwThisPress = input.throwing; |
| 677 | 568 | if (world.time - p.lastShotAt > 0.35) p.shotIndex = 0; |
| 678 | 569 | 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); | |
| 680 | 571 | } |
| 681 | 572 | |
| 682 | 573 | /** One fixed simulation tick. `input` is the person's; bots think for themselves. */ |
src/games.ts +11
| @@ -248,6 +248,17 @@ export type Game = { | ||
| 248 | 248 | * room for a stray import of something large. Neither other bundle moved a |
| 249 | 249 | * byte: nothing outside `src/client/` and `src/pages/game.ts` changed here, and |
| 250 | 250 | * 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. | |
| 251 | 262 | */ |
| 252 | 263 | export const GAMES: readonly Game[] = [ |
| 253 | 264 | { |
static/app.4b18a0517b.js deleted −58
Diff not shown — a line 71,533 characters wide (limit 2,000). −58
View filestatic/app.537d8dffb3.js added +58
Diff not shown — a line 71,613 characters wide (limit 2,000). +58
View file
new above
offline