Files
swarm-house/prototype/src/sim.ts
T
eSlider 1e5854ee0a
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify simulator (pull_request) Failing after 5s
CI & Release / Trivy scan (pull_request) Skipped
CI & Release / Semantic Release (pull_request) Skipped
feat: prototype perimeter patrol, 3D view, and swarm algorithms
Add optional Three.js scene, base-pad lifecycle, mycelial swarms,
UFO transit steering, and algorithm selector (boids, APF, ACO, hypha).
2026-07-12 12:51:25 +01:00

1900 lines
55 KiB
TypeScript

// 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<number, TransitIntel>; // 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<SwarmAlgorithm, string> = {
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<string, LinkStats>;
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<string, LinkStats>,
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<string, RoadSegment>();
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<number, TransitIntel>): Map<number, TransitIntel> {
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<number, TransitIntel>, from: Map<number, TransitIntel>): 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<string> } {
const awareList = drones.map((d) => cloneAware(d.aware));
const intelLinks = new Set<string>();
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<number, EscortDuty>;
steerMetrics?: Map<number, SteerMetrics>;
}
function buildEscortDuty(drones: Drone[], obstacles: Obstacle[], t: number): Map<number, EscortDuty> {
const duty = new Map<number, EscortDuty>();
const used = new Set<number>();
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<string>() };
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<number, SteerMetrics>();
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`;
}