Enforce Ingress-style link-crossing prevention
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.
This commit is contained in:
@@ -44,6 +44,9 @@ Map's public API.
|
|||||||
links running through it.
|
links running through it.
|
||||||
- A link can be created between any two places your team currently holds,
|
- 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.
|
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
|
- Three mutually-linked, same-team places form a field (computed live, not
|
||||||
stored) — see `server/src/services/fields.ts`.
|
stored) — see `server/src/services/fields.ts`.
|
||||||
- Team choice is one-way once made.
|
- Team choice is one-way once made.
|
||||||
|
|||||||
@@ -221,3 +221,55 @@ describe('linking and fields', () => {
|
|||||||
expect(fieldsRes.json()).toHaveLength(0);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { haversineMeters } from '../services/geo.js';
|
import { haversineMeters, segmentsIntersect } from '../services/geo.js';
|
||||||
import type { Claim, Place, User } from '../types.js';
|
import type { Claim, LinkRow, Place, User } from '../types.js';
|
||||||
|
|
||||||
const linkBody = z.object({
|
const linkBody = z.object({
|
||||||
fromPlaceId: z.number().int(),
|
fromPlaceId: z.number().int(),
|
||||||
@@ -23,6 +23,7 @@ export default async function linksRoutes(app: FastifyInstance) {
|
|||||||
const getExistingLink = db.prepare(`
|
const getExistingLink = db.prepare(`
|
||||||
SELECT * FROM links WHERE (from_place_id = ? AND to_place_id = ?) OR (from_place_id = ? AND to_place_id = ?)
|
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(`
|
const insertLink = db.prepare(`
|
||||||
INSERT INTO links (from_place_id, to_place_id, team, created_by_pubkey, created_at)
|
INSERT INTO links (from_place_id, to_place_id, team, created_by_pubkey, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
@@ -62,6 +63,25 @@ export default async function linksRoutes(app: FastifyInstance) {
|
|||||||
return reply.code(409).send({ error: 'these places are already linked' });
|
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());
|
const result = insertLink.run(fromPlaceId, toPlaceId, user.team, req.userPubkey, nowSecs());
|
||||||
return { id: result.lastInsertRowid, fromPlaceId, toPlaceId, team: user.team };
|
return { id: result.lastInsertRowid, fromPlaceId, toPlaceId, team: user.team };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { haversineMeters, triangleAreaKm2 } from './geo.js';
|
import { haversineMeters, segmentsIntersect, triangleAreaKm2 } from './geo.js';
|
||||||
|
|
||||||
describe('haversineMeters', () => {
|
describe('haversineMeters', () => {
|
||||||
it('returns ~0 for the same point', () => {
|
it('returns ~0 for the same point', () => {
|
||||||
@@ -29,3 +29,39 @@ describe('triangleAreaKm2', () => {
|
|||||||
expect(area).toBeLessThan(1000); // sanity bound, not a precise reference value
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,3 +32,40 @@ export function triangleAreaKm2(a: LatLon, b: LatLon, c: LatLon): number {
|
|||||||
const pc = toXY(c);
|
const pc = toXY(c);
|
||||||
return Math.abs(pb.x * pc.y - pc.x * pb.y) / 2;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user