Files
regress/server/src/services/fields.ts
T
ssmithx d6ef514c84 Scaffold Regress: Fastify+SQLite server and Vue frontend for the Ingress-style BTC Map capture game
- Server: NIP-98 nostr auth (session cookies), BTC Map sync, claim/link/field
  routes, physical-presence checks via geolocation distance. 31 tests passing.
- Frontend: Leaflet map, team pick, claim/link UI, Archipelago identity
  bridge vendored (nostr-provider.js) for dashboard launch support.
- Verified end-to-end against the live BTC Map API (167 real Madeira places)
  and via genuine NIP-98-signed HTTP requests against the built app.
2026-08-05 13:14:03 +00:00

79 lines
2.4 KiB
TypeScript

import type { LatLon } from './geo.js';
import { triangleAreaKm2 } from './geo.js';
export interface LinkEdge {
fromPlaceId: number;
toPlaceId: number;
team: string;
}
export interface Field {
team: string;
placeIds: [number, number, number];
areaKm2: number;
}
/**
* Every set of three mutually-linked, same-team places forms a control field —
* mirrors Ingress's triangle-fill rule. Recomputed from scratch on read rather
* than persisted, since claims/links can invalidate fields from several call
* sites and a derived view can't go stale.
*/
export function computeFields(links: LinkEdge[], placeCoords: Map<number, LatLon>): Field[] {
const adjacencyByTeam = new Map<string, Map<number, Set<number>>>();
for (const link of links) {
if (!adjacencyByTeam.has(link.team)) adjacencyByTeam.set(link.team, new Map());
const adj = adjacencyByTeam.get(link.team)!;
if (!adj.has(link.fromPlaceId)) adj.set(link.fromPlaceId, new Set());
if (!adj.has(link.toPlaceId)) adj.set(link.toPlaceId, new Set());
adj.get(link.fromPlaceId)!.add(link.toPlaceId);
adj.get(link.toPlaceId)!.add(link.fromPlaceId);
}
const fields: Field[] = [];
for (const [team, adj] of adjacencyByTeam) {
const nodes = [...adj.keys()].sort((x, y) => x - y);
for (const u of nodes) {
const uNeighbors = [...adj.get(u)!].filter((v) => v > u);
for (const v of uNeighbors) {
const vNeighbors = adj.get(v)!;
for (const w of uNeighbors) {
if (w <= v || !vNeighbors.has(w)) continue;
const a = placeCoords.get(u);
const b = placeCoords.get(v);
const c = placeCoords.get(w);
if (!a || !b || !c) continue;
fields.push({ team, placeIds: [u, v, w], areaKm2: triangleAreaKm2(a, b, c) });
}
}
}
}
return fields;
}
export interface TeamScore {
team: string;
claimedPlaces: number;
links: number;
fields: number;
areaKm2: number;
}
export function summarizeScores(
teams: string[],
claimCounts: Map<string, number>,
linkCounts: Map<string, number>,
fields: Field[],
): TeamScore[] {
return teams.map((team) => {
const teamFields = fields.filter((f) => f.team === team);
return {
team,
claimedPlaces: claimCounts.get(team) ?? 0,
links: linkCounts.get(team) ?? 0,
fields: teamFields.length,
areaKm2: teamFields.reduce((sum, f) => sum + f.areaKm2, 0),
};
});
}