From b798d6e77d015fd9bbe870530e7402c230871c54 Mon Sep 17 00:00:00 2001 From: BrandDead <1.74973198e+08+BrandDead@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:56:56 +0000 Subject: [PATCH] fix: bind DRIVE passenger shots and replay rewards to runs --- docs/PROJECT_LOG.md | 5 + .../src/components/driveby/DriveByEngine.tsx | 76 +++++++++++++-- .../src/components/driveby/DriveByGame.tsx | 58 ++++++++--- .../driveby/driveByParticipants.test.tsx | 68 +++++++++++++ .../components/driveby/driveByParticipants.ts | 21 ++++ .../driveby/driveByRewards.test.tsx | 95 +++++++++++++++++++ 6 files changed, 300 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/driveby/driveByParticipants.test.tsx create mode 100644 frontend/src/components/driveby/driveByParticipants.ts create mode 100644 frontend/src/components/driveby/driveByRewards.test.tsx diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md index 0b2d422..5a12cd9 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -69,6 +69,11 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN ## Log +### 2026-10-01 — DRIVE passenger identity and local reward guard (#201, partial) + +- The legacy DRIVE mission now receives a snapshot of the confirmed four-seat crew, exposes the selected **active shooter passenger** in its HUD, rechecks eligibility before starting and at firing time, tags player bullets with that member ID, and credits kills/XP only to named passengers whose bullets cause a hit. The driver is excluded even when shooter-trained; duplicate, malformed and stale seats cannot fire. A run ID lets each local retry/replay book once without accepting a callback from a prior run. A valid in-flight shot keeps its credit if a member later becomes unavailable. +- Red→green participant and reward regressions cover shooter-driver vs passenger, stale/injured and duplicate seats, switching, named XP, repeated completion, retries, and post-shot roster changes. The actual demo DRIVE path was replayed at 375×812, 390×844 and 1440×900 with the status-rail overlap corrected. **Still open on #201:** shots are not gated or parameterized by a recorded weapon/ammo inventory; same-frame finalization may read stale stats; and mounted-component duplicate protection is not a durable authoritative reward receipt across reloads. This slice is not outside-beta readiness or the authenticated two-user proof (#175). + ### 2026-09-30 — Drive-by car loadout: true roles, portrait-first HUD, original responsive art (#197) - The DRIVE app's four-seat selector now limits drivers to active dealers/recruits/shooters/dedicated drivers; only active shooters occupy passenger seats. Seat assignments are rechecked at launch, including stale member roles, injuries, absence and duplicates. The legacy first-person mini-game does not yet bind individual shots to a named passenger (#201), although the driver is excluded from shooter XP. diff --git a/frontend/src/components/driveby/DriveByEngine.tsx b/frontend/src/components/driveby/DriveByEngine.tsx index 44e57ce..49492c5 100644 --- a/frontend/src/components/driveby/DriveByEngine.tsx +++ b/frontend/src/components/driveby/DriveByEngine.tsx @@ -30,6 +30,9 @@ import { shouldTriggerBulletCam, } from '../../utils/bulletCamTrigger'; import '../combat/CombatTacticalHud.css'; +import { useGangStore } from '../../stores/gameStore'; +import type { CarCrew } from './CarCrewSelector'; +import { availablePassengerShooters } from './driveByParticipants'; // ============ TYPES ============ type TargetType = 'gang' | 'civilian' | 'leader'; @@ -55,6 +58,7 @@ interface Target { interface Bullet { id: number; + shooterId: string | null; startX: number; startY: number; x: number; @@ -86,6 +90,7 @@ interface Block { interface GameStats { kills: number; + killsByShooter: Record; civilianHits: number; accuracy: number; shotsHit: number; @@ -129,14 +134,24 @@ const preloadImages = (urls: string[]): Promise // ============ MAIN COMPONENT ============ const DriveByEngine: React.FC<{ onExit?: () => void; - onComplete?: (stats: GameStats) => void; + onRunStart?: (runId: number) => void; + onComplete?: (stats: GameStats, runId: number) => void; /** Structured target from CarCrewSelector — seeds the procedural street */ targetBlock?: DriveByTarget | null; + /** Confirmed seats from the DRIVE app; the driver never fires. */ + crew?: CarCrew | null; }> = ({ onExit, + onRunStart, onComplete, targetBlock, + crew, }) => { + const members = useGangStore(state => state.members); + const validShooters = availablePassengerShooters(crew, members); + const [selectedShooterId, setSelectedShooterId] = useState(null); + const activeShooterId = selectedShooterId ?? validShooters[0]?.id ?? null; + const activeShooter = validShooters.find(member => member.id === activeShooterId); const [gameState, setGameState] = useState<'menu' | 'playing' | 'paused' | 'gameover' | 'victory'>('menu'); const [score, setScore] = useState(0); const [heat, setHeat] = useState(0); @@ -148,7 +163,7 @@ const DriveByEngine: React.FC<{ const [bullets, setBullets] = useState([]); const [crosshair, setCrosshair] = useState({ x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2 }); const [stats, setStats] = useState({ - kills: 0, civilianHits: 0, accuracy: 0, shotsHit: 0, shotsFired: 0, blocksCleared: 0, moneyEarned: 0 + kills: 0, killsByShooter: {}, civilianHits: 0, accuracy: 0, shotsHit: 0, shotsFired: 0, blocksCleared: 0, moneyEarned: 0 }); const [combatFeed, setCombatFeed] = useState([]); const [hitMarker, setHitMarker] = useState(null); @@ -157,6 +172,7 @@ const DriveByEngine: React.FC<{ const canvasRef = useRef(null); const gameLoopRef = useRef(0); + const runIdRef = useRef(0); const lastShotRef = useRef(0); const blockPosRef = useRef(0); const parallaxRef = useRef({ bg: 0, mid: 0, fg: 0 }); @@ -276,6 +292,11 @@ const DriveByEngine: React.FC<{ // ============ GAME INITIALIZATION ============ const initGame = useCallback(() => { + if (!activeShooterId || !availablePassengerShooters(crew, useGangStore.getState().members) + .some(member => member.id === activeShooterId)) return; + const runId = ++runIdRef.current; + onRunStart?.(runId); + setSelectedShooterId(activeShooterId); setScore(0); setHeat(0); setCarHealth(100); @@ -296,7 +317,7 @@ const DriveByEngine: React.FC<{ bulletCamActiveRef.current = false; lastBulletCamAtRef.current = 0; setBulletCamShot(null); - setStats({ kills: 0, civilianHits: 0, accuracy: 0, shotsHit: 0, shotsFired: 0, blocksCleared: 0, moneyEarned: 0 }); + setStats({ kills: 0, killsByShooter: {}, civilianHits: 0, accuracy: 0, shotsHit: 0, shotsFired: 0, blocksCleared: 0, moneyEarned: 0 }); // Reset window state so retries start with a fresh intact window const freshWindow = createWindowState(true); setWindowState(freshWindow); @@ -331,7 +352,7 @@ const DriveByEngine: React.FC<{ setBlocks(newBlocks); setGameState('playing'); - }, []); + }, [activeShooterId, crew, onRunStart]); // ============ SPAWN TARGETS ============ const spawnTargets = useCallback((block: Block) => { @@ -385,6 +406,9 @@ const DriveByEngine: React.FC<{ // ============ SHOOTING ============ const shoot = useCallback((x: number, y: number) => { if (gameState !== 'playing' || ammo <= 0 || bulletCamActiveRef.current) return; + // Check at click time as the roster may change after mission selection. + if (!activeShooterId || !availablePassengerShooters(crew, useGangStore.getState().members) + .some(member => member.id === activeShooterId)) return; // Sprint 17: cannot fire through raised/moving glass. if (!canShoot(windowRef.current)) { playSound('miss'); @@ -410,6 +434,7 @@ const DriveByEngine: React.FC<{ setBullets(prev => [...prev, { id: now, + shooterId: activeShooterId, startX: GAME_WIDTH * 0.7, startY: GAME_HEIGHT * 0.75, x: GAME_WIDTH * 0.7, @@ -419,7 +444,7 @@ const DriveByEngine: React.FC<{ speed: BULLET_SPEED, isPlayer: true }]); - }, [gameState, ammo, addParticles, triggerShake]); + }, [gameState, ammo, addParticles, triggerShake, activeShooterId, crew]); // ============ AUDIO ============ const audioCtxRef = useRef(null); @@ -636,7 +661,7 @@ const DriveByEngine: React.FC<{ lastBulletCamAtRef.current = now; setBulletCamShot({ shotId: `${bullet.id}:${target.id}`, - shooterId: 'driveby-player', + shooterId: bullet.shooterId ?? 'unknown-passenger', startX: bullet.startX, startY: bullet.startY, targetX: bullet.targetX, @@ -660,6 +685,7 @@ const DriveByEngine: React.FC<{ // ============ GAME LOOP ============ useEffect(() => { if (gameState !== 'playing') return; + const runId = runIdRef.current; const canvas = canvasRef.current; if (!canvas) return; @@ -723,7 +749,7 @@ const DriveByEngine: React.FC<{ if (nextBlock >= blocks.length) { setGameState('victory'); - if (onComplete) onComplete(stats); + if (onComplete) onComplete(stats, runId); return; } @@ -754,6 +780,7 @@ const DriveByEngine: React.FC<{ setBullets(prev => [...prev, { id: now + target.id, + shooterId: null, startX: target.x + target.width / 2, startY: target.y + target.height / 2, x: target.x + target.width / 2, @@ -833,6 +860,9 @@ const DriveByEngine: React.FC<{ setStats(s => ({ ...s, kills: s.kills + 1, + killsByShooter: bullet.shooterId + ? { ...s.killsByShooter, [bullet.shooterId]: (s.killsByShooter[bullet.shooterId] ?? 0) + 1 } + : s.killsByShooter, shotsHit: s.shotsHit + 1, moneyEarned: s.moneyEarned + target.bounty })); @@ -869,7 +899,7 @@ const DriveByEngine: React.FC<{ const newHealth = h - Math.max(1, Math.round(10 * mult)); if (newHealth <= 0) { setGameState('gameover'); - if (onComplete) onComplete(stats); + if (onComplete) onComplete(stats, runId); } playSound('damage'); triggerShake(5); @@ -898,7 +928,7 @@ const DriveByEngine: React.FC<{ // Check raid threshold if (heat >= HEAT_RAID_THRESHOLD) { setGameState('gameover'); - if (onComplete) onComplete(stats); + if (onComplete) onComplete(stats, runId); return; } @@ -1269,6 +1299,17 @@ const DriveByEngine: React.FC<{ /> )} + {(gameState === 'menu' || gameState === 'playing') && crew && ( +
+ Current shooter: {activeShooter?.name ?? 'Unavailable'} + {validShooters.map(member => )} +
+ )} + {/* Sprint 17: touch control for the window — [2] on desktop */} {gameState === 'playing' && !windowState.shattered && ( {onExit && } @@ -1360,6 +1401,21 @@ function darkenColor(hex: string, amount: number): string { // ============ STYLES ============ const styles: { [key: string]: React.CSSProperties } = { + shooterBar: { + position: 'absolute', top: 64, left: 10, zIndex: 12, + display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', + color: '#fff', background: 'rgba(10,10,15,.88)', padding: '6px 10px', + border: '1px solid rgba(255,255,255,.16)', borderRadius: 6, + fontSize: 12, maxWidth: 'calc(100% - 20px)', + }, + shooterButton: { + border: '1px solid rgba(255,255,255,.3)', borderRadius: 4, + background: '#22262a', color: '#fff', font: 'inherit', padding: '5px 8px', + cursor: 'pointer', minHeight: 30, + }, + shooterButtonActive: { + border: '1px solid #00d64f', color: '#a3ffb9', + }, container: { position: 'relative', width: '100%', diff --git a/frontend/src/components/driveby/DriveByGame.tsx b/frontend/src/components/driveby/DriveByGame.tsx index c8d56c3..2d09a7e 100644 --- a/frontend/src/components/driveby/DriveByGame.tsx +++ b/frontend/src/components/driveby/DriveByGame.tsx @@ -5,16 +5,18 @@ // Step 3: Launch the drive-by shooter engine // ============================================================ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import { useNavigationStore, usePlayerStore, useGangStore } from '../../stores/gameStore'; import CarCrewSelector, { type CarCrew } from './CarCrewSelector'; import DriveByEngine from './DriveByEngine'; import { vaultDeposit } from '../../utils/moneyRouter'; import { useCombatIntentStore } from '../../stores/combatIntentStore'; import { useGhostStore } from '../../stores/ghostCrewStore'; +import { availablePassengerShooters } from './driveByParticipants'; interface GameStats { kills: number; + killsByShooter: Record; civilianHits: number; accuracy: number; shotsHit: number; @@ -32,13 +34,35 @@ const DriveByGame: React.FC = () => { const [phase, setPhase] = useState('crew_select'); const [crew, setCrew] = useState(null); + const completedRef = useRef(false); + const activeRunIdRef = useRef(null); + const approvedShootersRef = useRef>(new Set()); const handleCrewConfirm = useCallback((selectedCrew: CarCrew) => { - setCrew(selectedCrew); + completedRef.current = false; + activeRunIdRef.current = null; + approvedShootersRef.current = new Set(); + setCrew({ + seats: selectedCrew.seats.map(seat => ({ ...seat, allowedRoles: [...seat.allowedRoles] })), + targetBlock: selectedCrew.targetBlock ? { ...selectedCrew.targetBlock } : null, + }); setPhase('mission'); }, []); - const handleComplete = useCallback((stats: GameStats) => { + const handleRunStart = useCallback((runId: number) => { + if (!crew) return; + approvedShootersRef.current = new Set( + availablePassengerShooters(crew, useGangStore.getState().members).map(member => member.id), + ); + activeRunIdRef.current = runId; + completedRef.current = false; + }, [crew]); + + const handleComplete = useCallback((stats: GameStats, runId: number) => { + // RAF and hit callbacks may race at an end boundary. Never pay twice for + // the same mounted run. Durable receipts remain a separate #201 gate. + if (completedRef.current || !crew || runId !== activeRunIdRef.current) return; + completedRef.current = true; if (stats.moneyEarned > 0) { vaultDeposit(stats.moneyEarned, 'combat_loot', `Drive-by take $${stats.moneyEarned}`); } else if (stats.moneyEarned < 0) { @@ -56,16 +80,22 @@ const DriveByGame: React.FC = () => { useGhostStore.getState().recordPlayerAttack(targetCrewId, targetBlockId); } - // Update crew member XP/stats based on performance - if (crew) { - crew.seats.forEach(seat => { - if (seat.memberId && seat.position !== 'driver') { - // Shooters gain XP from kills - updateMember(seat.memberId, { - xp: stats.kills * 10, - }); - } - }); + // Kill credit is attached to the bullet's recorded passenger, not every + // occupied seat. The driver cannot be credited for in-car firing. + // Eligibility was checked again at firing time. A wound while that bullet + // is in flight cannot retroactively erase its shooter's earned credit. + const eligibleIds = approvedShootersRef.current; + const credited = Object.entries(stats.killsByShooter ?? {}) + .filter(([id, kills]) => eligibleIds.has(id) && Number.isSafeInteger(kills) && kills > 0); + if (credited.reduce((sum, [, kills]) => sum + kills, 0) <= stats.kills) { + for (const [id, kills] of credited) { + const current = useGangStore.getState().members.find(member => member.id === id); + if (current) updateMember(id, { + experience: current.experience + kills * 10, + xp: (current.xp ?? current.experience) + kills * 10, + kills: current.kills + kills, + }); + } } }, [updateMoney, updateHeat, addXP, crew, updateMember]); @@ -90,8 +120,10 @@ const DriveByGame: React.FC = () => {
); diff --git a/frontend/src/components/driveby/driveByParticipants.test.tsx b/frontend/src/components/driveby/driveByParticipants.test.tsx new file mode 100644 index 0000000..89ec9e6 --- /dev/null +++ b/frontend/src/components/driveby/driveByParticipants.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import type { GangMember } from '../../types/game.types'; +import { useGangStore } from '../../stores/gameStore'; +import type { CarCrew } from './CarCrewSelector'; +import DriveByEngine from './DriveByEngine'; +import { availablePassengerShooters } from './driveByParticipants'; + +export const member = (id: string, role: string, status: GangMember['status'] = 'active'): GangMember => ({ + id, gangId: 'test-gang', name: id, nickname: id, avatarUrl: '', backstory: '', age: 25, + region: 'miami', stats: { strength: 50, agility: 50, intelligence: 50, charisma: 50, luck: 50, intimidation: 50 }, + level: 2, experience: 100, xp: 100, skillPoints: 0, skills: [], loyalty: 80, morale: 80, + respect: 50, kills: 0, arrests: 0, dealsCompleted: 0, moneyEarned: 0, status, + currentAssignment: null, joinedAt: new Date(0).toISOString(), role, +}); +export const crew = (driver: string, ...passengers: string[]): CarCrew => ({ + targetBlock: { address: 'Fictional Test Block', seedMode: 'text-seed' }, + seats: [ + { position: 'driver', label: 'Driver', allowedRoles: ['dealer', 'recruit', 'shooter'], memberId: driver }, + { position: 'passenger', label: 'Front passenger', allowedRoles: ['shooter'], memberId: passengers[0] ?? null }, + { position: 'back_left', label: 'Rear left', allowedRoles: ['shooter'], memberId: passengers[1] ?? null }, + { position: 'back_right', label: 'Rear right', allowedRoles: ['shooter'], memberId: null }, + ], +}); + +beforeEach(() => { + useGangStore.setState({ members: [member('Wheel', 'shooter'), member('Rome', 'shooter'), member('Nia', 'shooter')] }); +}); + +describe('legacy DRIVE passenger participant contract', () => { + it('offers only the confirmed shooter passengers as playable firing members, even with a shooter driver', () => { + render(); + expect(screen.getByRole('button', { name: /fire as rome/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /fire as nia/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /fire as wheel/i })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /fire as nia/i })); + expect(screen.getByText(/current shooter: nia/i)).toBeInTheDocument(); + }); + + it('does not let a stale or non-shooter passenger start a mission', () => { + useGangStore.setState({ members: [member('Wheel', 'dealer'), member('Rome', 'shooter', 'injured')] }); + render(); + expect(screen.getByRole('button', { name: /start mission/i })).toBeDisabled(); + expect(screen.queryByRole('button', { name: /fire as rome/i })).not.toBeInTheDocument(); + }); + + it('removes a selected shooter who becomes unavailable mid-run without silently switching to another', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /fire as nia/i })); + act(() => useGangStore.setState({ members: [member('Wheel', 'shooter'), member('Rome', 'shooter'), member('Nia', 'shooter', 'injured')] })); + expect(screen.getByText(/current shooter: unavailable/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /fire as nia/i })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /fire as rome/i })); + expect(screen.getByText(/current shooter: rome/i)).toBeInTheDocument(); + }); + + it('ignores a forged duplicate driver ID in a passenger seat', () => { + expect(availablePassengerShooters(crew('Wheel', 'Wheel', 'Rome'), useGangStore.getState().members) + .map(shooter => shooter.id)).toEqual(['Rome']); + }); + + it('does not turn an unknown seat position into a shooting passenger', () => { + const forged = crew('Wheel', 'Rome'); + forged.seats[1].position = 'trunk' as typeof forged.seats[number]['position']; + expect(availablePassengerShooters(forged, useGangStore.getState().members)).toEqual([]); + }); +}); diff --git a/frontend/src/components/driveby/driveByParticipants.ts b/frontend/src/components/driveby/driveByParticipants.ts new file mode 100644 index 0000000..776cc2a --- /dev/null +++ b/frontend/src/components/driveby/driveByParticipants.ts @@ -0,0 +1,21 @@ +import type { GangMember } from '../../types/game.types'; +import type { CarCrew } from './CarCrewSelector'; + +/** A driver can be shooter-trained, but never fires from the vehicle. */ +export function availablePassengerShooters(crew: CarCrew | null | undefined, members: GangMember[]): GangMember[] { + if (!crew) return []; + const driverId = crew.seats.find(seat => seat.position === 'driver')?.memberId; + const driver = members.find(member => member.id === driverId); + if (!driver || driver.status !== 'active' || + !['dealer', 'recruit', 'shooter', 'driver'].includes(driver.role ?? '')) return []; + const seen = new Set([driverId!]); + const shooters: GangMember[] = []; + for (const seat of crew.seats) { + if (!['passenger', 'back_left', 'back_right'].includes(seat.position) || + !seat.memberId || seen.has(seat.memberId)) continue; + seen.add(seat.memberId); + const member = members.find(candidate => candidate.id === seat.memberId); + if (member?.status === 'active' && member.role === 'shooter') shooters.push(member); + } + return shooters; +} diff --git a/frontend/src/components/driveby/driveByRewards.test.tsx b/frontend/src/components/driveby/driveByRewards.test.tsx new file mode 100644 index 0000000..497b162 --- /dev/null +++ b/frontend/src/components/driveby/driveByRewards.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import type { GangMember } from '../../types/game.types'; +import { useGangStore } from '../../stores/gameStore'; +import type { CarCrew } from './CarCrewSelector'; + +const member = (id: string, role: string): GangMember => ({ + id, gangId: 'test-gang', name: id, nickname: id, avatarUrl: '', backstory: '', age: 25, + region: 'miami', stats: { strength: 50, agility: 50, intelligence: 50, charisma: 50, luck: 50, intimidation: 50 }, + level: 2, experience: 100, xp: 100, skillPoints: 0, skills: [], loyalty: 80, morale: 80, + respect: 50, kills: 0, arrests: 0, dealsCompleted: 0, moneyEarned: 0, status: 'active', + currentAssignment: null, joinedAt: new Date(0).toISOString(), role, +}); +const crew: CarCrew = { + targetBlock: { address: 'Fictional Test Block', seedMode: 'text-seed' }, + seats: [ + { position: 'driver', label: 'Driver', allowedRoles: ['shooter'], memberId: 'Wheel' }, + { position: 'passenger', label: 'Passenger', allowedRoles: ['shooter'], memberId: 'Rome' }, + { position: 'back_left', label: 'Rear left', allowedRoles: ['shooter'], memberId: 'Nia' }, + { position: 'back_right', label: 'Rear right', allowedRoles: ['shooter'], memberId: null }, + ], +}; +let mockRunId = 0; +vi.mock('./CarCrewSelector', () => ({ + default: ({ onConfirm }: { onConfirm: (value: CarCrew) => void }) => ( + + ), +})); +// The canvas loop is external to this parent test; assert actual Zustand member outcomes. +vi.mock('./DriveByEngine', () => ({ + default: ({ onComplete, onRunStart }: { onComplete: (stats: object, runId: number) => void; onRunStart?: (runId: number) => void }) => ( + <> + + + + + ), +})); +vi.mock('../../utils/moneyRouter', () => ({ vaultDeposit: vi.fn() })); + +import DriveByGame from './DriveByGame'; +describe('legacy DRIVE named rewards', () => { + beforeEach(() => { + mockRunId = 0; + crew.seats[1].memberId = 'Rome'; + useGangStore.setState({ members: [member('Wheel', 'shooter'), member('Rome', 'shooter'), member('Nia', 'shooter')] }); + }); + it('credits only the actual killer and never books a second completion', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Confirm crew' })); + fireEvent.click(screen.getByRole('button', { name: 'Start run' })); + // The selector is no longer the mission authority after confirmation. + crew.seats[1].memberId = 'Wheel'; + const finish = screen.getByRole('button', { name: 'Finish mission' }); + fireEvent.click(finish); + fireEvent.click(finish); + const byId = Object.fromEntries(useGangStore.getState().members.map(m => [m.id, m])); + expect(byId.Wheel.experience).toBe(100); + expect(byId.Nia.experience).toBe(100); + expect(byId.Rome.experience).toBe(120); + expect(byId.Rome.kills).toBe(2); + }); + it('settles each replay once, not only the first run with this crew', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Confirm crew' })); + fireEvent.click(screen.getByRole('button', { name: 'Start run' })); + fireEvent.click(screen.getByRole('button', { name: 'Finish mission' })); + fireEvent.click(screen.getByRole('button', { name: 'Finish mission' })); + fireEvent.click(screen.getByRole('button', { name: 'Start run' })); + fireEvent.click(screen.getByRole('button', { name: 'Finish previous run' })); + expect(useGangStore.getState().members.find(m => m.id === 'Rome')?.experience).toBe(120); + fireEvent.click(screen.getByRole('button', { name: 'Finish mission' })); + fireEvent.click(screen.getByRole('button', { name: 'Finish mission' })); + const rome = useGangStore.getState().members.find(m => m.id === 'Rome')!; + expect(rome.experience).toBe(140); + expect(rome.kills).toBe(4); + }); + it('preserves credit for a shot approved before the shooter or driver becomes unavailable', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Confirm crew' })); + fireEvent.click(screen.getByRole('button', { name: 'Start run' })); + act(() => useGangStore.setState({ members: useGangStore.getState().members.map(m => + m.id === 'Rome' || m.id === 'Wheel' ? { ...m, status: 'injured' as const } : m) })); + fireEvent.click(screen.getByRole('button', { name: 'Finish mission' })); + expect(useGangStore.getState().members.find(m => m.id === 'Rome')?.experience).toBe(120); + expect(useGangStore.getState().members.find(m => m.id === 'Wheel')?.experience).toBe(100); + }); +});