Split The code is public; the app answers only to the crew.
Choose how hard the bots are, before the match and during it #64
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/38-choose-how-hard-the-bots-are-before-the → main 2 commits fork
9 files
changed +233
−24
README.md +16 −2
| @@ -127,6 +127,16 @@ the scoreboard, <kbd>Esc</kbd> pauses the match on a desktop and `menu` on the | ||
| 127 | 127 | pad does it on a phone. Pausing also opens mute, volume and |
| 128 | 128 | look sensitivity, which take effect at once and are remembered for next time. |
| 129 | 129 | |
| 130 | The menu's **Difficulty** select picks Easy, Normal or Hard before a match | |
| 131 | starts — each one a band of the bots' skill rather than one number, so a | |
| 132 | side still runs from fodder to uncomfortable at every setting, just a | |
| 133 | different stretch of it. It is remembered for next time, same as the other | |
| 134 | settings, and the scoreboard (<kbd>Tab</kbd>) says which one a match is being | |
| 135 | played at. The same select is in the pause overlay for a match already | |
| 136 | running — a difficulty picked there takes effect from the next round, never | |
| 137 | the one in progress, so changing it mid-fight cannot hand a bot new hands | |
| 138 | between one shot and the next. | |
| 139 | ||
| 130 | 140 | Paused means paused. While the pause overlay or the match-over card is up the |
| 131 | 141 | world does not run: no ticks, no round clock, no bots, no bomb timer, and the |
| 132 | 142 | canvas keeps the last frame it drew. A hidden tab pauses the same way, so |
| @@ -206,7 +216,8 @@ with the canvas as you turn; nobody re-walks the grid to draw it. | ||
| 206 | 216 | this round, where they fell), `radar` (the blips the |
| 207 | 217 | HUD's minimap draws, and nothing it draws them with), `summary` (the end of |
| 208 | 218 | the match as numbers), `ears` (a sound's pan and volume, as plain data), |
| 209 | `settings` (mute, volume and sensitivity: read, validated, and never NaN), | |
| 219 | `settings` (mute, volume, sensitivity and difficulty: read, validated, and | |
| 220 | never NaN or an unknown name), | |
| 210 | 221 | `renderer`, `hud`, `input`, `audio`, `main`. |
| 211 | 222 | Everything except the last five is headless and tested; `renderer` and `hud` |
| 212 | 223 | are checked against recording contexts, which is how a frame's draws are |
| @@ -318,7 +329,10 @@ rather than 37%. | ||
| 318 | 329 | Skill is still a handicap laid on top, so the ladder the game already had |
| 319 | 330 | survives: a low-skill bot gets the same intent from the policy but cannot turn |
| 320 | 331 | as fast, has a wobble on its aim it cannot see, and waits longer before it |
| 321 | shoots. `src/client/bots.ts` keeps the pre-policy fight as `scriptedFight`, | |
| 332 | shoots. The **Difficulty** setting picks which band of that 0..1 skill scale a | |
| 333 | match's bots are drawn from — Easy, Normal or Hard — so the ladder from fodder | |
| 334 | to uncomfortable still runs inside whichever one is chosen; see *Playing* for | |
| 335 | where to set it. `src/client/bots.ts` keeps the pre-policy fight as `scriptedFight`, | |
| 322 | 336 | unused by the game, because it is the opponent the policy trains against and the |
| 323 | 337 | baseline it is measured against — a baseline that moves with the policy measures |
| 324 | 338 | nothing. |
src/client/bots.test.ts +67 −2
| @@ -1,8 +1,12 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { applyIntent, botThink, CARD, makeBrain, policy, think } from "./bots.ts"; | |
| 2 | import { | |
| 3 | applyIntent, botThink, CARD, DIFFICULTIES, makeBrain, policy, restyleBrain, skillBand, think, | |
| 4 | } from "./bots.ts"; | |
| 3 | 5 | import * as map from "./map.ts"; |
| 4 | 6 | import { COMPETITIVE, SHORT } from "./match.ts"; |
| 5 | import { aimAt, bodyCentre, createWorld, NO_INPUT, update, type Player } from "./world.ts"; | |
| 7 | import { | |
| 8 | aimAt, bodyCentre, createWorld, NO_INPUT, setDifficulty, startRound, update, type Player, | |
| 9 | } from "./world.ts"; | |
| 6 | 10 | |
| 7 | 11 | const duel = (seed = 2024): { world: ReturnType<typeof createWorld>; me: Player; bot: Player } => { |
| 8 | 12 | const world = createWorld(COMPETITIVE, "you", "ct", 1, seed); |
| @@ -81,6 +85,67 @@ test("a bot that has only just seen you does not shoot yet", () => { | ||
| 81 | 85 | expect(immediate.fire).toBe(false); |
| 82 | 86 | }); |
| 83 | 87 | |
| 88 | // --- difficulty ----------------------------------------------------------- | |
| 89 | ||
| 90 | test("easy hands are strictly slower and shakier than hard's", () => { | |
| 91 | const easy = createWorld(SHORT, "you", "ct", 5, 999, "easy"); | |
| 92 | const hard = createWorld(SHORT, "you", "ct", 5, 999, "hard"); | |
| 93 | for (const easyBot of easy.players) { | |
| 94 | if (!easyBot.bot) continue; | |
| 95 | const hardBot = hard.players.find((p) => p.id === easyBot.id)!; | |
| 96 | const easyBrain = easyBot.brain!; | |
| 97 | const hardBrain = hardBot.brain!; | |
| 98 | expect(easyBrain.aimSpeed).toBeLessThan(hardBrain.aimSpeed); | |
| 99 | expect(easyBrain.aimError).toBeGreaterThan(hardBrain.aimError); | |
| 100 | expect(easyBrain.reaction).toBeGreaterThan(hardBrain.reaction); | |
| 101 | } | |
| 102 | }); | |
| 103 | ||
| 104 | test("createWorld gives every bot a skill inside its difficulty's band", () => { | |
| 105 | for (const difficulty of DIFFICULTIES) { | |
| 106 | const { lo, hi } = skillBand(difficulty); | |
| 107 | const world = createWorld(SHORT, "you", "ct", 5, 555, difficulty); | |
| 108 | const bots = world.players.filter((p) => p.bot); | |
| 109 | expect(bots.length).toBeGreaterThan(0); | |
| 110 | for (const bot of bots) { | |
| 111 | expect(bot.brain).toBeDefined(); | |
| 112 | expect(bot.brain!.skill).toBeGreaterThanOrEqual(lo); | |
| 113 | expect(bot.brain!.skill).toBeLessThanOrEqual(hi); | |
| 114 | } | |
| 115 | } | |
| 116 | }); | |
| 117 | ||
| 118 | test("a difficulty change waits for the next startRound", () => { | |
| 119 | const world = createWorld(SHORT, "you", "ct", 5, 777, "normal"); | |
| 120 | const bot = world.players.find((p) => p.bot)!; | |
| 121 | const before = bot.brain!.skill; | |
| 122 | setDifficulty(world, "hard"); | |
| 123 | // Queued, not applied: the round already running keeps the hands it started with. | |
| 124 | expect(world.difficulty).toBe("normal"); | |
| 125 | expect(bot.brain!.skill).toBe(before); | |
| 126 | startRound(world); | |
| 127 | expect(world.difficulty).toBe("hard"); | |
| 128 | const { lo, hi } = skillBand("hard"); | |
| 129 | expect(bot.brain!.skill).toBeGreaterThanOrEqual(lo); | |
| 130 | expect(bot.brain!.skill).toBeLessThanOrEqual(hi); | |
| 131 | }); | |
| 132 | ||
| 133 | test("restyleBrain changes the hands but keeps the plan it already made", () => { | |
| 134 | const brain = makeBrain(0.9, 5); | |
| 135 | brain.path = [[100, 200, 0]]; | |
| 136 | brain.step = 1; | |
| 137 | brain.site = "B"; | |
| 138 | brain.targetId = 4; | |
| 139 | restyleBrain(brain, 3, "easy"); | |
| 140 | const { lo, hi } = skillBand("easy"); | |
| 141 | expect(brain.skill).toBeGreaterThanOrEqual(lo); | |
| 142 | expect(brain.skill).toBeLessThanOrEqual(hi); | |
| 143 | expect(brain.path).toEqual([[100, 200, 0]]); | |
| 144 | expect(brain.step).toBe(1); | |
| 145 | expect(brain.site).toBe("B"); | |
| 146 | expect(brain.targetId).toBe(4); | |
| 147 | }); | |
| 148 | ||
| 84 | 149 | test("the card describes a real training run", () => { |
| 85 | 150 | expect(CARD.envSteps).toBeGreaterThan(1_000_000); |
| 86 | 151 | expect(CARD.updates).toBeGreaterThan(0); |
src/client/bots.ts +42 −2
| @@ -36,6 +36,33 @@ export function policy(): Policy { | ||
| 36 | 36 | /** The policy decides at 16 Hz and holds it, exactly as it was trained. */ |
| 37 | 37 | const SKIP = 4; |
| 38 | 38 | |
| 39 | /** | |
| 40 | * How hard the opposition is. It is a band of skills, not one number — a side | |
| 41 | * still runs from fodder to uncomfortable at every setting, just a different | |
| 42 | * stretch of the same 0..1 scale `makeBrain` turns into hands. | |
| 43 | */ | |
| 44 | export type Difficulty = "easy" | "normal" | "hard"; | |
| 45 | export const DIFFICULTIES: readonly Difficulty[] = ["easy", "normal", "hard"]; | |
| 46 | export const DEFAULT_DIFFICULTY: Difficulty = "normal"; | |
| 47 | const DIFFICULTY_LABELS: Record<Difficulty, string> = { easy: "Easy", normal: "Normal", hard: "Hard" }; | |
| 48 | export const difficultyLabel = (difficulty: Difficulty): string => DIFFICULTY_LABELS[difficulty]; | |
| 49 | ||
| 50 | const BANDS: Record<Difficulty, { lo: number; hi: number }> = { | |
| 51 | easy: { lo: 0, hi: 0.35 }, | |
| 52 | normal: { lo: 0.25, hi: 1 }, | |
| 53 | hard: { lo: 0.55, hi: 1 }, | |
| 54 | }; | |
| 55 | ||
| 56 | /** A bot's place in its band, from its id — the same spread `brainOf` always used. */ | |
| 57 | function skillFor(id: number, difficulty: Difficulty): number { | |
| 58 | const { lo, hi } = BANDS[difficulty]; | |
| 59 | const frac = ((id * 37) % 100) / 100; | |
| 60 | return clamp(lo + frac * (hi - lo), 0, 1); | |
| 61 | } | |
| 62 | ||
| 63 | /** The band itself, for anything — a test, a hint on the page — that needs its edges. */ | |
| 64 | export const skillBand = (difficulty: Difficulty): { lo: number; hi: number } => BANDS[difficulty]; | |
| 65 | ||
| 39 | 66 | export type BotBrain = { |
| 40 | 67 | skill: number; // 0 = fodder, 1 = uncomfortable |
| 41 | 68 | reaction: number; // seconds between seeing and shooting |
| @@ -110,12 +137,25 @@ export function botBuy(world: World, bot: Player): void { | ||
| 110 | 137 | |
| 111 | 138 | function brainOf(world: World, bot: Player): BotBrain { |
| 112 | 139 | if (bot.brain === undefined) { |
| 113 | const skill = clamp(0.25 + ((bot.id * 37) % 100) / 130, 0, 1); | |
| 114 | bot.brain = makeBrain(skill, 0x1234 + bot.id * 2654435761); | |
| 140 | bot.brain = makeBrain(skillFor(bot.id, world.difficulty), 0x1234 + bot.id * 2654435761); | |
| 115 | 141 | } |
| 116 | 142 | return bot.brain; |
| 117 | 143 | } |
| 118 | 144 | |
| 145 | /** | |
| 146 | * Re-skill a brain for a new difficulty in place: the hands change, but the | |
| 147 | * path, the plan and the memory it already built up this match do not. Called | |
| 148 | * once a round, from `startRound`, so a change queued mid-fight cannot hand a | |
| 149 | * bot new hands between one shot and the next. | |
| 150 | */ | |
| 151 | export function restyleBrain(brain: BotBrain, id: number, difficulty: Difficulty): void { | |
| 152 | const skill = skillFor(id, difficulty); | |
| 153 | brain.skill = skill; | |
| 154 | brain.reaction = 0.5 - skill * 0.33; | |
| 155 | brain.aimSpeed = 180 + skill * 620; | |
| 156 | brain.aimError = 4.5 - skill * 3.6; | |
| 157 | } | |
| 158 | ||
| 119 | 159 | /** This bot's place in its side's list, so the plan can hand out two sites. */ |
| 120 | 160 | function slotOf(world: World, bot: Player): number { |
| 121 | 161 | let slot = 0; |
src/client/hud.ts +10 −4
| @@ -451,21 +451,27 @@ function drawScoreboard( | ||
| 451 | 451 | ): void { |
| 452 | 452 | const w = Math.min(width - 40, Math.round(760 * scale)); |
| 453 | 453 | const rowH = Math.round(26 * scale); |
| 454 | const h = rowH * (world.players.length + 4); | |
| 454 | const h = rowH * (world.players.length + 5); | |
| 455 | 455 | const x = width / 2 - w / 2, y = height / 2 - h / 2; |
| 456 | 456 | ctx.fillStyle = "rgba(10, 12, 16, 0.88)"; |
| 457 | 457 | ctx.fillRect(x, y, w, h); |
| 458 | 458 | ctx.font = `${Math.round(15 * scale)}px system-ui, sans-serif`; |
| 459 | // Which band the fight is being played at, so a screenshot of the scoreline | |
| 460 | // still means something once the round it was taken in is long over. | |
| 461 | ctx.textAlign = "center"; | |
| 462 | ctx.fillStyle = "#9aa4b4"; | |
| 463 | const difficulty = world.difficulty.charAt(0).toUpperCase() + world.difficulty.slice(1); | |
| 464 | ctx.fillText(`Difficulty: ${difficulty}`, x + w / 2, y + rowH); | |
| 459 | 465 | ctx.textAlign = "left"; |
| 460 | 466 | ctx.fillStyle = "#c8ccd4"; |
| 461 | ctx.fillText("player", x + 16, y + rowH); | |
| 467 | ctx.fillText("player", x + 16, y + rowH * 2); | |
| 462 | 468 | ctx.textAlign = "right"; |
| 463 | 469 | for (const [i, label] of ["kills", "deaths", "hs", "damage", "money"].entries()) |
| 464 | ctx.fillText(label, x + w - 16 - i * Math.round(90 * scale), y + rowH); | |
| 470 | ctx.fillText(label, x + w - 16 - i * Math.round(90 * scale), y + rowH * 2); | |
| 465 | 471 | |
| 466 | 472 | const order = [...world.players].sort((a, b) => b.kills - a.kills || b.damage - a.damage); |
| 467 | 473 | order.forEach((p, row) => { |
| 468 | const ty = y + rowH * (row + 2.4); | |
| 474 | const ty = y + rowH * (row + 3.4); | |
| 469 | 475 | ctx.textAlign = "left"; |
| 470 | 476 | ctx.fillStyle = p === world.me ? "#ffffff" : p.team === "t" ? T_COLOR : CT_COLOR; |
| 471 | 477 | ctx.globalAlpha = p.alive ? 1 : 0.45; |
src/client/main.ts +22 −4
| @@ -22,8 +22,8 @@ import { nextWatched, watched } from "./spectate.ts"; | ||
| 22 | 22 | import { summarize, type MatchSummary } from "./summary.ts"; |
| 23 | 23 | import { buyable } from "./weapons.ts"; |
| 24 | 24 | import { |
| 25 | buy, canBuy, canBuyItem, createWorld, eyePos, flinch, priceOf, update, | |
| 26 | type BuyItem, type Player, type SoundName, type World, | |
| 25 | buy, canBuy, canBuyItem, createWorld, DIFFICULTIES, eyePos, flinch, priceOf, setDifficulty, update, | |
| 26 | type BuyItem, type Difficulty, type Player, type SoundName, type World, | |
| 27 | 27 | } from "./world.ts"; |
| 28 | 28 | |
| 29 | 29 | /** |
| @@ -50,6 +50,8 @@ function boot(): void { | ||
| 50 | 50 | const nameField = $<HTMLInputElement>("name"); |
| 51 | 51 | const lengthField = $<HTMLSelectElement>("length"); |
| 52 | 52 | const sideField = $<HTMLSelectElement>("side"); |
| 53 | const difficultyField = $<HTMLSelectElement>("difficulty"); | |
| 54 | const pauseDifficultyField = $<HTMLSelectElement>("pause-difficulty"); | |
| 53 | 55 | const sensField = $<HTMLInputElement>("sens"); |
| 54 | 56 | const pauseSensField = $<HTMLInputElement>("pause-sens"); |
| 55 | 57 | const volumeField = $<HTMLInputElement>("volume"); |
| @@ -84,6 +86,18 @@ function boot(): void { | ||
| 84 | 86 | pauseSensField.value = String(next); |
| 85 | 87 | saveSettings(settings); |
| 86 | 88 | }; |
| 89 | // Same pairing as sensitivity: the menu's field picks it before a match, the | |
| 90 | // pause overlay's changes it mid-match — but `setDifficulty` only queues the | |
| 91 | // change for the next round, so a bot already in a fight keeps its hands. | |
| 92 | const syncDifficulty = (value: string): void => { | |
| 93 | const next: Difficulty = (DIFFICULTIES as readonly string[]).includes(value) | |
| 94 | ? (value as Difficulty) : DEFAULT_SETTINGS.difficulty; | |
| 95 | settings = { ...settings, difficulty: next }; | |
| 96 | difficultyField.value = next; | |
| 97 | pauseDifficultyField.value = next; | |
| 98 | saveSettings(settings); | |
| 99 | if (world !== null) setDifficulty(world, next); | |
| 100 | }; | |
| 87 | 101 | const updateMuteButton = (): void => { |
| 88 | 102 | muteButton.textContent = settings.muted ? "Unmute" : "Mute"; |
| 89 | 103 | muteButton.setAttribute("aria-pressed", String(settings.muted)); |
| @@ -91,12 +105,16 @@ function boot(): void { | ||
| 91 | 105 | sensField.value = String(settings.sensitivity); |
| 92 | 106 | pauseSensField.value = String(settings.sensitivity); |
| 93 | 107 | volumeField.value = String(Math.round(settings.volume * 100)); |
| 108 | difficultyField.value = settings.difficulty; | |
| 109 | pauseDifficultyField.value = settings.difficulty; | |
| 94 | 110 | updateMuteButton(); |
| 95 | 111 | |
| 96 | 112 | // "change" rather than "input": a number field mid-edit (cleared, or "0.") |
| 97 | 113 | // is not a value to clamp and snap back on every keystroke. |
| 98 | 114 | sensField.addEventListener("change", () => syncSensitivity(Number(sensField.value))); |
| 99 | 115 | pauseSensField.addEventListener("change", () => syncSensitivity(Number(pauseSensField.value))); |
| 116 | difficultyField.addEventListener("change", () => syncDifficulty(difficultyField.value)); | |
| 117 | pauseDifficultyField.addEventListener("change", () => syncDifficulty(pauseDifficultyField.value)); | |
| 100 | 118 | volumeField.addEventListener("input", () => { |
| 101 | 119 | const next = Math.min(1, Math.max(0, Number(volumeField.value) / 100)); |
| 102 | 120 | settings = { ...settings, volume: Number.isFinite(next) ? next : settings.volume }; |
| @@ -111,7 +129,7 @@ function boot(): void { | ||
| 111 | 129 | }); |
| 112 | 130 | // The pause dialog sits over a live match: a key typed into one of its |
| 113 | 131 | // fields must never reach the window-level handler that drives the game. |
| 114 | for (const field of [pauseSensField, volumeField, muteButton, fullscreenPauseButton]) | |
| 132 | for (const field of [pauseSensField, volumeField, muteButton, pauseDifficultyField, fullscreenPauseButton]) | |
| 115 | 133 | field.addEventListener("keydown", (event) => event.stopPropagation()); |
| 116 | 134 | |
| 117 | 135 | let world: World | null = null; |
| @@ -283,7 +301,7 @@ function boot(): void { | ||
| 283 | 301 | const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT; |
| 284 | 302 | const team = sideField.value === "t" ? "t" : "ct"; |
| 285 | 303 | const name = nameField.value.trim().slice(0, 24) || "you"; |
| 286 | world = createWorld(rules, name, team, 5); | |
| 304 | world = createWorld(rules, name, team, 5, undefined, settings.difficulty); | |
| 287 | 305 | matchId = crypto.randomUUID(); |
| 288 | 306 | posted = -1; |
| 289 | 307 | watching = world.me.id; |
src/client/settings.test.ts +23 −3
| @@ -24,10 +24,10 @@ test("a JSON value that isn't an object is the default", () => { | ||
| 24 | 24 | |
| 25 | 25 | test("out-of-range numbers are clamped, not rejected", () => { |
| 26 | 26 | expect(parseSettings('{"volume":5,"sensitivity":9999}')).toEqual({ |
| 27 | muted: false, volume: 1, sensitivity: 12, | |
| 27 | muted: false, volume: 1, sensitivity: 12, difficulty: "normal", | |
| 28 | 28 | }); |
| 29 | 29 | expect(parseSettings('{"volume":-3,"sensitivity":-1}')).toEqual({ |
| 30 | muted: false, volume: 0, sensitivity: 0.2, | |
| 30 | muted: false, volume: 0, sensitivity: 0.2, difficulty: "normal", | |
| 31 | 31 | }); |
| 32 | 32 | }); |
| 33 | 33 | |
| @@ -43,6 +43,26 @@ test("muted is only ever true for exactly true", () => { | ||
| 43 | 43 | }); |
| 44 | 44 | |
| 45 | 45 | test("a whole, valid settings object round-trips", () => { |
| 46 | const stored = { muted: true, volume: 0.4, sensitivity: 3.5 }; | |
| 46 | const stored = { muted: true, volume: 0.4, sensitivity: 3.5, difficulty: "hard" }; | |
| 47 | 47 | expect(parseSettings(JSON.stringify(stored))).toEqual(stored); |
| 48 | 48 | }); |
| 49 | ||
| 50 | test("a missing difficulty is the default", () => { | |
| 51 | expect(parseSettings('{"muted":true}').difficulty).toBe("normal"); | |
| 52 | }); | |
| 53 | ||
| 54 | test("a garbage difficulty is the default", () => { | |
| 55 | expect(parseSettings('{"difficulty":123}').difficulty).toBe("normal"); | |
| 56 | expect(parseSettings('{"difficulty":null}').difficulty).toBe("normal"); | |
| 57 | expect(parseSettings('{"difficulty":["hard"]}').difficulty).toBe("normal"); | |
| 58 | }); | |
| 59 | ||
| 60 | test("an unknown difficulty name is the default", () => { | |
| 61 | expect(parseSettings('{"difficulty":"impossible"}').difficulty).toBe("normal"); | |
| 62 | expect(parseSettings('{"difficulty":"EASY"}').difficulty).toBe("normal"); | |
| 63 | }); | |
| 64 | ||
| 65 | test("a valid difficulty is kept exactly", () => { | |
| 66 | expect(parseSettings('{"difficulty":"easy"}').difficulty).toBe("easy"); | |
| 67 | expect(parseSettings('{"difficulty":"hard"}').difficulty).toBe("hard"); | |
| 68 | }); |
src/client/settings.ts +16 −5
| @@ -1,20 +1,30 @@ | ||
| 1 | 1 | /** |
| 2 | * Settings a player can reach mid-match: mute, volume and look sensitivity. | |
| 3 | * Stored in localStorage beside `cs.name`. `parseSettings` is the boundary — | |
| 4 | * a missing, malformed or out-of-range value falls back to the default and | |
| 5 | * never puts NaN into the game. | |
| 2 | * Settings a player can reach mid-match: mute, volume, look sensitivity and | |
| 3 | * bot difficulty. Stored in localStorage beside `cs.name`. `parseSettings` is | |
| 4 | * the boundary — a missing, malformed, out-of-range or unknown value falls | |
| 5 | * back to the default and never puts NaN or an unrecognised string into the | |
| 6 | * game. What a difficulty setting actually does to a bot is bots.ts's | |
| 7 | * business; this file only knows the three names it can be. | |
| 6 | 8 | */ |
| 7 | 9 | |
| 10 | import { DEFAULT_DIFFICULTY, DIFFICULTIES, type Difficulty } from "./bots.ts"; | |
| 11 | ||
| 8 | 12 | export type Settings = { |
| 9 | 13 | muted: boolean; |
| 10 | 14 | volume: number; |
| 11 | 15 | sensitivity: number; |
| 16 | difficulty: Difficulty; | |
| 12 | 17 | }; |
| 13 | 18 | |
| 14 | export const DEFAULT_SETTINGS: Settings = { muted: false, volume: 1, sensitivity: 2.2 }; | |
| 19 | export const DEFAULT_SETTINGS: Settings = { | |
| 20 | muted: false, volume: 1, sensitivity: 2.2, difficulty: DEFAULT_DIFFICULTY, | |
| 21 | }; | |
| 15 | 22 | export const SENSITIVITY_MIN = 0.2; |
| 16 | 23 | export const SENSITIVITY_MAX = 12; |
| 17 | 24 | |
| 25 | const isDifficulty = (value: unknown): value is Difficulty => | |
| 26 | typeof value === "string" && (DIFFICULTIES as readonly string[]).includes(value); | |
| 27 | ||
| 18 | 28 | const KEY = "cs.settings"; |
| 19 | 29 | |
| 20 | 30 | const bounded = (value: unknown, lo: number, hi: number, fallback: number): number => { |
| @@ -37,6 +47,7 @@ export function parseSettings(raw: string | null): Settings { | ||
| 37 | 47 | muted: obj.muted === true, |
| 38 | 48 | volume: bounded(obj.volume, 0, 1, DEFAULT_SETTINGS.volume), |
| 39 | 49 | sensitivity: bounded(obj.sensitivity, SENSITIVITY_MIN, SENSITIVITY_MAX, DEFAULT_SETTINGS.sensitivity), |
| 50 | difficulty: isDifficulty(obj.difficulty) ? obj.difficulty : DEFAULT_SETTINGS.difficulty, | |
| 40 | 51 | }; |
| 41 | 52 | } |
| 42 | 53 |
src/client/world.ts +26 −2
| @@ -23,9 +23,11 @@ import { | ||
| 23 | 23 | ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset, |
| 24 | 24 | type WeaponId, type Weapon, |
| 25 | 25 | } from "./weapons.ts"; |
| 26 | import { botBuy, botThink, type BotBrain } from "./bots.ts"; | |
| 26 | import { botBuy, botThink, DEFAULT_DIFFICULTY, restyleBrain, type BotBrain, type Difficulty } from "./bots.ts"; | |
| 27 | 27 | |
| 28 | 28 | export type Team = rules.Team; |
| 29 | export type { Difficulty } from "./bots.ts"; | |
| 30 | export { DEFAULT_DIFFICULTY, DIFFICULTIES, difficultyLabel } from "./bots.ts"; | |
| 29 | 31 | |
| 30 | 32 | export type Ammo = { mag: number; reserve: number }; |
| 31 | 33 | |
| @@ -142,6 +144,10 @@ export type World = { | ||
| 142 | 144 | boxes: typeof map.boxes; |
| 143 | 145 | bombPlanted: boolean; // in this round, for the economy |
| 144 | 146 | pendingBuy: boolean; |
| 147 | /** How hard the bots are. What that means is bots.ts's business, not this file's. */ | |
| 148 | difficulty: Difficulty; | |
| 149 | /** Queued by `setDifficulty`; `startRound` applies it and clears it. */ | |
| 150 | pendingDifficulty: Difficulty | null; | |
| 145 | 151 | }; |
| 146 | 152 | |
| 147 | 153 | const BOT_NAMES = [ |
| @@ -175,6 +181,7 @@ export function createWorld( | ||
| 175 | 181 | playerTeam: Team = "ct", |
| 176 | 182 | teamSize = 5, |
| 177 | 183 | seed = (Date.now() & 0xffff) >>> 0, |
| 184 | difficulty: Difficulty = DEFAULT_DIFFICULTY, | |
| 178 | 185 | ): World { |
| 179 | 186 | const match = rules.newMatch(ruleSet, playerTeam); |
| 180 | 187 | const players: Player[] = []; |
| @@ -199,11 +206,21 @@ export function createWorld( | ||
| 199 | 206 | pickups: [], grenades: [], bodies: [], |
| 200 | 207 | effects: [], feed: [], messages: [], |
| 201 | 208 | random, boxes: map.boxes, bombPlanted: false, pendingBuy: false, |
| 209 | difficulty, pendingDifficulty: null, | |
| 202 | 210 | }; |
| 203 | 211 | startRound(world); |
| 204 | 212 | return world; |
| 205 | 213 | } |
| 206 | 214 | |
| 215 | /** | |
| 216 | * Queue a difficulty change. It does not touch a bot mid-fight — `startRound` | |
| 217 | * picks it up and re-skills every brain already in play, so the person being | |
| 218 | * crushed sees it from the next round rather than having to finish this one. | |
| 219 | */ | |
| 220 | export function setDifficulty(world: World, difficulty: Difficulty): void { | |
| 221 | world.pendingDifficulty = difficulty; | |
| 222 | } | |
| 223 | ||
| 207 | 224 | export const teammates = (world: World, team: Team): Player[] => world.players.filter((p) => p.team === team); |
| 208 | 225 | export const alive = (world: World, team: Team): Player[] => |
| 209 | 226 | world.players.filter((p) => p.team === team && p.alive); |
| @@ -223,6 +240,10 @@ export function startRound(world: World): void { | ||
| 223 | 240 | // rather than a draw from `world.random`: the training arena steps this same |
| 224 | 241 | // world, and a stream that shifted would move numbers that mean something. |
| 225 | 242 | world.plan = roundPlan(world.seed, world.match.round); |
| 243 | if (world.pendingDifficulty !== null) { | |
| 244 | world.difficulty = world.pendingDifficulty; | |
| 245 | world.pendingDifficulty = null; | |
| 246 | } | |
| 226 | 247 | const spots = { t: [...map.spawns.t], ct: [...map.spawns.ct] }; |
| 227 | 248 | for (const p of world.players) { |
| 228 | 249 | const list = spots[p.team]; |
| @@ -247,7 +268,10 @@ export function startRound(world: World): void { | ||
| 247 | 268 | p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) }; |
| 248 | 269 | if (p.primary !== null) p.ammo[p.primary] = emptyAmmo(p.primary); |
| 249 | 270 | p.weapon = p.primary ?? p.secondary; |
| 250 | if (p.brain !== undefined) p.brain.reset = true; | |
| 271 | if (p.brain !== undefined) { | |
| 272 | p.brain.reset = true; | |
| 273 | restyleBrain(p.brain, p.id, world.difficulty); | |
| 274 | } | |
| 251 | 275 | } |
| 252 | 276 | const ts = teammates(world, "t"); |
| 253 | 277 | const carrier = ts[Math.floor(world.random() * ts.length)]; |
src/pages/game.ts +11
| @@ -193,6 +193,11 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 193 | 193 | <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"><small>in the match only</small></label> |
| 194 | 194 | <label>Match<select id="length"><option value="short">Short — first to 5</option><option value="long">Competitive — first to 13</option></select></label> |
| 195 | 195 | <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label> |
| 196 | <label>Difficulty<select id="difficulty"> | |
| 197 | <option value="easy">Easy</option> | |
| 198 | <option value="normal" selected>Normal</option> | |
| 199 | <option value="hard">Hard</option> | |
| 200 | </select></label> | |
| 196 | 201 | <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label> |
| 197 | 202 | </div> |
| 198 | 203 | <div class="row"><button type="button" class="primary" id="play">Play</button> |
| @@ -215,9 +220,15 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 215 | 220 | <div class="row"> |
| 216 | 221 | <label>Sensitivity<input id="pause-sens" type="number" min="0.2" max="12" step="0.1"></label> |
| 217 | 222 | <label>Volume<input id="volume" type="range" min="0" max="100" step="1"></label> |
| 223 | <label>Difficulty<select id="pause-difficulty"> | |
| 224 | <option value="easy">Easy</option> | |
| 225 | <option value="normal">Normal</option> | |
| 226 | <option value="hard">Hard</option> | |
| 227 | </select></label> | |
| 218 | 228 | <button type="button" id="mute" aria-pressed="false">Mute</button> |
| 219 | 229 | <button type="button" id="fullscreen-pause" aria-pressed="false" hidden>Fullscreen</button> |
| 220 | 230 | </div> |
| 231 | <p class="hint">Difficulty takes effect from the next round.</p> | |
| 221 | 232 | <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div> |
| 222 | 233 | </div> |
| 223 | 234 | <div class="overlay" id="summary" hidden> |