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

A pocket that holds one of each: per-kind grenades, and the rule slot 4 draws by #142

Merged opened this

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

Merged

scout/cs:scout/130-a-pocket-that-holds-one-of-each-per-kind → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/130-a-pocket-that-holds-one-of-each-per-kind was merged into main as d54675d.

Review

Sign in to review this change.
6 files changed +161 −38
src/client/buy.ts +2 −2
@@ -6,7 +6,7 @@
66 */
77
88 import { buyable, WEAPONS } from "./weapons.ts";
9 import { canBuy, MAX_GRENADES, priceOf, type BuyItem, type Player, type World } from "./world.ts";
9 import { canBuy, grenadeFull, priceOf, type BuyItem, type Player, type World } from "./world.ts";
1010
1111 export type BuyState = "ok" | "owned" | "poor" | "side" | "closed";
1212
@@ -37,7 +37,7 @@ function owned(player: Player, item: BuyItem): boolean {
3737 if (item === "armor") return player.armor >= 100;
3838 if (item === "helmet") return player.helmet;
3939 if (item === "kit") return player.defuseKit;
40 if (WEAPONS[item].kind === "grenade") return player.grenades >= MAX_GRENADES;
40 if (WEAPONS[item].kind === "grenade") return grenadeFull(player, WEAPONS[item].id);
4141 return player.primary === item || player.secondary === item;
4242 }
4343
src/client/hud.test.ts +2 −2
@@ -379,7 +379,7 @@ test("the radar sits in the bottom-left corner and labels both sites", () => {
379379
380380 test("the HUD says what is in the pocket, and counts it where the ammo goes", () => {
381381 const world = createWorld(SHORT, "me", "ct", 5, 97);
382 world.me.grenades = 1;
382 world.me.grenades = { he: 1 };
383383 const carrying = recorder();
384384 draw(carrying, world);
385385 const texts = (r: { calls: Call[] }): string[] =>
@@ -394,7 +394,7 @@ test("the HUD says what is in the pocket, and counts it where the ammo goes", ()
394394 expect(texts(held)).toContain("HE Grenade");
395395
396396 // With none, neither line is there to read.
397 world.me.grenades = 0;
397 world.me.grenades = {};
398398 world.me.weapon = world.me.secondary;
399399 const without = recorder();
400400 draw(without, world);
src/client/hud.ts +16 −5
@@ -19,7 +19,10 @@ import type { Camera } from "./renderer.ts";
1919 import { drawReport } from "./reportPanel.ts";
2020 import { deathNotice } from "./spectate.ts";
2121 import { inaccuracy, WEAPONS } from "./weapons.ts";
22 import { canSee, currentWeapon, eyePos, offeredPickup, useProgress, type Player, type World } from "./world.ts";
22 import {
23 canSee, currentWeapon, eyePos, grenadeCount, grenadeKinds, offeredPickup, useProgress,
24 type Player, type World,
25 } from "./world.ts";
2326
2427 const T_COLOR = "#e2b04a";
2528 const CT_COLOR = "#6fa8e8";
@@ -431,10 +434,18 @@ export function drawHud(
431434 carried += Math.round(20 * scale);
432435 }
433436 // What is in the pocket, whether or not it is in the hands: a grenade you
434 // forgot you bought is a grenade you never threw.
435 if (view.grenades > 0) {
437 // forgot you bought is a grenade you never threw. Every kind carried reads
438 // on the one line — with one kind, as today; empty draws nothing, which is
439 // the whole of how this avoids a layout shift when the pocket runs out.
440 // Untestable until a second real kind exists to carry: check this line's
441 // width at 360px once one does, since it grows sideways with each kind.
442 const pocket = grenadeKinds(view);
443 if (pocket.length > 0) {
436444 ctx.fillStyle = "#c8e6a0";
437 ctx.fillText(`HE ×${view.grenades} · 4`, pad, carried);
445 const label = pocket
446 .map((id) => `${WEAPONS[id].name.replace(" Grenade", "")} ×${grenadeCount(view, id)}`)
447 .join(" ");
448 ctx.fillText(`${label} · 4`, pad, carried);
438449 }
439450
440451 // --- ammo and weapon
@@ -444,7 +455,7 @@ export function drawHud(
444455 const ammo = view.ammo[weapon.id];
445456 // A grenade counts what is left in the pocket where a gun counts its magazine.
446457 const ammoText = weapon.kind === "grenade"
447 ? `×${view.grenades}`
458 ? `×${grenadeCount(view, weapon.id)}`
448459 : weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`;
449460 ctx.fillText(ammoText, width - pad, height - pad);
450461 ctx.font = small;
src/client/main.ts +6 −4
@@ -30,8 +30,8 @@ import { CALL_LIFE, isCall, type Call } from "./plan.ts";
3030 import { nextWatched, watched } from "./spectate.ts";
3131 import { summarize, type MatchSummary } from "./summary.ts";
3232 import {
33 buy, canBuy, createWorld, DEFAULT_MAP, DIFFICULTIES, eyePos, flinch, MAP_NAMES, mapFor, radioCall,
34 setDifficulty, update, type Difficulty, type Player, type World,
33 buy, canBuy, createWorld, DEFAULT_MAP, DIFFICULTIES, eyePos, flinch, grenadeSlot, MAP_NAMES, mapFor,
34 radioCall, setDifficulty, update, type Difficulty, type Player, type World,
3535 } from "./world.ts";
3636
3737 /**
@@ -721,9 +721,11 @@ function boot(): void {
721721 me.pitch = clamp(me.pitch + dy, -89, 89);
722722 }
723723 // Slot 4 is asked for whether or not there is a grenade in the pocket; the
724 // world is the one that says no, so the rule lives in one place.
724 // world is the one that says no, so the rule lives in one place — and it
725 // is also the one that says which kind, since with two in the pocket
726 // that is a cycle and not a constant.
725727 const switchTo =
726 controls.slotWanted === 4 ? "he" :
728 controls.slotWanted === 4 ? grenadeSlot(me) :
727729 controls.slotWanted === 3 ? "knife" :
728730 controls.slotWanted === 2 ? me.secondary :
729731 controls.slotWanted === 1 ? me.primary : null;
src/client/world.test.ts +54 −15
@@ -5,11 +5,11 @@ import { dist } from "./math.ts";
55 import { DE_FORGE, siteAt } from "./map.ts";
66 import { SHORT, winReward } from "./match.ts";
77 import { GAIT_CYCLE, STEP_STRIDE } from "./physics.ts";
8 import { WEAPONS } from "./weapons.ts";
8 import { WEAPONS, type WeaponId } from "./weapons.ts";
99 import {
1010 aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, createWorld, currentWeapon, damageDealt,
11 damageTaken, ENVIRONMENT, eyePos, flinch, newBomb, NO_INPUT, offeredPickup, radioCall, shoot,
12 startRound, update,
11 damageTaken, ENVIRONMENT, eyePos, flinch, grenadeCount, grenadeSlot, MAX_GRENADES, newBomb, nextGrenade,
12 NO_INPUT, offeredPickup, radioCall, shoot, startRound, update,
1313 type Input, type Player, type World,
1414 } from "./world.ts";
1515
@@ -653,7 +653,7 @@ function armWithGrenade(world: World): void {
653653 world.me.vel = [0, 0, 0];
654654 world.me.yaw = 0;
655655 world.me.pitch = 0;
656 world.me.grenades = 1;
656 world.me.grenades = { he: 1 };
657657 world.me.nextShotAt = 0;
658658 world.effects.length = 0;
659659 }
@@ -679,34 +679,42 @@ test("an HE costs $300, only in the freeze, and only one fits in a pocket", () =
679679 const world = createWorld(SHORT, "me", "ct", 5, 211);
680680 world.me.money = 1000;
681681 expect(buy(world, world.me, "he")).toBe(true);
682 expect(world.me.grenades).toBe(1);
682 expect(grenadeCount(world.me, "he")).toBe(1);
683683 expect(world.me.money).toBe(700);
684684 expect(world.me.weapon).not.toBe("he"); // bought into a pocket, not into your hands
685685 expect(buy(world, world.me, "he")).toBe(false); // one at a time
686 expect(world.me.grenades).toBe(1);
686 expect(grenadeCount(world.me, "he")).toBe(1);
687687 expect(world.me.money).toBe(700);
688688
689689 run(world, SHORT.freezeTime + 0.5);
690 world.me.grenades = 0;
690 world.me.grenades = {};
691691 expect(buy(world, world.me, "he")).toBe(false); // the buy is over
692 expect(world.me.grenades).toBe(0);
692 expect(grenadeCount(world.me, "he")).toBe(0);
693 });
694
695 test("the cap is read off the count, not off having bought before", () => {
696 const world = createWorld(SHORT, "me", "ct", 5, 213);
697 world.me.money = 1000;
698 world.me.grenades = { he: MAX_GRENADES }; // set directly, never bought
699 expect(buy(world, world.me, "he")).toBe(false);
700 expect(grenadeCount(world.me, "he")).toBe(MAX_GRENADES);
693701 });
694702
695703 test("slot 4 needs a grenade in the pocket, and the throw sends one on its way", () => {
696704 const world = createWorld(SHORT, "me", "ct", 5, 223);
697705 armWithGrenade(world);
698 world.me.grenades = 0;
706 world.me.grenades = {};
699707 applyInput(world, world.me, { ...NO_INPUT, switchTo: "he" }, TICK);
700708 expect(world.me.weapon).not.toBe("he"); // nothing to hold
701709
702 world.me.grenades = 1;
710 world.me.grenades = { he: 1 };
703711 applyInput(world, world.me, { ...NO_INPUT, switchTo: "he" }, TICK);
704712 expect(world.me.weapon).toBe("he");
705713 expect(currentWeapon(world.me).name).toBe("HE Grenade");
706714
707715 applyInput(world, world.me, { ...NO_INPUT, switchTo: "he", fire: true }, TICK);
708716 expect(world.grenades.length).toBe(1);
709 expect(world.me.grenades).toBe(0);
717 expect(grenadeCount(world.me, "he")).toBe(0);
710718 expect(world.me.weapon).toBe(world.me.secondary); // the hand goes back to the gun
711719 const thrown = world.grenades[0];
712720 expect(thrown.owner).toBe(world.me.id);
@@ -714,6 +722,37 @@ test("slot 4 needs a grenade in the pocket, and the throw sends one on its way",
714722 expect(thrown.vel[2]).toBeGreaterThan(0); // and up, so it arcs
715723 });
716724
725 test("slot 4 on an empty pocket has nothing to draw, and keeps the gun in your hands", () => {
726 const world = createWorld(SHORT, "me", "ct", 5, 224);
727 armWithGrenade(world);
728 world.me.grenades = {};
729 world.me.primary = "m4a4";
730 world.me.weapon = "m4a4";
731 expect(grenadeSlot(world.me)).toBeNull();
732 applyInput(world, world.me, { ...NO_INPUT, switchTo: grenadeSlot(world.me) }, TICK);
733 expect(world.me.weapon).toBe("m4a4");
734 });
735
736 test("the cycle slot 4 draws by: one kind is unambiguous, two cycle and wrap", () => {
737 // Only HE exists today, so the cycle itself — not the catalog — is what a
738 // second kind will lean on; "smoke" here stands in for whatever lands next.
739 const smoke = "smoke" as WeaponId;
740 expect(nextGrenade(["he"], "ak47" as WeaponId)).toBe("he"); // one kind, from a gun
741 expect(nextGrenade(["he"], "he")).toBe("he"); // one kind, holding it: unchanged
742 expect(nextGrenade(["he", smoke], "ak47" as WeaponId)).toBe("he"); // two kinds: the first, from a gun
743 expect(nextGrenade(["he", smoke], "he")).toBe(smoke); // press again: the other one
744 expect(nextGrenade(["he", smoke], smoke)).toBe("he"); // and again: back around
745 expect(nextGrenade([], "he")).toBeNull(); // nothing carried, nothing to draw
746 });
747
748 test("grenadeSlot reads the rule off the pocket actually carried", () => {
749 const world = createWorld(SHORT, "me", "ct", 5, 225);
750 armWithGrenade(world);
751 world.me.grenades = { he: 1 };
752 world.me.weapon = world.me.secondary;
753 expect(grenadeSlot(world.me)).toBe("he"); // the one kind carried, exactly as today
754 });
755
717756 test("the fuse goes off once, and the grenade is gone with it", () => {
718757 const world = createWorld(SHORT, "me", "ct", 5, 227);
719758 armWithGrenade(world);
@@ -803,11 +842,11 @@ test("a grenade thrown is gone at the next round; one kept is still yours", () =
803842 expect(world.grenades.length).toBe(1);
804843 startRound(world);
805844 expect(world.grenades.length).toBe(0);
806 expect(world.me.grenades).toBe(0);
845 expect(grenadeCount(world.me, "he")).toBe(0);
807846
808 world.me.grenades = 1;
847 world.me.grenades = { he: 1 };
809848 startRound(world);
810 expect(world.me.grenades).toBe(1);
849 expect(grenadeCount(world.me, "he")).toBe(1);
811850 expect(world.me.weapon).not.toBe("he"); // a round starts with the gun up
812851 });
813852
@@ -817,7 +856,7 @@ test("a player killed holding a grenade leaves their gun, never the grenade", ()
817856 faceOff(world, shooter);
818857 world.me.primary = "m4a4";
819858 world.me.ammo.m4a4 = { mag: 12, reserve: 30 };
820 world.me.grenades = 1;
859 world.me.grenades = { he: 1 };
821860 world.me.weapon = "he";
822861 world.me.health = 1;
823862 world.me.armor = 0;
src/client/world.ts +81 −10
@@ -43,7 +43,13 @@ export type Player = Actor & Body & {
4343 primary: WeaponId | null;
4444 secondary: WeaponId;
4545 ammo: Record<string, Ammo>;
46 grenades: number; // HE grenades carried, thrown one at a time
46 /**
47 * The pocket: one slot per grenade kind, so a second kind (smoke, when it
48 * lands) is a second key here and not a second mechanism. Missing or zero
49 * both mean "none of that kind" — `grenadeCount` is the one place that
50 * distinction is allowed to matter.
51 */
52 grenades: Partial<Record<WeaponId, number>>;
4753 defuseKit: boolean;
4854 hasBomb: boolean;
4955 nextShotAt: number;
@@ -232,7 +238,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player
232238 money: 800,
233239 weapon: secondary, primary: null, secondary,
234240 ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) },
235 grenades: 0,
241 grenades: {},
236242 defuseKit: false, hasBomb: false,
237243 nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false, firedThisPress: false, emptyThisPress: false,
238244 punch: [0, 0], nextPickupAt: 0,
@@ -431,9 +437,74 @@ export function priceOf(item: BuyItem, player: Player): number {
431437 export const canBuy = (world: World, player: Player): boolean =>
432438 world.match.phase === "freeze" && player.alive;
outdated

Approved. This is the refactor #130 asked for, it is player-invisible today, and it holds up under a real run. (This forge's review API only takes an anchored comment, so the verdict is here, on the line the one substantive note is about. An earlier probe of mine left a stray comment reading p on world.ts:447 that the API gives me no way to delete — ignore it.)

What I checked at d61fe6d

  • tsc --noEmit && bun test on the head tree: types clean, 571 pass / 0 fail across 58 files. (The pull body says 544 — a stale count, not a problem.)
  • Drove the world myself rather than trusting the suite: buy an HE gives grenadeCount 1 and grenadeSlot "he", the second buy is refused, and 400 s of ticks through five rounds and half time leaves every player's pocket {} — so clearGrenades works on the one path that has no test of its own.
  • Turning a number into an object is the real risk in a diff like this, and it is clean: at the head, grep finds no call site left outside world.ts, hud.ts and their tests. bots.ts, observe.ts, report.ts, summary.ts and the server never touched it, observe.ts's 32 inputs are untouched so the shipped policy's numbers still mean what they meant, and no player object is shallow-copied anywhere, so one player's pocket can never alias another's.
  • throwGrenade reading p.weapon as the kind is safe: shoot only reaches it when currentWeapon(p).kind === "grenade", and takeGrenade returns false without writing when the count is 0. main.ts passing a null from grenadeSlot lands exactly where the old hardcoded "he" was refused by holds.
  • Security: nothing server-side is in this diff — no identity, no request body, no rate limit, no SQL, no escaping. I booted this exact head anyway: / answers 200 in 7 ms with the floor intact (CSP, X-Content-Type-Options, Referrer-Policy, HttpOnly SameSite=Lax session). No new dependency, no dead code, no TODO, and each new export names one concept.

Notes, none of them blocking

  1. This line. The issue's first box asks for "the total ceiling stated as a constant"; this makes MAX_GRENADES explicitly per-kind and adds no cap on the sum of kinds. Invisible with one kind, and it matches the pull's title — but it is a deliberate deviation from what was asked and the "Left out" paragraph does not mention it. Say it there, and add the total when #37 lands.
  2. "a second kind is refused too: MAX_GRENADES caps every kind, not just HE" only exercises HE — it sets { he: MAX_GRENADES } and proves nothing the test above it does not. There is no second kind to test yet, so name it for what it checks.
  3. hud.ts's multi-kind join cannot be reached by a test, since grenadeKinds filters GRENADE_IDS and a placeholder id never gets in. It gets its test with the first real kind — and check that line's width at 360px then, because it grows sideways.
  4. clearGrenades could be p.grenades = {}; iterating GRENADE_IDS only matters for a key addGrenade cannot write.

Preview: https://cs--142--forge-trevato-apps.open-platform.sh answers 404 not found from the edge on five tries over ten minutes — not the app's own 404. The forge's pictures at this head show the landing page rendering fine on phone and desktop, and a local boot of d61fe6d is healthy, so I read that as preview infrastructure rather than the change. Worth a glance once it lands.

433439
434 /** How many HE grenades fit in a pocket. One, the way CS counts them. */
440 /**
441 * How many of one kind fit in the pocket. One, the way CS counts them — a
442 * ceiling a second kind gets of its own, not one the pocket shares. There is
443 * deliberately no separate cap on the pocket's total: with one kind that is
444 * the same number as this one, and the choice of what a fuller pocket should
445 * cost is #37's to make when a second kind actually lands, not this issue's
446 * to guess at.
447 */
outdated

p

435448 export const MAX_GRENADES = 1;

Approved. All four notes from my last pass are answered, and the change holds up at f943369. (This forge's review API only takes an anchored comment, so the verdict rides here, on the line note 1 was about.)

The notes, closed

  1. The per-kind ceiling now says out loud that there is deliberately no total cap, and that #37 makes that call when a second kind lands — better placed in the code than in the pull body, where I asked for it.
  2. The redundant test is renamed to what it actually checks: the cap is read off the count, not off having bought before.
  3. The untestable HUD join carries its reason and the 360px warning for whoever adds the second kind.
  4. clearGrenades is p.grenades = {}.

What I checked at this head

  • tsc --noEmit && bun test on the branch tree: types clean, 618 pass / 0 fail across 61 files.
  • The branch is three commits behind main (#137 and #140 landed after its merge point), so I did the merge myself rather than trust mergeable: true: git merge-tree against 3162f4b gives a clean tree, and the full check on that tree is 649 pass / 0 fail, types clean. Nothing in #140's saved.ts reaches the pocket — SavedPlayer carries no grenades field — so the type change cannot touch the snapshot schema.
  • The branch's own diff against its merge base is exactly the six files listed and nothing else; merging main in smuggled nothing.
  • Drove the world again: buying gives grenadeCount 1 and grenadeSlot "he", the second buy is refused, and 400 s across five rounds and half time leaves every pocket {} — so the simplified clearGrenades still fires on the one path that has no test of its own.
  • The object-for-number swap is still clean: no p.grenades access survives outside world.ts's helpers; bots.ts, observe.ts, plan.ts, saved.ts and the server never touch it, observe.ts's 32 policy inputs are untouched, and no player object is shallow-copied anywhere, so one player's pocket cannot alias another's. throwGrenade reading p.weapon as the kind stays safe: shoot only reaches it when the held weapon is a grenade, and takeGrenade returns false without writing at zero.
  • Security: nothing server-side is in this diff — no identity, no request body, no rate limit, no SQL, no escaping. Booted the head anyway: / answers 200 in 15 ms with the floor intact (CSP, X-Content-Type-Options, Referrer-Policy, HttpOnly SameSite=Lax session). No new dependency, no dead code, no TODO.

Two small things, neither worth another round

  • The pull body still says "544 tests (4 new)", and its "Left out" paragraph still does not mention the absent total cap. The code says both correctly now; the description is just stale.
  • The preview at https://cs--142--forge-trevato-apps.open-platform.sh still answers 404 not found from the edge — that is not the app's own 404. The forge's pictures at this head render the landing page fine on phone and desktop (now with the Map select from #136), and a local boot of f943369 is healthy, so I read this as preview infrastructure rather than the change. Worth one look after it lands.

Merge it.

Approved, and nothing has changed that would move that. (The forge's review API only takes an anchored comment, so the verdict rides here on the per-kind ceiling.)

e611e80 is a merge of main and nothing else: the branch's diff against its merge base is byte-identical to the one I approved at f943369 — the same six files, the same 161/38 lines. #137 and #140 came in with the merge; the pocket is untouched by them.

Re-checked at this head anyway

  • tsc --noEmit && bun test on the head tree: types clean, 649 pass / 0 fail across 62 files.
  • The branch still trails main by one (#145 landed after its merge point), so I merged it myself rather than trust mergeable: true: git merge-tree against 83014ee gives a clean tree, and the full check on that tree is 664 pass / 0 fail, types clean. That is the number that matters for landing.
  • Behaviour probe again: buying gives grenadeCount 1 and grenadeSlot "he", the second buy is refused, and 400 s across five rounds and half time leaves every pocket {}.
  • Booted e611e80: / answers 200 in 15 ms with all three floor headers and the HttpOnly SameSite=Lax session. Nothing server-side is in the diff — no identity, no request body, no rate limit, no SQL, no escaping — and no new dependency.

Two notes for whoever merges, neither blocking

  • The phone picture still shows the leaderboard's wins column running off 360px. That is not this pull: #145 fixed it on main after this branch's merge base, so the fix arrives with the merge rather than being missing from it.
  • The preview at https://cs--142--forge-trevato-apps.open-platform.sh has answered 404 not found from the edge at every head I have reviewed, across three rounds and some fifteen attempts — that is not the app's own 404, and the forge's own pictures at each head render fine. A local boot of every head has been healthy, so I read it as preview infrastructure rather than the change; it is worth someone chasing separately, because a pull whose preview never answers cannot be tried by hand by anyone.

Merge it.

436449
450 /** Every weapon this game treats as a grenade, in the order weapons.ts lists
451 * them — the order `grenadeSlot` cycles in and a fresh pull starts at. */
452 const GRENADE_IDS: WeaponId[] = Object.values(WEAPONS)
453 .filter((w) => w.kind === "grenade")
454 .map((w) => w.id);
455
456 /** How many of `id` are in `p`'s pocket. Never undefined, never negative. */
457 export const grenadeCount = (p: Player, id: WeaponId): number => p.grenades[id] ?? 0;
458
459 /** Every kind actually sitting in the pocket right now, in `GRENADE_IDS` order. */
460 export const grenadeKinds = (p: Player): WeaponId[] =>
461 GRENADE_IDS.filter((id) => grenadeCount(p, id) > 0);
462
463 /** Is `id`'s slot in the pocket already full? The one ceiling `canBuyItem`
464 * and the buy panel's "already carrying one" row both answer to. */
465 export const grenadeFull = (p: Player, id: WeaponId): boolean => grenadeCount(p, id) >= MAX_GRENADES;
466
467 /** One more `id` in the pocket. The caller (`buy`) has already checked room. */
468 function addGrenade(p: Player, id: WeaponId): void {
469 p.grenades[id] = grenadeCount(p, id) + 1;
470 }
471
472 /** Take one `id` out of the pocket. False, and nothing changes, if there was none. */
473 function takeGrenade(p: Player, id: WeaponId): boolean {
474 const left = grenadeCount(p, id) - 1;
475 if (left < 0) return false;
476 if (left > 0) p.grenades[id] = left;
477 else delete p.grenades[id];
478 return true;
479 }
480
481 /** Empty the pocket entirely — a side switch at half time starts everyone over. */
482 function clearGrenades(p: Player): void {
483 p.grenades = {};
484 }
485
486 /**
487 * The rule slot 4 draws by, given what is actually carried: one kind in the
488 * pocket is unambiguous — the only case there has ever been, so with one kind
489 * carried this always answers with that kind, exactly as it did before there
490 * was a pocket to hold more than one. Two or more: `held` already being one
491 * of them cycles to the next in `carried`'s order, wrapping back to the
492 * first — press again to come back around. From anything else, a gun or an
493 * empty hand, it starts at the front. An empty `carried` has nothing to draw,
494 * so `applyInput` leaves the gun in your hands.
495 *
496 * Pure and separate from `grenadeKinds` so the cycle itself can be proven
497 * with any list of ids — this game only has one real kind to carry today.
498 */
499 export function nextGrenade(carried: WeaponId[], held: WeaponId): WeaponId | null {
500 if (carried.length === 0) return null;
501 const at = carried.indexOf(held);
502 return carried[at === -1 ? 0 : (at + 1) % carried.length] as WeaponId;
503 }
504
505 /** Which kind slot 4 draws for `p` right now — `nextGrenade` over what is actually in the pocket. */
506 export const grenadeSlot = (p: Player): WeaponId | null => nextGrenade(grenadeKinds(p), p.weapon);
507
437508 /**
438509 * Would `buy` take this? The buy panel asks so a button can be dead before it
439510 * is pressed, and `buy` asks so there is one list of reasons and not two.
@@ -448,7 +519,7 @@ export function canBuyItem(world: World, player: Player, item: BuyItem): boolean
448519 const weapon = WEAPONS[item];
449520 if (weapon === undefined || weapon.kind === "knife" || weapon.kind === "bomb") return false;
450521 if (weapon.team !== "both" && weapon.team !== player.team) return false;
451 if (weapon.kind === "grenade") return player.grenades < MAX_GRENADES;
522 if (weapon.kind === "grenade") return !grenadeFull(player, weapon.id);
452523 return true;
453524 }
454525
@@ -466,7 +537,7 @@ export function buy(world: World, player: Player, item: BuyItem): boolean {
466537 } else if (WEAPONS[item].kind === "grenade") {
467538 // Bought, not drawn: a grenade goes in a pocket and the gun stays in the
468539 // hands. Slot 4 is how it comes out, which is a choice, not a side effect.
469 player.grenades += 1;
540 addGrenade(player, WEAPONS[item].id);
470541 } else {
471542 if (WEAPONS[item].kind === "pistol") player.secondary = item;
472543 else player.primary = item;
@@ -782,12 +853,12 @@ const BURST_SPREAD = 70;
782853 * gun once the last one is gone — a slot with nothing in it is not a slot.
783854 */
784855 function throwGrenade(world: World, p: Player): boolean {
785 if (p.grenades <= 0) return false;
786 p.grenades -= 1;
856 const id = p.weapon;
857 if (!takeGrenade(p, id)) return false;
787858 p.nextShotAt = world.time + THROW_COOLDOWN;
788859 p.lastShotAt = world.time;
789860 world.grenades.push(thrown(p.id, p.team, eyePos(p), p.yaw, p.pitch, p.vel));
790 if (p.grenades <= 0) p.weapon = p.primary ?? p.secondary;
861 if (grenadeCount(p, id) === 0) p.weapon = p.primary ?? p.secondary;
791862 return true;
792863 }
793864
@@ -982,7 +1053,7 @@ function footsteps(world: World, p: Player, wish: Wish, before: Vec3): void {
9821053 /** Is `id` something `p` can put in their hands right now? */
9831054 const holds = (p: Player, id: WeaponId): boolean =>
9841055 id === "knife" || id === p.primary || id === p.secondary ||
985 (WEAPONS[id]?.kind === "grenade" && p.grenades > 0);
1056 (WEAPONS[id]?.kind === "grenade" && grenadeCount(p, id) > 0);
9861057
9871058 export function applyInput(world: World, p: Player, input: Input, dt: number): void {
9881059 if (!p.alive) return;
@@ -1089,7 +1160,7 @@ export function update(world: World, dt: number, input: Input): void {
10891160 p.armor = 0;
10901161 p.helmet = false;
10911162 p.defuseKit = false;
1092 p.grenades = 0;
1163 clearGrenades(p);
10931164 p.secondary = p.team === "t" ? "glock" : "usp";
10941165 p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) };
10951166 p.weapon = p.secondary;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs