diff --git a/README.md b/README.md index 94b0078..2507b1c 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ src/ scenes/ Phaser scenes (Boot, Preload, MainMenu, Setup, Match, Result) game/ Game objects (Ball, Flipper, Bumper, Table, Match orchestration) ai/ AI personalities (Defensive, Aggressive, Trickster, Ghost) + heuristics - scoring/ calculateScores + MatchTracker (last-touch + bumper points) + scoring/ calculateScores + MatchTracker (defensive setup scoring + bumper points) ``` ### Фазовый план diff --git a/playtest-protocol.md b/playtest-protocol.md index 7ccbb60..fa54d13 100644 --- a/playtest-protocol.md +++ b/playtest-protocol.md @@ -42,7 +42,7 @@ per личность, названный тестером без подсказ | Личность | Tells | |---|---| | **Defensive (Бэтти)** | Setup: 2 slingshot в углах ворот + 1 турбо центр + 1 curve + 1 пусто. Поведение: реакция надёжная (`reliabilityMul: 1.1`, capped 1.0 = почти 100 %), чуть быстрее (`reactionMul: 0.9`). AI пропускает редко; матчи длиннее обычного. | -| **Aggressive (Турбо)** | Setup: 2 турбо в центре (фарм) + 1 slingshot угол + 1 curve + 1 standard. Поведение: реагирует с большего расстояния (`triggerDistBonus: +80px`), чаще промахивается (`reliabilityMul: 0.85`). Высокий счёт у обеих сторон, быстрые матчи. | +| **Aggressive (Турбо)** | Setup: 2 турбо в своей зоне (defensive trap против мяча игрока) + 1 slingshot угол + 1 curve + 1 standard. Поведение: реагирует с большего расстояния (`triggerDistBonus: +80px`), чаще промахивается (`reliabilityMul: 0.85`). Быстрые матчи; игрок, застрявший в setup Турбо, быстро отдаёт ему очки. | | **Trickster (Хитрый Хо)** | Setup: 4 curve bumper'а (лабиринт) + 1 турбо центр. Поведение: 15 % случайный спайк jitter (×1.3) — реакция «дёргается». Непредсказуемый счёт; необычные траектории мяча. | | **Ghost (Эхо)** | Setup: balanced (2 slingshot + 1 турбо + 1 curve + 1 standard). Поведение: 15 % «echo double-swing» — обе стороны активируются одновременно, даже если мяч идёт только в одну. Медианный счёт; характерный двойной flip. | diff --git a/src/game/Bumper.ts b/src/game/Bumper.ts index 34a9ac2..dff0f7a 100644 --- a/src/game/Bumper.ts +++ b/src/game/Bumper.ts @@ -1,10 +1,9 @@ import * as Phaser from 'phaser'; import { PALETTE, BUMPER_POINTS } from '../config/defaults'; -import type { BumperType } from '../types'; +import type { BumperType, BumperScoreSource } from '../types'; -// Bumper — статичный круглый bumper с возможностью разных типов. -// Phase 1: используется только Standard (для центральных fixed bumpers). -// Phase 2: используются все 4 типа для setup-bumpers (см. SetupBumper.ts). +// Bumper — статичный круглый bumper. +// v3.10 Defensive setup scoring: source определяет credit-логику в MatchTracker. export interface BumperEvents { onHit?: (bumper: Bumper, pointsEarned: number) => void; @@ -12,6 +11,7 @@ export interface BumperEvents { export class Bumper { readonly type: BumperType; + readonly source: BumperScoreSource; readonly points: number; private body: MatterJS.BodyType; private sprite: Phaser.GameObjects.Arc; @@ -28,10 +28,12 @@ export class Bumper { x: number, y: number, type: BumperType, + source: BumperScoreSource, events: BumperEvents = {}, ) { this.scene = scene; this.type = type; + this.source = source; this.points = BUMPER_POINTS[type]; this.events = events; diff --git a/src/scenes/MatchScene.ts b/src/scenes/MatchScene.ts index 6678027..e38b1c9 100644 --- a/src/scenes/MatchScene.ts +++ b/src/scenes/MatchScene.ts @@ -118,22 +118,20 @@ export class MatchScene extends Phaser.Scene { ); // === Fixed bumpers (3 в центральной зоне, симметрично) === + // source = 'fixed_last_touch' → очки last-touch'еру (см. v3.10). const cy = g.centerY; - this.fixedBumpers.push( - new Bumper(this, this.matter, GAME_WIDTH * 0.25, cy - 80, 'standard', { - onHit: (_, points) => this.handleBumperHit(points), - }), - ); - this.fixedBumpers.push( - new Bumper(this, this.matter, GAME_WIDTH * 0.75, cy - 80, 'standard', { - onHit: (_, points) => this.handleBumperHit(points), - }), - ); - this.fixedBumpers.push( - new Bumper(this, this.matter, GAME_WIDTH * 0.5, cy + 80, 'standard', { - onHit: (_, points) => this.handleBumperHit(points), - }), - ); + const fixedPositions: Array<[number, number]> = [ + [GAME_WIDTH * 0.25, cy - 80], + [GAME_WIDTH * 0.75, cy - 80], + [GAME_WIDTH * 0.5, cy + 80], + ]; + for (const [fx, fy] of fixedPositions) { + this.fixedBumpers.push( + new Bumper(this, this.matter, fx, fy, 'standard', 'fixed_last_touch', { + onHit: (bumper, points) => this.handleBumperHit(bumper, points), + }), + ); + } // === AI setup-bumpers (если есть playerSetup в data; в Phase 1 нет setup-phase) === if (this.sceneData.playerSetup) { @@ -261,13 +259,15 @@ export class MatchScene extends Phaser.Scene { private placeSetupBumpers(setup: SetupConfig, side: 'player' | 'ai'): void { const g = this.table.geometry; const baseY = side === 'player' ? g.playerGoalY - 200 : g.aiGoalY + 200; + const source: 'player_setup' | 'ai_setup' = + side === 'player' ? 'player_setup' : 'ai_setup'; for (const slot of setup.slots) { if (!slot.bumperType) continue; const pos = this.slotPosition(slot.slotId, baseY, side); if (!pos) continue; - const bumper = new Bumper(this, this.matter, pos.x, pos.y, slot.bumperType, { - onHit: (_, points) => this.handleBumperHit(points), + const bumper = new Bumper(this, this.matter, pos.x, pos.y, slot.bumperType, source, { + onHit: (b, points) => this.handleBumperHit(b, points), }); this.setupBumpers.push(bumper); } @@ -301,10 +301,32 @@ export class MatchScene extends Phaser.Scene { this.ball.setOwner(side); } - private handleBumperHit(points: number): void { - if (points <= 0) return; + private handleBumperHit(bumper: Bumper, points: number): void { + if (points <= 0) return; // cooldown — turbo returns 0, не считаем const matchTimeSec = (this.time.now - this.matchStartTime) / 1000; - this.tracker.recordBumperHit(points, this.time.now, matchTimeSec); + const ballOwner = this.tracker.getOwner(); + const result = this.tracker.recordBumperHit( + points, + bumper.source, + ballOwner, + bumper.type, + this.time.now, + matchTimeSec, + ); + + // Particle effect — цвет per credited side (v3.10 DoD) + this.spawnBumperParticles(bumper, result.creditedTo); + + // Telemetry: bumper_hit (v3.10 spec — telemetry-spec.md) + this.trackEvent('bumper_hit', { + matchId: this.matchId, + bumperType: bumper.type, + source: bumper.source, + ballOwnerBeforeHit: ballOwner, + creditedTo: result.creditedTo, + pointsEarned: result.pointsEarned, + matchTimeSeconds: matchTimeSec, + }); } private handleCollisions(pairs: MatterJS.IPair[]): void { @@ -402,9 +424,11 @@ export class MatchScene extends Phaser.Scene { const now = this.time.now; const durationSec = this.tracker.getMatchDurationSec(now); - // Player получает свои bumper-очки + // Player + AI bumper-очки (v3.10 defensive scoring) const playerBumperPoints = this.tracker.getPlayerBumperPoints(); const playerBumperHits = this.tracker.getPlayerBumperHits(); + const aiBumperPoints = this.tracker.getAIBumperPoints(); + const aiBumperHits = this.tracker.getAIBumperHits(); const playerGoals = this.tracker.getPlayerGoals(); const aiGoals = this.tracker.getAIGoals(); @@ -436,6 +460,8 @@ export class MatchScene extends Phaser.Scene { goalsAgainst: aiGoals, bumperPointsEarned: playerBumperPoints, bumperHitsCount: playerBumperHits, + aiBumperPointsEarned: aiBumperPoints, + aiBumperHitsCount: aiBumperHits, boostersUsed: this.tracker.getPlayerBoostersUsed(), victory: playerWon, durationSeconds: durationSec, @@ -458,6 +484,8 @@ export class MatchScene extends Phaser.Scene { goalsAgainst: aiGoals, bumperHitsCount: playerBumperHits, bumperPointsEarned: playerBumperPoints, + aiBumperHitsCount: aiBumperHits, + aiBumperPointsEarned: aiBumperPoints, boostersUsed: matchResult.boostersUsed, durationSeconds: durationSec, seasonalPointsBase: matchResult.seasonalPointsBase, @@ -485,6 +513,51 @@ export class MatchScene extends Phaser.Scene { }); } + /** + * v3.10 DoD: 12 частиц радиально от bumper'а, цвет по creditedTo. + * player → magenta, ai → cyan, null → white/grey (hit без очков). + * Реализация: pseudo-particles через short-lived Arc + tween (без ParticleEmitter). + */ + private spawnBumperParticles( + bumper: Bumper, + creditedTo: 'player' | 'ai' | null, + ): void { + const body = bumper.getBody(); + const cx = body.position.x; + const cy = body.position.y; + const color = + creditedTo === 'player' ? PALETTE.player + : creditedTo === 'ai' ? PALETTE.ai + : 0x888888; + const alpha = creditedTo === null ? 0.5 : 0.9; + const count = 12; + const radius = 3; + const travel = 36; + + for (let i = 0; i < count; i++) { + const angle = (i / count) * Math.PI * 2; + const dx = Math.cos(angle) * travel; + const dy = Math.sin(angle) * travel; + const dot = this.add.circle(cx, cy, radius, color, alpha); + dot.setDepth(50); + this.tweens.add({ + targets: dot, + x: cx + dx, + y: cy + dy, + alpha: 0, + scale: 0.4, + duration: 320, + ease: 'Cubic.easeOut', + onComplete: () => dot.destroy(), + }); + } + + // Screenshake — только на credited hit (иначе нейтральные bounce дёргают экран). + if (creditedTo !== null) { + this.cameras.main.shake(80, 0.0025); + } + } + private buildTouchZones(): void { // 3 touch zones внизу экрана для mobile + desktop click. // Visual: полупрозрачные overlay'и; реагируют на pointerdown. @@ -555,9 +628,9 @@ export class MatchScene extends Phaser.Scene { }) .setOrigin(0.5); - // BP — наверху под таймером (раньше был в зоне флипперов/booster overlay) + // BP — сравнительный «P x / AI y» (v3.10 defensive scoring видно обе стороны) this.bumperPointsText = this.add - .text(GAME_WIDTH / 2, 145, 'BP: 0', { + .text(GAME_WIDTH / 2, 145, 'BP P 0 / AI 0', { fontFamily: 'monospace', fontSize: '18px', color: '#ffbe0b', @@ -573,7 +646,9 @@ export class MatchScene extends Phaser.Scene { const min = Math.floor(t / 60); const sec = Math.floor(t % 60); this.timerText.setText(`${min}:${sec.toString().padStart(2, '0')}`); - this.bumperPointsText.setText(`BP: ${this.tracker.getPlayerBumperPoints()}`); + this.bumperPointsText.setText( + `BP P ${this.tracker.getPlayerBumperPoints()} / AI ${this.tracker.getAIBumperPoints()}`, + ); } private showCenterMessage(message: string, color: number, duration: number): void { diff --git a/src/scenes/ResultScene.ts b/src/scenes/ResultScene.ts index f604ca4..ce26b3c 100644 --- a/src/scenes/ResultScene.ts +++ b/src/scenes/ResultScene.ts @@ -41,9 +41,10 @@ export class ResultScene extends Phaser.Scene { }) .setOrigin(0.5); - // Sub-stats + // Sub-stats (v3.10 defensive scoring — показываем обе стороны) const statsBlock = [ - `Bumper-очки: ${matchResult.bumperPointsEarned}`, + `Bumper-очки: P ${matchResult.bumperPointsEarned} / AI ${matchResult.aiBumperPointsEarned}`, + `Bumper-хитов: P ${matchResult.bumperHitsCount} / AI ${matchResult.aiBumperHitsCount}`, `Бустеров использовано: ${matchResult.boostersUsed}`, `Длительность: ${Math.round(matchResult.durationSeconds)}с`, '', diff --git a/src/scoring/MatchTracker.test.ts b/src/scoring/MatchTracker.test.ts new file mode 100644 index 0000000..bff5b8c --- /dev/null +++ b/src/scoring/MatchTracker.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { MatchTracker, computeCreditedTo } from './MatchTracker'; +import type { BumperScoreSource, BallOwner } from '../types'; + +// v3.10 Defensive setup scoring — 9-cell credit matrix: +// +// ballOwner = player | ai | neutral +// fixed_last_touch → player | ai | null +// player_setup → null | player | null +// ai_setup → ai | null | null + +describe('computeCreditedTo — 9-cell matrix', () => { + type Case = [BumperScoreSource, BallOwner, 'player' | 'ai' | null]; + const cases: Case[] = [ + ['fixed_last_touch', 'player', 'player'], + ['fixed_last_touch', 'ai', 'ai'], + ['fixed_last_touch', 'neutral', null], + + ['player_setup', 'player', null], // self-farm: запрещено + ['player_setup', 'ai', 'player'], // defensive trap: атакующий AI кормит защитника + ['player_setup', 'neutral', null], + + ['ai_setup', 'player', 'ai'], // defensive trap: атакующий игрок кормит AI + ['ai_setup', 'ai', null], // AI self-farm: запрещено + ['ai_setup', 'neutral', null], + ]; + + for (const [source, ballOwner, expected] of cases) { + const desc = `${source} × ballOwner=${ballOwner} → ${expected ?? 'null'}`; + it(desc, () => { + expect(computeCreditedTo(source, ballOwner)).toBe(expected); + }); + } +}); + +describe('MatchTracker.recordBumperHit — credit и point-counters', () => { + let tracker: MatchTracker; + beforeEach(() => { + tracker = new MatchTracker(0); + }); + + it('fixed + player → player BP++', () => { + tracker.setOwner('player', 100); + const result = tracker.recordBumperHit(5, 'fixed_last_touch', 'player', 'standard', 200, 0.2); + expect(result.creditedTo).toBe('player'); + expect(result.pointsEarned).toBe(5); + expect(tracker.getPlayerBumperPoints()).toBe(5); + expect(tracker.getAIBumperPoints()).toBe(0); + expect(tracker.getPlayerBumperHits()).toBe(1); + expect(tracker.getAIBumperHits()).toBe(0); + }); + + it('player_setup + player ball → 0 очков (self-farm запрещён)', () => { + const result = tracker.recordBumperHit(10, 'player_setup', 'player', 'turbo', 0, 0); + expect(result.creditedTo).toBe(null); + expect(result.pointsEarned).toBe(0); + expect(tracker.getPlayerBumperPoints()).toBe(0); + expect(tracker.getAIBumperPoints()).toBe(0); + expect(tracker.getUncreditedBumperHits()).toBe(1); + }); + + it('player_setup + AI ball → player BP+10 (defensive trap)', () => { + const result = tracker.recordBumperHit(10, 'player_setup', 'ai', 'turbo', 0, 0); + expect(result.creditedTo).toBe('player'); + expect(result.pointsEarned).toBe(10); + expect(tracker.getPlayerBumperPoints()).toBe(10); + expect(tracker.getPlayerBumperHits()).toBe(1); + }); + + it('ai_setup + player ball → AI BP+3 (defensive trap)', () => { + const result = tracker.recordBumperHit(3, 'ai_setup', 'player', 'slingshot', 0, 0); + expect(result.creditedTo).toBe('ai'); + expect(result.pointsEarned).toBe(3); + expect(tracker.getAIBumperPoints()).toBe(3); + expect(tracker.getAIBumperHits()).toBe(1); + }); + + it('ai_setup + AI ball → 0 (AI self-farm запрещён)', () => { + const result = tracker.recordBumperHit(10, 'ai_setup', 'ai', 'turbo', 0, 0); + expect(result.creditedTo).toBe(null); + expect(result.pointsEarned).toBe(0); + expect(tracker.getAIBumperPoints()).toBe(0); + expect(tracker.getUncreditedBumperHits()).toBe(1); + }); + + it('neutral ball + любой bumper → 0 очков', () => { + const fixedR = tracker.recordBumperHit(5, 'fixed_last_touch', 'neutral', 'standard', 0, 0); + const psR = tracker.recordBumperHit(10, 'player_setup', 'neutral', 'turbo', 0, 0); + const aiR = tracker.recordBumperHit(3, 'ai_setup', 'neutral', 'slingshot', 0, 0); + expect(fixedR.creditedTo).toBe(null); + expect(psR.creditedTo).toBe(null); + expect(aiR.creditedTo).toBe(null); + expect(tracker.getPlayerBumperPoints()).toBe(0); + expect(tracker.getAIBumperPoints()).toBe(0); + expect(tracker.getUncreditedBumperHits()).toBe(3); + }); +}); + +describe('MatchTracker.recordBumperHit — стэк очков и событий', () => { + it('многократные hits корректно накапливают BP per сторона', () => { + const t = new MatchTracker(0); + // Player атакует — player получает 3 hits через ai_setup (по 3 pts slingshot) + t.recordBumperHit(3, 'ai_setup', 'player', 'slingshot', 0, 0); + t.recordBumperHit(3, 'ai_setup', 'player', 'slingshot', 100, 0.1); + t.recordBumperHit(10, 'ai_setup', 'player', 'turbo', 200, 0.2); + expect(t.getAIBumperPoints()).toBe(16); + expect(t.getAIBumperHits()).toBe(3); + expect(t.getPlayerBumperPoints()).toBe(0); + }); + + it('симметричный сценарий: AI атакует — player фармит через player_setup', () => { + const t = new MatchTracker(0); + t.recordBumperHit(10, 'player_setup', 'ai', 'turbo', 0, 0); + t.recordBumperHit(10, 'player_setup', 'ai', 'turbo', 100, 0.1); + t.recordBumperHit(5, 'player_setup', 'ai', 'standard', 200, 0.2); + expect(t.getPlayerBumperPoints()).toBe(25); + expect(t.getPlayerBumperHits()).toBe(3); + expect(t.getAIBumperPoints()).toBe(0); + }); + + it('central fixed bumpers фармят обе стороны по smene владения', () => { + const t = new MatchTracker(0); + t.recordBumperHit(5, 'fixed_last_touch', 'player', 'standard', 0, 0); + t.setOwner('ai', 50); + t.recordBumperHit(5, 'fixed_last_touch', 'ai', 'standard', 100, 0.1); + t.recordBumperHit(5, 'fixed_last_touch', 'ai', 'standard', 200, 0.2); + expect(t.getPlayerBumperPoints()).toBe(5); + expect(t.getAIBumperPoints()).toBe(10); + }); + + it('events log пишет все hits с метаданными для telemetry', () => { + const t = new MatchTracker(0); + t.recordBumperHit(5, 'fixed_last_touch', 'player', 'standard', 0, 0); + t.recordBumperHit(0, 'fixed_last_touch', 'neutral', 'standard', 100, 0.1); + const events = t.getEvents().filter((e) => e.type === 'bumper_hit'); + expect(events).toHaveLength(2); + expect(events[0]?.data).toMatchObject({ + source: 'fixed_last_touch', + bumperType: 'standard', + ballOwner: 'player', + creditedTo: 'player', + pointsEarned: 5, + }); + expect(events[1]?.data).toMatchObject({ + creditedTo: null, + pointsEarned: 0, + }); + }); +}); + +describe('MatchTracker.recordGoal — гол не зависит от ballOwner', () => { + it('player забил → playerGoals++, isAutogoal=false fix to AI side ball', () => { + const t = new MatchTracker(0); + t.setOwner('ai', 0); // мяч принадлежал AI + t.recordGoal('player', 100, 1.0, false); // crossedGoal=ai_goal → scorer=player + expect(t.getPlayerGoals()).toBe(1); + expect(t.getAIGoals()).toBe(0); + }); + + it('autogoal: ballOwner совпал с владельцем пробитых ворот, scorer = противник', () => { + const t = new MatchTracker(0); + t.setOwner('player', 0); // игрок владел мячом + // мяч прошёл в player_goal → scorer = ai (противоположный) + // isAutogoal = ballOwner === ownerOf(player_goal) === 'player' → true + t.recordGoal('ai', 100, 1.0, true); + expect(t.getAIGoals()).toBe(1); + expect(t.getPlayerGoals()).toBe(0); + const event = t.getEvents().find((e) => e.type === 'goal'); + expect(event?.data).toMatchObject({ + scorer: 'ai', + isAutogoal: true, + ballOwnershipBeforeGoal: 'player', + }); + }); +}); diff --git a/src/scoring/MatchTracker.ts b/src/scoring/MatchTracker.ts index 032719a..3579239 100644 --- a/src/scoring/MatchTracker.ts +++ b/src/scoring/MatchTracker.ts @@ -1,7 +1,33 @@ -import type { BallOwner } from '../types'; +import type { BallOwner, BumperScoreSource, BumperType } from '../types'; // MatchTracker — runtime-state матча. // Хранит счёт голов, owner мяча, накопленные bumper-очки per сторона, события (для telemetry). +// v3.10 Defensive setup scoring: recordBumperHit принимает source + ballOwner, +// сам выводит creditedTo по правилам data-contracts.md#bumper-scoring-event-model. + +/** Результат recordBumperHit — нужен MatchScene для telemetry bumper_hit event. */ +export interface BumperHitResult { + creditedTo: 'player' | 'ai' | null; + pointsEarned: number; +} + +/** + * v3.10 credit-логика — pure function для unit-тестов. + * Возвращает 'player' | 'ai' | null. null = hit не даёт очки. + */ +export function computeCreditedTo( + source: BumperScoreSource, + ballOwner: BallOwner, +): 'player' | 'ai' | null { + switch (source) { + case 'fixed_last_touch': + return ballOwner === 'neutral' ? null : ballOwner; + case 'player_setup': + return ballOwner === 'ai' ? 'player' : null; + case 'ai_setup': + return ballOwner === 'player' ? 'ai' : null; + } +} export interface MatchEvent { type: 'goal' | 'bumper_hit' | 'flipper_hit' | 'owner_change'; @@ -21,9 +47,10 @@ export class MatchTracker { private playerBumperPoints = 0; private aiBumperPoints = 0; - // Counters для telemetry + // Counters credited hits (для telemetry / leaderboard) private playerBumperHits = 0; private aiBumperHits = 0; + private uncreditedBumperHits = 0; // hit, который получил creditedTo=null private playerBoostersUsed = 0; private aiBoostersUsed = 0; @@ -56,24 +83,52 @@ export class MatchTracker { }); } - // === Bumper hit === - recordBumperHit(points: number, now: number, matchTimeSec: number): void { - if (this.ballOwner === 'neutral') { - // Серый мяч — очки сгорают (см. concept-design.md) - return; - } - if (this.ballOwner === 'player') { - this.playerBumperPoints += points; + // === Bumper hit (v3.10 defensive setup scoring) === + /** + * Принимает raw points (по типу bumper'а) + source + ballOwner на момент удара. + * Применяет credit-логику per data-contracts.md#bumper-scoring-event-model: + * fixed_last_touch → ballOwner (neutral=null) + * player_setup → 'player', только если ballOwner === 'ai' (defensive trap) + * ai_setup → 'ai', только если ballOwner === 'player' + * + * Если creditedTo === null — points сгорают, hit учитывается как uncredited + * (для visual feedback и аналитики, не для leaderboard). + */ + recordBumperHit( + rawPoints: number, + source: BumperScoreSource, + ballOwner: BallOwner, + bumperType: BumperType, + now: number, + matchTimeSec: number, + ): BumperHitResult { + const creditedTo = computeCreditedTo(source, ballOwner); + const pointsEarned = creditedTo !== null ? rawPoints : 0; + + if (creditedTo === 'player') { + this.playerBumperPoints += pointsEarned; this.playerBumperHits += 1; - } else { - this.aiBumperPoints += points; + } else if (creditedTo === 'ai') { + this.aiBumperPoints += pointsEarned; this.aiBumperHits += 1; + } else { + this.uncreditedBumperHits += 1; } + this.events.push({ type: 'bumper_hit', timestamp: now, - data: { owner: this.ballOwner, points, matchTimeSec }, + data: { + source, + bumperType, + ballOwner, + creditedTo, + pointsEarned, + matchTimeSec, + }, }); + + return { creditedTo, pointsEarned }; } // === Goal === @@ -118,6 +173,12 @@ export class MatchTracker { getPlayerBumperHits(): number { return this.playerBumperHits; } + getAIBumperHits(): number { + return this.aiBumperHits; + } + getUncreditedBumperHits(): number { + return this.uncreditedBumperHits; + } getPlayerBoostersUsed(): number { return this.playerBoostersUsed; } diff --git a/src/types/index.ts b/src/types/index.ts index 2587e31..5cfc447 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,6 +17,10 @@ export type SetupSlotId = export type BumperType = 'standard' | 'slingshot' | 'curve' | 'turbo'; +// v3.10 Defensive setup scoring — источник bumper-hit определяет credit-логику. +// см. ~/Knowledge/Projects/pinball-duel/concepts/data-contracts.md#bumper-scoring-event-model +export type BumperScoreSource = 'fixed_last_touch' | 'player_setup' | 'ai_setup'; + export interface SetupConfig { // 5 активных slots per matchа (выбраны game design'ом из 7 anchor positions) // см. data-contracts.md#setupconfig-schema @@ -134,8 +138,10 @@ export interface MatchResult { aiSetup: SetupConfig; goalsScored: number; goalsAgainst: number; - bumperPointsEarned: number; // v3.3+: реальные очки с учётом типа bumper - bumperHitsCount: number; + bumperPointsEarned: number; // v3.3+: реальные очки игрока с учётом типа bumper + bumperHitsCount: number; // credited hits игрока (v3.10 defensive scoring) + aiBumperPointsEarned: number; // v3.10: симметричный счётчик AI + aiBumperHitsCount: number; // v3.10: credited hits AI boostersUsed: number; victory: boolean; durationSeconds: number;