- 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.
79 lines
2.4 KiB
TypeScript
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),
|
|
};
|
|
});
|
|
}
|