Split The code is public; the app answers only to the crew.

Switching weapons is instant, so running on the knife costs nothing and the AWP deploys as fast as a pistol #202

Merged opened this

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

Merged

vesper/cs:vesper/185-switching-weapons-is-instant-so-running → main 1 commit fork

  • Merged. vesper/185-switching-weapons-is-instant-so-running was merged into main as 598c302.

Review

Sign in to review this change.
5 files changed +285 −33
README.md +41 −17
@@ -49,7 +49,9 @@ wants one, but the running server never reads it.
4949 — 55 in the chest, 180 in the back, where "the back" is CS's own test of standing
5050 within 61.65° of straight behind someone and armour stops the same share either
5151 way, so one stab from behind ends a full-health player with a vest on —
52 deterministic spray patterns,
52 deterministic spray patterns, a draw on every weapon — 0.4 s on the knife up
53 to 1.25 s on the AWP, before which the trigger and the scope do nothing,
54 which is what makes the three carry speeds a choice —
5355 a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and
5456 loss bonuses. Cover is not all equal: a bullet goes **through a crate** if
5557 the round is strong enough for the thickness it meets. Crates are the only
@@ -189,6 +191,20 @@ pad does it on a phone. Pausing also opens mute, volume,
189191 look sensitivity and the scope's own share of it, which take effect at once and
190192 are remembered for next time.
191193
194 **A gun has to come up before it fires.** Every weapon has a draw — the knife
195 0.4 s, a pistol 0.5–0.6 s, a rifle 1 s, the AWP 1.25 s — and for that long
196 after you press <kbd>1</kbd>, <kbd>2</kbd> or <kbd>3</kbd> the trigger does
197 nothing and the scope will not come up. Walking is untouched, and that is the
198 whole point of it: the knife is the fastest thing to run on (250 u/s against
199 the AK's 215 and the AWP's 200), so running on it is a real choice and not a
200 free one — you pay the rifle's second at the other end, which is where you
201 wanted to be shooting. A gun taken off the floor is the same: walk over a
202 dropped AWP and it is 1.25 s before it will fire, so the swap is a commitment
203 too. Asking for the weapon already in your hands costs nothing at all, a round
204 starts with everyone's gun already up, and the numbers are one per weapon in
205 `src/client/weapons.ts` (`deployTime`) beside the carry speed they exist to
206 make matter.
207
192208 **The scope has its own sensitivity.** The AWP draws 40° where the hip draws
193209 90°, so one number for both means the same flick sweeps the same degrees across
194210 less than half as much screen — the scoped turn that overshoots. **Scope sens**
@@ -792,16 +808,24 @@ and the same shipped policy:
792808
793809 | | `de_forge` | `de_cinder` |
794810 | --- | --- | --- |
795 | rounds played | 208 | 204 |
796 | plant rate | **63%** | **52%** |
797 | detonated | 99 | 83 |
798 | elimination | 64 | 88 |
799 | defused | 23 | 19 |
800 | ends on the clock | 22 (**11%**) | 14 (**7%**) |
801 | defuse rate | 18% of planted | 18% of planted |
802 | side split | T 56% (117) · CT 44% (91) | T 44% (90) · CT 56% (114) |
811 | rounds played | 214 | 203 |
812 | plant rate | **64%** | **50%** |
813 | detonated | 110 | 84 |
814 | elimination | 61 | 92 |
815 | defused | 19 | 16 |
816 | ends on the clock | 24 (**11%**) | 11 (**5%**) |
817 | defuse rate | 14% of planted | 16% of planted |
818 | side split | T 58% (125) · CT 42% (89) | T 43% (88) · CT 57% (115) |
803819 | median round | **123 s** | **93 s** |
804 | longest round | 144 s | 145 s |
820 | longest round | 150 s | 145 s |
821
822 Re-measured at #185, the change that gave a weapon a deploy time. The same run
823 before it was `de_forge` 208 rounds, 63% planted, `detonated 99 · time 22 ·
824 elimination 64 · defused 23`, T 56–44, and `de_cinder` 204 rounds, 52% planted,
825 `detonated 83 · time 14 · elimination 88 · defused 19`, CT 56–44 — a point or
826 two either way, which is what a second of draw costs a bot that walks over a
827 gun. The shipped policy's own number did not move at all (see *The bots*): a
828 duel in the arena starts with the rifle already up.
805829
806830 About 6 seconds of wall clock a match on `de_forge` and 4 on `de_cinder` —
807831 which is why this is a script and not a test. Before the two sides read the
@@ -810,12 +834,12 @@ defused 19` over 216 rounds: a quarter of rounds decided by nothing at all.
810834
811835 The side split is the other half of that question — a plant rate says how
812836 often the bomb goes down, not who wins the round it does not — and it flips
813 between the two maps: terrorists win `de_forge` 56–44, counter-terrorists win
814 `de_cinder` 56–44. Neither map hands one side a runaway; the twelve points is
815 the map's geometry showing up in the score the same way it shows up in the
837 between the two maps: terrorists win `de_forge` 58–42, counter-terrorists win
838 `de_cinder` 57–43. Neither map hands one side a runaway; the fifteen-odd points
839 is the map's geometry showing up in the score the same way it shows up in the
816840 plant rate, not a bot that plays one side better than the other.
817841
818 **`de_cinder` is eleven points off `de_forge`'s plant rate, and that number is
842 **`de_cinder` is fourteen points off `de_forge`'s plant rate, and that number is
819843 accepted rather than tuned away.** What it is measuring is the map's geometry
820844 and not a weakness in the bots. `de_forge` puts both sites along the top with
821845 CT spawn between them: a counter-terrorist walks 12–22 cells to a site while a
@@ -825,10 +849,10 @@ produces. `de_cinder`'s sites are diagonally opposite, so no spawn is near
825849 both: the counter-terrorists walk 17–28 and the terrorists 22–38, which is
826850 close enough that both sides are still moving when they meet. The fight happens
827851 on the way in rather than on the site, and a side wiped in the open ends the
828 round before anyone has planted anything. Hence 88 eliminations against 64,
852 round before anyone has planted anything. Hence 92 eliminations against 61,
829853 and a median round 30 seconds
830854 shorter. The rounds that go nowhere at all — the failure #105 was about — are
831 *fewer*, not more: 7% against 11%.
855 *fewer*, not more: 5% against 11%.
832856
833857 Two things say this is the layout and not a broken site. The plants split
834858 across A and B the same way on both maps (16/32 and 15/26 over twelve seeded
@@ -850,7 +874,7 @@ end in a terrorist win; 43 of the 55 rounds that ended on the clock in the
850874 pre-#105 run above were that. What ends them now is a counter-terrorist that has run out of clock
851875 walking over to find out why nobody has come, which is most of why
852876 `elimination` nearly doubled. The rest of the baseline drifts whenever the
853 bots or the blast do — it was 13% defused before #66 and #64 landed — so
877 bots or the blast do — it was 13% defused before #66 and #64 landed, and 18% before #185 — so
854878 re-run it rather than trust it after a change to either.
855879
856880 ## Measuring the tick
src/client/weapons.test.ts +20
@@ -85,6 +85,26 @@ test("every weapon says what it does to cover, and a rifle says more than a pist
8585 expect(penetration(WEAPONS.knife)).toEqual({ units: 0, survives: 0 });
8686 });
8787
88 test("every weapon says how long it takes to come up, and the order is CS's", () => {
89 for (const id of Object.keys(WEAPONS) as WeaponId[]) {
90 const { deployTime } = WEAPONS[id];
91 expect(Number.isFinite(deployTime)).toBe(true);
92 expect(deployTime).toBeGreaterThanOrEqual(0);
93 expect(deployTime).toBeLessThanOrEqual(2);
94 }
95 const at = (id: WeaponId): number => WEAPONS[id].deployTime;
96 // The knife is the quickest thing to draw as well as the quickest to run
97 // on; a pistol beats a rifle; the AWP is the slowest thing anyone carries.
98 expect(at("knife")).toBeLessThan(at("glock"));
99 expect(at("knife")).toBeLessThan(at("usp"));
100 for (const pistol of ["glock", "usp", "deagle"] as WeaponId[])
101 for (const rifle of ["ak47", "m4a4"] as WeaponId[])
102 expect(at(pistol)).toBeLessThan(at(rifle));
103 expect(at("mp9")).toBeLessThan(at("ak47"));
104 for (const id of Object.keys(WEAPONS) as WeaponId[])
105 if (id !== "awp") expect(at(id)).toBeLessThan(at("awp"));
106 });
107
88108 test("what comes out the far side of a crate is less than what went in", () => {
89109 const ak = WEAPONS.ak47;
90110 const full = computeDamage(ak, 500, "chest", 0, false);
src/client/weapons.ts +26 −10
@@ -28,6 +28,19 @@ export type Weapon = {
2828 reserve: number;
2929 reloadTime: number;
3030 killReward: number;
31 /**
32 * Seconds between this coming up in your hands and it firing. It is what
33 * makes `carrySpeed` a choice rather than decoration: the knife is the
34 * fastest thing to run on *and* the fastest to put away, so running on it
35 * costs the rifle's draw at the other end.
36 *
37 * The shape is CS's — a pistol is up before a rifle, the AWP is the slowest
38 * thing anyone carries and the knife the quickest — and the numbers follow
39 * CS:GO's published draw times, rounded to the nearest twentieth: 1.25 s on
40 * the AWP down to 0.4 s on the knife. Close enough that the habits transfer,
41 * which is the same bar the rest of this table is written to.
42 */
43 deployTime: number;
3144 carrySpeed: number; // u/s while holding it
3245 spread: number; // degrees of inaccuracy standing still
3346 moveSpread: number; // extra degrees at a full run
@@ -40,23 +53,26 @@ const WEAPON_LIST: Weapon[] = [
4053 // CS's two knife numbers: 55 in the front, 180 in the back. 180 through
4154 // `armorPenetration` is 153 to health, which ends a full-health player with
4255 // a vest on in one; 55 is three stabs through armour, the way it always was.
43 { id: "knife", name: "Knife", kind: "knife", team: "both", price: 0, damage: 55, backstabDamage: 180, 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 },
44 { 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 },
45 { 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 },
46 { id: "deagle", name: "Desert Eagle", kind: "pistol", team: "both", price: 700, damage: 63, armorPenetration: 0.925, rangeModifier: 0.81, rpm: 267, magazine: 7, reserve: 35, reloadTime: 2.2, killReward: 300, carrySpeed: 230, spread: 0.25, moveSpread: 2.6, recoil: 2.2, automatic: false },
47 { 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 },
48 { 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 },
49 { 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 },
50 { 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 },
56 { id: "knife", name: "Knife", kind: "knife", team: "both", price: 0, damage: 55, backstabDamage: 180, armorPenetration: 0.85, rangeModifier: 1, rpm: 240, magazine: 0, reserve: 0, reloadTime: 0, killReward: 1500, deployTime: 0.4, carrySpeed: 250, spread: 0, moveSpread: 0, recoil: 0, automatic: true },
57 { 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, deployTime: 0.5, carrySpeed: 240, spread: 0.22, moveSpread: 1.6, recoil: 0.7, automatic: false },
58 { 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, deployTime: 0.5, carrySpeed: 240, spread: 0.18, moveSpread: 1.5, recoil: 0.75, automatic: false },
59 { id: "deagle", name: "Desert Eagle", kind: "pistol", team: "both", price: 700, damage: 63, armorPenetration: 0.925, rangeModifier: 0.81, rpm: 267, magazine: 7, reserve: 35, reloadTime: 2.2, killReward: 300, deployTime: 0.6, carrySpeed: 230, spread: 0.25, moveSpread: 2.6, recoil: 2.2, automatic: false },
60 { 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, deployTime: 0.6, carrySpeed: 240, spread: 0.3, moveSpread: 1.1, recoil: 0.6, automatic: true },
61 { 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, deployTime: 1, carrySpeed: 215, spread: 0.16, moveSpread: 3.4, recoil: 1.5, automatic: true },
62 { 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, deployTime: 1, carrySpeed: 225, spread: 0.15, moveSpread: 3.2, recoil: 1.25, automatic: true },
63 { 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, deployTime: 1.25, carrySpeed: 200, spread: 0.05, moveSpread: 12, recoil: 3.5, zoom: 40, automatic: false },
5164 // Thrown, not fired: the rate is the hand's, `damage` is what the blast does
5265 // at its centre, and how far that reaches is grenades.ts's business. It goes
5366 // last because a weapon's spray pattern is seeded from its place in this
5467 // list, and every gun above it already has the pattern people have learned.
55 { id: "he", name: "HE Grenade", kind: "grenade", team: "both", price: 300, damage: 98, armorPenetration: 0.5, rangeModifier: 1, rpm: 120, magazine: 0, reserve: 0, reloadTime: 0, killReward: 300, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false },
68 // Its `deployTime` is 0 because the pocket is not a holster: what a grenade
69 // costs is the hand's own cooldown after the throw, which `world.ts` already
70 // charges, and slot 4 is not a way to have a gun up any sooner.
71 { id: "he", name: "HE Grenade", kind: "grenade", team: "both", price: 300, damage: 98, armorPenetration: 0.5, rangeModifier: 1, rpm: 120, magazine: 0, reserve: 0, reloadTime: 0, killReward: 300, deployTime: 0, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false },
5672 // Never bought, never held — this exists so the kill feed and the blast
5773 // that reads `damage`/`armorPenetration` have one row to point at, the same
5874 // way every gun does. `buyable` and `canBuyItem` refuse it by kind.
59 { id: "bomb", name: "C4", kind: "bomb", team: "both", price: 0, damage: 500, armorPenetration: 0.5, rangeModifier: 1, rpm: 0, magazine: 0, reserve: 0, reloadTime: 0, killReward: 0, carrySpeed: 0, spread: 0, moveSpread: 0, recoil: 0, automatic: false },
75 { id: "bomb", name: "C4", kind: "bomb", team: "both", price: 0, damage: 500, armorPenetration: 0.5, rangeModifier: 1, rpm: 0, magazine: 0, reserve: 0, reloadTime: 0, killReward: 0, deployTime: 0, carrySpeed: 0, spread: 0, moveSpread: 0, recoil: 0, automatic: false },
6076 ];
6177
6278 export const WEAPONS: Record<WeaponId, Weapon> = Object.fromEntries(
src/client/world.test.ts +168
@@ -329,6 +329,174 @@ test("a long round cannot grow the floor without bound", () => {
329329 expect(world.pickups.length).toBeLessThanOrEqual(24);
330330 });
331331
332 /**
333 * The draw. A weapon coming up is a beat you owe before it fires, which is
334 * what makes `carrySpeed` a choice: the knife is the fastest thing to run on
335 * and you pay the rifle's deploy at the other end.
336 */
337
338 /** One player, one input, the world clock moving the way `update` moves it. */
339 function step(world: World, input: Input, ticks = 1): void {
340 for (let i = 0; i < ticks; i++) {
341 world.time += TICK;
342 applyInput(world, world.me, input, TICK);
343 }
344 }
345
346 /** How many ticks of `seconds`, rounded up: the first tick that is past it. */
347 const ticksFor = (seconds: number): number => Math.ceil(seconds / TICK) + 1;
348
349 /** The person in a live round on a flat floor, one gun bought and the clock clear. */
350 function armed(world: World, primary: WeaponId): void {
351 world.boxes = [{ min: [-4000, -4000, -64], max: [4000, 4000, 0], mat: 0 }];
352 world.match.phase = "live";
353 for (const p of world.players) if (p !== world.me) p.alive = false;
354 world.me.pos = [0, 0, 0];
355 world.me.vel = [0, 0, 0];
356 world.me.yaw = 0;
357 world.me.pitch = 0;
358 world.me.onGround = true;
359 world.me.primary = primary;
360 world.me.ammo[primary] = { mag: WEAPONS[primary].magazine, reserve: WEAPONS[primary].reserve };
361 world.me.weapon = primary;
362 world.me.nextShotAt = 0;
363 world.me.reloadUntil = 0;
364 world.effects.length = 0;
365 }
366
367 /** Shots fired this tick, which is the only proof a trigger did anything. */
368 const shotsNow = (world: World): number =>
369 world.effects.filter((e) => e.kind === "shot" && e.at === world.time).length;
370
371 test("a rifle drawn off the knife cannot fire on the switch frame, and can once it is up", () => {
372 const world = createWorld(SHORT, "me", "ct", 5, 401);
373 armed(world, "ak47");
374 const ammo = world.me.ammo.ak47;
375 step(world, { ...NO_INPUT, switchTo: "knife" });
376 expect(world.me.weapon).toBe("knife");
377 step(world, NO_INPUT, ticksFor(WEAPONS.knife.deployTime));
378
379 // The rifle and the trigger on the same tick: the rifle comes up, nothing
380 // comes out of it.
381 step(world, { ...NO_INPUT, switchTo: "ak47", fire: true });
382 expect(world.me.weapon).toBe("ak47");
383 expect(shotsNow(world)).toBe(0);
384 expect(ammo.mag).toBe(WEAPONS.ak47.magazine);
385
386 // Halfway through the draw, still nothing — with the trigger held the whole way.
387 step(world, { ...NO_INPUT, fire: true }, Math.floor(WEAPONS.ak47.deployTime / 2 / TICK));
388 expect(ammo.mag).toBe(WEAPONS.ak47.magazine);
389
390 step(world, { ...NO_INPUT, fire: true }, ticksFor(WEAPONS.ak47.deployTime / 2));
391 expect(ammo.mag).toBeLessThan(WEAPONS.ak47.magazine);
392 });
393
394 test("the draw is the weapon's own: a pistol is up before a rifle, the AWP last", () => {
395 const drawn = (id: WeaponId): number => {
396 const world = createWorld(SHORT, "me", "ct", 5, 402);
397 armed(world, id);
398 step(world, { ...NO_INPUT, switchTo: "knife" });
399 step(world, NO_INPUT, ticksFor(WEAPONS.knife.deployTime));
400 step(world, { ...NO_INPUT, switchTo: id, fire: true });
401 const at = world.time;
402 for (let i = 0; i < ticksFor(2); i++) {
403 step(world, { ...NO_INPUT, fire: true });
404 if (shotsNow(world) > 0) return world.time - at;
405 }
406 throw new Error(`${id} never fired`);
407 };
408 const usp = drawn("usp");
409 const m4 = drawn("m4a4");
410 const awp = drawn("awp");
411 expect(usp).toBeCloseTo(WEAPONS.usp.deployTime, 1);
412 expect(usp).toBeLessThan(m4);
413 expect(m4).toBeLessThan(awp);
414 });
415
416 test("switching to the weapon already in hand costs nothing, however often it is asked for", () => {
417 const world = createWorld(SHORT, "me", "ct", 5, 403);
418 armed(world, "ak47");
419 const ammo = world.me.ammo.ak47;
420 // bots.ts asks for its primary every tick it wakes up: a re-deploy a tick is
421 // a bot that can never shoot.
422 step(world, { ...NO_INPUT, switchTo: "ak47", fire: true });
423 expect(shotsNow(world)).toBe(1);
424 expect(ammo.mag).toBe(WEAPONS.ak47.magazine - 1);
425 step(world, { ...NO_INPUT, switchTo: "ak47", fire: true }, ticksFor(1));
426 expect(ammo.mag).toBeLessThan(WEAPONS.ak47.magazine - 5); // a second of automatic fire
427 });
428
429 test("a gun off the floor has to come up too: no free AWP the frame you walk over one", () => {
430 const world = createWorld(SHORT, "me", "ct", 5, 404);
431 dropRifle(world);
432 const taker = standOnIt(world, null);
433 applyInput(world, taker, NO_INPUT, TICK);
434 expect(taker.weapon).toBe("m4a4");
435 expect(shoot(world, taker)).toBe(false); // picked it up this tick; it is not up yet
436 world.time += WEAPONS.m4a4.deployTime / 2;
437 expect(shoot(world, taker)).toBe(false);
438 world.time += WEAPONS.m4a4.deployTime / 2 + TICK;
439 expect(shoot(world, taker)).toBe(true);
440 });
441
442 test("a scope during the draw is refused, and takes once the AWP is up", () => {
443 const world = createWorld(SHORT, "me", "ct", 5, 405);
444 armed(world, "awp");
445 step(world, { ...NO_INPUT, switchTo: "knife" });
446 step(world, NO_INPUT, ticksFor(WEAPONS.knife.deployTime));
447
448 step(world, { ...NO_INPUT, switchTo: "awp", scope: true });
449 expect(world.me.weapon).toBe("awp");
450 expect(world.me.scoped).toBe(false);
451 step(world, { ...NO_INPUT, scope: true }, Math.floor(WEAPONS.awp.deployTime / 2 / TICK));
452 expect(world.me.scoped).toBe(false);
453 step(world, { ...NO_INPUT, scope: true }, ticksFor(WEAPONS.awp.deployTime / 2));
454 expect(world.me.scoped).toBe(true);
455 });
456
457 test("the draw stops nobody walking: the speed is the weapon in your hands, up or not", () => {
458 // The rule the deploy time exists to make matter. Hold W for a second and
459 // read the speed: it is the carry speed of whatever is in your hands, and
460 // the first tick of a draw runs at the new weapon's speed, not the old one's.
461 const topSpeed = (id: WeaponId, drawIt: boolean): number => {
462 const world = createWorld(SHORT, "me", "ct", 5, 406);
463 armed(world, id === "knife" ? "ak47" : id);
464 if (drawIt) {
465 // Something else in the hands, up, and then `id` drawn on the same tick
466 // the walking starts: the speed under a draw is the new weapon's.
467 const other: WeaponId = id === "knife" ? (world.me.primary as WeaponId) : "knife";
468 step(world, { ...NO_INPUT, switchTo: other });
469 step(world, NO_INPUT, ticksFor(WEAPONS[other].deployTime));
470 step(world, { ...NO_INPUT, forward: 1, switchTo: id }, ticksFor(1.5));
471 } else {
472 world.me.weapon = id;
473 step(world, { ...NO_INPUT, forward: 1 }, ticksFor(1.5));
474 }
475 return Math.hypot(world.me.vel[0], world.me.vel[1]);
476 };
477 for (const id of ["knife", "ak47", "awp"] as WeaponId[]) {
478 expect(topSpeed(id, false)).toBeCloseTo(WEAPONS[id].carrySpeed, 0);
479 expect(topSpeed(id, true)).toBeCloseTo(WEAPONS[id].carrySpeed, 0);
480 }
481 expect(WEAPONS.knife.carrySpeed).toBeGreaterThan(WEAPONS.ak47.carrySpeed);
482 expect(WEAPONS.ak47.carrySpeed).toBeGreaterThan(WEAPONS.awp.carrySpeed);
483 });
484
485 test("a round starts with everyone deployed: the first live tick can shoot", () => {
486 const world = createWorld(SHORT, "me", "ct", 5, 407);
487 armed(world, "awp");
488 // End the last round mid-draw and mid-cooldown: neither is owed in the next.
489 step(world, { ...NO_INPUT, switchTo: "knife" });
490 step(world, { ...NO_INPUT, switchTo: "awp" });
491 expect(world.me.nextShotAt).toBeGreaterThan(world.time);
492 startRound(world);
493 expect(world.me.weapon).toBe("awp");
494 expect(world.me.nextShotAt).toBe(0);
495 expect(world.players.every((p) => p.nextShotAt <= world.time)).toBe(true);
496 world.match.phase = "live";
497 expect(shoot(world, world.me)).toBe(true);
498 });
499
332500 /** The damage the person took, narrowed out of the effects list. */
333501 const hurtsIn = (world: World): { at: number; victim: number; from: number[]; amount: number }[] =>
334502 world.effects.flatMap((e) => (e.kind === "hurt" ? [e] : []));
src/client/world.ts +30 −6
@@ -493,7 +493,12 @@ export function startRound(world: World): void {
493493 // else, and a gun swapped away is gone from the list the same way.
494494 p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) };
495495 if (p.primary !== null) p.ammo[p.primary] = emptyAmmo(p.primary);
496 // Everyone starts the round deployed: the gun is already up through the
497 // freeze, so the first second of a live round is not a dead one. This is
498 // an assignment and not `deploy`, and the shot clock goes with it — a
499 // trigger pulled at the end of last round is not a beat owed in this one.
496500 p.weapon = p.primary ?? p.secondary;
501 p.nextShotAt = 0;
497502 if (p.brain !== undefined) {
498503 p.brain.reset = true;
499504 restyleBrain(p.brain, p.id, world.difficulty);
@@ -696,6 +701,27 @@ export function currentWeapon(p: Player): Weapon {
696701 return WEAPONS[p.weapon] ?? WEAPONS.knife;
697702 }
698703
704 /**
705 * Put `id` in `p`'s hands and start its draw: the weapon's `deployTime` has to
706 * pass before it will fire or scope, and `carrySpeed` is the speed you run at
707 * the whole way through — so the knife you ran in on is paid for at the other
708 * end, and an AWP taken off a body is not a pistol.
709 *
710 * `Math.max`, not an assignment: a gun brought up mid-cooldown must not
711 * shorten the one the last shot left. The slower of the two clocks wins.
712 *
713 * It is the caller's business whether this was asked for — a switch to the
714 * weapon already in hand never gets here, because deploying every tick is a
715 * weapon that never fires, and `bots.ts` asks for its primary every tick it
716 * wakes up.
717 */
718 function deploy(world: World, p: Player, id: WeaponId): void {
719 p.weapon = id;
720 p.shotIndex = 0;
721 p.scoped = false;
722 p.nextShotAt = Math.max(p.nextShotAt, world.time + (WEAPONS[id]?.deployTime ?? 0));
723 }
724
699725 function reload(world: World, p: Player): void {
700726 const w = currentWeapon(p);
701727 const ammo = p.ammo[w.id];
@@ -962,10 +988,10 @@ function takePickup(world: World, p: Player, pickup: Pickup): void {
962988 if (held !== null) delete p.ammo[held];
963989 p[slot] = pickup.weapon;
964990 p.ammo[pickup.weapon] = { mag: pickup.ammo.mag, reserve: pickup.ammo.reserve };
965 p.weapon = pickup.weapon;
966 p.shotIndex = 0;
991 // A gun off the floor comes up like any other: you walk over an AWP, you wait
992 // for it the same 1.25 s the person who dropped it would have.
993 deploy(world, p, pickup.weapon);
967994 p.reloadUntil = 0;
968 p.scoped = false;
969995 p.nextPickupAt = world.time + PICKUP_COOLDOWN;
970996 }
971997
@@ -1210,10 +1236,8 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
12101236 const frozen = world.match.phase === "freeze" || world.match.phase === "over";
12111237 if (input.switchTo !== null && holds(p, input.switchTo)) {
12121238 if (p.weapon !== input.switchTo) {
1213 p.weapon = input.switchTo;
1214 p.shotIndex = 0;
1239 deploy(world, p, input.switchTo);
12151240 p.reloadUntil = 0;
1216 p.scoped = false;
12171241 }
12181242 }
12191243 const w = currentWeapon(p);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs