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
- @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 }; }