Files
star-map/src/app/shared/astro/routing.ts
T
SenrokaiandClaude Opus 5 1a53f26474 Answer the review: say which search gave up, and let the bisection earn its offer
The panel printed "No route at this range." beside the range it was offering —
which is the sentence this branch exists to stop it printing. It was gated on
there being no offer, and a search that gives up usually has one: Sol to
HD 120147 at 4.5 pc spends the budget, offers 4.83 pc, and says there is no
route where a 71-jump route exists. The wording now follows the search at the
range that was asked for, and nothing else.

That needs the two give-ups kept apart, so `least` travels beside `gaveUp` to
the panel: one says the asked range was not searched out, the other that the
search for a range that would work was. HIP 69445 at 3 pc — asked-range search
exhaustive in 44 ms, ceiling probe out of budget — used to read "Too many stars
to search at this range." and now reads "No route at this range.", with nothing
claimed after it.

Two more from the same review. The budget flag was read off the settled count,
so a search that proved a dead end with the last star it was allowed reported a
give-up; it now records why the loop stopped. And the bisection's cap could fire
before a single probe had narrowed anything, leaving the ceiling route's own
longest hop as the answer: star 1000115173 at 3 pc was told to go to 8.00 pc,
the control's maximum, for a crossing that works at 6. It now offers 6.93.

Measured in the app, all three: "Too many stars to search at this range.
4.90 pc would reach.", "No route at this range." alone, and 7.00 pc in place of
8.00. The duplicated dead-end test now asks the question it was named for —
exactly the budget's worth of stars reaching each other and none of them the
destination — and each fix kills its own mutant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 14:46:47 +02:00

75 lines
4.2 KiB
TypeScript

/**
* The route questions the map asks of the whole catalogue, as messages: what a worker is sent,
* what it sends back, and the one function that turns the first into the second.
*
* Kept apart from the worker itself so it runs the same on either side of the thread boundary.
* The scene asks through `RoutingClient`, which hands these to a Web Worker where one exists and
* answers them in place where one does not.
*/
import { jumpLinkSegments, LinkBudget, minimumRangeBetween, Route, routeBetween } from './jump-links';
import { StarNeighbourhood } from './star-neighbourhood';
/** The catalogue, sent once: ids, and positions packed three to a star in the same order. */
export interface RoutingCatalogue {
readonly kind: 'catalogue';
readonly ids: Int32Array;
readonly positions: Float32Array;
}
export type RoutingRequest =
| { readonly kind: 'route'; readonly requestId: number; readonly fromId: number; readonly toId: number; readonly rangePc: number; readonly ceilingPc: number }
/**
* `drawn` is the stars the map is drawing, as positions in the catalogue that was sent: only they
* are linked. `budget`, where given, keeps only the links nearest the view that fit its length.
*/
| { readonly kind: 'links'; readonly requestId: number; readonly rangePc: number; readonly drawn: Uint32Array; readonly budget?: LinkBudget };
export type RoutingResponse =
/**
* 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 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 };
/** A spatial index over a catalogue sent as a {@link RoutingCatalogue}. */
export function indexCatalogue({ ids, positions }: RoutingCatalogue): StarNeighbourhood {
return new StarNeighbourhood(Array.from(ids, (id, i) => ({ id, x: positions[i * 3], y: positions[i * 3 + 1], z: positions[i * 3 + 2] })));
}
/**
* Answers one request. A route that cannot be made comes back with the range that would make one,
* searched no wider than `ceilingPc`, so a refusal is usually also an offer — unless the searches
* gave up, which is reported rather than passed off as "there is no route".
*/
export function answerRouting(index: StarNeighbourhood, request: RoutingRequest): RoutingResponse {
if (request.kind === 'links') {
// An index of its own over the drawn stars, in cells as wide as the range, so each cell is
// paired with its immediate neighbours only: 14 cells a cell at 8 pc rather than 63.
const drawn = new StarNeighbourhood(Array.from(request.drawn, (at) => index.pointAt(at)), request.rangePc);
return { kind: 'links', requestId: request.requestId, segments: jumpLinkSegments(drawn, request.rangePc, request.budget) };
}
const { route, gaveUp } = routeBetween(index, request.fromId, request.toId, request.rangePc);
// 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) {
// 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, least: needed.least };
}