From f0d437b60f3a4b1ca6cafd3e54fcd28b76c9542e Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 5 Aug 2026 13:33:06 +0000 Subject: [PATCH] Enforce Ingress-style link-crossing prevention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new link can no longer cross any existing link from either team — segmentsIntersect() does a standard orientation-based segment test, and routes/links.ts excludes shared-endpoint pairs (fanning multiple links out of the same portal is fine, that's not a crossing). 6 new tests; 37 total passing. --- README.md | 3 ++ server/src/app.test.ts | 52 +++++++++++++++++++++++++++++++++ server/src/routes/links.ts | 24 +++++++++++++-- server/src/services/geo.test.ts | 38 +++++++++++++++++++++++- server/src/services/geo.ts | 37 +++++++++++++++++++++++ 5 files changed, 151 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ab9e3e5..8957203 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ Map's public API. links running through it. - A link can be created between any two places your team currently holds, as long as you're standing at the origin place — no maximum link distance. +- A new link can't cross any existing link (either team) — same as Ingress. + Links sharing an endpoint don't count as crossing. See + `services/geo.ts#segmentsIntersect` and `routes/links.ts`. - Three mutually-linked, same-team places form a field (computed live, not stored) — see `server/src/services/fields.ts`. - Team choice is one-way once made. diff --git a/server/src/app.test.ts b/server/src/app.test.ts index fdc9936..230c0cc 100644 --- a/server/src/app.test.ts +++ b/server/src/app.test.ts @@ -221,3 +221,55 @@ describe('linking and fields', () => { expect(fieldsRes.json()).toHaveLength(0); }); }); + +describe('link crossing', () => { + // Synthetic coordinates forming a clean X, unrelated to the real Madeira + // places above — easier to reason about the geometry with round numbers. + const W = { id: 90001, lat: 10, lon: 10 }; + const X = { id: 90002, lat: 10, lon: 12 }; + const Y = { id: 90003, lat: 9, lon: 11 }; + const Z = { id: 90004, lat: 11, lon: 11 }; + + beforeAll(async () => { + for (const p of [W, X, Y, Z]) { + app.ctx.db + .prepare('INSERT INTO places (id, lat, lon, name, synced_at) VALUES (?, ?, ?, ?, ?)') + .run(p.id, p.lat, p.lon, `synthetic-${p.id}`, Math.floor(Date.now() / 1000)); + await app.inject({ + method: 'POST', + url: `/api/places/${p.id}/claim`, + headers: { cookie: cookieA }, + payload: { lat: p.lat, lon: p.lon }, + }); + } + await app.inject({ + method: 'POST', + url: '/api/links', + headers: { cookie: cookieA }, + payload: { fromPlaceId: W.id, toPlaceId: X.id, lat: W.lat, lon: W.lon }, + }); + }); + + it('rejects a new link that would cross an existing one', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/links', + headers: { cookie: cookieA }, + payload: { fromPlaceId: Y.id, toPlaceId: Z.id, lat: Y.lat, lon: Y.lon }, + }); + expect(res.statusCode).toBe(409); + expect(res.json().error).toMatch(/cross/); + }); + + it('still allows a new link sharing an endpoint with an existing one', async () => { + // W-Y shares endpoint W with the existing W-X link — that's fanning out + // from the same portal, not a crossing, so it must be allowed. + const res = await app.inject({ + method: 'POST', + url: '/api/links', + headers: { cookie: cookieA }, + payload: { fromPlaceId: W.id, toPlaceId: Y.id, lat: W.lat, lon: W.lon }, + }); + expect(res.statusCode).toBe(200); + }); +}); diff --git a/server/src/routes/links.ts b/server/src/routes/links.ts index 907d903..978b91d 100644 --- a/server/src/routes/links.ts +++ b/server/src/routes/links.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import { haversineMeters } from '../services/geo.js'; -import type { Claim, Place, User } from '../types.js'; +import { haversineMeters, segmentsIntersect } from '../services/geo.js'; +import type { Claim, LinkRow, Place, User } from '../types.js'; const linkBody = z.object({ fromPlaceId: z.number().int(), @@ -23,6 +23,7 @@ export default async function linksRoutes(app: FastifyInstance) { const getExistingLink = db.prepare(` SELECT * FROM links WHERE (from_place_id = ? AND to_place_id = ?) OR (from_place_id = ? AND to_place_id = ?) `); + const getAllLinks = db.prepare('SELECT * FROM links'); const insertLink = db.prepare(` INSERT INTO links (from_place_id, to_place_id, team, created_by_pubkey, created_at) VALUES (?, ?, ?, ?, ?) @@ -62,6 +63,25 @@ export default async function linksRoutes(app: FastifyInstance) { return reply.code(409).send({ error: 'these places are already linked' }); } + // Links are physical XM beams in the sky in Ingress — a new link can't cross + // any existing link, from either team. Links sharing an endpoint with the new + // one aren't "crossing" (that's just a portal with multiple links fanning out). + for (const existing of getAllLinks.all() as LinkRow[]) { + if ( + existing.from_place_id === fromPlaceId || + existing.from_place_id === toPlaceId || + existing.to_place_id === fromPlaceId || + existing.to_place_id === toPlaceId + ) { + continue; + } + const existingFrom = getPlace.get(existing.from_place_id) as Place; + const existingTo = getPlace.get(existing.to_place_id) as Place; + if (segmentsIntersect(fromPlace, toPlace, existingFrom, existingTo)) { + return reply.code(409).send({ error: `would cross an existing link (id ${existing.id})` }); + } + } + const result = insertLink.run(fromPlaceId, toPlaceId, user.team, req.userPubkey, nowSecs()); return { id: result.lastInsertRowid, fromPlaceId, toPlaceId, team: user.team }; }); diff --git a/server/src/services/geo.test.ts b/server/src/services/geo.test.ts index 6ab10cf..2b07fc4 100644 --- a/server/src/services/geo.test.ts +++ b/server/src/services/geo.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { haversineMeters, triangleAreaKm2 } from './geo.js'; +import { haversineMeters, segmentsIntersect, triangleAreaKm2 } from './geo.js'; describe('haversineMeters', () => { it('returns ~0 for the same point', () => { @@ -29,3 +29,39 @@ describe('triangleAreaKm2', () => { expect(area).toBeLessThan(1000); // sanity bound, not a precise reference value }); }); + +describe('segmentsIntersect', () => { + it('detects a clean X crossing', () => { + const p1 = { lat: 10, lon: 10 }; + const q1 = { lat: 10, lon: 12 }; + const p2 = { lat: 9, lon: 11 }; + const q2 = { lat: 11, lon: 11 }; + expect(segmentsIntersect(p1, q1, p2, q2)).toBe(true); + }); + + it('returns false for parallel, non-crossing segments', () => { + const p1 = { lat: 10, lon: 10 }; + const q1 = { lat: 10, lon: 12 }; + const p2 = { lat: 12, lon: 10 }; + const q2 = { lat: 12, lon: 12 }; + expect(segmentsIntersect(p1, q1, p2, q2)).toBe(false); + }); + + it('treats a shared endpoint as an intersection at the raw geometry level', () => { + // The shared point is a valid intersection point, so the raw test reports + // true here — callers must explicitly exclude shared-endpoint pairs + // themselves before treating this as a "crossing" (see routes/links.ts). + const shared = { lat: 10, lon: 10 }; + const a = { lat: 10, lon: 12 }; + const b = { lat: 12, lon: 10 }; + expect(segmentsIntersect(shared, a, shared, b)).toBe(true); + }); + + it('returns false for two segments far apart', () => { + const p1 = { lat: 0, lon: 0 }; + const q1 = { lat: 0, lon: 1 }; + const p2 = { lat: 5, lon: 5 }; + const q2 = { lat: 5, lon: 6 }; + expect(segmentsIntersect(p1, q1, p2, q2)).toBe(false); + }); +}); diff --git a/server/src/services/geo.ts b/server/src/services/geo.ts index 2a9f0e3..f15a8a4 100644 --- a/server/src/services/geo.ts +++ b/server/src/services/geo.ts @@ -32,3 +32,40 @@ export function triangleAreaKm2(a: LatLon, b: LatLon, c: LatLon): number { const pc = toXY(c); return Math.abs(pb.x * pc.y - pc.x * pb.y) / 2; } + +function orientation(p: LatLon, q: LatLon, r: LatLon): 0 | 1 | 2 { + const val = (q.lat - p.lat) * (r.lon - q.lon) - (q.lon - p.lon) * (r.lat - q.lat); + if (Math.abs(val) < 1e-12) return 0; // collinear + return val > 0 ? 1 : 2; +} + +function onSegment(p: LatLon, q: LatLon, r: LatLon): boolean { + return ( + q.lon <= Math.max(p.lon, r.lon) + 1e-12 && + q.lon >= Math.min(p.lon, r.lon) - 1e-12 && + q.lat <= Math.max(p.lat, r.lat) + 1e-12 && + q.lat >= Math.min(p.lat, r.lat) - 1e-12 + ); +} + +/** + * Standard orientation-based segment-segment intersection test. Used to + * enforce Ingress's "links can't cross" rule — treats lat/lon as a flat + * plane, which is fine for the crossing question (a topological property) + * even though it's not metrically accurate at scale. + */ +export function segmentsIntersect(p1: LatLon, q1: LatLon, p2: LatLon, q2: LatLon): boolean { + const o1 = orientation(p1, q1, p2); + const o2 = orientation(p1, q1, q2); + const o3 = orientation(p2, q2, p1); + const o4 = orientation(p2, q2, q1); + + if (o1 !== o2 && o3 !== o4) return true; + + if (o1 === 0 && onSegment(p1, p2, q1)) return true; + if (o2 === 0 && onSegment(p1, q2, q1)) return true; + if (o3 === 0 && onSegment(p2, p1, q2)) return true; + if (o4 === 0 && onSegment(p2, q1, q2)) return true; + + return false; +}