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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/143-switching-a-weapon-costs-the-time-it-cos → main 1 commit fork
11 files
changed +552
−29
README.md +7 −1
| @@ -327,7 +327,13 @@ it scored, exactly as it did before. | ||
| 327 | 327 | <kbd>G</kbd> throws the HE grenade in your pocket and <kbd>T</kbd> the |
| 328 | 328 | flashbang, if you bought them, |
| 329 | 329 | <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 | |
| 331 | 337 | the scoreboard, <kbd>F</kbd> puts the stage on the whole screen and |
| 332 | 338 | <kbd>Esc</kbd> or <kbd>P</kbd> pauses. Right mouse pulls up the scope on the one |
| 333 | 339 | 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"; | ||
| 10 | 10 | import { colOf, findPath, rowOf, walkable, worldOf } from "./level.ts"; |
| 11 | 11 | import { dist, type Vec3 } from "../engine/index.ts"; |
| 12 | 12 | import { OBS_SIZE } from "./observe.ts"; |
| 13 | import { FLASH, type WeaponId } from "./weapons.ts"; | |
| 13 | import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts"; | |
| 14 | 14 | import { DE_FORGE } from "./map.ts"; |
| 15 | 15 | 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"; | |
| 17 | 17 | |
| 18 | 18 | // The bots in here think with the policy this repository ships, handed in the |
| 19 | 19 | // 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", () => { | ||
| 319 | 319 | expect(d.me.health).toBeLessThan(100); |
| 320 | 320 | }); |
| 321 | 321 | |
| 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 | ||
| 322 | 350 | test("a bot with bullets left plays exactly as it did: no swap, no knife", () => { |
| 323 | 351 | const d = standoff(); |
| 324 | 352 | 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", | ||
| 241 | 241 | // The instant it finishes the word goes. |
| 242 | 242 | expect(says(draw(scene((w) => { w.time = 10; w.me.reloadUntil = 10; })), "reloading")).toBe(false); |
| 243 | 243 | |
| 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 | ||
| 244 | 255 | // Carrying the bomb is written beside the gun's name. |
| 245 | 256 | const carrier = draw(scene((w) => { w.me.team = "t"; w.me.hasBomb = true; })); |
| 246 | 257 | expect(says(carrier, `${WEAPONS.usp.name} · bomb`)).toBe(true); |
src/client/hud.ts +7 −2
| @@ -247,8 +247,13 @@ export function drawHud( | ||
| 247 | 247 | width - pad, height - pad - Math.round(30 * scale), |
| 248 | 248 | { align: "right", font: small, fill: GREY }, |
| 249 | 249 | ); |
| 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" }); | |
| 252 | 257 | |
| 253 | 258 | // --- who is left |
| 254 | 259 | const aliveT = world.players.filter((p) => p.team === "t" && p.alive).length; |
src/client/replay.test.ts +111 −6
| @@ -1,12 +1,15 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The golden digests: what this simulation does, pinned. |
| 3 | 3 | * |
| 4 | * Four runs, all seeded, all stepped through the same script in | |
| 4 | * Six runs, all seeded, all stepped through a script in | |
| 5 | 5 | * `replay.ts`: the whole game on de_forge — ten players, the match rules, the |
| 6 | 6 | * bomb, the bots and their policy — the duel in the training arena, so an |
| 7 | 7 | * 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. | |
| 10 | 13 | * |
| 11 | 14 | * If one of these fails, read the message: it names the first second of play |
| 12 | 15 | * at which the run and the pin part company, and prints the constants to |
| @@ -29,13 +32,13 @@ import { DE_FORGE } from "./map.ts"; | ||
| 29 | 32 | import { SHORT } from "./match.ts"; |
| 30 | 33 | import { CHECKPOINT_CHARS } from "../engine/index.ts"; |
| 31 | 34 | 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, | |
| 33 | 36 | withoutEntropy, type Pin, type Run, |
| 34 | 37 | } from "./replay.ts"; |
| 35 | 38 | 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"; | |
| 37 | 40 | import { cloudRadius } from "./grenade.ts"; |
| 38 | import { SMOKE } from "./weapons.ts"; | |
| 41 | import { SMOKE, WEAPONS } from "./weapons.ts"; | |
| 39 | 42 | import { createArena, reset, stepInput } from "../rl/arena.ts"; |
| 40 | 43 | import { useShipped } from "./shipped.ts"; |
| 41 | 44 | |
| @@ -104,6 +107,16 @@ const TICKS = SECONDS * TICKS_PER_SECOND; | ||
| 104 | 107 | * either key, so no flash goes off and no `blind` is ever non-zero here. |
| 105 | 108 | */ |
| 106 | 109 | 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 | */ | |
| 107 | 120 | const ROUND_SECONDS = [ |
| 108 | 121 | "c858433691b7", "d46389733924", "1d2038791546", "318d01ffb6a5", |
| 109 | 122 | "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", ( | ||
| 250 | 263 | expect(stood.pocket).toBe(0); |
| 251 | 264 | }); |
| 252 | 265 | |
| 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 | ||
| 253 | 358 | test("a seeded duel in the training arena plays exactly as it played", () => { |
| 254 | 359 | const run = duelRun(); |
| 255 | 360 | must(run, DUEL_PIN); |
src/client/replay.ts +47
| @@ -216,6 +216,53 @@ export const SMOKE_SCRIPT: Beat[] = [ | ||
| 216 | 216 | { atTick: 1920, input: {} }, |
| 217 | 217 | ]; |
| 218 | 218 | |
| 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 | ||
| 219 | 266 | /** |
| 220 | 267 | * A cursor over the script: hand it a tick and it returns the input held at |
| 221 | 268 | * 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 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | 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, | |
| 5 | 5 | } from "./weapons.ts"; |
| 6 | 6 | |
| 7 | 7 | 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", () => { | ||
| 87 | 87 | expect(buyable("t").map((w) => w.id)).toContain("ak47"); |
| 88 | 88 | }); |
| 89 | 89 | |
| 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 | ||
| 90 | 136 | // --- the flashbang -------------------------------------------------------- |
| 91 | 137 | |
| 92 | 138 | 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 = { | ||
| 87 | 87 | magazine: number; |
| 88 | 88 | reserve: number; |
| 89 | 89 | 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; | |
| 90 | 99 | killReward: number; |
| 91 | 100 | carrySpeed: number; // u/s while holding it |
| 92 | 101 | spread: number; // degrees of inaccuracy standing still |
| @@ -102,8 +111,21 @@ export type Weapon = { | ||
| 102 | 111 | smoke?: Smoke; |
| 103 | 112 | }; |
| 104 | 113 | |
| 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 | ||
| 105 | 127 | /** 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 } }; | |
| 107 | 129 | |
| 108 | 130 | /** |
| 109 | 131 | * 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 | ||
| 112 | 134 | * short: 1.6 seconds of white is a fight you lost, and three would be a tab you |
| 113 | 135 | * closed. The two angles are the curve's, and `blindSeconds` below is the curve. |
| 114 | 136 | */ |
| 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 } }; | |
| 116 | 138 | |
| 117 | 139 | /** |
| 118 | 140 | * 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" | ||
| 131 | 153 | * opens where the canister *stopped*, so `spent` in `src/client/grenade.ts` |
| 132 | 154 | * holds it until the thing has come to rest. |
| 133 | 155 | */ |
| 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 } }; | |
| 135 | 157 | |
| 136 | 158 | /** The fuse on a thrown weapon, in seconds: the one number all three kinds have. */ |
| 137 | 159 | export const fuseOf = (weapon: Weapon): number => |
| 138 | 160 | weapon.blast?.fuse ?? weapon.flash?.fuse ?? weapon.smoke?.fuse ?? 0; |
| 139 | 161 | |
| 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 | */ | |
| 140 | 190 | 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 }, | |
| 144 | 194 | // The Deagle carries: `rangeModifier` is 0.955 rather than CS's 0.81 so that |
| 145 | 195 | // a headshot through a helmet kills at every distance a bullet in this game |
| 146 | 196 | // 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 }, | |
| 152 | 202 | // The thrown weapons. Their bullet numbers are the empty ones — none of them |
| 153 | 203 | // is ever held, fired or reloaded — and everything they do is in `blast`, |
| 154 | 204 | // `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 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { createAudio } from "./audio.ts"; |
| 3 | 3 | import { beepInterval } from "./bomb.ts"; |
| 4 | import { siteAt } from "./level.ts"; | |
| 4 | import { siteAt, walkable, worldOf } from "./level.ts"; | |
| 5 | 5 | import { DE_FORGE } from "./map.ts"; |
| 6 | 6 | import { SHORT } from "./match.ts"; |
| 7 | 7 | import { WEAPONS } from "./weapons.ts"; |
| 8 | 8 | import { buy } from "./economy.ts"; |
| 9 | 9 | 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, | |
| 12 | 12 | type Input, type Player, type Sound, type World, |
| 13 | 13 | } from "./world.ts"; |
| 14 | 14 | import { cloudRadius, type Cloud } from "./grenade.ts"; |
| @@ -331,6 +331,170 @@ test("a match plays itself to the end inside its own time", () => { | ||
| 331 | 331 | expect(Math.max(world.match.score.t, world.match.score.ct)).toBe(2); |
| 332 | 332 | }); |
| 333 | 333 | |
| 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 | ||
| 334 | 498 | // --- smoke ---------------------------------------------------------------- |
| 335 | 499 | |
| 336 | 500 | /** |
src/client/world.ts +59 −2
| @@ -68,6 +68,15 @@ export type Player = Actor & Body & { | ||
| 68 | 68 | blind: number; |
| 69 | 69 | nextShotAt: number; |
| 70 | 70 | 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; | |
| 71 | 80 | shotIndex: number; |
| 72 | 81 | lastShotAt: number; |
| 73 | 82 | scoped: boolean; |
| @@ -226,7 +235,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player | ||
| 226 | 235 | weapon: secondary, primary: null, secondary, |
| 227 | 236 | ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) }, |
| 228 | 237 | 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, | |
| 230 | 239 | firedThisPress: false, threwThisPress: null, |
| 231 | 240 | punch: [0, 0], stride: 0, |
| 232 | 241 | kills: 0, deaths: 0, headshots: 0, damage: 0, spawnAt: 0, |
| @@ -385,6 +394,11 @@ export function startRound(world: World): void { | ||
| 385 | 394 | if (w !== undefined) p.ammo[id] = emptyAmmo(w.id); |
| 386 | 395 | } |
| 387 | 396 | 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; | |
| 388 | 402 | if (p.brain !== undefined) p.brain.reset = true; |
| 389 | 403 | } |
| 390 | 404 | const ts = teammates(world, "t"); |
| @@ -437,10 +451,31 @@ export function currentWeapon(p: Player): Weapon { | ||
| 437 | 451 | return WEAPONS[p.weapon] ?? WEAPONS.knife; |
| 438 | 452 | } |
| 439 | 453 | |
| 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 | ||
| 440 | 471 | function reload(world: World, p: Player): void { |
| 441 | 472 | const w = currentWeapon(p); |
| 442 | 473 | const ammo = p.ammo[w.id]; |
| 443 | 474 | 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; | |
| 444 | 479 | if (ammo.mag >= w.magazine || ammo.reserve <= 0 || world.time < p.reloadUntil) return; |
| 445 | 480 | p.reloadUntil = world.time + w.reloadTime; |
| 446 | 481 | p.scoped = false; |
| @@ -462,6 +497,9 @@ function finishReload(world: World, p: Player): void { | ||
| 462 | 497 | export function shoot(world: World, p: Player): boolean { |
| 463 | 498 | const w = currentWeapon(p); |
| 464 | 499 | 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; | |
| 465 | 503 | if (world.match.phase === "freeze") return false; |
| 466 | 504 | const ammo = p.ammo[w.id]; |
| 467 | 505 | 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 | ||
| 672 | 710 | p.shotIndex = 0; |
| 673 | 711 | p.reloadUntil = 0; |
| 674 | 712 | 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; | |
| 675 | 725 | } |
| 676 | 726 | } |
| 677 | 727 | const w = currentWeapon(p); |
| @@ -690,7 +740,11 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 690 | 740 | footstep(world, p, wish.walk, dt); |
| 691 | 741 | finishReload(world, p); |
| 692 | 742 | 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; | |
| 694 | 748 | if (input.fire && (w.automatic || p.nextShotAt <= world.time)) { |
| 695 | 749 | if (w.automatic) shoot(world, p); |
| 696 | 750 | else if (!p.firedThisPress) { |
| @@ -737,6 +791,9 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 737 | 791 | p.secondary = p.team === "t" ? "glock" : "usp"; |
| 738 | 792 | p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) }; |
| 739 | 793 | 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; | |
| 740 | 797 | } |
| 741 | 798 | say(world, "Half time — switching sides"); |
| 742 | 799 | } |
src/rl/arena.ts +4
| @@ -150,6 +150,10 @@ const equip = (p: Player, weapon: "ak47" | "m4a4"): void => { | ||
| 150 | 150 | p.shotIndex = 0; |
| 151 | 151 | p.nextShotAt = 0; |
| 152 | 152 | 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; | |
| 153 | 157 | p.lastShotAt = -99; |
| 154 | 158 | p.punch = [0, 0]; |
| 155 | 159 | p.damage = 0; |
new above
offline