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

forked from trevato/cs

Switching a weapon costs the time it costs in Counter-Strike: a deploy time, so the knife is a decision #269

Merged opened this

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

Merged

torque/cs-engine:torque/143-switching-a-weapon-costs-the-time-it-cos → main 1 commit fork

  • Merged. torque/143-switching-a-weapon-costs-the-time-it-cos was merged into main as de67dcb.

Review

Sign in to review this change.
11 files changed +552 −29
README.md +7 −1
@@ -327,7 +327,13 @@ it scored, exactly as it did before.
327327 <kbd>G</kbd> throws the HE grenade in your pocket and <kbd>T</kbd> the
328328 flashbang, if you bought them,
329329 <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze
330 time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns, <kbd>Tab</kbd> shows
330 time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns — and **a switch costs CS's deploy
331 time**: the carry speed changes on the tick you press the key, so tapping
332 <kbd>3</kbd> runs at the knife's 250 u/s straight away, but the weapon you come
333 back to cannot fire, scope or reload until it is up (a pistol 0.7 s, a rifle or
334 the knife 1.0 s, the AWP 1.25 s, counted in whole ticks). Knife-running a
335 sightline is a decision, and the sums are in `src/client/weapons.ts` —
336 <kbd>Tab</kbd> shows
331337 the scoreboard, <kbd>F</kbd> puts the stage on the whole screen and
332338 <kbd>Esc</kbd> or <kbd>P</kbd> pauses. Right mouse pulls up the scope on the one
333339 gun that has one, and **the mouse slows down with the zoom**: the view narrows
src/client/bots.test.ts +30 −2
@@ -10,10 +10,10 @@ import { grip } from "./spray.ts";
1010 import { colOf, findPath, rowOf, walkable, worldOf } from "./level.ts";
1111 import { dist, type Vec3 } from "../engine/index.ts";
1212 import { OBS_SIZE } from "./observe.ts";
13 import { FLASH, type WeaponId } from "./weapons.ts";
13 import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts";
1414 import { DE_FORGE } from "./map.ts";
1515 import { COMPETITIVE } from "./match.ts";
16 import { aimAt, applyInput, bodyCentre, createWorld, type Player } from "./world.ts";
16 import { aimAt, applyInput, bodyCentre, createWorld, deployTicks, type Player } from "./world.ts";
1717
1818 // The bots in here think with the policy this repository ships, handed in the
1919 // way a browser hands in the one it downloaded.
@@ -319,6 +319,34 @@ test("a bot with nothing left to fire closes the distance and knifes", () => {
319319 expect(d.me.health).toBeLessThan(100);
320320 });
321321
322 test("a bot pays the deploy time too: no bot bypasses the timer", () => {
323 const d = standoff();
324 d.bot.primary = "ak47";
325 d.bot.weapon = "ak47";
326 d.bot.ammo.ak47 = { mag: 0, reserve: 0 };
327 // A bot switches the way a person does — `input.switchTo` into `applyInput`
328 // — so it goes through exactly the same clock. Its pistol is a Glock (it is
329 // a T here) and CS's 0.7 s is 45 whole ticks at 64 Hz.
330 let swappedAt = -1;
331 let firstShot = -1;
332 for (let t = 0; t < 192; t++) {
333 const input = botThink(d.world, d.bot, 1 / 64);
334 const holding: WeaponId = d.bot.weapon;
335 const before = d.bot.lastShotAt;
336 applyInput(d.world, d.bot, input, 1 / 64);
337 if (swappedAt < 0 && d.bot.weapon !== holding) swappedAt = t;
338 if (firstShot < 0 && d.bot.lastShotAt !== before) firstShot = t;
339 d.world.time += 1 / 64;
340 }
341 expect<WeaponId>(d.bot.weapon).toBe(d.bot.secondary);
342 expect(swappedAt).toBeGreaterThanOrEqual(0);
343 expect(firstShot).toBeGreaterThan(swappedAt);
344 expect(deployTicks(WEAPONS[d.bot.secondary], 1 / 64)).toBe(45);
345 // The empty rifle's trigger sent nothing before the swap either, so the first
346 // shot of the round is the pistol's, and it is not one tick earlier than 45.
347 expect(firstShot - swappedAt).toBeGreaterThanOrEqual(45);
348 });
349
322350 test("a bot with bullets left plays exactly as it did: no swap, no knife", () => {
323351 const d = standoff();
324352 d.bot.primary = "ak47";
src/client/hud.test.ts +11
@@ -241,6 +241,17 @@ test("ammo reads mag / reserve, the knife reads a dash, and reloading says so",
241241 // The instant it finishes the word goes.
242242 expect(says(draw(scene((w) => { w.time = 10; w.me.reloadUntil = 10; })), "reloading")).toBe(false);
243243
244 // Mid-switch the same line says so instead: the weapon is in the hands but
245 // not up yet, and the word goes the instant it is.
246 const switching = draw(scene((w) => { w.time = 10; w.me.deployUntil = 10.5; }));
247 expect(line(switching, "switching").fill).toBe("#f0c060");
248 expect(says(switching, "reloading")).toBe(false);
249 expect(says(draw(scene((w) => { w.time = 10; w.me.deployUntil = 10; })), "switching")).toBe(false);
250 // And a switch that cut a reload short reads as the switch it is.
251 const both = draw(scene((w) => { w.time = 10; w.me.deployUntil = 10.5; w.me.reloadUntil = 12; }));
252 expect(says(both, "switching")).toBe(true);
253 expect(says(both, "reloading")).toBe(false);
254
244255 // Carrying the bomb is written beside the gun's name.
245256 const carrier = draw(scene((w) => { w.me.team = "t"; w.me.hasBomb = true; }));
246257 expect(says(carrier, `${WEAPONS.usp.name} · bomb`)).toBe(true);
src/client/hud.ts +7 −2
@@ -247,8 +247,13 @@ export function drawHud(
247247 width - pad, height - pad - Math.round(30 * scale),
248248 { align: "right", font: small, fill: GREY },
249249 );
250 if (world.time < view.reloadUntil)
251 ui.text("reloading", width - pad, height - pad - Math.round(50 * scale), { align: "right", font: small, fill: "#f0c060" });
250 // What the gun is busy doing, in the one line there is room for. The two
251 // cannot both be true — a switch cancels the reload it interrupted and a
252 // reload cannot start until the new weapon is up — so this is an order of
253 // reading and not a choice between two things happening at once.
254 const busy = world.time < view.deployUntil ? "switching" : world.time < view.reloadUntil ? "reloading" : null;
255 if (busy !== null)
256 ui.text(busy, width - pad, height - pad - Math.round(50 * scale), { align: "right", font: small, fill: "#f0c060" });
252257
253258 // --- who is left
254259 const aliveT = world.players.filter((p) => p.team === "t" && p.alive).length;
src/client/replay.test.ts +111 −6
@@ -1,12 +1,15 @@
11 /**
22 * The golden digests: what this simulation does, pinned.
33 *
4 * Four runs, all seeded, all stepped through the same script in
4 * Six runs, all seeded, all stepped through a script in
55 * `replay.ts`: the whole game on de_forge — ten players, the match rules, the
66 * bomb, the bots and their policy — the duel in the training arena, so an
77 * extraction that moves the environment PPO learned in is caught as loudly as
8 * one that moves the game, and a match on each of two maps nobody wrote, so a
9 * change that only shows on a layout carved from a seed is caught at all.
8 * one that moves the game, a match on each of two maps nobody wrote, so a
9 * change that only shows on a layout carved from a seed is caught at all, and
10 * two rounds that do something the others never do — one throws a smoke, one
11 * switches weapons — because a rule nothing in a pinned run exercises is a rule
12 * no digest can hold.
1013 *
1114 * If one of these fails, read the message: it names the first second of play
1215 * at which the run and the pin part company, and prints the constants to
@@ -29,13 +32,13 @@ import { DE_FORGE } from "./map.ts";
2932 import { SHORT } from "./match.ts";
3033 import { CHECKPOINT_CHARS } from "../engine/index.ts";
3134 import {
32 compare, digest, fold, reader, snapshot, SMOKE_SCRIPT, TICK, TICKS_PER_SECOND,
35 compare, digest, fold, reader, snapshot, SMOKE_SCRIPT, SWITCH_SCRIPT, TICK, TICKS_PER_SECOND,
3336 withoutEntropy, type Pin, type Run,
3437 } from "./replay.ts";
3538 import { colOf, rowOf, walkable, type Level } from "./level.ts";
36 import { createWorld, update, type Player } from "./world.ts";
39 import { createWorld, deployTicks, update, type Player } from "./world.ts";
3740 import { cloudRadius } from "./grenade.ts";
38 import { SMOKE } from "./weapons.ts";
41 import { SMOKE, WEAPONS } from "./weapons.ts";
3942 import { createArena, reset, stepInput } from "../rl/arena.ts";
4043 import { useShipped } from "./shipped.ts";
4144
@@ -104,6 +107,16 @@ const TICKS = SECONDS * TICKS_PER_SECOND;
104107 * either key, so no flash goes off and no `blind` is ever non-zero here.
105108 */
106109 const ROUND = "2baf12fbafbff25007a9147511d1307ee9f4d5cf7d0b2473d7dae4fea876490e";
110 /*
111 * Not re-pinned for #143, which gave every weapon a deploy time, and the reason
112 * is worth writing down rather than leaving to look like an oversight. Two
113 * switches happen in these forty seconds — Tundra draws its knife at tick 1522
114 * and Pike at tick 1582 — and each of them now stands 64 ticks with a knife it
115 * cannot swing. Neither is inside knife range in those ticks, so no swing was
116 * refused, no `world.random()` roll was skipped and nothing in the field list
117 * below moved: the play is the same play to the bit. SWITCH_ROUND further down
118 * is the run written to feel the rule, and it is new rather than moved.
119 */
107120 const ROUND_SECONDS = [
108121 "c858433691b7", "d46389733924", "1d2038791546", "318d01ffb6a5",
109122 "0fab6fec7329", "46cbc192435f", "db863794da65", "0e5d2d2d2685",
@@ -250,6 +263,98 @@ test("the smoked round has a cloud standing in it, and it is gone by the end", (
250263 expect(stood.pocket).toBe(0);
251264 });
252265
266 // --- a round with a weapon switch in it -----------------------------------
267
268 /**
269 * Twenty seconds of the same round with the person switching weapons: seed 7 on
270 * de_forge again, so the only difference between this and the round pin is
271 * which keys were pressed. `SWITCH_SCRIPT` in `replay.ts` says what they were.
272 *
273 * This pin is new rather than moved, for the reason #165's smoke pin was new:
274 * the four runs above press 1, 2 and 3 never, so nothing in them exercises the
275 * deploy time #143 added, and the two switches that *do* happen in the ten-bot
276 * round are two bots drawing a knife with nobody inside knife range for the 64
277 * ticks it costs them — which is why ROUND, DUEL and the two generated matches
278 * all still hash to exactly what they hashed to before. The rule is real and
279 * those runs could not feel it; this one is written so that it can.
280 *
281 * The whole freeze is the round pin's freeze, to the character: this script
282 * makes the same 650 buy on the same tick, and seconds 1 to 7 hash to exactly
283 * what ROUND's do. The two part at second 8 — the second the round goes live,
284 * where ROUND is already holding W and this one is still standing still — and
285 * from tick 576 on what they differ by is the rule.
286 */
287 const SWITCH_SECONDS_LONG = 20;
288 const SWITCH_TICKS = SWITCH_SECONDS_LONG * TICKS_PER_SECOND;
289 const SWITCH_ROUND = "899614cb381d4d54c0856641963f1aaa6c9d8abac2c3a8d52b2f8aaef05c016e";
290 const SWITCH_ROUND_SECONDS: readonly string[] = [
291 "c858433691b7", "d46389733924", "1d2038791546", "318d01ffb6a5",
292 "0fab6fec7329", "46cbc192435f", "db863794da65", "2ee4690ed7ab",
293 "9324fbbb0613", "29367a5dec24", "4b1fe24b0cf3", "008c0bc37d56",
294 "f3a2be3d29aa", "e81f689bb628", "20967b3ec5ef", "164e83be3f18",
295 "70db83d7c9d8", "5abbcc14f978", "84afb2c11f16", "8202a14b68d1",
296 ];
297
298 const SWITCH_PIN: Pin = {
299 label: "round with a weapon switch in it", constant: "SWITCH_ROUND",
300 digest: SWITCH_ROUND, seconds: SWITCH_ROUND_SECONDS,
301 };
302
303 /** The switching round, exactly as its pin was taken. */
304 const switchRun = (): Run =>
305 withoutEntropy(() =>
306 digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), SWITCH_TICKS, SWITCH_SCRIPT));
307
308 test("a seeded round with a weapon switch in it plays exactly as it played", () => {
309 const run = switchRun();
310 must(run, SWITCH_PIN);
311 expect(run.digest).toBe(SWITCH_ROUND);
312 expect(run.ticks).toBe(SWITCH_TICKS);
313 expect(run.seconds).toEqual([...SWITCH_ROUND_SECONDS]);
314 // The same seed on the same map: the freeze before either script buys is the
315 // same second to the character, and the play parts company after it.
316 expect(run.seconds.slice(0, 7)).toEqual(ROUND_SECONDS.slice(0, 7));
317 expect(run.seconds[10]).not.toBe(ROUND_SECONDS[10]);
318 });
319
320 /**
321 * The run really does contain the rule, to the tick, rather than a script that
322 * missed: the knife is asked to swing inside its deploy and does not, the USP
323 * answers on the 45th tick after the tap and not the 44th, and the reload the
324 * switch cancelled does not restart until the pistol is up.
325 */
326 test("the switching round has the deploy time in it, at the ticks the script says", () => {
327 const seen = withoutEntropy(() => {
328 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7);
329 const next = reader(SWITCH_SCRIPT);
330 const switches: number[] = [];
331 const shots: number[] = [];
332 let held = world.me.weapon;
333 for (let tick = 0; tick < SWITCH_TICKS; tick++) {
334 const before = world.me.lastShotAt;
335 update(world, TICK, next(tick, world, world.me));
336 if (world.me.weapon !== held) {
337 switches.push(tick);
338 held = world.me.weapon;
339 }
340 if (world.me.lastShotAt !== before) shots.push(tick);
341 }
342 return { switches, shots };
343 });
344 // Tap 3 at 576, tap 2 at 768, tap 3 again at 928 mid-reload, tap 2 at 1024.
345 expect(seen.switches).toEqual([576, 768, 928, 1024]);
346 // Nothing went out inside the knife's 64 ticks — the swing asked for at 600
347 // is inside it — and nothing inside the USP's 45 either. The first shot after
348 // each tap is at least the deploy away from it.
349 for (const shot of seen.shots) {
350 const after = seen.switches.filter((at) => at <= shot).pop();
351 if (after === undefined) continue;
352 const weapon = after === 576 || after === 928 ? WEAPONS.knife : WEAPONS.usp;
353 expect({ shot, early: shot - after < deployTicks(weapon, TICK) }).toEqual({ shot, early: false });
354 }
355 expect(seen.shots.length).toBeGreaterThan(0); // and shots did go out
356 });
357
253358 test("a seeded duel in the training arena plays exactly as it played", () => {
254359 const run = duelRun();
255360 must(run, DUEL_PIN);
src/client/replay.ts +47
@@ -216,6 +216,53 @@ export const SMOKE_SCRIPT: Beat[] = [
216216 { atTick: 1920, input: {} },
217217 ];
218218
219 /**
220 * Twenty seconds with a weapon switch in them. The two scripts above never
221 * press 1, 2 or 3, so before this every pinned run in this repository was a run
222 * in which nobody ever changed weapons — and the deploy time, which is the one
223 * thing a switch now costs, was pinned by nothing at all.
224 *
225 * This one buys armour in the freeze like the round pin does, walks out, and
226 * then makes the trade the whole rule is about. At tick 576 it taps 3: the
227 * knife is in the hands on that tick and the body is already running at the
228 * knife's 250 u/s, and the swing it asks for at tick 600 does not land, because
229 * the knife is not up until tick 640. At tick 768 it taps 2 and holds the
230 * trigger down the whole way: the USP is up 45 ticks later, at tick 813, and
231 * the first round of the burst leaves on exactly that tick. It reloads, taps 3
232 * mid-reload to cancel it, taps 2 back, and holds R through the deploy.
233 *
234 * What it pins is not the arithmetic, which is hand-checked in
235 * `world.test.ts`. It is that the timer is part of the *round*: a body that
236 * cannot shoot for 45 ticks is somewhere else afterwards, and every bot near it
237 * is somewhere else too.
238 */
239 export const SWITCH_SCRIPT: Beat[] = [
240 { atTick: 0, input: {} },
241 { atTick: 96, input: {}, buy: "armor" }, // 650 of the 800, the round pin's buy
242 { atTick: 512, input: { forward: 1 } }, // the round goes live
243 // Tap 3. The carry speed is the knife's from this tick; the trigger is not.
244 { atTick: 576, input: { forward: 1, switchTo: "knife" } },
245 { atTick: 584, input: { forward: 1 } }, // the key comes up: one press, one switch
246 { atTick: 600, input: { forward: 1, fire: true } }, // inside the deploy: nothing
247 { atTick: 616, input: { forward: 1 } },
248 { atTick: 672, input: { forward: 1, side: 1, fire: true } }, // past it: the knife swings
249 { atTick: 704, input: { forward: 1, side: 1 } },
250 // Tap 2, with the trigger already held: the pistol answers on tick 813.
251 { atTick: 768, input: { forward: 1, switchTo: "usp", fire: true } },
252 { atTick: 776, input: { forward: 1, fire: true } },
253 { atTick: 832, input: { forward: 1 } },
254 { atTick: 896, input: { forward: 1, reload: true } },
255 { atTick: 928, input: { forward: 1, switchTo: "knife", reload: true } }, // cancels it
256 { atTick: 936, input: { forward: 1, reload: true } },
257 { atTick: 1024, input: { forward: 1, switchTo: "usp", reload: true } }, // and restarts it, late
258 { atTick: 1032, input: { forward: 1, reload: true } },
259 { atTick: 1152, input: { forward: 1, fire: true } },
260 { atTick: 1184, input: { side: -1 } },
261 { atTick: 1280, input: { use: true } },
262 { atTick: 1408, input: { forward: 1, duck: true } },
263 { atTick: 1600, input: { forward: 1 } },
264 ];
265
219266 /**
220267 * A cursor over the script: hand it a tick and it returns the input held at
221268 * that tick, taking any buy on the way past. The input is a fresh object each
src/client/weapons.test.ts +48 −2
@@ -1,7 +1,7 @@
11 import { expect, test } from "bun:test";
22 import {
3 blindSeconds, BULLET_RANGE, buyable, computeDamage, FLASH, HE, inaccuracy, isThrown, recoilOffset,
4 WEAPONS,
3 blindSeconds, BULLET_RANGE, buyable, computeDamage, FLASH, GRENADE_DEPLOY, HE, inaccuracy,
4 isThrown, recoilOffset, WEAPONS, type WeaponId,
55 } from "./weapons.ts";
66
77 test("an AK chest shot through armour is survivable, a headshot is not", () => {
@@ -87,6 +87,52 @@ test("the buy menu only offers what your side carries", () => {
8787 expect(buyable("t").map((w) => w.id)).toContain("ak47");
8888 });
8989
90 // --- deploy times ---------------------------------------------------------
91
92 test("every weapon says how long it takes to come up, and the table is CS's", () => {
93 // The numbers, in seconds, weapon by weapon. A failure here is a row that
94 // changed, and the table above it says where each came from.
95 const deploy: Record<WeaponId, number> = {
96 knife: 1,
97 glock: 0.7, usp: 0.7, deagle: 0.7,
98 mp9: 0.7,
99 ak47: 1, m4a4: 1,
100 awp: 1.25,
101 he: GRENADE_DEPLOY, flash: GRENADE_DEPLOY, smoke: GRENADE_DEPLOY,
102 };
103 for (const [id, seconds] of Object.entries(deploy))
104 expect(WEAPONS[id as WeaponId].deployTime).toBe(seconds);
105 // Every row has one, it is a real number of seconds, and nothing is instant.
106 for (const w of Object.values(WEAPONS)) {
107 expect(Number.isFinite(w.deployTime)).toBe(true);
108 expect(w.deployTime).toBeGreaterThan(0);
109 }
110 // The order is the one the trade-off is made of: a pistol is the quick draw,
111 // a rifle and the knife cost the same second as each other, and the AWP is
112 // the slowest thing in the game to bring back up.
113 expect(WEAPONS.usp.deployTime).toBeLessThan(WEAPONS.ak47.deployTime);
114 expect(WEAPONS.knife.deployTime).toBe(WEAPONS.ak47.deployTime);
115 expect(WEAPONS.awp.deployTime).toBeGreaterThan(WEAPONS.m4a4.deployTime);
116 expect(Math.max(...Object.values(WEAPONS).map((w) => w.deployTime))).toBe(WEAPONS.awp.deployTime);
117 });
118
119 test("the knife run pays for itself past 1,250 units, and not before", () => {
120 // The trade, from this table alone. The knife carries at 250 u/s and the AWP
121 // at 200, so over `d` units the run saves
122 //
123 // d / 200 − d / 250 = d / 1000 seconds
124 //
125 // and coming back to the AWP costs its 1.25 s deploy. The two are equal at
126 // d = 1,250 units — a long sightline on this map, and the point past which
127 // running with the knife out is worth doing.
128 const saved = (d: number): number => d / WEAPONS.awp.carrySpeed - d / WEAPONS.knife.carrySpeed;
129 expect(saved(1000)).toBeCloseTo(1, 12);
130 expect(saved(1250)).toBeCloseTo(WEAPONS.awp.deployTime, 12);
131 expect(saved(1000)).toBeLessThan(WEAPONS.awp.deployTime); // a short trip: not worth it
132 expect(saved(2000)).toBeGreaterThan(WEAPONS.awp.deployTime); // a long one: worth 0.75 s
133 expect(saved(2000) - WEAPONS.awp.deployTime).toBeCloseTo(0.75, 12);
134 });
135
90136 // --- the flashbang --------------------------------------------------------
91137
92138 test("the flashbang is in the table with its own numbers, and no bullet ones", () => {
src/client/weapons.ts +61 −11
@@ -87,6 +87,15 @@ export type Weapon = {
8787 magazine: number;
8888 reserve: number;
8989 reloadTime: number;
90 /**
91 * Seconds between taking this weapon out and being able to use it: CS's
92 * deploy time, the length of the draw animation, during which the trigger,
93 * the scope and the reload key all do nothing. It is what makes a switch a
94 * decision instead of a free lunch — the carry speed below changes the
95 * instant you press the key, and this is the bill for it. Where each number
96 * came from is beside its row in WEAPON_LIST.
97 */
98 deployTime: number;
9099 killReward: number;
91100 carrySpeed: number; // u/s while holding it
92101 spread: number; // degrees of inaccuracy standing still
@@ -102,8 +111,21 @@ export type Weapon = {
102111 smoke?: Smoke;
103112 };
104113
114 /**
115 * How long a grenade takes to come out, in seconds: CS's half-second pull, the
116 * shortest deploy in the game. It is the same for all three because it is the
117 * same animation for all three, and it is written once for that reason.
118 *
119 * Nothing in this simulation reads it today: a grenade is thrown out of a
120 * pocket (`throwing` in `src/client/world.ts`) rather than held in the hands, so
121 * no switch ever deploys one. It is here because every row in this table
122 * carries a deploy time and a row that lied about it would be worse than a row
123 * that states the number and says who reads it.
124 */
125 export const GRENADE_DEPLOY = 0.5;
126
105127 /** The HE grenade, by the name everything that throws one calls it. */
106 export const HE: Weapon & { blast: Blast } = { id: "he", name: "HE Grenade", kind: "grenade", team: "both", price: 300, damage: 0, armorPenetration: 0, rangeModifier: 1, rpm: 0, magazine: 0, reserve: 0, reloadTime: 0, killReward: 300, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false, blast: { damage: 98, radius: 350, fuse: 1.5, armorPenetration: 0.5 } };
128 export const HE: Weapon & { blast: Blast } = { id: "he", name: "HE Grenade", kind: "grenade", team: "both", price: 300, damage: 0, armorPenetration: 0, rangeModifier: 1, rpm: 0, magazine: 0, reserve: 0, reloadTime: 0, deployTime: GRENADE_DEPLOY, killReward: 300, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false, blast: { damage: 98, radius: 350, fuse: 1.5, armorPenetration: 0.5 } };
107129
108130 /**
109131 * The flashbang. CS's $200, on a 1.6-second fuse, and it matters a great deal
@@ -112,7 +134,7 @@ export const HE: Weapon & { blast: Blast } = { id: "he", name: "HE Grenade", kin
112134 * short: 1.6 seconds of white is a fight you lost, and three would be a tab you
113135 * closed. The two angles are the curve's, and `blindSeconds` below is the curve.
114136 */
115 export const FLASH: Weapon & { flash: Flash } = { id: "flash", name: "Flashbang", kind: "grenade", team: "both", price: 200, damage: 0, armorPenetration: 0, rangeModifier: 1, rpm: 0, magazine: 0, reserve: 0, reloadTime: 0, killReward: 300, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false, flash: { fuse: 1.6, radius: 1200, maxBlind: 1.6, fullAngle: 30, awayAngle: 135 } };
137 export const FLASH: Weapon & { flash: Flash } = { id: "flash", name: "Flashbang", kind: "grenade", team: "both", price: 200, damage: 0, armorPenetration: 0, rangeModifier: 1, rpm: 0, magazine: 0, reserve: 0, reloadTime: 0, deployTime: GRENADE_DEPLOY, killReward: 300, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false, flash: { fuse: 1.6, radius: 1200, maxBlind: 1.6, fullAngle: 30, awayAngle: 135 } };
116138
117139 /**
118140 * The smoke grenade. CS's $300, and the only thing in this table that changes
@@ -131,24 +153,52 @@ export const FLASH: Weapon & { flash: Flash } = { id: "flash", name: "Flashbang"
131153 * opens where the canister *stopped*, so `spent` in `src/client/grenade.ts`
132154 * holds it until the thing has come to rest.
133155 */
134 export const SMOKE: Weapon & { smoke: Smoke } = { id: "smoke", name: "Smoke Grenade", kind: "grenade", team: "both", price: 300, damage: 0, armorPenetration: 0, rangeModifier: 1, rpm: 0, magazine: 0, reserve: 0, reloadTime: 0, killReward: 300, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false, smoke: { fuse: 1.5, radius: 180, grow: 1.5, life: 15, fade: 2.5 } };
156 export const SMOKE: Weapon & { smoke: Smoke } = { id: "smoke", name: "Smoke Grenade", kind: "grenade", team: "both", price: 300, damage: 0, armorPenetration: 0, rangeModifier: 1, rpm: 0, magazine: 0, reserve: 0, reloadTime: 0, deployTime: GRENADE_DEPLOY, killReward: 300, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false, smoke: { fuse: 1.5, radius: 180, grow: 1.5, life: 15, fade: 2.5 } };
135157
136158 /** The fuse on a thrown weapon, in seconds: the one number all three kinds have. */
137159 export const fuseOf = (weapon: Weapon): number =>
138160 weapon.blast?.fuse ?? weapon.flash?.fuse ?? weapon.smoke?.fuse ?? 0;
139161
162 /**
163 * The table. One row a weapon, and the one number added late enough to be worth
164 * its own paragraph is `deployTime`.
165 *
166 * **Where the deploy times came from.** They are CS:GO's draw times — how long
167 * each weapon's deploy animation runs before the trigger answers — as the
168 * community's weapon tables have measured and published them, rounded to a
169 * hundredth of a second because this simulation counts in ticks of 1/64 s
170 * anyway and a thousandth would be a lie about the precision. Four numbers
171 * cover the ten rows:
172 *
173 * - **0.7 s** — every pistol (Glock-18, USP-S, Desert Eagle) and the MP9.
174 * A sidearm is what you swap *to* in a hurry, and CS makes it the quick draw.
175 * - **1.0 s** — the AK-47, the M4A4 and the knife. A rifle is a second out of
176 * the fight, and the knife costs the same second as the rifle it replaces,
177 * which is what stops "tap 3, tap 1" from being free.
178 * - **1.25 s** — the AWP. The slowest thing anybody swaps to, and the reason a
179 * sniper who ran to a spot with the knife arrives a quarter of a second after
180 * they would like to.
181 * - **0.5 s** — the three thrown weapons, via `GRENADE_DEPLOY` above, which
182 * nothing reads.
183 *
184 * The rule they are read under is `src/client/world.ts`: the deploy is counted
185 * in whole ticks from the moment of the switch, and until it passes the player
186 * cannot fire, scope or start a reload. The carry speed on the same row changes
187 * on the tick of the switch itself — that asymmetry is the whole of the
188 * trade-off and it is stated at the switch in `applyInput`.
189 */
140190 const WEAPON_LIST: Weapon[] = [
141 { id: "knife", name: "Knife", kind: "knife", team: "both", price: 0, damage: 55, armorPenetration: 0.85, rangeModifier: 1, rpm: 240, magazine: 0, reserve: 0, reloadTime: 0, killReward: 1500, carrySpeed: 250, spread: 0, moveSpread: 0, recoil: 0, automatic: true },
142 { id: "glock", name: "Glock-18", kind: "pistol", team: "t", price: 200, damage: 30, armorPenetration: 0.47, rangeModifier: 0.75, rpm: 400, magazine: 20, reserve: 120, reloadTime: 2.1, killReward: 300, carrySpeed: 240, spread: 0.22, moveSpread: 1.6, recoil: 0.7, automatic: false },
143 { id: "usp", name: "USP-S", kind: "pistol", team: "ct", price: 200, damage: 35, armorPenetration: 0.505, rangeModifier: 0.79, rpm: 352, magazine: 12, reserve: 24, reloadTime: 2.2, killReward: 300, carrySpeed: 240, spread: 0.18, moveSpread: 1.5, recoil: 0.75, automatic: false },
191 { id: "knife", name: "Knife", kind: "knife", team: "both", price: 0, damage: 55, armorPenetration: 0.85, rangeModifier: 1, rpm: 240, magazine: 0, reserve: 0, reloadTime: 0, deployTime: 1, killReward: 1500, carrySpeed: 250, spread: 0, moveSpread: 0, recoil: 0, automatic: true },
192 { id: "glock", name: "Glock-18", kind: "pistol", team: "t", price: 200, damage: 30, armorPenetration: 0.47, rangeModifier: 0.75, rpm: 400, magazine: 20, reserve: 120, reloadTime: 2.1, deployTime: 0.7, killReward: 300, carrySpeed: 240, spread: 0.22, moveSpread: 1.6, recoil: 0.7, automatic: false },
193 { id: "usp", name: "USP-S", kind: "pistol", team: "ct", price: 200, damage: 35, armorPenetration: 0.505, rangeModifier: 0.79, rpm: 352, magazine: 12, reserve: 24, reloadTime: 2.2, deployTime: 0.7, killReward: 300, carrySpeed: 240, spread: 0.18, moveSpread: 1.5, recoil: 0.75, automatic: false },
144194 // The Deagle carries: `rangeModifier` is 0.955 rather than CS's 0.81 so that
145195 // a headshot through a helmet kills at every distance a bullet in this game
146196 // can travel. See `BULLET_RANGE` below for the arithmetic.
147 { id: "deagle", name: "Desert Eagle", kind: "pistol", team: "both", price: 700, damage: 63, armorPenetration: 0.925, rangeModifier: 0.955, rpm: 267, magazine: 7, reserve: 35, reloadTime: 2.2, killReward: 300, carrySpeed: 230, spread: 0.25, moveSpread: 2.6, recoil: 2.2, automatic: false },
148 { id: "mp9", name: "MP9", kind: "smg", team: "both", price: 1250, damage: 26, armorPenetration: 0.6, rangeModifier: 0.74, rpm: 857, magazine: 30, reserve: 120, reloadTime: 2.1, killReward: 600, carrySpeed: 240, spread: 0.3, moveSpread: 1.1, recoil: 0.6, automatic: true },
149 { id: "ak47", name: "AK-47", kind: "rifle", team: "t", price: 2700, damage: 36, armorPenetration: 0.775, rangeModifier: 0.98, rpm: 600, magazine: 30, reserve: 90, reloadTime: 2.5, killReward: 300, carrySpeed: 215, spread: 0.16, moveSpread: 3.4, recoil: 1.5, automatic: true },
150 { id: "m4a4", name: "M4A4", kind: "rifle", team: "ct", price: 3100, damage: 33, armorPenetration: 0.7, rangeModifier: 0.97, rpm: 666, magazine: 30, reserve: 90, reloadTime: 3.1, killReward: 300, carrySpeed: 225, spread: 0.15, moveSpread: 3.2, recoil: 1.25, automatic: true },
151 { id: "awp", name: "AWP", kind: "sniper", team: "both", price: 4750, damage: 115, armorPenetration: 0.975, rangeModifier: 0.99, rpm: 41, magazine: 10, reserve: 30, reloadTime: 3.7, killReward: 100, carrySpeed: 200, spread: 0.05, moveSpread: 12, recoil: 3.5, zoom: 40, automatic: false },
197 { id: "deagle", name: "Desert Eagle", kind: "pistol", team: "both", price: 700, damage: 63, armorPenetration: 0.925, rangeModifier: 0.955, rpm: 267, magazine: 7, reserve: 35, reloadTime: 2.2, deployTime: 0.7, killReward: 300, carrySpeed: 230, spread: 0.25, moveSpread: 2.6, recoil: 2.2, automatic: false },
198 { id: "mp9", name: "MP9", kind: "smg", team: "both", price: 1250, damage: 26, armorPenetration: 0.6, rangeModifier: 0.74, rpm: 857, magazine: 30, reserve: 120, reloadTime: 2.1, deployTime: 0.7, killReward: 600, carrySpeed: 240, spread: 0.3, moveSpread: 1.1, recoil: 0.6, automatic: true },
199 { id: "ak47", name: "AK-47", kind: "rifle", team: "t", price: 2700, damage: 36, armorPenetration: 0.775, rangeModifier: 0.98, rpm: 600, magazine: 30, reserve: 90, reloadTime: 2.5, deployTime: 1, killReward: 300, carrySpeed: 215, spread: 0.16, moveSpread: 3.4, recoil: 1.5, automatic: true },
200 { id: "m4a4", name: "M4A4", kind: "rifle", team: "ct", price: 3100, damage: 33, armorPenetration: 0.7, rangeModifier: 0.97, rpm: 666, magazine: 30, reserve: 90, reloadTime: 3.1, deployTime: 1, killReward: 300, carrySpeed: 225, spread: 0.15, moveSpread: 3.2, recoil: 1.25, automatic: true },
201 { id: "awp", name: "AWP", kind: "sniper", team: "both", price: 4750, damage: 115, armorPenetration: 0.975, rangeModifier: 0.99, rpm: 41, magazine: 10, reserve: 30, reloadTime: 3.7, deployTime: 1.25, killReward: 100, carrySpeed: 200, spread: 0.05, moveSpread: 12, recoil: 3.5, zoom: 40, automatic: false },
152202 // The thrown weapons. Their bullet numbers are the empty ones — none of them
153203 // is ever held, fired or reloaded — and everything they do is in `blast`,
154204 // `flash` and `smoke`: CS's $300 HE, 98 hit points at the centre falling off
src/client/world.test.ts +167 −3
@@ -1,14 +1,14 @@
11 import { expect, test } from "bun:test";
22 import { createAudio } from "./audio.ts";
33 import { beepInterval } from "./bomb.ts";
4 import { siteAt } from "./level.ts";
4 import { siteAt, walkable, worldOf } from "./level.ts";
55 import { DE_FORGE } from "./map.ts";
66 import { SHORT } from "./match.ts";
77 import { WEAPONS } from "./weapons.ts";
88 import { buy } from "./economy.ts";
99 import {
10 applyInput, canSee, createWorld, currentWeapon, EFFECT_CAPACITY, eyePos, insideSmoke, NO_INPUT,
11 shoot, startRound, throwOne, update,
10 applyInput, canSee, createWorld, currentWeapon, deployTicks, EFFECT_CAPACITY, eyePos, insideSmoke,
11 NO_INPUT, shoot, startRound, throwOne, update,
1212 type Input, type Player, type Sound, type World,
1313 } from "./world.ts";
1414 import { cloudRadius, type Cloud } from "./grenade.ts";
@@ -331,6 +331,170 @@ test("a match plays itself to the end inside its own time", () => {
331331 expect(Math.max(world.match.score.t, world.match.score.ct)).toBe(2);
332332 });
333333
334 // --- deploy times ---------------------------------------------------------
335
336 /**
337 * A live round with two bodies in it: the person, and one enemy standing at the
338 * far end of the map who thinks for itself about nothing. Everything below is
339 * about the trigger and the clock, so nobody else is in the round to end it —
340 * one alive a side is what keeps the phase live while the person is driven a
341 * tick at a time.
342 */
343 const alone = (seed: number): World => {
344 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, seed);
345 const enemy = world.players.find((p) => p.team === "t") as Player;
346 enemy.bot = false;
347 only(world, [world.me, enemy]);
348 // Past the freeze, so the phase is not what is refusing the shots below.
349 while (world.match.phase === "freeze") update(world, TICK, NO_INPUT);
350 return world;
351 };
352
353 /**
354 * Somewhere on this level a body can run `cells` cells due east without meeting
355 * a wall, and the middle of the cell it starts from. The knife-run test needs
356 * open ground and de_forge's spawns face whichever way they face, so the ground
357 * is looked up rather than assumed.
358 */
359 function runway(cells: number): Vec3 {
360 for (let row = 0; row < DE_FORGE.grid.height; row++)
361 for (let col = 0; col + cells < DE_FORGE.grid.width; col++) {
362 let open = true;
363 for (let ahead = 0; ahead <= cells; ahead++) open &&= walkable(DE_FORGE, col + ahead, row);
364 if (open) return worldOf(DE_FORGE, col, row, 2);
365 }
366 throw new Error(`de_forge has no ${cells} cells in a row anybody can run down`);
367 }
368
369 /** One tick of this input, and whether a round left the barrel on it. */
370 const fired = (world: World, input: Partial<Input>): boolean => {
371 const before = world.me.lastShotAt;
372 update(world, TICK, { ...NO_INPUT, ...input });
373 return world.me.lastShotAt !== before;
374 };
375
376 test("a switched weapon fires nothing until it is up, to the tick", () => {
377 const world = alone(101);
378 // The knife: CS's 1.0 s, which at this simulation's 1/64 s tick is 64 whole
379 // ticks. The switch happens on the tick the key is read, and `deployUntil`
380 // is a whole number of ticks past that tick's clock.
381 const at = world.time;
382 expect(fired(world, { switchTo: "knife", fire: true })).toBe(false);
383 expect(world.me.weapon).toBe("knife");
384 expect(world.me.deployUntil).toBeCloseTo(at + TICK + 1, 12);
385
386 // Sixty-three more ticks of holding the trigger down on an automatic weapon
387 // send nothing at all: 1 + 63 = 64 ticks of the deploy, the last of which is
388 // still inside it.
389 for (let tick = 2; tick <= 64; tick++)
390 expect({ tick, fired: fired(world, { fire: true }) }).toEqual({ tick, fired: false });
391 // And the 65th tick — the first at which the clock has reached `deployUntil`
392 // — is the swing.
393 expect(fired(world, { fire: true })).toBe(true);
394 expect(world.time).toBeCloseTo(world.me.deployUntil, 12);
395 expect(world.time - at).toBeCloseTo(65 * TICK, 12); // the switch tick, 63 dead ones, the swing
396 });
397
398 test("a pistol's 0.7 s is 45 ticks, because 44.8 of them is not a tick anybody acts in", () => {
399 const world = alone(103);
400 // Out to the knife and back to the USP, which is the switch that costs the
401 // pistol's deploy. 0.7 × 64 = 44.8, rounded up to 45 ticks = 0.703125 s.
402 expect(deployTicks(WEAPONS.usp, TICK)).toBe(45);
403 expect(deployTicks(WEAPONS.knife, TICK)).toBe(64);
404 expect(deployTicks(WEAPONS.awp, TICK)).toBe(80);
405 update(world, TICK, { ...NO_INPUT, switchTo: "knife" });
406 const at = world.time;
407 update(world, TICK, { ...NO_INPUT, switchTo: "usp" });
408 expect(world.me.deployUntil).toBeCloseTo(at + TICK + 45 * TICK, 12);
409 // The USP is semi-automatic, so the trigger is held the whole way down: the
410 // first 44 ticks after the switch send nothing and the 45th sends one round.
411 for (let tick = 2; tick <= 45; tick++)
412 expect({ tick, fired: fired(world, { fire: true }) }).toEqual({ tick, fired: false });
413 expect(fired(world, { fire: true })).toBe(true);
414 });
415
416 test("the speed is immediate and the trigger is not: that is the knife run", () => {
417 const world = alone(107);
418 // An AWP, handed over rather than bought, because the shop shut with the
419 // freeze: what is under test is the switch and not the wallet.
420 world.me.primary = "awp";
421 world.me.ammo.awp = { mag: 10, reserve: 30 };
422 world.me.weapon = "awp";
423 // Six cells of open floor, run due east: enough for a second and a half at
424 // either carry speed without a wall having an opinion about the answer.
425 world.me.pos = runway(6);
426 world.me.yaw = 0;
427 run(world, 1.5, { ...NO_INPUT, forward: 1 });
428 const carrying = Math.hypot(world.me.vel[0], world.me.vel[1]);
429 expect(carrying).toBeCloseTo(WEAPONS.awp.carrySpeed, 0); // 200 u/s, the AWP's
430
431 // Tap 3. One tick later the body is already accelerating past the AWP's cap
432 // towards the knife's 250 — the ground is free — and 79 ticks after that the
433 // knife is still not up.
434 update(world, TICK, { ...NO_INPUT, forward: 1, switchTo: "knife" });
435 expect(Math.hypot(world.me.vel[0], world.me.vel[1])).toBeGreaterThan(carrying);
436 run(world, 1.5, { ...NO_INPUT, forward: 1 });
437 const running = Math.hypot(world.me.vel[0], world.me.vel[1]);
438 expect(running).toBeCloseTo(WEAPONS.knife.carrySpeed, 0); // 250 u/s, the knife's
439 expect(running).toBeGreaterThan(carrying);
440
441 // And coming back is the bill: 80 ticks — the AWP's 1.25 s — in which the
442 // sniper who ran there can neither scope nor fire. Before this change it was
443 // nought ticks and the run was free.
444 update(world, TICK, { ...NO_INPUT, forward: 1, switchTo: "awp" });
445 expect(deployTicks(WEAPONS.awp, TICK)).toBe(80);
446 for (let tick = 2; tick <= 80; tick++) {
447 expect({ tick, fired: fired(world, { fire: true, scope: true }) }).toEqual({ tick, fired: false });
448 expect(world.me.scoped).toBe(false); // the scope waits with the trigger
449 }
450 expect(fired(world, { fire: true, scope: true })).toBe(true);
451 expect(world.me.scoped).toBe(true);
452 });
453
454 test("spawning is not switching: the round goes live with the gun already up", () => {
455 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 109);
456 world.me.money = 5000;
457 buy(world, world.me, "m4a4"); // a buy in the freeze is a gun in your hands
458 expect(world.me.deployUntil).toBe(0);
459 while (world.match.phase === "freeze") {
460 update(world, TICK, NO_INPUT);
461 expect(world.me.deployUntil).toBe(0);
462 }
463 // The first tick of the live round sends a round: nobody stands about.
464 expect(world.players.every((p) => p.deployUntil === 0)).toBe(true);
465 expect(fired(world, { fire: true })).toBe(true);
466 // And the next round hands it back the same way, whatever was mid-deploy.
467 update(world, TICK, { ...NO_INPUT, switchTo: "knife" });
468 expect(world.me.deployUntil).toBeGreaterThan(world.time);
469 startRound(world);
470 expect(world.players.every((p) => p.deployUntil === 0)).toBe(true);
471 });
472
473 test("a switch mid-reload cancels it, and the next one waits for the new weapon", () => {
474 const world = alone(113);
475 const ammo = world.me.ammo.usp as { mag: number; reserve: number };
476 ammo.mag = 2;
477 run(world, 0.2, { ...NO_INPUT, reload: true });
478 expect(world.me.reloadUntil).toBeGreaterThan(world.time);
479
480 // Switching away cancels it, exactly as it always did: no magazine went in.
481 update(world, TICK, { ...NO_INPUT, switchTo: "knife" });
482 expect(world.me.reloadUntil).toBe(0);
483 expect(ammo.mag).toBe(2);
484
485 // Switching back restarts it, as it always did — but not until the pistol is
486 // up: 44 ticks of holding R do nothing, and the 45th starts the reload.
487 update(world, TICK, { ...NO_INPUT, switchTo: "usp", reload: true });
488 for (let tick = 2; tick <= 45; tick++) {
489 update(world, TICK, { ...NO_INPUT, reload: true });
490 expect({ tick, reloading: world.me.reloadUntil }).toEqual({ tick, reloading: 0 });
491 }
492 update(world, TICK, { ...NO_INPUT, reload: true });
493 expect(world.me.reloadUntil).toBeCloseTo(world.time + WEAPONS.usp.reloadTime, 12);
494 run(world, WEAPONS.usp.reloadTime + 0.1, { ...NO_INPUT, reload: true });
495 expect(ammo.mag).toBe(WEAPONS.usp.magazine);
496 });
497
334498 // --- smoke ----------------------------------------------------------------
335499
336500 /**
src/client/world.ts +59 −2
@@ -68,6 +68,15 @@ export type Player = Actor & Body & {
6868 blind: number;
6969 nextShotAt: number;
7070 reloadUntil: number;
71 /**
72 * The clock the weapon in these hands is *usable* at: set by a switch, and by
73 * nothing else. Until it passes this body cannot fire, scope or start a
74 * reload — `deploying` below is the one question every one of those asks.
75 *
76 * Zero is a body that is simply holding what it is holding, which is how a
77 * round starts: spawning is not switching, and freeze time is not a deploy.
78 */
79 deployUntil: number;
7180 shotIndex: number;
7281 lastShotAt: number;
7382 scoped: boolean;
@@ -226,7 +235,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player
226235 weapon: secondary, primary: null, secondary,
227236 ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) },
228237 defuseKit: false, hasBomb: false, pocket: { he: 0, flash: 0, smoke: 0 }, blind: 0,
229 nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false,
238 nextShotAt: 0, reloadUntil: 0, deployUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false,
230239 firedThisPress: false, threwThisPress: null,
231240 punch: [0, 0], stride: 0,
232241 kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0,
@@ -385,6 +394,11 @@ export function startRound(world: World): void {
385394 if (w !== undefined) p.ammo[id] = emptyAmmo(w.id);
386395 }
387396 p.weapon = p.primary ?? p.secondary;
397 // Spawning is not switching: a round starts with the gun already in your
398 // hands. A deploy time charged here would be a freeze time that ends with
399 // everybody standing about unable to shoot, which is not what a deploy is
400 // for and not what CS does either.
401 p.deployUntil = 0;
388402 if (p.brain !== undefined) p.brain.reset = true;
389403 }
390404 const ts = teammates(world, "t");
@@ -437,10 +451,31 @@ export function currentWeapon(p: Player): Weapon {
437451 return WEAPONS[p.weapon] ?? WEAPONS.knife;
438452 }
439453
454 /**
455 * How many whole ticks this weapon takes to come up, at this simulation's tick
456 * length. The deploy is counted in ticks rather than in seconds because a tick
457 * is the smallest thing anybody can act in: a deploy that ended halfway through
458 * one would end, in practice, at the tick after it, and rounding *up* here says
459 * so out loud instead of leaving it to a floating-point comparison.
460 *
461 * At the 64 Hz this game runs at that is: the AWP's 1.25 s is 80 ticks exactly,
462 * a rifle's or the knife's 1.0 s is 64, and a pistol's or the MP9's 0.7 s is 45
463 * — 44.8 rounded up, so 0.703125 s, which is one tick's worth of honesty about
464 * a number the clock cannot hold.
465 */
466 export const deployTicks = (weapon: Weapon, dt: number): number => Math.ceil(weapon.deployTime / dt);
467
468 /** Is this weapon still coming up? The one question the trigger, the scope and the reload ask. */
469 export const deploying = (world: World, p: Player): boolean => world.time < p.deployUntil;
470
440471 function reload(world: World, p: Player): void {
441472 const w = currentWeapon(p);
442473 const ammo = p.ammo[w.id];
443474 if (w.magazine === 0 || ammo === undefined) return;
475 // A magazine cannot go into a gun that is not in the hands yet. A switch
476 // already cancelled whatever reload was running; this is what stops the
477 // *next* one from starting before the new weapon is up.
478 if (deploying(world, p)) return;
444479 if (ammo.mag >= w.magazine || ammo.reserve <= 0 || world.time < p.reloadUntil) return;
445480 p.reloadUntil = world.time + w.reloadTime;
446481 p.scoped = false;
@@ -462,6 +497,9 @@ function finishReload(world: World, p: Player): void {
462497 export function shoot(world: World, p: Player): boolean {
463498 const w = currentWeapon(p);
464499 if (world.time < p.nextShotAt || world.time < p.reloadUntil) return false;
500 // Before the magazine is even looked at: a weapon still coming up fires
501 // nothing, and an empty one still coming up does not start a reload either.
502 if (deploying(world, p)) return false;
465503 if (world.match.phase === "freeze") return false;
466504 const ammo = p.ammo[w.id];
467505 if (w.magazine > 0 && (ammo === undefined || ammo.mag <= 0)) {
@@ -672,6 +710,18 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
672710 p.shotIndex = 0;
673711 p.reloadUntil = 0;
674712 p.scoped = false;
713 // CS's deploy time, counted in whole ticks of this simulation's own clock
714 // so a switch costs the same number of ticks in a replay, in the training
715 // arena and on a phone with a bad frame rate.
716 //
717 // The asymmetry below it is the whole point of the rule. The weapon in
718 // the hands changed on *this* tick, so `wish.maxSpeed` two lines down is
719 // already the new weapon's `carrySpeed` and the body is already moving at
720 // it: the speed is immediate. The trigger is not — it, the scope and the
721 // reload key all wait out `deployUntil`. That is what makes tapping 3 to
722 // run at the knife's 250 u/s a decision rather than a free lunch: the
723 // ground is free and coming back to the gun is what it costs.
724 p.deployUntil = world.time + deployTicks(currentWeapon(p), dt) * dt;
675725 }
676726 }
677727 const w = currentWeapon(p);
@@ -690,7 +740,11 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
690740 footstep(world, p, wish.walk, dt);
691741 finishReload(world, p);
692742 if (input.reload) reload(world, p);
693 if (w.zoom !== undefined && input.scope !== p.scoped && world.time > p.nextShotAt - 0.05) p.scoped = input.scope;
743 // The scope waits with the trigger: an AWP that is not up yet has no eye to
744 // put to it. Holding the right button through the deploy scopes on the tick
745 // it comes up, which is what a held key does everywhere else in this file.
746 if (w.zoom !== undefined && input.scope !== p.scoped && !deploying(world, p) && world.time > p.nextShotAt - 0.05)
747 p.scoped = input.scope;
694748 if (input.fire && (w.automatic || p.nextShotAt <= world.time)) {
695749 if (w.automatic) shoot(world, p);
696750 else if (!p.firedThisPress) {
@@ -737,6 +791,9 @@ export function update(world: World, dt: number, input: Input): void {
737791 p.secondary = p.team === "t" ? "glock" : "usp";
738792 p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) };
739793 p.weapon = p.secondary;
794 // Changing sides hands you the other side's pistol; it is not a switch
795 // either, and `startRound` clears this again on the way into the round.
796 p.deployUntil = 0;
740797 }
741798 say(world, "Half time — switching sides");
742799 }
src/rl/arena.ts +4
@@ -150,6 +150,10 @@ const equip = (p: Player, weapon: "ak47" | "m4a4"): void => {
150150 p.shotIndex = 0;
151151 p.nextShotAt = 0;
152152 p.reloadUntil = 0;
153 // A duel starts with the rifle already up. Equipping is the arena's version
154 // of spawning and not a switch, so nobody pays a deploy time for it and the
155 // environment PPO learned in is the environment it learned in.
156 p.deployUntil = 0;
153157 p.lastShotAt = -99;
154158 p.punch = [0, 0];
155159 p.damage = 0;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs