From 22f571da5b63e8aca7bdedb9f5b909128f9c593f Mon Sep 17 00:00:00 2001 From: ubuntu03 Date: Wed, 4 Nov 2026 19:49:05 -0800 Subject: [PATCH 01/48] Add isolated cache-only exploration and captured evidence contracts --- .github/workflows/ci.yml | 5 +-- e2e/cached-exploration.spec.ts | 21 ++++++++++++ src/client/api.ts | 8 +++-- src/components/ExplorerPage.tsx | 42 ++++++++++++----------- src/evidence/captured.ts | 42 +++++++++++++++++++++++ src/evidence/contracts.ts | 14 ++++++++ src/routes/api.world.$owner.$repo.ts | 8 +++++ src/routes/world.$owner.$repo.tsx | 6 ++-- src/server/cached-world.ts | 22 ++++++++++++ src/server/service.ts | 9 +++-- src/server/store.ts | 5 +-- src/server/world-storage.ts | 2 +- test/cached-exploration.test.ts | 50 ++++++++++++++++++++++++++++ test/fixtures/offline-runtime.ts | 4 +++ 14 files changed, 207 insertions(+), 31 deletions(-) create mode 100644 e2e/cached-exploration.spec.ts create mode 100644 src/evidence/captured.ts create mode 100644 src/evidence/contracts.ts create mode 100644 src/server/cached-world.ts create mode 100644 test/cached-exploration.test.ts create mode 100644 test/fixtures/offline-runtime.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d36a197..e635903 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI on: push: - branches: [main] + branches: [main, feature/atlas-cached-exploration] pull_request: permissions: contents: read @@ -14,6 +14,7 @@ jobs: timeout-minutes: 15 env: MOONSHOT_BUDGET_USD: "0" + MOONSHOT_OFFLINE_FIXTURE: "1" MOONSHOT_BASE_PATH: /github-factory/ steps: - uses: actions/checkout@v4 @@ -28,7 +29,7 @@ jobs: - name: Verify prefixed production app in Chromium shell: bash run: | - HOST=127.0.0.1 PORT=4177 bun run start > /tmp/github-factory.log 2>&1 & + HOST=127.0.0.1 PORT=4177 bun --no-env-file --preload ./test/fixtures/offline-runtime.ts .output/server/index.mjs > /tmp/github-factory.log 2>&1 & app_pid=$! trap 'kill "$app_pid" 2>/dev/null || true' EXIT for attempt in {1..30}; do diff --git a/e2e/cached-exploration.spec.ts b/e2e/cached-exploration.spec.ts new file mode 100644 index 0000000..ee7c02e --- /dev/null +++ b/e2e/cached-exploration.spec.ts @@ -0,0 +1,21 @@ +import { basePath, test, expect, worldApi, worldPath } from "./fixtures"; + +test("SYNTHETIC cached entry, retry, reload and sixty-second idle never collect or request AI",async({page,api})=>{ + api.world.source="github";let ready=false;const queries:string[]=[]; + api.respond("GET",worldApi,route=>{const u=new URL(route.request().url());queries.push(u.search);return ready?{body:api.world}:{status:404,body:{error:"Not collected. No collection was requested.",code:"CACHE_MISS"}}}); + await page.clock.install();await page.goto(worldPath+"?mode=cached");await expect(page.getByRole("alert")).toContainText("Not collected"); + ready=true;await page.getByRole("button",{name:"Try again",exact:true}).click();await expect(page.locator(".stage-heading h1")).toContainText("browser-fixture"); + const before=api.requests.length;await page.clock.fastForward(61000);expect(api.requests.length).toBe(before); + await page.reload();await expect(page.locator(".stage-heading h1")).toContainText("browser-fixture"); + expect(queries).toEqual(["?mode=cached","?mode=cached","?mode=cached"]);expect(api.requests.some(r=>r.method==="POST"||r.path.includes("blueprint")||r.path.includes("budget"))).toBe(false); +}); +test("SYNTHETIC unknown exploration mode does not silently enter legacy collection",async({page,api})=>{ + await page.goto(worldPath+"?mode=other");await expect(page.getByRole("alert")).toContainText("Invalid exploration mode");expect(api.requests).toEqual([]); +}); +test("SYNTHETIC actual local empty-store route rejects malformed intent and returns cache miss without collection",async({request})=>{ + test.skip(process.env.MOONSHOT_OFFLINE_FIXTURE !== "1", "Native empty-store test requires the explicit outbound-blocked fixture runtime."); + // APIRequestContext bypasses browser fixtures: this uses the owned keyless local + // runtime's empty synthetic store, never a public origin or real repository. + for(const query of ["mode=other","mode=cached&mode=cached","mode=cached&refresh=true"]){const response=await request.get(`${basePath}/api/world/synthetic-cache-test/not-collected?${query}`);expect(response.status()).toBe(400);expect((await response.json()).code).toBe("INVALID_REQUEST")} + const miss=await request.get(`${basePath}/api/world/synthetic-cache-test/not-collected?mode=cached`);expect(miss.status()).toBe(404);expect((await miss.json()).code).toBe("CACHE_MISS"); +}); diff --git a/src/client/api.ts b/src/client/api.ts index 703afff..ad5dbcf 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -8,15 +8,15 @@ export async function request(url: string, options?: RequestInit): Promise } catch { throw new Error("The server returned an unreadable response. Please try again."); } - if (!r.ok) throw new Error((result as { error?: string })?.error || `Request failed (${r.status}).`); + if (!r.ok) throw Object.assign(new Error((result as { error?: string })?.error || `Request failed (${r.status}).`), { status:r.status, code:(result as {code?:string})?.code }); return result as T; } export const getGallery = () => request<{ projects: FeaturedProject[] }>("/api/gallery"); export const createWorld = (repo: string) => request("/api/worlds", { method: "POST", body: JSON.stringify({ repo, enrich: false }) }); const pendingWorlds = new Map>(); -export function getWorld(owner: string, repo: string): Promise { - const url = `/api/world/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; +function worldRequest(owner: string, repo: string, cached: boolean): Promise { + const url = `/api/world/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}${cached ? "?mode=cached" : ""}`; const pending = pendingWorlds.get(url); if (pending) return pending; const requestWorld = request(url); @@ -27,6 +27,8 @@ export function getWorld(owner: string, repo: string): Promise { void requestWorld.then(clear, clear); return requestWorld; } +export const getWorld = (owner:string,repo:string) => worldRequest(owner,repo,false); +export const getCachedWorld = (owner:string,repo:string) => worldRequest(owner,repo,true); export const refreshWorld = (owner: string, repo: string) => request(`/api/world/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { method: "POST", diff --git a/src/components/ExplorerPage.tsx b/src/components/ExplorerPage.tsx index 52ed2e5..081bc2e 100644 --- a/src/components/ExplorerPage.tsx +++ b/src/components/ExplorerPage.tsx @@ -1,5 +1,5 @@ import { appPath } from "../base-path"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Activity, ArrowLeft, @@ -35,7 +35,7 @@ import { import type { BudgetStatus, StyleId, WorldNode, WorldSnapshot } from "../contracts"; import { WorldCanvas } from "../world/WorldCanvas"; import { STYLES } from "../client/demo"; -import { clock, enrichWorld, getBudget, getWorld, refreshWorld, shortNumber } from "../client/api"; +import { clock, enrichWorld, getBudget, getCachedWorld, getWorld, refreshWorld, shortNumber } from "../client/api"; import { Brand, External } from "./shared"; /** Keep source text intact while offering natural identifier wrapping points. */ function identifierBreaks(value: string) { @@ -62,10 +62,14 @@ function ciSummary(w: WorldSnapshot) { export function ExplorerPage({ owner, repo, + readMode = "legacy", }: { owner: string; repo: string; + readMode?: "legacy" | "cached" | "invalid"; }) { + const loadGeneration = useRef(0); + const cachedOnly = readMode === "cached"; const [world, setWorld] = useState(null), [error, setError] = useState(null), [refreshing, setRefreshing] = useState(false), @@ -83,41 +87,39 @@ export function ExplorerPage({ }, []); const load = useCallback( async (refresh = false) => { + const generation = ++loadGeneration.current; if (refresh) setRefreshing(true); try { - const next = await (refresh ? refreshWorld(owner, repo) : getWorld(owner, repo)); + if (readMode === "invalid") throw new Error("Invalid exploration mode. Return to Atlas to choose a cached world."); + const next = await (cachedOnly ? getCachedWorld(owner, repo) : refresh ? refreshWorld(owner, repo) : getWorld(owner, repo)); + if (generation !== loadGeneration.current) return; setWorld(next); setError(null); } catch (e) { - setError(e instanceof Error ? e.message : "The repository could not be loaded."); + if (generation === loadGeneration.current) setError(e instanceof Error ? e.message : "The repository could not be loaded."); } finally { - setRefreshing(false); + if (generation === loadGeneration.current) setRefreshing(false); } }, - [owner, repo], + [owner, repo, readMode, cachedOnly], ); useEffect(() => { let active = true; - getWorld(owner, repo) - .then((w) => { - if (active) setWorld(w); - }) - .catch((e) => { - if (active) setError(e.message); - }); - getBudget() + void load(); + if (!cachedOnly && readMode !== "invalid") getBudget() .then((b) => { if (active) setBudget(b); }) .catch(() => {}); - const timer = setInterval(() => { + const timer = readMode === "legacy" ? setInterval(() => { if (active && document.visibilityState === "visible") void load(); - }, 60000); + }, 60000) : undefined; return () => { active = false; - clearInterval(timer); + ++loadGeneration.current; + if (timer) clearInterval(timer); }; - }, [owner, repo, load]); + }, [owner, repo, load, cachedOnly, readMode]); const changeStyle = (s: StyleId) => { setStyle(s); const url = new URL(window.location.href); @@ -149,7 +151,9 @@ export function ExplorerPage({ stale = world ? Date.now() > world.refreshAfter : false; const share = async () => { try { - await navigator.clipboard.writeText(window.location.href); + const shareUrl = new URL(window.location.href); + shareUrl.searchParams.delete("atlasReturn"); shareUrl.searchParams.delete("from"); + await navigator.clipboard.writeText(shareUrl.href); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { diff --git a/src/evidence/captured.ts b/src/evidence/captured.ts new file mode 100644 index 0000000..59964c1 --- /dev/null +++ b/src/evidence/captured.ts @@ -0,0 +1,42 @@ +import type { WorldNode, WorldSnapshot } from "../contracts"; +import { codePointCompare, digest, snapshotKey, type EvidenceRef, type WorldIdentity } from "./contracts"; +const fullName = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,38}\/[a-zA-Z0-9_.-]{1,100}$/; +export function worldIdentity(world: WorldSnapshot, now = Date.now()): WorldIdentity | null { + if (!world || world.schemaVersion !== 1 || !["github", "demo"].includes(world.source) || !world.repo || !fullName.test(world.repo.fullName) || + world.repo.url !== `https://github.com/${world.repo.fullName}` || !/^[a-f0-9]{40}$/i.test(world.headSha) || + !Number.isFinite(Date.parse(world.collectedAt)) || Date.parse(world.collectedAt) > now || !Number.isFinite(world.refreshAfter)) return null; + return { repoKey: world.repo.fullName.toLowerCase(), headSha: world.headSha.toLowerCase(), collectedAt: world.collectedAt, source: world.source }; +} +export function cachedWorldReady(world: WorldSnapshot, now = Date.now()) { + return worldIdentity(world, now)?.source === "github" && ["full", "truncated"].includes(world.coverage?.tree) && + Array.isArray(world.nodes) && world.nodes.length > 0 && world.nodes.length <= 100 && Array.isArray(world.edges) && world.edges.length <= 256 && !!world.blueprint; +} +export function safeDirectory(path: string) { + return typeof path === "string" && path.length > 0 && path.length <= 512 && !/[\\\u0000-\u001f\u007f%?#]/.test(path) && + path.split("/").every(s => s !== "" && s !== "." && s !== ".."); +} +export function recordedNodeSource(world: WorldSnapshot, node: WorldNode): string | null { + if (!safeDirectory(node.path) || !node.id || node.id.startsWith("coverage:") || /[\u0000-\u001f]/.test(node.id) || !Array.isArray(node.evidence)) return null; + const expected = `${world.repo.url}/tree/${world.headSha}/${node.path.split("/").map(encodeURIComponent).join("/")}`; + return node.evidence.includes(expected) ? expected : null; +} +const legacyTiming = "The original per-endpoint upstream fetch time was not retained; this is the capture time."; +export async function capturedEvidence(world: WorldSnapshot, now = Date.now()): Promise<{ identity: WorldIdentity; evidence: EvidenceRef[]; history: { availability: "unavailable"; timeRange: null } } | null> { + const identity = worldIdentity(world, now); if (!identity || !cachedWorldReady(world, now)) return null; + const evidence: EvidenceRef[] = []; + const add = async (kind: EvidenceRef["kind"], subject: EvidenceRef["subject"], urls: string[], claimCode: string, claim: string, confidence: EvidenceRef["confidence"], limitations: string[], eventTime: EvidenceRef["eventTime"] = null) => { + const sourceUrls = [...urls].sort(codePointCompare); + evidence.push({ id: await digest([snapshotKey(identity), kind, Object.entries(subject).sort(([a], [b]) => codePointCompare(a,b)), claimCode, sourceUrls]), worldIdentity: identity, kind, sourceUrls, subject, claim, confidence, limitations: [legacyTiming, ...limitations], eventTime, fetchedAt: identity.collectedAt }); + }; + await add("capture", { sha: identity.headSha }, [world.repo.url], "captured-identity", "Recorded repository identity, head and capture time.", "observed", ["Capture time is not an event time. Current GitHub state is unknown."]); + for (const node of [...world.nodes].sort((a,b) => codePointCompare(a.path,b.path) || codePointCompare(a.id,b.id))) { + const url = recordedNodeSource(world,node); if (!url) continue; + await add("node-link", { nodeId: node.id }, [url], "captured-directory-link", "The map records a directory source at the captured head.", "observed", ["The district role is inferred; runtime behavior and entrypoints are unknown."]); + } + for (const edge of [...world.edges].sort((a,b)=>codePointCompare(a.id,b.id)).slice(0,50)) await add("edge-record", { edgeId: edge.id }, [], "captured-edge-annotation", "Recorded map annotation; independent edge provenance is unavailable.", edge.confidence, ["Endpoint node links do not prove this relationship or runtime traffic."]); + for (const c of [...(world.commits || [])].sort((a,b) => codePointCompare(a.id,b.id)).slice(0,20)) { + if (!/^[a-f0-9]{40}$/i.test(c.id) || c.url !== `${world.repo.url}/commit/${c.id}` || !Number.isFinite(Date.parse(c.date)) || typeof c.message !== "string" || !c.message.trim()) continue; + await add("commit-marker", { sha: c.id.toLowerCase() }, [c.url], "captured-commit-marker", "Recorded commit identity and author timestamp.", "observed", ["Merge, ancestry, mainline arrival and complete history are unknown."], { at:c.date,basis:"author" }); + } + return { identity, evidence, history:{availability:"unavailable",timeRange:null} }; +} diff --git a/src/evidence/contracts.ts b/src/evidence/contracts.ts new file mode 100644 index 0000000..1dcce6a --- /dev/null +++ b/src/evidence/contracts.ts @@ -0,0 +1,14 @@ +/** Captured source facts only. Player progress and camera never enter a world. */ +export interface WorldIdentity { repoKey: string; headSha: string; collectedAt: string; source: "github" | "demo" } +export interface EvidenceRef { + id: string; worldIdentity: WorldIdentity; + kind: "capture" | "node-link" | "edge-record" | "commit-marker" | "history-event" | "tree-frame"; + sourceUrls: string[]; subject: { nodeId?: string; edgeId?: string; sha?: string; eventId?: string }; + eventTime: null | { at: string; basis: "author" | "committer" | "created" | "closed" | "merged" }; + fetchedAt: string; claim: string; confidence: "observed" | "inferred" | "unknown"; limitations: string[]; +} +export const snapshotKey = (i: WorldIdentity) => JSON.stringify([i.repoKey, i.headSha, i.collectedAt, i.source]); +export const codePointCompare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0; +export async function digest(value: unknown) { + return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(value)))), n => n.toString(16).padStart(2, "0")).join(""); +} diff --git a/src/routes/api.world.$owner.$repo.ts b/src/routes/api.world.$owner.$repo.ts index c50f637..00db3d8 100644 --- a/src/routes/api.world.$owner.$repo.ts +++ b/src/routes/api.world.$owner.$repo.ts @@ -5,6 +5,14 @@ export const Route = createFileRoute("/api/world/$owner/$repo")({ GET: async ({ params, request }) => { const { json, apiError } = await import("../server/http"); try { + const { worldReadMode, readCachedWorld } = await import("../server/cached-world"); + const mode = worldReadMode(request.url); + if (mode === "cached") { + const { clientIdentity, accessConfig } = await import("../server/access"); + clientIdentity(request, accessConfig()); + const { getCachedWorld } = await import("../server/service"); + return json(await readCachedWorld(`${params.owner}/${params.repo}`, getCachedWorld)); + } const { accessRequest } = await import("../server/access"); const access = accessRequest(request); const { loadWorld } = await import("../server/service"); diff --git a/src/routes/world.$owner.$repo.tsx b/src/routes/world.$owner.$repo.tsx index 19f87bf..a160d5f 100644 --- a/src/routes/world.$owner.$repo.tsx +++ b/src/routes/world.$owner.$repo.tsx @@ -1,7 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; import { ExplorerPage } from "../components/ExplorerPage"; -export const Route = createFileRoute("/world/$owner/$repo")({ component: WorldPage }); +export const Route = createFileRoute("/world/$owner/$repo")({ validateSearch: (search: Record) => search, component: WorldPage }); function WorldPage() { const { owner, repo } = Route.useParams(); - return ; + const search = Route.useSearch(); + const readMode = search.mode === undefined ? "legacy" : search.mode === "cached" ? "cached" : "invalid"; + return ; } diff --git a/src/server/cached-world.ts b/src/server/cached-world.ts new file mode 100644 index 0000000..bcd73ab --- /dev/null +++ b/src/server/cached-world.ts @@ -0,0 +1,22 @@ +import type { WorldSnapshot } from "../contracts"; +import { cachedWorldReady } from "../evidence/captured"; +import { WorldStorageError } from "./world-storage"; +import { HttpError } from "./http"; +/** No ambiguous intent can downgrade cache-only navigation to collection. */ +export function worldReadMode(url: string): "cached" | "legacy" { + const query = new URL(url).searchParams; + if (!query.has("mode")) return "legacy"; + if (query.getAll("mode").length !== 1 || query.get("mode") !== "cached" || [...query.keys()].some(k => k !== "mode")) + throw new HttpError(400,"INVALID_REQUEST","Cached reads accept exactly one mode=cached parameter and no other query fields."); + return "cached"; +} +export async function readCachedWorld(input: string, read: (key:string) => Promise, now=Date.now()) { + let world:WorldSnapshot|null; + try { world = await read(input); } catch(error) { + if ((error as {status?:number}).status === 400) throw error; + throw new WorldStorageError(); + } + if (!world) throw new HttpError(404,"CACHE_MISS","Not collected. No cached world is available; no collection was requested."); + if (!cachedWorldReady(world,now)) throw new HttpError(503,"WORLD_STORAGE_UNAVAILABLE","The cached capture cannot be verified for exploration."); + return world; +} diff --git a/src/server/service.ts b/src/server/service.ts index c9fc928..1174e4b 100644 --- a/src/server/service.ts +++ b/src/server/service.ts @@ -55,7 +55,7 @@ export function createWorldService(options: { collector: Collector; store: Snaps }; const getCachedWorld = async (input: string): Promise => { const ref = parseRepo(input); - return cachedSnapshot(await options.store.get(ref.fullName, now())); + return cachedSnapshot(await options.store.get(ref.fullName, now(), { readOnly: true })); }; const loadWorld = async (input: string, settings?: { refresh?: boolean; admit?: () => void }): Promise => { const ref = parseRepo(input), @@ -145,8 +145,13 @@ let singleton: ReturnType | undefined; function service() { if (!singleton) { const config = getConfig(); + let collector: GitHubClient | undefined; singleton = createWorldService({ - collector: new GitHubClient(config.githubToken, new PublicGitHubQuota(join(config.dataDir, "public-quota.sqlite"), config.githubRequestsPerHour).wrap(fetch)), + // Cache reads must not initialize or consume the upstream quota store. + collector: { collect: (ref) => { + const client = collector ??= new GitHubClient(config.githubToken, new PublicGitHubQuota(join(config.dataDir, "public-quota.sqlite"), config.githubRequestsPerHour).wrap(fetch)); + return client.collect(ref); + } }, store: config.worldStore === "d1" ? new D1WorldStore({ endpoint: config.d1StorageUrl, secret: config.d1StorageSecret, databaseId: config.d1DatabaseId }) : new WorldStore(worldStorePath(config.dataDir)), }); } diff --git a/src/server/store.ts b/src/server/store.ts index 1806309..a85935b 100644 --- a/src/server/store.ts +++ b/src/server/store.ts @@ -4,7 +4,7 @@ import { dirname, join } from "node:path"; import type { WorldSnapshot } from "../contracts"; import { parseRepo } from "./github"; import { isRenderableWorld } from "./world-readiness"; -import type { CachedWorld, WorldSummary, WorldStorageStatus } from "./world-storage"; +import { WorldStorageError, type CachedWorld, type WorldSummary, type WorldStorageStatus } from "./world-storage"; export type { CachedWorld } from "./world-storage"; /** Independent SQLite file; a complete snapshot is replaced atomically in a single statement. */ @@ -18,7 +18,7 @@ export class WorldStore { ); this.db.exec("CREATE TABLE IF NOT EXISTS world_aliases (alias TEXT PRIMARY KEY, canonical TEXT NOT NULL)"); } - get(repo: string, now = Date.now()): CachedWorld | null { + get(repo: string, now = Date.now(), options?: { readOnly?: boolean }): CachedWorld | null { const key = repo.toLowerCase(); const alias = this.db.query("SELECT canonical FROM world_aliases WHERE alias = ?").get(key) as { canonical: string } | null; const canonical = alias?.canonical || key; @@ -40,6 +40,7 @@ export class WorldStore { throw new Error("Invalid cached snapshot"); return { world, expiresAt: row.expires_at, fresh: row.expires_at > now }; } catch { + if (options?.readOnly) throw new WorldStorageError(); this.delete(repo); return null; } diff --git a/src/server/world-storage.ts b/src/server/world-storage.ts index bf8a2e0..65b140c 100644 --- a/src/server/world-storage.ts +++ b/src/server/world-storage.ts @@ -5,7 +5,7 @@ export interface CachedWorld { world: WorldSnapshot; expiresAt: number; fresh: b export interface WorldSummary { key: string; repo: RepoIdentity; collectedAt: string; expiresAt: number; ready: boolean } export interface WorldStorageStatus { backend: "sqlite" | "d1"; authoritative: true; schemaVersion: number; databaseId?: string; worlds: number; aliases: number } export interface SnapshotStore { - get(repo: string, now?: number): Awaitable; + get(repo: string, now?: number, options?: { readOnly?: boolean }): Awaitable; summaries?(repos: string[], now?: number): Awaitable; put(world: WorldSnapshot, ttlMs?: number, now?: number): Awaitable; alias?(input: string, canonical: string): Awaitable; diff --git a/test/cached-exploration.test.ts b/test/cached-exploration.test.ts new file mode 100644 index 0000000..832d04c --- /dev/null +++ b/test/cached-exploration.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import { worldReadMode, readCachedWorld } from "../src/server/cached-world"; +import { createWorldService } from "../src/server/service"; +import { capturedEvidence, cachedWorldReady, worldIdentity } from "../src/evidence/captured"; +import { snapshotKey } from "../src/evidence/contracts"; +import { getCachedWorld, getWorld } from "../src/client/api"; +import { syntheticWorld } from "./fixtures/browser-world"; +import { WorldStore } from "../src/server/store"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +const captured = () => { const w=syntheticWorld();w.source="github";w.nodes[0].evidence=[`${w.repo.url}/tree/${w.headSha}/${w.nodes[0].path}`];return w; }; +test("SYNTHETIC cache intent rejects duplicates/unknown/conflicts before reading anything", () => { + for(const query of ["mode=unknown","mode=cached&mode=cached","mode=cached&refresh=true","mode=cached&enrich=false","mode=cached&x=1"]) + expect(()=>worldReadMode("https://example.test/api/world/a/b?"+query)).toThrow("exactly one"); + expect(worldReadMode("https://example.test/api/world/a/b?mode=cached")).toBe("cached"); + expect(worldReadMode("https://example.test/api/world/a/b")).toBe("legacy"); +}); +test("SYNTHETIC cache miss cannot join concurrent collection and never writes aliases",async()=>{ + let collects=0,writes=0;let reject!:(e:Error)=>void; + const pending=new Promise((_,r)=>reject=r); + const svc=createWorldService({store:{get:()=>null,put:()=>{writes++},alias:()=>{writes++}},collector:{collect:()=>{collects++;return pending}}}); + const legacy=svc.loadWorld("synthetic/repo").catch(()=>{});await Promise.resolve(); + await expect(readCachedWorld("synthetic/repo",svc.getCachedWorld)).rejects.toMatchObject({status:404,code:"CACHE_MISS"}); + expect(collects).toBe(1);expect(writes).toBe(0);reject(new Error("SYNTHETIC collector stopped"));await legacy; +}); +test("SYNTHETIC cache hit keeps stale capture and storage failure cannot become miss/collection",async()=>{ + const w=captured();const svc=createWorldService({store:{get:()=>({world:w,expiresAt:1,fresh:false}),put:()=>{throw Error("write forbidden")}},collector:{collect:async()=>{throw Error("collector forbidden")}}}); + const hit=await readCachedWorld(w.repo.fullName,svc.getCachedWorld);expect(hit.headSha).toBe(w.headSha);expect(hit.collectedAt).toBe(w.collectedAt);expect(hit.refreshAfter).toBe(w.refreshAfter); + await expect(readCachedWorld(w.repo.fullName,async()=>{throw Error("SYNTHETIC storage failed")})).rejects.toMatchObject({status:503,code:"WORLD_STORAGE_UNAVAILABLE"}); + for(const mutate of [(x:any)=>delete x.coverage,(x:any)=>x.refreshAfter=NaN,(x:any)=>x.source="demo"]){const invalid=structuredClone(w);mutate(invalid);expect(cachedWorldReady(invalid)).toBe(false);await expect(readCachedWorld(w.repo.fullName,async()=>invalid)).rejects.toMatchObject({status:503,code:"WORLD_STORAGE_UNAVAILABLE"});} +}); +test("SYNTHETIC readonly invalid cache never deletes or repairs the stored row",()=>{ + const dir=mkdtempSync(join(tmpdir(),"factory-cache-readonly-")),path=join(dir,"worlds.sqlite"),store=new WorldStore(path),db=new Database(path); + try{db.query("INSERT INTO worlds VALUES(?,?,?,?)").run("synthetic/repo","corrupt",1,0);expect(()=>store.get("synthetic/repo",2,{readOnly:true})).toThrow();expect((db.query("SELECT COUNT(*) n FROM worlds").get()as any).n).toBe(1);}finally{db.close();store.close();rmSync(dir,{recursive:true,force:true})} +}); +test("SYNTHETIC client cache-only pending URL is isolated from legacy collecting GET",async()=>{ + const original=globalThis.fetch;const calls:string[]=[];let release!:(r:Response)=>void; + globalThis.fetch=(async(url:any)=>{calls.push(String(url));return String(url).includes("mode=cached")?Response.json({error:"Not collected",code:"CACHE_MISS"},{status:404}):new Promise(r=>release=r)})as typeof fetch; + try{const legacy=getWorld("synthetic","isolation");const cache=getCachedWorld("synthetic","isolation");expect(cache).not.toBe(legacy);expect(getWorld("synthetic","isolation")).toBe(legacy);await expect(cache).rejects.toMatchObject({code:"CACHE_MISS"});expect(calls.length).toBe(2);release(Response.json(captured()));await legacy;}finally{globalThis.fetch=original} +}); +test("SYNTHETIC evidence is deterministic, narrow and never reconstructs missing source or merge proof",async()=>{ + const w=captured();w.nodes[0].label="真实源码 @scope/monorepo";const first=(await capturedEvidence(w))!;const reversed=structuredClone(w);reversed.nodes.reverse();reversed.edges.reverse(); + expect((await capturedEvidence(reversed))!.evidence).toEqual(first.evidence);expect(first.identity).toEqual(worldIdentity(w)!);expect(snapshotKey(first.identity)).toBe(JSON.stringify([w.repo.fullName,w.headSha,w.collectedAt,"github"])); + expect(first.history).toEqual({availability:"unavailable",timeRange:null});expect(first.evidence.filter(e=>e.kind==="node-link")).toHaveLength(1);expect(first.evidence.find(e=>e.kind==="edge-record")!.sourceUrls).toEqual([]); + w.nodes[0].evidence=[];expect((await capturedEvidence(w))!.evidence.some(e=>e.kind==="node-link")).toBe(false); + const commit=w.commits[0];commit.id="f".repeat(40);commit.url=`${w.repo.url}/commit/${commit.id}`;commit.message="Merge #123 合并 — source text only"; + const marker=(await capturedEvidence(w))!.evidence.find(e=>e.kind==="commit-marker")!;expect(marker.subject.sha).toBe(commit.id);expect(marker.eventTime?.basis).toBe("author");expect(marker.limitations.join(" ")).toContain("Merge"); +}); diff --git a/test/fixtures/offline-runtime.ts b/test/fixtures/offline-runtime.ts new file mode 100644 index 0000000..0ca27e0 --- /dev/null +++ b/test/fixtures/offline-runtime.ts @@ -0,0 +1,4 @@ +/** Explicit CI/local synthetic runtime only. Browser-route fixtures cannot + * intercept a server collector regression, so block every outbound fetch too. */ +if (process.env.MOONSHOT_OFFLINE_FIXTURE !== "1") throw new Error("Explicit offline fixture flag required"); +globalThis.fetch = (async () => { throw new Error("SYNTHETIC offline runtime blocked outbound network"); }) as unknown as typeof fetch; From 9f7d6e9e3a08948050eef8021d2113c14c5e3dc3 Mon Sep 17 00:00:00 2001 From: ubuntu03 Date: Wed, 4 Nov 2026 19:59:38 -0800 Subject: [PATCH 02/48] Reject ambiguous deployment chronology using exact UTC instants --- docs/DEPLOYMENT.md | 2 +- scripts/deployment-chronology.ts | 33 ++ scripts/live-worker-authority.ts | 15 +- test/deployment-chronology.test.ts | 27 ++ test/fixtures/deployment-chronology.json | 518 +++++++++++++++++++++++ test/fixtures/retirement-program.ts | 10 +- 6 files changed, 589 insertions(+), 16 deletions(-) create mode 100644 scripts/deployment-chronology.ts create mode 100644 test/deployment-chronology.test.ts create mode 100644 test/fixtures/deployment-chronology.json diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index a7b04bf..1f0fc6b 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -79,7 +79,7 @@ The async adapter coalesces up to32concurrent world keys; gallery summaries use The first restart needs a retirement fence because the legacy9b app has no HOLD/activity counters. Deploy the reviewed edge admission fence before stopping anything: world GET/HEAD, create/refresh/AI and unknown API admissions return503; cheap GET/HEAD gallery/preview/health/budget can continue initially. Before any connector interruption, independently read Cloudflare management evidence for the physical database binding, one 100% active deployment/version, the actual deployed module bytes, and all fence/origin/company/source/epoch settings. The private drain config must pin `workerAuthority.deploymentId`, `versionId` and `moduleSha256`; authenticated RPC status alone cannot authorize interruption. Then stop **only the dedicated product connector**, after checking its exact unit/token/tunnel identity and single-product ingress. Verify that Cloudflare reports no tunnel connections and the retiring Bun PID has no accepted input sockets. This closes previously accepted slow request bodies and prevents old origin requests from reentering after the fence. Keep the retiring Bun alive. -The reviewed `scripts/drain-retiring-runtime.ts` waits at least240seconds after input closure (60second source collection + bounded model fetch/body up to90seconds + settlement/transport margin). It checks the same PID/start time/boot ID, original ledger device/inode and protected old PID, then requires30continuous seconds with no active/unknown process sockets and no public `ai_public_hourly` reservations or new public admission. IPv4/IPv6 TCP/UDP/raw and UNIX sockets are classified; only the exact inherited journald UNIX stream can be ignored. A sampling gap above5seconds resets the quiet window. Legacy reservations are recorded separately and never cancelled/settled. Unknown or remaining work fails closed. The ledger authority comes from a separately pinned original handoff receipt, never from the current environment: absolute path, realpath, device and inode must all agree in retirement, selection, opening and rollback gates. A current environment pointing to a copied ledger cannot establish its own authority. Repeat the independent management checks throughout draining, at the final retirement boundary and immediately before restart. Each observation brackets the actual module read with current deployment/settings readbacks; unknown or changed traffic, bindings, version or module aborts. A final fresh receipt check immediately precedes the restart. No new-process zero counter or instant zero reservation is accepted as retirement proof. +The reviewed `scripts/drain-retiring-runtime.ts` waits at least240seconds after input closure (60second source collection + bounded model fetch/body up to90seconds + settlement/transport margin). It checks the same PID/start time/boot ID, original ledger device/inode and protected old PID, then requires30continuous seconds with no active/unknown process sockets and no public `ai_public_hourly` reservations or new public admission. IPv4/IPv6 TCP/UDP/raw and UNIX sockets are classified; only the exact inherited journald UNIX stream can be ignored. A sampling gap above5seconds resets the quiet window. Legacy reservations are recorded separately and never cancelled/settled. Unknown or remaining work fails closed. The ledger authority comes from a separately pinned original handoff receipt, never from the current environment: absolute path, realpath, device and inode must all agree in retirement, selection, opening and rollback gates. A current environment pointing to a copied ledger cannot establish its own authority. Repeat the independent management checks throughout draining, at the final retirement boundary and immediately before restart. Deployment chronology is parsed as explicit-offset RFC3339 instants with nanosecond precision, never sorted as strings. Missing/invalid dates, unknown `-00:00` offsets, conflicting deployment IDs and equal instants (including different precision or equivalent offsets) reject the observation. The accepted operational range is 2000 onward through host time plus five minutes; fractions above nine digits, leap seconds and offsets beyond ±14:00 are unsupported and fail closed. Each observation brackets the actual module read with current deployment/settings readbacks; unknown or changed traffic, bindings, version or module aborts. A final fresh receipt check immediately precedes the restart. No new-process zero counter or instant zero reservation is accepted as retirement proof. Install the same tested artifact on the **new product service only**, initially using SQLite with HOLD already set. The connector can reopen under the still-active edge fence after exact candidate/held health is verified. The protected old service/port4176 and all existing ledger/admission files remain unchanged. This procedure deliberately has a maintenance window; record its actual start/end, response gaps and available read scope. diff --git a/scripts/deployment-chronology.ts b/scripts/deployment-chronology.ts new file mode 100644 index 0000000..16e5821 --- /dev/null +++ b/scripts/deployment-chronology.ts @@ -0,0 +1,33 @@ +/** Cloudflare deployment chronology, shared semantic contract with the private + * Python operations helper. RFC3339, explicit known offset, <=9 fraction digits; + * retain nanoseconds rather than truncating precision through Date.parse. + * Operational range: 2000-01-01 onward, at most five minutes ahead of this host. + * Unsupported leap seconds/unknown -00:00 offsets fail closed. */ +export function deploymentInstant(value: unknown, nowMs = Date.now()): bigint { + if (typeof value !== "string" || !Number.isSafeInteger(nowMs)) throw new Error("Invalid deployment chronology"); + const match = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]{1,9}))?(Z|[+-][0-9]{2}:[0-9]{2})$/.exec(value); + if (!match || match[0] !== value || match[8] === "-00:00") throw new Error("Invalid deployment chronology"); + const [year, month, day, hour, minute, second] = match.slice(1,7).map(Number); + const local = Date.UTC(year,month-1,day,hour,minute,second), date = new Date(local); + if (year < 2000 || date.getUTCFullYear() !== year || date.getUTCMonth() !== month-1 || date.getUTCDate() !== day || date.getUTCHours() !== hour || date.getUTCMinutes() !== minute || date.getUTCSeconds() !== second) throw new Error("Invalid deployment chronology"); + const zone = match[8], offsetHours = zone === "Z" ? 0 : Number(zone.slice(1,3)), offsetMinutes = zone === "Z" ? 0 : Number(zone.slice(4,6)); + if (offsetHours > 14 || offsetMinutes > 59 || offsetHours === 14 && offsetMinutes !== 0) throw new Error("Invalid deployment timezone"); + const offset = (offsetHours * 60 + offsetMinutes) * (zone[0] === "-" ? -1 : 1); + const instant = BigInt(local - offset * 60_000) * 1_000_000n + BigInt((match[7] || "").padEnd(9,"0")); + if (instant < 946684800000000000n || instant > BigInt(Math.trunc(nowMs) + 300_000) * 1_000_000n) throw new Error("Unreasonable deployment chronology"); + return instant; +} +const uuid = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9-]{36}$/.test(value); +export function selectCurrentWorkerDeployment(value: any, nowMs = Date.now()) { + if (!value || !Array.isArray(value.deployments) || !value.deployments.length || value.deployments.length > 1000) throw new Error("Current Worker deployment unavailable"); + const instants = new Set(), ids = new Set(); + const rows = value.deployments.map((row: any) => { + const instant = deploymentInstant(row?.created_on,nowMs); + if (!uuid(row?.id) || ids.has(row.id) || instants.has(instant)) throw new Error("Ambiguous or conflicting deployment chronology"); + ids.add(row.id); instants.add(instant); return { row, instant }; + }); + rows.sort((a: any,b: any) => a.instant > b.instant ? -1 : 1); + const active = rows[0].row; + if (!Array.isArray(active.versions) || active.versions.length !== 1 || active.versions[0]?.percentage !== 100 || !uuid(active.versions[0].version_id)) throw new Error("Worker must have a single known 100% version"); + return { deploymentId: active.id as string, versionId: active.versions[0].version_id as string }; +} diff --git a/scripts/live-worker-authority.ts b/scripts/live-worker-authority.ts index 4044934..77ea367 100644 --- a/scripts/live-worker-authority.ts +++ b/scripts/live-worker-authority.ts @@ -1,18 +1,9 @@ import { assertIdentity, stableJson, type StorageIdentity } from "../deploy/migration-contract"; import { requireWorkerBindingAuthority } from "./worker-binding-authority"; +import { selectCurrentWorkerDeployment } from "./deployment-chronology"; export interface WorkerDeploymentIdentity { deploymentId: string; versionId: string; moduleSha256: string } export interface WorkerManagementConfig { cloudflareAccountId: string; workerAuthority: WorkerDeploymentIdentity; storageIdentity: StorageIdentity } const uuid = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9-]{36}$/.test(value); -function currentDeployment(value: any) { - if (!value || !Array.isArray(value.deployments) || !value.deployments.length) throw new Error("Current Worker deployment unavailable"); - const rows = [...value.deployments]; - if (rows.some((row) => !Number.isFinite(Date.parse(row?.created_on)))) throw new Error("Worker deployment chronology unknown"); - rows.sort((a, b) => Date.parse(b.created_on) - Date.parse(a.created_on)); - if (rows.length > 1 && rows[0].created_on === rows[1].created_on && rows[0].id !== rows[1].id) throw new Error("Ambiguous current Worker deployment"); - const active = rows[0]; - if (!uuid(active.id) || !Array.isArray(active.versions) || active.versions.length !== 1 || active.versions[0]?.percentage !== 100 || !uuid(active.versions[0].version_id)) throw new Error("Worker must have a single known 100% version"); - return { deploymentId: active.id, versionId: active.versions[0].version_id }; -} async function moduleHash(response: Response) { const type = response.headers.get("content-type") || "", body = Buffer.from(await response.arrayBuffer()); if (/^(application|text)\/javascript(?:\+module)?(?:\s*;|$)/i.test(type)) return new Bun.CryptoHasher("sha256").update(body).digest("hex"); @@ -48,12 +39,12 @@ export async function verifyLiveWorkerAuthority(config: WorkerManagementConfig, return response; }; const json = async (suffix: string) => { const body = await (await read(suffix)).json() as any; if (body?.success !== true) throw new Error("Worker management evidence rejected"); return body.result; }; - const first = currentDeployment(await json("/deployments")); + const first = selectCurrentWorkerDeployment(await json("/deployments")); if (first.deploymentId !== config.workerAuthority.deploymentId || first.versionId !== config.workerAuthority.versionId) throw new Error("Worker deployment/version changed"); const bindings = requireWorkerBindingAuthority(await json("/settings"), config.storageIdentity); const moduleSha256 = await moduleHash(await read("")); if (moduleSha256 !== config.workerAuthority.moduleSha256) throw new Error("Actual Worker module changed"); - const last = currentDeployment(await json("/deployments")); + const last = selectCurrentWorkerDeployment(await json("/deployments")); const lastBindings = requireWorkerBindingAuthority(await json("/settings"), config.storageIdentity); if (stableJson(first) !== stableJson(last) || stableJson(bindings) !== stableJson(lastBindings)) throw new Error("Worker changed during authority observation"); return { ...first, moduleSha256, bindings }; diff --git a/test/deployment-chronology.test.ts b/test/deployment-chronology.test.ts new file mode 100644 index 0000000..9ae9a2d --- /dev/null +++ b/test/deployment-chronology.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import table from "./fixtures/deployment-chronology.json"; +import { deploymentInstant, selectCurrentWorkerDeployment } from "../scripts/deployment-chronology"; +for(const row of table.cases){ + test(`SYNTHETIC UTC chronology shared fixture: ${row.name}`,()=>{ + for(const records of [row.records,[...row.records].reverse()]){ + if(row.expectedDeploymentId===null)expect(()=>selectCurrentWorkerDeployment({deployments:records},table.nowMs)).toThrow(); + else expect(selectCurrentWorkerDeployment({deployments:records},table.nowMs).deploymentId).toBe(row.expectedDeploymentId); + } + const run=Bun.spawnSync([process.execPath,"--no-env-file",new URL("./fixtures/retirement-program.ts",import.meta.url).pathname],{cwd:new URL("..",import.meta.url).pathname,env:{PATH:process.env.PATH,HOME:"/tmp",MOON_FACTORY_RETIREMENT_FIXTURE:"chronology:"+row.name},stdout:"pipe",stderr:"pipe",timeout:15000}); + expect(run.exitCode,run.stderr.toString()).toBe(0); + const result=JSON.parse(run.stdout.toString()); + expect(result.stopCalls).toBe(row.expectedDeploymentId?1:0); + expect(result.receipts).toBe(row.expectedDeploymentId?1:0); + }); +} +test("SYNTHETIC deployment chronology retains sub-millisecond precision and normalizes offsets",()=>{ + expect(deploymentInstant("2026-11-04T12:00:00.000000002Z",table.nowMs)-deploymentInstant("2026-11-04T12:00:00.000000001Z",table.nowMs)).toBe(1n); + expect(deploymentInstant("2026-11-04T12:00:00Z",table.nowMs)).toBe(deploymentInstant("2026-11-04T13:00:00.000+01:00",table.nowMs)); +}); + +test("SYNTHETIC nonfinite deployment values and host clocks fail closed",()=>{ + for(const value of [NaN,Infinity,-Infinity]) { + expect(()=>deploymentInstant(value,table.nowMs)).toThrow(); + expect(()=>deploymentInstant("2026-11-04T12:00:00Z",value)).toThrow(); + } +}); diff --git a/test/fixtures/deployment-chronology.json b/test/fixtures/deployment-chronology.json new file mode 100644 index 0000000..4e5f04c --- /dev/null +++ b/test/fixtures/deployment-chronology.json @@ -0,0 +1,518 @@ +{ + "schemaVersion": 1, + "nowMs": 1793836800000, + "policy": "Explicit known RFC3339 offset, <=9 fractional digits, UTC nanoseconds, 2000 onward and <=host now+5min. Every record must be valid; tied instants and duplicate deployment ids reject.", + "cases": [ + { + "name": "unordered-latest", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T11:00:00Z" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00Z" + } + ], + "expectedDeploymentId": "22222222-2222-4222-8222-222222222222" + }, + { + "name": "offset-latest", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00Z" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00-01:00" + } + ], + "expectedDeploymentId": "22222222-2222-4222-8222-222222222222" + }, + { + "name": "precision-tie", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00Z" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00.000Z" + } + ], + "expectedDeploymentId": null + }, + { + "name": "offset-tie", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00Z" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T13:00:00+01:00" + } + ], + "expectedDeploymentId": null + }, + { + "name": "nanosecond-precision-tie", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00.1234Z" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00.123400000Z" + } + ], + "expectedDeploymentId": null + }, + { + "name": "distinct-nanoseconds", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00.000000001Z" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00.000000002Z" + } + ], + "expectedDeploymentId": "22222222-2222-4222-8222-222222222222" + }, + { + "name": "duplicate-deployment-conflict", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T11:00:00Z" + }, + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00Z" + } + ], + "expectedDeploymentId": null + }, + { + "name": "missing", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ] + } + ], + "expectedDeploymentId": null + }, + { + "name": "null", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": null + } + ], + "expectedDeploymentId": null + }, + { + "name": "number", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": 42 + } + ], + "expectedDeploymentId": null + }, + { + "name": "nan", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "NaN" + } + ], + "expectedDeploymentId": null + }, + { + "name": "infinite", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "Infinity" + } + ], + "expectedDeploymentId": null + }, + { + "name": "no-timezone", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00" + } + ], + "expectedDeploymentId": null + }, + { + "name": "unknown-offset", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00-00:00" + } + ], + "expectedDeploymentId": null + }, + { + "name": "invalid-calendar", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-02-30T12:00:00Z" + } + ], + "expectedDeploymentId": null + }, + { + "name": "invalid-month", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-13-04T12:00:00Z" + } + ], + "expectedDeploymentId": null + }, + { + "name": "end-of-day", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T24:00:00Z" + } + ], + "expectedDeploymentId": null + }, + { + "name": "leap-second", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:60Z" + } + ], + "expectedDeploymentId": null + }, + { + "name": "invalid-offset", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00+24:00" + } + ], + "expectedDeploymentId": null + }, + { + "name": "beyond-14h-offset", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00+14:01" + } + ], + "expectedDeploymentId": null + }, + { + "name": "excess-precision", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00.1234567890Z" + } + ], + "expectedDeploymentId": null + }, + { + "name": "too-old", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "1999-12-31T23:59:59Z" + } + ], + "expectedDeploymentId": null + }, + { + "name": "unreasonable-future", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2030-11-04T12:00:00Z" + } + ], + "expectedDeploymentId": null + }, + { + "name": "older-invalid-cannot-be-ignored", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00Z" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-03T25:00:00Z" + } + ], + "expectedDeploymentId": null + }, + { + "name": "trailing-newline", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00Z\n" + } + ], + "expectedDeploymentId": null + }, + { + "name": "leading-space", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": " 2026-11-04T12:00:00Z" + } + ], + "expectedDeploymentId": null + }, + { + "name": "trailing-space", + "records": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "versions": [ + { + "version_id": "33333333-3333-4333-8333-333333333333", + "percentage": 100 + } + ], + "created_on": "2026-11-04T12:00:00Z " + } + ], + "expectedDeploymentId": null + } + ] +} diff --git a/test/fixtures/retirement-program.ts b/test/fixtures/retirement-program.ts index 266e38a..f67f6e4 100644 --- a/test/fixtures/retirement-program.ts +++ b/test/fixtures/retirement-program.ts @@ -5,9 +5,12 @@ import { mock } from "bun:test"; import { Database } from "bun:sqlite"; import * as filesystem from "node:fs"; import * as childProcess from "node:child_process"; +import chronology from "./deployment-chronology.json"; import { liveAuthorityFixture, syntheticModule } from "./worker-authority"; const kind = process.env.MOON_FACTORY_RETIREMENT_FIXTURE; if (!kind) throw new Error("Explicit SYNTHETIC subprocess required"); +const chronologyCase = kind.startsWith("chronology:") ? chronology.cases.find(c=>c.name===kind.slice(11)) : undefined; +if(kind.startsWith("chronology:")&&!chronologyCase)throw new Error("Unknown chronology fixture"); const fixture = liveAuthorityFixture({ kind: ["split", "physical-db"].includes(kind) ? kind : undefined }); const hash = (s: string) => new Bun.CryptoHasher("sha256").update(s).digest("hex"); const retiredRelease = "/home/sn0admin/.local/share/github-factory/releases/SYNTHETIC"; @@ -64,6 +67,7 @@ globalThis.fetch = (async (url: any, options: any) => { managementReads++; if (value.endsWith("/deployments")) { deploymentReads++; + if(chronologyCase)return Response.json({success:true,result:{deployments:chronologyCase.records}}); if (kind === "before-stop-version" && deploymentReads >= 3) { const data = fixture.deployment(); data.deployments[0].versions[0].version_id = "66666666-6666-4666-8666-666666666666"; return Response.json({ success: true, result: data }); } } if (value.endsWith("/github-factory-gateway") && (kind === "during-drain-module" && stopCalls || kind === "final-module" && readyObserved)) return new Response(syntheticModule + "// drift", { headers: { "content-type": "application/javascript" } }); @@ -78,11 +82,11 @@ let rejection = ""; try { const { drainRetiringRuntime } = await import("../../scripts/drain-retiring-runtime"); await drainRetiringRuntime(config); - if (kind !== "valid") throw new Error("Invalid retirement passed"); + if (kind !== "valid" && !chronologyCase?.expectedDeploymentId) throw new Error("Invalid retirement passed"); } catch (error) { - if (kind === "valid") throw error; + if (kind === "valid" || chronologyCase?.expectedDeploymentId) throw error; rejection = (error as Error).message; - const expected = kind === "split" ? /single known 100%/ : kind === "physical-db" ? /physical Worker binding/ : kind === "before-stop-version" ? /deployment\/version changed/ : /Actual Worker module changed/; + const expected = chronologyCase ? /chronology|timezone/ : kind === "split" ? /single known 100%/ : kind === "physical-db" ? /physical Worker binding/ : kind === "before-stop-version" ? /deployment\/version changed/ : /Actual Worker module changed/; if (!expected.test(rejection)) throw error; } filesystem.rmSync(fixtureDir, { recursive: true, force: true }); From 65f5577f41cc60cb3cd635aa55ccecf13b45e344 Mon Sep 17 00:00:00 2001 From: ubuntu03 Date: Wed, 4 Nov 2026 20:45:43 -0800 Subject: [PATCH 03/48] Add a cache-only Atlas with source cards and restorable world entry --- e2e/atlas.spec.ts | 192 +++++++++++++++++++++++++ src/atlas/AtlasCanvas.tsx | 242 ++++++++++++++++++++++++++++++++ src/atlas/AtlasPage.tsx | 175 +++++++++++++++++++++++ src/atlas/atlas.css | 71 ++++++++++ src/atlas/camera.ts | 39 +++++ src/atlas/loader.ts | 22 +++ src/atlas/model.ts | 99 +++++++++++++ src/atlas/paint.ts | 185 ++++++++++++++++++++++++ src/atlas/session.ts | 48 +++++++ src/client/api.ts | 2 +- src/components/ExplorerPage.tsx | 16 ++- src/components/LandingPage.tsx | 2 + src/progression/session.ts | 21 +++ src/routeTree.gen.ts | 21 +++ src/routes/atlas.tsx | 3 + src/world/WorldCanvas.tsx | 10 +- test/atlas.test.ts | 98 +++++++++++++ 17 files changed, 1241 insertions(+), 5 deletions(-) create mode 100644 e2e/atlas.spec.ts create mode 100644 src/atlas/AtlasCanvas.tsx create mode 100644 src/atlas/AtlasPage.tsx create mode 100644 src/atlas/atlas.css create mode 100644 src/atlas/camera.ts create mode 100644 src/atlas/loader.ts create mode 100644 src/atlas/model.ts create mode 100644 src/atlas/paint.ts create mode 100644 src/atlas/session.ts create mode 100644 src/progression/session.ts create mode 100644 src/routes/atlas.tsx create mode 100644 test/atlas.test.ts diff --git a/e2e/atlas.spec.ts b/e2e/atlas.spec.ts new file mode 100644 index 0000000..61df4d7 --- /dev/null +++ b/e2e/atlas.spec.ts @@ -0,0 +1,192 @@ +import { basePath, test, expect } from "./fixtures"; +import { syntheticWorld } from "../test/fixtures/browser-world"; +const atlasPath = basePath + "/atlas", keys = ["synthetic-atlas/alpha", "synthetic-atlas/beta", "synthetic-atlas/missing"]; +function captured(key: string, head: string) { let w = syntheticWorld(); const old = w.repo.url, oldHead = w.headSha; w = JSON.parse(JSON.stringify(w).replaceAll(old, `https://github.com/${key}`).replaceAll(oldHead, head)); const [owner, name] = key.split("/"); w.source = "github"; w.repo = { ...w.repo, owner, name, fullName: key, url: `https://github.com/${key}`, description: "SYNTHETIC 原始源码说明 · captured evidence only", license: "NOASSERTION" }; w.nodes[0].label = "SYNTHETIC 源码"; w.nodes[0].path = "packages/源码"; w.nodes[0].evidence = [`${w.repo.url}/tree/${head}/packages/${encodeURIComponent("源码")}`]; return w; } +async function fixture(page: any, api: any) { + const worlds = [captured(keys[0], "a".repeat(40)), captured(keys[1], "b".repeat(40))]; + api.respond("GET", "/api/gallery", () => ({ body: { projects: keys.map((key, i) => ({ fullName: key, name: `SYNTHETIC ${key.split("/")[1]}`, description: "SYNTHETIC catalog", category: "Editorial region", suggestedStyle: i === 1 ? "factorio" : "moonshot", ready: true, stars: null, language: null, collectedAt: null })) } })); + for (const [i, key] of keys.entries()) + api.respond("GET", "/api/world/" + key, (route: any) => { expect(new URL(route.request().url()).search).toBe("?mode=cached"); return i < 2 ? { body: worlds[i] } : { status: 404, body: { code: "CACHE_MISS", error: "Not collected. No collection requested." } }; }); + for(const key of keys) api.respond("GET","/api/gallery-preview/"+key,()=>({body:{state:"unknown",reason:"cache-miss",fullName:key,headSha:null,collectedAt:null,refreshAfter:null,github:"unknown",tree:"unavailable",nodes:[],edges:[],totalNodes:0,totalEdges:0,limited:false}})); + await page.addInitScript(() => { + const p = CanvasRenderingContext2D.prototype as any, fill = p.fillRect, text = p.fillText; + p.fillRect = function (...args: any[]) { if (this.canvas.parentElement?.classList.contains("atlas-map") && args[0] === 0 && args[1] === 0 && args[2] >= this.canvas.clientWidth - 1 && args[3] >= this.canvas.clientHeight - 1) { + this.canvas.__frames = (this.canvas.__frames ?? 0) + 1; + this.canvas.__labels = []; + } return fill.apply(this, args); }; + p.fillText = function (...args: any[]) { if (this.canvas.parentElement?.classList.contains("atlas-map")) + (this.canvas.__labels ??= []).push({ text: args[0], x: args[1], y: args[2] }); return text.apply(this, args); }; + }); + return worlds; +} +async function showList(page: any) { const button = page.getByRole("button", { name: "Repository list", exact: true }); if (await button.isVisible()) + await button.click(); } +function row(page: any, key: string) { return page.locator(".atlas-list-rows").getByRole("button", { name: new RegExp(key) }); } +test("SYNTHETIC Atlas two-repository cached round trips preserve identity/source/styles/view/focus without stars or POST", async ({ page, api }) => { + const worlds = await fixture(page, api); + await page.goto(atlasPath); + await expect(row(page, keys[0])).toContainText("Cached status not checked"); + expect(api.requests.filter((r: any) => r.path.includes("/api/world/"))).toEqual([]); + for (const [i, key] of keys.slice(0, 2).entries()) { + await row(page, key).click(); + const card = page.locator(".atlas-card"); + await expect(card.getByText("Discovered this session · No Exploration star awarded.", { exact: true })).toBeVisible(); + await expect(card.getByRole("link", { name: "GitHub source" })).toHaveAttribute("href", worlds[i].repo.url); + await expect(card).toContainText("Stale capture · current state unknown"); + await expect(card).toContainText("Historical trees unavailable"); + const cv = page.locator(".atlas-map > canvas"); + await cv.focus(); + await cv.press("="); + await cv.press("ArrowDown"); + const before = await page.evaluate(() => JSON.parse(sessionStorage.getItem("moonfactory.atlas.session.v1")!)); + await card.getByRole("link", { name: "Enter cached world" }).click(); + await expect(page).toHaveURL(new RegExp(`/world/${key}\\?`)); + await expect(page.locator(".explorer-visit-status")).toContainText("Visited this session"); + await expect(page.locator(".explorer-visit-status")).toContainText("No Exploration star"); + await page.locator(".district-list").getByRole("button", { name: /SYNTHETIC 源码/ }).click(); + const inspector = page.locator(".inspector"); + await expect(inspector.locator(".path-label")).toHaveText("packages/源码"); + await expect(inspector.getByRole("link", { name: "Inspect on GitHub" })).toHaveAttribute("href", `${worlds[i].repo.url}/tree/${worlds[i].headSha}/packages/${encodeURIComponent("源码")}`); + for (const name of ["Moon Factory", "Factorio", "Red Alert", "Red Alert 2", "Civilization"]) { + await page.getByRole("group", { name: "World perspective" }).getByRole("button", { name: new RegExp(name + "$") }).click(); + await expect(inspector.locator(".path-label")).toHaveText("packages/源码"); + } + await page.getByRole("link", { name: "Back to Atlas", exact: true }).click(); + await expect(page.locator(".atlas-card")).toContainText("Visited this session"); + await expect(row(page, key)).toBeFocused(); + const after = await page.evaluate(() => JSON.parse(sessionStorage.getItem("moonfactory.atlas.session.v1")!)); + expect(after.centerX).toBeCloseTo(before.centerX, 5); + expect(after.centerY).toBeCloseTo(before.centerY, 5); + expect(after.zoomRatio).toBeCloseTo(before.zoomRatio, 6); + } + expect(api.requests.filter((r: any) => r.method === "POST" || r.path.includes("blueprint") || r.path.includes("budget"))).toEqual([]); +}); +test("SYNTHETIC unchecked, real cache miss and 503 stay separate, with no implicit collection", async ({ page, api }) => { + await fixture(page, api); + api.respond("GET", "/api/world/" + keys[1], () => ({ status: 503, body: { code: "WORLD_STORAGE_UNAVAILABLE", error: "SYNTHETIC authoritative store unavailable" } })); + await page.goto(atlasPath); + await expect(row(page, keys[2])).toContainText("Cached status not checked"); + await row(page, keys[2]).click(); + await expect(page.locator(".atlas-card .atlas-status")).toHaveText("Not collected"); + await expect(page.getByRole("link", { name: "Enter cached world" })).toHaveCount(0); + await row(page, keys[1]).click(); + await expect(page.locator(".atlas-card .atlas-status")).toHaveText("Cached facts unavailable"); + await expect(page.locator(".atlas-card")).not.toContainText("Not collected"); + expect(api.requests.every((r: any) => r.method === "GET")).toBe(true); +}); +test("SYNTHETIC actual painted map label selects the same repository as its visible list and stays idle", async ({ page, api }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await fixture(page, api); + await page.goto(atlasPath); + await row(page, keys[0]).click(); + await expect(page.getByRole("link", { name: "Enter cached world" })).toBeVisible(); + await page.getByRole("button", { name: "Close selected landmark" }).click(); + const cv = page.locator(".atlas-map > canvas"); + await expect.poll(() => cv.evaluate((c: any, key: string) => c.__labels?.some((p: any) => p.text === key), keys[0])).toBe(true); + const label = await cv.evaluate((c: any, key: string) => c.__labels.find((p: any) => p.text === key), keys[0]), box = (await cv.boundingBox())!; + await page.mouse.click(box.x + label.x, box.y + label.y); + await expect(row(page, keys[0])).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator(".atlas-card")).toContainText("a".repeat(40)); + await page.waitForTimeout(150); + const before = await cv.evaluate((c: any) => c.__frames); + await page.waitForTimeout(200); + expect(await cv.evaluate((c: any) => c.__frames)).toBe(before); + const dimensions = await cv.evaluate((c: any) => ({ width: c.width, height: c.height, css: c.getBoundingClientRect().width })); + expect(dimensions.width / dimensions.css).toBeLessThanOrEqual(1.501); + expect(dimensions.width * dimensions.height).toBeLessThanOrEqual(4000000); +}); +for (const width of [320, 390, 768]) + test(`SYNTHETIC Atlas ${width}px cached identity/source/keyboard remain readable without horizontal overflow`, async ({ page, api }) => { + await page.setViewportSize({ width, height: 1000 }); + await fixture(page, api); + await page.goto(atlasPath); + await showList(page); + await row(page, keys[0]).focus(); + await page.keyboard.press("ArrowDown"); + await expect(row(page, keys[1])).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.locator(".atlas-card")).toContainText("b".repeat(40)); + await expect(page.locator(".atlas-card").getByRole("link", { name: "GitHub source" })).toHaveAttribute("href", "https://github.com/" + keys[1]); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + expect(await page.locator(".atlas-card").evaluate(e => e.scrollWidth <= e.clientWidth + 1)).toBe(true); + await page.getByRole("link", { name: "Enter cached world" }).click(); + await expect(page.locator(".explorer-visit-status")).toContainText("Visited this session"); + await page.getByRole("link", { name: "Back to Atlas", exact: true }).click(); + await expect(row(page, keys[1])).toBeFocused(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + }); +test("SYNTHETIC hidden Atlas map stops drawing, resumes one state-correct frame and never polls", async ({ page, api }) => { + await page.setViewportSize({ width: 390, height: 1000 }); + await fixture(page, api); + await page.goto(atlasPath); + const cv = page.locator(".atlas-map > canvas"); + await expect.poll(() => cv.evaluate((c: any) => c.__frames ?? 0)).toBeGreaterThan(0); + await showList(page); + await page.waitForTimeout(150); + const frames = await cv.evaluate((c: any) => c.__frames), requests = api.requests.length; + await page.waitForTimeout(250); + expect(await cv.evaluate((c: any) => c.__frames)).toBe(frames); + expect(api.requests.length).toBe(requests); + await page.getByRole("button", { name: "Map", exact: true }).click(); + await expect.poll(() => cv.evaluate((c: any) => c.__frames)).toBeGreaterThan(frames); +}); +test("SYNTHETIC real two-touch pinch and cancellation never become repository entry or collection", async ({ page, api, context }) => { + await page.setViewportSize({ width: 390, height: 1000 }); + await fixture(page, api); + await page.goto(atlasPath); + const cv = page.locator(".atlas-map > canvas"); + await expect.poll(() => cv.evaluate((c: any) => c.__frames ?? 0)).toBeGreaterThan(0); + const b = (await cv.boundingBox())!, cx = b.x + b.width / 2, cy = b.y + b.height / 2, cdp = await context.newCDPSession(page); + await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx - 30, y: cy, id: 1 }, { x: cx + 30, y: cy, id: 2 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx - 70, y: cy, id: 1 }, { x: cx + 70, y: cy, id: 2 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + expect(await page.evaluate(() => JSON.parse(sessionStorage.getItem("moonfactory.atlas.session.v1")!).zoomRatio)).toBeGreaterThan(1); + expect(api.requests.filter((r: any) => r.path.includes("/api/world/"))).toEqual([]); + await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx, y: cy, id: 1 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx + 35, y: cy + 35, id: 1 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchCancel", touchPoints: [] }); + await cv.focus(); + await cv.press("0"); + await showList(page); + await row(page, keys[0]).click(); + await expect(page.getByRole("link", { name: "Enter cached world" })).toBeVisible(); + expect(api.requests.every((r: any) => r.method === "GET")).toBe(true); +}); + +test("SYNTHETIC Atlas light/dark chrome computes readable contrast and mobile entry stays visible", async ({ page, api }, testInfo) => { + await fixture(page, api); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(basePath + "/"); + await expect(page.getByRole("link", { name: "Explore Atlas", exact: true }).filter({ visible: true })).toBeVisible(); + await page.getByRole("link", { name: "Explore Atlas", exact: true }).filter({ visible: true }).click(); + await showList(page); + await row(page, keys[0]).click(); + await expect(page.getByRole("link", { name: "Enter cached world" })).toBeVisible(); + const audit = await page.evaluate(() => { + const rgb = (value: string) => { const a = value.match(/[\d.]+/g)?.map(Number) ?? []; return [a[0] ?? 0, a[1] ?? 0, a[2] ?? 0, a[3] ?? 1]; }; + const over = (a: number[], b: number[]) => { const alpha = a[3] + b[3] * (1 - a[3]); return [0, 1, 2].map(i => (a[i] * a[3] + b[i] * b[3] * (1 - a[3])) / (alpha || 1)).concat(alpha); }; + const lum = (a: number[]) => a.slice(0, 3).map(n => { n /= 255; return n <= .04045 ? n / 12.92 : ((n + .055) / 1.055) ** 2.4; }).reduce((n, v, i) => n + v * [.2126, .7152, .0722][i], 0); + const failures: unknown[] = [], samples: unknown[] = []; + for (const el of document.querySelectorAll(".atlas-shell *")) { + const c = getComputedStyle(el), r = el.getBoundingClientRect(); + if (!r.width || !r.height || c.visibility === "hidden" || el.closest(".sr-only")) continue; + const text = Array.from(el.childNodes).filter(n => n.nodeType === 3).map(n => n.textContent).join("").trim(); + if (!text) continue; + const ancestors: Element[] = []; let p: Element | null = el; + while (p) { ancestors.unshift(p); p = p.parentElement; } + let bg = [255, 255, 255, 1], opacity = 1; + for (const ancestor of ancestors) { const style = getComputedStyle(ancestor); bg = over(rgb(style.backgroundColor), bg); opacity *= Number(style.opacity); } + const foreground = rgb(c.color); foreground[3] *= opacity; + const a = lum(over(foreground, bg)), b = lum(bg), ratio = (Math.max(a, b) + .05) / (Math.min(a, b) + .05); + const sample = { tag: el.tagName, text: text.slice(0, 100), ratio, size: c.fontSize }; + samples.push(sample); + if (ratio < (parseFloat(c.fontSize) >= 24 ? 3 : 4.5) || parseFloat(c.fontSize) < 12) failures.push(sample); + } + return { samples, failures, width: innerWidth, documentWidth: document.documentElement.scrollWidth }; + }); + expect(audit.failures).toEqual([]); + expect(audit.samples.length).toBeGreaterThan(15); + expect(audit.documentWidth).toBe(audit.width); + await testInfo.attach("computed-contrast-synthetic.json", { body: JSON.stringify(audit, null, 2), contentType: "application/json" }); + await page.screenshot({ path: testInfo.outputPath("atlas-390-synthetic.png"), fullPage: true }); +}); diff --git a/src/atlas/AtlasCanvas.tsx b/src/atlas/AtlasCanvas.tsx new file mode 100644 index 0000000..b1af237 --- /dev/null +++ b/src/atlas/AtlasCanvas.tsx @@ -0,0 +1,242 @@ +import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { constrain, fit, pan, resizeCamera, wheelFactor, zoomAt, type Camera, type Point, type Size } from "./camera"; +import { homeBounds, type CacheStatus, type Landmark } from "./model"; +import { contains, paintAtlas, type HitTarget } from "./paint"; +interface Props { + landmarks: Landmark[]; + statuses: Map; + selected: string | null; + initialView?: { + centerX: number; + centerY: number; + zoomRatio: number; + }; + locateRequest?: { + key: string; + revision: number; + } | null; + onChoose: (keys: string[], origin: "canvas") => void; + onClear: () => void; + onView: (camera: Camera, homeZoom: number, commit: boolean) => void; +} +export function AtlasCanvas(props: Props) { + const canvas = useRef(null), wrap = useRef(null), data = useRef(props); + data.current = props; + const view = useRef<{ + camera: Camera; + size: Size; + initialized: boolean; + }>({ camera: { centerX: 1400, centerY: 1000, zoom: .2 }, size: { width: 1, height: 1 }, initialized: false }); + const targets = useRef([]), invalidate = useRef(() => { }), commitView = useRef<(c: Camera, commit?: boolean) => void>(() => { }), cancelGesture = useRef<(silent?: boolean) => void>(() => { }); + const pointers = useRef(new Map()), gesture = useRef<{ + mode: "drag" | "pinch"; + camera: Camera; + point: Point; + distance: number; + moved: boolean; + at: number; + touch: boolean; + } | null>(null), suppressTap = useRef(false); + const [unavailable, setUnavailable] = useState(false), [tooltip, setTooltip] = useState<{ + text: string; + x: number; + y: number; + key: string; + } | null>(null); + const hoverTimer = useRef>(), hoverKey = useRef(null); + const clearHover = () => { if (hoverTimer.current) + clearTimeout(hoverTimer.current); hoverTimer.current = undefined; hoverKey.current = null; setTooltip(null); invalidate.current(); }; + const change = (c: Camera, commit = false) => commitView.current(c, commit); + const home = () => change(fit(homeBounds(data.current.landmarks), view.current.size), true); + const locate = (key: string | null) => { const p = data.current.landmarks.find(x => x.repoKey === key); if (p) + change({ ...view.current.camera, centerX: p.x, centerY: p.y }, true); }; + useEffect(() => { + const cv = canvas.current, el = wrap.current; + if (!cv || !el) + return; + let raf = 0, visible = true, disposed = false, lastPaint = -Infinity; + const context = cv.getContext("2d"); + if (!context) { + setUnavailable(true); + return; + } + const eligible = () => !disposed && visible && !document.hidden && el.clientWidth > 0 && el.clientHeight > 0; + const request = () => { if (!raf && eligible()) + raf = requestAnimationFrame(draw); }; + const draw = (now: number) => { + raf = 0; + if (!eligible()) + return; + if (now - lastPaint < 1000 / 30) { + request(); + return; + } + lastPaint = now; + const { size, camera } = view.current, ratio = cv.width / size.width; + context.setTransform(ratio, 0, 0, ratio, 0, 0); + targets.current = paintAtlas(context, data.current.landmarks, camera, size, data.current.statuses, data.current.selected, hoverKey.current); + }; + invalidate.current = request; + commitView.current = (camera, commit = false) => { view.current.camera = constrain(camera, view.current.size); data.current.onView(view.current.camera, fit(homeBounds(data.current.landmarks), view.current.size).zoom, commit); request(); }; + const resize = () => { + const rect = el.getBoundingClientRect(), size = { width: rect.width, height: rect.height }; + if (size.width < 1 || size.height < 1) + return; + const old = view.current, home = homeBounds(data.current.landmarks), homeCamera = fit(home, size), saved = data.current.initialView; + const camera = old.initialized ? resizeCamera(old.camera, old.size, size, home) : saved ? constrain({ centerX: saved.centerX, centerY: saved.centerY, zoom: homeCamera.zoom * saved.zoomRatio }, size) : constrain(homeCamera, size); + const pixels = size.width < 600 ? 1500000 : 4000000, ratio = Math.min(1.5, window.devicePixelRatio || 1, Math.sqrt(pixels / (size.width * size.height))); + cv.width = Math.max(1, Math.floor(size.width * ratio)); + cv.height = Math.max(1, Math.floor(size.height * ratio)); + view.current = { camera, size, initialized: true }; + data.current.onView(camera, homeCamera.zoom, false); + request(); + }; + const observer = new ResizeObserver(resize); + observer.observe(el); + resize(); + const stop = () => { + if (raf) cancelAnimationFrame(raf); + raf = 0; + cancelGesture.current(disposed); + }; + const intersection = new IntersectionObserver(entries => { visible = entries[0]?.isIntersecting ?? false; if (visible) + request(); + else + stop(); }); + intersection.observe(el); + const visibility = () => { if (document.hidden) + stop(); + else + request(); }; + document.addEventListener("visibilitychange", visibility); + const blur = () => cancelGesture.current(); + window.addEventListener("blur", blur); + const wheel = (e: WheelEvent) => { if (e.ctrlKey || e.metaKey) + return; e.preventDefault(); clearHover(); const r = cv.getBoundingClientRect(); change(zoomAt(view.current.camera, wheelFactor(e.deltaY, e.deltaMode, r.height), { x: e.clientX - r.left, y: e.clientY - r.top }, view.current.size), true); }; + cv.addEventListener("wheel", wheel, { passive: false }); + return () => { disposed = true; stop(); observer.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", visibility); window.removeEventListener("blur", blur); cv.removeEventListener("wheel", wheel); if (hoverTimer.current) + clearTimeout(hoverTimer.current); cv.width = 0; cv.height = 0; invalidate.current = () => { }; commitView.current = () => { }; }; + }, []); + useEffect(() => { invalidate.current(); }, [props.landmarks, props.statuses, props.selected]); + useEffect(() => { if (props.locateRequest) + locate(props.locateRequest.key); }, [props.locateRequest, props.landmarks]); + const point = (e: ReactPointerEvent) => { const r = e.currentTarget.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }; + const hits = (p: Point) => targets.current.filter(t => t.rects.some(r => contains(r, p.x, p.y))); + const begin = (e: ReactPointerEvent) => { + if (e.button !== 0 || pointers.current.size >= 2) + return; + clearHover(); + const p = point(e); + pointers.current.set(e.pointerId, p); + e.currentTarget.setPointerCapture(e.pointerId); + const all = [...pointers.current.values()]; + if (all.length === 2) { + suppressTap.current = true; + gesture.current = { mode: "pinch", camera: { ...view.current.camera }, point: { x: (all[0].x + all[1].x) / 2, y: (all[0].y + all[1].y) / 2 }, distance: Math.max(1, Math.hypot(all[0].x - all[1].x, all[0].y - all[1].y)), moved: true, at: performance.now(), touch: true }; + } + else + gesture.current = { mode: "drag", camera: { ...view.current.camera }, point: p, distance: 1, moved: false, at: performance.now(), touch: e.pointerType !== "mouse" }; + }; + const move = (e: ReactPointerEvent) => { + const p = point(e), g = gesture.current; + if (!pointers.current.has(e.pointerId) || !g) { + if (e.pointerType === "mouse" && !props.selected) { + if (hoverTimer.current) + clearTimeout(hoverTimer.current); + const hit = hits(p); + if (hit.length === 1 && hit[0].keys.length === 1) { + const key = hit[0].keys[0], landmark = props.landmarks.find(x => x.repoKey === key); + hoverTimer.current = setTimeout(() => { hoverKey.current = key; setTooltip({ key, text: landmark?.fullName ?? key, x: Math.max(8, Math.min(p.x + 12, view.current.size.width - 288)), y: Math.max(8, Math.min(p.y - 48, view.current.size.height - 60)) }); invalidate.current(); }, 120); + } + else + clearHover(); + } + return; + } + pointers.current.set(e.pointerId, p); + if (g.mode === "pinch" && pointers.current.size === 2) { + const [a, b] = [...pointers.current.values()], mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + const zoomed = zoomAt(g.camera, Math.hypot(a.x - b.x, a.y - b.y) / g.distance, g.point, view.current.size); + change(pan(zoomed, mid.x - g.point.x, mid.y - g.point.y, view.current.size)); + } + else { + const dx = p.x - g.point.x, dy = p.y - g.point.y; + if (Math.hypot(dx, dy) > (g.touch ? 10 : 6)) + g.moved = true; + if (g.moved) + change(pan(g.camera, dx, dy, view.current.size)); + } + }; + const end = (e: ReactPointerEvent) => { + if (!pointers.current.has(e.pointerId)) + return; + const g = gesture.current; + if (g?.mode === "drag" && !g.moved && !suppressTap.current && performance.now() - g.at <= 500) { + const matched = hits(point(e)); + const keys = [...new Set(matched.flatMap(t => t.keys))]; + if (keys.length) + props.onChoose(keys, "canvas"); + } + pointers.current.delete(e.pointerId); + if (e.currentTarget.hasPointerCapture(e.pointerId)) + e.currentTarget.releasePointerCapture(e.pointerId); + const remaining = [...pointers.current.values()]; + gesture.current = remaining.length ? { mode: "drag", camera: { ...view.current.camera }, point: remaining[0], distance: 1, moved: false, at: performance.now(), touch: true } : null; + if (!remaining.length) + suppressTap.current = false; + change(view.current.camera, true); + }; + cancelGesture.current = (silent = false) => { + const cv = canvas.current, ids = [...pointers.current.keys()]; + pointers.current.clear(); + gesture.current = null; + suppressTap.current = false; + for (const id of ids) if (cv?.hasPointerCapture(id)) cv.releasePointerCapture(id); + if (hoverTimer.current) clearTimeout(hoverTimer.current); + hoverKey.current = null; + if (!silent) setTooltip(null); + }; + return
+ {unavailable ?

Map unavailable. Use the repository list and source card.

: cancelGesture.current()} onLostPointerCapture={e => { if (pointers.current.has(e.pointerId)) + cancelGesture.current(); }} onPointerLeave={() => { if (!pointers.current.size) + clearHover(); }} onKeyDown={e => { + if (e.ctrlKey || e.metaKey || e.altKey) + return; + const c = view.current.camera, s = view.current.size, step = e.shiftKey ? 128 : 64; + const deltas: Record = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] }; + if (deltas[e.key]) { + e.preventDefault(); + change(pan(c, ...deltas[e.key], s), true); + } + else if (["+", "=", "-"].includes(e.key)) { + e.preventDefault(); + change(zoomAt(c, e.key === "-" ? 1 / 1.2 : 1.2, { x: s.width / 2, y: s.height / 2 }, s), true); + } + else if (e.key === "0") { + e.preventDefault(); + home(); + } + else if (e.key.toLowerCase() === "f") { + e.preventDefault(); + locate(props.selected); + } + else if (e.key === "Escape") { + if (tooltip) + clearHover(); + else + props.onClear(); + } + }}/>} + {tooltip && !props.selected &&
{tooltip.text}
} +
+ + + + +
+

Scenic routes, not dependency links · Static scenery

+
; +} diff --git a/src/atlas/AtlasPage.tsx b/src/atlas/AtlasPage.tsx new file mode 100644 index 0000000..e3b7f09 --- /dev/null +++ b/src/atlas/AtlasPage.tsx @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "@tanstack/react-router"; +import type { FeaturedProject } from "../contracts"; +import { getCachedWorld, getGallery } from "../client/api"; +import { Brand, External } from "../components/shared"; +import { factsFromWorld, layoutLandmarks, statusText, type CacheStatus } from "./model"; +import { AtlasCanvas } from "./AtlasCanvas"; +import { readAtlasSession, saveAtlasSession, type AtlasSession } from "./session"; +import { selectionLoader } from "./loader"; +import type { Camera } from "./camera"; +import { explorationFlags, noteExploration, subscribeExploration } from "../progression/session"; +import "./atlas.css"; +export function AtlasPage() { + const [catalog, setCatalog] = useState(null), [catalogError, setCatalogError] = useState(null), [selected, setSelected] = useState(null), [statuses, setStatuses] = useState(new Map()), [imports, setImports] = useState([]), [query, setQuery] = useState(""), [page, setPage] = useState(0), [pane, setPane] = useState<"map" | "list">("map"), [chooser, setChooser] = useState([]), [locateRequest, setLocateRequest] = useState<{ + key: string; + revision: number; + } | null>(null), [positionSaved, setPositionSaved] = useState(true), [initial, setInitial] = useState(null), [progressRevision, setProgressRevision] = useState(0), [focusedKey, setFocusedKey] = useState(null), [inspectInput, setInspectInput] = useState(""), [inspectError, setInspectError] = useState(null); + const session = useRef(null), view = useRef({ centerX: 1400, centerY: 1000, zoomRatio: 1 }), selectedRef = useRef(null), origin = useRef<"list" | "canvas">("list"), importRef = useRef([]), statusRef = useRef(statuses), list = useRef(null), pendingRestore = useRef(false), mounted = useRef(true); + const loader = useMemo(() => selectionLoader((key: string) => { const [owner, repo] = key.split("/"); return getCachedWorld(owner, repo); }), []); + const updateStatus = useCallback((key: string, value: CacheStatus) => { const next = new Map(statusRef.current); next.set(key, value); statusRef.current = next; setStatuses(next); }, []); + const save = useCallback((token?: string) => { + const record: AtlasSession = { layoutVersion: 1, ...view.current, selectedRepoKey: selectedRef.current, focusOrigin: origin.current, listScroll: list.current?.scrollTop ?? session.current?.listScroll ?? 0, importedRepoKeys: importRef.current, returnToken: token ?? session.current?.returnToken ?? null }; + session.current = record; + setPositionSaved(saveAtlasSession(record)); + }, []); + const loadFacts = useCallback((key: string) => { + const old = statusRef.current.get(key), lastReceived = old?.state === "cached" ? old.facts : old?.lastReceived; + updateStatus(key, { state: "loading", lastReceived }); + loader.select(key, w => { + const facts = factsFromWorld(w, key); + if (!facts) { + updateStatus(key, { state: "unavailable", lastReceived }); + return; + } + updateStatus(key, { state: "cached", facts }); + }, error => updateStatus(key, (error as { + code?: string; + })?.code === "CACHE_MISS" ? { state: "miss", lastReceived } : { state: "unavailable", lastReceived })); + }, [loader, updateStatus]); + const cancelRead = useCallback(() => { + loader.cancel(); + const key = selectedRef.current, state = key ? statusRef.current.get(key) : undefined; + if (key && state?.state === "loading") updateStatus(key, { state: "unchecked", lastReceived: state.lastReceived }); + }, [loader, updateStatus]); + const choose = useCallback((keys: string[], via: "list" | "canvas") => { + if (keys.length !== 1) { + cancelRead(); + setChooser(keys); + return; + } + const key = keys[0]; + const previous = selectedRef.current, prior = previous ? statusRef.current.get(previous) : undefined; + if (previous && previous !== key && prior?.state === "loading") + updateStatus(previous, { state: "unchecked", lastReceived: prior.lastReceived }); + selectedRef.current = key; + origin.current = via; + setSelected(key); + setChooser([]); + setInspectError(null); + if (via === "list") + setLocateRequest(x => ({ key, revision: (x?.revision ?? 0) + 1 })); + if (statusRef.current.get(key)?.state !== "cached") + loadFacts(key); + else + loader.cancel(); + }, [loader, loadFacts, updateStatus, cancelRead]); + useEffect(() => { + mounted.current = true; + const saved = readAtlasSession(); + setPositionSaved(saved.saved); + session.current = saved.value; + const params = new URLSearchParams(window.location.search), returnToken = params.get("atlasReturn"), canRestore = saved.value && params.get("home") !== "1" && (!params.has("atlasReturn") || params.getAll("atlasReturn").length === 1 && returnToken === saved.value.returnToken); + const restorable = canRestore ? saved.value : null; + if (saved.value) { + importRef.current = saved.value.importedRepoKeys; + setImports(importRef.current); + } + if (restorable) { + view.current = { centerX: restorable.centerX, centerY: restorable.centerY, zoomRatio: restorable.zoomRatio }; + selectedRef.current = restorable.selectedRepoKey; + origin.current = restorable.focusOrigin; + setInitial(restorable); + setSelected(restorable.selectedRepoKey); + if (window.innerWidth < 600 && restorable.focusOrigin === "list") + setPane("list"); + pendingRestore.current = true; + } + void getGallery().then(({ projects }) => { if (mounted.current) + setCatalog(projects.slice(0, 2000)); }).catch(() => { if (mounted.current) { + setCatalog([]); + setCatalogError("Catalog unavailable. Cached status remains unknown."); + } }); + const onPageHide = () => save(); + window.addEventListener("pagehide", onPageHide); + const unsubscribe = subscribeExploration(() => setProgressRevision(x => x + 1)); + return () => { mounted.current = false; loader.cancel(); window.removeEventListener("pagehide", onPageHide); unsubscribe(); }; + }, [loader, save]); + const landmarks = useMemo(() => layoutLandmarks(catalog ?? [], imports), [catalog, imports]); + useEffect(() => { if (catalog && pendingRestore.current) { + pendingRestore.current = false; + if (selectedRef.current) { + loadFacts(selectedRef.current); + const index = landmarks.findIndex(x => x.repoKey === selectedRef.current); + if (index >= 0) + setPage(Math.floor(index / 50)); + } + requestAnimationFrame(() => { if (list.current && session.current) + list.current.scrollTop = session.current.listScroll; const key = selectedRef.current; if (key) { + const target = window.innerWidth < 600 && origin.current === "canvas" ? document.querySelector(".atlas-map canvas") : document.getElementById("atlas-repo-" + key); + (target ?? document.getElementById("atlas-filter"))?.focus({ preventScroll: true }); + } }); + } }, [catalog, landmarks, loadFacts]); + const current = selected ? statuses.get(selected) : undefined, facts = current?.state === "cached" ? current.facts : undefined, lastReceived = current && current.state !== "cached" ? current.lastReceived : undefined; + useEffect(() => { if (chooser.length === 0 && facts && selected === facts.identity.repoKey) + noteExploration(facts.identity, "discovered"); }, [facts, selected, chooser.length]); + const selectedLandmark = landmarks.find(x => x.repoKey === selected), filtered = landmarks.filter(x => (x.fullName + " " + x.title + " " + x.category).toLowerCase().includes(query.toLowerCase())), rows = filtered.slice(page * 50, page * 50 + 50), enterToken = useMemo(() => selected ? crypto.randomUUID() : null, [selected]); + const flags = selected ? explorationFlags(selected) : { discovered: false, visited: false }; + void progressRevision; + const inspect = () => { + try { + const url = new URL(inspectInput.trim()); + const parts = url.pathname.replace(/\/$/, "").split("/").slice(1); + if (url.protocol !== "https:" || url.hostname !== "github.com" || url.port || url.username || url.password || url.search || url.hash || parts.length !== 2 || !parts.every(p => /^[a-z0-9_.-]+$/i.test(p))) + throw Error(); + const key = parts.join("/").toLowerCase(); + if (!landmarks.some(p => p.repoKey === key) && imports.length >= 100) { + setInspectError("The 100-repository survey capacity is full. Existing positions are retained."); + return; + } + choose([key], "list"); + } + catch { + setInspectError("Enter a public HTTPS GitHub repository URL. This action only checks existing cached facts."); + } + }; + useEffect(() => { if (facts && selected && !landmarks.some(p => p.repoKey === selected) && importRef.current.length < 100) { + importRef.current = [...importRef.current, selected]; + setImports(importRef.current); + save(); + } }, [facts, selected, landmarks, save]); + return
+

Repository Atlas

Mission Control
+

Choose a landmark, inspect its captured identity, then enter its world. This atlas shows its catalog and your cached survey entries.

{catalog === null ? "Loading catalog" : `${landmarks.length} listed landmarks · ${[...statuses.values()].filter(s => s.state === "cached").length} captures checked this visit`} · No full GitHub map or complete history is claimed.

+ {catalogError &&

{catalogError}

} + {!positionSaved &&

View position is temporary. Browser session storage is unavailable; a reload may lose it.

} +
+
+
{ setQuery(e.target.value); setPage(0); }} placeholder="Repository or editorial region"/> +
{ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) + return; const buttons = Array.from(e.currentTarget.querySelectorAll("button")), index = buttons.indexOf(document.activeElement as HTMLButtonElement); if (index < 0) + return; e.preventDefault(); const next = e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : Math.max(0, Math.min(buttons.length - 1, index + (e.key === "ArrowDown" ? 1 : -1))); buttons[next]?.focus(); }}> + {rows.map((p, i) => )} + {catalog !== null && rows.length === 0 &&

No matching landmarks. Unlisted public repositories may already have cached worlds.

} +
+ {filtered.length > 50 &&
{page + 1} / {Math.ceil(filtered.length / 50)}
} +
{ e.preventDefault(); inspect(); }}> setInspectInput(e.target.value)} placeholder="https://github.com/owner/repo" required/>No collection, GitHub or model request.{inspectError &&

{inspectError}

}
+
+ {catalog !== null && !(initial?.selectedRepoKey === selected && selected && (!current || current.state === "loading")) ? { cancelRead(); setChooser([]); selectedRef.current = null; setSelected(null); save(); }} onView={(camera: Camera, homeZoom: number, commit: boolean) => { view.current = { centerX: camera.centerX, centerY: camera.centerY, zoomRatio: camera.zoom / homeZoom }; if (commit) + save(); }}/> :

Loading the bounded catalog…

} + +
+
Public cached source · Read only · Original scenery · Session observations are not saved progress
+
; +} diff --git a/src/atlas/atlas.css b/src/atlas/atlas.css new file mode 100644 index 0000000..25b2244 --- /dev/null +++ b/src/atlas/atlas.css @@ -0,0 +1,71 @@ +.atlas-shell { height:100dvh; min-height:600px; display:flex; flex-direction:column; color:var(--geist-fg); background:var(--geist-bg); font-size:14px; line-height:1.6; } +.atlas-header { display:flex; flex-wrap:wrap; gap:16px; align-items:center; min-height:64px; padding:10px 24px; background:var(--geist-surface); border-bottom:1px solid var(--geist-border); } +.atlas-header .brand { min-height:44px; } +.atlas-header h1 { font:500 24px/1.3 var(--heading); } +.atlas-header > a:last-child { margin-left:auto; color:var(--geist-interactive); text-decoration:underline; text-underline-offset:3px; min-height:44px; display:flex; align-items:center; } +.atlas-intro { display:flex; flex-wrap:wrap; justify-content:space-between; gap:4px 24px; padding:10px 24px; } +.atlas-intro p { color:var(--geist-secondary); max-width:750px; } +.atlas-intro p+p { font-size:12px; } +.atlas-warning { padding:8px 24px; color:var(--geist-warning-fg); background:var(--geist-warning-bg); overflow-wrap:anywhere; } +.atlas-body { display:grid; grid-template-columns:260px minmax(0,1fr) 320px; flex:1; min-height:0; } +.atlas-list { min-width:0; min-height:0; display:flex; flex-direction:column; background:var(--geist-surface); padding:16px; border-right:1px solid var(--geist-border); gap:10px; } +.atlas-list > label { font-size:12px; color:var(--geist-secondary); } +.atlas-shell input { width:100%; min-width:0; border:1px solid var(--geist-control-border); border-radius:4px; padding:10px; background:var(--geist-surface); color:var(--geist-fg); font-size:14px; } +.atlas-shell input::placeholder { color:var(--geist-muted); opacity:1; } +.atlas-list-rows { flex:1; overflow:auto; min-height:80px; scrollbar-width:thin; } +.atlas-list-rows > button { display:block; text-align:left; width:100%; min-height:70px; padding:12px 10px; margin-bottom:8px; border:1px solid var(--geist-control-border); border-radius:4px; } +.atlas-list-rows > button:hover { background:var(--geist-hover); } +.atlas-list-rows > button[aria-pressed=true] { background:var(--geist-selected); box-shadow:inset 3px 0 var(--geist-fg); } +.atlas-list-rows strong { display:block; font-size:13px; overflow-wrap:anywhere; } +.atlas-list-rows span,.atlas-list-rows small { display:block; color:var(--geist-secondary); font-size:12px; line-height:1.5; overflow-wrap:anywhere; } +.atlas-list-rows small { margin-top:4px; color:var(--geist-muted); } +.atlas-inspect { border-top:1px solid var(--geist-border); padding-top:12px; display:flex; flex-direction:column; gap:8px; } +.atlas-inspect label,.atlas-inspect small { font-size:12px; color:var(--geist-secondary); } +.atlas-inspect input { font-size:12px; } +.atlas-inspect button,.atlas-pagination button,.atlas-tabs button,.atlas-card > button { min-height:44px; padding:8px 12px; border:1px solid var(--geist-control-border); border-radius:4px; background:var(--geist-surface); } +.atlas-inspect button:hover,.atlas-card > button:hover { background:var(--geist-hover); } +.atlas-inspect [role=alert] { color:var(--geist-danger-fg); background:var(--geist-danger-bg); padding:8px; overflow-wrap:anywhere; } +.atlas-pagination { display:flex; gap:8px; align-items:center; justify-content:space-between; font-size:12px; } +.atlas-map { min-width:0; min-height:180px; position:relative; overflow:hidden; background:var(--geist-map-bg); } +.atlas-map > canvas { display:block; position:absolute; inset:0; width:100%; height:100%; touch-action:none; outline-offset:-4px; } +.atlas-map > canvas:focus-visible { outline:2px solid var(--geist-map-focus); } +.atlas-map-unavailable { color:var(--geist-map-fg); background:var(--geist-map-surface); position:absolute; top:16px; left:16px; right:80px; padding:12px; } +.atlas-camera { position:absolute; right:12px; top:12px; display:flex; flex-direction:column; gap:8px; } +.atlas-camera button { width:44px; height:44px; border:1px solid var(--geist-map-border); color:var(--geist-map-fg); background:var(--geist-map-surface); border-radius:4px; font-size:22px; } +.atlas-camera button:hover { background:var(--geist-primary-hover); } +.atlas-camera button:focus-visible { outline:2px solid var(--geist-map-focus); outline-offset:-3px; } +.atlas-camera button:disabled { background:var(--geist-map-surface); color:var(--geist-map-muted); opacity:1; } +.atlas-map-note { position:absolute; bottom:10px; left:12px; right:12px; width:max-content; max-width:calc(100% - 24px); color:var(--geist-map-fg); background:var(--geist-map-surface); font-size:12px; line-height:1.5; padding:4px 8px; pointer-events:none; } +.atlas-tooltip { position:absolute; max-width:280px; padding:8px 12px; font-size:13px; line-height:1.5; color:var(--geist-map-fg); background:var(--geist-map-surface); border:1px solid var(--geist-map-border); border-radius:4px; overflow-wrap:anywhere; pointer-events:none; } +.atlas-card { display:flex; flex-direction:column; min-width:0; min-height:0; overflow:hidden; background:var(--geist-surface); padding:20px; border-left:1px solid var(--geist-border); } +.atlas-card-content { min-height:0; flex:1; overflow:auto; scrollbar-width:thin; } +.atlas-actions { display:flex; gap:8px; flex-shrink:0; padding-top:12px; border-top:1px solid var(--geist-border); } +.atlas-actions > * { flex:1; } +.atlas-actions button { min-height:44px; padding:8px; border:1px solid var(--geist-control-border); border-radius:4px; background:var(--geist-surface); } +.atlas-card h2 { font:500 22px/1.3 var(--heading); overflow-wrap:anywhere; margin:8px 0 16px; } +.atlas-card h2 > span { display:block; } +.atlas-card p { color:var(--geist-secondary); margin-bottom:14px; overflow-wrap:anywhere; } +.atlas-card-top { display:flex; align-items:center; justify-content:space-between; gap:8px; font:12px/1.5 var(--mono); color:var(--geist-secondary); } +.atlas-card-top button { min-width:44px; min-height:44px; border:1px solid var(--geist-control-border); font-size:24px; border-radius:4px; } +.atlas-card .atlas-status { color:var(--geist-info-fg); background:var(--geist-info-bg); padding:8px; font-size:12px; } +.atlas-card .atlas-status[data-state="miss"],.atlas-card .atlas-status[data-state="unchecked"] { color:var(--geist-unknown-fg); background:var(--geist-unknown-bg); } +.atlas-card .atlas-status[data-state="unavailable"] { color:var(--geist-danger-fg); background:var(--geist-danger-bg); } +.atlas-card .atlas-status[data-state="cached"][data-freshness="stale"] { color:var(--geist-warning-fg); background:var(--geist-warning-bg); } +.atlas-last { font-size:12px; color:var(--geist-secondary); overflow-wrap:anywhere; } +.atlas-card dl { border-block:1px solid var(--geist-border); padding-block:12px; margin-bottom:16px; font-size:12px; } +.atlas-card dt { color:var(--geist-muted); margin-top:8px; } +.atlas-card dd { margin:0 0 12px; overflow-wrap:anywhere; color:var(--geist-fg); } +.atlas-card a:not(.atlas-enter) { display:inline-flex; align-items:center; min-height:44px; } +.atlas-card a { color:var(--geist-interactive); text-decoration:underline; text-underline-offset:3px; overflow-wrap:anywhere; } +.atlas-card .atlas-enter { display:flex; justify-content:center; align-items:center; gap:8px; color:var(--geist-primary-fg); background:var(--geist-primary-bg); min-height:44px; padding:12px; border-radius:4px; text-decoration:none; margin-top:0; } +.atlas-card .atlas-enter:hover { background:var(--geist-primary-hover); } +.atlas-card .atlas-observation { font-size:12px; margin-top:16px; } +.atlas-card > button { overflow-wrap:anywhere; max-width:100%; } +.atlas-card .atlas-retry { display:block; width:auto; margin-top:0; } +.atlas-footer { padding:8px 24px; color:var(--geist-secondary); border-top:1px solid var(--geist-border); font-size:12px; } +.atlas-tabs { display:none; } +.explorer-atlas-back { color:var(--geist-interactive); font-size:13px; text-decoration:underline; text-underline-offset:3px; min-height:44px; display:flex; align-items:center; } +.explorer-visit-status { font-size:12px; color:var(--geist-secondary); padding:8px 24px; background:var(--geist-bg); border-bottom:1px solid var(--geist-border); } +@media(max-width:1180px) and (min-width:1024px) { .atlas-body { grid-template-columns:220px minmax(0,1fr) 280px; } .atlas-list { padding:12px; } .atlas-card { padding:16px; } } +@media(max-width:1023px) { .atlas-body { grid-template-columns:220px minmax(0,1fr); grid-template-rows:minmax(180px,1fr) auto; } .atlas-list { grid-row:1/3; } .atlas-card { grid-column:2; max-height:35dvh; border-top:1px solid var(--geist-border); } .atlas-header { padding-inline:16px; } .atlas-intro { padding-inline:16px; } .atlas-header h1 { font-size:20px; } } +@media(max-width:600px) { .atlas-shell { min-height:680px; } .atlas-header { gap:8px 16px; } .atlas-header .brand strong { font-size:18px; } .atlas-header h1 { font-size:20px; } .atlas-header > a:last-child { margin-left:0; font-size:13px; } .atlas-intro { padding:8px 12px; } .atlas-intro p { font-size:13px; } .atlas-intro p+p { font-size:12px; } .atlas-tabs { display:flex; gap:8px; padding:8px 12px; } .atlas-tabs button { flex:1; } .atlas-tabs button[aria-pressed=true] { box-shadow:inset 0 -3px var(--geist-fg); background:var(--geist-selected); } .atlas-body { display:flex; flex-direction:column; min-height:360px; } .atlas-map { flex:1; min-height:180px; } .atlas-list { flex:1; min-height:180px; border:0; max-height:55dvh; } .atlas-list-rows { min-height:100px; } .atlas-pane-map .atlas-list { display:none; } .atlas-pane-list .atlas-map { display:none; } .atlas-card { flex:0 1 auto; max-height:35dvh; padding:14px 16px; border-left:0; } .atlas-card h2 { font-size:20px; } .atlas-card > p { font-size:14px; } .atlas-shell input,.atlas-inspect input { font-size:16px; } .atlas-footer { padding-inline:12px; } .explorer-visit-status { padding-inline:12px; } } diff --git a/src/atlas/camera.ts b/src/atlas/camera.ts new file mode 100644 index 0000000..a6a8bf7 --- /dev/null +++ b/src/atlas/camera.ts @@ -0,0 +1,39 @@ +export interface Point { + x: number; + y: number; +} +export interface Size { + width: number; + height: number; +} +export interface Bounds { + left: number; + top: number; + right: number; + bottom: number; +} +export interface Camera { + centerX: number; + centerY: number; + zoom: number; +} +export const SCENE: Bounds = { left: 0, top: 0, right: 8192, bottom: 6144 }; +const clamp = (n: number, a: number, b: number) => Math.min(b, Math.max(a, n)); +export function fit(bounds: Bounds, size: Size, padding = 24) { + const zoom = Math.min((Math.max(1, size.width) - Math.min(padding * 2, size.width / 2)) / Math.max(1, bounds.right - bounds.left), (Math.max(1, size.height) - Math.min(padding * 2, size.height / 2)) / Math.max(1, bounds.bottom - bounds.top)) * .88; + return { centerX: (bounds.left + bounds.right) / 2, centerY: (bounds.top + bounds.bottom) / 2, zoom: clamp(zoom, .025, 2.5) }; +} +export function constrain(camera: Camera, size: Size, bounds = SCENE): Camera { + const minimum = Math.max(.025, fit(bounds, size).zoom * .85), zoom = clamp(Number.isFinite(camera.zoom) ? camera.zoom : minimum, minimum, 2.5); + const axis = (n: number, start: number, end: number, span: number) => { const a = start + span / (2 * zoom), b = end - span / (2 * zoom); return a > b ? (start + end) / 2 : clamp(Number.isFinite(n) ? n : (start + end) / 2, a, b); }; + return { centerX: axis(camera.centerX, bounds.left, bounds.right, size.width), centerY: axis(camera.centerY, bounds.top, bounds.bottom, size.height), zoom }; +} +export const project = (p: Point, c: Camera, s: Size): Point => ({ x: s.width / 2 + (p.x - c.centerX) * c.zoom, y: s.height / 2 + (p.y - c.centerY) * c.zoom }); +export const unproject = (p: Point, c: Camera, s: Size): Point => ({ x: c.centerX + (p.x - s.width / 2) / c.zoom, y: c.centerY + (p.y - s.height / 2) / c.zoom }); +export function pan(c: Camera, dx: number, dy: number, s: Size) { return constrain({ ...c, centerX: c.centerX - dx / c.zoom, centerY: c.centerY - dy / c.zoom }, s); } +export function zoomAt(c: Camera, factor: number, point: Point, s: Size) { + const before = unproject(point, c, s), bounded = constrain({ ...c, zoom: c.zoom * factor }, s), after = unproject(point, bounded, s); + return constrain({ ...bounded, centerX: bounded.centerX + before.x - after.x, centerY: bounded.centerY + before.y - after.y }, s); +} +export function resizeCamera(c: Camera, old: Size, next: Size, home: Bounds) { return constrain({ ...c, zoom: fit(home, next).zoom * c.zoom / fit(home, old).zoom }, next); } +export const wheelFactor = (delta: number, mode: number, height: number) => Math.exp(-clamp(delta * (mode === 1 ? 16 : mode === 2 ? height : 1), -240, 240) * .0015); diff --git a/src/atlas/loader.ts b/src/atlas/loader.ts new file mode 100644 index 0000000..db28a20 --- /dev/null +++ b/src/atlas/loader.ts @@ -0,0 +1,22 @@ +/** At most two bounded cache reads and one replaceable latest selection. No + * polling, retry clock or request from hover/paint. Late results cannot win. */ +export function selectionLoader(read: (key: string) => Promise) { + let generation = 0, active = 0, pending: { + key: string; + generation: number; + accept: (value: T) => void; + reject: (error: unknown) => void; + } | null = null; + const start = (task: NonNullable) => { active++; void read(task.key).then(value => { if (task.generation === generation) + task.accept(value); }, error => { if (task.generation === generation) + task.reject(error); }).finally(() => { active--; if (pending) { + const next = pending; + pending = null; + if (next.generation === generation) + start(next); + } }); }; + return { select(key: string, accept: (value: T) => void, reject: (error: unknown) => void) { const task = { key, generation: ++generation, accept, reject }; if (active < 2) + start(task); + else + pending = task; }, cancel() { generation++; pending = null; } }; +} diff --git a/src/atlas/model.ts b/src/atlas/model.ts new file mode 100644 index 0000000..b64845f --- /dev/null +++ b/src/atlas/model.ts @@ -0,0 +1,99 @@ +import { CURATED_PROJECTS } from "../catalog"; +import type { FeaturedProject, StyleId, WorldSnapshot } from "../contracts"; +import { cachedWorldReady, worldIdentity } from "../evidence/captured"; +import { codePointCompare, type WorldIdentity } from "../evidence/contracts"; +import { project, type Bounds, type Camera, type Size } from "./camera"; +export interface Landmark { + repoKey: string; + fullName: string; + title: string; + category: string; + style: StyleId; + x: number; + y: number; +} +export interface CachedFacts { + identity: WorldIdentity; + fullName: string; + description: string; + sourceUrl: string; + tree: string; + github: string; + nodes: number; + edges: number; + refreshAfter: number; + license: string | null; +} +export type CacheStatus = { + state: "unchecked" | "loading" | "miss" | "unavailable"; + lastReceived?: CachedFacts; +} | { + state: "cached"; + facts: CachedFacts; +}; +export function factsFromWorld(w: WorldSnapshot, expectedKey?: string, now = Date.now()): CachedFacts | null { + const identity = worldIdentity(w, now); + if (!identity || !cachedWorldReady(w, now) || expectedKey && identity.repoKey !== expectedKey) + return null; + return { identity, fullName: w.repo.fullName, description: w.repo.description, sourceUrl: w.repo.url, tree: w.coverage.tree, github: w.health.github, nodes: w.nodes.length, edges: w.edges.length, refreshAfter: w.refreshAfter, license: w.repo.license }; +} +export function statusText(status?: CacheStatus) { return !status || status.state === "unchecked" ? "Cached status not checked" : status.state === "loading" ? "Loading cached facts" : status.state === "miss" ? "Not collected" : status.state === "unavailable" ? "Cached facts unavailable" : "Cached capture"; } +export function hash(value: string) { let h = 2166136261; for (const ch of value) + h = Math.imul(h ^ ch.codePointAt(0)!, 16777619); return h >>> 0; } +// Immutable v1 editorial slots. Response order, captures and GitHub stars never +// determine placement. A catalog slot change requires a new layout version. +const slots = new Map(CURATED_PROJECTS.map((p, i) => [p.fullName.toLowerCase(), i])); +const home = [[650, 650], [1400, 750], [950, 1400], [1850, 1400], [2150, 500]]; +export function layoutLandmarks(projects: readonly FeaturedProject[], imports: readonly string[]): Landmark[] { + const byKey = new Map(); + for (const p of projects) + if (/^[a-z0-9-]+\/[a-z0-9_.-]+$/i.test(p.fullName)) + byKey.set(p.fullName.toLowerCase(), p); + for (const key of imports.slice(0, 100)) + if (!byKey.has(key)) + byKey.set(key, { fullName: key, name: key.split("/")[1], description: "", category: "Survey region", suggestedStyle: "moonshot", language: null, stars: null, ready: false, collectedAt: null }); + return [...byKey].map(([key, p]) => { + const slot = slots.get(key), importSlot = imports.indexOf(key), h = hash(key); + const point = slot !== undefined ? slot < 5 ? home[slot] : [3200 + (slot - 5) % 5 * 850, 650 + Math.floor((slot - 5) / 5) * 1350] : importSlot >= 0 ? [500 + (importSlot % 10) * 760, 4700 + Math.floor(importSlot / 10) * 115] : [500 + h % 7200, 500 + (hash(key + ":y") % 5000)]; + return { repoKey: key, fullName: p.fullName, title: p.name, category: p.category, style: p.suggestedStyle, x: point[0], y: point[1] }; + }).sort((a, b) => a.y - b.y || codePointCompare(a.repoKey, b.repoKey)); +} +export function homeBounds(points: Landmark[]): Bounds { + const known = points.filter(p => (slots.get(p.repoKey) ?? 1000) < 5); + const group = known.length ? known : points.slice(0, 5); + return group.length ? { left: Math.min(...group.map(p => p.x)) - 160, top: Math.min(...group.map(p => p.y)) - 160, right: Math.max(...group.map(p => p.x)) + 160, bottom: Math.max(...group.map(p => p.y)) + 160 } : { left: 500, top: 400, right: 2200, bottom: 1800 }; +} +export interface ProjectedLandmark { + x: number; + y: number; + keys: string[]; + landmark?: Landmark; +} +export function visibleGroups(points: Landmark[], camera: Camera, size: Size, selected: string | null): ProjectedLandmark[] { + const visible = points.map(p => ({ ...project(p, camera, size), landmark: p })).filter(p => p.x >= -128 && p.x <= size.width + 128 && p.y >= -128 && p.y <= size.height + 128); + const cap = size.width < 600 ? 40 : 80, clusterCap = size.width < 600 ? 16 : 32; + let cell = 2 ** Math.ceil(Math.log2((size.width < 600 ? 72 : 64) / camera.zoom)); + for (let level = 0; level < 12; level++, cell *= 2) { + const bins = new Map(); + const singles: ProjectedLandmark[] = []; + for (const p of visible) { + if (p.landmark.repoKey === selected) { + singles.push({ ...p, keys: [selected] }); + continue; + } + const key = `${Math.floor(p.landmark.x / cell)}:${Math.floor(p.landmark.y / cell)}`; + const bin = bins.get(key) ?? []; + bin.push(p); + bins.set(key, bin); + } + const clusters: ProjectedLandmark[] = []; + for (const bin of bins.values()) + if (bin.length >= 3) + clusters.push({ x: bin.reduce((n, p) => n + p.x, 0) / bin.length, y: bin.reduce((n, p) => n + p.y, 0) / bin.length, keys: bin.map(p => p.landmark.repoKey).sort(codePointCompare) }); + else + singles.push(...bin.map(p => ({ ...p, keys: [p.landmark.repoKey] }))); + if (singles.length <= cap && clusters.length <= clusterCap || level === 11) + return [...clusters, ...singles].sort((a, b) => a.y - b.y || codePointCompare(a.keys[0], b.keys[0])); + } + return []; +} diff --git a/src/atlas/paint.ts b/src/atlas/paint.ts new file mode 100644 index 0000000..0289fca --- /dev/null +++ b/src/atlas/paint.ts @@ -0,0 +1,185 @@ +import { MAP_CONTRAST as C } from "../world/paint"; +import { hash, visibleGroups, type CacheStatus, type Landmark } from "./model"; +import type { Camera, Size } from "./camera"; +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} +export interface HitTarget { + keys: string[]; + rects: Rect[]; + x: number; + y: number; +} +export const contains = (r: Rect, x: number, y: number) => x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height; +const overlaps = (a: Rect, b: Rect) => a.x < b.x + b.width + 8 && a.x + a.width + 8 > b.x && a.y < b.y + b.height + 8 && a.y + a.height + 8 > b.y; +const polygon = (g: CanvasRenderingContext2D, points: number[][], fill: string, stroke?: string) => { g.beginPath(); points.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y)); g.closePath(); g.fillStyle = fill; g.fill(); if (stroke) { + g.strokeStyle = stroke; + g.lineWidth = 1.5; + g.stroke(); +} }; +function sculpture(g: CanvasRenderingContext2D, x: number, y: number, size: number, variant: number) { + g.save(); + g.translate(x, y); + g.scale(size / 64, size / 64); + g.fillStyle = C.surface; + g.beginPath(); + g.ellipse(0, 14, 40, 16, 0, 0, Math.PI * 2); + g.fill(); + polygon(g, [[-35, 3], [0, -8], [35, 3], [35, 14], [0, 25], [-35, 14]], C.border, C.edge); + polygon(g, [[-35, 3], [0, -8], [35, 3], [0, 14]], C.muted, C.edge); + if (variant === 0) { + polygon(g, [[-23, 0], [0, -9], [23, 0], [23, -13], [0, -22], [-23, -13]], C.surface, C.edge); + g.beginPath(); + g.ellipse(0, -17, 17, 14, 0, Math.PI, 0); + g.lineTo(17, -13); + g.lineTo(-17, -13); + g.closePath(); + g.fillStyle = C.muted; + g.fill(); + g.strokeStyle = C.edge; + g.stroke(); + } + else if (variant === 1) { + for (const [xx, hh] of [[-18, 34], [15, 45]]) { + polygon(g, [[xx - 7, 6], [xx + 7, 1], [xx + 7, -hh], [xx - 7, -hh + 5]], C.surface, C.edge); + polygon(g, [[xx - 10, -hh + 4], [xx, -hh - 2], [xx + 10, -hh - 6], [xx + 2, -hh]], C.muted, C.edge); + } + g.strokeStyle = C.edge; + g.lineWidth = 5; + g.beginPath(); + g.arc(-1, -10, 17, Math.PI, 0); + g.stroke(); + } + else + for (let i = 0; i < 3; i++) { + g.beginPath(); + g.ellipse(0, -i * 9, 28 - i * 6, 11 - i * 2, 0, 0, Math.PI * 2); + g.fillStyle = i % 2 ? C.surface : C.muted; + g.fill(); + g.strokeStyle = C.edge; + g.stroke(); + } + g.restore(); +} +/** Original vector scenery. No textures, tile cache, physics or cross-repo graph. */ +export function paintAtlas(g: CanvasRenderingContext2D, points: Landmark[], camera: Camera, size: Size, statuses: Map, selected: string | null, hover: string | null) { + g.fillStyle = C.bg; + g.fillRect(0, 0, size.width, size.height); + g.save(); + g.translate(size.width / 2 - camera.centerX * camera.zoom, size.height / 2 - camera.centerY * camera.zoom); + g.scale(camera.zoom, camera.zoom); + for (let i = 0; i < 14; i++) { + const x = 170 + (i % 5) * 1720, y = 340 + Math.floor(i / 5) * 1830, skew = (i % 3) * 140; + polygon(g, [[x, y], [x + 1100, y - 180 - skew], [x + 1510, y + 340], [x + 1050, y + 640], [x - 120, y + 470]], C.surface); + g.beginPath(); + g.moveTo(x - 120, y + 470); + g.bezierCurveTo(x + 350, y + 680, x + 1100, y + 570, x + 1510, y + 340); + g.strokeStyle = C.border; + g.globalAlpha = .35; + g.lineWidth = 2 / camera.zoom; + g.stroke(); + g.globalAlpha = 1; + } + g.beginPath(); + g.moveTo(550, 1200); + g.bezierCurveTo(2000, 2200, 2350, 400, 4100, 1200); + g.bezierCurveTo(6000, 2300, 3700, 3700, 6700, 4900); + g.setLineDash([9 / camera.zoom, 12 / camera.zoom]); + g.strokeStyle = C.border; + g.lineWidth = 1.5 / camera.zoom; + g.stroke(); + g.setLineDash([]); + g.restore(); + const groups = visibleGroups(points, camera, size, selected), targets: HitTarget[] = [], labels: { + group: typeof groups[number]; + rect: Rect; + text: string; + }[] = [], accepted: Rect[] = []; + const maxLabels = size.width < 600 ? 10 : 24; + const byPriority = [...groups].sort((a, b) => Number(b.keys.includes(selected ?? "")) - Number(a.keys.includes(selected ?? "")) || Number(b.keys.includes(hover ?? "")) - Number(a.keys.includes(hover ?? ""))); + for (const group of groups) { + const active = group.keys.length === 1 && group.keys[0] === selected, cached = group.landmark && statuses.get(group.landmark.repoKey)?.state === "cached", extent = camera.zoom < .12 ? 36 : camera.zoom > .6 ? 72 : 54; + const { x, y } = group; + if (group.keys.length > 1) { + g.fillStyle = C.surface; + g.strokeStyle = C.border; + g.lineWidth = 2; + g.beginPath(); + g.roundRect(x - 27, y - 26, 54, 52, 8); + g.fill(); + g.stroke(); + g.fillStyle = C.text; + g.font = '600 14px ui-monospace,monospace'; + g.textAlign = "center"; + g.textBaseline = "middle"; + g.fillText(String(group.keys.length), x, y); + } + else if (cached) + sculpture(g, x, y, extent, hash(group.keys[0]) % 3); + else { + g.strokeStyle = C.border; + g.lineWidth = 2; + g.setLineDash([5, 4]); + g.beginPath(); + g.ellipse(x, y, extent * .5, extent * .32, 0, 0, Math.PI * 2); + g.stroke(); + g.setLineDash([]); + g.fillStyle = C.surface; + g.fillRect(x - 10, y - 14, 20, 25); + g.fillStyle = C.text; + g.font = '500 18px ui-monospace,monospace'; + g.textBaseline = "middle"; + g.textAlign = "center"; + g.fillText("?", x, y + 5); + } + if (active || group.keys.includes(hover ?? "")) { + g.beginPath(); + g.ellipse(x, y + 8, extent * .67, extent * .42, 0, 0, Math.PI * 2); + g.strokeStyle = C.surface; + g.lineWidth = 6; + g.stroke(); + g.strokeStyle = active ? C.focus : C.text; + g.lineWidth = 2; + g.stroke(); + } + targets.push({ keys: group.keys, x, y, rects: [{ x: x - Math.max(22, extent * .65), y: y - Math.max(22, extent * .8), width: Math.max(44, extent * 1.3), height: Math.max(44, extent * 1.15) }] }); + } + for (const group of byPriority) { + if (labels.length >= maxLabels && !group.keys.includes(selected ?? "")) + continue; + let text = group.landmark?.fullName ?? `${group.keys.length} catalog landmarks`; + g.font = `${group.keys.includes(selected ?? "") ? 600 : 500} ${group.keys.includes(selected ?? "") ? 14 : 13}px ui-monospace,SFMono-Regular,Consolas,monospace`; + const maxWidth = Math.min(group.keys.includes(selected ?? "") ? 260 : 180, size.width - 24); + while (text.length > 1 && g.measureText(text + "…").width > maxWidth) + text = text.slice(0, -1); + if (text !== (group.landmark?.fullName ?? `${group.keys.length} catalog landmarks`)) + text += "…"; + const width = g.measureText(text).width + 16, height = 30; + const candidates = [{ x: group.x - width / 2, y: group.y + 42, width, height }, { x: group.x - width / 2, y: group.y - 76, width, height }, { x: group.x + 48, y: group.y - 16, width, height }, { x: group.x - width - 48, y: group.y - 16, width, height }]; + const rect = candidates.find(r => r.x >= 8 && r.x + r.width <= size.width - 8 && r.y >= 8 && r.y + r.height <= size.height - 64 && !accepted.some(a => overlaps(r, a)) && !(r.x + r.width > size.width - 64 && r.y < 230)); + if (rect) { + accepted.push(rect); + labels.push({ group, rect, text }); + targets.find(t => t.keys === group.keys)?.rects.push(rect); + } + } + for (const { group, rect, text } of labels) { + const active = group.keys.includes(selected ?? ""); + g.font = `${active ? 600 : 500} ${active ? 14 : 13}px ui-monospace,SFMono-Regular,Consolas,monospace`; + g.fillStyle = active ? C.labelSelectedPlate : C.labelPlate; + g.strokeStyle = active ? C.labelSelectedBorder : C.border; + g.lineWidth = active ? 2 : 1; + g.beginPath(); + g.roundRect(rect.x, rect.y, rect.width, rect.height, 3); + g.fill(); + g.stroke(); + g.fillStyle = active ? C.labelSelectedText : C.labelText; + g.textAlign = "center"; + g.textBaseline = "middle"; + g.fillText(text, rect.x + rect.width / 2, rect.y + 15); + } + return targets; +} diff --git a/src/atlas/session.ts b/src/atlas/session.ts new file mode 100644 index 0000000..05d5ff0 --- /dev/null +++ b/src/atlas/session.ts @@ -0,0 +1,48 @@ +import { appPath } from "../base-path"; +export const LAYOUT_VERSION = 1; +export interface AtlasSession { + layoutVersion: 1; + centerX: number; + centerY: number; + zoomRatio: number; + selectedRepoKey: string | null; + focusOrigin: "list" | "canvas"; + listScroll: number; + importedRepoKeys: string[]; + returnToken: string | null; +} +const KEY = "moonfactory.atlas.session.v1", validKey = (x: unknown): x is string => typeof x === "string" && /^[a-z0-9-]+\/[a-z0-9_.-]+$/.test(x) && x.length <= 140; +let memory: AtlasSession | null = null; +export function validateSession(x: any): x is AtlasSession { return !!x && x.layoutVersion === LAYOUT_VERSION && [x.centerX, x.centerY, x.zoomRatio, x.listScroll].every(Number.isFinite) && x.zoomRatio > 0 && x.zoomRatio <= 100 && x.listScroll >= 0 && (x.selectedRepoKey === null || validKey(x.selectedRepoKey)) && ["list", "canvas"].includes(x.focusOrigin) && Array.isArray(x.importedRepoKeys) && x.importedRepoKeys.length <= 100 && x.importedRepoKeys.every(validKey) && new Set(x.importedRepoKeys).size === x.importedRepoKeys.length && (x.returnToken === null || typeof x.returnToken === "string" && /^[a-f0-9-]{36}$/.test(x.returnToken)); } +export function readAtlasSession(storage?: Pick) { + try { + const raw = (storage ?? window.sessionStorage).getItem(KEY); + if (raw && new TextEncoder().encode(raw).length > 32768) + throw Error(); + const value = raw ? JSON.parse(raw) : null; + if (value && !validateSession(value)) + throw Error(); + memory = value; + return { value: value as AtlasSession | null, saved: true }; + } + catch { + return { value: memory, saved: false }; + } +} +export function saveAtlasSession(value: AtlasSession, storage?: Pick) { + if (!validateSession(value)) + return false; + memory = structuredClone(value); + const text = JSON.stringify(value); + if (new TextEncoder().encode(text).length > 32768) + return false; + try { + (storage ?? window.sessionStorage).setItem(KEY, text); + return true; + } + catch { + return false; + } +} +export function validatedReturnToken(search: string, session: AtlasSession | null) { const q = new URLSearchParams(search); const token = q.get("atlasReturn"); return q.getAll("mode").length === 1 && q.get("mode") === "cached" && q.getAll("from").length === 1 && q.get("from") === "atlas" && q.getAll("atlasReturn").length === 1 && token === session?.returnToken ? token : null; } +export function atlasBackHref(search: string, session: AtlasSession | null) { const token = validatedReturnToken(search, session); return appPath(token ? `/atlas?atlasReturn=${encodeURIComponent(token)}` : "/atlas?home=1"); } diff --git a/src/client/api.ts b/src/client/api.ts index ad5dbcf..daf90a4 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -19,7 +19,7 @@ function worldRequest(owner: string, repo: string, cached: boolean): Promise(url); + const requestWorld = request(url,cached ? {signal:AbortSignal.timeout(8000)} : undefined); pendingWorlds.set(url, requestWorld); const clear = () => { if (pendingWorlds.get(url) === requestWorld) pendingWorlds.delete(url); diff --git a/src/components/ExplorerPage.tsx b/src/components/ExplorerPage.tsx index 081bc2e..3f497b9 100644 --- a/src/components/ExplorerPage.tsx +++ b/src/components/ExplorerPage.tsx @@ -1,3 +1,8 @@ +import { Link } from "@tanstack/react-router"; +import { cachedWorldReady, worldIdentity } from "../evidence/captured"; +import { readAtlasSession, validatedReturnToken } from "../atlas/session"; +import { noteExploration, subscribeExploration, explorationFlags } from "../progression/session"; +import "../atlas/atlas.css"; import { appPath } from "../base-path"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -68,6 +73,7 @@ export function ExplorerPage({ repo: string; readMode?: "legacy" | "cached" | "invalid"; }) { + const [atlasToken,setAtlasToken] = useState(null), [,setProgressRevision] = useState(0); const loadGeneration = useRef(0); const cachedOnly = readMode === "cached"; const [world, setWorld] = useState(null), @@ -93,6 +99,7 @@ export function ExplorerPage({ if (readMode === "invalid") throw new Error("Invalid exploration mode. Return to Atlas to choose a cached world."); const next = await (cachedOnly ? getCachedWorld(owner, repo) : refresh ? refreshWorld(owner, repo) : getWorld(owner, repo)); if (generation !== loadGeneration.current) return; + if (cachedOnly && !cachedWorldReady(next)) throw new Error("This cached capture cannot be verified for exploration."); setWorld(next); setError(null); } catch (e) { @@ -120,6 +127,11 @@ export function ExplorerPage({ if (timer) clearInterval(timer); }; }, [owner, repo, load, cachedOnly, readMode]); + useEffect(() => { + if (!cachedOnly) return; + setAtlasToken(validatedReturnToken(window.location.search,readAtlasSession().value)); + return subscribeExploration(()=>setProgressRevision(x=>x+1)); + },[cachedOnly]); const changeStyle = (s: StyleId) => { setStyle(s); const url = new URL(window.location.href); @@ -165,6 +177,7 @@ export function ExplorerPage({ + {cachedOnly && world &&
{explorationFlags(world.repo.fullName.toLowerCase()).visited ? "Visited this session" : "Rendering cached world"} · No Exploration star awarded for entry.
} {!world ? (
{error ? ( @@ -324,7 +338,7 @@ export function ExplorerPage({
{tab === "world" ? ( <> - + { const identity = worldIdentity(world); if (cachedOnly && identity) noteExploration(identity,"visited"); }} /> + Explore Atlas
diff --git a/src/progression/session.ts b/src/progression/session.ts new file mode 100644 index 0000000..f7448ed --- /dev/null +++ b/src/progression/session.ts @@ -0,0 +1,21 @@ +import type { WorldIdentity } from "../evidence/contracts"; +/** First increment: session observations only, no receipt and no reward. The + * persistence/challenge increment will keep this single gameplay interface. */ +export interface ExplorationFlags { + discovered: boolean; + visited: boolean; +} +const flags = new Map(); +const listeners = new Set<() => void>(); +export function explorationFlags(key: string): ExplorationFlags { return flags.get(key) ?? { discovered: false, visited: false }; } +export function noteExploration(identity: WorldIdentity, event: "discovered" | "visited") { + if (identity.source !== "github" || !flags.has(identity.repoKey) && flags.size >= 100) + return; + const old = explorationFlags(identity.repoKey), next = { discovered: true, visited: old.visited || event === "visited" }; + if (next.discovered === old.discovered && next.visited === old.visited) + return; + flags.set(identity.repoKey, next); + for (const listener of listeners) + listener(); +} +export function subscribeExploration(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 8ed19f5..040447f 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -6,6 +6,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' +import { Route as AtlasRouteImport } from './routes/atlas' import { Route as ApiBudgetRouteImport } from './routes/api.budget' import { Route as ApiGalleryRouteImport } from './routes/api.gallery' import { Route as ApiHealthRouteImport } from './routes/api.health' @@ -20,6 +21,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const AtlasRoute = AtlasRouteImport.update({ + id: '/atlas', + path: '/atlas', + getParentRoute: () => rootRouteImport, +} as any) const ApiBudgetRoute = ApiBudgetRouteImport.update({ id: '/api/budget', path: '/api/budget', @@ -64,6 +70,7 @@ const ApiWorldOwnerRepoRoute = ApiWorldOwnerRepoRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/atlas': typeof AtlasRoute '/api/budget': typeof ApiBudgetRoute '/api/gallery': typeof ApiGalleryRoute '/api/health': typeof ApiHealthRoute @@ -75,6 +82,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/atlas': typeof AtlasRoute '/api/budget': typeof ApiBudgetRoute '/api/gallery': typeof ApiGalleryRoute '/api/health': typeof ApiHealthRoute @@ -87,6 +95,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/atlas': typeof AtlasRoute '/api/budget': typeof ApiBudgetRoute '/api/gallery': typeof ApiGalleryRoute '/api/health': typeof ApiHealthRoute @@ -100,6 +109,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/atlas' | '/api/budget' | '/api/gallery' | '/api/health' @@ -111,6 +121,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/atlas' | '/api/budget' | '/api/gallery' | '/api/health' @@ -122,6 +133,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/atlas' | '/api/budget' | '/api/gallery' | '/api/health' @@ -134,6 +146,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AtlasRoute: typeof AtlasRoute ApiBudgetRoute: typeof ApiBudgetRoute ApiGalleryRoute: typeof ApiGalleryRoute ApiHealthRoute: typeof ApiHealthRoute @@ -153,6 +166,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/atlas': { + id: '/atlas' + path: '/atlas' + fullPath: '/atlas' + preLoaderRoute: typeof AtlasRouteImport + parentRoute: typeof rootRouteImport + } '/api/budget': { id: '/api/budget' path: '/api/budget' @@ -214,6 +234,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AtlasRoute: AtlasRoute, ApiBudgetRoute: ApiBudgetRoute, ApiGalleryRoute: ApiGalleryRoute, ApiHealthRoute: ApiHealthRoute, diff --git a/src/routes/atlas.tsx b/src/routes/atlas.tsx new file mode 100644 index 0000000..a03ad36 --- /dev/null +++ b/src/routes/atlas.tsx @@ -0,0 +1,3 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { AtlasPage } from "../atlas/AtlasPage"; +export const Route = createFileRoute("/atlas")({validateSearch:(search:Record)=>search,component:AtlasPage}); diff --git a/src/world/WorldCanvas.tsx b/src/world/WorldCanvas.tsx index 8e9fd9e..d261482 100644 --- a/src/world/WorldCanvas.tsx +++ b/src/world/WorldCanvas.tsx @@ -9,14 +9,16 @@ export interface WorldCanvasProps { selectedId: string | null; onSelect: (id: string | null) => void; className?: string; + onRendered?: () => void; } -export function WorldCanvas({ world, style, selectedId, onSelect, className = "" }: WorldCanvasProps) { +export function WorldCanvas({ world, style, selectedId, onSelect, className = "", onRendered }: WorldCanvasProps) { const canvas = useRef(null), box = useRef(null), cam = useRef({ x: 0, y: 0, k: 1 }); const drag = useRef<{ id: number; x: number; y: number; cx: number; cy: number; moved: boolean } | null>(null); - const data = useRef({ world, style, selectedId, onSelect }); - data.current = { world, style, selectedId, onSelect }; + const data = useRef({ world, style, selectedId, onSelect, onRendered }); + const renderedIdentity = useRef(""); + data.current = { world, style, selectedId, onSelect, onRendered }; const layoutIdentity = useRef(""); const placed = useRef([]), hover = useRef(null), @@ -145,6 +147,8 @@ export function WorldCanvas({ world, style, selectedId, onSelect, className = "" g.fillStyle = vignette; g.fillRect(0, 0, width, height); paintLabels(g, placed.current, s, sel, hover.current, c); + const identity = JSON.stringify([w.repo.fullName, w.headSha, w.collectedAt, w.source]); + if (identity !== renderedIdentity.current) { renderedIdentity.current = identity; data.current.onRendered?.(); } }; frame = requestAnimationFrame(draw); const wheel = (e: WheelEvent) => { diff --git a/test/atlas.test.ts b/test/atlas.test.ts new file mode 100644 index 0000000..c657685 --- /dev/null +++ b/test/atlas.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "bun:test"; +import { constrain, fit, pan, project, resizeCamera, unproject, wheelFactor, zoomAt, SCENE } from "../src/atlas/camera"; +import { factsFromWorld, homeBounds, layoutLandmarks, visibleGroups, statusText } from "../src/atlas/model"; +import { readAtlasSession, saveAtlasSession, validateSession, validatedReturnToken, atlasBackHref, type AtlasSession } from "../src/atlas/session"; +import { selectionLoader } from "../src/atlas/loader"; +import { CURATED_PROJECTS } from "../src/catalog"; +import type { FeaturedProject } from "../src/contracts"; +import { syntheticWorld } from "./fixtures/browser-world"; +const size = { width: 1000, height: 700 }; +test("SYNTHETIC camera projection/inverse, pointer zoom and pan preserve coordinates before bounds", () => { + const camera = { centerX: 4096, centerY: 3072, zoom: .5 }, point = { x: 325, y: 220 }, world = unproject(point, camera, size); + expect(project(world, camera, size)).toEqual(point); + const zoomed = zoomAt(camera, 1.2, point, size), after = project(world, zoomed, size); + expect(after.x).toBeCloseTo(point.x, 8); + expect(after.y).toBeCloseTo(point.y, 8); + const moved = project(world, pan(camera, 70, -30, size), size); + expect(moved.x - point.x).toBeCloseTo(70); + expect(moved.y - point.y).toBeCloseTo(-30); + expect(wheelFactor(3, 1, 700)).toBeCloseTo(wheelFactor(48, 0, 700)); + expect(wheelFactor(1, 2, 700)).toBe(wheelFactor(240, 0, 700)); +}); +test("SYNTHETIC camera finite bounds and resize retain center/relative home zoom, including very wide view", () => { + const points = layoutLandmarks(CURATED_PROJECTS, []), home = homeBounds(points), camera = constrain(fit(home, size), size), next = { width: 1200, height: 900 }, resized = resizeCamera(camera, size, next, home); + expect(resized.centerX).toBeCloseTo(camera.centerX, 8); + expect(resized.centerY).toBeCloseTo(camera.centerY, 8); + expect(resized.zoom / fit(home, next).zoom).toBeCloseTo(camera.zoom / fit(home, size).zoom, 8); + for (const input of [{ centerX: NaN, centerY: Infinity, zoom: -1 }, { centerX: 1e90, centerY: -1e90, zoom: 100 }]) { + const c = constrain(input, size); + expect([c.centerX, c.centerY, c.zoom].every(Number.isFinite)).toBe(true); + expect(c.centerX).toBeGreaterThanOrEqual(SCENE.left); + expect(c.centerX).toBeLessThanOrEqual(SCENE.right); + expect(c.zoom).toBeLessThanOrEqual(2.5); + } +}); +test("SYNTHETIC stable editorial/import slots ignore response order, heads, metadata and later imports", () => { + const a = layoutLandmarks(CURATED_PROJECTS, ["synthetic/first"]), b = layoutLandmarks([...CURATED_PROJECTS].reverse().map(p => ({ ...p, stars: 12345, collectedAt: "different" })), ["synthetic/first", "synthetic/second"]); + for (const p of a) + expect(b.find(x => x.repoKey === p.repoKey)).toEqual(p); + expect(a).toHaveLength(CURATED_PROJECTS.length + 1); +}); +for (const population of [0, 19, 100, 500, 2000]) + test(`SYNTHETIC atlas density ${population} retains every visible identity through bounded clusters`, () => { + const projects: FeaturedProject[] = Array.from({ length: population }, (_, i) => ({ fullName: `synthetic/repo-${i}`, name: `Repo ${i}`, description: "SYNTHETIC", category: "Editorial region", language: null, stars: null, ready: false, collectedAt: null, suggestedStyle: "moonshot" })); + const landmarks = layoutLandmarks(projects, []), camera = fit(SCENE, size), groups = visibleGroups(landmarks, camera, size, landmarks[0]?.repoKey ?? null), keys = groups.flatMap(g => g.keys); + expect(new Set(keys).size).toBe(population); + expect(keys.length).toBe(population); + expect(groups.filter(g => g.keys.length === 1).length).toBeLessThanOrEqual(80); + expect(groups.filter(g => g.keys.length > 1).length).toBeLessThanOrEqual(32); + if (population) + expect(groups.some(g => g.keys.length === 1 && g.keys[0] === landmarks[0].repoKey)).toBe(true); + }); +test("SYNTHETIC unchecked/miss/storage failure remain separate and history cannot block a stale real capture", () => { + expect(statusText()).toBe("Cached status not checked"); + expect(statusText({ state: "miss" })).toBe("Not collected"); + expect(statusText({ state: "unavailable" })).toBe("Cached facts unavailable"); + const w = syntheticWorld(); + w.source = "github"; + w.repo.license = "NOASSERTION"; + const facts = factsFromWorld(w, w.repo.fullName.toLowerCase()); + expect(facts?.identity.headSha).toBe(w.headSha); + expect(facts?.license).toBe("NOASSERTION"); + expect(factsFromWorld(w, "wrong/repository")).toBe(null); +}); +test("SYNTHETIC session restoration rejects corrupt numbers/oversize input and only local exact return tokens restore", () => { + const session: AtlasSession = { layoutVersion: 1, centerX: 1400, centerY: 1000, zoomRatio: 1, selectedRepoKey: "synthetic/repo", focusOrigin: "list", listScroll: 25, importedRepoKeys: [], returnToken: "11111111-1111-4111-8111-111111111111" }; + let text = ""; + expect(saveAtlasSession(session, { setItem: (_k, v) => text = v })).toBe(true); + expect(readAtlasSession({ getItem: () => text }).value).toEqual(session); + expect(validateSession({ ...session, centerX: NaN })).toBe(false); + expect(validateSession({ ...session, zoomRatio: -1 })).toBe(false); + expect(readAtlasSession({ getItem: () => "x".repeat(32769) }).saved).toBe(false); + expect(saveAtlasSession(session, { setItem: () => { throw Error("SYNTHETIC denied"); } })).toBe(false); + expect(validatedReturnToken(`?mode=cached&from=atlas&atlasReturn=${session.returnToken}`, session)).toBe(session.returnToken); + expect(validatedReturnToken(`?mode=cached&mode=cached&from=atlas&atlasReturn=${session.returnToken}`, session)).toBe(null); + expect(atlasBackHref("?mode=cached&from=https://evil.test&atlasReturn=bad", session)).toMatch(/\/atlas\?home=1$/); +}); +test("SYNTHETIC selection loader caps concurrency at two, replaces queued work and discards late identities", async () => { + const releases = new Map void>(), calls: string[] = [], accepted: string[] = [], errors: unknown[] = []; + let active = 0, peak = 0; + const loader = selectionLoader((key: string) => { calls.push(key); active++; peak = Math.max(peak, active); return new Promise(resolve => releases.set(key, v => { active--; resolve(v); })); }); + for (const key of ["a", "b", "c", "d"]) + loader.select(key, v => accepted.push(v), e => errors.push(e)); + expect(calls).toEqual(["a", "b"]); + releases.get("a")!("old a"); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(calls).toEqual(["a", "b", "d"]); + releases.get("b")!("old b"); + releases.get("d")!("selected d"); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(accepted).toEqual(["selected d"]); + expect(errors).toEqual([]); + expect(peak).toBe(2); + loader.select("e", v => accepted.push(v), e => errors.push(e)); + loader.cancel(); + releases.get("e")!("cancelled"); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(accepted).toEqual(["selected d"]); +}); From d9826aec90c6e239b3d27b17d5a4feb3d3b6781e Mon Sep 17 00:00:00 2001 From: ubuntu03 Date: Wed, 4 Nov 2026 20:51:41 -0800 Subject: [PATCH 04/48] Set the existing SQLite busy wait before quota WAL setup --- src/server/access.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/access.ts b/src/server/access.ts index e26d250..068260c 100644 --- a/src/server/access.ts +++ b/src/server/access.ts @@ -44,7 +44,7 @@ export class ClientQuota { constructor(path: string, private limits: { requestsPerMinute: number; collectionsPerHour: number; aiPerHour: number; aiGlobalPerHour?: number }, private now = Date.now) { if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); this.db = new Database(path, { create: true }); - this.db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=3000; CREATE TABLE IF NOT EXISTS client_quota (identity TEXT NOT NULL, bucket TEXT NOT NULL, window INTEGER NOT NULL, expires_at INTEGER NOT NULL, used INTEGER NOT NULL, PRIMARY KEY(identity,bucket,window))"); + this.db.exec("PRAGMA busy_timeout=3000; PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS client_quota (identity TEXT NOT NULL, bucket TEXT NOT NULL, window INTEGER NOT NULL, expires_at INTEGER NOT NULL, used INTEGER NOT NULL, PRIMARY KEY(identity,bucket,window))"); } take(identity: string, bucket: AccessBucket) { const now = this.now(), duration = bucket === "request" ? 60_000 : 3_600_000; From c236832245dcb7777d11b4659c2a8870833253af Mon Sep 17 00:00:00 2001 From: ubuntu03 Date: Wed, 4 Nov 2026 21:15:36 -0800 Subject: [PATCH 05/48] Add local captured-source challenges and transactional exploration stars --- docs/QA.md | 8 ++ e2e/atlas.spec.ts | 8 +- e2e/progression-harness.ts | 4 + e2e/progression.spec.ts | 81 ++++++++++++++++ src/atlas/AtlasCanvas.tsx | 5 +- src/atlas/AtlasPage.tsx | 13 ++- src/atlas/paint.ts | 7 +- src/components/ExplorerPage.tsx | 15 ++- src/components/SourceChallenge.tsx | 52 +++++++++++ src/components/source-challenge.css | 17 ++++ src/progression/contracts.ts | 31 +++++++ src/progression/progress-store.ts | 137 ++++++++++++++++++++++++++++ src/progression/rules.ts | 59 ++++++++++++ src/progression/session.ts | 47 ++++++---- src/world/WorldCanvas.tsx | 11 ++- test/progression.test.ts | 33 +++++++ 16 files changed, 492 insertions(+), 36 deletions(-) create mode 100644 e2e/progression-harness.ts create mode 100644 e2e/progression.spec.ts create mode 100644 src/components/SourceChallenge.tsx create mode 100644 src/components/source-challenge.css create mode 100644 src/progression/contracts.ts create mode 100644 src/progression/progress-store.ts create mode 100644 src/progression/rules.ts create mode 100644 test/progression.test.ts diff --git a/docs/QA.md b/docs/QA.md index d428ba1..9d6156a 100644 --- a/docs/QA.md +++ b/docs/QA.md @@ -91,3 +91,11 @@ The integrated suite now also exercises a real zoom/pan followed by a synthetic A separate real production smoke exposed Bun's default 10-second idle timeout on a large repository survey. The isolated Nitro plugin now extends API requests to 180 seconds, covering the bounded 60-second GitHub survey and one bounded model call. The source network limits remain unchanged. Regression evidence is the failed production request plus a successful real production import lasting over 10 seconds; fixture tests alone do not prove this behavior. The real refresh smoke also exposed CI run URLs inside the architecture cache identity. A new run could evict the same-head field guide even though paths and topology had not changed. The focused regression now replaces a node's observed run URL and proves cache reuse with exactly one total generation attempt; the distinct recovered-topology test still rejects reuse after an expanded survey. + +## Local captured-source exploration candidate + +The feature branch adds an optional local source challenge to cache-only Atlas journeys. A visitor chooses a captured repository, explicitly enters its world, matches an existing pinned directory source (or recorded commit with an author timestamp), and classifies the evidence limits. Discovery, entry and source-link clicks award nothing. A correct explicit submission may create one Exploration star per canonical repository on this browser; it does not verify reading, learning, contribution, current runtime health or GitHub stars. + +Receipts retain the original completed head and capture independently of the current world. IndexedDB transactions serialize the permanent reward slot and reset generation across tabs. Capacity is bounded to 100 repositories, 2048 serialized UTF-8 bytes per record and 256 KiB of gameplay payload; earlier progress is never silently evicted. Unavailable, corrupt, unsupported or failed browser storage falls back visibly to session practice with unknown durable totals. Clear exploration progress confirms deletion of gameplay data only. No player data is added to public worlds, server storage, paid ledgers or a new API. + +Offline browser acceptance uses labeled synthetic GitHub-shaped captures, real browser IndexedDB and denied upstream access. It tests the two-repository Atlas round trip, incorrect answers, replay and changed-capture idempotency, independent connection completion races, reset epochs, multi-tab reconciliation, retained corruption/capacity failures, aborted commits, unsupported schemas, commit fallback, narrow-screen keyboard access and shared pause control. These are synthetic acceptance boundaries. The feature is not deployed; real public gameplay, independent comprehension benefits, Safari/Firefox and physical devices remain unverified. diff --git a/e2e/atlas.spec.ts b/e2e/atlas.spec.ts index 61df4d7..8e3b57c 100644 --- a/e2e/atlas.spec.ts +++ b/e2e/atlas.spec.ts @@ -30,7 +30,7 @@ test("SYNTHETIC Atlas two-repository cached round trips preserve identity/source for (const [i, key] of keys.slice(0, 2).entries()) { await row(page, key).click(); const card = page.locator(".atlas-card"); - await expect(card.getByText("Discovered this session · No Exploration star awarded.", { exact: true })).toBeVisible(); + await expect(card.getByText("Discovered on this device · No Exploration star awarded.", { exact: true })).toBeVisible(); await expect(card.getByRole("link", { name: "GitHub source" })).toHaveAttribute("href", worlds[i].repo.url); await expect(card).toContainText("Stale capture · current state unknown"); await expect(card).toContainText("Historical trees unavailable"); @@ -41,7 +41,7 @@ test("SYNTHETIC Atlas two-repository cached round trips preserve identity/source const before = await page.evaluate(() => JSON.parse(sessionStorage.getItem("moonfactory.atlas.session.v1")!)); await card.getByRole("link", { name: "Enter cached world" }).click(); await expect(page).toHaveURL(new RegExp(`/world/${key}\\?`)); - await expect(page.locator(".explorer-visit-status")).toContainText("Visited this session"); + await expect(page.locator(".explorer-visit-status")).toContainText("Visited on this device"); await expect(page.locator(".explorer-visit-status")).toContainText("No Exploration star"); await page.locator(".district-list").getByRole("button", { name: /SYNTHETIC 源码/ }).click(); const inspector = page.locator(".inspector"); @@ -52,7 +52,7 @@ test("SYNTHETIC Atlas two-repository cached round trips preserve identity/source await expect(inspector.locator(".path-label")).toHaveText("packages/源码"); } await page.getByRole("link", { name: "Back to Atlas", exact: true }).click(); - await expect(page.locator(".atlas-card")).toContainText("Visited this session"); + await expect(page.locator(".atlas-card")).toContainText("Visited on this device"); await expect(row(page, key)).toBeFocused(); const after = await page.evaluate(() => JSON.parse(sessionStorage.getItem("moonfactory.atlas.session.v1")!)); expect(after.centerX).toBeCloseTo(before.centerX, 5); @@ -110,7 +110,7 @@ for (const width of [320, 390, 768]) expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); expect(await page.locator(".atlas-card").evaluate(e => e.scrollWidth <= e.clientWidth + 1)).toBe(true); await page.getByRole("link", { name: "Enter cached world" }).click(); - await expect(page.locator(".explorer-visit-status")).toContainText("Visited this session"); + await expect(page.locator(".explorer-visit-status")).toContainText("Visited on this device"); await page.getByRole("link", { name: "Back to Atlas", exact: true }).click(); await expect(row(page, keys[1])).toBeFocused(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); diff --git a/e2e/progression-harness.ts b/e2e/progression-harness.ts new file mode 100644 index 0000000..d6dfbe0 --- /dev/null +++ b/e2e/progression-harness.ts @@ -0,0 +1,4 @@ +// Bundled only by offline Playwright; never imported by the application. +import { ProgressStore } from "../src/progression/progress-store"; +import { deriveChallenge, startAttempt, conclusionAnswers } from "../src/progression/rules"; +(globalThis as any).__progressHarness = { ProgressStore, deriveChallenge, startAttempt, conclusionAnswers }; diff --git a/e2e/progression.spec.ts b/e2e/progression.spec.ts new file mode 100644 index 0000000..d73222d --- /dev/null +++ b/e2e/progression.spec.ts @@ -0,0 +1,81 @@ +import { basePath, test, expect } from "./fixtures"; +import { syntheticWorld } from "../test/fixtures/browser-world"; +const keyA="synthetic-progress/alpha",keyB="synthetic-progress/beta"; +function capture(key=keyA,head="a".repeat(40)) {let w=syntheticWorld();const old=w.repo.url,oldHead=w.headSha;w=JSON.parse(JSON.stringify(w).replaceAll(old,`https://github.com/${key}`).replaceAll(oldHead,head));const [owner,name]=key.split("/");w.source="github";w.repo={...w.repo,owner,name,fullName:key,url:`https://github.com/${key}`,description:"SYNTHETIC captured fixture"};w.nodes[0].evidence=[`${w.repo.url}/tree/${head}/${w.nodes[0].path}`];return w;} +async function fixtures(api:any,worlds=[capture(),capture(keyB,"b".repeat(40))]) {api.respond("GET","/api/gallery",()=>({body:{projects:worlds.map(w=>({fullName:w.repo.fullName,name:w.repo.name,description:"SYNTHETIC",category:"SYNTHETIC",suggestedStyle:"moonshot",ready:true}))}}));for(const w of worlds)api.respond("GET","/api/world/"+w.repo.fullName,(r:any)=>{expect(new URL(r.request().url()).search).toBe("?mode=cached");return {body:w};});} +const challenge=(page:any)=>page.getByRole("region",{name:"Source challenge",exact:true}); +async function answer(page:any,wrong=false){const section=challenge(page);await section.getByRole("button",{name:/^(Start|Practice) source challenge$/}).click();await section.getByRole("group",{name:"Choose the matching captured source"}).getByRole("radio").first().check();await section.getByRole("radio",{name:wrong?"This proves the service is healthy in production.":"The map records this directory source; its role is inferred and runtime behavior is unknown.",exact:true}).check();await section.getByRole("button",{name:"Submit source answers",exact:true}).click();} +async function receipts(page:any){return page.evaluate(async()=>{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open("moon-factory-exploration",1);r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});return new Promise((resolve,reject)=>{const t=db.transaction("completions"),r=t.objectStore("completions").getAll();r.onsuccess=()=>{db.close();resolve(r.result);};r.onerror=()=>reject(r.error);});});} +async function harness(page:any){const result=await Bun.build({entrypoints:["./e2e/progression-harness.ts"],target:"browser",format:"iife"});if(!result.success)throw Error("Offline harness build failed");await page.addScriptTag({content:await result.outputs[0].text()});} + +test("SYNTHETIC two-repo source challenge loop: wrong answer, Saved receipt, Atlas lamp/back/focus, reload and repeat retain one slot",async({page,api})=>{ + await fixtures(api);await page.goto(basePath+"/atlas"); + for(const key of [keyA,keyB]){ + const row=page.locator(".atlas-list-rows").getByRole("button",{name:new RegExp(key)});await row.click();await expect(page.locator(".atlas-card")).toContainText("Discovered on this device");await expect(page.locator(".atlas-lantern")).toHaveCount(key===keyA?0:0); + await page.getByRole("link",{name:"Enter cached world",exact:true}).click();await expect(page.locator(".explorer-visit-status")).toContainText("No Exploration star awarded for entry"); + await answer(page,true);await expect(challenge(page).getByRole("status")).toContainText("Try again");expect((await receipts(page)).filter((r:any)=>r.completedIdentity.repoKey===key)).toHaveLength(0); + for (const name of ["Moon Factory","Factorio","Red Alert","Red Alert 2","Civilization"]) { await page.getByRole("group",{name:"World perspective"}).getByRole("button",{name:new RegExp(name+"$")}).click();await expect(challenge(page).getByRole("radio",{name:"This proves the service is healthy in production.",exact:true})).toBeChecked(); } + await challenge(page).getByRole("radio",{name:"The map records this directory source; its role is inferred and runtime behavior is unknown.",exact:true}).check();await challenge(page).getByRole("button",{name:"Submit source answers"}).click();await expect(challenge(page).getByRole("status")).toHaveText("Exploration star earned. Saved on this device."); + const first=(await receipts(page)).find((r:any)=>r.completedIdentity.repoKey===key);await page.reload();await expect(challenge(page).locator(".exploration-receipt")).toContainText("Completed on this device");await answer(page);await expect(challenge(page).getByRole("status")).toContainText("No additional Exploration star");expect((await receipts(page)).find((r:any)=>r.completedIdentity.repoKey===key)).toEqual(first); + await page.getByRole("link",{name:"Back to Atlas",exact:true}).click();await expect(page.locator(".atlas-lantern")).toContainText("Lantern lit · 1 Exploration star");await expect(page.locator(".atlas-list-rows").getByRole("button",{name:new RegExp(key)})).toBeFocused(); + } + expect(await receipts(page)).toHaveLength(2);expect(api.requests.some((r:any)=>r.method!=="GET"||r.path.includes("budget")||r.path.includes("blueprint"))).toBe(false); +}); + +test("SYNTHETIC real IndexedDB two-connection atomic completion, reset generation and changed head/rules",async({page,api})=>{ + await fixtures(api);await page.goto(basePath+"/atlas");await harness(page); + const result=await page.evaluate(async(w:any)=>{ + const {ProgressStore,deriveChallenge,startAttempt,conclusionAnswers}=(window as any).__progressHarness; + const a=new ProgressStore(indexedDB),b=new ProgressStore(indexedDB),c=await deriveChallenge(w); + const correct=(ch:any,g=0)=>({...startAttempt(ch,g),selectedEvidenceId:ch.evidence[0].id,selectedSubjectId:ch.target.subjectId,answerCodes:conclusionAnswers(ch.variant,ch.choices.conclusion[0].code)}); + const frozen=JSON.stringify(w),r=await Promise.all([a.complete(c,correct(c),c.identity),b.complete(c,correct(c),c.identity)]);const before=await a.read();let lateRejected=false;try{await a.complete(c,correct(c),c.identity,()=>false);}catch(e){lateRejected=(e as Error).message.includes("Snapshot changed");} + const other=structuredClone(w);other.headSha="d".repeat(40);other.nodes[0].evidence=[`${other.repo.url}/tree/${other.headSha}/${other.nodes[0].path}`];const v2=await deriveChallenge(other,Date.now(),2),again=await b.complete(v2,correct(v2),v2.identity); + await b.reset();let resetRejected=false;try{await a.complete(c,correct(c),c.identity);}catch(e){resetRejected=(e as Error).message.includes("reset");} + const after=await a.read();await a.complete(c,correct(c,after.generation),c.identity);const saved=await a.read();a.close();b.close(); + return {existing:r.map((x:any)=>x.existing).sort(),count:before.completions.length,first:r[0].receipt,again:again.receipt,resetRejected,empty:after.completions.length,generation:after.generation,resaved:saved.completions.length,lateRejected,unchanged:frozen===JSON.stringify(w)}; + },capture()); + expect(result.existing).toEqual([false,true]);expect(result.count).toBe(1);expect(result.first).toEqual(result.again);expect(result.resetRejected).toBe(true);expect(result.empty).toBe(0);expect(result.generation).toBe(1);expect(result.resaved).toBe(1);expect(result.unchanged).toBe(true);expect(result.lateRejected).toBe(true); +}); + +test("SYNTHETIC two tabs reconcile completion and reset without resurrecting a pending attempt",async({page,context,api})=>{ + await fixtures(api);await page.goto(`${basePath}/world/${keyA}?mode=cached`);const second=await context.newPage();await second.goto(`${basePath}/world/${keyA}?mode=cached`); + await challenge(page).getByRole("button",{name:"Start source challenge"}).click();await challenge(page).getByRole("group",{name:"Choose the matching captured source"}).getByRole("radio").first().check();await challenge(page).getByRole("radio",{name:/^The map records this directory source/}).check(); + await answer(second);await expect(challenge(second).getByRole("status")).toContainText("Saved on this device");await expect(challenge(page).locator(".exploration-receipt")).toContainText("1 Exploration star"); + await second.getByRole("link",{name:"Back to Atlas",exact:true}).click();await second.getByRole("button",{name:"Clear exploration progress",exact:true}).click();await second.getByRole("button",{name:"Confirm clear exploration progress",exact:true}).click(); + await expect(challenge(page).getByRole("status")).toHaveText("Progress was reset. Start again.");await expect(challenge(page).getByRole("button",{name:"Submit source answers"})).toHaveCount(0);expect(await receipts(page)).toHaveLength(0);await second.close(); +}); + +test("SYNTHETIC source unavailability/commit fallback and blocked storage remain explicit session-only",async({page,api})=>{ + const w=capture();for(const n of w.nodes)n.evidence=[];const commit=w.commits[0];commit.id="e".repeat(40);commit.url=`${w.repo.url}/commit/${commit.id}`;commit.message="Merge #123 原始信息";await fixtures(api,[w]); + await page.addInitScript(()=>Object.defineProperty(window,"indexedDB",{get:()=>undefined}));await page.goto(`${basePath}/world/${keyA}?mode=cached`); + const panel=challenge(page);await panel.getByRole("button",{name:"Start source challenge"}).click();await expect(panel).toContainText("Author timestamp");await expect(panel).toContainText("Merge #123 原始信息");await panel.getByRole("group",{name:"Choose the matching captured source"}).getByRole("radio").first().check();await panel.getByRole("radio",{name:"This is an author timestamp; a merged pull request is not established.",exact:true}).check();await panel.getByRole("button",{name:"Submit source answers"}).click();await expect(panel.getByRole("status")).toContainText("Session progress — not saved");await expect(panel).toContainText("Durable progress count is unknown"); + w.commits=[];await page.reload();await expect(challenge(page)).toContainText("Source challenge unavailable");await expect(challenge(page).getByRole("button",{name:"Start source challenge"})).toHaveCount(0); +}); + +test("SYNTHETIC narrow-screen keyboard challenge controls remain reachable, pause preserves source facts and local reward",async({page,api})=>{ + await page.setViewportSize({width:360,height:1000});await page.emulateMedia({reducedMotion:"no-preference"});await fixtures(api);await page.goto(`${basePath}/world/${keyA}?mode=cached`);await page.getByRole("button",{name:"Pause motion",exact:true}).click();await expect(page.getByRole("button",{name:"Resume motion"})).toHaveAttribute("aria-pressed","true");await page.waitForTimeout(150);const pixels=await page.getByTestId("world-canvas").evaluate((c:HTMLCanvasElement)=>c.toDataURL());await page.waitForTimeout(200);expect(await page.getByTestId("world-canvas").evaluate((c:HTMLCanvasElement)=>c.toDataURL())).toBe(pixels); + await page.getByRole("button",{name:"Show source challenge",exact:true}).click();const panel=challenge(page);const start=panel.getByRole("button",{name:"Start source challenge"});await start.focus();await page.keyboard.press("Enter");await panel.getByRole("group",{name:"Choose the matching captured source"}).getByRole("radio").first().focus();await page.keyboard.press("Space");await panel.getByRole("radio",{name:/^The map records this directory source/}).focus();await page.keyboard.press("Space");await panel.getByRole("button",{name:"Submit source answers"}).focus();await page.keyboard.press("Enter");await expect(panel.getByRole("status")).toContainText("Saved on this device"); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);expect(await panel.evaluate(e=>e.scrollWidth<=e.clientWidth+1)).toBe(true);await expect(page.getByRole("link",{name:"Back to Atlas",exact:true})).toBeVisible(); +}); + +test("SYNTHETIC real IDB caps, corrupt records, aborted save and newer schema retain data with honest session fallback",async({page,api})=>{ + await fixtures(api);await page.goto(basePath+"/atlas");await harness(page); + const result=await page.evaluate(async(w:any)=>{ + const {ProgressStore,deriveChallenge,startAttempt,conclusionAnswers}=(window as any).__progressHarness; + const c=await deriveChallenge(w),s=new ProgressStore(indexedDB),a={...startAttempt(c,0),selectedEvidenceId:c.evidence[0].id,selectedSubjectId:c.target.subjectId,answerCodes:conclusionAnswers(c.variant,c.choices.conclusion[0].code)}; + const db=await new Promise((resolve,reject)=>{const r=indexedDB.open("moon-factory-exploration",1);r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);}); + const write=(run:(tx:IDBTransaction)=>void)=>new Promise((resolve,reject)=>{const t=db.transaction(["repoProgress","completions","metadata"],"readwrite");t.oncomplete=()=>resolve();t.onerror=()=>reject(t.error);run(t);}); + await write(t=>{for(let i=0;i<100;i++)t.objectStore("repoProgress").put({schemaVersion:1,repoKey:`synthetic-limit/repo${i}`,discovered:true,visited:false});}); + const cap=await s.complete(c,a,c.identity); + const count=(name:string)=>new Promise((resolve)=>{const r=db.transaction(name).objectStore(name).count();r.onsuccess=()=>resolve(r.result);});const capRepos=await count("repoProgress"),capCompletions=await count("completions"); + await write(t=>{t.objectStore("repoProgress").clear();t.objectStore("repoProgress").put({schemaVersion:1,repoKey:"synthetic-corrupt/retained",discovered:true,visited:false,extra:"x".repeat(2049)});}); + const corrupt=await s.read();let retained=0;await new Promise((resolve)=>{const r=db.transaction("repoProgress").objectStore("repoProgress").count();r.onsuccess=()=>{retained=r.result;resolve();};}); + await write(t=>t.objectStore("repoProgress").clear()); + const original=IDBObjectStore.prototype.add;IDBObjectStore.prototype.add=function(){throw new DOMException("SYNTHETIC quota failure","QuotaExceededError");}; + const aborted=await s.complete(c,a,c.identity);IDBObjectStore.prototype.add=original;const durableCount=await count("completions");const recovered=await s.complete(c,a,c.identity),recoveredCount=await count("completions"); + s.close();db.close();const higher=await new Promise((resolve,reject)=>{const r=indexedDB.open("moon-factory-exploration",2);r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});higher.close(); + const unsupported=await new ProgressStore(indexedDB).read(); + return {cap:cap.receipt.persistence,capRepos,capCompletions,corrupt:corrupt.persistence,retained,aborted:aborted.receipt.persistence,durableCount,recovered:recovered.receipt.persistence,recoveredCount,recoveredOriginal:recovered.receipt.completedAt===cap.receipt.completedAt,unsupported:unsupported.persistence,unknown:unsupported.durableCountKnown}; + },capture()); + expect(result).toEqual({cap:"session",capRepos:100,capCompletions:0,corrupt:"session",retained:1,aborted:"session",durableCount:0,recovered:"device",recoveredCount:1,recoveredOriginal:true,unsupported:"session",unknown:false}); +}); diff --git a/src/atlas/AtlasCanvas.tsx b/src/atlas/AtlasCanvas.tsx index b1af237..42a4108 100644 --- a/src/atlas/AtlasCanvas.tsx +++ b/src/atlas/AtlasCanvas.tsx @@ -6,6 +6,7 @@ interface Props { landmarks: Landmark[]; statuses: Map; selected: string | null; + lit?: ReadonlySet; initialView?: { centerX: number; centerY: number; @@ -74,7 +75,7 @@ export function AtlasCanvas(props: Props) { lastPaint = now; const { size, camera } = view.current, ratio = cv.width / size.width; context.setTransform(ratio, 0, 0, ratio, 0, 0); - targets.current = paintAtlas(context, data.current.landmarks, camera, size, data.current.statuses, data.current.selected, hoverKey.current); + targets.current = paintAtlas(context, data.current.landmarks, camera, size, data.current.statuses, data.current.selected, hoverKey.current, data.current.lit); }; invalidate.current = request; commitView.current = (camera, commit = false) => { view.current.camera = constrain(camera, view.current.size); data.current.onView(view.current.camera, fit(homeBounds(data.current.landmarks), view.current.size).zoom, commit); request(); }; @@ -117,7 +118,7 @@ export function AtlasCanvas(props: Props) { return () => { disposed = true; stop(); observer.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", visibility); window.removeEventListener("blur", blur); cv.removeEventListener("wheel", wheel); if (hoverTimer.current) clearTimeout(hoverTimer.current); cv.width = 0; cv.height = 0; invalidate.current = () => { }; commitView.current = () => { }; }; }, []); - useEffect(() => { invalidate.current(); }, [props.landmarks, props.statuses, props.selected]); + useEffect(() => { invalidate.current(); }, [props.landmarks, props.statuses, props.selected, props.lit]); useEffect(() => { if (props.locateRequest) locate(props.locateRequest.key); }, [props.locateRequest, props.landmarks]); const point = (e: ReactPointerEvent) => { const r = e.currentTarget.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }; diff --git a/src/atlas/AtlasPage.tsx b/src/atlas/AtlasPage.tsx index e3b7f09..07a8abd 100644 --- a/src/atlas/AtlasPage.tsx +++ b/src/atlas/AtlasPage.tsx @@ -8,9 +8,11 @@ import { AtlasCanvas } from "./AtlasCanvas"; import { readAtlasSession, saveAtlasSession, type AtlasSession } from "./session"; import { selectionLoader } from "./loader"; import type { Camera } from "./camera"; -import { explorationFlags, noteExploration, subscribeExploration } from "../progression/session"; +import { explorationFlags, noteExploration, subscribeExploration, useProgress, clearExploration, explorationCompletion, reconcileProgress } from "../progression/session"; import "./atlas.css"; export function AtlasPage() { + const progress = useProgress(), [resetConfirm,setResetConfirm] = useState(false), [resetError,setResetError] = useState(""); + const lit = useMemo(() => new Set(progress.completions.map(r=>r.completedIdentity.repoKey)),[progress]); const [catalog, setCatalog] = useState(null), [catalogError, setCatalogError] = useState(null), [selected, setSelected] = useState(null), [statuses, setStatuses] = useState(new Map()), [imports, setImports] = useState([]), [query, setQuery] = useState(""), [page, setPage] = useState(0), [pane, setPane] = useState<"map" | "list">("map"), [chooser, setChooser] = useState([]), [locateRequest, setLocateRequest] = useState<{ key: string; revision: number; @@ -114,6 +116,7 @@ export function AtlasPage() { useEffect(() => { if (chooser.length === 0 && facts && selected === facts.identity.repoKey) noteExploration(facts.identity, "discovered"); }, [facts, selected, chooser.length]); const selectedLandmark = landmarks.find(x => x.repoKey === selected), filtered = landmarks.filter(x => (x.fullName + " " + x.title + " " + x.category).toLowerCase().includes(query.toLowerCase())), rows = filtered.slice(page * 50, page * 50 + 50), enterToken = useMemo(() => selected ? crypto.randomUUID() : null, [selected]); + const completion = selected ? explorationCompletion(selected) : null; const flags = selected ? explorationFlags(selected) : { discovered: false, visited: false }; void progressRevision; const inspect = () => { @@ -140,6 +143,7 @@ export function AtlasPage() { } }, [facts, selected, landmarks, save]); return

Repository Atlas

Mission Control
+
{progress.durableCountKnown ? `${progress.completions.length} locally lit landmarks` : "Saved landmark count unknown"} · {progress.persistence === "device" ? "On this device" : "Session progress — not saved"}{resetConfirm &&

Clear only this browser’s exploration flags and stars? Atlas positions, public source caches and server budgets are preserved.

}{resetError &&

{resetError}

}{progress.warning &&

{progress.warning}

}

Choose a landmark, inspect its captured identity, then enter its world. This atlas shows its catalog and your cached survey entries.

{catalog === null ? "Loading catalog" : `${landmarks.length} listed landmarks · ${[...statuses.values()].filter(s => s.state === "cached").length} captures checked this visit`} · No full GitHub map or complete history is claimed.

{catalogError &&

{catalogError}

} {!positionSaved &&

View position is temporary. Browser session storage is unavailable; a reload may lose it.

} @@ -155,21 +159,22 @@ export function AtlasPage() { {filtered.length > 50 &&
{page + 1} / {Math.ceil(filtered.length / 50)}
}
{ e.preventDefault(); inspect(); }}> setInspectInput(e.target.value)} placeholder="https://github.com/owner/repo" required/>No collection, GitHub or model request.{inspectError &&

{inspectError}

}
- {catalog !== null && !(initial?.selectedRepoKey === selected && selected && (!current || current.state === "loading")) ? { cancelRead(); setChooser([]); selectedRef.current = null; setSelected(null); save(); }} onView={(camera: Camera, homeZoom: number, commit: boolean) => { view.current = { centerX: camera.centerX, centerY: camera.centerY, zoomRatio: camera.zoom / homeZoom }; if (commit) + {catalog !== null && !(initial?.selectedRepoKey === selected && selected && (!current || current.state === "loading")) ? { cancelRead(); setChooser([]); selectedRef.current = null; setSelected(null); save(); }} onView={(camera: Camera, homeZoom: number, commit: boolean) => { view.current = { centerX: camera.centerX, centerY: camera.centerY, zoomRatio: camera.zoom / homeZoom }; if (commit) save(); }}/> :

Loading the bounded catalog…

} -
Public cached source · Read only · Original scenery · Session observations are not saved progress
+
Public cached source · Read only · Original scenery · Local exploration is separate from GitHub stars and contributions
; } diff --git a/src/atlas/paint.ts b/src/atlas/paint.ts index 0289fca..03d64be 100644 --- a/src/atlas/paint.ts +++ b/src/atlas/paint.ts @@ -65,7 +65,7 @@ function sculpture(g: CanvasRenderingContext2D, x: number, y: number, size: numb g.restore(); } /** Original vector scenery. No textures, tile cache, physics or cross-repo graph. */ -export function paintAtlas(g: CanvasRenderingContext2D, points: Landmark[], camera: Camera, size: Size, statuses: Map, selected: string | null, hover: string | null) { +export function paintAtlas(g: CanvasRenderingContext2D, points: Landmark[], camera: Camera, size: Size, statuses: Map, selected: string | null, hover: string | null, lit: ReadonlySet = new Set()) { g.fillStyle = C.bg; g.fillRect(0, 0, size.width, size.height); g.save(); @@ -145,6 +145,11 @@ export function paintAtlas(g: CanvasRenderingContext2D, points: Landmark[], came g.lineWidth = 2; g.stroke(); } + if (group.keys.some(key => lit.has(key))) { + g.strokeStyle = C.focus; g.fillStyle = C.surface; g.lineWidth = 2; + g.beginPath(); g.arc(x + extent * .42, y - extent * .5, 12, 0, Math.PI * 2); g.fill(); g.stroke(); + g.fillStyle = C.text; g.font = '600 16px ui-monospace,monospace'; g.textAlign = "center"; g.textBaseline = "middle"; g.fillText("✧", x + extent * .42, y - extent * .5); + } targets.push({ keys: group.keys, x, y, rects: [{ x: x - Math.max(22, extent * .65), y: y - Math.max(22, extent * .8), width: Math.max(44, extent * 1.3), height: Math.max(44, extent * 1.15) }] }); } for (const group of byPriority) { diff --git a/src/components/ExplorerPage.tsx b/src/components/ExplorerPage.tsx index 3f497b9..7f2d1c1 100644 --- a/src/components/ExplorerPage.tsx +++ b/src/components/ExplorerPage.tsx @@ -3,6 +3,7 @@ import { cachedWorldReady, worldIdentity } from "../evidence/captured"; import { readAtlasSession, validatedReturnToken } from "../atlas/session"; import { noteExploration, subscribeExploration, explorationFlags } from "../progression/session"; import "../atlas/atlas.css"; +import { SourceChallenge } from "./SourceChallenge"; import { appPath } from "../base-path"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -76,6 +77,8 @@ export function ExplorerPage({ const [atlasToken,setAtlasToken] = useState(null), [,setProgressRevision] = useState(0); const loadGeneration = useRef(0); const cachedOnly = readMode === "cached"; + const [sourceVerified,setSourceVerified] = useState(false); + const [motionPaused,setMotionPaused] = useState(false), [challengeOpen,setChallengeOpen] = useState(false); const [world, setWorld] = useState(null), [error, setError] = useState(null), [refreshing, setRefreshing] = useState(false), @@ -101,9 +104,10 @@ export function ExplorerPage({ if (generation !== loadGeneration.current) return; if (cachedOnly && !cachedWorldReady(next)) throw new Error("This cached capture cannot be verified for exploration."); setWorld(next); + setSourceVerified(true); setError(null); } catch (e) { - if (generation === loadGeneration.current) setError(e instanceof Error ? e.message : "The repository could not be loaded."); + if (generation === loadGeneration.current) { setSourceVerified(false); setError(e instanceof Error ? e.message : "The repository could not be loaded."); } } finally { if (generation === loadGeneration.current) setRefreshing(false); } @@ -112,6 +116,7 @@ export function ExplorerPage({ ); useEffect(() => { let active = true; + setWorld(null); setSelected(null); setSourceVerified(false); void load(); if (!cachedOnly && readMode !== "invalid") getBudget() .then((b) => { @@ -177,6 +182,7 @@ export function ExplorerPage({
+ {cachedOnly && } {cachedOnly && Back to Atlas} / @@ -195,7 +201,7 @@ export function ExplorerPage({
- {cachedOnly && world &&
{explorationFlags(world.repo.fullName.toLowerCase()).visited ? "Visited this session" : "Rendering cached world"} · No Exploration star awarded for entry.
} + {cachedOnly && world &&
{explorationFlags(world.repo.fullName.toLowerCase()).visited ? "Visited on this device" : "Rendering cached world"} · No Exploration star awarded for entry.
} {!world ? (
{error ? ( @@ -338,7 +344,7 @@ export function ExplorerPage({
{tab === "world" ? ( <> - { const identity = worldIdentity(world); if (cachedOnly && identity) noteExploration(identity,"visited"); }} /> + { const identity = worldIdentity(world); if (cachedOnly && identity) noteExploration(identity,"visited"); }} />
{styleInfo.name.toUpperCase()} / REPOSITORY WORLD

@@ -548,7 +554,8 @@ export function ExplorerPage({ ))}

-