Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
bot spray control is pretty mid #192
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/190-bot-spray-control-is-pretty-mid → main 1 commit fork
16 files
changed +548
−107
README.md +32 −5
| @@ -780,12 +780,12 @@ already solves it, so that stayed as it was: pathing, buying, planting, | ||
| 780 | 780 | defusing, listening for shots through walls. The fight is a reflex, and that is |
| 781 | 781 | now a policy learned with PPO. |
| 782 | 782 | |
| 783 | - **What it sees** — 32 numbers, all egocentric (`src/client/observe.ts`): the | |
| 783 | - **What it sees** — 33 numbers, all egocentric (`src/client/observe.ts`): the | |
| 784 | 784 | sine and cosine of its aim error, the range, the enemy's health and whether it |
| 785 | 785 | is looking back, its own health, armour, magazine, recoil, current inaccuracy |
| 786 | 786 | and how long since it last saw anything, plus how much open floor is ahead and |
| 787 | to each side. Nothing in map coordinates, so what it learns in one corridor | |
| 788 | works in the next one. | |
| 787 | to each side, and how much of its own spray pattern its hands hold. Nothing in | |
| 788 | map coordinates, so what it learns in one corridor works in the next one. | |
| 789 | 789 | - **What it does** — six small categorical heads (`src/client/action.ts`): a |
| 790 | 790 | turn rate, a pitch rate, forward, sideways, fire, and stance. Flattening a |
| 791 | 791 | structured action into independent heads instead of one 3402-way choice is the |
| @@ -827,12 +827,39 @@ now a policy learned with PPO. | ||
| 827 | 827 | |
| 828 | 828 | Skill is still a handicap laid on top, so the ladder the game already had |
| 829 | 829 | survives: a low-skill bot gets the same intent from the policy but cannot turn |
| 830 | as fast, has a wobble on its aim it cannot see, and waits longer before it | |
| 831 | shoots. `src/client/bots.ts` keeps the pre-policy fight as `scriptedFight`, | |
| 830 | as fast, has a wobble on its aim it cannot see, waits longer before it shoots, | |
| 831 | and holds less of its own spray. `src/client/bots.ts` keeps the pre-policy fight as `scriptedFight`, | |
| 832 | 832 | unused by the game, because it is the opponent the policy trains against and the |
| 833 | 833 | baseline it is measured against — a baseline that moves with the policy measures |
| 834 | 834 | nothing. |
| 835 | 835 | |
| 836 | ### Spray control | |
| 837 | ||
| 838 | A rifle held down climbs a fixed pattern and six tenths of that climb is where | |
| 839 | the round actually goes, so the twentieth bullet of an AK burst leaves the | |
| 840 | barrel about six degrees above the crosshair. A person answers that by pulling | |
| 841 | the mouse down. `src/client/spray.ts` is what a bot's hands do about it: a | |
| 842 | *grip* — the share of the pattern they cancel, from 0.22 for fodder to 0.9 for | |
| 843 | the best hands in the game, never 1 — and a *rate*, how fast that pull catches | |
| 844 | up with the climb, because a pull is a reflex chasing a gun and not a rail. The | |
| 845 | pull is on the view and nowhere else, so the bullets that come out are the ones | |
| 846 | a person pulling down would have sent; a flashed bot holds none of it; and a | |
| 847 | burst that ends gives the whole pull back, so nothing of it outlives the spray. | |
| 848 | ||
| 849 | The half that is not in that file is the aim. Before this, the shaped reward in | |
| 850 | the arena was paid on *yaw* alone — how squarely the bot was turned towards the | |
| 851 | enemy — and pitch was left to the damage term to work out. What it worked out | |
| 852 | was to point at the floor and let the climb walk the spray up a body: measured | |
| 853 | over 120 duels, the crosshair sat five to seven degrees below the chest through | |
| 854 | the whole magazine, the first round of a burst hit 7% of the time and the burst | |
| 855 | as a whole hit 22%. That is a way to win a fight, and it is the reason the | |
| 856 | answer here was not to bolt a pull onto the old policy — hands that pull down | |
| 857 | under a head that already aims low just miss lower. So the arena now gives the | |
| 858 | learner the same hands the game's bots have, with the grip among the numbers it | |
| 859 | observes, and pays the shaping on `muzzleError`: the angle between the line to | |
| 860 | the enemy's chest and where the *next round* would actually go, recoil and all. | |
| 861 | Aim at the body, hold the pattern, and the spray lands. | |
| 862 | ||
| 836 | 863 | The site at could not be reached from this sandbox (the |
| 837 | 864 | network here is the forge and the package registry), so the influence is from |
| 838 | 865 | what PufferLib is known for rather than from its documentation: flat observation |
src/client/bots.test.ts +42 −2
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { |
| 3 | applyIntent, blindness, botThink, CARD, flashed, makeBrain, pathTo, policy, think, | |
| 3 | applyIntent, blindness, botThink, CARD, flashed, makeBrain, pathTo, policy, ride, think, | |
| 4 | 4 | } from "./bots.ts"; |
| 5 | import { grip } from "./spray.ts"; | |
| 5 | 6 | import { colOf, findPath, rowOf, walkable, worldOf } from "./level.ts"; |
| 6 | 7 | import { dist, type Vec3 } from "../engine/index.ts"; |
| 7 | 8 | import { OBS_SIZE } from "./observe.ts"; |
| @@ -60,12 +61,47 @@ test("a bot turns towards a target it can see, and shoots at it", () => { | ||
| 60 | 61 | expect(fired).toBeGreaterThan(0); |
| 61 | 62 | }); |
| 62 | 63 | |
| 64 | test("the hands hold the gun down through a spray and give the whole pull back", () => { | |
| 65 | const { world, bot } = duel(); | |
| 66 | const brain = bot.brain!; | |
| 67 | bot.weapon = "ak47"; | |
| 68 | bot.pitch = 0; | |
| 69 | bot.yaw = 180; | |
| 70 | // Ten rounds into a spray: the pattern has climbed and the hands are on it. | |
| 71 | bot.shotIndex = 10; | |
| 72 | for (let t = 0; t < 32; t++) ride(bot, brain, brain.grip, 1 / 64); | |
| 73 | expect(bot.pitch).toBeGreaterThan(1); // pointing below where it was aiming | |
| 74 | expect(brain.pull[1]).toBeCloseTo(bot.pitch, 9); | |
| 75 | expect(brain.pull[0]).toBeCloseTo(bot.yaw - 180, 9); | |
| 76 | // The world puts shotIndex back a third of a second after the trigger comes | |
| 77 | // up, and the pull unwinds with it: nothing of it outlives the burst. | |
| 78 | bot.shotIndex = 0; | |
| 79 | for (let t = 0; t < 64; t++) ride(bot, brain, brain.grip, 1 / 64); | |
| 80 | expect(bot.pitch).toBeCloseTo(0, 2); | |
| 81 | expect(bot.yaw).toBeCloseTo(180, 2); | |
| 82 | expect(Math.hypot(brain.pull[0], brain.pull[1])).toBeLessThan(0.01); | |
| 83 | ||
| 84 | // And the scripted brain never rides at all: it is the baseline the policy is | |
| 85 | // measured against, and a baseline that moves measures nothing. | |
| 86 | const scripted = duel(); | |
| 87 | scripted.bot.brain = makeBrain(1, 99, false); | |
| 88 | scripted.bot.weapon = "ak47"; | |
| 89 | scripted.bot.shotIndex = 10; | |
| 90 | for (let t = 0; t < 32; t++) botThink(scripted.world, scripted.bot, 1 / 64); | |
| 91 | expect(scripted.bot.brain.pull).toEqual([0, 0]); | |
| 92 | }); | |
| 93 | ||
| 63 | 94 | test("skill is a handicap on the hands, not on the policy", () => { |
| 64 | 95 | const fast = makeBrain(1, 7); |
| 65 | 96 | const slow = makeBrain(0, 7); |
| 66 | 97 | expect(slow.aimSpeed).toBeLessThan(fast.aimSpeed); |
| 67 | 98 | expect(slow.aimError).toBeGreaterThan(fast.aimError); |
| 68 | 99 | expect(slow.reaction).toBeGreaterThan(fast.reaction); |
| 100 | // Spray control is on the same ladder: fodder holds a fifth of the pattern | |
| 101 | // and chases it slowly, the best hands in the game hold nine tenths. | |
| 102 | expect(slow.grip).toBeLessThan(fast.grip); | |
| 103 | expect(fast.grip).toBeCloseTo(grip(1), 12); | |
| 104 | expect(slow.gripRate).toBeLessThan(fast.gripRate); | |
| 69 | 105 | |
| 70 | 106 | // The same intent, asked of both: the slow one cannot turn as far. |
| 71 | 107 | const { world, bot } = duel(); |
| @@ -138,6 +174,7 @@ test("a flashed bot keeps its policy and loses its hands: slower, wilder, later, | ||
| 138 | 174 | expect(clear.aimSpeed).toBe(brain.aimSpeed); |
| 139 | 175 | expect(clear.aimError).toBe(brain.aimError); |
| 140 | 176 | expect(clear.reaction).toBe(brain.reaction); |
| 177 | expect(clear.grip).toBe(brain.grip); | |
| 141 | 178 | |
| 142 | 179 | bot.blind = FLASH.flash.maxBlind; |
| 143 | 180 | const hit = flashed(bot, brain); |
| @@ -146,6 +183,9 @@ test("a flashed bot keeps its policy and loses its hands: slower, wilder, later, | ||
| 146 | 183 | expect(hit.aimError).toBeCloseTo(brain.aimError * 7, 6); |
| 147 | 184 | expect(hit.reaction).toBeCloseTo(brain.reaction * 4, 6); |
| 148 | 185 | expect(hit.sight).toBeLessThan(clear.sight); |
| 186 | // And it holds none of its spray: a pair of hands that cannot see the gun | |
| 187 | // cannot ride the pattern either. | |
| 188 | expect(hit.grip).toBe(0); | |
| 149 | 189 | // Half way through the flash it is half of each: the number in the world is |
| 150 | 190 | // the only thing that decides, and it is the same one the HUD paints. |
| 151 | 191 | bot.blind = FLASH.flash.maxBlind / 2; |
| @@ -153,7 +193,7 @@ test("a flashed bot keeps its policy and loses its hands: slower, wilder, later, | ||
| 153 | 193 | |
| 154 | 194 | // Its head is untouched: the observation, the heads and the weights know |
| 155 | 195 | // nothing about any of this. |
| 156 | expect(OBS_SIZE).toBe(32); | |
| 196 | expect(OBS_SIZE).toBe(33); | |
| 157 | 197 | expect(brain.obs.length).toBe(OBS_SIZE); |
| 158 | 198 | }); |
| 159 | 199 |
src/client/bots.ts +50 −5
| @@ -18,6 +18,7 @@ import { | ||
| 18 | 18 | } from "../engine/index.ts"; |
| 19 | 19 | import { colOf, findPath, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts"; |
| 20 | 20 | import { observe, OBS_SIZE, type Memory } from "./observe.ts"; |
| 21 | import { ease, grip, gripRate, sprayPull } from "./spray.ts"; | |
| 21 | 22 | import { CARD, PACKED } from "./weights.ts"; |
| 22 | 23 | import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts"; |
| 23 | 24 | import { buy } from "./economy.ts"; |
| @@ -41,6 +42,10 @@ export type BotBrain = { | ||
| 41 | 42 | reaction: number; // seconds between seeing and shooting |
| 42 | 43 | aimSpeed: number; // degrees a second |
| 43 | 44 | aimError: number; // degrees of standing error |
| 45 | grip: number; // 0..1, the share of the spray pattern these hands pull back | |
| 46 | gripRate: number; // how fast that pull catches up, e-foldings a second | |
| 47 | /** Where the pull is holding the view right now: [yaw, pitch] degrees. */ | |
| 48 | pull: [number, number]; | |
| 44 | 49 | goal: Vec3 | null; |
| 45 | 50 | path: Vec3[]; |
| 46 | 51 | step: number; |
| @@ -76,6 +81,9 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr | ||
| 76 | 81 | reaction: 0.5 - skill * 0.33, |
| 77 | 82 | aimSpeed: 180 + skill * 620, |
| 78 | 83 | aimError: 4.5 - skill * 3.6, |
| 84 | grip: grip(skill), | |
| 85 | gripRate: gripRate(skill), | |
| 86 | pull: [0, 0], | |
| 79 | 87 | goal: null, path: [], step: 0, repathAt: 0, |
| 80 | 88 | targetId: null, seenAt: 0, burstUntil: 0, restUntil: 0, |
| 81 | 89 | strafe: 0, strafeUntil: 0, suspect: null, plannedFor: "", |
| @@ -111,21 +119,51 @@ export const blindness = (bot: Player): number => clamp(bot.blind / FLASH.flash. | ||
| 111 | 119 | /** |
| 112 | 120 | * What a flash leaves a bot with. Fully blind it turns at a fifth of its usual |
| 113 | 121 | * speed, its hands wander seven times as far off centre, it takes four times as |
| 114 | * long to decide to shoot at all, and it only notices an enemy inside a | |
| 115 | * twelfth of its usual sight — which is roughly what a person flashed in the | |
| 116 | * face does, and is the whole of the disadvantage. Its policy is untouched: | |
| 117 | * `OBS_SIZE`, the heads and the shipped weights know nothing about this. | |
| 122 | * long to decide to shoot at all, it holds none of its spray pattern, and it | |
| 123 | * only notices an enemy inside a twelfth of its usual sight — which is roughly | |
| 124 | * what a person flashed in the face does, and is the whole of the disadvantage. | |
| 125 | * Its policy is untouched: `OBS_SIZE`, the heads and the shipped weights know | |
| 126 | * nothing about this. | |
| 118 | 127 | */ |
| 119 | export function flashed(bot: Player, brain: BotBrain): { aimSpeed: number; aimError: number; reaction: number; sight: number } { | |
| 128 | export function flashed( | |
| 129 | bot: Player, | |
| 130 | brain: BotBrain, | |
| 131 | ): { aimSpeed: number; aimError: number; reaction: number; sight: number; grip: number } { | |
| 120 | 132 | const b = blindness(bot); |
| 121 | 133 | return { |
| 122 | 134 | aimSpeed: brain.aimSpeed * (1 - 0.8 * b), |
| 123 | 135 | aimError: brain.aimError * (1 + 6 * b), |
| 124 | 136 | reaction: brain.reaction * (1 + 3 * b), |
| 125 | 137 | sight: SIGHT * (1 - 0.92 * b), |
| 138 | grip: brain.grip * (1 - b), | |
| 126 | 139 | }; |
| 127 | 140 | } |
| 128 | 141 | |
| 142 | /** | |
| 143 | * One tick of the bot riding its own recoil: the pull eases towards what would | |
| 144 | * cancel the shot it is about to take, and the *change* in it is what moves the | |
| 145 | * view — exactly the way the wobble below is applied, and for the same reason. | |
| 146 | * The view carries the pull, nothing else does, so the bullets that leave the | |
| 147 | * barrel are the ones a person pulling down would have sent. | |
| 148 | * | |
| 149 | * `bot.shotIndex` is where the next round lands in the pattern, and the world | |
| 150 | * puts it back to zero a third of a second after the trigger comes up — so a | |
| 151 | * bot that stops spraying eases back up to where it was aiming, with the whole | |
| 152 | * of the pull given back. Nothing here is a net offset that outlives a burst. | |
| 153 | * | |
| 154 | * The policy sees the pulled view, because the arena it learned in pulls the | |
| 155 | * same way (`src/rl/arena.ts`) and its own grip is one of the 33 numbers it is | |
| 156 | * handed. A policy told nothing about its hands would aim for a recoil nobody | |
| 157 | * was about to make. | |
| 158 | */ | |
| 159 | export function ride(bot: Player, brain: BotBrain, grip: number, dt: number): void { | |
| 160 | const want = sprayPull(bot.weapon, bot.shotIndex, grip); | |
| 161 | const next = ease(brain.pull, want, brain.gripRate, dt); | |
| 162 | bot.yaw += next[0] - brain.pull[0]; | |
| 163 | bot.pitch = clamp(bot.pitch + next[1] - brain.pull[1], -89, 89); | |
| 164 | brain.pull = next; | |
| 165 | } | |
| 166 | ||
| 129 | 167 | /** How far a bot notices an enemy at all, in units, with its eyes working. */ |
| 130 | 168 | const SIGHT = 4000; |
| 131 | 169 | |
| @@ -350,12 +388,19 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 350 | 388 | brain.memory.sinceSeen = 9; |
| 351 | 389 | brain.hold = 0; |
| 352 | 390 | brain.intent = { ...STILL }; |
| 391 | // A fresh round hands the body a fresh view; the pull is not owed back. | |
| 392 | brain.pull = [0, 0]; | |
| 353 | 393 | input.switchTo = bot.primary ?? bot.secondary; |
| 354 | 394 | } |
| 355 | 395 | if (world.match.phase === "freeze") { |
| 356 | 396 | return input; |
| 357 | 397 | } |
| 358 | 398 | |
| 399 | // The hands ride the pattern whatever the head is doing, so a bot that stops | |
| 400 | // fighting — or stops firing — gives the pull back rather than walking off | |
| 401 | // aiming at the floor. Not on the scripted brain: that one is the baseline | |
| 402 | // the policy is measured against, and a baseline that moves measures nothing. | |
| 403 | if (brain.useLearned) ride(bot, brain, flashed(bot, brain).grip, dt); | |
| 359 | 404 | listen(world, bot, brain); |
| 360 | 405 | const target = pickTarget(world, bot, brain); |
| 361 | 406 | if (target !== null) { |
src/client/observe.ts +7 −2
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * What a bot sees, as a flat vector of 32 numbers in roughly [-1, 1]. | |
| 2 | * What a bot sees, as a flat vector of 33 numbers in roughly [-1, 1]. | |
| 3 | 3 | * |
| 4 | 4 | * Everything here is egocentric — distances and directions are in the bot's |
| 5 | 5 | * own frame, never the map's — so a policy trained in one corner of de_forge |
| @@ -19,7 +19,7 @@ import { inaccuracy, WEAPONS } from "./weapons.ts"; | ||
| 19 | 19 | import type { Player, World } from "./world.ts"; |
| 20 | 20 | import { aimAt, bodyCentre, currentWeapon, eyePos } from "./world.ts"; |
| 21 | 21 | |
| 22 | export const OBS_SIZE = 32; | |
| 22 | export const OBS_SIZE = 33; | |
| 23 | 23 | |
| 24 | 24 | /** What the bot remembers between ticks that the world does not store for it. */ |
| 25 | 25 | export type Memory = { |
| @@ -120,6 +120,11 @@ export function observe( | ||
| 120 | 120 | out[at + 29] = theirSpeed; |
| 121 | 121 | out[at + 30] = ahead; |
| 122 | 122 | out[at + 31] = right - left; |
| 123 | // How much of its own spray pattern these hands hold (`src/client/spray.ts`). | |
| 124 | // A policy that did not know this would aim for a recoil nobody was about to | |
| 125 | // make: the same crosshair is right for a bot that rides the pattern and | |
| 126 | // wrong for one whose gun climbs out of the fight. | |
| 127 | out[at + 32] = bot.brain?.grip ?? 0; | |
| 123 | 128 | return out; |
| 124 | 129 | } |
| 125 | 130 |
src/client/replay.test.ts +18 −9
| @@ -35,6 +35,15 @@ const TICKS = SECONDS * TICKS_PER_SECOND; | ||
| 35 | 35 | |
| 36 | 36 | /** |
| 37 | 37 | * Forty seconds of a five-a-side round on de_forge, seed 7. Re-pinned for |
| 38 | * #190, which gave the bots spray control: their hands now ride the recoil | |
| 39 | * pattern (`src/client/spray.ts`) and the policy they fight with was retrained | |
| 40 | * in an arena whose learner has those hands and whose shaping is paid on where | |
| 41 | * the round would actually land. The first nine seconds — the freeze and the | |
| 42 | * walk out of spawn — are the same numbers to the character, and every second | |
| 43 | * from the tenth on, which is the first at which a bot has an enemy to decide | |
| 44 | * about, hashes differently. Nobody's footing moved; the shooting did. | |
| 45 | * | |
| 46 | * Before that it was re-pinned for | |
| 38 | 47 | * #177, which retrained the policy the ten bots in it fight with: every second |
| 39 | 48 | * from the tenth on — the first at which a bot has an enemy to decide about — |
| 40 | 49 | * hashes differently, and the nine before it are the numbers #164 left to the |
| @@ -54,18 +63,18 @@ const TICKS = SECONDS * TICKS_PER_SECOND; | ||
| 54 | 63 | * anything: the bots buy no grenades and the scripted person never presses |
| 55 | 64 | * either key, so no flash goes off and no `blind` is ever non-zero here. |
| 56 | 65 | */ |
| 57 | const ROUND = "3dd837d703034e7c0ba08c2633300e1b28f8e0b4e160193077dba84438538c3e"; | |
| 66 | const ROUND = "dc3121bc9214db5c1928c61e155c022b827dc361bb72e128a84a4c28bc08f4b9"; | |
| 58 | 67 | const ROUND_SECONDS = [ |
| 59 | 68 | "5978706f2efd", "aa6aa9a5a3c9", "f822e864db93", "6f1cf4de1926", |
| 60 | 69 | "25923b9cb132", "83b06bc2430e", "b97f8d621af5", "c1b94afedda0", |
| 61 | "700c15a0f6c1", "c38f50dab1ca", "7a99f7ac430d", "704874f88a78", | |
| 62 | "362cdfb7dc07", "dc096b96ac6f", "c7f5998a8d2a", "0d20ab5e1686", | |
| 63 | "8ca62062fe8a", "87d4d63b8ff1", "37390d1539de", "eaeef81da1d6", | |
| 64 | "a33856d0ce01", "6dde9a94751a", "4c929dfc9607", "ee774c17027d", | |
| 65 | "9135e1bb46df", "643a85bf9c93", "3b62f2485ccd", "f6cee0354c77", | |
| 66 | "94578e2ff523", "7efd91c9c90d", "769e3d6d5c1f", "861c0e487740", | |
| 67 | "471399956ceb", "4391147a2f38", "12a4eed48969", "fcde6163b6d4", | |
| 68 | "ec71e47d09bb", "2b5f5f764b2f", "ed40a891d704", "8d43dd52fa3e", | |
| 70 | "700c15a0f6c1", "32921cefb2ff", "8241df979380", "c815eb110a37", | |
| 71 | "95d7115059f4", "e131dfcfdb54", "a56027278fc7", "3e66ed95e89c", | |
| 72 | "df1d01273592", "392385270f63", "c41cf61d1f0f", "e5a9d02a9e68", | |
| 73 | "88d708808f5f", "b956000db9df", "c04cbb5e0444", "94c30ef49e7d", | |
| 74 | "d5771420a724", "f3ed6f1d7224", "a797590b29d2", "41c4e0317add", | |
| 75 | "554dbc193f79", "bbf96042b6c7", "64a02bf5f0a8", "ae9d223ce2b2", | |
| 76 | "0599d003b07e", "75b6b121770e", "558606b5b303", "c94ba5961c21", | |
| 77 | "3da066135df7", "51037e8ff1cb", "c6cc8d93ed31", "a001ff0f1424", | |
| 69 | 78 | ]; |
| 70 | 79 | |
| 71 | 80 | /** |
src/client/spray.test.ts added +129
| @@ -0,0 +1,129 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { BEST_GRIP, ease, FAST_RATE, grip, gripRate, SLOW_RATE, sprayPull, WORST_GRIP } from "./spray.ts"; | |
| 3 | import { recoilOffset, RECOIL_SHARE } from "./weapons.ts"; | |
| 4 | import { DE_FORGE } from "./map.ts"; | |
| 5 | import { worldOf } from "./level.ts"; | |
| 6 | import { COMPETITIVE } from "./match.ts"; | |
| 7 | import { aimAt, bodyCentre, canSee, createWorld, shoot, type Player } from "./world.ts"; | |
| 8 | ||
| 9 | const TICK = 1 / 64; | |
| 10 | ||
| 11 | test("the pull is the exact mirror of what recoil does to the bullet", () => { | |
| 12 | // `shoot` sends the round at yaw - right·RECOIL_SHARE and pitch - up·RECOIL_SHARE. | |
| 13 | // Full grip holds the view the same distance the other way, so the two cancel | |
| 14 | // to nothing and the bullet leaves along the crosshair the burst started on. | |
| 15 | for (const shot of [0, 1, 3, 7, 15, 29, 99]) { | |
| 16 | const [right, up] = recoilOffset("ak47", shot); | |
| 17 | const [yaw, pitch] = sprayPull("ak47", shot, 1); | |
| 18 | expect(yaw).toBeCloseTo(right * RECOIL_SHARE, 12); | |
| 19 | expect(pitch).toBeCloseTo(up * RECOIL_SHARE, 12); | |
| 20 | // Half the grip is half the pull: the share is a straight multiplier. | |
| 21 | expect(sprayPull("ak47", shot, 0.5)[1]).toBeCloseTo(pitch / 2, 12); | |
| 22 | // And no grip at all is no pull at all, which is what the game did before. | |
| 23 | expect(sprayPull("ak47", shot, 0)).toEqual([0, 0]); | |
| 24 | } | |
| 25 | // The first round of a burst is not recoiling yet, so there is nothing to hold. | |
| 26 | expect(sprayPull("ak47", 0, 1)).toEqual([0, 0]); | |
| 27 | // Nothing thrown has a pattern, so nothing thrown has a pull. | |
| 28 | expect(sprayPull("flash", 5, 1)).toEqual([0, 0]); | |
| 29 | }); | |
| 30 | ||
| 31 | test("a pair of hands is a share and a speed, and skill is the ladder between", () => { | |
| 32 | expect(grip(0)).toBeCloseTo(WORST_GRIP, 12); | |
| 33 | expect(grip(1)).toBeCloseTo(BEST_GRIP, 12); | |
| 34 | expect(grip(0.5)).toBeGreaterThan(grip(0.25)); | |
| 35 | expect(gripRate(0)).toBeCloseTo(SLOW_RATE, 12); | |
| 36 | expect(gripRate(1)).toBeCloseTo(FAST_RATE, 12); | |
| 37 | // Off the end of the ladder is the end of the ladder, never past it: the | |
| 38 | // best hands in the game still leave a tenth of the pattern uncontrolled. | |
| 39 | expect(grip(3)).toBeCloseTo(BEST_GRIP, 12); | |
| 40 | expect(grip(-1)).toBeCloseTo(WORST_GRIP, 12); | |
| 41 | expect(BEST_GRIP).toBeLessThan(1); | |
| 42 | }); | |
| 43 | ||
| 44 | test("the pull eases towards the climb, and how long a tick is cannot change it", () => { | |
| 45 | const to: [number, number] = [2, 6]; | |
| 46 | const one = ease([0, 0], to, 20, TICK); | |
| 47 | expect(one[1]).toBeGreaterThan(0); | |
| 48 | expect(one[1]).toBeLessThan(to[1]); // it lags: a reflex chasing a gun | |
| 49 | const half = ease(ease([0, 0], to, 20, TICK / 2), to, 20, TICK / 2); | |
| 50 | expect(half[0]).toBeCloseTo(one[0], 12); | |
| 51 | expect(half[1]).toBeCloseTo(one[1], 12); | |
| 52 | // Fast hands close more of the gap in the same tick than slow ones. | |
| 53 | expect(ease([0, 0], to, FAST_RATE, TICK)[1]).toBeGreaterThan(ease([0, 0], to, SLOW_RATE, TICK)[1]); | |
| 54 | // Nowhere to go is nowhere: hands that are not spraying add exactly zero. | |
| 55 | expect(ease([0, 0], [0, 0], 20, TICK)).toEqual([0, 0]); | |
| 56 | // And a pull left on the view unwinds back to nothing once the burst stops. | |
| 57 | let pull: [number, number] = [2, 6]; | |
| 58 | for (let i = 0; i < 64; i++) pull = ease(pull, [0, 0], FAST_RATE, TICK); | |
| 59 | expect(Math.hypot(pull[0], pull[1])).toBeLessThan(0.01); | |
| 60 | }); | |
| 61 | ||
| 62 | /** | |
| 63 | * The claim the whole file rests on, measured on the real bullets: hold an AK | |
| 64 | * down for a full magazine at a standing body six, eight and ten cells down | |
| 65 | * long A, and add up the damage. The view is pulled by the same arithmetic | |
| 66 | * `bots.ts` rides with, and the only difference between two runs is the pair | |
| 67 | * of hands — the same world from the same seed, the same ticks, so the same | |
| 68 | * accuracy rolls come out of `world.random` in the same order. | |
| 69 | */ | |
| 70 | function sprayDamage(share: number, rate: number, cells: number): number { | |
| 71 | const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 1, 4242); | |
| 72 | world.match.phase = "live"; | |
| 73 | const shooter = world.players[1] as Player; | |
| 74 | const dummy = world.me; | |
| 75 | // Down long A, which is four cells wide and twenty long: a clear line. | |
| 76 | shooter.pos = worldOf(DE_FORGE, 36, 12, 2); | |
| 77 | dummy.pos = worldOf(DE_FORGE, 36, 12 + cells, 2); | |
| 78 | expect(canSee(world, shooter, dummy)).toBe(true); | |
| 79 | shooter.weapon = "ak47"; | |
| 80 | shooter.primary = "ak47"; | |
| 81 | shooter.ammo.ak47 = { mag: 30, reserve: 90 }; | |
| 82 | const aim = aimAt(shooter, bodyCentre(dummy)); | |
| 83 | shooter.yaw = aim.yaw; | |
| 84 | shooter.pitch = aim.pitch; | |
| 85 | ||
| 86 | let pull: [number, number] = [0, 0]; | |
| 87 | let fired = 0; | |
| 88 | for (let tick = 0; tick < 64 * 4 && fired < 30; tick++) { | |
| 89 | world.time += TICK; | |
| 90 | const next = ease(pull, sprayPull("ak47", shooter.shotIndex, share), rate, TICK); | |
| 91 | shooter.yaw += next[0] - pull[0]; | |
| 92 | shooter.pitch += next[1] - pull[1]; | |
| 93 | pull = next; | |
| 94 | if (shoot(world, shooter)) fired += 1; | |
| 95 | // A dummy that keeps standing there, so thirty rounds are thirty chances. | |
| 96 | dummy.health = 100; | |
| 97 | dummy.alive = true; | |
| 98 | } | |
| 99 | expect(fired).toBe(30); | |
| 100 | return shooter.damage; | |
| 101 | } | |
| 102 | ||
| 103 | /** Ninety rounds: one magazine at each of the three ranges. */ | |
| 104 | const magazines = (skill: number | null): number => | |
| 105 | [6, 8, 10].reduce( | |
| 106 | (sum, cells) => | |
| 107 | sum + (skill === null | |
| 108 | ? sprayDamage(0, FAST_RATE, cells) | |
| 109 | : sprayDamage(grip(skill), gripRate(skill), cells)), | |
| 110 | 0, | |
| 111 | ); | |
| 112 | ||
| 113 | test("a controlled spray stays on the body an uncontrolled one climbs off", () => { | |
| 114 | const loose = magazines(null); // the bots before this change: no pull at all | |
| 115 | const fodder = magazines(0); | |
| 116 | const middling = magazines(0.5); | |
| 117 | const best = magazines(1); | |
| 118 | // Ninety AK rounds at a standing body. Uncontrolled the pattern walks off it | |
| 119 | // after the first handful and 339 damage is the lot; the best hands in the | |
| 120 | // game keep 3,639 of it on the body, which is a magazine that kills whoever | |
| 121 | // it started on. The ladder in between is the whole point of a skill number: | |
| 122 | // 374 for fodder, 743 for the middle of the pack. | |
| 123 | expect(best).toBeGreaterThan(loose * 3); | |
| 124 | expect(middling).toBeGreaterThan(loose); | |
| 125 | expect(best).toBeGreaterThan(middling); | |
| 126 | expect(middling).toBeGreaterThan(fodder); | |
| 127 | // Even the worst hands in the game hold a little of it. | |
| 128 | expect(fodder).toBeGreaterThanOrEqual(loose); | |
| 129 | }); |
src/client/spray.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * Spray control: what a pair of hands does about the recoil it just made. | |
| 3 | * | |
| 4 | * A rifle held down climbs a fixed pattern (`makePattern` in weapons.ts) and | |
| 5 | * `RECOIL_SHARE` of that pattern is where the round actually goes, so the | |
| 6 | * twentieth bullet of an AK burst leaves the barrel some six degrees above the | |
| 7 | * crosshair. A person answers that by pulling the mouse down and a little | |
| 8 | * against the drift — the crosshair ends up below the enemy and the bullets | |
| 9 | * end up on it — and that pull is a *hand* skill, learned by the muscle rather | |
| 10 | * than decided by the head. It belongs here for the same reason a bot's turn | |
| 11 | * rate and its wobble do: the policy asks for a crosshair, and how much of the | |
| 12 | * pattern the hands can hold is what the ladder is made of. | |
| 13 | * | |
| 14 | * Two numbers describe a pair of hands: | |
| 15 | * | |
| 16 | * grip — the share of the pattern they cancel, 0 (none of it) to 1 (all of | |
| 17 | * it). Never quite 1, because a bot that erases its own recoil to the | |
| 18 | * bit is not a person and is not fun to play against. | |
| 19 | * rate — how fast the pull catches up with the climb, in e-foldings a | |
| 20 | * second. A pull is a reflex chasing a gun, not a rail: it lags, and | |
| 21 | * the first shots of a burst are the ones it lags on. | |
| 22 | * | |
| 23 | * Nothing here knows what a bot is. It is the recoil pattern, a shot number | |
| 24 | * and a pair of hands, in and out — the world's bullets are not touched and a | |
| 25 | * person gets no help from this file. | |
| 26 | */ | |
| 27 | ||
| 28 | import { clamp } from "../engine/index.ts"; | |
| 29 | import { recoilOffset, RECOIL_SHARE, type WeaponId } from "./weapons.ts"; | |
| 30 | ||
| 31 | /** What the worst hands in the game hold: a quarter of the pattern, no more. */ | |
| 32 | export const WORST_GRIP = 0.22; | |
| 33 | ||
| 34 | /** And the best. Deliberately short of 1: a perfect spray is nobody's. */ | |
| 35 | export const BEST_GRIP = 0.9; | |
| 36 | ||
| 37 | /** How fast the worst and the best hands catch up, in e-foldings a second. */ | |
| 38 | export const SLOW_RATE = 7; | |
| 39 | export const FAST_RATE = 26; | |
| 40 | ||
| 41 | /** The share of the pattern hands of this skill pull back. */ | |
| 42 | export const grip = (skill: number): number => | |
| 43 | WORST_GRIP + (BEST_GRIP - WORST_GRIP) * clamp(skill, 0, 1); | |
| 44 | ||
| 45 | /** How fast hands of this skill chase the climb. */ | |
| 46 | export const gripRate = (skill: number): number => | |
| 47 | SLOW_RATE + (FAST_RATE - SLOW_RATE) * clamp(skill, 0, 1); | |
| 48 | ||
| 49 | /** | |
| 50 | * Where hands with this `grip` want to hold the view for shot number `shot` of | |
| 51 | * a `id` spray: [yaw, pitch] degrees, added to the aim. | |
| 52 | * | |
| 53 | * The sign is the mirror of `shoot` in world.ts, which sends the round at | |
| 54 | * `yaw - right · RECOIL_SHARE` and `pitch - up · RECOIL_SHARE`. Cancelling that | |
| 55 | * means holding the crosshair the same distance the other way — down the | |
| 56 | * screen (pitch counts downwards) and into the drift — so at full grip the | |
| 57 | * bullet goes exactly where the crosshair was before the trigger went down. | |
| 58 | */ | |
| 59 | export function sprayPull(id: WeaponId, shot: number, grip: number): [number, number] { | |
| 60 | const [right, up] = recoilOffset(id, shot); | |
| 61 | return [right * RECOIL_SHARE * grip, up * RECOIL_SHARE * grip]; | |
| 62 | } | |
| 63 | ||
| 64 | /** | |
| 65 | * One tick of a pull chasing where it wants to be: an exponential ease, so the | |
| 66 | * gap closes by `1 - e^(-rate·dt)` of itself however long the tick was. Framerate | |
| 67 | * cannot change it, which is the property a fixed-tick simulation wants anyway, | |
| 68 | * and a gap of nothing stays exactly nothing — hands that are not spraying add | |
| 69 | * exactly zero to the view. | |
| 70 | */ | |
| 71 | export function ease( | |
| 72 | from: readonly [number, number], | |
| 73 | to: readonly [number, number], | |
| 74 | rate: number, | |
| 75 | dt: number, | |
| 76 | ): [number, number] { | |
| 77 | const k = 1 - Math.exp(-Math.max(0, rate) * dt); | |
| 78 | return [from[0] + (to[0] - from[0]) * k, from[1] + (to[1] - from[1]) * k]; | |
| 79 | } |
src/client/weapons.ts +10
| @@ -147,6 +147,16 @@ const PATTERNS = new Map<WeaponId, [number, number][]>( | ||
| 147 | 147 | WEAPON_LIST.map((w, i) => [w.id, makePattern(w, 0x5eed + i * 7919)]), |
| 148 | 148 | ); |
| 149 | 149 | |
| 150 | /** | |
| 151 | * How much of the pattern reaches the bullet. The muzzle walks the whole of | |
| 152 | * `makePattern`, and six tenths of that walk is where the round actually goes: | |
| 153 | * the rest is the gun moving in the hands without the barrel following it all | |
| 154 | * the way. It is a constant rather than a literal in `shoot` because spray | |
| 155 | * control is the mirror of it — `src/client/spray.ts` cancels exactly this | |
| 156 | * share — and the two must never be able to disagree. | |
| 157 | */ | |
| 158 | export const RECOIL_SHARE = 0.6; | |
| 159 | ||
| 150 | 160 | /** Where shot number `n` of a burst lands relative to the crosshair. */ |
| 151 | 161 | export function recoilOffset(id: WeaponId, shot: number): [number, number] { |
| 152 | 162 | const pattern = PATTERNS.get(id); |
src/client/weights.test.ts +2 −2
| @@ -18,13 +18,13 @@ import { OBS_SIZE } from "./observe.ts"; | ||
| 18 | 18 | import { PACKED } from "./weights.ts"; |
| 19 | 19 | |
| 20 | 20 | /** What `unpack(PACKED)` has dequantised to since the weights were trained. */ |
| 21 | const DIGEST = "5582db35be828690f086d23bb6ee92c7807696188634bac6c4c8b09730068aa5"; | |
| 21 | const DIGEST = "755d03516ba0122e6b30a04ac575c311e652ef12f38276fa0e5eb6289ed34103"; | |
| 22 | 22 | |
| 23 | 23 | test("the shipped weights unpack to the shape the game asks the policy for", () => { |
| 24 | 24 | const p = unpack(PACKED); |
| 25 | 25 | expect(p.shape.heads).toEqual(HEADS); |
| 26 | 26 | expect(p.shape.obs).toBe(OBS_SIZE); |
| 27 | expect(p.shape.obs).toBe(32); | |
| 27 | expect(p.shape.obs).toBe(33); | |
| 28 | 28 | expect(p.w1.length).toBe(p.shape.hidden * p.shape.obs); |
| 29 | 29 | for (const v of p.w1) expect(Number.isFinite(v)).toBe(true); |
| 30 | 30 | }); |
src/client/weights.ts +10 −10
Diff not shown — a line 10,883 characters wide (limit 2,000). +10 −10
View filesrc/client/world.ts +3 −3
| @@ -30,7 +30,7 @@ import { | ||
| 30 | 30 | import * as rules from "./match.ts"; |
| 31 | 31 | import { CS_HULL, CS_MOVE } from "./movement.ts"; |
| 32 | 32 | import { |
| 33 | WEAPONS, inaccuracy, recoilOffset, | |
| 33 | WEAPONS, inaccuracy, recoilOffset, RECOIL_SHARE, | |
| 34 | 34 | type ThrownId, type WeaponId, type Weapon, |
| 35 | 35 | } from "./weapons.ts"; |
| 36 | 36 | import { botBuy, botThink, type BotBrain } from "./bots.ts"; |
| @@ -382,8 +382,8 @@ export function shoot(world: World, p: Player): boolean { | ||
| 382 | 382 | const [rx, ry] = recoilOffset(w.id, p.shotIndex); |
| 383 | 383 | p.shotIndex += 1; |
| 384 | 384 | const accuracyRoll = (): number => (world.random() + world.random() - 1); |
| 385 | const yaw = p.yaw - rx * 0.6 + accuracyRoll() * spread; | |
| 386 | const pitch = p.pitch - ry * 0.6 + accuracyRoll() * spread; | |
| 385 | const yaw = p.yaw - rx * RECOIL_SHARE + accuracyRoll() * spread; | |
| 386 | const pitch = p.pitch - ry * RECOIL_SHARE + accuracyRoll() * spread; | |
| 387 | 387 | p.punch = [p.punch[0] + (rx - p.punch[0]) * 0.5, p.punch[1] + (ry - p.punch[1]) * 0.5]; |
| 388 | 388 | |
| 389 | 389 | const from = eyePos(p); |
src/rl/arena.test.ts +54 −2
| @@ -4,9 +4,10 @@ import { OBS_SIZE } from "../client/observe.ts"; | ||
| 4 | 4 | import { colOf, openCells, rowOf, walkable, type Level } from "../client/level.ts"; |
| 5 | 5 | import { DE_FORGE } from "../client/map.ts"; |
| 6 | 6 | import { aimAt, bodyCentre, canSee } from "../client/world.ts"; |
| 7 | import { recoilOffset, RECOIL_SHARE } from "../client/weapons.ts"; | |
| 7 | 8 | import { |
| 8 | createArena, EPISODE_TICKS, observeFoe, observeLearner, relevel, reset, SKIP, step, stepInput, | |
| 9 | stillFoe, | |
| 9 | createArena, EPISODE_TICKS, muzzleError, observeFoe, observeLearner, relevel, reset, SKIP, step, | |
| 10 | stepInput, stillFoe, | |
| 10 | 11 | } from "./arena.ts"; |
| 11 | 12 | import { holdoutLevels } from "./maps.ts"; |
| 12 | 13 | |
| @@ -156,3 +157,54 @@ test("the arena leaves no render effects behind to grow without bound", () => { | ||
| 156 | 157 | expect(arena.world.messages.length).toBe(0); |
| 157 | 158 | expect(arena.world.match.phase).toBe("live"); |
| 158 | 159 | }); |
| 160 | ||
| 161 | test("the shaping is paid on where the round would go, not on where the crosshair is", () => { | |
| 162 | const arena = createArena(11, stillFoe); | |
| 163 | const { learner, foe } = arena; | |
| 164 | learner.pos = [0, 0, 2]; | |
| 165 | foe.pos = [700, 0, 2]; | |
| 166 | learner.weapon = "m4a4"; | |
| 167 | const aim = aimAt(learner, bodyCentre(foe)); | |
| 168 | learner.yaw = aim.yaw; | |
| 169 | learner.pitch = aim.pitch; | |
| 170 | // The first round of a burst goes where the crosshair is, so a crosshair on | |
| 171 | // the chest is an error of nothing. | |
| 172 | learner.shotIndex = 0; | |
| 173 | expect(muzzleError(learner, foe)).toBeCloseTo(0, 9); | |
| 174 | // A dozen rounds in, the same crosshair sends the round over the shoulder — | |
| 175 | // which is exactly the bot the old yaw-only shaping was happy with. | |
| 176 | learner.shotIndex = 12; | |
| 177 | expect(muzzleError(learner, foe)).toBeGreaterThan(2); | |
| 178 | // Holding the pattern down puts the muzzle back on the body. | |
| 179 | const [right, up] = recoilOffset("m4a4", 12); | |
| 180 | learner.yaw = aim.yaw + right * RECOIL_SHARE; | |
| 181 | learner.pitch = aim.pitch + up * RECOIL_SHARE; | |
| 182 | expect(muzzleError(learner, foe)).toBeCloseTo(0, 9); | |
| 183 | }); | |
| 184 | ||
| 185 | test("the learner is handed a pair of hands, and they are not the duel's dice", () => { | |
| 186 | const arena = createArena(4242, stillFoe); | |
| 187 | expect(arena.learner.brain).toBe(arena.hands); | |
| 188 | expect(arena.hands.grip).toBeGreaterThan(0); | |
| 189 | // The grip the policy is shown is the grip its hands have. | |
| 190 | const obs = new Float32Array(OBS_SIZE); | |
| 191 | observeLearner(arena, obs); | |
| 192 | expect(obs[OBS_SIZE - 1]).toBeCloseTo(arena.hands.grip, 6); | |
| 193 | // Fresh hands every duel, off a stream of their own: the spawns, the ranges | |
| 194 | // and the opponent a seed draws are the same whatever the hands came out as. | |
| 195 | const grips = new Set<number>(); | |
| 196 | const spots: string[] = []; | |
| 197 | for (let i = 0; i < 8; i++) { | |
| 198 | reset(arena); | |
| 199 | grips.add(arena.hands.grip); | |
| 200 | spots.push(`${arena.learner.pos[0]},${arena.foe.pos[0]}`); | |
| 201 | } | |
| 202 | expect(grips.size).toBeGreaterThan(1); | |
| 203 | const again = createArena(4242, stillFoe); | |
| 204 | const repeat: string[] = []; | |
| 205 | for (let i = 0; i < 8; i++) { | |
| 206 | reset(again); | |
| 207 | repeat.push(`${again.learner.pos[0]},${again.foe.pos[0]}`); | |
| 208 | } | |
| 209 | expect(repeat).toEqual(spots); | |
| 210 | }); |
src/rl/arena.ts +48 −3
| @@ -13,9 +13,11 @@ | ||
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | import { decode, toInput, type Intent } from "../client/action.ts"; |
| 16 | import { makeBrain, botThink } from "../client/bots.ts"; | |
| 16 | import { makeBrain, botThink, ride, type BotBrain } from "../client/bots.ts"; | |
| 17 | 17 | import { openCells, type Level } from "../client/level.ts"; |
| 18 | 18 | import { DE_FORGE } from "../client/map.ts"; |
| 19 | import { grip as gripOf, gripRate } from "../client/spray.ts"; | |
| 20 | import { recoilOffset, RECOIL_SHARE } from "../client/weapons.ts"; | |
| 19 | 21 | import { angleDelta, clamp, dist, resetEffects, rng, type Vec3 } from "../engine/index.ts"; |
| 20 | 22 | import { observe, OBS_SIZE, type Memory } from "../client/observe.ts"; |
| 21 | 23 | import type { Input, Player, World } from "../client/world.ts"; |
| @@ -60,6 +62,17 @@ export type Arena = { | ||
| 60 | 62 | random: () => number; |
| 61 | 63 | /** What the opponent does this tick. Swapped out for self-play. */ |
| 62 | 64 | opponent: (arena: Arena, dt: number) => Input; |
| 65 | /** | |
| 66 | * The learner's hands: how much of its spray pattern it holds and how fast | |
| 67 | * it catches up, drawn afresh each duel from its own stream so the policy | |
| 68 | * meets every rung of the game's skill ladder. Its grip is one of the | |
| 69 | * numbers it observes, so it can aim for the recoil it is actually going to | |
| 70 | * make rather than for an average one. | |
| 71 | */ | |
| 72 | hands: BotBrain; | |
| 73 | /** Where those hands are drawn from, kept apart from `random` on purpose: | |
| 74 | * a duel's spawns, ranges and opponent must not move when the hands do. */ | |
| 75 | handRandom: () => number; | |
| 63 | 76 | /** How far apart a fresh duel starts. The trainer widens it as it goes. */ |
| 64 | 77 | minRange: number; |
| 65 | 78 | maxRange: number; |
| @@ -95,8 +108,11 @@ export function createArena(seed: number, opponent = scriptedFoe, level: Level = | ||
| 95 | 108 | const world = createWorld(level, SHORT, "learner", "ct", 1, seed); |
| 96 | 109 | const learner = world.me; |
| 97 | 110 | const foe = world.players[1] as Player; |
| 111 | const hands = makeBrain(0.65, seed ^ 0x2545f491); | |
| 112 | learner.brain = hands; | |
| 98 | 113 | const arena: Arena = { |
| 99 | 114 | world, open: openOf(level), learner, foe, |
| 115 | hands, handRandom: rng(seed ^ 0x2545f491), | |
| 100 | 116 | memory: { sinceSeen: 9 }, |
| 101 | 117 | foeMemory: { sinceSeen: 9 }, |
| 102 | 118 | ticks: 0, |
| @@ -131,6 +147,7 @@ const equip = (p: Player, weapon: "ak47" | "m4a4"): void => { | ||
| 131 | 147 | p.lastShotAt = -99; |
| 132 | 148 | p.punch = [0, 0]; |
| 133 | 149 | p.damage = 0; |
| 150 | if (p.brain !== undefined) p.brain.pull = [0, 0]; | |
| 134 | 151 | }; |
| 135 | 152 | |
| 136 | 153 | /** |
| @@ -147,17 +164,37 @@ function potentialOf(arena: Arena, visible: boolean): number { | ||
| 147 | 164 | const { learner, foe } = arena; |
| 148 | 165 | if (!learner.alive || !foe.alive) return 0; |
| 149 | 166 | const range = dist(learner.pos, foe.pos); |
| 150 | const aim = Math.abs(angleDelta(learner.yaw, aimAt(learner, bodyCentre(foe)).yaw)); | |
| 167 | const aim = muzzleError(learner, foe); | |
| 151 | 168 | // Three terms: a gentle slope that points the bot the right way from |
| 152 | 169 | // anywhere, and two peaks — one a dozen degrees wide, one three — because |
| 153 | 170 | // "nearly aimed" and "aimed" are the whole difference between a miss and a |
| 154 | 171 | // kill, and a single slope flattens out long before that difference matters. |
| 155 | 172 | const aimed = visible |
| 156 | ? 0.4 * (1 - aim / 180) + 0.5 * Math.exp(-aim / 12) + 0.5 * Math.exp(-aim / 3) | |
| 173 | ? 0.4 * clamp(1 - aim / 180, 0, 1) + 0.5 * Math.exp(-aim / 12) + 0.5 * Math.exp(-aim / 3) | |
| 157 | 174 | : 0; |
| 158 | 175 | return aimed + 0.3 * (1 - clamp(range / 2500, 0, 1)); |
| 159 | 176 | } |
| 160 | 177 | |
| 178 | /** | |
| 179 | * How far off the body the round this bot is about to fire would land, in | |
| 180 | * degrees: the crosshair walked by whatever is left of the spray pattern after | |
| 181 | * the hands have pulled against it, against the line to the chest. | |
| 182 | * | |
| 183 | * This is what the shaping above is paid on, and both halves of it matter. It | |
| 184 | * is the *muzzle* and not the crosshair, so a bot a dozen rounds into a spray | |
| 185 | * is graded on where the twelfth round goes; and it is an angle and not a yaw, | |
| 186 | * so pitch is taught at all — shaping on yaw alone left the pitch to the damage | |
| 187 | * term, and what that taught was to point at the floor and let the climb walk | |
| 188 | * the spray up a body, which is a 22%-accuracy way to win a fight. | |
| 189 | */ | |
| 190 | export function muzzleError(bot: Player, target: Player): number { | |
| 191 | const aim = aimAt(bot, bodyCentre(target)); | |
| 192 | const [right, up] = recoilOffset(bot.weapon, bot.shotIndex); | |
| 193 | const yaw = angleDelta(bot.yaw - right * RECOIL_SHARE, aim.yaw); | |
| 194 | const pitch = aim.pitch - (bot.pitch - up * RECOIL_SHARE); | |
| 195 | return Math.hypot(yaw, pitch); | |
| 196 | } | |
| 197 | ||
| 161 | 198 | /** |
| 162 | 199 | * A fresh duel. Most of the time the two can see each other from the start, |
| 163 | 200 | * because that is the situation worth being good at; the rest of the time they |
| @@ -202,6 +239,11 @@ export function reset(arena: Arena): void { | ||
| 202 | 239 | learner.pitch = 0; |
| 203 | 240 | foe.yaw = aimAt(foe, bodyCentre(learner)).yaw + (random() * 2 - 1) * 90; |
| 204 | 241 | foe.pitch = 0; |
| 242 | // Fresh hands for the learner, off its own stream. | |
| 243 | const skill = arena.handRandom(); | |
| 244 | arena.hands.grip = gripOf(skill); | |
| 245 | arena.hands.gripRate = gripRate(skill); | |
| 246 | arena.hands.pull = [0, 0]; | |
| 205 | 247 | foe.brain = makeBrain( |
| 206 | 248 | 0.15 + random() * Math.max(0.05, arena.maxSkill - 0.15), |
| 207 | 249 | (random() * 0xffffff) | 0, |
| @@ -259,6 +301,9 @@ export function step(arena: Arena, intent: Intent, skip = SKIP): StepResult { | ||
| 259 | 301 | for (let k = 0; k < skip; k++) { |
| 260 | 302 | learner.yaw += intent.yawRate * TICK; |
| 261 | 303 | learner.pitch = clamp(learner.pitch + intent.pitchRate * TICK, -89, 89); |
| 304 | // The hands ride the pattern under the decision, exactly as they do in the | |
| 305 | // game: this is the environment the shipped policy has to be good in. | |
| 306 | ride(learner, arena.hands, arena.hands.grip, TICK); | |
| 262 | 307 | const ammo = learner.ammo[learner.weapon]; |
| 263 | 308 | const tick = stepInput(arena, toInput(intent, ammo !== undefined && ammo.mag === 0), TICK); |
| 264 | 309 | out.reward += tick.reward; |
src/rl/learned.test.ts +6 −6
| @@ -51,7 +51,7 @@ test("the trained policy wins more of the same duels than the bots it replaced", | ||
| 51 | 51 | const scripted = duels("scripted"); |
| 52 | 52 | expect(scripted.won).toBeGreaterThan(0); // the baseline is not a straw man |
| 53 | 53 | expect(learned.won).toBeGreaterThan(scripted.won); |
| 54 | // The measured margin is 72% against 38% over 240 duels; this asks for far | |
| 54 | // The measured margin is 71% against 42% over 240 duels; this asks for far | |
| 55 | 55 | // less, so it fails on a policy that regressed and not on ordinary variance. |
| 56 | 56 | expect(learned.won / DUELS).toBeGreaterThan(0.5); |
| 57 | 57 | }); |
| @@ -64,11 +64,11 @@ test("the trained policy wins more of the same duels than the bots it replaced", | ||
| 64 | 64 | * |
| 65 | 65 | * What a confused bot does here is not lose — it draws. Six seconds of a duel |
| 66 | 66 | * go by, nobody dies, and from the other end of a corridor that is a bot that |
| 67 | * has forgotten what it was doing. On exactly these duels the weights before | |
| 68 | * this pull won 68% and drew 14%, against 71% and 8% on de_forge; the ones | |
| 69 | * this pull ships win 72% and draw 7%, which is the same fight on a map nobody | |
| 70 | * wrote. The threshold below sits between the two, so a policy trained on one | |
| 71 | * map again fails here. | |
| 67 | * has forgotten what it was doing. On exactly these duels the weights #190 | |
| 68 | * ships — trained with hands that ride the recoil and shaping paid on where | |
| 69 | * the round lands — win 73% and draw 10%, against 71% on de_forge, while the | |
| 70 | * scripted baseline wins 49% and draws 19%. The threshold below sits between | |
| 71 | * the two, so a policy trained on one map again fails here. | |
| 72 | 72 | */ |
| 73 | 73 | const UNSEEN_DUELS = 192; |
| 74 | 74 |
static/app.be118e74ac.js added +58
Diff not shown — a line 72,602 characters wide (limit 2,000). +58
View filestatic/app.c6a7c68916.js deleted −58
Diff not shown — a line 71,983 characters wide (limit 2,000). −58
View file
new above
offline