diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index dfd9092..9529727 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -423,7 +423,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus'); await advanceFrames(engine, 0.3); - component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null, gaveUp: false }); + component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null, gaveUp: false, least: true }); await advanceFrames(engine, 0.3); // As catalogue indices: the Sun is the first entry of STARS, Proxima the third. @@ -473,7 +473,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect(links.mock.calls[1][1]).not.toBe(links.mock.calls[0][1]); // A route re-chooses the drawn stars around its pins, and here they come out the same: no new graph. - component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }], totalPc: 0, neededRangePc: null, gaveUp: false }); + component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }], totalPc: 0, neededRangePc: null, gaveUp: false, least: true }); await advanceFrames(engine, 0.3); await settle(); expect(links).toHaveBeenCalledTimes(2); @@ -595,7 +595,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { }); it('shows the answer to the latest route asked for, whatever order the answers arrive in', async () => { - type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null; gaveUp: boolean }; + type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null; gaveUp: boolean; least: boolean }; const answers: Array<(answer: Answer) => void> = []; const component = fixture.componentInstance as unknown as { routing: { route(): Promise; links(): Promise; dispose(): void }; @@ -613,9 +613,9 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 }); expect(component.routePending()).toBe(true); - answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null, gaveUp: false }); + answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null, gaveUp: false, least: true }); await flushAsync(); - answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null, gaveUp: false }); + answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null, gaveUp: false, least: true }); await flushAsync(); expect(component.routeResult()?.stars.map((star) => star.id)).toEqual([SUN.id, PROXIMA.id]); @@ -623,7 +623,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { // "It gave up" travels to the panel, which says something else for it than for "there is none". component.onRouteRequested({ fromId: SUN.id, toId: ALPHA_CENTAURI.id, rangePc: 0.5 }); - answers[2]({ route: null, neededRangePc: null, gaveUp: true }); + answers[2]({ route: null, neededRangePc: null, gaveUp: true, least: false }); await flushAsync(); expect(component.routeResult()).toMatchObject({ stars: [], gaveUp: true }); }); diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 7a693b9..9778c27 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -1528,7 +1528,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { const request = ++this.routeRequest; this.routePending.set(true); void this.routing.route(fromId, toId, rangePc, ROUTE_RANGE_CEILING_PC).then( - ({ route, neededRangePc, gaveUp }) => { + ({ route, neededRangePc, gaveUp, least }) => { if (request !== this.routeRequest) { return; } @@ -1537,7 +1537,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { stars: route ? route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })) : [], totalPc: route?.totalPc ?? 0, neededRangePc, - gaveUp + gaveUp, + least }); this.jumpLinks?.setRoute(route?.stars ?? [], (id) => this.starsById.get(id)); }, diff --git a/src/app/features/galaxy-system/routing-client.spec.ts b/src/app/features/galaxy-system/routing-client.spec.ts index 314cbe4..feca7df 100644 --- a/src/app/features/galaxy-system/routing-client.spec.ts +++ b/src/app/features/galaxy-system/routing-client.spec.ts @@ -73,7 +73,7 @@ describe('RoutingClient without a worker', () => { it('answers a route from the index it was given', async () => { const client = new RoutingClient(STARS, POSITIONS, index); - await expect(client.route(100, 104, 1.5, 8)).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false }); + await expect(client.route(100, 104, 1.5, 8)).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false, least: true }); client.dispose(); }); @@ -143,8 +143,8 @@ describe('RoutingClient with a worker', () => { await expect(first).resolves.toHaveLength(6); const routeRequest = worker.requests[1]; - worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4, gaveUp: false }); - await expect(route).resolves.toEqual({ route: null, neededRangePc: 4, gaveUp: false }); + worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4, gaveUp: false, least: true }); + await expect(route).resolves.toEqual({ route: null, neededRangePc: 4, gaveUp: false, least: true }); await flush(); expect(worker.requests.map((request) => (request.kind === 'links' ? request.rangePc : request.kind))).toEqual([5, 'route', 8]); @@ -161,14 +161,14 @@ describe('RoutingClient with a worker', () => { const widerRange = client.route(100, 104, 2.5, 8); expect(worker.requests).toHaveLength(1); - worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: 4, gaveUp: false }); + worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: 4, gaveUp: false, least: true }); expect(await again).toEqual(await once); await flush(); // The same two stars at another range is another question. expect(worker.requests.map((request) => request.rangePc)).toEqual([1.5, 2.5]); - worker.answer({ kind: 'route', requestId: worker.requests[1].requestId, route: null, neededRangePc: null, gaveUp: false }); - await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: false }); + worker.answer({ kind: 'route', requestId: worker.requests[1].requestId, route: null, neededRangePc: null, gaveUp: false, least: true }); + await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: false, least: true }); client.dispose(); }); @@ -222,9 +222,9 @@ describe('RoutingClient with a worker', () => { const { client, worker } = clientWithFake(); const answer = client.route(100, 105, 1.5, 8); - worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: null, gaveUp: true }); + worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: null, gaveUp: true, least: false }); - await expect(answer).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: true }); + await expect(answer).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: true, least: false }); client.dispose(); }); @@ -250,7 +250,7 @@ describe('RoutingClient with a worker', () => { worker.fail(); - await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false }); + await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false, least: true }); expect(Array.from(await graph)).toEqual([0, 0, 0, 1, 0, 0]); await expect(client.route(100, 105, 1.5, 8)).resolves.toMatchObject({ route: null }); expect(worker.terminated).toBe(true); diff --git a/src/app/features/galaxy-system/routing-client.ts b/src/app/features/galaxy-system/routing-client.ts index 58bf63f..d213093 100644 --- a/src/app/features/galaxy-system/routing-client.ts +++ b/src/app/features/galaxy-system/routing-client.ts @@ -6,8 +6,10 @@ import { StarRecord } from '../../shared/models/star.model'; export interface RouteAnswer { readonly route: Route | null; readonly neededRangePc: number | null; - /** True when the search gave up rather than ruling a route out; see `routeBetween`. */ + /** True when the search at the range asked for gave up rather than ruling a route out. */ readonly gaveUp: boolean; + /** True when the search for a range that would work looked everywhere up to the ceiling. */ + readonly least: boolean; } /** A request dropped before it was sent, because a newer one of the same kind replaced it. */ @@ -107,8 +109,8 @@ export class RoutingClient { route(fromId: number, toId: number, rangePc: number, ceilingPc: number): Promise { return this.ask({ kind: 'route', requestId: this.nextRequestId++, fromId, toId, rangePc, ceilingPc }).then((response) => response.kind === 'route' - ? { route: response.route, neededRangePc: response.neededRangePc, gaveUp: response.gaveUp } - : { route: null, neededRangePc: null, gaveUp: false } + ? { route: response.route, neededRangePc: response.neededRangePc, gaveUp: response.gaveUp, least: response.least } + : { route: null, neededRangePc: null, gaveUp: false, least: false } ); } diff --git a/src/app/features/hud/hud-dock.component.spec.ts b/src/app/features/hud/hud-dock.component.spec.ts index bee9dd5..26c5412 100644 --- a/src/app/features/hud/hud-dock.component.spec.ts +++ b/src/app/features/hud/hud-dock.component.spec.ts @@ -258,19 +258,26 @@ describe('HudDockComponent', () => { fixture.componentRef.setInput('defaultTab', 'routes'); const summary = () => host().querySelector('[data-testid="route-summary"]')?.textContent?.replace(/\s+/g, ' ').trim() ?? ''; - fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: true }); + fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: true, least: false }); fixture.detectChanges(); expect(summary()).toBe('Too many stars to search at this range.'); // Having looked everywhere the range reaches is a different answer, and one that can be stated. - fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: false }); + fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: false, least: true }); fixture.detectChanges(); expect(summary()).toContain('No chain of jumps up to'); - // A range a chain was found at is worth offering whether or not anything shorter was ruled out. - fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 6.4, gaveUp: true }); + // A range a chain was found at is worth offering — but the search that gave up at the range + // asked for still gave up, and saying "no route" beside the offer is saying it did not. + fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 6.4, gaveUp: true, least: false }); fixture.detectChanges(); - expect(summary()).toBe('No route at this range. 6.40 pc would reach.'); + expect(summary()).toBe('Too many stars to search at this range. 6.40 pc would reach.'); + + // The other way round: the range asked for was searched to exhaustion and the wider search was + // the one that gave up. There is no route at this range, and nothing further can be claimed. + fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: false, least: false }); + fixture.detectChanges(); + expect(summary()).toBe('No route at this range.'); }); it('keeps what the Routes panel was set to across a trip to another tab', () => { diff --git a/src/app/features/hud/routes-panel.component.ts b/src/app/features/hud/routes-panel.component.ts index b7304fb..584aadb 100644 --- a/src/app/features/hud/routes-panel.component.ts +++ b/src/app/features/hud/routes-panel.component.ts @@ -24,6 +24,8 @@ export interface RouteResult { readonly neededRangePc: number | null; /** True when the search gave up rather than showing there is no route at this range. */ readonly gaveUp: boolean; + /** True when the search for a range that would work looked everywhere up to the widest offered. */ + readonly least: boolean; } export interface RouteRequest { @@ -115,7 +117,7 @@ type Field = 'from' | 'to';

} @else {

- @if (plotted.gaveUp && plotted.neededRangePc === null) { + @if (plotted.gaveUp) { Too many stars to search at this range. } @else { No route at this range. @@ -128,7 +130,7 @@ type Field = 'from' | 'to'; > {{ format(plotted.neededRangePc) }} would reach. - } @else if (!plotted.gaveUp) { + } @else if (plotted.least) { No chain of jumps up to {{ format(maxRangePc) }} reaches it. }

diff --git a/src/app/shared/astro/jump-links.spec.ts b/src/app/shared/astro/jump-links.spec.ts index ef693ef..fc3f95d 100644 --- a/src/app/shared/astro/jump-links.spec.ts +++ b/src/app/shared/astro/jump-links.spec.ts @@ -94,14 +94,13 @@ describe('routeBetween', () => { expect(search.gaveUp).toBe(true); }); - it('reports a genuine dead end as one, having looked everywhere the range reaches', () => { - const split = index([ - { id: 0, x: 0, y: 0, z: 0 }, - { id: 1, x: 1, y: 0, z: 0 }, - { id: 2, x: 20, y: 0, z: 0 } - ]); + it('reports a dead end proved with the last star of the budget as a dead end, not a give-up', { timeout: 30_000 }, () => { + // Exactly the budget's worth of stars reach each other, and the destination is not among them. + // The search does look everywhere the range reaches, so "no chain" is what it found — but the + // set is full at the end of it, and a budget read off the settled count says it gave up. + const search = routeBetween(budgetExactly(), 0, BUDGET_ISLAND, 1.5); - expect(routeBetween(split, 0, 2, 5)).toEqual({ route: null, gaveUp: false }); + expect(search).toEqual({ route: null, gaveUp: false }); }); it('heads for the destination rather than exhausting a dense knot around the departure', () => { @@ -131,6 +130,19 @@ function knotAndChain(cellSizePc?: number): StarNeighbourhood { return new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut, { id: ISLAND, x: 500, y: 0, z: 0 }], cellSizePc); } +/** + * Exactly a search's budget of stars that reach one another — 39 999 through the 30 pc cube around + * the origin, plus the departure — and one at 500 pc that nothing reaches. The dead end is real and + * the search proves it, with the last star it is allowed. + */ +const BUDGET_ISLAND = 99_999; +function budgetExactly(): StarNeighbourhood { + let seed = 13; + const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15; + const crowd: StarPoint[] = Array.from({ length: 39_999 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() })); + return new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...crowd, { id: BUDGET_ISLAND, x: 500, y: 0, z: 0 }], 1.5); +} + /** * 45 000 stars in a 10 pc cube — dense enough to stay one connected piece at half a parsec, where * walking it costs more than a search's budget — with a chain a parsec a hop leaving its edge for @@ -166,6 +178,9 @@ describe('minimumRangeBetween', () => { expect(needed.least).toBe(false); expect(needed.rangePc).not.toBeNull(); expect(routeBetween(knot, 0, CROWD_CHAIN_END, needed.rangePc!).route).not.toBeNull(); + // Narrower than the ceiling's own route, too: stopping before the bisection has found a range + // of its own hands back the ceiling, which is the control's maximum — the question, not an answer. + expect(needed.rangePc!).toBeLessThan(routeBetween(knot, 0, CROWD_CHAIN_END, 1.2).route!.longestHopPc); }); it('names the shortest range that opens a way through', () => { diff --git a/src/app/shared/astro/jump-links.ts b/src/app/shared/astro/jump-links.ts index a6ea876..c5fbce1 100644 --- a/src/app/shared/astro/jump-links.ts +++ b/src/app/shared/astro/jump-links.ts @@ -180,11 +180,18 @@ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: num const frontier = new Frontier(); frontier.push(fromId, straightLineOn(origin.x, origin.y, origin.z)); - while (frontier.size > 0 && settled.size < MAX_VISITED) { + let gaveUp = false; + while (frontier.size > 0) { const starId = frontier.pop(); if (settled.has(starId)) { continue; } + // Counted against the budget only once the frontier has been drained of stale duplicates, so + // the flag below records why the search stopped rather than how full the set happened to be. + if (settled.size >= MAX_VISITED) { + gaveUp = true; + break; + } settled.add(starId); const costHere = travelled.get(starId)!; @@ -216,7 +223,7 @@ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: num // An empty frontier means the range reaches nothing further; a spent budget means only that the // search stopped looking. - return { route: null, gaveUp: settled.size >= MAX_VISITED }; + return { route: null, gaveUp }; } /** @@ -235,18 +242,24 @@ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: num * Each step has to answer "is there a chain at this range", and a search that gives up answers * nothing. It is still worth carrying on from — the ranges above it are the ones left to try — but * the result is no longer the least range, only a range that works, and `least` says which. The - * number of steps is bounded for the same reason: each one that gives up walks the whole budget, - * and 11 s of them for a star at 236 pc bought two decimal places nobody reads. + * number of steps that may give up is bounded for the same reason: each one walks the whole budget, + * and 11 s of them for a star at 236 pc bought two decimal places nobody reads. Bounded, but not + * before the bisection has found a range of its own: until then the only range it could offer is + * the ceiling's, which is the control's maximum, for crossings that work well below it. */ export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): RangeSearch { const widest = routeBetween(index, fromId, toId, ceilingPc); if (!widest.route) { return { rangePc: null, least: !widest.gaveUp }; } + const ceilingHopPc = widest.route.longestHopPc; let unreachable = 0; - let reachable = widest.route.longestHopPc; + let reachable = ceilingHopPc; let giveUps = 0; - while (reachable - unreachable > RANGE_RESOLUTION_PC && giveUps < MAX_RANGE_GIVE_UPS) { + // The cap cannot fire while `reachable` is still the ceiling route's own longest hop: that is + // the question, not an answer the bisection earned, and offering it sends the control to its + // maximum for a crossing that works well below — 8.00 pc for a star that routes at 6. + while (reachable - unreachable > RANGE_RESOLUTION_PC && (giveUps < MAX_RANGE_GIVE_UPS || reachable === ceilingHopPc)) { const range = (unreachable + reachable) / 2; const { route, gaveUp } = routeBetween(index, fromId, toId, range); if (route) { diff --git a/src/app/shared/astro/routing.spec.ts b/src/app/shared/astro/routing.spec.ts index 0cf2004..2b975ea 100644 --- a/src/app/shared/astro/routing.spec.ts +++ b/src/app/shared/astro/routing.spec.ts @@ -42,13 +42,13 @@ describe('answerRouting', () => { it('answers a route the range allows, with nothing to raise it to', () => { const answer = answerRouting(index, { kind: 'route', requestId: 7, fromId: 10, toId: 14, rangePc: 1.5, ceilingPc: 8 }); - expect(answer).toEqual({ kind: 'route', requestId: 7, route: routeBetween(direct, 10, 14, 1.5).route, neededRangePc: null, gaveUp: false }); + expect(answer).toEqual({ kind: 'route', requestId: 7, route: routeBetween(direct, 10, 14, 1.5).route, neededRangePc: null, gaveUp: false, least: true }); }); it('answers a route the range does not allow with the range that would', () => { const answer = answerRouting(index, { kind: 'route', requestId: 8, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 8 }); - expect(answer).toEqual({ kind: 'route', requestId: 8, route: null, neededRangePc: minimumRangeBetween(direct, 10, 99, 8).rangePc, gaveUp: false }); + expect(answer).toEqual({ kind: 'route', requestId: 8, route: null, neededRangePc: minimumRangeBetween(direct, 10, 99, 8).rangePc, gaveUp: false, least: true }); expect(answer.kind === 'route' && answer.neededRangePc).toBeCloseTo(5, 1); }); diff --git a/src/app/shared/astro/routing.ts b/src/app/shared/astro/routing.ts index 6c10473..521f89d 100644 --- a/src/app/shared/astro/routing.ts +++ b/src/app/shared/astro/routing.ts @@ -27,10 +27,20 @@ export type RoutingRequest = export type RoutingResponse = /** - * `gaveUp` is true when the searches spent their budget rather than looking everywhere: there - * being no route and no range to offer is then what was found, not what exists. + * Two searches, and two things they can fail to prove, kept apart because they are printed as + * different sentences. `gaveUp` is about the range that was asked for: true when that search + * spent its budget rather than looking everywhere the range reaches. `least` is about the search + * for a range that would work: true when it looked everywhere up to the ceiling, so `null` there + * means no chain exists rather than none was found. */ - | { readonly kind: 'route'; readonly requestId: number; readonly route: Route | null; readonly neededRangePc: number | null; readonly gaveUp: boolean } + | { + readonly kind: 'route'; + readonly requestId: number; + readonly route: Route | null; + readonly neededRangePc: number | null; + readonly gaveUp: boolean; + readonly least: boolean; + } | { readonly kind: 'links'; readonly requestId: number; readonly segments: Float32Array } /** The question threw in the worker. Sent back so the request settles instead of waiting for good. */ | { readonly kind: 'failed'; readonly requestId: number; readonly message: string }; @@ -56,8 +66,9 @@ export function answerRouting(index: StarNeighbourhood, request: RoutingRequest) // At the ceiling the question has just been asked: the range search would repeat it, identically // and at the same cost, before bisecting below it. if (route || request.rangePc >= request.ceilingPc) { - return { kind: 'route', requestId: request.requestId, route, neededRangePc: null, gaveUp: !route && gaveUp }; + // Asked at the ceiling, the one search answers both questions. + return { kind: 'route', requestId: request.requestId, route, neededRangePc: null, gaveUp: !route && gaveUp, least: !gaveUp }; } const needed = minimumRangeBetween(index, request.fromId, request.toId, request.ceilingPc); - return { kind: 'route', requestId: request.requestId, route: null, neededRangePc: needed.rangePc, gaveUp: gaveUp || !needed.least }; + return { kind: 'route', requestId: request.requestId, route: null, neededRangePc: needed.rangePc, gaveUp, least: needed.least }; }