diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9fc8c48..5bebb04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ |---|---|---| | Feature | `feat/` | `feat/spectator-mode` | | Fix | `fix/` | `fix/meteor-blast-radius` | -| Claude session | `claude/` | `claude/bot-pathfinding` | +| AI session | `ai/` | `ai/bot-pathfinding` | | Chore | `chore/` | `chore/upgrade-rn-073` | ## Commit format diff --git a/app.json b/app.json index 67e3acd..cf3495b 100644 --- a/app.json +++ b/app.json @@ -1,20 +1,37 @@ { "name": "RunDown", - "displayName": "Run Down", + "displayName": "Hidden Cam Detector", "expo": { - "name": "Run Down", + "name": "Hidden Cam Detector", "slug": "run-down", - "version": "0.1.0", - "orientation": "landscape", + "version": "2.0.0", + "orientation": "portrait", "ios": { - "bundleIdentifier": "com.claude.rundown", + "bundleIdentifier": "com.cloudygetty.hidcamdetector", "infoPlist": { + "NSBluetoothAlwaysUsageDescription": "Used to scan for hidden Bluetooth cameras nearby.", + "NSBluetoothPeripheralUsageDescription": "Used to detect Bluetooth surveillance devices.", + "NSLocalNetworkUsageDescription": "Used to scan the local WiFi network for IP cameras.", + "NSCameraUsageDescription": "Used for IR lens detection via the device camera.", "UIBackgroundModes": ["fetch", "processing"], - "BGTaskSchedulerPermittedIdentifiers": ["com.claude.rundown.refresh"] + "BGTaskSchedulerPermittedIdentifiers": ["com.cloudygetty.hidcamdetector.refresh"] } }, "android": { - "package": "com.claude.rundown" - } + "package": "com.cloudygetty.hidcamdetector", + "permissions": [ + "BLUETOOTH", + "BLUETOOTH_ADMIN", + "BLUETOOTH_SCAN", + "BLUETOOTH_CONNECT", + "ACCESS_WIFI_STATE", + "CHANGE_WIFI_STATE", + "ACCESS_COARSE_LOCATION", + "ACCESS_FINE_LOCATION", + "CAMERA", + "VIBRATE" + ] + }, + "plugins": [] } } diff --git a/index.js b/index.js index 117ddca..ed6406e 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ import { AppRegistry } from 'react-native'; -import App from './src/App'; +import { DetectorApp } from './src/detector/DetectorApp'; import { name as appName } from './app.json'; -AppRegistry.registerComponent(appName, () => App); +AppRegistry.registerComponent(appName, () => DetectorApp); diff --git a/src/components/HUD.tsx b/src/components/HUD.tsx index 5907735..83583c7 100644 --- a/src/components/HUD.tsx +++ b/src/components/HUD.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; -import { Player, Bombardment, WeaponType, GameState } from '../types'; +import { Player, Bombardment, WeaponType } from '../types'; type Props = { player: Player; @@ -76,7 +76,6 @@ export const HUD: React.FC = ({ const phaseSeconds = Math.ceil(bombardment.timeUntilNextPhase / 1000); const impactSeconds = Math.ceil(bombardment.timeUntilNextImpact / 1000); const incomingMeteor = bombardment.timeUntilNextImpact < 2000; - const abilityCooldownPct = player.abilityChargeMs > 0 ? 1 : 0; const abilityReady = player.abilityChargeMs === 0; const isCorrupted = player.corruptionDps > 0; const isBounty = bountyPlayerId === player.id; @@ -124,7 +123,10 @@ export const HUD: React.FC = ({ - CORE: {player.heldCoreEffect ? CORE_EFFECT_LABELS[player.heldCoreEffect] ?? player.heldCoreEffect : '?'} + CORE:{' '} + {player.heldCoreEffect + ? CORE_EFFECT_LABELS[player.heldCoreEffect] ?? player.heldCoreEffect + : '?'} {' '} -{player.corruptionDps.toFixed(0)} HP/s @@ -266,7 +268,11 @@ const styles = StyleSheet.create({ paddingVertical: 4, borderRadius: 12, }, - killsChipBounty: { backgroundColor: 'rgba(180,30,0,0.85)', borderWidth: 1, borderColor: '#ff4400' }, + killsChipBounty: { + backgroundColor: 'rgba(180,30,0,0.85)', + borderWidth: 1, + borderColor: '#ff4400', + }, killsText: { color: '#ffcc00', fontWeight: 'bold', fontSize: 13 }, killsTextBounty: { color: '#ff6633' }, @@ -295,7 +301,11 @@ const styles = StyleSheet.create({ paddingVertical: 3, alignSelf: 'flex-start', }, - coreChipActive: { backgroundColor: 'rgba(180,0,200,0.55)', borderWidth: 1, borderColor: '#cc44ff' }, + coreChipActive: { + backgroundColor: 'rgba(180,0,200,0.55)', + borderWidth: 1, + borderColor: '#cc44ff', + }, coreChipText: { color: '#ee88ff', fontSize: 10, fontWeight: 'bold' }, corruptText: { color: '#ff4488' }, diff --git a/src/core/characters/characters.ts b/src/core/characters/characters.ts index 1a96f05..4d3fa5f 100644 --- a/src/core/characters/characters.ts +++ b/src/core/characters/characters.ts @@ -52,7 +52,8 @@ export const CHARACTERS: Character[] = [ }, ability: { name: 'Titan Guard', - description: 'Deploys a frontal energy shield, absorbing all incoming damage for 6 seconds before releasing it as a shockwave.', + description: + 'Deploys a frontal energy shield, absorbing all incoming damage for 6 seconds before releasing it as a shockwave.', cooldownMs: 28000, durationMs: 6000, effectType: 'damage_immunity', @@ -63,7 +64,7 @@ export const CHARACTERS: Character[] = [ name: 'Nyra Solis', title: 'The Solar', lore: 'Channelled solar energy into her biology. Warm to the touch. Lethal at range.', - meteorQuip: 'A core. Finally. Stay back — it\'s mine.', + meteorQuip: "A core. Finally. Stay back — it's mine.", portraitSource: PORTRAITS.nyra, accentColor: '#ffaa00', passive: { @@ -73,7 +74,8 @@ export const CHARACTERS: Character[] = [ }, ability: { name: 'Solar Bloom', - description: 'Emits a healing flare — instantly restores 60 HP — while enemies within range are marked for +40% bonus damage for 5 seconds.', + description: + 'Emits a healing flare — instantly restores 60 HP — while enemies within range are marked for +40% bonus damage for 5 seconds.', cooldownMs: 22000, durationMs: 5000, effectType: 'damage_boost', @@ -94,7 +96,8 @@ export const CHARACTERS: Character[] = [ }, ability: { name: 'Trapline', - description: 'Activates hidden snares — marks all enemies and increases outgoing damage by 40% for 6 seconds.', + description: + 'Activates hidden snares — marks all enemies and increases outgoing damage by 40% for 6 seconds.', cooldownMs: 18000, durationMs: 6000, effectType: 'damage_boost', @@ -115,7 +118,8 @@ export const CHARACTERS: Character[] = [ }, ability: { name: 'Mind Fracture', - description: 'Deploys hallucinations — enemies cannot accurately target for 4 seconds, effectively granting damage immunity.', + description: + 'Deploys hallucinations — enemies cannot accurately target for 4 seconds, effectively granting damage immunity.', cooldownMs: 20000, durationMs: 4000, effectType: 'damage_immunity', @@ -137,7 +141,8 @@ export const CHARACTERS: Character[] = [ }, ability: { name: 'Smoke Reign', - description: 'Blankets the area — grants damage immunity for 3 seconds and doubles movement speed within the smoke.', + description: + 'Blankets the area — grants damage immunity for 3 seconds and doubles movement speed within the smoke.', cooldownMs: 16000, durationMs: 3000, effectType: 'damage_immunity', @@ -158,7 +163,8 @@ export const CHARACTERS: Character[] = [ }, ability: { name: 'Predator Leap', - description: 'Launches forward 200 units and marks the target, increasing all damage dealt to them by 50% for 5 seconds.', + description: + 'Launches forward 200 units and marks the target, increasing all damage dealt to them by 50% for 5 seconds.', cooldownMs: 15000, durationMs: 5000, effectType: 'damage_boost', @@ -201,7 +207,8 @@ export const CHARACTERS: Character[] = [ }, ability: { name: 'Shadow Bind', - description: 'Tethers enemies — damage dealt to any target is increased by 45% for 5 seconds as the bind amplifies all hits.', + description: + 'Tethers enemies — damage dealt to any target is increased by 45% for 5 seconds as the bind amplifies all hits.', cooldownMs: 18000, durationMs: 5000, effectType: 'damage_boost', @@ -212,7 +219,7 @@ export const CHARACTERS: Character[] = [ name: 'Orin "Scrap" Dax', title: 'The Salvager', lore: 'Built his first weapon from a vending machine and a door hinge. Still uses it.', - meteorQuip: 'Free parts. I\'ll take it.', + meteorQuip: "Free parts. I'll take it.", portraitSource: PORTRAITS.orin, accentColor: '#cc8800', passive: { @@ -243,7 +250,8 @@ export const CHARACTERS: Character[] = [ }, ability: { name: 'Sonic Crescendo', - description: 'Charges a focused sound blast — knocks back nearby enemies and deals +50% weapon damage for 5 seconds.', + description: + 'Charges a focused sound blast — knocks back nearby enemies and deals +50% weapon damage for 5 seconds.', cooldownMs: 20000, durationMs: 5000, effectType: 'damage_boost', @@ -265,7 +273,8 @@ export const CHARACTERS: Character[] = [ }, ability: { name: 'Gravity Well', - description: 'Pulls enemies toward a central point — all pulled targets take +50% weapon damage for 5 seconds.', + description: + 'Pulls enemies toward a central point — all pulled targets take +50% weapon damage for 5 seconds.', cooldownMs: 22000, durationMs: 5000, effectType: 'damage_boost', @@ -286,7 +295,8 @@ export const CHARACTERS: Character[] = [ }, ability: { name: 'Cryo Veil', - description: 'Freezes the surrounding ground — grants damage immunity for 4 seconds and creates ice barriers that block bullets.', + description: + 'Freezes the surrounding ground — grants damage immunity for 4 seconds and creates ice barriers that block bullets.', cooldownMs: 20000, durationMs: 4000, effectType: 'damage_immunity', @@ -297,7 +307,7 @@ export const CHARACTERS: Character[] = [ name: 'Jax "Overclock" Renn', title: 'The Overclocked', lore: 'Neural augmentations pushed past rated limits. The tremor in his hands is from the speed, not the fear.', - meteorQuip: 'HA. Let\'s go again.', + meteorQuip: "HA. Let's go again.", portraitSource: PORTRAITS.jax, accentColor: '#ff4400', passive: { @@ -307,7 +317,8 @@ export const CHARACTERS: Character[] = [ }, ability: { name: 'Adrenal Override', - description: 'Pushes all systems past the limit — doubles fire rate and movement speed for 5 seconds, but drains 5 HP per second during the effect.', + description: + 'Pushes all systems past the limit — doubles fire rate and movement speed for 5 seconds, but drains 5 HP per second during the effect.', cooldownMs: 20000, durationMs: 5000, effectType: 'rapid_fire', @@ -329,7 +340,8 @@ export const CHARACTERS: Character[] = [ }, ability: { name: 'Void Step', - description: 'Becomes intangible for 5 seconds — grants damage immunity and passes through all obstacles.', + description: + 'Becomes intangible for 5 seconds — grants damage immunity and passes through all obstacles.', cooldownMs: 25000, durationMs: 5000, effectType: 'damage_immunity', diff --git a/src/core/characters/portraits.ts b/src/core/characters/portraits.ts index 6f50a2e..f3ec6e5 100644 --- a/src/core/characters/portraits.ts +++ b/src/core/characters/portraits.ts @@ -1,21 +1,20 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ // Static require() calls so Metro can resolve portrait assets at bundle time. // Swap any entry to null to fall back to the accent-color placeholder in LobbyScreen. export const PORTRAITS: Record = { - vex: require('../../../assets/portraits/vex.png'), + vex: require('../../../assets/portraits/vex.png'), brutus: require('../../../assets/portraits/brutus.png'), - nyra: require('../../../assets/portraits/nyra.png'), - kade: require('../../../assets/portraits/kade.png'), - iris: require('../../../assets/portraits/iris.png'), - rook: require('../../../assets/portraits/rook.png'), - talon: require('../../../assets/portraits/talon.png'), - voss: require('../../../assets/portraits/voss.png'), - sable: require('../../../assets/portraits/sable.png'), - orin: require('../../../assets/portraits/orin.png'), - lyric: require('../../../assets/portraits/lyric.png'), + nyra: require('../../../assets/portraits/nyra.png'), + kade: require('../../../assets/portraits/kade.png'), + iris: require('../../../assets/portraits/iris.png'), + rook: require('../../../assets/portraits/rook.png'), + talon: require('../../../assets/portraits/talon.png'), + voss: require('../../../assets/portraits/voss.png'), + sable: require('../../../assets/portraits/sable.png'), + orin: require('../../../assets/portraits/orin.png'), + lyric: require('../../../assets/portraits/lyric.png'), magnus: require('../../../assets/portraits/magnus.png'), - eira: require('../../../assets/portraits/eira.png'), - jax: require('../../../assets/portraits/jax.png'), - kael: require('../../../assets/portraits/kael.png'), + eira: require('../../../assets/portraits/eira.png'), + jax: require('../../../assets/portraits/jax.png'), + kael: require('../../../assets/portraits/kael.png'), }; diff --git a/src/core/gameEngine/GameEngine.ts b/src/core/gameEngine/GameEngine.ts index c47d2aa..47d0099 100644 --- a/src/core/gameEngine/GameEngine.ts +++ b/src/core/gameEngine/GameEngine.ts @@ -5,9 +5,6 @@ import { Vector2, FractureCore, FractureCoreEffect, - GravityZone, - TimeEchoZone, - HelixRelay, SupplyDrop, WeaponType, Rarity, @@ -255,12 +252,20 @@ function tickHelixRelays(state: GameState, deltaMs: number): GameState { { id: `relay_loot_${relay.id}_${Date.now()}`, position: { - x: relay.position.x + randomInRange(-HELIX_RELAY_REWARD_LOOT_RADIUS, HELIX_RELAY_REWARD_LOOT_RADIUS), - y: relay.position.y + randomInRange(-HELIX_RELAY_REWARD_LOOT_RADIUS, HELIX_RELAY_REWARD_LOOT_RADIUS), + x: + relay.position.x + + randomInRange(-HELIX_RELAY_REWARD_LOOT_RADIUS, HELIX_RELAY_REWARD_LOOT_RADIUS), + y: + relay.position.y + + randomInRange(-HELIX_RELAY_REWARD_LOOT_RADIUS, HELIX_RELAY_REWARD_LOOT_RADIUS), }, weapon: null, ammo: randomInt(60, 120), - materials: { wood: randomInt(40, 80), stone: randomInt(30, 60), metal: randomInt(20, 40) }, + materials: { + wood: randomInt(40, 80), + stone: randomInt(30, 60), + metal: randomInt(20, 40), + }, shield: 100, health: 50, }, @@ -347,7 +352,14 @@ function tickSupplyDrops(state: GameState, deltaMs: number): GameState { } function spawnSupplyDrop(mapWidth: number, mapHeight: number): SupplyDrop { - const epicWeapons: WeaponType[] = ['sniper', 'heavy_sniper', 'rail_gun', 'minigun', 'rocket_launcher', 'heavy_ar']; + const epicWeapons: WeaponType[] = [ + 'sniper', + 'heavy_sniper', + 'rail_gun', + 'minigun', + 'rocket_launcher', + 'heavy_ar', + ]; const rarities: Rarity[] = ['epic', 'legendary']; return { id: `supply_${Date.now()}_${Math.random().toString(36).slice(2)}`, @@ -408,7 +420,11 @@ function moveHumanPlayer(state: GameState, input: InputState, deltaMs: number): const gravitySpeedMult = inGravityZone ? 0.6 : 1; const speed = - PLAYER_SPEED * player.speedMult * abilitySpeedMult * gravitySpeedMult * (deltaMs / TICK_RATE_MS); + PLAYER_SPEED * + player.speedMult * + abilitySpeedMult * + gravitySpeedMult * + (deltaMs / TICK_RATE_MS); const dir = normalize(input.moveVector); const rawNext: Vector2 = { @@ -487,7 +503,11 @@ function spawnMeteorEffects(state: GameState, newImpacts: MeteorImpact[]): GameS for (const impact of newImpacts) { if (impact.meteorType === 'explosive') { - const effects: FractureCoreEffect[] = ['cooldown_reduction', 'damage_amp', 'ability_mutation']; + const effects: FractureCoreEffect[] = [ + 'cooldown_reduction', + 'damage_amp', + 'ability_mutation', + ]; fractureCores = [ ...fractureCores, { @@ -602,7 +622,8 @@ export function fireShot(state: GameState, shooterId: string, targetPos: Vector2 } // Outgoing multipliers: passive × ability × Fracture Core damage_amp - const abilityDamageMult = players[shooterIndex].activeAbilityEffect === 'damage_boost' ? 1.5 : 1; + const abilityDamageMult = + players[shooterIndex].activeAbilityEffect === 'damage_boost' ? 1.5 : 1; const coreDamageMult = players[shooterIndex].heldCoreEffect === 'damage_amp' ? 1.4 : 1; const rawDmg = Math.round( weapon.damage * players[shooterIndex].damageMult * abilityDamageMult * coreDamageMult, diff --git a/src/core/meteor/MeteorManager.ts b/src/core/meteor/MeteorManager.ts index f5d1090..e90c09b 100644 --- a/src/core/meteor/MeteorManager.ts +++ b/src/core/meteor/MeteorManager.ts @@ -141,8 +141,12 @@ function tickImpacts(impacts: MeteorImpact[], deltaMs: number): MeteorImpact[] { // WHY: 5% echo, 15% gravity, 80% explosive — chaos is calibrated, not random. function pickMeteorType(): MeteorType { const roll = Math.random(); - if (roll < 0.05) return 'echo'; - if (roll < 0.20) return 'gravity'; + if (roll < 0.05) { + return 'echo'; + } + if (roll < 0.2) { + return 'gravity'; + } return 'explosive'; } diff --git a/src/detector/DetectorApp.tsx b/src/detector/DetectorApp.tsx new file mode 100644 index 0000000..6a75407 --- /dev/null +++ b/src/detector/DetectorApp.tsx @@ -0,0 +1,166 @@ +import React, { useState } from 'react'; +import { SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { SweepScreen } from './screens/SweepScreen'; +import { NetworkScreen } from './screens/NetworkScreen'; +import { MagnetometerScreen } from './screens/MagnetometerScreen'; +import { BluetoothScreen } from './screens/BluetoothScreen'; +import { HistoryScreen } from './screens/HistoryScreen'; +import { useDetectorStore } from './store/detector.store'; +import { THREAT_COLORS } from './services/threat.service'; + +type Tab = 'sweep' | 'network' | 'magnetic' | 'bluetooth' | 'history'; + +interface TabDef { + id: Tab; + label: string; + icon: string; +} + +const TABS: TabDef[] = [ + { id: 'sweep', label: 'SWEEP', icon: '◎' }, + { id: 'network', label: 'NET', icon: '⊞' }, + { id: 'magnetic', label: 'MAG', icon: '⊕' }, + { id: 'bluetooth', label: 'BLE', icon: '◈' }, + { id: 'history', label: 'LOG', icon: '≡' }, +]; + +export function DetectorApp() { + const [activeTab, setActiveTab] = useState('sweep'); + const threatScore = useDetectorStore((s) => s.threatScore); + const scanPhase = useDetectorStore((s) => s.scanPhase); + const cameraCount = useDetectorStore( + (s) => [...s.networkDevices, ...s.bleDevices].filter((d) => d.isCamera).length, + ); + + const threatColor = THREAT_COLORS[threatScore.level]; + const isActive = scanPhase === 'scanning'; + + return ( + + {/* Header */} + + + + HIDDEN CAM DETECTOR + + {isActive && ( + + SCANNING + + )} + {cameraCount > 0 && !isActive && ( + + {cameraCount} CAM + + )} + {threatScore.total > 0 && ( + {threatScore.total} + )} + + + {/* Screen */} + + {activeTab === 'sweep' && } + {activeTab === 'network' && } + {activeTab === 'magnetic' && } + {activeTab === 'bluetooth' && } + {activeTab === 'history' && } + + + {/* Tab bar */} + + {TABS.map((tab) => { + const active = tab.id === activeTab; + return ( + setActiveTab(tab.id)} + activeOpacity={0.7} + > + {tab.icon} + {tab.label} + {active && } + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#0D0A14' }, + + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#1A1625', + }, + headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + liveIndicator: { width: 7, height: 7, borderRadius: 3.5 }, + appName: { + color: '#C9A84C', + fontSize: 11, + letterSpacing: 4, + fontFamily: 'monospace', + fontWeight: '600', + }, + liveBadge: { + backgroundColor: '#22C55E18', + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 3, + }, + liveBadgeText: { color: '#22C55E', fontSize: 9, letterSpacing: 3, fontFamily: 'monospace' }, + alertBadge: { + backgroundColor: '#EF444418', + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 3, + }, + alertBadgeText: { + color: '#EF4444', + fontSize: 9, + letterSpacing: 2, + fontFamily: 'monospace', + fontWeight: '700', + }, + headerScore: { fontSize: 18, fontWeight: '700', fontFamily: 'monospace' }, + + screen: { flex: 1 }, + + tabBar: { + flexDirection: 'row', + backgroundColor: '#08060F', + borderTopWidth: 1, + borderTopColor: '#1A1625', + paddingBottom: 4, + }, + tab: { + flex: 1, + alignItems: 'center', + paddingTop: 10, + paddingBottom: 6, + position: 'relative', + }, + tabIcon: { fontSize: 16, color: '#2A2440', marginBottom: 3 }, + tabIconActive: { color: '#C9A84C' }, + tabLabel: { fontSize: 7, letterSpacing: 1, fontFamily: 'monospace', color: '#2A2440' }, + tabLabelActive: { color: '#C9A84C' }, + tabIndicator: { + position: 'absolute', + top: 0, + left: '20%', + right: '20%', + height: 2, + backgroundColor: '#C9A84C', + borderRadius: 1, + }, +}); diff --git a/src/detector/components/DeviceCard.tsx b/src/detector/components/DeviceCard.tsx new file mode 100644 index 0000000..d029eed --- /dev/null +++ b/src/detector/components/DeviceCard.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import type { NetworkDevice, BleDevice } from '../types'; +import { SignalBars } from './SignalBars'; + +type Device = NetworkDevice | BleDevice; + +function isNet(d: Device): d is NetworkDevice { + return 'ip' in d; +} + +interface Props { + device: Device; +} + +export function DeviceCard({ device }: Props) { + const isCamera = device.isCamera; + const accent = isCamera ? '#EF4444' : '#C9A84C'; + + const title = isNet(device) ? device.hostname ?? device.ip : device.name ?? 'Unknown Device'; + + const sub = isNet(device) + ? `${device.ip} · ports: ${device.openPorts.join(', ')}` + : `RSSI ${device.rssi} dBm`; + + const typeTag = isNet(device) + ? device.cameraType?.replace('_', ' ').toUpperCase() ?? 'NETWORK DEVICE' + : 'BLUETOOTH DEVICE'; + + return ( + + + + {typeTag} + + {title} + + {sub} + + + {!isNet(device) && } + + + {device.confidence}% + + + + {isCamera && ( + + + + {isNet(device) ? 'IP CAMERA DETECTED' : 'BLE CAMERA DETECTED'} + + + )} + + {!isNet(device) && device.cameraIndicators.length > 0 && ( + + {device.cameraIndicators.map((ind, i) => ( + + {ind} + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#100D1A', + borderWidth: 1, + borderColor: '#1E1A2E', + borderRadius: 12, + padding: 14, + marginBottom: 8, + }, + cardAlert: { borderColor: '#EF444440', backgroundColor: '#150A0A' }, + row: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + info: { flex: 1 }, + tag: { fontSize: 9, letterSpacing: 3, fontFamily: 'monospace', marginBottom: 4 }, + title: { color: '#E8E4F0', fontSize: 14, fontWeight: '600', marginBottom: 2 }, + sub: { color: '#4B4560', fontSize: 11, fontFamily: 'monospace' }, + badge: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8 }, + confidence: { fontSize: 14, fontWeight: '700', fontFamily: 'monospace' }, + alertRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginTop: 10, + paddingTop: 10, + borderTopWidth: 1, + borderTopColor: '#EF444425', + }, + alertDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#EF4444' }, + alertText: { + color: '#EF4444', + fontSize: 10, + letterSpacing: 3, + fontFamily: 'monospace', + fontWeight: '700', + }, + indicators: { marginTop: 8 }, + indicator: { color: '#5C566E', fontSize: 10, fontFamily: 'monospace', marginBottom: 2 }, +}); diff --git a/src/detector/components/MagneticGraph.tsx b/src/detector/components/MagneticGraph.tsx new file mode 100644 index 0000000..a7ba473 --- /dev/null +++ b/src/detector/components/MagneticGraph.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import type { MagneticReading } from '../types'; + +interface Props { + readings: MagneticReading[]; +} + +const GRAPH_H = 72; +const BAR_W = 3; +const BAR_GAP = 1; +const ANOMALY_THRESHOLD = 15; // µT — above this = suspicious + +export function MagneticGraph({ readings }: Props) { + const maxAnomaly = Math.max(ANOMALY_THRESHOLD * 2, ...readings.map((r) => r.anomaly)); + const latest = readings[readings.length - 1]; + const peak = readings.length ? Math.max(...readings.map((r) => r.anomaly)) : 0; + + return ( + + + MAGNETIC ANOMALY + µT above baseline + + + + {/* Threshold line */} + + + {/* Bars */} + {readings.map((r, i) => { + const barH = Math.max(2, (r.anomaly / maxAnomaly) * GRAPH_H); + const hot = r.anomaly > ANOMALY_THRESHOLD; + return ( + + ); + })} + + + + ANOMALY_THRESHOLD} + /> + + ANOMALY_THRESHOLD} /> + + + + ); +} + +function Stat({ label, value, hot }: { label: string; value: string; hot?: boolean }) { + return ( + + {value} + {label} + + ); +} + +const styles = StyleSheet.create({ + card: { backgroundColor: '#0A0814', borderRadius: 12, padding: 14, marginVertical: 8 }, + header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 12 }, + label: { color: '#5C566E', fontSize: 10, letterSpacing: 3, fontFamily: 'monospace' }, + unit: { color: '#3D3650', fontSize: 9, fontFamily: 'monospace' }, + graphArea: { + height: GRAPH_H, + flexDirection: 'row', + alignItems: 'flex-end', + overflow: 'hidden', + position: 'relative', + }, + thresholdLine: { + position: 'absolute', + left: 0, + right: 0, + height: StyleSheet.hairlineWidth, + backgroundColor: '#F59E0B50', + }, + footer: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12 }, + stat: { alignItems: 'center' }, + statVal: { color: '#C9A84C', fontSize: 12, fontFamily: 'monospace', fontWeight: '600' }, + statLabel: { + color: '#3D3650', + fontSize: 8, + letterSpacing: 2, + fontFamily: 'monospace', + marginTop: 2, + }, +}); diff --git a/src/detector/components/RadarPulse.tsx b/src/detector/components/RadarPulse.tsx new file mode 100644 index 0000000..df8e66f --- /dev/null +++ b/src/detector/components/RadarPulse.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useRef } from 'react'; +import { Animated, StyleSheet, View } from 'react-native'; + +interface Props { + active: boolean; + color?: string; + size?: number; +} + +const PULSE_COUNT = 3; +const PULSE_DURATION = 2200; + +export function RadarPulse({ active, color = '#C9A84C', size = 160 }: Props) { + const anims = useRef(Array.from({ length: PULSE_COUNT }, () => new Animated.Value(0))).current; + + useEffect(() => { + if (!active) { + anims.forEach((a) => a.setValue(0)); + return; + } + + const loops = anims.map((anim, i) => + Animated.loop( + Animated.sequence([ + Animated.delay((PULSE_DURATION / PULSE_COUNT) * i), + Animated.timing(anim, { + toValue: 1, + duration: PULSE_DURATION, + useNativeDriver: true, + }), + Animated.timing(anim, { toValue: 0, duration: 0, useNativeDriver: true }), + ]), + ), + ); + + loops.forEach((l) => l.start()); + return () => loops.forEach((l) => l.stop()); + }, [active, anims]); + + return ( + + {anims.map((anim, i) => ( + + ))} + {/* Center dot */} + + {/* Cross-hair lines */} + {active && ( + <> + + + + )} + + ); +} + +const styles = StyleSheet.create({ + line: { position: 'absolute', borderWidth: StyleSheet.hairlineWidth }, + lineH: { left: '20%', right: '20%' }, + lineV: { top: '20%', bottom: '20%' }, +}); diff --git a/src/detector/components/SignalBars.tsx b/src/detector/components/SignalBars.tsx new file mode 100644 index 0000000..dedfba7 --- /dev/null +++ b/src/detector/components/SignalBars.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +interface Props { + /** RSSI value (negative dBm, e.g. -55). Pass 0 for no signal. */ + rssi: number; + color?: string; +} + +function rssiBars(rssi: number): number { + if (rssi === 0) { + return 0; + } + if (rssi >= -50) { + return 5; + } + if (rssi >= -60) { + return 4; + } + if (rssi >= -70) { + return 3; + } + if (rssi >= -80) { + return 2; + } + return 1; +} + +export function SignalBars({ rssi, color = '#C9A84C' }: Props) { + const filled = rssiBars(rssi); + + return ( + + {Array.from({ length: 5 }, (_, i) => ( + + ))} + + ); +} + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'flex-end', gap: 3 }, + bar: { width: 6, borderRadius: 2 }, +}); diff --git a/src/detector/components/ThreatMeter.tsx b/src/detector/components/ThreatMeter.tsx new file mode 100644 index 0000000..e2ae6d9 --- /dev/null +++ b/src/detector/components/ThreatMeter.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useRef } from 'react'; +import { Animated, StyleSheet, Text, View } from 'react-native'; +import { THREAT_COLORS, THREAT_LABELS } from '../services/threat.service'; +import type { ThreatScore } from '../types'; + +const SEGMENT_COLORS = [ + '#22C55E', + '#22C55E', + '#22C55E', // 0-29 safe + '#84CC16', + '#F59E0B', // 30-49 + '#F97316', + '#F97316', // 50-69 + '#EF4444', + '#EF4444', + '#EF4444', // 70-100 +]; + +interface Props { + score: ThreatScore; + compact?: boolean; +} + +export function ThreatMeter({ score, compact = false }: Props) { + const ring = useRef(new Animated.Value(0)).current; + const size = compact ? 120 : 170; + + useEffect(() => { + Animated.spring(ring, { + toValue: score.total / 100, + useNativeDriver: false, + tension: 60, + friction: 12, + }).start(); + }, [score.total, ring]); + + const borderColor = ring.interpolate({ + inputRange: [0, 0.14, 0.34, 0.54, 0.74, 1], + outputRange: [ + THREAT_COLORS.safe, + THREAT_COLORS.low, + THREAT_COLORS.medium, + THREAT_COLORS.high, + THREAT_COLORS.critical, + '#FF1111', + ], + }); + + const color = THREAT_COLORS[score.level]; + const filled = Math.round(score.total / 10); + + return ( + + + + {score.total} + THREAT + + + + {THREAT_LABELS[score.level]} + + + {SEGMENT_COLORS.map((c, i) => ( + + ))} + + + {!compact && ( + + + + + + )} + + ); +} + +function ContribBar({ label, value }: { label: string; value: number }) { + const w = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.timing(w, { toValue: value / 100, duration: 500, useNativeDriver: false }).start(); + }, [value, w]); + + return ( + + {label} + + 60 ? '#EF4444' : '#8B5CF6' }]} + /> + + + {Math.round(value)} + + ); +} + +const styles = StyleSheet.create({ + wrapper: { alignItems: 'center', gap: 12 }, + ring: { + borderWidth: 5, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#0D0A14', + }, + inner: { alignItems: 'center' }, + score: { fontWeight: '900', letterSpacing: -2 }, + scoreUnit: { + color: '#3D3650', + fontSize: 9, + letterSpacing: 4, + fontFamily: 'monospace', + marginTop: 2, + }, + level: { fontSize: 11, letterSpacing: 4, fontFamily: 'monospace', fontWeight: '700' }, + segmentRow: { flexDirection: 'row', gap: 3 }, + segment: { width: 20, height: 6, borderRadius: 2 }, + contributors: { width: '100%', gap: 6 }, +}); + +const cStyles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + label: { color: '#3D3650', fontSize: 9, letterSpacing: 2, fontFamily: 'monospace', width: 28 }, + track: { flex: 1, flexDirection: 'row', height: 4, borderRadius: 2, overflow: 'hidden' }, + fill: { height: 4, borderRadius: 2 }, + empty: { height: 4, backgroundColor: '#1A1625' }, + val: { color: '#5C566E', fontSize: 10, fontFamily: 'monospace', width: 24, textAlign: 'right' }, +}); diff --git a/src/detector/hooks/useBluetooth.ts b/src/detector/hooks/useBluetooth.ts new file mode 100644 index 0000000..3e62035 --- /dev/null +++ b/src/detector/hooks/useBluetooth.ts @@ -0,0 +1,19 @@ +import { useCallback, useRef } from 'react'; +import { bluetoothService } from '../services/bluetooth.service'; +import { useDetectorStore } from '../store/detector.store'; + +export function useBluetooth() { + const abortRef = useRef(null); + const addBleDevice = useDetectorStore((s) => s.addBleDevice); + + const start = useCallback(async () => { + abortRef.current = new AbortController(); + await bluetoothService.scan(addBleDevice, 12000, abortRef.current.signal); + }, [addBleDevice]); + + const stop = useCallback(() => { + abortRef.current?.abort(); + }, []); + + return { start, stop }; +} diff --git a/src/detector/hooks/useMagnetometer.ts b/src/detector/hooks/useMagnetometer.ts new file mode 100644 index 0000000..9fbf525 --- /dev/null +++ b/src/detector/hooks/useMagnetometer.ts @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; +import { magnetometerService } from '../services/magnetometer.service'; +import { useDetectorStore } from '../store/detector.store'; + +export function useMagnetometer(active: boolean): void { + const setMagneticReading = useDetectorStore((s) => s.setMagneticReading); + + useEffect(() => { + if (!active) { + return; + } + magnetometerService.start(setMagneticReading); + return () => magnetometerService.stop(); + }, [active, setMagneticReading]); +} diff --git a/src/detector/hooks/useNetworkScan.ts b/src/detector/hooks/useNetworkScan.ts new file mode 100644 index 0000000..18dccec --- /dev/null +++ b/src/detector/hooks/useNetworkScan.ts @@ -0,0 +1,24 @@ +import { useCallback, useRef } from 'react'; +import { scanNetwork } from '../services/network.service'; +import { useDetectorStore } from '../store/detector.store'; + +export function useNetworkScan() { + const abortRef = useRef(null); + const addNetworkDevice = useDetectorStore((s) => s.addNetworkDevice); + const setNetworkProgress = useDetectorStore((s) => s.setNetworkProgress); + + const start = useCallback(async () => { + abortRef.current = new AbortController(); + await scanNetwork( + (scanned, total) => setNetworkProgress(Math.round((scanned / total) * 100)), + addNetworkDevice, + abortRef.current.signal, + ); + }, [addNetworkDevice, setNetworkProgress]); + + const stop = useCallback(() => { + abortRef.current?.abort(); + }, []); + + return { start, stop }; +} diff --git a/src/detector/hooks/useThreatScore.ts b/src/detector/hooks/useThreatScore.ts new file mode 100644 index 0000000..4c3080f --- /dev/null +++ b/src/detector/hooks/useThreatScore.ts @@ -0,0 +1,6 @@ +import { useDetectorStore } from '../store/detector.store'; +import type { ThreatScore } from '../types'; + +export function useThreatScore(): ThreatScore { + return useDetectorStore((s) => s.threatScore); +} diff --git a/src/detector/screens/BluetoothScreen.tsx b/src/detector/screens/BluetoothScreen.tsx new file mode 100644 index 0000000..df9176b --- /dev/null +++ b/src/detector/screens/BluetoothScreen.tsx @@ -0,0 +1,228 @@ +import React, { useCallback, useState } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useDetectorStore } from '../store/detector.store'; +import { useBluetooth } from '../hooks/useBluetooth'; +import { DeviceCard } from '../components/DeviceCard'; +import { SignalBars } from '../components/SignalBars'; + +export function BluetoothScreen() { + const bleDevices = useDetectorStore((s) => s.bleDevices); + const resetScan = useDetectorStore((s) => s.resetScan); + const btScan = useBluetooth(); + const [running, setRunning] = useState(false); + const [done, setDone] = useState(false); + + const handleStart = useCallback(async () => { + resetScan(); + setDone(false); + setRunning(true); + await btScan.start(); + setRunning(false); + setDone(true); + }, [btScan, resetScan]); + + const handleStop = useCallback(() => { + btScan.stop(); + setRunning(false); + setDone(true); + }, [btScan]); + + const cameras = bleDevices.filter((d) => d.isCamera); + const others = bleDevices.filter((d) => !d.isCamera); + + return ( + + + BLUETOOTH SCANNER + DETECTING HIDDEN BLE CAMERAS + + {/* Info */} + + + + + + + + {/* Status */} + + + + {running + ? 'SCANNING FOR BLE DEVICES...' + : done + ? `SCAN COMPLETE · ${bleDevices.length} devices` + : 'READY'} + + + + {/* Camera devices */} + {cameras.length > 0 && ( + <> + + BLE CAMERAS ({cameras.length}) + + {cameras.map((d) => ( + + ))} + + )} + + {/* Anonymous / suspicious */} + {others.filter((d) => !d.name).length > 0 && ( + <> + + ANONYMOUS DEVICES ({others.filter((d) => !d.name).length}) + + {others + .filter((d) => !d.name) + .map((d) => ( + + ))} + + )} + + {/* Named non-camera devices */} + {others.filter((d) => d.name).length > 0 && ( + <> + KNOWN DEVICES + {others + .filter((d) => d.name) + .map((d) => ( + + + + {d.name} + {d.rssi} dBm + + + ))} + + )} + + {!running && !done && ( + + BLE SCAN + + Detects Bluetooth Low Energy devices within range.{'\n'} + Anonymous or suspiciously named devices are flagged. + + + )} + + + + {!running ? ( + + SCAN BLUETOOTH + + ) : ( + + STOP + + )} + + + ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +const ir = StyleSheet.create({ + row: { + flexDirection: 'row', + paddingVertical: 7, + borderBottomWidth: 1, + borderBottomColor: '#1A1625', + }, + label: { width: 80, color: '#3D3650', fontSize: 9, letterSpacing: 2, fontFamily: 'monospace' }, + value: { flex: 1, color: '#7B748C', fontSize: 10, fontFamily: 'monospace' }, +}); + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#0D0A14' }, + content: { padding: 20, paddingBottom: 100 }, + title: { + color: '#C9A84C', + fontSize: 11, + letterSpacing: 6, + fontFamily: 'monospace', + textAlign: 'center', + marginBottom: 4, + }, + subtitle: { + color: '#2A2440', + fontSize: 9, + letterSpacing: 2, + fontFamily: 'monospace', + textAlign: 'center', + marginBottom: 20, + }, + infoCard: { backgroundColor: '#0A0814', borderRadius: 12, padding: 14, marginBottom: 16 }, + statusCard: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + backgroundColor: '#100D1A', + borderRadius: 12, + borderWidth: 1, + borderColor: '#1E1A2E', + padding: 16, + marginBottom: 4, + }, + statusDot: { width: 8, height: 8, borderRadius: 4 }, + statusText: { color: '#7B748C', fontSize: 10, letterSpacing: 3, fontFamily: 'monospace' }, + sectionLabel: { + color: '#3D3650', + fontSize: 9, + letterSpacing: 4, + fontFamily: 'monospace', + marginTop: 20, + marginBottom: 10, + }, + knownRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#1A1625', + }, + knownName: { color: '#7B748C', fontSize: 13 }, + knownRssi: { color: '#3D3650', fontSize: 10, fontFamily: 'monospace', marginTop: 2 }, + empty: { alignItems: 'center', marginTop: 48, gap: 12 }, + emptyPrimary: { color: '#3D3650', fontSize: 12, letterSpacing: 4, fontFamily: 'monospace' }, + emptySecondary: { color: '#2A2440', fontSize: 11, textAlign: 'center', lineHeight: 18 }, + controls: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + padding: 16, + backgroundColor: '#0D0A14', + borderTopWidth: 1, + borderTopColor: '#1A1625', + }, + btn: { borderRadius: 14, padding: 18, alignItems: 'center' }, + btnPrimary: { backgroundColor: '#C9A84C' }, + btnStop: { backgroundColor: '#EF4444' }, + btnText: { + color: '#0D0A14', + fontSize: 12, + letterSpacing: 4, + fontFamily: 'monospace', + fontWeight: '700', + }, +}); diff --git a/src/detector/screens/HistoryScreen.tsx b/src/detector/screens/HistoryScreen.tsx new file mode 100644 index 0000000..c63f54f --- /dev/null +++ b/src/detector/screens/HistoryScreen.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useDetectorStore } from '../store/detector.store'; +import { THREAT_COLORS, THREAT_LABELS } from '../services/threat.service'; +import type { ScanResult } from '../types'; + +export function HistoryScreen() { + const history = useDetectorStore((s) => s.history); + + const totalScans = history.length; + const camerasFound = history.filter((s) => s.cameraDetected).length; + const avgScore = totalScans + ? Math.round(history.reduce((a, s) => a + s.threatScore, 0) / totalScans) + : 0; + + return ( + + + SCAN HISTORY + + {totalScans} SCAN{totalScans !== 1 ? 'S' : ''} RECORDED + + + {/* Summary stats */} + {totalScans > 0 && ( + + + 0} /> + + + )} + + {history.length === 0 ? ( + + NO HISTORY YET + + Complete a sweep to build your scan history.{'\n'} + History stores up to 100 scans. + + + ) : ( + history.map((scan) => ) + )} + + + ); +} + +function ScanCard({ scan }: { scan: ScanResult }) { + const color = THREAT_COLORS[scan.threatLevel]; + const label = THREAT_LABELS[scan.threatLevel]; + const date = new Date(scan.timestamp); + const durationSec = Math.round(scan.duration / 1000); + const cameras = [...scan.networkDevices, ...scan.bleDevices].filter((d) => d.isCamera); + + return ( + + + + + {date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })} + {' '} + {date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + {label} + + + {scan.threatScore} + /100 + + + + + + + + + + + {scan.cameraDetected && cameras.length > 0 && ( + + CAMERAS DETECTED + {cameras.map((c, i) => ( + + {'› '} + {'ip' in c ? c.ip : c.name ?? 'Unknown BLE device'} + {' '}({c.confidence}% confidence) + + ))} + + )} + + ); +} + +function SumCard({ label, value, alert }: { label: string; value: string; alert?: boolean }) { + return ( + + {value} + {label} + + ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( + + {value} + {label} + + ); +} + +const sum = StyleSheet.create({ + card: { + flex: 1, + backgroundColor: '#100D1A', + borderRadius: 10, + borderWidth: 1, + borderColor: '#1E1A2E', + padding: 12, + alignItems: 'center', + }, + alert: { borderColor: '#EF444440', backgroundColor: '#150A0A' }, + val: { color: '#C9A84C', fontSize: 24, fontWeight: '700', fontFamily: 'monospace' }, + label: { color: '#3D3650', fontSize: 8, letterSpacing: 2, fontFamily: 'monospace', marginTop: 2 }, +}); + +const met = StyleSheet.create({ + cell: { flex: 1, alignItems: 'center' }, + val: { + color: '#C9A84C', + fontSize: 14, + fontFamily: 'monospace', + fontWeight: '600', + marginBottom: 2, + }, + label: { color: '#3D3650', fontSize: 8, letterSpacing: 1, fontFamily: 'monospace' }, +}); + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#0D0A14' }, + content: { padding: 20, paddingBottom: 40 }, + title: { + color: '#C9A84C', + fontSize: 11, + letterSpacing: 6, + fontFamily: 'monospace', + textAlign: 'center', + marginBottom: 4, + }, + subtitle: { + color: '#2A2440', + fontSize: 9, + letterSpacing: 3, + fontFamily: 'monospace', + textAlign: 'center', + marginBottom: 20, + }, + summaryRow: { flexDirection: 'row', gap: 8, marginBottom: 20 }, + empty: { alignItems: 'center', marginTop: 60, gap: 12 }, + emptyPrimary: { color: '#3D3650', fontSize: 12, letterSpacing: 4, fontFamily: 'monospace' }, + emptySecondary: { color: '#2A2440', fontSize: 11, textAlign: 'center', lineHeight: 18 }, + card: { + backgroundColor: '#100D1A', + borderRadius: 12, + borderWidth: 1, + borderColor: '#1E1A2E', + padding: 16, + marginBottom: 12, + }, + cardAlert: { borderColor: '#EF444440', backgroundColor: '#150A0A' }, + cardTop: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 14, + }, + cardDate: { color: '#4B4560', fontSize: 11, fontFamily: 'monospace', marginBottom: 4 }, + cardLevel: { fontSize: 13, letterSpacing: 3, fontFamily: 'monospace', fontWeight: '700' }, + scoreBubble: { + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 10, + alignItems: 'center', + }, + scoreNum: { fontSize: 26, fontWeight: '900', fontFamily: 'monospace' }, + scoreUnit: { color: '#3D3650', fontSize: 9, fontFamily: 'monospace' }, + metrics: { flexDirection: 'row' }, + cameraList: { + marginTop: 12, + paddingTop: 12, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: '#EF444430', + }, + cameraListTitle: { + color: '#EF4444', + fontSize: 9, + letterSpacing: 4, + fontFamily: 'monospace', + marginBottom: 8, + }, + cameraItem: { color: '#EF444490', fontSize: 11, fontFamily: 'monospace', marginBottom: 4 }, +}); diff --git a/src/detector/screens/MagnetometerScreen.tsx b/src/detector/screens/MagnetometerScreen.tsx new file mode 100644 index 0000000..8ff5ca8 --- /dev/null +++ b/src/detector/screens/MagnetometerScreen.tsx @@ -0,0 +1,280 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Animated, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useDetectorStore } from '../store/detector.store'; +import { useMagnetometer } from '../hooks/useMagnetometer'; +import { MagneticGraph } from '../components/MagneticGraph'; +import type { MagneticReading } from '../types'; + +interface Threshold { + label: string; + max: number; + color: string; + desc: string; +} + +const THRESHOLDS: Threshold[] = [ + { label: 'BASELINE', max: 8, color: '#22C55E', desc: 'Normal ambient field' }, + { label: 'SLIGHT ANOMALY', max: 20, color: '#84CC16', desc: 'Minor electronic interference' }, + { label: 'ELECTRONICS', max: 45, color: '#F59E0B', desc: 'Nearby electronic device' }, + { label: 'STRONG ANOMALY', max: 80, color: '#F97316', desc: 'Likely hidden hardware' }, + { + label: 'DEVICE DETECTED', + max: Infinity, + color: '#EF4444', + desc: 'Camera or surveillance device', + }, +]; + +function getThreshold(anomaly: number): Threshold { + return THRESHOLDS.find((t) => anomaly <= t.max) ?? THRESHOLDS[THRESHOLDS.length - 1]; +} + +export function MagnetometerScreen() { + const magneticReading = useDetectorStore((s) => s.magneticReading); + const [readings, setReadings] = useState([]); + const [peak, setPeak] = useState(0); + const pulseAnim = useRef(new Animated.Value(1)).current; + + useMagnetometer(true); + + useEffect(() => { + if (!magneticReading) { + return; + } + setReadings((prev) => [...prev.slice(-120), magneticReading]); + setPeak((prev) => Math.max(prev, magneticReading.anomaly)); + }, [magneticReading]); + + const anomaly = magneticReading?.anomaly ?? 0; + const threshold = getThreshold(anomaly); + const hot = anomaly > 20; + + // Pulse animation when anomaly is high + useEffect(() => { + if (!hot) { + pulseAnim.setValue(1); + return; + } + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { toValue: 1.06, duration: 500, useNativeDriver: true }), + Animated.timing(pulseAnim, { toValue: 1, duration: 500, useNativeDriver: true }), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [hot, pulseAnim]); + + return ( + + + MAGNETIC DETECTOR + MOVE PHONE SLOWLY — WATCH FOR SPIKES + + {/* Main anomaly display */} + + {anomaly.toFixed(1)} + µT ANOMALY + {threshold.label} + {threshold.desc} + + + {/* X / Y / Z breakdown */} + + {(['x', 'y', 'z'] as const).map((axis) => ( + + {axis.toUpperCase()} + {(magneticReading?.[axis] ?? 0).toFixed(1)} + µT + + ))} + + + {/* Stats row */} + + + + 20} /> + + + {/* Graph */} + {readings.length > 2 && } + + {/* Threshold guide */} + + ANOMALY THRESHOLDS + {THRESHOLDS.map((t) => ( + + + + {t.label} + {t.desc} + + + {t.max === Infinity ? '> 80 µT' : `≤ ${t.max} µT`} + + + ))} + + + {/* Tips */} + + WHERE TO SCAN + {[ + 'Smoke detectors — common hiding spot', + 'Alarm clocks, USB chargers, picture frames', + 'Air vents, wall outlets, ceiling fixtures', + 'TV remotes, pens, books on shelves', + 'Move within 5 cm of suspicious objects', + ].map((tip, i) => ( + {`› ${tip}`} + ))} + + + + ); +} + +function StatCard({ label, value, hot }: { label: string; value: string; hot?: boolean }) { + return ( + + {value} + {label} + + ); +} + +const sc = StyleSheet.create({ + card: { + flex: 1, + backgroundColor: '#100D1A', + borderRadius: 10, + borderWidth: 1, + borderColor: '#1E1A2E', + padding: 12, + alignItems: 'center', + }, + val: { + color: '#C9A84C', + fontSize: 13, + fontFamily: 'monospace', + fontWeight: '700', + marginBottom: 4, + textAlign: 'center', + }, + label: { + color: '#3D3650', + fontSize: 8, + letterSpacing: 2, + fontFamily: 'monospace', + textAlign: 'center', + }, +}); + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#0D0A14' }, + content: { padding: 20, paddingBottom: 40 }, + title: { + color: '#C9A84C', + fontSize: 11, + letterSpacing: 6, + fontFamily: 'monospace', + textAlign: 'center', + marginBottom: 4, + }, + subtitle: { + color: '#2A2440', + fontSize: 9, + letterSpacing: 3, + fontFamily: 'monospace', + textAlign: 'center', + marginBottom: 24, + }, + mainCard: { + backgroundColor: '#100D1A', + borderRadius: 16, + borderWidth: 2, + padding: 32, + alignItems: 'center', + marginBottom: 16, + }, + anomalyNum: { fontSize: 68, fontWeight: '900', fontFamily: 'monospace', letterSpacing: -2 }, + anomalyUnit: { + color: '#3D3650', + fontSize: 10, + letterSpacing: 4, + fontFamily: 'monospace', + marginTop: 4, + }, + thresholdLabel: { + fontSize: 12, + letterSpacing: 4, + fontFamily: 'monospace', + fontWeight: '700', + marginTop: 14, + }, + thresholdDesc: { color: '#5C566E', fontSize: 11, marginTop: 4 }, + xyzRow: { flexDirection: 'row', gap: 8, marginBottom: 12 }, + xyzCard: { + flex: 1, + backgroundColor: '#100D1A', + borderRadius: 10, + borderWidth: 1, + borderColor: '#1E1A2E', + padding: 14, + alignItems: 'center', + }, + xyzAxis: { + color: '#5C566E', + fontSize: 9, + letterSpacing: 3, + fontFamily: 'monospace', + marginBottom: 4, + }, + xyzVal: { color: '#8B5CF6', fontSize: 16, fontFamily: 'monospace', fontWeight: '600' }, + xyzUnit: { color: '#3D3650', fontSize: 9, fontFamily: 'monospace', marginTop: 2 }, + statsRow: { flexDirection: 'row', gap: 8, marginBottom: 8 }, + guide: { + backgroundColor: '#0A0814', + borderRadius: 12, + padding: 16, + marginTop: 16, + marginBottom: 16, + }, + guideTitle: { + color: '#3D3650', + fontSize: 9, + letterSpacing: 4, + fontFamily: 'monospace', + marginBottom: 14, + }, + guideRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 8, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#1A1625', + }, + guideDot: { width: 8, height: 8, borderRadius: 4 }, + guideLabel: { fontSize: 10, letterSpacing: 2, fontFamily: 'monospace', fontWeight: '600' }, + guideDesc: { color: '#3D3650', fontSize: 9, marginTop: 1 }, + guideRange: { color: '#3D3650', fontSize: 9, fontFamily: 'monospace' }, + tips: { backgroundColor: '#0A0814', borderRadius: 12, padding: 16 }, + tipsTitle: { + color: '#C9A84C', + fontSize: 9, + letterSpacing: 4, + fontFamily: 'monospace', + marginBottom: 12, + }, + tip: { color: '#5C566E', fontSize: 12, lineHeight: 22 }, +}); diff --git a/src/detector/screens/NetworkScreen.tsx b/src/detector/screens/NetworkScreen.tsx new file mode 100644 index 0000000..b79eba1 --- /dev/null +++ b/src/detector/screens/NetworkScreen.tsx @@ -0,0 +1,222 @@ +import React, { useCallback, useState } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useDetectorStore } from '../store/detector.store'; +import { useNetworkScan } from '../hooks/useNetworkScan'; +import { DeviceCard } from '../components/DeviceCard'; + +export function NetworkScreen() { + const { networkDevices, networkScanProgress, resetScan } = useDetectorStore(); + + const netScan = useNetworkScan(); + const [running, setRunning] = useState(false); + const [done, setDone] = useState(false); + + const handleStart = useCallback(async () => { + resetScan(); + setDone(false); + setRunning(true); + await netScan.start(); + setRunning(false); + setDone(true); + }, [netScan, resetScan]); + + const handleStop = useCallback(() => { + netScan.stop(); + setRunning(false); + setDone(true); + }, [netScan]); + + const cameras = networkDevices.filter((d) => d.isCamera); + const others = networkDevices.filter((d) => !d.isCamera); + + return ( + + + NETWORK SCANNER + PROBING LOCAL SUBNET · COMMON CAMERA PORTS + + {/* How it works */} + + + + + + + + {/* Progress */} + + + + {running + ? `SCANNING ${networkScanProgress}%` + : done + ? 'SCAN COMPLETE' + : 'READY TO SCAN'} + + + {networkDevices.length} device{networkDevices.length !== 1 ? 's' : ''} + + + + + + + + {/* Cameras */} + {cameras.length > 0 && ( + <> + + {cameras.map((d) => ( + + ))} + + )} + + {/* Other devices */} + {others.length > 0 && ( + <> + + {others.map((d) => ( + + ))} + + )} + + {!running && !done && ( + + NETWORK SCAN + + Scans your local WiFi subnet for IP cameras,{'\n'} + DVRs, NVRs, and other video devices. + + + )} + + {done && cameras.length === 0 && networkDevices.length === 0 && ( + + NO CAMERAS FOUND + + No IP cameras detected on the network.{'\n'} + Wireless cameras may use cellular or local recording. + + + )} + + + + {!running ? ( + + SCAN NETWORK + + ) : ( + + STOP SCAN + + )} + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function SectionLabel({ label, alert }: { label: string; alert?: boolean }) { + return {label}; +} + +const rowStyles = StyleSheet.create({ + row: { + flexDirection: 'row', + paddingVertical: 7, + borderBottomWidth: 1, + borderBottomColor: '#1A1625', + }, + label: { width: 90, color: '#3D3650', fontSize: 9, letterSpacing: 2, fontFamily: 'monospace' }, + value: { flex: 1, color: '#7B748C', fontSize: 10, fontFamily: 'monospace' }, +}); + +const sectionStyles = StyleSheet.create({ + label: { + color: '#3D3650', + fontSize: 9, + letterSpacing: 4, + fontFamily: 'monospace', + marginTop: 20, + marginBottom: 10, + }, +}); + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#0D0A14' }, + content: { padding: 20, paddingBottom: 100 }, + title: { + color: '#C9A84C', + fontSize: 11, + letterSpacing: 6, + fontFamily: 'monospace', + textAlign: 'center', + marginBottom: 4, + }, + subtitle: { + color: '#2A2440', + fontSize: 9, + letterSpacing: 2, + fontFamily: 'monospace', + textAlign: 'center', + marginBottom: 20, + }, + infoCard: { + backgroundColor: '#0A0814', + borderRadius: 12, + padding: 14, + marginBottom: 16, + }, + progressCard: { + backgroundColor: '#100D1A', + borderRadius: 12, + borderWidth: 1, + borderColor: '#1E1A2E', + padding: 16, + marginBottom: 4, + }, + progressHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 12 }, + progressStatus: { color: '#C9A84C', fontSize: 10, letterSpacing: 3, fontFamily: 'monospace' }, + progressCount: { color: '#8B5CF6', fontSize: 10, letterSpacing: 2, fontFamily: 'monospace' }, + progressTrack: { height: 4, backgroundColor: '#1A1625', borderRadius: 2, overflow: 'hidden' }, + progressFill: { height: 4, backgroundColor: '#8B5CF6', borderRadius: 2 }, + empty: { alignItems: 'center', marginTop: 48, gap: 12 }, + emptyPrimary: { color: '#3D3650', fontSize: 12, letterSpacing: 4, fontFamily: 'monospace' }, + emptySecondary: { color: '#2A2440', fontSize: 11, textAlign: 'center', lineHeight: 18 }, + controls: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + padding: 16, + backgroundColor: '#0D0A14', + borderTopWidth: 1, + borderTopColor: '#1A1625', + }, + btn: { borderRadius: 14, padding: 18, alignItems: 'center' }, + btnPrimary: { backgroundColor: '#C9A84C' }, + btnStop: { backgroundColor: '#EF4444' }, + btnText: { + color: '#0D0A14', + fontSize: 12, + letterSpacing: 4, + fontFamily: 'monospace', + fontWeight: '700', + }, +}); diff --git a/src/detector/screens/SweepScreen.tsx b/src/detector/screens/SweepScreen.tsx new file mode 100644 index 0000000..d61ebcc --- /dev/null +++ b/src/detector/screens/SweepScreen.tsx @@ -0,0 +1,326 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, Vibration, View } from 'react-native'; +import { useDetectorStore } from '../store/detector.store'; +import { useMagnetometer } from '../hooks/useMagnetometer'; +import { useNetworkScan } from '../hooks/useNetworkScan'; +import { useBluetooth } from '../hooks/useBluetooth'; +import { ThreatMeter } from '../components/ThreatMeter'; +import { RadarPulse } from '../components/RadarPulse'; +import { DeviceCard } from '../components/DeviceCard'; +import { MagneticGraph } from '../components/MagneticGraph'; +import { THREAT_COLORS } from '../services/threat.service'; +import type { MagneticReading } from '../types'; + +const MAX_GRAPH = 60; + +export function SweepScreen() { + const { + scanPhase, + threatScore, + magneticReading, + networkDevices, + bleDevices, + networkScanProgress, + startScan, + stopScan, + saveScan, + resetScan, + } = useDetectorStore(); + + const [graphReadings, setGraphReadings] = useState([]); + const isScanning = scanPhase === 'scanning'; + + const netScan = useNetworkScan(); + const btScan = useBluetooth(); + useMagnetometer(isScanning || scanPhase === 'complete'); + + useEffect(() => { + if (magneticReading) { + setGraphReadings((prev) => [...prev.slice(-MAX_GRAPH + 1), magneticReading]); + } + }, [magneticReading]); + + // Haptic alert on high/critical threat + useEffect(() => { + if (threatScore.level === 'critical') { + Vibration.vibrate([0, 300, 150, 300, 150, 300]); + } else if (threatScore.level === 'high') { + Vibration.vibrate([0, 200, 100, 200]); + } + }, [threatScore.level]); + + const handleStart = useCallback(() => { + setGraphReadings([]); + startScan(); + netScan.start(); + btScan.start(); + }, [startScan, netScan, btScan]); + + const handleStop = useCallback(() => { + netScan.stop(); + btScan.stop(); + stopScan(); + saveScan(); + }, [netScan, btScan, stopScan, saveScan]); + + const handleReset = useCallback(() => { + resetScan(); + setGraphReadings([]); + }, [resetScan]); + + const threatColor = THREAT_COLORS[threatScore.level]; + const cameraDevices = [ + ...networkDevices.filter((d) => d.isCamera), + ...bleDevices.filter((d) => d.isCamera), + ]; + const allDevices = [...networkDevices, ...bleDevices]; + + return ( + + + ALL-SENSOR SWEEP + MAGNETIC · NETWORK · BLUETOOTH + + {/* Primary displays */} + + + + + + {/* Stat grid */} + + + + 0} /> + 20} + /> + + + {/* Network progress */} + {isScanning && networkScanProgress < 100 && ( + + NETWORK SCAN {networkScanProgress}% + + + + + )} + + {/* Magnetic graph */} + {graphReadings.length > 2 && } + + {/* Camera alert banner */} + {cameraDevices.length > 0 && ( + + + {cameraDevices.length} CAMERA{cameraDevices.length > 1 ? 'S' : ''} DETECTED + + Verify each device below immediately + + )} + + {/* Device list */} + {allDevices.length > 0 && ( + + DETECTED DEVICES ({allDevices.length}) + {/* Show camera devices first */} + {cameraDevices.map((d) => ( + + ))} + {allDevices + .filter((d) => !d.isCamera) + .map((d) => ( + + ))} + + )} + + {scanPhase === 'idle' && ( + + + Tap SCAN to launch all detectors simultaneously.{'\n'} + Sweep your phone slowly around the room — aim for corners, vents, clocks, and smoke + detectors. + + + )} + + + {/* Bottom controls */} + + {scanPhase === 'idle' && ( + + START SCAN + + )} + {isScanning && ( + + STOP & SAVE + + )} + {scanPhase === 'complete' && ( + + + + SCAN COMPLETE — {threatScore.total}/100 + + + + NEW SCAN + + + )} + + + ); +} + +function GridStat({ + label, + value, + sub, + alert, +}: { + label: string; + value: string | number; + sub?: string; + alert?: boolean; +}) { + return ( + + {value} + {label} + {sub !== undefined && {sub}} + + ); +} + +const gs = StyleSheet.create({ + cell: { + flex: 1, + backgroundColor: '#100D1A', + borderRadius: 10, + borderWidth: 1, + borderColor: '#1E1A2E', + padding: 12, + alignItems: 'center', + }, + cellAlert: { borderColor: '#EF444440', backgroundColor: '#150A0A' }, + val: { color: '#C9A84C', fontSize: 22, fontWeight: '700', fontFamily: 'monospace' }, + label: { color: '#3D3650', fontSize: 9, letterSpacing: 2, fontFamily: 'monospace', marginTop: 2 }, + sub: { color: '#2A2440', fontSize: 9, fontFamily: 'monospace', marginTop: 1 }, +}); + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#0D0A14' }, + content: { padding: 20, paddingBottom: 110 }, + + pageTitle: { + color: '#C9A84C', + fontSize: 11, + letterSpacing: 6, + fontFamily: 'monospace', + textAlign: 'center', + marginBottom: 4, + }, + pageSubtitle: { + color: '#2A2440', + fontSize: 9, + letterSpacing: 3, + fontFamily: 'monospace', + textAlign: 'center', + marginBottom: 28, + }, + + primaryRow: { + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + marginBottom: 24, + }, + + grid: { flexDirection: 'row', gap: 8, marginBottom: 16 }, + + progressWrap: { marginBottom: 14 }, + progressLabel: { + color: '#5C566E', + fontSize: 9, + letterSpacing: 3, + fontFamily: 'monospace', + marginBottom: 6, + }, + progressTrack: { height: 4, backgroundColor: '#1A1625', borderRadius: 2, overflow: 'hidden' }, + progressFill: { height: 4, backgroundColor: '#8B5CF6', borderRadius: 2 }, + + alertBanner: { + backgroundColor: '#EF444412', + borderWidth: 1, + borderColor: '#EF444450', + borderRadius: 12, + padding: 16, + alignItems: 'center', + marginVertical: 12, + }, + alertBannerText: { + color: '#EF4444', + fontSize: 14, + letterSpacing: 4, + fontFamily: 'monospace', + fontWeight: '700', + }, + alertBannerSub: { color: '#EF444480', fontSize: 10, marginTop: 4 }, + + deviceSection: { marginTop: 8 }, + sectionLabel: { + color: '#3D3650', + fontSize: 9, + letterSpacing: 4, + fontFamily: 'monospace', + marginBottom: 10, + }, + + idleHint: { + marginTop: 40, + padding: 20, + borderRadius: 12, + borderWidth: 1, + borderColor: '#1E1A2E', + borderStyle: 'dashed', + }, + idleHintText: { + color: '#3D3650', + fontSize: 12, + lineHeight: 20, + textAlign: 'center', + }, + + controls: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + padding: 16, + backgroundColor: '#0D0A14', + borderTopWidth: 1, + borderTopColor: '#1A1625', + }, + btn: { borderRadius: 14, padding: 18, alignItems: 'center' }, + btnStart: { backgroundColor: '#C9A84C' }, + btnStop: { backgroundColor: '#EF4444' }, + btnNew: { backgroundColor: '#8B5CF6' }, + btnText: { + color: '#0D0A14', + fontSize: 12, + letterSpacing: 4, + fontFamily: 'monospace', + fontWeight: '700', + }, + completeRow: { gap: 10 }, + completeBadge: { borderRadius: 10, padding: 12, alignItems: 'center' }, + completeLabel: { fontSize: 11, letterSpacing: 3, fontFamily: 'monospace', fontWeight: '600' }, +}); diff --git a/src/detector/services/bluetooth.service.ts b/src/detector/services/bluetooth.service.ts new file mode 100644 index 0000000..9c52e6f --- /dev/null +++ b/src/detector/services/bluetooth.service.ts @@ -0,0 +1,133 @@ +import type { BleDevice } from '../types'; + +// Name patterns strongly associated with IP/BLE cameras +const CAMERA_NAME_RE = [ + /\bcam(era)?\b/i, + /\bipc\b/i, + /\bdvr\b/i, + /\bnvr\b/i, + /\bspy\b/i, + /\bhidden\b/i, + /reolink/i, + /dahua/i, + /hikvision/i, + /foscam/i, + /blink/i, + /wyze\s*cam/i, + /arlo/i, + /ring\s*cam/i, + /nest\s*cam/i, + /eufycam/i, +]; + +// Known BLE manufacturer IDs mapped to camera vendors +// TODO[P1]: Expand from full IEEE OUI database +const CAMERA_MANUFACTURER_IDS: number[] = [ + 0x01e4, // Axis Communications + 0x033b, // Sony (some surveillance) + 0x04e8, // Samsung Techwin +]; + +export function analyzeDevice( + name?: string, + manufacturerId?: number, +): { + isCamera: boolean; + indicators: string[]; + confidence: number; +} { + const indicators: string[] = []; + let confidence = 0; + + if (name) { + for (const re of CAMERA_NAME_RE) { + if (re.test(name)) { + indicators.push(`Name matches: ${re.source}`); + confidence += 40; + break; + } + } + } + + if (manufacturerId !== undefined && CAMERA_MANUFACTURER_IDS.includes(manufacturerId)) { + indicators.push(`Camera vendor ID: 0x${manufacturerId.toString(16).toUpperCase()}`); + confidence += 45; + } + + // Unnamed devices near -60 dBm or closer are mildly suspicious + if (!name) { + indicators.push('Anonymous device'); + confidence += 8; + } + + return { isCamera: confidence >= 40, indicators, confidence: Math.min(100, confidence) }; +} + +class BluetoothService { + // NOTE[P1]: Replace mock with react-native-ble-plx when installed: + // import { BleManager } from 'react-native-ble-plx'; + // const mgr = new BleManager(); + // mgr.startDeviceScan(null, { allowDuplicates: false }, (err, device) => { + // if (device) onDevice(mapDevice(device)); + // }); + private readonly useMock = true; + + async scan( + onDevice: (d: BleDevice) => void, + durationMs = 12000, + signal?: AbortSignal, + ): Promise { + return this.useMock ? this.mockScan(onDevice, durationMs, signal) : Promise.resolve([]); + } + + private mockScan( + onDevice: (d: BleDevice) => void, + durationMs: number, + signal?: AbortSignal, + ): Promise { + const pool: Array<{ name?: string; rssi: number }> = [ + { name: 'iPhone 14', rssi: -68 }, + { name: 'AirPods Pro', rssi: -72 }, + { name: 'MacBook Air', rssi: -81 }, + { name: undefined, rssi: -54 }, // anonymous — mildly suspicious + { name: 'Smart TV', rssi: -77 }, + { name: undefined, rssi: -47 }, // anonymous, close range + { name: 'BT Speaker', rssi: -85 }, + { name: 'iPad mini', rssi: -63 }, + ]; + + const found: BleDevice[] = []; + let idx = 0; + const interval = Math.floor(durationMs / pool.length); + + return new Promise((resolve) => { + const tick = setInterval(() => { + if (signal?.aborted || idx >= pool.length) { + clearInterval(tick); + resolve(found); + return; + } + const raw = pool[idx++]; + const analysis = analyzeDevice(raw.name); + const device: BleDevice = { + id: `ble-${idx}`, + name: raw.name, + rssi: raw.rssi, + isCamera: analysis.isCamera, + cameraIndicators: analysis.indicators, + confidence: analysis.confidence, + firstSeen: Date.now(), + }; + found.push(device); + onDevice(device); + }, interval); + + setTimeout(() => { + clearInterval(tick); + resolve(found); + }, durationMs); + }); + } +} + +export const bluetoothService = new BluetoothService(); diff --git a/src/detector/services/magnetometer.service.ts b/src/detector/services/magnetometer.service.ts new file mode 100644 index 0000000..7e27818 --- /dev/null +++ b/src/detector/services/magnetometer.service.ts @@ -0,0 +1,92 @@ +import type { MagneticReading } from '../types'; + +// Rolling baseline window — 20 readings @ 10 Hz = 2 seconds +const BASELINE_WINDOW = 20; + +type ReadingCallback = (r: MagneticReading) => void; + +class MagnetometerService { + private readings: number[] = []; + private subscriber: ReadingCallback | null = null; + private timer: ReturnType | null = null; + + // NOTE[P1]: Swap mock with react-native-sensors when installed: + // import { magnetometer, SensorTypes, setUpdateIntervalForType } from 'react-native-sensors'; + // setUpdateIntervalForType(SensorTypes.magnetometer, 100); + // const sub = magnetometer.subscribe(({ x, y, z }) => this.process(x, y, z)); + private readonly useMock = true; + + start(onReading: ReadingCallback): void { + this.subscriber = onReading; + this.readings = []; + if (this.useMock) { + this.startMock(); + } + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + this.subscriber = null; + this.readings = []; + } + + private startMock(): void { + // Realistic Earth magnetic field baseline (~25-65 µT depending on location) + const baseX = 22 + Math.random() * 10; + const baseY = -15 + Math.random() * 5; + const baseZ = 40 + Math.random() * 10; + + let anomalyActive = false; + let anomalyMagnitude = 0; + let ticksSinceAnomaly = 0; + + this.timer = setInterval(() => { + const noise = () => (Math.random() - 0.5) * 3; + + // Randomly trigger anomaly bursts simulating nearby electronics + ticksSinceAnomaly++; + if (!anomalyActive && ticksSinceAnomaly > 40 && Math.random() < 0.04) { + anomalyActive = true; + anomalyMagnitude = 15 + Math.random() * 65; + ticksSinceAnomaly = 0; + } + if (anomalyActive && Math.random() < 0.08) { + anomalyActive = false; + anomalyMagnitude = 0; + } + + const boost = anomalyActive ? anomalyMagnitude : 0; + const x = baseX + noise() + boost * 0.6; + const y = baseY + noise() + boost * 0.3; + const z = baseZ + noise() + boost * 0.1; + + this.process(x, y, z); + }, 100); // 10 Hz + } + + private process(x: number, y: number, z: number): void { + const magnitude = Math.sqrt(x * x + y * y + z * z); + this.readings.push(magnitude); + if (this.readings.length > BASELINE_WINDOW) { + this.readings.shift(); + } + + const baseline = this.readings.reduce((a, b) => a + b, 0) / this.readings.length; + const anomaly = Math.abs(magnitude - baseline); + + this.subscriber?.({ + x, + y, + z, + magnitude, + baseline, + anomaly, + timestamp: Date.now(), + }); + } +} + +export const magnetometerService = new MagnetometerService(); diff --git a/src/detector/services/network.service.ts b/src/detector/services/network.service.ts new file mode 100644 index 0000000..e6908d4 --- /dev/null +++ b/src/detector/services/network.service.ts @@ -0,0 +1,187 @@ +import type { NetworkDevice } from '../types'; + +// Strings that appear in camera HTTP responses / headers +const CAMERA_SIGNATURES = [ + 'hikvision', + 'dahua', + 'axis', + 'foscam', + 'amcrest', + 'reolink', + 'lorex', + 'wisenet', + 'hanwha', + 'bosch', + 'pelco', + 'vivotek', + 'avigilon', + 'ipcam', + 'netcam', + 'ip camera', + 'webcam', + 'ipcamera', + 'cgi-bin/guestimage', + 'videoserver', + 'dvr', + 'nvr', + 'camera', + 'surveillance', + 'mjpg', + 'mjpeg', + 'onvif', +] as const; + +// Ports commonly opened by IP cameras / DVRs / NVRs +const CAMERA_PORTS = [80, 554, 8080, 8554, 81, 8081, 8888, 443, 9000, 37777] as const; + +interface ProbeResult { + ip: string; + port: number; + status: number; + headers: Record; + body: string; + responseTimeMs: number; +} + +async function probeHost(ip: string, port: number, timeoutMs = 1200): Promise { + const start = Date.now(); + try { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), timeoutMs); + const res = await fetch(`http://${ip}:${port}/`, { + signal: controller.signal, + method: 'GET', + headers: { 'User-Agent': 'Mozilla/5.0' }, + }); + clearTimeout(t); + + const headers: Record = {}; + res.headers.forEach((v: string, k: string) => { + headers[k] = v; + }); + + let body = ''; + try { + body = await res.text(); + } catch { + /* ignore */ + } + + return { + ip, + port, + status: res.status, + headers, + body: body.slice(0, 3000), + responseTimeMs: Date.now() - start, + }; + } catch { + return null; + } +} + +function fingerprint(r: ProbeResult): { + isCamera: boolean; + confidence: number; + cameraType: NetworkDevice['cameraType']; +} { + const haystack = [...Object.values(r.headers), r.body].join(' ').toLowerCase(); + + let confidence = 0; + let cameraType: NetworkDevice['cameraType'] = 'unknown'; + + for (const sig of CAMERA_SIGNATURES) { + if (haystack.includes(sig)) { + confidence += sig === 'dvr' || sig === 'nvr' ? 25 : 18; + if (sig === 'dvr') { + cameraType = 'dvr'; + } else if (sig === 'nvr') { + cameraType = 'nvr'; + } else if (cameraType === 'unknown') { + cameraType = 'ip_camera'; + } + } + } + + // RTSP port is a strong camera signal + if (r.port === 554 || r.port === 8554) { + confidence += 20; + } + // Device responded — adds a baseline signal + if (r.status > 0) { + confidence += 5; + } + + return { + isCamera: confidence >= 22, + confidence: Math.min(100, confidence), + cameraType, + }; +} + +// NOTE[P1]: Replace with react-native-wifi-reborn to get real local IP +function inferSubnet(): string { + return '192.168.1'; +} + +export async function scanNetwork( + onProgress: (scanned: number, total: number) => void, + onDevice: (device: NetworkDevice) => void, + signal?: AbortSignal, +): Promise { + const subnet = inferSubnet(); + const ips = Array.from({ length: 254 }, (_, i) => `${subnet}.${i + 1}`); + const found: NetworkDevice[] = []; + const BATCH = 16; // concurrent probes + + for (let i = 0; i < ips.length; i += BATCH) { + if (signal?.aborted) { + break; + } + + const batch = ips.slice(i, i + BATCH); + const probes = await Promise.all( + batch.flatMap((ip) => CAMERA_PORTS.map((port) => probeHost(ip, port))), + ); + + for (const result of probes) { + if (!result) { + continue; + } + const fp = fingerprint(result); + const existing = found.find((d) => d.ip === result.ip); + + if (existing) { + if (!existing.openPorts.includes(result.port)) { + existing.openPorts.push(result.port); + } + existing.confidence = Math.max(existing.confidence, fp.confidence); + if (fp.isCamera) { + existing.isCamera = true; + } + if (fp.cameraType !== 'unknown') { + existing.cameraType = fp.cameraType; + } + existing.lastSeen = Date.now(); + } else { + const device: NetworkDevice = { + id: `net-${result.ip}`, + ip: result.ip, + openPorts: [result.port], + isCamera: fp.isCamera, + cameraType: fp.cameraType, + responseTimeMs: result.responseTimeMs, + confidence: fp.confidence, + firstSeen: Date.now(), + lastSeen: Date.now(), + }; + found.push(device); + onDevice(device); + } + } + + onProgress(Math.min(i + BATCH, ips.length), ips.length); + } + + return found; +} diff --git a/src/detector/services/threat.service.ts b/src/detector/services/threat.service.ts new file mode 100644 index 0000000..b13dd59 --- /dev/null +++ b/src/detector/services/threat.service.ts @@ -0,0 +1,107 @@ +import type { ThreatScore, ThreatLevel, MagneticReading, NetworkDevice, BleDevice } from '../types'; + +const WEIGHTS = { magnetic: 0.25, network: 0.45, bluetooth: 0.3 } as const; + +// Magnetic anomaly thresholds in µT above rolling baseline +const MAG = { low: 8, medium: 20, high: 45, critical: 80 } as const; + +function scoreMagnetic(reading: MagneticReading | null): number { + if (!reading) { + return 0; + } + const a = reading.anomaly; + if (a < MAG.low) { + return 0; + } + if (a < MAG.medium) { + return 25; + } + if (a < MAG.high) { + return 55; + } + if (a < MAG.critical) { + return 80; + } + return 100; +} + +function scoreNetwork(devices: NetworkDevice[]): number { + if (!devices.length) { + return 0; + } + const cameras = devices.filter((d) => d.isCamera); + if (!cameras.length) { + return Math.min(12, devices.length * 2); + } + return Math.min(100, Math.max(...cameras.map((d) => d.confidence))); +} + +function scoreBluetooth(devices: BleDevice[]): number { + if (!devices.length) { + return 0; + } + const cameras = devices.filter((d) => d.isCamera); + if (!cameras.length) { + return Math.min(8, devices.length * 2); + } + return Math.min(100, Math.max(...cameras.map((d) => d.confidence))); +} + +export function computeThreatScore(params: { + magnetic: MagneticReading | null; + networkDevices: NetworkDevice[]; + bleDevices: BleDevice[]; +}): ThreatScore { + const magnetic = scoreMagnetic(params.magnetic); + const network = scoreNetwork(params.networkDevices); + const bluetooth = scoreBluetooth(params.bleDevices); + + const total = Math.round( + magnetic * WEIGHTS.magnetic + network * WEIGHTS.network + bluetooth * WEIGHTS.bluetooth, + ); + + // Confidence reflects how many sensor channels are active + const confidence = + (params.magnetic ? 25 : 0) + + (params.networkDevices.length > 0 ? 45 : 0) + + (params.bleDevices.length > 0 ? 30 : 0); + + return { + total: Math.min(100, total), + level: levelFromScore(total), + contributors: { magnetic, network, bluetooth }, + confidence, + }; +} + +export function levelFromScore(score: number): ThreatLevel { + if (score >= 75) { + return 'critical'; + } + if (score >= 55) { + return 'high'; + } + if (score >= 35) { + return 'medium'; + } + if (score >= 15) { + return 'low'; + } + return 'safe'; +} + +export const THREAT_COLORS: Record = { + safe: '#22C55E', + low: '#84CC16', + medium: '#F59E0B', + high: '#F97316', + critical: '#EF4444', +}; + +export const THREAT_LABELS: Record = { + safe: 'AREA CLEAR', + low: 'LOW RISK', + medium: 'SUSPICIOUS', + high: 'THREAT DETECTED', + critical: 'CAMERA FOUND', +}; diff --git a/src/detector/store/detector.store.ts b/src/detector/store/detector.store.ts new file mode 100644 index 0000000..0887b3a --- /dev/null +++ b/src/detector/store/detector.store.ts @@ -0,0 +1,119 @@ +import { create } from 'zustand'; +import type { + MagneticReading, + NetworkDevice, + BleDevice, + ScanResult, + ThreatScore, + ScanPhase, +} from '../types'; +import { computeThreatScore } from '../services/threat.service'; + +interface DetectorState { + scanPhase: ScanPhase; + scanStartTime: number | null; + magneticReading: MagneticReading | null; + networkDevices: NetworkDevice[]; + bleDevices: BleDevice[]; + networkScanProgress: number; + threatScore: ThreatScore; + history: ScanResult[]; + + startScan: () => void; + stopScan: () => void; + setMagneticReading: (r: MagneticReading) => void; + addNetworkDevice: (d: NetworkDevice) => void; + addBleDevice: (d: BleDevice) => void; + setNetworkProgress: (n: number) => void; + saveScan: () => void; + resetScan: () => void; +} + +const INITIAL_THREAT: ThreatScore = { + total: 0, + level: 'safe', + contributors: { magnetic: 0, network: 0, bluetooth: 0 }, + confidence: 0, +}; + +function recompute( + magnetic: MagneticReading | null, + networkDevices: NetworkDevice[], + bleDevices: BleDevice[], +): ThreatScore { + return computeThreatScore({ magnetic, networkDevices, bleDevices }); +} + +export const useDetectorStore = create((set, get) => ({ + scanPhase: 'idle', + scanStartTime: null, + magneticReading: null, + networkDevices: [], + bleDevices: [], + networkScanProgress: 0, + threatScore: INITIAL_THREAT, + history: [], + + startScan: () => + set({ + scanPhase: 'scanning', + scanStartTime: Date.now(), + networkDevices: [], + bleDevices: [], + networkScanProgress: 0, + magneticReading: null, + threatScore: INITIAL_THREAT, + }), + + stopScan: () => set({ scanPhase: 'complete' }), + + setMagneticReading: (magneticReading) => { + const { networkDevices, bleDevices } = get(); + set({ magneticReading, threatScore: recompute(magneticReading, networkDevices, bleDevices) }); + }, + + addNetworkDevice: (device) => { + const { magneticReading, bleDevices } = get(); + const prev = get().networkDevices; + const idx = prev.findIndex((d) => d.id === device.id); + const networkDevices = + idx >= 0 ? prev.map((d, i) => (i === idx ? device : d)) : [...prev, device]; + set({ networkDevices, threatScore: recompute(magneticReading, networkDevices, bleDevices) }); + }, + + addBleDevice: (device) => { + const { magneticReading, networkDevices } = get(); + const bleDevices = [...get().bleDevices, device]; + set({ bleDevices, threatScore: recompute(magneticReading, networkDevices, bleDevices) }); + }, + + setNetworkProgress: (networkScanProgress) => set({ networkScanProgress }), + + saveScan: () => { + const { magneticReading, networkDevices, bleDevices, threatScore, scanStartTime, history } = + get(); + const result: ScanResult = { + id: `scan-${Date.now()}`, + timestamp: Date.now(), + duration: scanStartTime ? Date.now() - scanStartTime : 0, + threatScore: threatScore.total, + threatLevel: threatScore.level, + magneticAnomalyPeak: magneticReading?.anomaly ?? 0, + networkDevices, + bleDevices, + cameraDetected: threatScore.level === 'critical' || threatScore.level === 'high', + }; + set({ history: [result, ...history].slice(0, 100) }); + }, + + resetScan: () => + set({ + scanPhase: 'idle', + scanStartTime: null, + magneticReading: null, + networkDevices: [], + bleDevices: [], + networkScanProgress: 0, + threatScore: INITIAL_THREAT, + }), +})); diff --git a/src/detector/types.ts b/src/detector/types.ts new file mode 100644 index 0000000..d7a4597 --- /dev/null +++ b/src/detector/types.ts @@ -0,0 +1,62 @@ +export type ThreatLevel = 'safe' | 'low' | 'medium' | 'high' | 'critical'; + +export type ScanPhase = 'idle' | 'scanning' | 'complete'; + +export interface MagneticReading { + x: number; + y: number; + z: number; + magnitude: number; + baseline: number; + anomaly: number; + timestamp: number; +} + +export interface NetworkDevice { + id: string; + ip: string; + mac?: string; + hostname?: string; + vendor?: string; + openPorts: number[]; + isCamera: boolean; + cameraType?: 'ip_camera' | 'dvr' | 'nvr' | 'unknown'; + responseTimeMs?: number; + firstSeen: number; + lastSeen: number; + confidence: number; +} + +export interface BleDevice { + id: string; + name?: string; + rssi: number; + isCamera: boolean; + cameraIndicators: string[]; + firstSeen: number; + confidence: number; +} + +export interface ScanResult { + id: string; + timestamp: number; + location?: string; + duration: number; + threatScore: number; + threatLevel: ThreatLevel; + magneticAnomalyPeak: number; + networkDevices: NetworkDevice[]; + bleDevices: BleDevice[]; + cameraDetected: boolean; +} + +export interface ThreatScore { + total: number; + level: ThreatLevel; + contributors: { + magnetic: number; + network: number; + bluetooth: number; + }; + confidence: number; +} diff --git a/src/screens/LobbyScreen.tsx b/src/screens/LobbyScreen.tsx index 3045c4b..993de67 100644 --- a/src/screens/LobbyScreen.tsx +++ b/src/screens/LobbyScreen.tsx @@ -1,13 +1,5 @@ import React, { useState } from 'react'; -import { - View, - Text, - TouchableOpacity, - StyleSheet, - ScrollView, - Modal, - Image, -} from 'react-native'; +import { View, Text, TouchableOpacity, StyleSheet, ScrollView, Modal, Image } from 'react-native'; import { useGameStore } from '../services/state'; import { clearBotBrains } from '../services/ai'; import { CHARACTERS } from '../core/characters'; @@ -74,10 +66,7 @@ export const LobbyScreen: React.FC = () => { {c.title} - + {c.ability.name} @@ -93,7 +82,9 @@ export const LobbyScreen: React.FC = () => { {/* Selected character summary bar */} {(() => { const sel = CHARACTERS.find((c) => c.id === selectedId); - if (!sel) return null; + if (!sel) { + return null; + } return ( { > {sel.name} - {sel.ability.name} —{' '} - {(sel.ability.cooldownMs / 1000).toFixed(0)}s cd + {sel.ability.name} — {(sel.ability.cooldownMs / 1000).toFixed(0)}s cd {sel.ability.durationMs > 0 ? ` · ${(sel.ability.durationMs / 1000).toFixed(0)}s` : ' · instant'} @@ -131,9 +121,7 @@ export const LobbyScreen: React.FC = () => { onPress={() => setDetailId(null)} > {detailChar && ( - + {detailChar.portraitSource !== null && ( { - return { wood: 100, stone: 50, metal: 25 }; -} - function makePlayer( id: string, name: string, @@ -304,7 +299,11 @@ function makePlayer( status: 'alive', weapons: [makeWeapon('pickaxe', 'common'), null, null], activeWeaponSlot: 0, - materials: { wood: baseMaterials, stone: 50 + (isHuman ? p.materialsBonus : 0), metal: 25 + (isHuman ? p.materialsBonus : 0) }, + materials: { + wood: baseMaterials, + stone: 50 + (isHuman ? p.materialsBonus : 0), + metal: 25 + (isHuman ? p.materialsBonus : 0), + }, kills: 0, isBuilding: false, selectedBuildPiece: 'wall', @@ -379,7 +378,7 @@ function buildHelixRelays(mapWidth: number, mapHeight: number): HelixRelay[] { const positions: Vector2[] = [ { x: mapWidth * 0.25, y: mapHeight * 0.25 }, { x: mapWidth * 0.75, y: mapHeight * 0.25 }, - { x: mapWidth * 0.5, y: mapHeight * 0.5 }, + { x: mapWidth * 0.5, y: mapHeight * 0.5 }, { x: mapWidth * 0.25, y: mapHeight * 0.75 }, { x: mapWidth * 0.75, y: mapHeight * 0.75 }, ]; @@ -394,10 +393,16 @@ function buildHelixRelays(mapWidth: number, mapHeight: number): HelixRelay[] { } function buildInitialState(characterId = DEFAULT_CHARACTER_ID): GameState { - const human = makePlayer('human', 'You', true, { - x: MAP_WIDTH / 2, - y: MAP_HEIGHT / 2, - }, characterId); + const human = makePlayer( + 'human', + 'You', + true, + { + x: MAP_WIDTH / 2, + y: MAP_HEIGHT / 2, + }, + characterId, + ); const bots: Player[] = Array.from({ length: BOT_COUNT }, (_, i) => makePlayer(`bot_${i}`, `Bot${i + 1}`, false, { x: randomInRange(100, MAP_WIDTH - 100), diff --git a/src/types/game.ts b/src/types/game.ts index 0c77d77..35f5864 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -2,10 +2,10 @@ export type AbilityEffectType = | 'none' - | 'damage_immunity' // take zero damage - | 'speed_boost' // movement speed multiplied - | 'rapid_fire' // fire rate doubled - | 'damage_boost'; // outgoing damage multiplied + | 'damage_immunity' // take zero damage + | 'speed_boost' // movement speed multiplied + | 'rapid_fire' // fire rate doubled + | 'damage_boost'; // outgoing damage multiplied export type CharacterAbility = { name: string; @@ -23,9 +23,9 @@ export type CharacterPassive = { speedMult: number; damageMult: number; damageResistance: number; // 0.0–1.0 fraction of incoming damage blocked - reloadMult: number; // multiplied into reloadTime (< 1.0 = faster) - killHealAmount: number; // HP restored per elimination - materialsBonus: number; // extra starting units of each material + reloadMult: number; // multiplied into reloadTime (< 1.0 = faster) + killHealAmount: number; // HP restored per elimination + materialsBonus: number; // extra starting units of each material }; export type Character = { @@ -147,23 +147,23 @@ export type Player = { selectedBuildMaterial: BuildingMaterial; // Character system characterId: string; - damageMult: number; // outgoing damage multiplier from passive - damageResistance: number; // fraction of incoming damage blocked (0.0–1.0) - killHealAmount: number; // HP restored per kill - speedMult: number; // movement speed multiplier from passive - reloadMult: number; // reload time multiplier (< 1.0 = faster) - abilityChargeMs: number; // ms until ability is ready again (0 = ready) - abilityActiveMs: number; // ms remaining on the active timed effect + damageMult: number; // outgoing damage multiplier from passive + damageResistance: number; // fraction of incoming damage blocked (0.0–1.0) + killHealAmount: number; // HP restored per kill + speedMult: number; // movement speed multiplier from passive + reloadMult: number; // reload time multiplier (< 1.0 = faster) + abilityChargeMs: number; // ms until ability is ready again (0 = ready) + abilityActiveMs: number; // ms remaining on the active timed effect activeAbilityEffect: AbilityEffectType; // Fracture Core system heldCoreEffect: FractureCoreEffect | null; // buff from a picked-up Fracture Core - corruptionDps: number; // HP drained per second while holding a core + corruptionDps: number; // HP drained per second while holding a core }; export type MeteorType = | 'explosive' // standard — deals AoE damage, leaves a Fracture Core - | 'gravity' // bends space — creates a Gravity Distortion Field, no AoE damage - | 'echo'; // rare — creates a Time Echo Zone, no AoE damage + | 'gravity' // bends space — creates a Gravity Distortion Field, no AoE damage + | 'echo'; // rare — creates a Time Echo Zone, no AoE damage // A single meteor impact crater — shown briefly on the map after a strike export type MeteorImpact = { @@ -177,8 +177,8 @@ export type MeteorImpact = { export type FractureCoreEffect = | 'cooldown_reduction' // instantly halves ability cooldown; ongoing: charges faster - | 'damage_amp' // +40% outgoing damage while held - | 'ability_mutation'; // triggers a random ability effect for 10s; unpredictable + | 'damage_amp' // +40% outgoing damage while held + | 'ability_mutation'; // triggers a random ability effect for 10s; unpredictable // Loot left at explosive impact sites. Strong buff, slow corruption. export type FractureCore = { @@ -194,7 +194,7 @@ export type GravityZone = { position: Vector2; radius: number; pullStrength: number; // units per tick pulled toward center - speedMult: number; // movement speed multiplier inside the zone + speedMult: number; // movement speed multiplier inside the zone age: number; maxAge: number; }; @@ -214,9 +214,9 @@ export type HelixRelay = { id: string; position: Vector2; captureRadius: number; - captureProgress: number; // 0.0 → 1.0 (fills over 5 seconds while occupied) + captureProgress: number; // 0.0 → 1.0 (fills over 5 seconds while occupied) capturedById: string | null; - rewardCooldownMs: number; // ms until it can reward again after capture + rewardCooldownMs: number; // ms until it can reward again after capture }; // A crate airdropped by rogue factions opposing Helix. @@ -225,7 +225,7 @@ export type SupplyDrop = { id: string; position: Vector2; isLanded: boolean; - landInMs: number; // countdown ms until impact (positive = descending) + landInMs: number; // countdown ms until impact (positive = descending) pickupRadius: number; weaponType: WeaponType; rarity: Rarity; @@ -294,7 +294,7 @@ export type GameState = { // Mid-match objectives helixRelays: HelixRelay[]; supplyDrops: SupplyDrop[]; - nextSupplyDropMs: number; // countdown until next supply drop spawns + nextSupplyDropMs: number; // countdown until next supply drop spawns // Comeback mechanic bountyPlayerId: string | null; // highest-kill alive player — bonus loot on elimination // Active character quip (displayed briefly after a nearby meteor strike)