// Swarm simulation core: pure functions over immutable-ish state. // Models patrol flight, separation, obstacle avoidance, mesh links with // pose-broadcast flashes and opportunistic bulk sync transfers. export interface Vec { x: number; y: number; } export interface Drone { id: number; name?: string; // live mode: real drone_id from the data plane pos: Vec; vel: Vec; heading: number; // radians waypoint: number; battery: number; // 0..100 channel: number; // Wi-Fi channel currently in use } export interface Obstacle { id: number; pos: Vec; radius: number; moving: boolean; vel: Vec; } export interface LinkStats { key: string; a: number; b: number; totalUp: number; // bytes, a -> b totalDown: number; // bytes, b -> a rate: number; // current bytes/s (both directions) flash: number; // 0..1, decaying broadcast pulse bulk: number; // remaining bytes of an in-flight bulk transfer } export interface World { t: number; drones: Drone[]; obstacles: Obstacle[]; links: Map; totalBytes: number; broadcasts: number; } export const AREA = 1000; // meters, vertical extent of the field export const MAX_ASPECT = 2.2; // Horizontal extent follows the display aspect ratio (set via makeWorld), // so wide monitors get a genuinely wider patrol area, not empty margins. let areaX = AREA; export function areaWidth(): number { return areaX; } export const LINK_RANGE = 320; const CRUISE = 28; const SEPARATION = 55; const POSE_BYTES = 46; const POSE_HZ = 5; // Steady per-link broadcast throughput (both directions), bytes/s export const POSE_RATE_BYTES = POSE_BYTES * POSE_HZ; const CHANNELS = [1, 6, 11, 36, 40, 44, 149, 157]; let seedState = 1234; export function seed(v: number): void { seedState = v || 1; } function rnd(): number { // xorshift32 — deterministic runs seedState ^= seedState << 13; seedState ^= seedState >>> 17; seedState ^= seedState << 5; return ((seedState >>> 0) % 100000) / 100000; } // Two interleaved patrol routes: even drones circle the perimeter, odd // drones fly an X through the center. Paths cross mid-field, so the mesh // keeps bridging between the front and the back of the formation instead // of stretching into disconnected segments along one loop. Routes are // derived from the current field width, so they stretch on wide displays. function perimeterRoute(): Vec[] { return [ { x: 120, y: 120 }, { x: areaX - 120, y: 150 }, { x: areaX - 150, y: AREA - 120 }, { x: 150, y: AREA - 150 }, ]; } function crossRoute(): Vec[] { return [ { x: 150, y: 150 }, { x: areaX - 150, y: AREA - 150 }, { x: areaX - 150, y: 150 }, { x: 150, y: AREA - 150 }, ]; } function routeOf(d: Drone): Vec[] { return d.id % 2 === 0 ? perimeterRoute() : crossRoute(); } // Transit objects enter at one edge, cross the whole area, and respawn at // a fresh edge once they leave — a stream of through-traffic instead of // obstacles bouncing until they wedge into a corner. const TRANSIT_MARGIN = 120; function spawnTransit(id: number): Obstacle { const speed = 26 + rnd() * 30; const alongX = 120 + rnd() * (areaX - 240); const alongY = 120 + rnd() * (AREA - 240); const skew = (rnd() - 0.5) * speed * 0.9; // diagonal-ish crossings const edge = Math.floor(rnd() * 4); const table: { pos: Vec; vel: Vec }[] = [ { pos: { x: alongX, y: -TRANSIT_MARGIN + 20 }, vel: { x: skew, y: speed } }, { pos: { x: alongX, y: AREA + TRANSIT_MARGIN - 20 }, vel: { x: skew, y: -speed } }, { pos: { x: -TRANSIT_MARGIN + 20, y: alongY }, vel: { x: speed, y: skew } }, { pos: { x: areaX + TRANSIT_MARGIN - 20, y: alongY }, vel: { x: -speed, y: skew } }, ]; const { pos, vel } = table[edge]; return { id, pos, radius: 22 + rnd() * 16, moving: true, vel }; } function outOfTransit(ob: Obstacle): boolean { return ( ob.pos.x < -TRANSIT_MARGIN || ob.pos.x > areaX + TRANSIT_MARGIN || ob.pos.y < -TRANSIT_MARGIN || ob.pos.y > AREA + TRANSIT_MARGIN ); } export function makeWorld(droneCount: number, aspect = 1): World { areaX = AREA * Math.max(1, Math.min(MAX_ASPECT, aspect)); const drones: Drone[] = Array.from({ length: droneCount }, (_, i) => { const angle = (i / droneCount) * Math.PI * 2; return { id: i, pos: { x: areaX / 2 + Math.cos(angle) * (150 + rnd() * 120), y: AREA / 2 + Math.sin(angle) * (150 + rnd() * 120), }, vel: { x: 0, y: 0 }, heading: angle, waypoint: i % 4, battery: 90 + rnd() * 10, channel: CHANNELS[i % CHANNELS.length], }; }); // Static obstacles sit at fixed fractions of the field, so they spread // out instead of clustering left when the field widens const obstacles: Obstacle[] = [ { id: 0, pos: { x: areaX * 0.34, y: 420 }, radius: 60, moving: false, vel: { x: 0, y: 0 } }, { id: 1, pos: { x: areaX * 0.7, y: 260 }, radius: 45, moving: false, vel: { x: 0, y: 0 } }, { id: 2, pos: { x: areaX * 0.56, y: 720 }, radius: 70, moving: false, vel: { x: 0, y: 0 } }, spawnTransit(3), spawnTransit(4), spawnTransit(5), ]; return { t: 0, drones, obstacles, links: new Map(), totalBytes: 0, broadcasts: 0 }; } function linkKey(a: number, b: number): string { return a < b ? `${a}-${b}` : `${b}-${a}`; } function steer(d: Drone, world: World, dt: number): Drone { const route = routeOf(d); const wp = route[(d.waypoint + d.id) % route.length]; let ax = wp.x - d.pos.x; let ay = wp.y - d.pos.y; const wpDist = Math.hypot(ax, ay); let waypoint = d.waypoint; if (wpDist < 90) waypoint = (d.waypoint + 1) % route.length; ax /= wpDist || 1; ay /= wpDist || 1; // Separation from peers for (const other of world.drones) { if (other.id === d.id) continue; const dx = d.pos.x - other.pos.x; const dy = d.pos.y - other.pos.y; const dist = Math.hypot(dx, dy); if (dist < SEPARATION && dist > 0.01) { const push = (SEPARATION - dist) / SEPARATION; ax += (dx / dist) * push * 2.4; ay += (dy / dist) * push * 2.4; } } // Obstacle avoidance for (const ob of world.obstacles) { const dx = d.pos.x - ob.pos.x; const dy = d.pos.y - ob.pos.y; const dist = Math.hypot(dx, dy); const margin = ob.radius + 55; if (dist < margin && dist > 0.01) { const push = (margin - dist) / margin; ax += (dx / dist) * push * 4.0; ay += (dy / dist) * push * 4.0; } } const mag = Math.hypot(ax, ay) || 1; const vx = d.vel.x * 0.85 + (ax / mag) * CRUISE * 0.15; const vy = d.vel.y * 0.85 + (ay / mag) * CRUISE * 0.15; const heading = Math.atan2(vy, vx); return { ...d, waypoint, vel: { x: vx, y: vy }, heading, pos: { x: Math.max(20, Math.min(areaX - 20, d.pos.x + vx * dt)), y: Math.max(20, Math.min(AREA - 20, d.pos.y + vy * dt)), }, battery: Math.max(0, d.battery - dt * 0.05), channel: rnd() < dt * 0.15 ? CHANNELS[Math.floor(rnd() * CHANNELS.length)] : d.channel, }; } export function tick(world: World, dt: number): World { const drones = world.drones.map((d) => steer(d, world, dt)); const obstacles = world.obstacles.map((ob) => { if (!ob.moving) return ob; const moved = { ...ob, pos: { x: ob.pos.x + ob.vel.x * dt, y: ob.pos.y + ob.vel.y * dt }, }; // Once a transit object leaves the area, respawn it at a random edge return outOfTransit(moved) ? spawnTransit(ob.id) : moved; }); const links = new Map(world.links); let totalBytes = world.totalBytes; let broadcasts = world.broadcasts; // Decay all links; drop the ones out of range for (const [key, l] of links) { const a = drones[l.a]; const b = drones[l.b]; const inRange = a && b && Math.hypot(a.pos.x - b.pos.x, a.pos.y - b.pos.y) <= LINK_RANGE; if (!inRange) { links.delete(key); continue; } links.set(key, { ...l, flash: Math.max(0, l.flash - dt * 3), rate: l.rate * 0.9 }); } for (let i = 0; i < drones.length; i++) { for (let j = i + 1; j < drones.length; j++) { const dist = Math.hypot( drones[i].pos.x - drones[j].pos.x, drones[i].pos.y - drones[j].pos.y, ); if (dist > LINK_RANGE) continue; const key = linkKey(i, j); const link = links.get(key) ?? ({ key, a: i, b: j, totalUp: 0, totalDown: 0, rate: 0, flash: 0, bulk: 0 } as LinkStats); // Pose broadcasts: 5 Hz each direction const poseBytes = POSE_BYTES * POSE_HZ * dt; let up = poseBytes; let down = poseBytes; if (rnd() < dt * POSE_HZ * 0.35) { link.flash = 1; broadcasts += 1; } // Occasional bulk sync (sealed partition replication) if (link.bulk <= 0 && rnd() < dt * 0.02) { link.bulk = 50_000 + rnd() * 450_000; } if (link.bulk > 0) { const chunk = Math.min(link.bulk, 120_000 * dt); link.bulk -= chunk; if (rnd() < 0.5) up += chunk; else down += chunk; } link.totalUp += up; link.totalDown += down; link.rate = link.rate * 0.9 + ((up + down) / dt) * 0.1; totalBytes += up + down; links.set(key, { ...link }); } } return { t: world.t + dt, drones, obstacles, links, totalBytes, broadcasts }; } export function fmtBytes(n: number): string { if (n < 1024) return `${n.toFixed(0)} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; }