Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/PROJECT_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
76 changes: 66 additions & 10 deletions frontend/src/components/driveby/DriveByEngine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -55,6 +58,7 @@ interface Target {

interface Bullet {
id: number;
shooterId: string | null;
startX: number;
startY: number;
x: number;
Expand Down Expand Up @@ -86,6 +90,7 @@ interface Block {

interface GameStats {
kills: number;
killsByShooter: Record<string, number>;
civilianHits: number;
accuracy: number;
shotsHit: number;
Expand Down Expand Up @@ -129,14 +134,24 @@ const preloadImages = (urls: string[]): Promise<Record<string, HTMLImageElement>
// ============ 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<string | null>(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);
Expand All @@ -148,7 +163,7 @@ const DriveByEngine: React.FC<{
const [bullets, setBullets] = useState<Bullet[]>([]);
const [crosshair, setCrosshair] = useState({ x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2 });
const [stats, setStats] = useState<GameStats>({
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<CombatFeedItem[]>([]);
const [hitMarker, setHitMarker] = useState<CombatHitMarker | null>(null);
Expand All @@ -157,6 +172,7 @@ const DriveByEngine: React.FC<{

const canvasRef = useRef<HTMLCanvasElement>(null);
const gameLoopRef = useRef<number>(0);
const runIdRef = useRef(0);
const lastShotRef = useRef(0);
const blockPosRef = useRef(0);
const parallaxRef = useRef({ bg: 0, mid: 0, fg: 0 });
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -331,7 +352,7 @@ const DriveByEngine: React.FC<{

setBlocks(newBlocks);
setGameState('playing');
}, []);
}, [activeShooterId, crew, onRunStart]);

// ============ SPAWN TARGETS ============
const spawnTargets = useCallback((block: Block) => {
Expand Down Expand Up @@ -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');
Expand All @@ -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,
Expand All @@ -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<AudioContext | null>(null);
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -723,7 +749,7 @@ const DriveByEngine: React.FC<{

if (nextBlock >= blocks.length) {
setGameState('victory');
if (onComplete) onComplete(stats);
if (onComplete) onComplete(stats, runId);
return;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -1269,6 +1299,17 @@ const DriveByEngine: React.FC<{
/>
)}

{(gameState === 'menu' || gameState === 'playing') && crew && (
<div role="group" aria-label="Firing passenger" style={styles.shooterBar}>
<span>Current shooter: {activeShooter?.name ?? 'Unavailable'}</span>
{validShooters.map(member => <button type="button" key={member.id}
aria-label={`Fire as ${member.name}`}
aria-pressed={activeShooterId === member.id}
style={{ ...styles.shooterButton, ...(activeShooterId === member.id ? styles.shooterButtonActive : {}) }}
onClick={() => setSelectedShooterId(member.id)}>{member.name}</button>)}
</div>
)}

{/* Sprint 17: touch control for the window — [2] on desktop */}
{gameState === 'playing' && !windowState.shattered && (
<button
Expand Down Expand Up @@ -1298,7 +1339,7 @@ const DriveByEngine: React.FC<{
<p style={styles.instructionItem}><span style={{...styles.instructionIcon, color: '#ffaa00'}}>~</span> Heat 100 = raid. Keep the car alive.</p>
<p style={styles.instructionItem}><span style={{...styles.instructionIcon, color: '#8de8ff'}}>2</span> Lower the glass or you cannot shoot</p>
</div>
<button style={styles.startButton} onClick={initGame}>
<button style={styles.startButton} onClick={initGame} disabled={!activeShooter}>
START MISSION
</button>
{onExit && <button style={styles.exitButton} onClick={onExit}>BACK</button>}
Expand Down Expand Up @@ -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%',
Expand Down
58 changes: 45 additions & 13 deletions frontend/src/components/driveby/DriveByGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>;
civilianHits: number;
accuracy: number;
shotsHit: number;
Expand All @@ -32,13 +34,35 @@ const DriveByGame: React.FC = () => {

const [phase, setPhase] = useState<Phase>('crew_select');
const [crew, setCrew] = useState<CarCrew | null>(null);
const completedRef = useRef(false);
const activeRunIdRef = useRef<number | null>(null);
const approvedShootersRef = useRef<Set<string>>(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) {
Expand All @@ -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]);

Expand All @@ -90,8 +120,10 @@ const DriveByGame: React.FC = () => {
<div style={{ width: '100%', height: '100%', background: '#0a0a0a' }}>
<DriveByEngine
onExit={handleExit}
onRunStart={handleRunStart}
onComplete={handleComplete}
targetBlock={crew?.targetBlock ?? null}
crew={crew}
/>
</div>
);
Expand Down
Loading
Loading