Doc fix + stuck telemetry position для hot-spot analysis

Codex review on c9826ce — 3 findings, 2 actionable, 1 backlog.

## #2 Low: comment в flipperState расходится с реализацией

Старый комментарий писал что press во время releasing rejected, но
тесты и код специально разрешают re-press (responsive design choice).

Fixed: переписан state diagram с explicit «releasing» state как
intermediate (cooldown ещё НЕ активен). Добавлено явное упоминание
что re-press allowed во время releasing — это design choice, не bug.

## #1 High backlog: bonus telemetry для hot-spot analysis

Codex отметил residual ball_unstuck/hard_respawn без конкретных hot-spot
данных. Чтобы убрать blind tuning, добавил позицию + bumper type в
telemetry events:

- ball_unstuck: + ballPositionX, ballPositionY, overlappingBumperType
- ball_hard_respawn: + ballPositionX, ballPositionY

findBumperTypeAtPosition(pos): O(N) lookup среди fixed+setup bumpers
по координатам (с 1px tolerance). Возвращает «<source>:<type>» строку
для аналитики (e.g., «player_setup:curve» — самый частый stuck-spot
будет видно сразу при анализе аналитики).

Этого достаточно для будущего targeted fix без необходимости guess-tuning.

## #3 Known backlog

npm audit Vite/esbuild moderate — отложен до Phase 5 Vite 8 upgrade.

186/186 tests, typecheck/lint/build .

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
aevgarik
2026-05-24 18:43:33 +03:00
parent c9826ce1f1
commit b40bc1a516
2 changed files with 39 additions and 10 deletions

View File

@@ -2,16 +2,25 @@
// Extracted из Flipper.ts чтобы isPressed/cooldown логика была unit-тестируемой
// без mocking Phaser tweens.
//
// State transitions:
// idle (isPressed=false, cooldown expired)
// press(now) → pressed (isPressed=true)
// press during cooldown / already pressed → rejected
// pressed
// → release() → releasing (isPressed=false, tween в процессе)
// → press → rejected (already pressed)
// releasing
// → onReleaseComplete(now) → cooldown started (cooldownUntil = now + cooldownMs)
// → press(now) во время releasing → rejected (если в cooldown)
// States and transitions:
// idle (isPressed=false, cooldownUntil expired)
// press(now) → pressed
// press during cooldown → rejected
//
// pressed (isPressed=true)
// release() → releasing
// press → rejected (already pressed)
//
// releasing (isPressed=false, release-tween в процессе, cooldown ЕЩЁ НЕ активен)
// press(now) → pressed (ALLOWED — responsive re-press до завершения tween;
// cooldownUntil ещё старое значение, canPress=true)
// onReleaseComplete(now) → cooldown starts (cooldownUntil = now + cooldownMs)
//
// cooldown (после onReleaseComplete до истечения cooldownMs)
// press(now < cooldownUntil) → rejected
// time passes → idle
//
// Re-press allowed во время releasing — design choice для responsive control.
//
// Flipper.ts wrapper'ит это с Phaser tweens + Matter body updates.

View File

@@ -584,17 +584,37 @@ export class MatchScene extends Phaser.Scene {
matchId: this.matchId,
attempt: this.stuckWatchdog.getNudgeAttempts(),
hadOverlap: overlap !== null,
// Position для hot-spot analysis — где конкретно ball застревает (codex backlog #1)
ballPositionX: Math.round(bp.x),
ballPositionY: Math.round(bp.y),
overlappingBumperType: overlap?.body
? this.findBumperTypeAtPosition(overlap.body.position)
: null,
matchTimeSeconds: (this.time.now - this.matchStartTime) / 1000,
});
}
/** Найти bumper type на позиции (для telemetry hot-spot analysis). */
private findBumperTypeAtPosition(pos: { x: number; y: number }): string | null {
for (const b of [...this.fixedBumpers, ...this.setupBumpers]) {
const body = b.getBody();
if (Math.abs(body.position.x - pos.x) < 1 && Math.abs(body.position.y - pos.y) < 1) {
return `${b.source}:${b.type}`;
}
}
return null;
}
/**
* Hard respawn — мяч действительно не разлипается после 3 nudge.
* Уничтожаем тело, спавним из бокового порта в нейтральном цвете.
*/
private applyStuckHardRespawn(): void {
const bp = this.ball.position;
this.trackEvent('ball_hard_respawn', {
matchId: this.matchId,
ballPositionX: Math.round(bp.x),
ballPositionY: Math.round(bp.y),
matchTimeSeconds: (this.time.now - this.matchStartTime) / 1000,
});
this.ball.destroy();