quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Record where a player is looking in every phase, alive or not
Ben: "the screen is still shaking on random rounds... when the round starts,
moving and standing still". Then the two facts that found it: the readout
said 30 Hz in, so the wire was fine, and pressing V stopped it dead.
That last one is the whole diagnosis. The two views draw with different
angles — `facing = mode === "fp" ? head.angle : eye.angle`. Top-down uses the
server's; first person uses the browser's. So a fight between those two is
invisible in one view and violent in the other.
And they were fighting. `player.angle = input.angle` lived inside
movePlayer, which:
- the `end` and `over` phases return before ever reaching, and
- a dead player never enters (it returns on `!player.alive`).
So between rounds, and for anyone lying dead, the server's copy of your angle
froze while your browser kept following your mouse. yaw.js takes the server's
angle only when it disagrees with what the client recently sent — and once
those sent angles aged out of SENT_MEMORY, the frozen one stopped looking
like an echo and started looking like a correction. The view snapped back to
where you were last recorded looking, over and over.
Measured, a client looking around for twenty seconds:
alive, live phase 0.1 snaps a second
alive, END phase (round just decided) 0.8
DEAD, waiting for the round 0.8
alive, buy phase 0.1
After: 0.1 everywhere, which is the baseline in a live round.
The fix is one rule, in one place, and it is the rule that was wrong rather
than a number that needed tuning: looking around is not moving. Movement is
what a phase and a death gate; where your head points is recorded every tick
regardless. The assignment moves out of movePlayer into tick, for every
player, in every phase.
Why the buy phase was fine and the end phase was not — movePlayer runs in
buy — is also why this only ever bit at a round turning over, which is
exactly where it was reported.
305 tests. The new one walks a seat through live, dead, end, over and buy and
asserts the angle follows; putting the assignment back inside movePlayer
fails it and nothing else.
2 files
changed +62
−1
src/match.test.ts +43
| @@ -414,3 +414,46 @@ test("the deal is the seeded shuffle, so the same match replays the same", () => | ||
| 414 | 414 | expect(newMatch(77).players.map((p) => p.name)).toEqual(newMatch(77).players.map((p) => p.name)); |
| 415 | 415 | expect(newMatch(77).players.map((p) => p.name)).not.toEqual(newMatch(78).players.map((p) => p.name)); |
| 416 | 416 | }); |
| 417 | ||
| 418 | const round1 = (n: number) => Math.round(n * 1000) / 1000; | |
| 419 | ||
| 420 | test("where you are looking is recorded in every phase, alive or not", () => { | |
| 421 | // The bug this pins: `player.angle = input.angle` lived inside movePlayer, | |
| 422 | // which the end and over phases return before reaching and which a dead | |
| 423 | // player never enters. The server's copy of your angle froze, the browser | |
| 424 | // kept its own, and once the angles the client had recently sent aged out | |
| 425 | // of its memory the frozen one read as a correction — so the view snapped | |
| 426 | // back, about 0.8 times a second, in first person only. Looking around is | |
| 427 | // not moving, and no phase and no death stops you doing it. | |
| 428 | const state = newMatch(5); | |
| 429 | const me = state.players[0]!; | |
| 430 | me.bot = false; | |
| 431 | ||
| 432 | const looksFollow = (label: string) => { | |
| 433 | for (const want of [0.4, -1.2, 2.9]) { | |
| 434 | setInput(me, { angle: want }); | |
| 435 | tick(state, 1 / 30); | |
| 436 | expect(`${label}: ${round1(me.angle)}`).toBe(`${label}: ${round1(want)}`); | |
| 437 | } | |
| 438 | }; | |
| 439 | ||
| 440 | looksFollow("live"); | |
| 441 | ||
| 442 | me.alive = false; | |
| 443 | me.hp = 0; | |
| 444 | looksFollow("dead"); | |
| 445 | me.alive = true; | |
| 446 | me.hp = 100; | |
| 447 | ||
| 448 | state.phase = "end"; | |
| 449 | state.clock = 99; | |
| 450 | looksFollow("between rounds"); | |
| 451 | ||
| 452 | state.phase = "over"; | |
| 453 | state.clock = 99; | |
| 454 | looksFollow("match over"); | |
| 455 | ||
| 456 | state.phase = "buy"; | |
| 457 | state.clock = 99; | |
| 458 | looksFollow("buy"); | |
| 459 | }); |
src/match.ts +19 −1
| @@ -314,6 +314,24 @@ export function tick(state: MatchState, dt: number): void { | ||
| 314 | 314 | state.shots = state.shots.filter((s) => s.age < 0.12); |
| 315 | 315 | for (const event of state.events) event.age += dt; |
| 316 | 316 | |
| 317 | // Where you are LOOKING is recorded every tick, in every phase, alive or | |
| 318 | // dead. Moving is what the phases and dying gate; turning your head is not. | |
| 319 | // | |
| 320 | // This used to live inside movePlayer, which the round-end and match-over | |
| 321 | // phases return before reaching and which a dead player never enters. The | |
| 322 | // server's copy of your angle therefore froze between rounds and while you | |
| 323 | // were down — and the browser, which owns your view and only takes the | |
| 324 | // server's angle when the server disagrees, kept looking where the mouse | |
| 325 | // said. Once the angles a client had recently sent aged out of its memory | |
| 326 | // (yaw.js SENT_MEMORY), the frozen one read as a genuine correction and the | |
| 327 | // view snapped back to it. Measured: about 0.8 snaps a second while dead or | |
| 328 | // between rounds, against 0.0 in a live round. It was invisible in the | |
| 329 | // top-down view, which draws the server's angle, and violent in first | |
| 330 | // person, which draws the browser's — and that asymmetry is what made it | |
| 331 | // look like the renderer rather than the rules. | |
| 332 | for (const player of state.players) | |
| 333 | if (Number.isFinite(player.input.angle)) player.angle = player.input.angle; | |
| 334 | ||
| 317 | 335 | if (state.phase === "over") { |
| 318 | 336 | state.clock -= dt; |
| 319 | 337 | if (state.clock <= 0) resetMatch(state); |
| @@ -385,7 +403,7 @@ export function speedOf(state: MatchState, player: Player): number { | ||
| 385 | 403 | function movePlayer(state: MatchState, player: Player, dt: number, penned: boolean): void { |
| 386 | 404 | if (!player.alive) return; |
| 387 | 405 | const input = player.input; |
| 388 | if (Number.isFinite(input.angle)) player.angle = input.angle; | |
| 406 | // The angle is applied for every player in tick(), whatever the phase. | |
| 389 | 407 | let mx = input.mx; |
| 390 | 408 | let my = input.my; |
| 391 | 409 | const len = Math.hypot(mx, my); |