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.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import L from 'leaflet';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useGameStore } from '../stores/game';
|
||||
import type { Place } from '../stores/game';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const game = useGameStore();
|
||||
|
||||
const mapEl = ref<HTMLDivElement | null>(null);
|
||||
let map: L.Map | null = null;
|
||||
let markersLayer: L.LayerGroup | null = null;
|
||||
let linksLayer: L.LayerGroup | null = null;
|
||||
let fieldsLayer: L.LayerGroup | null = null;
|
||||
|
||||
const selectedPlaceId = ref<number | null>(null);
|
||||
const linkSourceId = ref<number | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const actionBusy = ref(false);
|
||||
|
||||
const selectedPlace = computed<Place | null>(
|
||||
() => game.places.find((p) => p.id === selectedPlaceId.value) ?? null,
|
||||
);
|
||||
const linkSourcePlace = computed<Place | null>(
|
||||
() => game.places.find((p) => p.id === linkSourceId.value) ?? null,
|
||||
);
|
||||
|
||||
const TEAM_COLOR: Record<string, string> = { orange: '#f97316', green: '#22c55e' };
|
||||
const NEUTRAL_COLOR = '#9ca3af';
|
||||
|
||||
function colorFor(team: string | null): string {
|
||||
return team ? TEAM_COLOR[team] : NEUTRAL_COLOR;
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
if (!map || !markersLayer || !linksLayer || !fieldsLayer) return;
|
||||
markersLayer.clearLayers();
|
||||
linksLayer.clearLayers();
|
||||
fieldsLayer.clearLayers();
|
||||
|
||||
const coordsById = new Map(game.places.map((p) => [p.id, p]));
|
||||
|
||||
for (const place of game.places) {
|
||||
const isSource = place.id === linkSourceId.value;
|
||||
const marker = L.circleMarker([place.lat, place.lon], {
|
||||
radius: isSource ? 10 : 7,
|
||||
color: isSource ? '#ffffff' : colorFor(place.claim_team),
|
||||
weight: isSource ? 3 : 2,
|
||||
fillColor: colorFor(place.claim_team),
|
||||
fillOpacity: 0.85,
|
||||
});
|
||||
marker.on('click', () => onMarkerClick(place));
|
||||
marker.bindTooltip(place.name || `Place ${place.id}`, { direction: 'top' });
|
||||
markersLayer.addLayer(marker);
|
||||
}
|
||||
|
||||
for (const link of game.links) {
|
||||
const from = coordsById.get(link.from_place_id);
|
||||
const to = coordsById.get(link.to_place_id);
|
||||
if (!from || !to) continue;
|
||||
linksLayer.addLayer(
|
||||
L.polyline(
|
||||
[
|
||||
[from.lat, from.lon],
|
||||
[to.lat, to.lon],
|
||||
],
|
||||
{ color: TEAM_COLOR[link.team], weight: 2, opacity: 0.8 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of game.fields) {
|
||||
const points = field.placeIds
|
||||
.map((id) => coordsById.get(id))
|
||||
.filter((p): p is Place => !!p)
|
||||
.map((p) => [p.lat, p.lon] as [number, number]);
|
||||
if (points.length !== 3) continue;
|
||||
fieldsLayer.addLayer(
|
||||
L.polygon(points, { color: TEAM_COLOR[field.team], weight: 1, fillOpacity: 0.15, stroke: false }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onMarkerClick(place: Place) {
|
||||
selectedPlaceId.value = place.id;
|
||||
actionError.value = null;
|
||||
}
|
||||
|
||||
async function doClaim(place: Place) {
|
||||
actionError.value = null;
|
||||
actionBusy.value = true;
|
||||
try {
|
||||
await game.claim(place.id);
|
||||
} catch (err) {
|
||||
actionError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startLink(place: Place) {
|
||||
linkSourceId.value = place.id;
|
||||
actionError.value = null;
|
||||
}
|
||||
|
||||
function cancelLink() {
|
||||
linkSourceId.value = null;
|
||||
}
|
||||
|
||||
async function completeLink(place: Place) {
|
||||
if (!linkSourceId.value) return;
|
||||
actionError.value = null;
|
||||
actionBusy.value = true;
|
||||
try {
|
||||
await game.link(linkSourceId.value, place.id);
|
||||
linkSourceId.value = null;
|
||||
} catch (err) {
|
||||
actionError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doSync() {
|
||||
actionError.value = null;
|
||||
actionBusy.value = true;
|
||||
try {
|
||||
await game.syncPlaces();
|
||||
} catch (err) {
|
||||
actionError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const orangeScore = computed(() => game.scores.find((s) => s.team === 'orange'));
|
||||
const greenScore = computed(() => game.scores.find((s) => s.team === 'green'));
|
||||
|
||||
onMounted(async () => {
|
||||
if (mapEl.value) {
|
||||
map = L.map(mapEl.value).setView([32.7607, -16.9595], 12);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
fieldsLayer = L.layerGroup().addTo(map);
|
||||
linksLayer = L.layerGroup().addTo(map);
|
||||
markersLayer = L.layerGroup().addTo(map);
|
||||
}
|
||||
await game.refreshAll();
|
||||
redraw();
|
||||
});
|
||||
|
||||
watch(() => [game.places, game.links, game.fields], redraw, { deep: true });
|
||||
watch(linkSourceId, redraw);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<header class="flex items-center justify-between gap-4 bg-neutral-900 px-4 py-2 text-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-bold">Regress</span>
|
||||
<span class="flex items-center gap-1 text-orange-500">
|
||||
● {{ orangeScore?.claimedPlaces ?? 0 }} places · {{ orangeScore?.fields ?? 0 }} fields ·
|
||||
{{ (orangeScore?.areaKm2 ?? 0).toFixed(2) }} km²
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-green-500">
|
||||
● {{ greenScore?.claimedPlaces ?? 0 }} places · {{ greenScore?.fields ?? 0 }} fields ·
|
||||
{{ (greenScore?.areaKm2 ?? 0).toFixed(2) }} km²
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-neutral-400">
|
||||
{{ auth.displayName ?? auth.pubkey?.slice(0, 8) }} ·
|
||||
<span :class="auth.team === 'orange' ? 'text-orange-500' : 'text-green-500'">{{ auth.team }}</span>
|
||||
</span>
|
||||
<button class="rounded bg-neutral-800 px-3 py-1 hover:bg-neutral-700" :disabled="actionBusy" @click="doSync">
|
||||
Sync from BTC Map
|
||||
</button>
|
||||
<button class="rounded bg-neutral-800 px-3 py-1 hover:bg-neutral-700" @click="auth.logout()">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="relative flex-1">
|
||||
<div ref="mapEl" class="h-full w-full"></div>
|
||||
|
||||
<div
|
||||
v-if="linkSourcePlace"
|
||||
class="absolute left-4 top-4 z-[1000] rounded-lg bg-neutral-900/95 px-4 py-2 text-sm shadow-lg"
|
||||
>
|
||||
Linking from <strong>{{ linkSourcePlace.name }}</strong> — click a friendly place to complete, or
|
||||
<button class="ml-2 text-neutral-400 underline" @click="cancelLink">cancel</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedPlace"
|
||||
class="absolute right-4 top-4 z-[1000] w-72 rounded-lg bg-neutral-900/95 p-4 text-sm shadow-lg"
|
||||
>
|
||||
<div class="mb-1 flex items-start justify-between gap-2">
|
||||
<h2 class="font-bold">{{ selectedPlace.name || `Place ${selectedPlace.id}` }}</h2>
|
||||
<button class="text-neutral-500" @click="selectedPlaceId = null">✕</button>
|
||||
</div>
|
||||
<p v-if="selectedPlace.address" class="mb-2 text-neutral-400">{{ selectedPlace.address }}</p>
|
||||
<p class="mb-3">
|
||||
Status:
|
||||
<span v-if="!selectedPlace.claim_team" class="text-neutral-400">unclaimed</span>
|
||||
<span v-else :class="selectedPlace.claim_team === 'orange' ? 'text-orange-500' : 'text-green-500'">
|
||||
claimed by {{ selectedPlace.claim_team }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
v-if="selectedPlace.claim_team !== auth.team"
|
||||
class="rounded bg-white px-3 py-2 font-semibold text-black hover:bg-neutral-200 disabled:opacity-50"
|
||||
:disabled="actionBusy"
|
||||
@click="doClaim(selectedPlace)"
|
||||
>
|
||||
Claim for {{ auth.team }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="linkSourceId !== selectedPlace.id"
|
||||
class="rounded bg-neutral-800 px-3 py-2 hover:bg-neutral-700 disabled:opacity-50"
|
||||
:disabled="actionBusy"
|
||||
@click="startLink(selectedPlace)"
|
||||
>
|
||||
Link from here
|
||||
</button>
|
||||
<button
|
||||
v-if="linkSourceId && linkSourceId !== selectedPlace.id && selectedPlace.claim_team === auth.team"
|
||||
class="rounded bg-neutral-800 px-3 py-2 hover:bg-neutral-700 disabled:opacity-50"
|
||||
:disabled="actionBusy"
|
||||
@click="completeLink(selectedPlace)"
|
||||
>
|
||||
Complete link to here
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="actionError" class="mt-3 text-red-400">{{ actionError }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!game.places.length && !game.loading"
|
||||
class="absolute inset-0 z-[999] flex items-center justify-center bg-black/60"
|
||||
>
|
||||
<div class="rounded-lg bg-neutral-900 p-6 text-center">
|
||||
<p class="mb-3">No places loaded yet — pull the current Madeira dataset from BTC Map.</p>
|
||||
<button class="rounded bg-white px-4 py-2 font-semibold text-black" :disabled="actionBusy" @click="doSync">
|
||||
Sync from BTC Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user