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
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/130-a-pocket-that-holds-one-of-each-per-kind → main 5 commits fork 3 threads · 3 unresolved
src/client/buy.ts +2 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | 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"; | |
| 10 | 10 | |
| 11 | 11 | export type BuyState = "ok" | "owned" | "poor" | "side" | "closed"; |
| 12 | 12 | |
| @@ -37,7 +37,7 @@ function owned(player: Player, item: BuyItem): boolean { | ||
| 37 | 37 | if (item === "armor") return player.armor >= 100; |
| 38 | 38 | if (item === "helmet") return player.helmet; |
| 39 | 39 | 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); | |
| 41 | 41 | return player.primary === item || player.secondary === item; |
| 42 | 42 | } |
| 43 | 43 |
src/client/hud.test.ts +2 −2
| @@ -379,7 +379,7 @@ test("the radar sits in the bottom-left corner and labels both sites", () => { | ||
| 379 | 379 | |
| 380 | 380 | test("the HUD says what is in the pocket, and counts it where the ammo goes", () => { |
| 381 | 381 | const world = createWorld(SHORT, "me", "ct", 5, 97); |
| 382 | world.me.grenades = 1; | |
| 382 | world.me.grenades = { he: 1 }; | |
| 383 | 383 | const carrying = recorder(); |
| 384 | 384 | draw(carrying, world); |
| 385 | 385 | 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", () | ||
| 394 | 394 | expect(texts(held)).toContain("HE Grenade"); |
| 395 | 395 | |
| 396 | 396 | // With none, neither line is there to read. |
| 397 | world.me.grenades = 0; | |
| 397 | world.me.grenades = {}; | |
| 398 | 398 | world.me.weapon = world.me.secondary; |
| 399 | 399 | const without = recorder(); |
| 400 | 400 | draw(without, world); |
src/client/hud.ts +16 −5
| @@ -19,7 +19,10 @@ import type { Camera } from "./renderer.ts"; | ||
| 19 | 19 | import { drawReport } from "./reportPanel.ts"; |
| 20 | 20 | import { deathNotice } from "./spectate.ts"; |
| 21 | 21 | 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"; | |
| 23 | 26 | |
| 24 | 27 | const T_COLOR = "#e2b04a"; |
| 25 | 28 | const CT_COLOR = "#6fa8e8"; |
| @@ -431,10 +434,18 @@ export function drawHud( | ||
| 431 | 434 | carried += Math.round(20 * scale); |
| 432 | 435 | } |
| 433 | 436 | // 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) { | |
| 436 | 444 | 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); | |
| 438 | 449 | } |
| 439 | 450 | |
| 440 | 451 | // --- ammo and weapon |
| @@ -444,7 +455,7 @@ export function drawHud( | ||
| 444 | 455 | const ammo = view.ammo[weapon.id]; |
| 445 | 456 | // A grenade counts what is left in the pocket where a gun counts its magazine. |
| 446 | 457 | const ammoText = weapon.kind === "grenade" |
| 447 | ? `×${view.grenades}` | |
| 458 | ? `×${grenadeCount(view, weapon.id)}` | |
| 448 | 459 | : weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`; |
| 449 | 460 | ctx.fillText(ammoText, width - pad, height - pad); |
| 450 | 461 | ctx.font = small; |
src/client/main.ts +6 −4
| @@ -30,8 +30,8 @@ import { CALL_LIFE, isCall, type Call } from "./plan.ts"; | ||
| 30 | 30 | import { nextWatched, watched } from "./spectate.ts"; |
| 31 | 31 | import { summarize, type MatchSummary } from "./summary.ts"; |
| 32 | 32 | 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, | |
| 35 | 35 | } from "./world.ts"; |
| 36 | 36 | |
| 37 | 37 | /** |
| @@ -721,9 +721,11 @@ function boot(): void { | ||
| 721 | 721 | me.pitch = clamp(me.pitch + dy, -89, 89); |
| 722 | 722 | } |
| 723 | 723 | // 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. | |
| 725 | 727 | const switchTo = |
| 726 | controls.slotWanted === 4 ? "he" : | |
| 728 | controls.slotWanted === 4 ? grenadeSlot(me) : | |
| 727 | 729 | controls.slotWanted === 3 ? "knife" : |
| 728 | 730 | controls.slotWanted === 2 ? me.secondary : |
| 729 | 731 | controls.slotWanted === 1 ? me.primary : null; |
src/client/world.test.ts +54 −15
| @@ -5,11 +5,11 @@ import { dist } from "./math.ts"; | ||
| 5 | 5 | import { DE_FORGE, siteAt } from "./map.ts"; |
| 6 | 6 | import { SHORT, winReward } from "./match.ts"; |
| 7 | 7 | import { GAIT_CYCLE, STEP_STRIDE } from "./physics.ts"; |
| 8 | import { WEAPONS } from "./weapons.ts"; | |
| 8 | import { WEAPONS, type WeaponId } from "./weapons.ts"; | |
| 9 | 9 | import { |
| 10 | 10 | 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, | |
| 13 | 13 | type Input, type Player, type World, |
| 14 | 14 | } from "./world.ts"; |
| 15 | 15 | |
| @@ -653,7 +653,7 @@ function armWithGrenade(world: World): void { | ||
| 653 | 653 | world.me.vel = [0, 0, 0]; |
| 654 | 654 | world.me.yaw = 0; |
| 655 | 655 | world.me.pitch = 0; |
| 656 | world.me.grenades = 1; | |
| 656 | world.me.grenades = { he: 1 }; | |
| 657 | 657 | world.me.nextShotAt = 0; |
| 658 | 658 | world.effects.length = 0; |
| 659 | 659 | } |
| @@ -679,34 +679,42 @@ test("an HE costs $300, only in the freeze, and only one fits in a pocket", () = | ||
| 679 | 679 | const world = createWorld(SHORT, "me", "ct", 5, 211); |
| 680 | 680 | world.me.money = 1000; |
| 681 | 681 | expect(buy(world, world.me, "he")).toBe(true); |
| 682 | expect(world.me.grenades).toBe(1); | |
| 682 | expect(grenadeCount(world.me, "he")).toBe(1); | |
| 683 | 683 | expect(world.me.money).toBe(700); |
| 684 | 684 | expect(world.me.weapon).not.toBe("he"); // bought into a pocket, not into your hands |
| 685 | 685 | 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); | |
| 687 | 687 | expect(world.me.money).toBe(700); |
| 688 | 688 | |
| 689 | 689 | run(world, SHORT.freezeTime + 0.5); |
| 690 | world.me.grenades = 0; | |
| 690 | world.me.grenades = {}; | |
| 691 | 691 | 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); | |
| 693 | 701 | }); |
| 694 | 702 | |
| 695 | 703 | test("slot 4 needs a grenade in the pocket, and the throw sends one on its way", () => { |
| 696 | 704 | const world = createWorld(SHORT, "me", "ct", 5, 223); |
| 697 | 705 | armWithGrenade(world); |
| 698 | world.me.grenades = 0; | |
| 706 | world.me.grenades = {}; | |
| 699 | 707 | applyInput(world, world.me, { ...NO_INPUT, switchTo: "he" }, TICK); |
| 700 | 708 | expect(world.me.weapon).not.toBe("he"); // nothing to hold |
| 701 | 709 | |
| 702 | world.me.grenades = 1; | |
| 710 | world.me.grenades = { he: 1 }; | |
| 703 | 711 | applyInput(world, world.me, { ...NO_INPUT, switchTo: "he" }, TICK); |
| 704 | 712 | expect(world.me.weapon).toBe("he"); |
| 705 | 713 | expect(currentWeapon(world.me).name).toBe("HE Grenade"); |
| 706 | 714 | |
| 707 | 715 | applyInput(world, world.me, { ...NO_INPUT, switchTo: "he", fire: true }, TICK); |
| 708 | 716 | expect(world.grenades.length).toBe(1); |
| 709 | expect(world.me.grenades).toBe(0); | |
| 717 | expect(grenadeCount(world.me, "he")).toBe(0); | |
| 710 | 718 | expect(world.me.weapon).toBe(world.me.secondary); // the hand goes back to the gun |
| 711 | 719 | const thrown = world.grenades[0]; |
| 712 | 720 | 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", | ||
| 714 | 722 | expect(thrown.vel[2]).toBeGreaterThan(0); // and up, so it arcs |
| 715 | 723 | }); |
| 716 | 724 | |
| 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 | ||
| 717 | 756 | test("the fuse goes off once, and the grenade is gone with it", () => { |
| 718 | 757 | const world = createWorld(SHORT, "me", "ct", 5, 227); |
| 719 | 758 | armWithGrenade(world); |
| @@ -803,11 +842,11 @@ test("a grenade thrown is gone at the next round; one kept is still yours", () = | ||
| 803 | 842 | expect(world.grenades.length).toBe(1); |
| 804 | 843 | startRound(world); |
| 805 | 844 | expect(world.grenades.length).toBe(0); |
| 806 | expect(world.me.grenades).toBe(0); | |
| 845 | expect(grenadeCount(world.me, "he")).toBe(0); | |
| 807 | 846 | |
| 808 | world.me.grenades = 1; | |
| 847 | world.me.grenades = { he: 1 }; | |
| 809 | 848 | startRound(world); |
| 810 | expect(world.me.grenades).toBe(1); | |
| 849 | expect(grenadeCount(world.me, "he")).toBe(1); | |
| 811 | 850 | expect(world.me.weapon).not.toBe("he"); // a round starts with the gun up |
| 812 | 851 | }); |
| 813 | 852 | |
| @@ -817,7 +856,7 @@ test("a player killed holding a grenade leaves their gun, never the grenade", () | ||
| 817 | 856 | faceOff(world, shooter); |
| 818 | 857 | world.me.primary = "m4a4"; |
| 819 | 858 | world.me.ammo.m4a4 = { mag: 12, reserve: 30 }; |
| 820 | world.me.grenades = 1; | |
| 859 | world.me.grenades = { he: 1 }; | |
| 821 | 860 | world.me.weapon = "he"; |
| 822 | 861 | world.me.health = 1; |
| 823 | 862 | world.me.armor = 0; |
src/client/world.ts +81 −10
| @@ -43,7 +43,13 @@ export type Player = Actor & Body & { | ||
| 43 | 43 | primary: WeaponId | null; |
| 44 | 44 | secondary: WeaponId; |
| 45 | 45 | 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>>; | |
| 47 | 53 | defuseKit: boolean; |
| 48 | 54 | hasBomb: boolean; |
| 49 | 55 | nextShotAt: number; |
| @@ -232,7 +238,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player | ||
| 232 | 238 | money: 800, |
| 233 | 239 | weapon: secondary, primary: null, secondary, |
| 234 | 240 | ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) }, |
| 235 | grenades: 0, | |
| 241 | grenades: {}, | |
| 236 | 242 | defuseKit: false, hasBomb: false, |
| 237 | 243 | nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false, firedThisPress: false, emptyThisPress: false, |
| 238 | 244 | punch: [0, 0], nextPickupAt: 0, |
| @@ -431,9 +437,74 @@ export function priceOf(item: BuyItem, player: Player): number { | ||
| 431 | 437 | export const canBuy = (world: World, player: Player): boolean => |
| 432 | 438 | world.match.phase === "freeze" && player.alive; |
| 433 | 439 | |
| 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 |
||
| 435 | 448 | export const MAX_GRENADES = 1; |
Approved. All four notes from my last pass are answered, and the change holds up at The notes, closed
What I checked at this head
Two small things, neither worth another round
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.)
Re-checked at this head anyway
Two notes for whoever merges, neither blocking
Merge it. |
||
| 436 | 449 | |
| 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 | ||
| 437 | 508 | /** |
| 438 | 509 | * Would `buy` take this? The buy panel asks so a button can be dead before it |
| 439 | 510 | * 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 | ||
| 448 | 519 | const weapon = WEAPONS[item]; |
| 449 | 520 | if (weapon === undefined || weapon.kind === "knife" || weapon.kind === "bomb") return false; |
| 450 | 521 | 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); | |
| 452 | 523 | return true; |
| 453 | 524 | } |
| 454 | 525 | |
| @@ -466,7 +537,7 @@ export function buy(world: World, player: Player, item: BuyItem): boolean { | ||
| 466 | 537 | } else if (WEAPONS[item].kind === "grenade") { |
| 467 | 538 | // Bought, not drawn: a grenade goes in a pocket and the gun stays in the |
| 468 | 539 | // 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); | |
| 470 | 541 | } else { |
| 471 | 542 | if (WEAPONS[item].kind === "pistol") player.secondary = item; |
| 472 | 543 | else player.primary = item; |
| @@ -782,12 +853,12 @@ const BURST_SPREAD = 70; | ||
| 782 | 853 | * gun once the last one is gone — a slot with nothing in it is not a slot. |
| 783 | 854 | */ |
| 784 | 855 | 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; | |
| 787 | 858 | p.nextShotAt = world.time + THROW_COOLDOWN; |
| 788 | 859 | p.lastShotAt = world.time; |
| 789 | 860 | 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; | |
| 791 | 862 | return true; |
| 792 | 863 | } |
| 793 | 864 | |
| @@ -982,7 +1053,7 @@ function footsteps(world: World, p: Player, wish: Wish, before: Vec3): void { | ||
| 982 | 1053 | /** Is `id` something `p` can put in their hands right now? */ |
| 983 | 1054 | const holds = (p: Player, id: WeaponId): boolean => |
| 984 | 1055 | 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); | |
| 986 | 1057 | |
| 987 | 1058 | export function applyInput(world: World, p: Player, input: Input, dt: number): void { |
| 988 | 1059 | if (!p.alive) return; |
| @@ -1089,7 +1160,7 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 1089 | 1160 | p.armor = 0; |
| 1090 | 1161 | p.helmet = false; |
| 1091 | 1162 | p.defuseKit = false; |
| 1092 | p.grenades = 0; | |
| 1163 | clearGrenades(p); | |
| 1093 | 1164 | p.secondary = p.team === "t" ? "glock" : "usp"; |
| 1094 | 1165 | p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) }; |
| 1095 | 1166 | p.weapon = p.secondary; |
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
pon world.ts:447 that the API gives me no way to delete — ignore it.)What I checked at d61fe6d
tsc --noEmit && bun teston the head tree: types clean, 571 pass / 0 fail across 58 files. (The pull body says 544 — a stale count, not a problem.)grenadeCount1 andgrenadeSlot"he", the second buy is refused, and 400 s of ticks through five rounds and half time leaves every player's pocket{}— soclearGrenadesworks on the one path that has no test of its own.grepfinds no call site left outsideworld.ts,hud.tsand their tests.bots.ts,observe.ts,report.ts,summary.tsand 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.throwGrenadereadingp.weaponas the kind is safe:shootonly reaches it whencurrentWeapon(p).kind === "grenade", andtakeGrenadereturns false without writing when the count is 0.main.tspassing anullfromgrenadeSlotlands exactly where the old hardcoded"he"was refused byholds./answers 200 in 7 ms with the floor intact (CSP,X-Content-Type-Options,Referrer-Policy, HttpOnlySameSite=Laxsession). No new dependency, no dead code, no TODO, and each new export names one concept.Notes, none of them blocking
MAX_GRENADESexplicitly 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."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.hud.ts's multi-kind join cannot be reached by a test, sincegrenadeKindsfiltersGRENADE_IDSand 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.clearGrenadescould bep.grenades = {}; iteratingGRENADE_IDSonly matters for a keyaddGrenadecannot write.Preview: https://cs--142--forge-trevato-apps.open-platform.sh answers
404 not foundfrom 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.