// 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 alt?: number; // meters AGL — swarm layer when routeMode is perimeter altVel?: number; aware?: Map; // mesh-shared transit sightings phase?: DronePhase; dataBuffer?: number; // bytes queued for base offload offloadProgress?: number; // 0..1 while on pad landingQueued?: boolean; } export type DronePhase = "deploy" | "patrol" | "return" | "landed" | "takeoff"; export interface BasePad { pos: Vec; radius: number; totalOffloaded: number; offloadingIds: number[]; } export interface TransitIntel { id: number; pos: Vec; vel: Vec; radius: number; seenAt: number; } export interface Obstacle { id: number; pos: Vec; radius: number; moving: boolean; vel: Vec; /** Exit waypoint — transit steers through the interior toward this point. */ exit?: Vec; alt?: number; altVel?: number; /** Nominal cruise speed (m/s) — used for throttle-only drone proximity response. */ transitCruise?: number; } 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 type RouteMode = "default" | "perimeter"; export type SwarmAlgorithm = | "perimeter" | "boids" | "apf" | "aco" | "hypha" | "frontier" | "wave" | "mycelium"; export const SWARM_ALGORITHM_LABELS: Record = { perimeter: "Perimeter slots", boids: "Boids (Reynolds)", apf: "Potential field (APF)", aco: "Ant colony (ACO)", hypha: "HyphaNet (link flow)", frontier: "Frontier + prune", wave: "Travelling wave", mycelium: "Mycelium (combined)", }; export interface FlightStats { avgSpeed: number; idlePct: number; spinPct: number; passOverPct: number; bypassPct: number; } export interface FlightAccumulator { speedSum: number; samples: number; idleSamples: number; spinSamples: number; passOverSamples: number; bypassSamples: number; } export interface World { t: number; drones: Drone[]; obstacles: Obstacle[]; links: Map; totalBytes: number; broadcasts: number; routeMode?: RouteMode; swarmAlgo?: SwarmAlgorithm; perimeterHeat?: number[]; hyphaStrength?: number[]; flightAcc?: FlightAccumulator; statsWindowStart?: number; flightStats?: FlightStats; basePad?: BasePad; landingQueue?: number[]; } const ESCORT_PER_TRANSIT = 4; export { ESCORT_PER_TRANSIT }; 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 function basePadCenter(): Vec { return { x: areaX / 2, y: AREA / 2 }; } function padLandingSpot(id: number, pad: BasePad): Vec { const angle = id * 2.399963229; const r = pad.radius * 0.38; return { x: pad.pos.x + Math.cos(angle) * r, y: pad.pos.y + Math.sin(angle) * r, }; } function distToPad(d: Drone, pad: BasePad): number { return Math.hypot(d.pos.x - pad.pos.x, d.pos.y - pad.pos.y); } function landingSlotsUsed(drones: Drone[], pad: BasePad): number { let used = 0; for (const d of drones) { if (d.phase === "landed" || d.phase === "takeoff") { used += 1; continue; } if (d.phase === "return" && distToPad(d, pad) < pad.radius * 2.8) used += 1; } return used; } function manageLandingQueue(drones: Drone[], pad: BasePad, queueIn: number[]): { drones: Drone[]; landingQueue: number[]; } { let queue = [...queueIn]; let dronesOut = drones.map((d) => ({ ...d })); for (const d of dronesOut) { if (d.phase !== "patrol" || d.landingQueued) continue; const bufferFull = (d.dataBuffer ?? 0) >= DATA_OFFLOAD_THRESHOLD; const lowBattery = d.battery < BATTERY_LAND_THRESHOLD; if ((lowBattery || bufferFull) && !queue.includes(d.id)) { if (d.battery < BATTERY_CRITICAL) queue.unshift(d.id); else queue.push(d.id); dronesOut = dronesOut.map((x) => (x.id === d.id ? { ...x, landingQueued: true } : x)); } } queue = queue.filter((id) => { const d = dronesOut.find((x) => x.id === id); return d && d.phase === "patrol"; }); queue.sort((a, b) => { const da = dronesOut.find((x) => x.id === a); const db = dronesOut.find((x) => x.id === b); return (da?.battery ?? 100) - (db?.battery ?? 100); }); let slots = landingSlotsUsed(dronesOut, pad); while (queue.length > 0 && slots < MAX_CONCURRENT_LANDING) { const id = queue.shift()!; dronesOut = dronesOut.map((x) => x.id === id ? { ...x, phase: "return" as DronePhase, landingQueued: false } : x, ); slots += 1; } return { drones: dronesOut, landingQueue: queue }; } 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]; const ALT_MIN = 14; const ALT_MAX = 42; const ALT_SEP = 10; const PERIMETER_SEGMENTS = 48; const IDLE_SPEED = CRUISE * 0.25; const SPIN_SPEED = CRUISE * 0.4; const MIN_PATROL_SPEED = CRUISE * 0.4; const HEADING_SPEED_MIN = 5; const STATS_WINDOW_S = 8; const BASE_PAD_RADIUS = 58; const BATTERY_LAND_THRESHOLD = 32; const BATTERY_CRITICAL = 16; const BATTERY_DEPLOY_MIN = 88; const DATA_OFFLOAD_THRESHOLD = 520_000; const MAX_CONCURRENT_LANDING = 2; const OFFLOAD_RATE = 140_000; const OFFLOAD_PAD_SECONDS = 7; const BATTERY_RECHARGE = 16; const DATA_COLLECT_RATE = 4200; const DETECT_RANGE = 210; export { DETECT_RANGE, BASE_PAD_RADIUS }; function emptyFlightAcc(): FlightAccumulator { return { speedSum: 0, samples: 0, idleSamples: 0, spinSamples: 0, passOverSamples: 0, bypassSamples: 0, }; } function flightAccToStats(acc: FlightAccumulator): FlightStats { const n = Math.max(1, acc.samples); return { avgSpeed: acc.speedSum / n, idlePct: (acc.idleSamples / n) * 100, spinPct: (acc.spinSamples / n) * 100, passOverPct: (acc.passOverSamples / n) * 100, bypassPct: (acc.bypassSamples / n) * 100, }; } 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, mode: RouteMode = "default"): Vec[] { if (mode === "perimeter") return perimeterRoute(); return d.id % 2 === 0 ? perimeterRoute() : crossRoute(); } function perimeterLoop(): Vec[] { const route = perimeterRoute(); return [...route, route[0]]; } function perimeterLength(): number { const pts = perimeterLoop(); let len = 0; for (let i = 0; i < pts.length - 1; i++) { len += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); } return len; } function perimeterPointAt(dist: number): Vec { const pts = perimeterLoop(); const total = perimeterLength(); let s = ((dist % total) + total) % total; let acc = 0; for (let i = 0; i < pts.length - 1; i++) { const seg = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); if (acc + seg >= s) { const t = seg > 0 ? (s - acc) / seg : 0; return { x: pts[i].x + (pts[i + 1].x - pts[i].x) * t, y: pts[i].y + (pts[i + 1].y - pts[i].y) * t, }; } acc += seg; } return { x: pts[0].x, y: pts[0].y }; } function onFieldTransits(obstacles: Obstacle[]): Obstacle[] { return obstacles.filter((o) => o.moving && !outOfTransit(o)); } function hasLinkInRange(d: Drone, drones: Drone[]): boolean { for (const other of drones) { if (other.id === d.id) continue; if (linkRange3d(d, other) <= LINK_RANGE) return true; } return false; } function evenPerimeterSteer( d: Drone, droneCount: number, ax: number, ay: number, ): { ax: number; ay: number } { return goalSeekSteer(d, droneCount, ax, ay, 2.8); } function lerpAngle(a: number, b: number, t: number): number { let d = b - a; while (d > Math.PI) d -= 2 * Math.PI; while (d < -Math.PI) d += 2 * Math.PI; return a + d * t; } function goalSeekSteer( d: Drone, n: number, ax: number, ay: number, strength = 2.2, ): { ax: number; ay: number } { const target = slotTarget(d, n); const dx = target.x - d.pos.x; const dy = target.y - d.pos.y; const dist = Math.hypot(dx, dy) || 1; const tangent = perimeterTangentAt(d, n); if (dist < 85) { ax += tangent.x * 3.1 + (dx / dist) * 0.55; ay += tangent.y * 3.1 + (dy / dist) * 0.55; } else { ax += (dx / dist) * strength; ay += (dy / dist) * strength; } return { ax, ay }; } function resolveSteerStall( d: Drone, n: number, ax: number, ay: number, vx: number, vy: number, ): { ax: number; ay: number } { const mag = Math.hypot(ax, ay); const speed = Math.hypot(vx, vy); const tangent = perimeterTangentAt(d, n); const target = slotTarget(d, n); const dx = target.x - d.pos.x; const dy = target.y - d.pos.y; const toSlot = Math.hypot(dx, dy) || 1; if (mag < 0.08) { return { ax: tangent.x * 3.6, ay: tangent.y * 3.6 }; } const intentX = ax / mag; const intentY = ay / mag; const velDirX = speed > 0.01 ? vx / speed : intentX; const velDirY = speed > 0.01 ? vy / speed : intentY; const align = intentX * velDirX + intentY * velDirY; if (speed < IDLE_SPEED || (speed < CRUISE * 0.48 && align < 0.2)) { const blend = speed < IDLE_SPEED ? 3.2 : 2.4; return { ax: intentX * 0.2 + (tangent.x * 0.6 + (dx / toSlot) * 0.2) * blend, ay: intentY * 0.2 + (tangent.y * 0.6 + (dy / toSlot) * 0.2) * blend, }; } return { ax, ay }; } function integrateVel( d: Drone, ax: number, ay: number, cruiseMul: number, phase?: DronePhase, ): { vx: number; vy: number; heading: number } { const mag = Math.hypot(ax, ay) || 1; const desiredVx = (ax / mag) * CRUISE * cruiseMul; const desiredVy = (ay / mag) * CRUISE * cruiseMul; const blend = phase === "patrol" || phase === "deploy" ? 0.34 : 0.24; let vx = d.vel.x * (1 - blend) + desiredVx * blend; let vy = d.vel.y * (1 - blend) + desiredVy * blend; let speed = Math.hypot(vx, vy); let minSpeed = 0; if (phase === "patrol" || phase === "deploy") minSpeed = MIN_PATROL_SPEED * (phase === "deploy" ? 0.85 : 1); else if (phase === "return") minSpeed = CRUISE * 0.3; if (minSpeed > 0 && speed < minSpeed) { vx = (ax / mag) * minSpeed; vy = (ay / mag) * minSpeed; speed = minSpeed; } let heading: number; if (speed >= HEADING_SPEED_MIN) { heading = Math.atan2(vy, vx); } else if (mag > 0.08) { heading = lerpAngle(d.heading, Math.atan2(ay, ax), 0.15); } else { heading = d.heading; } return { vx, vy, heading }; } function linkSeekSteer( d: Drone, drones: Drone[], ax: number, ay: number, ): { ax: number; ay: number } { if (hasLinkInRange(d, drones)) return { ax, ay }; let nearest: Drone | null = null; let bestD = Infinity; for (const other of drones) { if (other.id === d.id) continue; const dist = linkRange3d(d, other); if (dist < bestD) { bestD = dist; nearest = other; } } if (!nearest) return { ax, ay }; const dx = nearest.pos.x - d.pos.x; const dy = nearest.pos.y - d.pos.y; const mag = Math.hypot(dx, dy) || 1; const pull = bestD > LINK_RANGE ? 0.38 : 0.18; const ux = dx / mag; const uy = dy / mag; const intentMag = Math.hypot(ax, ay); if (intentMag > 0.05 && ax * ux + ay * uy < 0) { return { ax, ay }; } return { ax: ax * (1 - pull) + ux * pull * 2.4, ay: ay * (1 - pull) + uy * pull * 2.4, }; } function slotTarget(d: Drone, n: number): Vec { const slot = ((d.id + 0.5) / Math.max(1, n)) * perimeterLength(); return perimeterPointAt(slot); } function perimeterTangentAt(d: Drone, n: number): Vec { const slot = ((d.id + 0.5) / Math.max(1, n)) * perimeterLength(); const p = perimeterPointAt(slot); const ahead = perimeterPointAt(slot + perimeterLength() * 0.015); const dx = ahead.x - p.x; const dy = ahead.y - p.y; const mag = Math.hypot(dx, dy) || 1; return { x: dx / mag, y: dy / mag }; } function patrolSteerBoids(d: Drone, drones: Drone[], ax: number, ay: number): { ax: number; ay: number } { const n = drones.length; ({ ax, ay } = goalSeekSteer(d, n, ax, ay, 2.2)); let alignX = 0; let alignY = 0; let alignN = 0; for (const other of drones) { if (other.id === d.id) continue; const odx = d.pos.x - other.pos.x; const ody = d.pos.y - other.pos.y; const od = Math.hypot(odx, ody); if (od < SEPARATION * 1.5 && od > 0.01) { const push = (SEPARATION * 1.5 - od) / (SEPARATION * 1.5); ax += (odx / od) * push * 2.4; ay += (ody / od) * push * 2.4; } const oSpeed = Math.hypot(other.vel.x, other.vel.y); if (od < LINK_RANGE && oSpeed > 6) { alignX += other.vel.x; alignY += other.vel.y; alignN += 1; } } if (alignN > 0) { const am = Math.hypot(alignX, alignY) || 1; ax += (alignX / alignN / am) * 0.65; ay += (alignY / alignN / am) * 0.65; } return linkSeekSteer(d, drones, ax, ay); } function patrolSteerApf( d: Drone, drones: Drone[], obstacles: Obstacle[], ax: number, ay: number, ): { ax: number; ay: number } { const n = drones.length; ({ ax, ay } = goalSeekSteer(d, n, ax, ay, 3.0)); for (const other of drones) { if (other.id === d.id) continue; const odx = d.pos.x - other.pos.x; const ody = d.pos.y - other.pos.y; const od = Math.hypot(odx, ody); const influence = 95; if (od < influence && od > 0.01) { const rep = ((influence - od) / influence) ** 2 * 4.5; ax += (odx / od) * rep; ay += (ody / od) * rep; } } for (const ob of obstacles) { if (ob.moving) continue; const odx = d.pos.x - ob.pos.x; const ody = d.pos.y - ob.pos.y; const od = Math.hypot(odx, ody); const margin = ob.radius + 70; if (od < margin && od > 0.01) { const rep = ((margin - od) / margin) ** 2 * 3.5; ax += (odx / od) * rep; ay += (ody / od) * rep; } } return linkSeekSteer(d, drones, ax, ay); } function nearestSegmentIndex(pos: Vec): number { let best = 0; let bestD = Infinity; const total = perimeterLength(); const segLen = total / PERIMETER_SEGMENTS; for (let i = 0; i < PERIMETER_SEGMENTS; i++) { const p = perimeterPointAt(i * segLen + segLen * 0.5); const dist = Math.hypot(pos.x - p.x, pos.y - p.y); if (dist < bestD) { bestD = dist; best = i; } } return best; } function updatePerimeterHeat(drones: Drone[], heat: number[], dt: number): number[] { const next = heat.map((h) => h + dt * 0.12); for (const d of drones) { const seg = nearestSegmentIndex(d.pos); next[seg] = Math.max(0, next[seg] - 1.4); } return next; } function patrolSteerAco( d: Drone, drones: Drone[], heat: number[], ax: number, ay: number, ): { ax: number; ay: number } { const n = drones.length; const homeSlot = Math.floor(((d.id + 0.5) / Math.max(1, n)) * PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS; let bestSeg = homeSlot; let bestScore = -Infinity; for (let off = -3; off <= 3; off++) { const seg = (homeSlot + off + PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS; const need = heat[seg] ?? 0; const score = need - Math.abs(off) * 0.35; if (score > bestScore) { bestScore = score; bestSeg = seg; } } const segLen = perimeterLength() / PERIMETER_SEGMENTS; const target = perimeterPointAt(bestSeg * segLen + segLen * 0.5); const dx = target.x - d.pos.x; const dy = target.y - d.pos.y; const dist = Math.hypot(dx, dy) || 1; const tangent = perimeterTangentAt(d, n); if (dist < 85) { ax += tangent.x * 2.8 + (dx / dist) * 0.6; ay += tangent.y * 2.8 + (dy / dist) * 0.6; } else { ax += (dx / dist) * 3.0; ay += (dy / dist) * 3.0; } return linkSeekSteer(d, drones, ax, ay); } function updateHyphaStrength( drones: Drone[], links: Map, strength: number[], dt: number, ): number[] { const next = strength.map((s) => Math.max(0, s - dt * 0.35)); for (const d of drones) { if (d.phase && d.phase !== "patrol") continue; const seg = nearestSegmentIndex(d.pos); next[seg] = Math.min(4.5, next[seg] + dt * 1.1); } for (const link of links.values()) { if (link.rate < 4000) continue; const a = drones[link.a]; const b = drones[link.b]; if (!a || !b) continue; const boost = Math.min(2.2, link.rate / 70000) * dt * 2.4; const sa = nearestSegmentIndex(a.pos); const sb = nearestSegmentIndex(b.pos); next[sa] = Math.min(5, next[sa] + boost); next[sb] = Math.min(5, next[sb] + boost); } return next; } function segmentTarget(seg: number): Vec { const segLen = perimeterLength() / PERIMETER_SEGMENTS; return perimeterPointAt(seg * segLen + segLen * 0.5); } function steerTowardPoint( d: Drone, n: number, target: Vec, ax: number, ay: number, strength: number, ): { ax: number; ay: number } { const dx = target.x - d.pos.x; const dy = target.y - d.pos.y; const dist = Math.hypot(dx, dy) || 1; const tangent = perimeterTangentAt(d, n); if (dist < 85) { ax += tangent.x * 2.6 + (dx / dist) * 0.55; ay += tangent.y * 2.6 + (dy / dist) * 0.55; } else { ax += (dx / dist) * strength; ay += (dy / dist) * strength; } return { ax, ay }; } function patrolSteerHypha( d: Drone, drones: Drone[], heat: number[], strength: number[], ax: number, ay: number, ): { ax: number; ay: number } { const n = drones.length; const home = Math.floor(((d.id + 0.5) / Math.max(1, n)) * PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS; let bestSeg = home; let bestScore = -Infinity; for (let off = -5; off <= 5; off++) { const seg = (home + off + PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS; const flow = strength[seg] ?? 0; const need = heat[seg] ?? 0; const score = flow * 1.4 + need * 0.45 - Math.abs(off) * 0.28; if (score > bestScore) { bestScore = score; bestSeg = seg; } } ({ ax, ay } = steerTowardPoint(d, n, segmentTarget(bestSeg), ax, ay, 2.9)); return linkSeekSteer(d, drones, ax, ay); } function patrolSteerFrontier( d: Drone, drones: Drone[], heat: number[], strength: number[], t: number, ax: number, ay: number, ): { ax: number; ay: number } { const n = drones.length; const slot = slotTarget(d, n); const distSlot = Math.hypot(d.pos.x - slot.x, d.pos.y - slot.y); const pathfinder = distSlot > 115 || (d.id + Math.floor(t / 7)) % 4 === 0; if (pathfinder) { let bestSeg = 0; let bestNeed = -Infinity; for (let seg = 0; seg < PERIMETER_SEGMENTS; seg++) { const need = (heat[seg] ?? 0) - (strength[seg] ?? 0) * 0.35; if (need > bestNeed) { bestNeed = need; bestSeg = seg; } } ({ ax, ay } = steerTowardPoint(d, n, segmentTarget(bestSeg), ax, ay, 3.2)); } else { ({ ax, ay } = goalSeekSteer(d, n, ax, ay, 2.4)); const home = Math.floor(((d.id + 0.5) / Math.max(1, n)) * PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS; const localFlow = strength[home] ?? 0; if (localFlow < 0.8) { ({ ax, ay } = linkSeekSteer(d, drones, ax, ay)); } } return linkSeekSteer(d, drones, ax, ay); } function patrolSteerWave( d: Drone, drones: Drone[], t: number, ax: number, ay: number, ): { ax: number; ay: number } { const n = drones.length; const total = perimeterLength(); const waveFront = (t * 42) % total; const slot = ((d.id + 0.5) / Math.max(1, n)) * total; const phase = (d.id / Math.max(1, n)) * total * 0.12; const targetDist = (slot + waveFront * 0.22 + phase) % total; ({ ax, ay } = steerTowardPoint(d, n, perimeterPointAt(targetDist), ax, ay, 3.1)); return linkSeekSteer(d, drones, ax, ay); } function patrolSteerMycelium( d: Drone, drones: Drone[], heat: number[], strength: number[], t: number, ax: number, ay: number, ): { ax: number; ay: number } { const n = drones.length; const home = Math.floor(((d.id + 0.5) / Math.max(1, n)) * PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS; let exploreSeg = home; let exploreScore = -Infinity; for (let off = -6; off <= 6; off++) { const seg = (home + off + PERIMETER_SEGMENTS) % PERIMETER_SEGMENTS; const nutrient = (heat[seg] ?? 0) * 1.2 - (strength[seg] ?? 0) * 0.5; const score = nutrient - Math.abs(off) * 0.22; if (score > exploreScore) { exploreScore = score; exploreSeg = seg; } } const slot = slotTarget(d, n); const dxs = slot.x - d.pos.x; const dys = slot.y - d.pos.y; const toSlot = Math.hypot(dxs, dys) || 1; const explore = segmentTarget(exploreSeg); const dxe = explore.x - d.pos.x; const dye = explore.y - d.pos.y; const toExplore = Math.hypot(dxe, dye) || 1; const pathfinder = (d.id + Math.floor(t / 5)) % 3 === 0; const exploreW = pathfinder ? 0.52 : 0.28; const slotW = 1 - exploreW; const hypha = strength[home] ?? 0; const flowW = Math.min(0.35, hypha * 0.12); ax += (dxe / toExplore) * exploreW * 2.8 + (dxs / toSlot) * slotW * 2.2 + (dxe / toExplore) * flowW * 1.5; ay += (dye / toExplore) * exploreW * 2.8 + (dys / toSlot) * slotW * 2.2 + (dye / toExplore) * flowW * 1.5; if (hypha < 0.6 || !hasLinkInRange(d, drones)) { ({ ax, ay } = linkSeekSteer(d, drones, ax, ay)); } return { ax, ay }; } interface SteerMetrics { passOver: boolean; bypass: boolean; } function applyPeerDeconflict( d: Drone, drones: Drone[], ax: number, ay: number, alt: number, ): { ax: number; ay: number; altAccel: number; metrics: SteerMetrics } { let altAccel = 0; let passOver = false; let bypass = false; const intentMag = Math.hypot(ax, ay) || 1; const ix = ax / intentMag; const iy = ay / intentMag; for (const other of 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 * 1.5 || dist < 0.01) continue; const toOtherX = -dx / dist; const toOtherY = -dy / dist; const closing = ix * toOtherX + iy * toOtherY; if (dist < SEPARATION) { const push = (SEPARATION - dist) / SEPARATION; ax += (dx / dist) * push * 1.6; ay += (dy / dist) * push * 1.6; } if (closing > 0.25 && dist < 75) { const preferOver = d.id < other.id || alt <= (other.alt ?? alt); const canClimb = alt < ALT_MAX - 6 && (other.alt ?? 22) >= ALT_MIN + 6; if (preferOver && canClimb) { altAccel += 5.5 * closing; passOver = true; } else { const perpX = -iy; const perpY = ix; const side = d.id < other.id ? 1 : -1; ax += perpX * side * 2.0 * closing; ay += perpY * side * 2.0 * closing; bypass = true; } } else if (dist < SEPARATION * 1.2) { const dz = alt - (other.alt ?? alt); if (Math.abs(dz) < ALT_SEP) { altAccel += (dz >= 0 ? 1 : -1) * 2.5; } } } return { ax, ay, altAccel, metrics: { passOver, bypass } }; } function enforceMinProgress( d: Drone, n: number, ax: number, ay: number, vx: number, vy: number, ): { ax: number; ay: number } { return resolveSteerStall(d, n, ax, ay, vx, vy); } function accumulateFlightStats( acc: FlightAccumulator, d: Drone, prev: Drone | undefined, metrics: SteerMetrics, ): void { const speed = Math.hypot(d.vel.x, d.vel.y); acc.speedSum += speed; acc.samples += 1; if (speed < IDLE_SPEED) acc.idleSamples += 1; if (prev) { const dHeading = Math.atan2(d.vel.y, d.vel.x); const pHeading = Math.atan2(prev.vel.y, prev.vel.x); let dh = Math.abs(dHeading - pHeading); if (dh > Math.PI) dh = 2 * Math.PI - dh; const disp = Math.hypot(d.pos.x - prev.pos.x, d.pos.y - prev.pos.y); if (speed < SPIN_SPEED && dh > 0.8 && disp < 4) acc.spinSamples += 1; } if (metrics.passOver) acc.passOverSamples += 1; if (metrics.bypass) acc.bypassSamples += 1; } // 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; const TRANSIT_ALT_MIN = 16; const TRANSIT_ALT_MAX = 58; const TRANSIT_ALT_CRUISE = 28; const HOUSE_ROOF_ALT = 24; export interface RoadSegment { ax: number; ay: number; bx: number; by: number; } function roadKey(i: number, j: number): string { return i < j ? `${i}-${j}` : `${j}-${i}`; } /** Tron-style road vectors between static houses: MST plus two nearest links per house. */ export function houseRoadNetwork(obstacles: Obstacle[]): RoadSegment[] { const houses = obstacles.filter((o) => !o.moving); const n = houses.length; if (n < 2) return []; const segs = new Map(); const add = (i: number, j: number): void => { if (i === j) return; const key = roadKey(i, j); if (segs.has(key)) return; segs.set(key, { ax: houses[i].pos.x, ay: houses[i].pos.y, bx: houses[j].pos.x, by: houses[j].pos.y, }); }; const inMst = new Array(n).fill(false); inMst[0] = true; for (let added = 1; added < n; added++) { let bestFrom = 0; let bestTo = 0; let bestDist = Infinity; for (let i = 0; i < n; i++) { if (!inMst[i]) continue; for (let j = 0; j < n; j++) { if (inMst[j]) continue; const d = Math.hypot(houses[i].pos.x - houses[j].pos.x, houses[i].pos.y - houses[j].pos.y); if (d < bestDist) { bestDist = d; bestFrom = i; bestTo = j; } } } add(bestFrom, bestTo); inMst[bestTo] = true; } for (let i = 0; i < n; i++) { const nearest = houses .map((h, j) => ({ j, d: i === j ? Infinity : Math.hypot(h.pos.x - houses[i].pos.x, h.pos.y - houses[i].pos.y), })) .sort((a, b) => a.d - b.d); add(i, nearest[0].j); add(i, nearest[1].j); } return [...segs.values()]; } function spawnStaticHouse(id: number, x: number, y: number, radius: number): Obstacle { return { id, pos: { x, y }, radius, moving: false, vel: { x: 0, y: 0 } }; } function staticHouses(): Obstacle[] { const slots = [ { xf: 0.1, y: 95, radius: 36 }, { xf: 0.1, y: 280, radius: 40 }, { xf: 0.1, y: 470, radius: 38 }, { xf: 0.1, y: 660, radius: 42 }, { xf: 0.1, y: 850, radius: 36 }, { xf: 0.24, y: 160, radius: 44 }, { xf: 0.24, y: 390, radius: 46 }, { xf: 0.24, y: 610, radius: 40 }, { xf: 0.24, y: 820, radius: 48 }, { xf: 0.38, y: 110, radius: 42 }, { xf: 0.38, y: 330, radius: 38 }, { xf: 0.38, y: 520, radius: 50 }, { xf: 0.38, y: 730, radius: 44 }, { xf: 0.38, y: 920, radius: 40 }, { xf: 0.52, y: 200, radius: 46 }, { xf: 0.52, y: 440, radius: 42 }, { xf: 0.52, y: 680, radius: 48 }, { xf: 0.66, y: 130, radius: 40 }, { xf: 0.66, y: 360, radius: 44 }, { xf: 0.66, y: 560, radius: 46 }, { xf: 0.66, y: 780, radius: 42 }, { xf: 0.66, y: 940, radius: 38 }, { xf: 0.8, y: 240, radius: 48 }, { xf: 0.8, y: 480, radius: 40 }, { xf: 0.8, y: 720, radius: 44 }, { xf: 0.92, y: 160, radius: 42 }, { xf: 0.92, y: 420, radius: 46 }, { xf: 0.92, y: 640, radius: 38 }, { xf: 0.92, y: 880, radius: 44 }, ]; return slots.map((s, id) => spawnStaticHouse(id, areaX * s.xf, s.y, s.radius)); } function transitEdgePoint(edge: number): Vec { const inset = 90; const pad = TRANSIT_MARGIN; switch (edge) { case 0: return { x: inset + rnd() * (areaX - 2 * inset), y: -pad + 20 }; case 1: return { x: inset + rnd() * (areaX - 2 * inset), y: AREA + pad - 20 }; case 2: return { x: -pad + 20, y: inset + rnd() * (AREA - 2 * inset) }; default: return { x: areaX + pad - 20, y: inset + rnd() * (AREA - 2 * inset) }; } } function spawnTransit(id: number): Obstacle { const cruise = 28 + rnd() * 28; const entryEdge = Math.floor(rnd() * 4); const exitEdge = rnd() < 0.72 ? (entryEdge + 2) % 4 : (entryEdge + 2 + (rnd() < 0.5 ? 1 : 3)) % 4; const entry = transitEdgePoint(entryEdge); const exit = transitEdgePoint(exitEdge); const dx = exit.x - entry.x; const dy = exit.y - entry.y; const dist = Math.hypot(dx, dy) || 1; return { id, pos: entry, radius: 22 + rnd() * 16, moving: true, vel: { x: (dx / dist) * cruise, y: (dy / dist) * cruise }, exit, alt: TRANSIT_ALT_CRUISE + rnd() * 6, altVel: 0, transitCruise: cruise, }; } 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 ); } function steerTransit( transit: Obstacle, obstacles: Obstacle[], drones: Drone[], dt: number, ): Obstacle { let { x, y } = transit.pos; let vx = transit.vel.x; let vy = transit.vel.y; let alt = transit.alt ?? TRANSIT_ALT_CRUISE; let altVel = transit.altVel ?? 0; const baseCruise = transit.transitCruise ?? (Math.hypot(vx, vy) || 28); let ax = 0; let ay = 0; let altAccel = 0; if (transit.exit) { const dx = transit.exit.x - x; const dy = transit.exit.y - y; const dist = Math.hypot(dx, dy) || 1; ax += (dx / dist) * 3.2; ay += (dy / dist) * 3.2; } else { ax += (vx / baseCruise) * 2; ay += (vy / baseCruise) * 2; } for (const house of obstacles) { if (house.moving) continue; const dx = x - house.pos.x; const dy = y - house.pos.y; const dist = Math.hypot(dx, dy); const threat = house.radius + transit.radius + 48; if (dist > threat + 130 || dist < 0.01) continue; const nx = dx / dist; const ny = dy / dist; const closing = -(vx * nx + vy * ny); if (closing < 0.08) continue; const urgency = 1 + Math.min(2.2, closing / baseCruise); const preferOver = (transit.id + house.id) % 3 !== 1 && alt < TRANSIT_ALT_MAX - 10; const flyingOver = alt >= HOUSE_ROOF_ALT + 10; if (preferOver && !flyingOver) { altAccel += 7.5 * urgency * closing; ax += nx * 0.35 * urgency; ay += ny * 0.35 * urgency; } else if (!flyingOver) { if (dist < threat) { const push = ((threat - dist) / threat) * 4.2 * urgency; ax += nx * push; ay += ny * push; } const tx = -ny; const ty = nx; const side = vx * ty - vy * tx >= 0 ? 1 : -1; ax += tx * side * 2.4 * urgency; ay += ty * side * 2.4 * urgency; } } // Drones: throttle only — no evasive steering or altitude changes. let speedFactor = 1; const dirX = vx / baseCruise; const dirY = vy / baseCruise; let droneAhead = false; for (const d of drones) { const dx = x - d.pos.x; const dy = y - d.pos.y; const dist = Math.hypot(dx, dy); if (dist > 160 || dist < 0.01) continue; const nx = dx / dist; const ny = dy / dist; const ahead = dirX * (-nx) + dirY * (-ny); if (ahead > 0.2 && dist < 130) { droneAhead = true; speedFactor = Math.min(speedFactor, 0.48 + (dist / 130) * 0.42); } else if (dist < 70) { speedFactor = Math.min(speedFactor, 0.72); } } if (!droneAhead) { speedFactor = Math.min(1.16, speedFactor + 0.06); } speedFactor = Math.max(0.42, Math.min(1.16, speedFactor)); const targetCruise = baseCruise * speedFactor; const mag = Math.hypot(ax, ay) || 1; vx = vx * 0.82 + (ax / mag) * targetCruise * 0.18; vy = vy * 0.82 + (ay / mag) * targetCruise * 0.18; const spd = Math.hypot(vx, vy) || targetCruise; vx = (vx / spd) * targetCruise; vy = (vy / spd) * targetCruise; const cruiseAlt = TRANSIT_ALT_CRUISE + Math.sin((transit.id + 1) * 1.7) * 2.5; altAccel += (cruiseAlt - alt) * 0.5; altVel = altVel * 0.88 + altAccel * dt * 12; alt = Math.max(TRANSIT_ALT_MIN, Math.min(TRANSIT_ALT_MAX, alt + altVel * dt)); x += vx * dt; y += vy * dt; for (const house of obstacles) { if (house.moving) continue; if (alt >= HOUSE_ROOF_ALT + 8) continue; const dx = x - house.pos.x; const dy = y - house.pos.y; const dist = Math.hypot(dx, dy); const minD = house.radius + transit.radius + 44; if (dist < minD && dist > 0.01) { const nx = dx / dist; const ny = dy / dist; x = house.pos.x + nx * minD; y = house.pos.y + ny * minD; const dot = vx * nx + vy * ny; if (dot < 0) { vx -= dot * nx * 1.1; vy -= dot * ny * 1.1; const s = Math.hypot(vx, vy) || targetCruise; vx = (vx / s) * targetCruise; vy = (vy / s) * targetCruise; } } } return { ...transit, pos: { x, y }, vel: { x: vx, y: vy }, alt, altVel }; } export function makeWorld( droneCount: number, aspect = 1, routeMode: RouteMode = "default", ): World { areaX = AREA * Math.max(1, Math.min(MAX_ASPECT, aspect)); const padPos = basePadCenter(); const basePad: BasePad = { pos: padPos, radius: BASE_PAD_RADIUS, totalOffloaded: 0, offloadingIds: [], }; const drones: Drone[] = Array.from({ length: droneCount }, (_, i) => { const onPerimeter = routeMode === "perimeter"; const angle = (i / Math.max(1, droneCount)) * Math.PI * 2; const ring = 6 + rnd() * 8; const spawn = onPerimeter ? { pos: { x: padPos.x + Math.cos(angle) * ring, y: padPos.y + Math.sin(angle) * ring, }, heading: angle, } : { pos: { x: areaX / 2 + Math.cos((i / droneCount) * Math.PI * 2) * (150 + rnd() * 120), y: AREA / 2 + Math.sin((i / droneCount) * Math.PI * 2) * (150 + rnd() * 120), }, heading: (i / droneCount) * Math.PI * 2, }; const base: Drone = { id: i, pos: spawn.pos, vel: { x: 0, y: 0 }, heading: spawn.heading, waypoint: i % 4, battery: 94 + rnd() * 6, channel: CHANNELS[i % CHANNELS.length], }; if (onPerimeter) { base.alt = 1.2 + rnd() * 0.8; base.altVel = 0; base.aware = new Map(); base.phase = "deploy"; base.dataBuffer = rnd() * 80_000; } return base; }); // Static obstacles sit at fixed fractions of the field, so they spread // out instead of clustering left when the field widens const houses = staticHouses(); const obstacles: Obstacle[] = [ ...houses, spawnTransit(houses.length), spawnTransit(houses.length + 1), spawnTransit(houses.length + 2), ]; return { t: 0, drones, obstacles, links: new Map(), totalBytes: 0, broadcasts: 0, routeMode, swarmAlgo: "boids", perimeterHeat: Array.from({ length: PERIMETER_SEGMENTS }, () => 0), hyphaStrength: Array.from({ length: PERIMETER_SEGMENTS }, () => 0), flightAcc: emptyFlightAcc(), statsWindowStart: 0, flightStats: flightAccToStats(emptyFlightAcc()), basePad, landingQueue: [], }; } function linkKey(a: number, b: number): string { return a < b ? `${a}-${b}` : `${b}-${a}`; } function linkRange3d(a: Drone, b: Drone): number { const dx = a.pos.x - b.pos.x; const dy = a.pos.y - b.pos.y; const dz = (a.alt ?? 0) - (b.alt ?? 0); return Math.hypot(dx, dy, dz); } function cloneAware(m?: Map): Map { return new Map(m ?? []); } function intelFrom(ob: Obstacle, t: number): TransitIntel { return { id: ob.id, pos: { x: ob.pos.x, y: ob.pos.y }, vel: { x: ob.vel.x, y: ob.vel.y }, radius: ob.radius, seenAt: t, }; } function observeDrone(d: Drone, obstacles: Obstacle[], t: number): Drone { if (!d.aware) return d; const aware = cloneAware(d.aware); for (const ob of obstacles) { if (!ob.moving) continue; if (outOfTransit(ob)) { aware.delete(ob.id); continue; } if (aware.has(ob.id)) { aware.set(ob.id, intelFrom(ob, t)); continue; } const dist = Math.hypot(d.pos.x - ob.pos.x, d.pos.y - ob.pos.y); if (dist <= DETECT_RANGE) aware.set(ob.id, intelFrom(ob, t)); } return { ...d, aware }; } function mergeIntel(into: Map, from: Map): boolean { let added = false; for (const [id, intel] of from) { const prev = into.get(id); if (!prev) added = true; if (!prev || intel.seenAt >= prev.seenAt) into.set(id, { ...intel }); } return added; } function propagateIntel( drones: Drone[], ): { drones: Drone[]; intelLinks: Set } { const awareList = drones.map((d) => cloneAware(d.aware)); const intelLinks = new Set(); for (let i = 0; i < drones.length; i++) { for (let j = i + 1; j < drones.length; j++) { if (linkRange3d(drones[i], drones[j]) > LINK_RANGE) continue; const ai = awareList[i].size; const aj = awareList[j].size; mergeIntel(awareList[i], awareList[j]); mergeIntel(awareList[j], awareList[i]); if (awareList[i].size > ai || awareList[j].size > aj) { intelLinks.add(linkKey(i, j)); } } } return { drones: drones.map((d, i) => ({ ...d, aware: awareList[i] })), intelLinks, }; } function escortSlot(slotIndex: number, intel: TransitIntel, t: number): { x: number; y: number; alt: number } { const golden = 2.399963229; const spin = t * 0.2; const phi = slotIndex * golden + spin + intel.id * 0.45; const u = slotIndex * 0.22 + 0.12; const theta = Math.acos(1 - 2 * Math.min(0.92, u)); const shell = intel.radius + 72; const sinT = Math.sin(theta); return { x: intel.pos.x + Math.cos(phi) * sinT * shell, y: intel.pos.y + Math.sin(phi) * sinT * shell, alt: ALT_MIN + 12 + (1 - Math.cos(theta)) * 0.55 * (ALT_MAX - ALT_MIN) + slotIndex * 2.5, }; } function applyEscortSteer( slotIndex: number, intel: TransitIntel, d: Drone, t: number, ax: number, ay: number, alt: number, altAccel: number, ): { ax: number; ay: number; altAccel: number } { const toObjX = intel.pos.x - d.pos.x; const toObjY = intel.pos.y - d.pos.y; const distToObj = Math.hypot(toObjX, toObjY); if (distToObj < 0.01) return { ax, ay, altAccel }; const slot = escortSlot(slotIndex, intel, t); const toSlotX = slot.x - d.pos.x; const toSlotY = slot.y - d.pos.y; const distToSlot = Math.hypot(toSlotX, toSlotY); let sx = toSlotX / (distToSlot || 1); let sy = toSlotY / (distToSlot || 1); let escortW = 0.88; // Far — intercept object center (mesh drones heading in) if (distToObj > intel.radius + 200) { sx = toObjX / distToObj; sy = toObjY / distToObj; escortW = 0.94; } else if (distToObj > intel.radius + 110) { sx = sx * 0.45 + (toObjX / distToObj) * 0.55; sy = sy * 0.45 + (toObjY / distToObj) * 0.55; escortW = 0.9; } const altPull = (slot.alt - alt) * 2.8 * escortW; return { ax: ax * (1 - escortW) + sx * 3.1 * escortW, ay: ay * (1 - escortW) + sy * 3.1 * escortW, altAccel: altAccel + altPull, }; } export interface EscortDuty { transitId: number; slot: number; intel: TransitIntel; } export interface SteerContext extends World { escortDuty: Map; steerMetrics?: Map; } function buildEscortDuty(drones: Drone[], obstacles: Obstacle[], t: number): Map { const duty = new Map(); const used = new Set(); for (const ob of onFieldTransits(obstacles)) { if (!drones.some((d) => d.aware?.has(ob.id))) continue; const intel = intelFrom(ob, t); const sorted = drones .filter((d) => !used.has(d.id) && (d.phase === "patrol" || !d.phase)) .map((d) => ({ d, dist: Math.hypot(d.pos.x - ob.pos.x, d.pos.y - ob.pos.y), })) .sort((a, b) => a.dist - b.dist); let slot = 0; for (const { d } of sorted) { if (slot >= ESCORT_PER_TRANSIT) break; duty.set(d.id, { transitId: ob.id, slot, intel }); used.add(d.id); slot += 1; } } return duty; } export function droneDetectsTransit(d: Drone, ob: Obstacle): boolean { if (!ob.moving || outOfTransit(ob)) return false; return Math.hypot(d.pos.x - ob.pos.x, d.pos.y - ob.pos.y) <= DETECT_RANGE; } function steer(d: Drone, world: SteerContext, dt: number, obstacles: Obstacle[]): Drone { const route = routeOf(d, world.routeMode ?? "default"); const swarm = world.routeMode === "perimeter"; const pad = world.basePad; const phase = d.phase ?? (swarm ? "patrol" : undefined); const duty = world.escortDuty.get(d.id); const escorting = Boolean(swarm && duty && phase === "patrol"); const patrolling = Boolean(swarm && phase === "patrol" && !escorting); if (swarm && phase === "landed" && pad) { const spot = padLandingSpot(d.id, pad); const progress = Math.min(1, (d.offloadProgress ?? 0) + dt / OFFLOAD_PAD_SECONDS); const buffer = d.dataBuffer ?? 0; const offloaded = Math.min(buffer, OFFLOAD_RATE * dt); const newBuffer = Math.max(0, buffer - offloaded); const newBattery = Math.min(100, d.battery + BATTERY_RECHARGE * dt); const ready = progress >= 1 && newBuffer <= 0 && newBattery >= BATTERY_DEPLOY_MIN; return { ...d, phase: ready ? "takeoff" : "landed", pos: spot, vel: { x: 0, y: 0 }, heading: d.heading, alt: 1.1, altVel: ready ? 2.5 : 0, offloadProgress: progress, dataBuffer: newBuffer, battery: newBattery, }; } const wp = route[(d.waypoint + d.id) % route.length]; let ax = 0; let ay = 0; let waypoint = d.waypoint; let cruiseMul = 1; if (swarm && pad && phase === "deploy") { const target = slotTarget(d, world.drones.length); const dx = target.x - d.pos.x; const dy = target.y - d.pos.y; const dist = Math.hypot(dx, dy) || 1; ax = (dx / dist) * 3.6; ay = (dy / dist) * 3.6; cruiseMul = 1.38; } else if (swarm && pad && phase === "return") { const dx = pad.pos.x - d.pos.x; const dy = pad.pos.y - d.pos.y; const dist = Math.hypot(dx, dy) || 1; ax = (dx / dist) * 3.4; ay = (dy / dist) * 3.4; cruiseMul = 1.05; } else if (swarm && pad && phase === "takeoff") { const dx = pad.pos.x - d.pos.x; const dy = pad.pos.y - d.pos.y; const dist = Math.hypot(dx, dy) || 1; if (dist > pad.radius * 0.25) { ax = (dx / dist) * 1.2; ay = (dy / dist) * 1.2; } cruiseMul = 0.55; } else if (patrolling) { const algo = world.swarmAlgo ?? "boids"; switch (algo) { case "perimeter": ({ ax, ay } = evenPerimeterSteer(d, world.drones.length, ax, ay)); ({ ax, ay } = linkSeekSteer(d, world.drones, ax, ay)); break; case "boids": ({ ax, ay } = patrolSteerBoids(d, world.drones, ax, ay)); break; case "apf": ({ ax, ay } = patrolSteerApf(d, world.drones, obstacles, ax, ay)); break; case "aco": ({ ax, ay } = patrolSteerAco( d, world.drones, world.perimeterHeat ?? [], ax, ay, )); break; case "hypha": ({ ax, ay } = patrolSteerHypha( d, world.drones, world.perimeterHeat ?? [], world.hyphaStrength ?? [], ax, ay, )); break; case "frontier": ({ ax, ay } = patrolSteerFrontier( d, world.drones, world.perimeterHeat ?? [], world.hyphaStrength ?? [], world.t, ax, ay, )); break; case "wave": ({ ax, ay } = patrolSteerWave(d, world.drones, world.t, ax, ay)); break; case "mycelium": ({ ax, ay } = patrolSteerMycelium( d, world.drones, world.perimeterHeat ?? [], world.hyphaStrength ?? [], world.t, ax, ay, )); break; } } else { ax = wp.x - d.pos.x; ay = wp.y - d.pos.y; const wpDist = Math.hypot(ax, ay); if (wpDist < 90) waypoint = (d.waypoint + 1) % route.length; ax /= wpDist || 1; ay /= wpDist || 1; } let altAccel = 0; let alt = d.alt ?? 22; let altVel = d.altVel ?? 0; let metrics: SteerMetrics = { passOver: false, bypass: false }; if (swarm && phase === "return") { altAccel += (3.2 - alt) * 4.2; } else if (swarm && phase === "takeoff") { altAccel += (ALT_MIN + 12 - alt) * 5.5; } else if (swarm && phase === "deploy") { const band = ALT_MIN + (d.id % 6) * 4.5; altAccel += (band - alt) * 3.2; } if (swarm && phase === "patrol") { ({ ax, ay, altAccel, metrics } = applyPeerDeconflict(d, world.drones, ax, ay, alt)); } else if (!swarm) { 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; } } } else if (swarm && (phase === "deploy" || phase === "return")) { 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 * 0.85 && dist > 0.01) { const push = (SEPARATION * 0.85 - dist) / SEPARATION; ax += (dx / dist) * push * 2; ay += (dy / dist) * push * 2; } } } if (duty && phase === "patrol") { ({ ax, ay, altAccel } = applyEscortSteer(duty.slot, duty.intel, d, world.t, ax, ay, alt, altAccel)); } for (const ob of obstacles) { if (ob.moving && duty?.transitId === ob.id) continue; 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; } } if (patrolling) { ({ ax, ay } = enforceMinProgress(d, world.drones.length, ax, ay, d.vel.x, d.vel.y)); } else if (phase === "deploy") { ({ ax, ay } = resolveSteerStall(d, world.drones.length, ax, ay, d.vel.x, d.vel.y)); } world.steerMetrics?.set(d.id, metrics); const { vx, vy, heading } = integrateVel(d, ax, ay, cruiseMul, phase); if (swarm && phase !== "landed") { if (phase === "patrol" || escorting) { const band = ALT_MIN + (d.id % 6) * 4.5; const legWave = Math.sin(world.t * 0.62 + d.id * 0.91) * (escorting ? 2.5 : 3); const posWave = Math.sin(d.pos.x * 0.011 + world.t * 0.45) * (escorting ? 1.5 : 2) + Math.cos(d.pos.y * 0.009 + world.t * 0.38 + d.id) * (escorting ? 1.5 : 2); const beeBob = escorting ? Math.sin(world.t * 3.2 + d.id * 1.4) * 4 : Math.sin(world.t * 2.4 + d.id * 1.2) * 5; const targetAlt = band + legWave + posWave + beeBob; altAccel += (targetAlt - alt) * (escorting ? 1.4 : 1.6); } altVel = altVel * 0.8 + altAccel * dt * (phase === "return" ? 18 : 15); alt = Math.max(0.8, Math.min(ALT_MAX, alt + altVel * dt)); } let batteryDrain = 0.05; if (phase === "patrol") batteryDrain = escorting ? 0.11 : 0.085; else if (phase === "deploy" || phase === "return") batteryDrain = 0.07; else if (phase === "takeoff") batteryDrain = 0.04; let dataBuffer = d.dataBuffer ?? 0; if (phase === "patrol") dataBuffer += DATA_COLLECT_RATE * dt; let nextPhase = phase; if (swarm && pad) { if (phase === "deploy") { const target = slotTarget(d, world.drones.length); const distSlot = Math.hypot(d.pos.x - target.x, d.pos.y - target.y); if (distSlot < 100 && alt >= ALT_MIN + 3) nextPhase = "patrol"; } else if (phase === "return") { if (distToPad({ ...d, pos: { x: d.pos.x + vx * dt, y: d.pos.y + vy * dt } }, pad) < pad.radius * 0.82 && alt <= 4.8) { nextPhase = "landed"; } } else if (phase === "takeoff" && alt >= ALT_MIN + 8) { nextPhase = "deploy"; } } const landedNow = nextPhase === "landed"; const spot = pad ? padLandingSpot(d.id, pad) : d.pos; return { ...d, phase: nextPhase, waypoint, vel: landedNow ? { x: 0, y: 0 } : { x: vx, y: vy }, heading, pos: landedNow ? spot : { 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 * batteryDrain), channel: rnd() < dt * 0.15 ? CHANNELS[Math.floor(rnd() * CHANNELS.length)] : d.channel, dataBuffer, offloadProgress: landedNow ? 0 : d.offloadProgress, landingQueued: nextPhase === "return" ? false : d.landingQueued, ...(swarm ? { alt: landedNow ? 1.1 : alt, altVel: landedNow ? 0 : altVel } : {}), }; } export function tick(world: World, dt: number): World { const obstacles = world.obstacles.map((ob) => { if (!ob.moving) return ob; const steered = steerTransit(ob, world.obstacles, world.drones, dt); return outOfTransit(steered) ? spawnTransit(ob.id) : steered; }); const observed = world.drones.map((d) => observeDrone(d, obstacles, world.t)); const { drones: informed, intelLinks } = world.routeMode === "perimeter" ? propagateIntel(observed) : { drones: observed, intelLinks: new Set() }; const escortDuty = buildEscortDuty(informed, obstacles, world.t); let landingQueue = world.landingQueue ?? []; let pad = world.basePad; let queueDrones = informed; if (world.routeMode === "perimeter" && pad) { const managed = manageLandingQueue(informed, pad, landingQueue); queueDrones = managed.drones; landingQueue = managed.landingQueue; } let flightAcc = world.flightAcc ?? emptyFlightAcc(); let statsWindowStart = world.statsWindowStart ?? world.t; if (world.t - statsWindowStart > STATS_WINDOW_S) { flightAcc = emptyFlightAcc(); statsWindowStart = world.t; } let perimeterHeat = world.perimeterHeat ?? Array.from({ length: PERIMETER_SEGMENTS }, () => 0); let hyphaStrength = world.hyphaStrength ?? Array.from({ length: PERIMETER_SEGMENTS }, () => 0); if (world.routeMode === "perimeter") { perimeterHeat = updatePerimeterHeat(queueDrones, perimeterHeat, dt); hyphaStrength = updateHyphaStrength(queueDrones, world.links, hyphaStrength, dt); } const steerMetrics = new Map(); const prevDrones = new Map(queueDrones.map((d) => [d.id, d])); const steerWorld: SteerContext = { ...world, obstacles, drones: queueDrones, escortDuty, perimeterHeat, hyphaStrength, steerMetrics, basePad: pad, landingQueue, }; let drones = queueDrones.map((d) => steer(d, steerWorld, dt, obstacles)); let totalOffloaded = pad?.totalOffloaded ?? 0; const offloadingIds: number[] = []; if (pad) { for (const d of drones) { if (d.phase === "landed") { offloadingIds.push(d.id); const prev = prevDrones.get(d.id); const prevBuf = prev?.dataBuffer ?? 0; const nowBuf = d.dataBuffer ?? 0; if (nowBuf < prevBuf) totalOffloaded += prevBuf - nowBuf; } } pad = { ...pad, totalOffloaded, offloadingIds }; } for (const d of drones) { const metrics = steerMetrics.get(d.id) ?? { passOver: false, bypass: false }; accumulateFlightStats(flightAcc, d, prevDrones.get(d.id), metrics); } const flightStats = flightAccToStats(flightAcc); 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 && (world.routeMode === "perimeter" ? linkRange3d(a, b) <= LINK_RANGE : 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 = world.routeMode === "perimeter" ? linkRange3d(drones[i], drones[j]) : 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; } if (intelLinks.has(key)) { link.flash = Math.max(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, routeMode: world.routeMode, swarmAlgo: world.swarmAlgo, perimeterHeat, hyphaStrength, flightAcc, statsWindowStart, flightStats, basePad: pad, landingQueue, }; } export function transitKnownByFleet(world: World, transitId: number): boolean { return world.drones.some((d) => d.aware?.has(transitId)); } 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`; }