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
This commit is contained in:
@@ -423,7 +423,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
||||||
await advanceFrames(engine, 0.3);
|
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);
|
await advanceFrames(engine, 0.3);
|
||||||
|
|
||||||
// As catalogue indices: the Sun is the first entry of STARS, Proxima the third.
|
// 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]);
|
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.
|
// 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 advanceFrames(engine, 0.3);
|
||||||
await settle();
|
await settle();
|
||||||
expect(links).toHaveBeenCalledTimes(2);
|
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 () => {
|
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 answers: Array<(answer: Answer) => void> = [];
|
||||||
const component = fixture.componentInstance as unknown as {
|
const component = fixture.componentInstance as unknown as {
|
||||||
routing: { route(): Promise<Answer>; links(): Promise<Float32Array>; dispose(): void };
|
routing: { route(): Promise<Answer>; links(): Promise<Float32Array>; dispose(): void };
|
||||||
@@ -613,9 +613,9 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
|
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
|
||||||
expect(component.routePending()).toBe(true);
|
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();
|
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();
|
await flushAsync();
|
||||||
|
|
||||||
expect(component.routeResult()?.stars.map((star) => star.id)).toEqual([SUN.id, PROXIMA.id]);
|
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".
|
// "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 });
|
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();
|
await flushAsync();
|
||||||
expect(component.routeResult()).toMatchObject({ stars: [], gaveUp: true });
|
expect(component.routeResult()).toMatchObject({ stars: [], gaveUp: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1528,7 +1528,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
const request = ++this.routeRequest;
|
const request = ++this.routeRequest;
|
||||||
this.routePending.set(true);
|
this.routePending.set(true);
|
||||||
void this.routing.route(fromId, toId, rangePc, ROUTE_RANGE_CEILING_PC).then(
|
void this.routing.route(fromId, toId, rangePc, ROUTE_RANGE_CEILING_PC).then(
|
||||||
({ route, neededRangePc, gaveUp }) => {
|
({ route, neededRangePc, gaveUp, least }) => {
|
||||||
if (request !== this.routeRequest) {
|
if (request !== this.routeRequest) {
|
||||||
return;
|
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}` })) : [],
|
stars: route ? route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })) : [],
|
||||||
totalPc: route?.totalPc ?? 0,
|
totalPc: route?.totalPc ?? 0,
|
||||||
neededRangePc,
|
neededRangePc,
|
||||||
gaveUp
|
gaveUp,
|
||||||
|
least
|
||||||
});
|
});
|
||||||
this.jumpLinks?.setRoute(route?.stars ?? [], (id) => this.starsById.get(id));
|
this.jumpLinks?.setRoute(route?.stars ?? [], (id) => this.starsById.get(id));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ describe('RoutingClient without a worker', () => {
|
|||||||
it('answers a route from the index it was given', async () => {
|
it('answers a route from the index it was given', async () => {
|
||||||
const client = new RoutingClient(STARS, POSITIONS, index);
|
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();
|
client.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,8 +143,8 @@ describe('RoutingClient with a worker', () => {
|
|||||||
await expect(first).resolves.toHaveLength(6);
|
await expect(first).resolves.toHaveLength(6);
|
||||||
|
|
||||||
const routeRequest = worker.requests[1];
|
const routeRequest = worker.requests[1];
|
||||||
worker.answer({ kind: 'route', requestId: routeRequest.requestId, 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 });
|
await expect(route).resolves.toEqual({ route: null, neededRangePc: 4, gaveUp: false, least: true });
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
expect(worker.requests.map((request) => (request.kind === 'links' ? request.rangePc : request.kind))).toEqual([5, 'route', 8]);
|
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);
|
const widerRange = client.route(100, 104, 2.5, 8);
|
||||||
|
|
||||||
expect(worker.requests).toHaveLength(1);
|
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);
|
expect(await again).toEqual(await once);
|
||||||
await flush();
|
await flush();
|
||||||
// The same two stars at another range is another question.
|
// The same two stars at another range is another question.
|
||||||
expect(worker.requests.map((request) => request.rangePc)).toEqual([1.5, 2.5]);
|
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 });
|
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 });
|
await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: false, least: true });
|
||||||
client.dispose();
|
client.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,9 +222,9 @@ describe('RoutingClient with a worker', () => {
|
|||||||
const { client, worker } = clientWithFake();
|
const { client, worker } = clientWithFake();
|
||||||
const answer = client.route(100, 105, 1.5, 8);
|
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();
|
client.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,7 +250,7 @@ describe('RoutingClient with a worker', () => {
|
|||||||
|
|
||||||
worker.fail();
|
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]);
|
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 });
|
await expect(client.route(100, 105, 1.5, 8)).resolves.toMatchObject({ route: null });
|
||||||
expect(worker.terminated).toBe(true);
|
expect(worker.terminated).toBe(true);
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { StarRecord } from '../../shared/models/star.model';
|
|||||||
export interface RouteAnswer {
|
export interface RouteAnswer {
|
||||||
readonly route: Route | null;
|
readonly route: Route | null;
|
||||||
readonly neededRangePc: number | 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;
|
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. */
|
/** 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<RouteAnswer> {
|
route(fromId: number, toId: number, rangePc: number, ceilingPc: number): Promise<RouteAnswer> {
|
||||||
return this.ask({ kind: 'route', requestId: this.nextRequestId++, fromId, toId, rangePc, ceilingPc }).then((response) =>
|
return this.ask({ kind: 'route', requestId: this.nextRequestId++, fromId, toId, rangePc, ceilingPc }).then((response) =>
|
||||||
response.kind === 'route'
|
response.kind === 'route'
|
||||||
? { route: response.route, neededRangePc: response.neededRangePc, gaveUp: response.gaveUp }
|
? { route: response.route, neededRangePc: response.neededRangePc, gaveUp: response.gaveUp, least: response.least }
|
||||||
: { route: null, neededRangePc: null, gaveUp: false }
|
: { route: null, neededRangePc: null, gaveUp: false, least: false }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -258,19 +258,26 @@ describe('HudDockComponent', () => {
|
|||||||
fixture.componentRef.setInput('defaultTab', 'routes');
|
fixture.componentRef.setInput('defaultTab', 'routes');
|
||||||
const summary = () => host().querySelector('[data-testid="route-summary"]')?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
|
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();
|
fixture.detectChanges();
|
||||||
expect(summary()).toBe('Too many stars to search at this range.');
|
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.
|
// 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();
|
fixture.detectChanges();
|
||||||
expect(summary()).toContain('No chain of jumps up to');
|
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.
|
// A range a chain was found at is worth offering — but the search that gave up at the range
|
||||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 6.4, gaveUp: true });
|
// 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();
|
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', () => {
|
it('keeps what the Routes panel was set to across a trip to another tab', () => {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface RouteResult {
|
|||||||
readonly neededRangePc: number | null;
|
readonly neededRangePc: number | null;
|
||||||
/** True when the search gave up rather than showing there is no route at this range. */
|
/** True when the search gave up rather than showing there is no route at this range. */
|
||||||
readonly gaveUp: boolean;
|
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 {
|
export interface RouteRequest {
|
||||||
@@ -115,7 +117,7 @@ type Field = 'from' | 'to';
|
|||||||
</p>
|
</p>
|
||||||
} @else {
|
} @else {
|
||||||
<p data-testid="route-summary" class="text-sm text-muted">
|
<p data-testid="route-summary" class="text-sm text-muted">
|
||||||
@if (plotted.gaveUp && plotted.neededRangePc === null) {
|
@if (plotted.gaveUp) {
|
||||||
Too many stars to search at this range.
|
Too many stars to search at this range.
|
||||||
} @else {
|
} @else {
|
||||||
No route at this range.
|
No route at this range.
|
||||||
@@ -128,7 +130,7 @@ type Field = 'from' | 'to';
|
|||||||
>
|
>
|
||||||
{{ format(plotted.neededRangePc) }} would reach.
|
{{ format(plotted.neededRangePc) }} would reach.
|
||||||
</button>
|
</button>
|
||||||
} @else if (!plotted.gaveUp) {
|
} @else if (plotted.least) {
|
||||||
No chain of jumps up to {{ format(maxRangePc) }} reaches it.
|
No chain of jumps up to {{ format(maxRangePc) }} reaches it.
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -94,14 +94,13 @@ describe('routeBetween', () => {
|
|||||||
expect(search.gaveUp).toBe(true);
|
expect(search.gaveUp).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a genuine dead end as one, having looked everywhere the range reaches', () => {
|
it('reports a dead end proved with the last star of the budget as a dead end, not a give-up', { timeout: 30_000 }, () => {
|
||||||
const split = index([
|
// Exactly the budget's worth of stars reach each other, and the destination is not among them.
|
||||||
{ id: 0, x: 0, y: 0, z: 0 },
|
// The search does look everywhere the range reaches, so "no chain" is what it found — but the
|
||||||
{ id: 1, x: 1, y: 0, z: 0 },
|
// set is full at the end of it, and a budget read off the settled count says it gave up.
|
||||||
{ id: 2, x: 20, y: 0, z: 0 }
|
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', () => {
|
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);
|
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
|
* 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
|
* 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.least).toBe(false);
|
||||||
expect(needed.rangePc).not.toBeNull();
|
expect(needed.rangePc).not.toBeNull();
|
||||||
expect(routeBetween(knot, 0, CROWD_CHAIN_END, needed.rangePc!).route).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', () => {
|
it('names the shortest range that opens a way through', () => {
|
||||||
|
|||||||
@@ -180,11 +180,18 @@ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: num
|
|||||||
const frontier = new Frontier();
|
const frontier = new Frontier();
|
||||||
frontier.push(fromId, straightLineOn(origin.x, origin.y, origin.z));
|
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();
|
const starId = frontier.pop();
|
||||||
if (settled.has(starId)) {
|
if (settled.has(starId)) {
|
||||||
continue;
|
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);
|
settled.add(starId);
|
||||||
const costHere = travelled.get(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
|
// An empty frontier means the range reaches nothing further; a spent budget means only that the
|
||||||
// search stopped looking.
|
// 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
|
* 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
|
* 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
|
* 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,
|
* 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.
|
* 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 {
|
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): RangeSearch {
|
||||||
const widest = routeBetween(index, fromId, toId, ceilingPc);
|
const widest = routeBetween(index, fromId, toId, ceilingPc);
|
||||||
if (!widest.route) {
|
if (!widest.route) {
|
||||||
return { rangePc: null, least: !widest.gaveUp };
|
return { rangePc: null, least: !widest.gaveUp };
|
||||||
}
|
}
|
||||||
|
const ceilingHopPc = widest.route.longestHopPc;
|
||||||
let unreachable = 0;
|
let unreachable = 0;
|
||||||
let reachable = widest.route.longestHopPc;
|
let reachable = ceilingHopPc;
|
||||||
let giveUps = 0;
|
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 range = (unreachable + reachable) / 2;
|
||||||
const { route, gaveUp } = routeBetween(index, fromId, toId, range);
|
const { route, gaveUp } = routeBetween(index, fromId, toId, range);
|
||||||
if (route) {
|
if (route) {
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ describe('answerRouting', () => {
|
|||||||
it('answers a route the range allows, with nothing to raise it to', () => {
|
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 });
|
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', () => {
|
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 });
|
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);
|
expect(answer.kind === 'route' && answer.neededRangePc).toBeCloseTo(5, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,20 @@ export type RoutingRequest =
|
|||||||
|
|
||||||
export type RoutingResponse =
|
export type RoutingResponse =
|
||||||
/**
|
/**
|
||||||
* `gaveUp` is true when the searches spent their budget rather than looking everywhere: there
|
* Two searches, and two things they can fail to prove, kept apart because they are printed as
|
||||||
* being no route and no range to offer is then what was found, not what exists.
|
* 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 }
|
| { 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. */
|
/** 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 };
|
| { 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
|
// 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.
|
// and at the same cost, before bisecting below it.
|
||||||
if (route || request.rangePc >= request.ceilingPc) {
|
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);
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user