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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/185-switching-weapons-is-instant-so-running → main 1 commit fork
5 files
changed +285
−33
README.md +41 −17
| @@ -49,7 +49,9 @@ wants one, but the running server never reads it. | ||
| 49 | 49 | — 55 in the chest, 180 in the back, where "the back" is CS's own test of standing |
| 50 | 50 | within 61.65° of straight behind someone and armour stops the same share either |
| 51 | 51 | 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 — | |
| 53 | 55 | a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and |
| 54 | 56 | loss bonuses. Cover is not all equal: a bullet goes **through a crate** if |
| 55 | 57 | 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, | ||
| 189 | 191 | look sensitivity and the scope's own share of it, which take effect at once and |
| 190 | 192 | are remembered for next time. |
| 191 | 193 | |
| 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 | ||
| 192 | 208 | **The scope has its own sensitivity.** The AWP draws 40° where the hip draws |
| 193 | 209 | 90°, so one number for both means the same flick sweeps the same degrees across |
| 194 | 210 | less than half as much screen — the scoped turn that overshoots. **Scope sens** |
| @@ -792,16 +808,24 @@ and the same shipped policy: | ||
| 792 | 808 | |
| 793 | 809 | | | `de_forge` | `de_cinder` | |
| 794 | 810 | | --- | --- | --- | |
| 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) | | |
| 803 | 819 | | 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. | |
| 805 | 829 | |
| 806 | 830 | About 6 seconds of wall clock a match on `de_forge` and 4 on `de_cinder` — |
| 807 | 831 | 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. | ||
| 810 | 834 | |
| 811 | 835 | The side split is the other half of that question — a plant rate says how |
| 812 | 836 | 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 | |
| 816 | 840 | plant rate, not a bot that plays one side better than the other. |
| 817 | 841 | |
| 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 | |
| 819 | 843 | accepted rather than tuned away.** What it is measuring is the map's geometry |
| 820 | 844 | and not a weakness in the bots. `de_forge` puts both sites along the top with |
| 821 | 845 | 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 | ||
| 825 | 849 | both: the counter-terrorists walk 17–28 and the terrorists 22–38, which is |
| 826 | 850 | close enough that both sides are still moving when they meet. The fight happens |
| 827 | 851 | 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, | |
| 829 | 853 | and a median round 30 seconds |
| 830 | 854 | 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%. | |
| 832 | 856 | |
| 833 | 857 | Two things say this is the layout and not a broken site. The plants split |
| 834 | 858 | 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 | ||
| 850 | 874 | pre-#105 run above were that. What ends them now is a counter-terrorist that has run out of clock |
| 851 | 875 | walking over to find out why nobody has come, which is most of why |
| 852 | 876 | `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 | |
| 854 | 878 | re-run it rather than trust it after a change to either. |
| 855 | 879 | |
| 856 | 880 | ## 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 | ||
| 85 | 85 | expect(penetration(WEAPONS.knife)).toEqual({ units: 0, survives: 0 }); |
| 86 | 86 | }); |
| 87 | 87 | |
| 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 | ||
| 88 | 108 | test("what comes out the far side of a crate is less than what went in", () => { |
| 89 | 109 | const ak = WEAPONS.ak47; |
| 90 | 110 | const full = computeDamage(ak, 500, "chest", 0, false); |
src/client/weapons.ts +26 −10
| @@ -28,6 +28,19 @@ export type Weapon = { | ||
| 28 | 28 | reserve: number; |
| 29 | 29 | reloadTime: number; |
| 30 | 30 | 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; | |
| 31 | 44 | carrySpeed: number; // u/s while holding it |
| 32 | 45 | spread: number; // degrees of inaccuracy standing still |
| 33 | 46 | moveSpread: number; // extra degrees at a full run |
| @@ -40,23 +53,26 @@ const WEAPON_LIST: Weapon[] = [ | ||
| 40 | 53 | // CS's two knife numbers: 55 in the front, 180 in the back. 180 through |
| 41 | 54 | // `armorPenetration` is 153 to health, which ends a full-health player with |
| 42 | 55 | // 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 }, | |
| 51 | 64 | // Thrown, not fired: the rate is the hand's, `damage` is what the blast does |
| 52 | 65 | // at its centre, and how far that reaches is grenades.ts's business. It goes |
| 53 | 66 | // last because a weapon's spray pattern is seeded from its place in this |
| 54 | 67 | // 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 }, | |
| 56 | 72 | // Never bought, never held — this exists so the kill feed and the blast |
| 57 | 73 | // that reads `damage`/`armorPenetration` have one row to point at, the same |
| 58 | 74 | // 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 }, | |
| 60 | 76 | ]; |
| 61 | 77 | |
| 62 | 78 | 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", () => { | ||
| 329 | 329 | expect(world.pickups.length).toBeLessThanOrEqual(24); |
| 330 | 330 | }); |
| 331 | 331 | |
| 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 | ||
| 332 | 500 | /** The damage the person took, narrowed out of the effects list. */ |
| 333 | 501 | const hurtsIn = (world: World): { at: number; victim: number; from: number[]; amount: number }[] => |
| 334 | 502 | world.effects.flatMap((e) => (e.kind === "hurt" ? [e] : [])); |
src/client/world.ts +30 −6
| @@ -493,7 +493,12 @@ export function startRound(world: World): void { | ||
| 493 | 493 | // else, and a gun swapped away is gone from the list the same way. |
| 494 | 494 | p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) }; |
| 495 | 495 | 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. | |
| 496 | 500 | p.weapon = p.primary ?? p.secondary; |
| 501 | p.nextShotAt = 0; | |
| 497 | 502 | if (p.brain !== undefined) { |
| 498 | 503 | p.brain.reset = true; |
| 499 | 504 | restyleBrain(p.brain, p.id, world.difficulty); |
| @@ -696,6 +701,27 @@ export function currentWeapon(p: Player): Weapon { | ||
| 696 | 701 | return WEAPONS[p.weapon] ?? WEAPONS.knife; |
| 697 | 702 | } |
| 698 | 703 | |
| 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 | ||
| 699 | 725 | function reload(world: World, p: Player): void { |
| 700 | 726 | const w = currentWeapon(p); |
| 701 | 727 | const ammo = p.ammo[w.id]; |
| @@ -962,10 +988,10 @@ function takePickup(world: World, p: Player, pickup: Pickup): void { | ||
| 962 | 988 | if (held !== null) delete p.ammo[held]; |
| 963 | 989 | p[slot] = pickup.weapon; |
| 964 | 990 | 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); | |
| 967 | 994 | p.reloadUntil = 0; |
| 968 | p.scoped = false; | |
| 969 | 995 | p.nextPickupAt = world.time + PICKUP_COOLDOWN; |
| 970 | 996 | } |
| 971 | 997 | |
| @@ -1210,10 +1236,8 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 1210 | 1236 | const frozen = world.match.phase === "freeze" || world.match.phase === "over"; |
| 1211 | 1237 | if (input.switchTo !== null && holds(p, input.switchTo)) { |
| 1212 | 1238 | if (p.weapon !== input.switchTo) { |
| 1213 | p.weapon = input.switchTo; | |
| 1214 | p.shotIndex = 0; | |
| 1239 | deploy(world, p, input.switchTo); | |
| 1215 | 1240 | p.reloadUntil = 0; |
| 1216 | p.scoped = false; | |
| 1217 | 1241 | } |
| 1218 | 1242 | } |
| 1219 | 1243 | const w = currentWeapon(p); |