feat: the state becomes California, and the port fills with ships
**Stage 1 of one California.** The owner's complaint had two halves and this is
the first: the state board was a CROPPED SLAB. `california.ts` stopped at 38.05 N,
so the board disagreed with its own minimap about the shape of California in a
single frame, and Bug Fire's 93,733 acres burned off-frame while the panel said
all clear. Bounds now run 32.50-42.05 N / -124.50 to -114.0 W — Cape Mendocino,
the ruled Oregon parallel, the 120th-meridian corner into the Nevada diagonal.
**And it got cheaper.** 391,169 triangles to 375,351, while gaining the North
Coast, the Sacramento Valley, the Klamath knot, the Cascade arc, Shasta at 4,320 m
and Lassen at 3,190 m. Extending the bounds alone would have doubled the lattice
to 168,813 points and blown the mobile cap; coarsening cellLat 0.022 -> 0.0312 and
cellLng 0.027 -> 0.0383 holds it at ~83,800. The cell as a FRACTION of the board
moves 0.0030 -> 0.0033 — unchanged in frame — because the camera retreats to frame
whatever it is given. That argument was already written in the pack's own comment.
The second half — three boards becoming one world you zoom through — is NOT here.
Merging at Bay density would be 34.04M triangles, 13x the highest budget, and
merging at SoCal density would downgrade San Francisco from 40 m lots to 164 m.
Both delete the board every marketing still is shot from. `sf.ts` and `socal.ts`
are untouched by design.
**Aerial perspective, which the state board could not have had before.** The old
fog started at 1.15 board spans = 944 km, on a board whose longest diagonal is
820 km — so no pixel could ever be fogged. Fog now responds to camera altitude,
clamped to the authored pair as a ceiling.
`Atmosphere.aerial(env, view)` is a second pure method returning `{ near, far }`
and **deliberately no colour**. That is structural, not stylistic: it is why a
future camera-dependent term cannot reach `environmentKey()`'s colour fingerprint
and start rebuilding the PMREM cubemap on every camera step. Coarsening the
fingerprint instead would have hidden one instance and armed the mechanism. A
mutation-tested seam guard fails if anyone merges the two paths back together.
**The port.** Terminal Island rendered as a bare tan polygon with generic white
blocks while the chapter text called it the busiest port complex in the
hemisphere. Now six container yards drawn as canvas atlases, 56 gantry cranes at
varied boom angles, the 13 km San Pedro breakwater, the dredged channel. Five
buckets merging ACROSS ports the way airports.ts merges across fields, so a
second complex costs no extra draws: +11 draws and +4,377 triangles for all of it.
At vertical exaggeration 3.4 a 130 m gantry is 1.132 units tall against a 400 m
ship's 1.024 long — the crane is the taller object, and it is what makes a port
read as a port from altitude.
**Ships, and the wake carries the information.** Moored hulls have no foam,
verified at three terminals; a tug under way in the Main Channel trails a clean
Kelvin V. One hull geometry, one InstancedMesh, orientation from the BERTH rather
than the wire. The AIS gate strips sog 102.3, heading 511 and cog 360 — all mean
"not available" — with an explicit test that cog 358.7 SURVIVES, because a naive
range check on cog eats real headings near north.
"Empty or full" is not in AIS position reports and is not invented per ship. The
honest answer is at port level and is a better story: 348,691 of 460,467 boxes
left Los Angeles empty in July 2026, corroborated by FBX01 $7,491 inbound against
FBX02 $347 outbound.
**Radar and birds ship dark, and say why.** California is 0.47% wet and migration
is nocturnal and seasonal, so both layers have nothing to say on most days. The
panel reads "No radar feed is configured, so this board draws no weather. That is
a fact about this box, not about the sky."
Also recorded, and it matters beyond this commit: **the GPU on amd-server never
leaves 500 MHz of a possible 2725**, traced across 80 seconds of sustained load.
`bay-area/desktop` is fragment-bound at that clock and sits on the vsync deadline,
so a trivial change in fragment work flips it between 16.8 and 33.3 with geometry
identical to the digit. Every frame-time number measured on this box is a floor.
Two investigations reached two different wrong conclusions from single-run
comparisons before this was traced. Geometry is the gate; frame time is advisory.
No cap was raised.
Tests 1,340 -> 1,540, server 280 -> 295.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* The vessel gate: three AIS sentinels, one berth, and the position that must
|
||||
* never exist.
|
||||
*
|
||||
* Every assertion in this file is about a defect that **typechecks, throws
|
||||
* nothing and renders a perfectly plausible harbour**. That is why they are
|
||||
* here rather than left to a picture: a fleet of ships all facing due north
|
||||
* looks like a design choice, a hull three kilometres out to sea looks like a
|
||||
* hull three kilometres out to sea, and a ship cutting the corner of a
|
||||
* breakwater looks like a ship. None of the three is visible in a still frame
|
||||
* and all three are wrong.
|
||||
*
|
||||
* The load-bearing one is `cog 358.7 survives`. `cog % 360` is the obvious
|
||||
* normalisation, it is what anybody would write, and it silently converts the
|
||||
* "not available" sentinel — exactly 360.0 — into a course of zero, due north.
|
||||
* Real course over ground reaches 358.7 in the store behind this feed, so a
|
||||
* range check cannot separate them either: the value, and only the value, can.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
AIS_COURSE_UNAVAILABLE,
|
||||
AIS_HEADING_UNAVAILABLE,
|
||||
AIS_SOG_UNAVAILABLE_KN,
|
||||
KNOTS_TO_MPS,
|
||||
VESSEL_MAKING_WAY_MPS,
|
||||
aisCourse,
|
||||
aisHeading,
|
||||
aisSpeedMps,
|
||||
berthAnchors,
|
||||
isMakingWay,
|
||||
metresBetween,
|
||||
modelHarbour,
|
||||
promoteVessels,
|
||||
reckonVessel,
|
||||
resolveBearing,
|
||||
vesselStatus,
|
||||
vesselSummary,
|
||||
type BerthAnchor,
|
||||
type VesselBounds,
|
||||
} from "../../server/vessels.ts";
|
||||
import type { Port } from "../../engine/types.ts";
|
||||
import type { VesselsBody, WireVessel } from "../../server/wire.ts";
|
||||
|
||||
// ---- Fixtures --------------------------------------------------------------
|
||||
|
||||
const SAN_PEDRO: VesselBounds = {
|
||||
minLat: 33.55,
|
||||
maxLat: 33.85,
|
||||
minLng: -118.4,
|
||||
maxLng: -118.0,
|
||||
};
|
||||
|
||||
/**
|
||||
* One berth on Pier 400, bearing 118° — a hull lying alongside it points
|
||||
* east-south-east. Hand-typed like everything else on these boards; the point of
|
||||
* it here is that the number is *authored* and therefore known before any ship
|
||||
* arrives.
|
||||
*/
|
||||
const BERTH: BerthAnchor = { id: "uslax-p400-a", lat: 33.72, lng: -118.24, bearing: 118 };
|
||||
|
||||
function wire(overrides: Partial<WireVessel> = {}): WireVessel {
|
||||
return {
|
||||
id: "w-1",
|
||||
kind: "container",
|
||||
lat: 33.72,
|
||||
lon: -118.24,
|
||||
speed: 0,
|
||||
course: null,
|
||||
heading: null,
|
||||
navStatus: 5,
|
||||
length: 300,
|
||||
beam: 45,
|
||||
ageSeconds: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function body(vessels: WireVessel[], source: VesselsBody["source"] = "cloud1"): VesselsBody {
|
||||
return {
|
||||
source,
|
||||
fetchedAt: "2026-08-22T02:00:00.000Z",
|
||||
vessels,
|
||||
intervalSeconds: 900,
|
||||
ttlSeconds: 900,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- The three sentinels ---------------------------------------------------
|
||||
|
||||
describe("the AIS sentinels, none of which is ever NULL", () => {
|
||||
it("rejects sog 102.3, which is 'not available' and 52.6 m/s", () => {
|
||||
assert.equal(aisSpeedMps(AIS_SOG_UNAVAILABLE_KN), null);
|
||||
assert.equal(aisSpeedMps(102.3), null);
|
||||
// Sixty seconds of it is 3.2 km — eight SoCal scene units — which is the
|
||||
// whole reason this is a rejection and not a clamp.
|
||||
assert.ok(AIS_SOG_UNAVAILABLE_KN * KNOTS_TO_MPS * 60 > 3_000);
|
||||
});
|
||||
|
||||
it("keeps a real speed, including the zero half the fleet reports", () => {
|
||||
assert.equal(aisSpeedMps(0), 0);
|
||||
assert.ok(Math.abs((aisSpeedMps(12) ?? 0) - 12 * KNOTS_TO_MPS) < 1e-9);
|
||||
// Zero is an answer and not an absence: 554 of 1,138 fixes in the store are
|
||||
// exactly 0.0, and conflating them with "unknown" would stop every moored
|
||||
// ship in the product being drawn.
|
||||
assert.notEqual(aisSpeedMps(0), null);
|
||||
});
|
||||
|
||||
it("rejects heading 511, which 40% of fixes carry", () => {
|
||||
assert.equal(aisHeading(AIS_HEADING_UNAVAILABLE), null);
|
||||
assert.equal(aisHeading(511), null);
|
||||
assert.equal(aisHeading(0), 0);
|
||||
assert.equal(aisHeading(359.9), 359.9);
|
||||
});
|
||||
|
||||
it("rejects cog exactly 360.0", () => {
|
||||
assert.equal(aisCourse(AIS_COURSE_UNAVAILABLE), null);
|
||||
assert.equal(aisCourse(360), null);
|
||||
assert.equal(aisCourse(360.0), null);
|
||||
});
|
||||
|
||||
it("ACCEPTS cog 358.7 — the assertion this whole file exists for", () => {
|
||||
// Real course over ground reaches 358.7, and 355.0, 355.4, 355.7, 356.3,
|
||||
// 356.9 and 357.0 all occur in the same store. A naive range check near
|
||||
// north, or a `% 360`, eats every one of them or turns the sentinel into a
|
||||
// course. Neither may happen.
|
||||
assert.equal(aisCourse(358.7), 358.7);
|
||||
for (const cog of [355.0, 355.4, 355.7, 356.3, 356.9, 357.0, 358.7, 359.99]) {
|
||||
assert.equal(aisCourse(cog), cog, `real course ${cog} was eaten by the gate`);
|
||||
}
|
||||
// And the trap itself, stated so nobody reintroduces it: `% 360` maps the
|
||||
// sentinel onto a perfectly good course.
|
||||
assert.equal(AIS_COURSE_UNAVAILABLE % 360, 0);
|
||||
assert.notEqual(aisCourse(AIS_COURSE_UNAVAILABLE), 0);
|
||||
});
|
||||
|
||||
it("survives garbage without throwing", () => {
|
||||
for (const bad of [null, undefined, Number.NaN, Infinity, -1]) {
|
||||
assert.equal(aisSpeedMps(bad as number), null);
|
||||
assert.equal(aisHeading(bad as number), null);
|
||||
assert.equal(aisCourse(bad as number), null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Motion is gated on speed, never on nav_status -------------------------
|
||||
|
||||
describe("speed gates the motion; nav_status only labels it", () => {
|
||||
it("treats nav_status 0 at 0.2 kn as stopped", () => {
|
||||
// 83 of 197 vessels reporting "under way using engine" are under half a
|
||||
// knot. The status is a word on a card; the speed is the fact.
|
||||
const speed = aisSpeedMps(0.2);
|
||||
assert.notEqual(speed, null);
|
||||
assert.equal(isMakingWay(speed), false);
|
||||
assert.equal(vesselStatus(0), "under-way");
|
||||
|
||||
const promotion = promoteVessels(
|
||||
body([wire({ lat: 33.7, lon: -118.2, speed: 0.2 * KNOTS_TO_MPS, navStatus: 0, course: 90 })]),
|
||||
SAN_PEDRO,
|
||||
[],
|
||||
);
|
||||
const drawn = promotion.drawn[0];
|
||||
assert.ok(drawn, "the hull was dropped rather than drawn stopped");
|
||||
assert.equal(drawn.status, "under-way", "the label must survive");
|
||||
assert.equal(drawn.speed, 0, "the motion must not");
|
||||
assert.equal(promotion.makingWay, 0);
|
||||
});
|
||||
|
||||
it("lets a hull at 6 kn make way", () => {
|
||||
const promotion = promoteVessels(
|
||||
body([wire({ lat: 33.7, lon: -118.2, speed: 6 * KNOTS_TO_MPS, navStatus: 0, course: 210 })]),
|
||||
SAN_PEDRO,
|
||||
[],
|
||||
);
|
||||
assert.equal(promotion.makingWay, 1);
|
||||
assert.ok((promotion.drawn[0]?.speed ?? 0) >= VESSEL_MAKING_WAY_MPS);
|
||||
});
|
||||
|
||||
it("refuses a wire speed that is the sentinel in metres per second", () => {
|
||||
// The upstream half of this feed is in another repo on another box and does
|
||||
// not exist yet, so "already stripped" is a promise nobody can keep today.
|
||||
const promotion = promoteVessels(
|
||||
body([wire({ speed: AIS_SOG_UNAVAILABLE_KN * KNOTS_TO_MPS })]),
|
||||
SAN_PEDRO,
|
||||
[BERTH],
|
||||
);
|
||||
assert.equal(promotion.drawn.length, 0);
|
||||
assert.equal(promotion.suppressed, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Orientation comes from the berth --------------------------------------
|
||||
|
||||
describe("a berthed hull points the way its quay does", () => {
|
||||
it("gives a stopped hull with no heading and no cog its berth's bearing", () => {
|
||||
// 21 of 150 stopped vessels have neither heading nor cog, and only 75 of the
|
||||
// 150 have a heading at all. This is the case the layer must not be spun by.
|
||||
const promotion = promoteVessels(
|
||||
body([wire({ lat: BERTH.lat, lon: BERTH.lng, heading: null, course: null, speed: 0 })]),
|
||||
SAN_PEDRO,
|
||||
[BERTH],
|
||||
);
|
||||
const drawn = promotion.drawn[0];
|
||||
assert.ok(drawn, "a hull with no orientation from the wire was dropped");
|
||||
assert.equal(drawn.bearing, BERTH.bearing);
|
||||
assert.equal(drawn.berthId, BERTH.id);
|
||||
assert.equal(promotion.alongside, 1);
|
||||
});
|
||||
|
||||
it("lets the wire nudge a berthed hull, and never swing it", () => {
|
||||
const nudged = resolveBearing({ heading: 121, course: null, speedMps: 0, berthBearing: 118 });
|
||||
assert.equal(nudged, 121);
|
||||
// A heading 180 degrees off the quay is an AIS unit that is wrong about
|
||||
// which end is the bow, not a ship moored backwards. The concrete wins.
|
||||
const absurd = resolveBearing({ heading: 298, course: null, speedMps: 0, berthBearing: 118 });
|
||||
assert.ok(absurd !== null && Math.abs(absurd - 118) <= 3, `berth bearing was swung to ${absurd}`);
|
||||
});
|
||||
|
||||
it("uses the course for a hull making way, and the heading for one at anchor", () => {
|
||||
assert.equal(resolveBearing({ heading: 30, course: 210, speedMps: 5, berthBearing: null }), 210);
|
||||
assert.equal(resolveBearing({ heading: 30, course: 210, speedMps: 0, berthBearing: null }), 30);
|
||||
});
|
||||
|
||||
it("withholds a hull nothing will orient, rather than inventing an angle", () => {
|
||||
const promotion = promoteVessels(
|
||||
body([wire({ lat: 33.6, lon: -118.1, heading: null, course: null, speed: 0 })]),
|
||||
SAN_PEDRO,
|
||||
[BERTH],
|
||||
);
|
||||
assert.equal(promotion.drawn.length, 0);
|
||||
assert.equal(promotion.withoutOrientation, 1);
|
||||
assert.equal(promotion.suppressed, 0, "an unoriented hull is not the same as an unreadable one");
|
||||
assert.match(vesselSummary(promotion), /which way/);
|
||||
});
|
||||
|
||||
it("does not reach across the harbour for a berth", () => {
|
||||
// A berth is one authored point and the reach is 400 m; a hull a kilometre
|
||||
// away is not lying on it.
|
||||
assert.ok(metresBetween(BERTH.lat, BERTH.lng, 33.73, -118.24) > 400);
|
||||
const promotion = promoteVessels(
|
||||
body([wire({ lat: 33.73, lon: -118.24, heading: 44, course: null, speed: 0 })]),
|
||||
SAN_PEDRO,
|
||||
[BERTH],
|
||||
);
|
||||
assert.equal(promotion.drawn[0]?.bearing, 44, "the wire heading should stand off the berth");
|
||||
assert.equal(promotion.drawn[0]?.berthId, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Dead reckoning, and the position that must not exist ------------------
|
||||
|
||||
describe("motion is along the reported course and never between two fixes", () => {
|
||||
/**
|
||||
* Two fixes fifteen minutes apart, on a hull that turned.
|
||||
*
|
||||
* `A` reports a course of 090 — due east — and eight knots. Fifteen minutes
|
||||
* later it is reported at `B`, which is to the *south* east, because it came
|
||||
* round the breakwater in between. The chord from A to B is therefore a line
|
||||
* nothing sailed, and every point on it except the ends is a place the ship
|
||||
* never was.
|
||||
*/
|
||||
const A = { lat: 33.72, lng: -118.28, speed: 8 * KNOTS_TO_MPS, course: 90 };
|
||||
const B = { lat: 33.69, lng: -118.2 };
|
||||
|
||||
it("advances along the course at the speed", () => {
|
||||
const after = reckonVessel(A, 450);
|
||||
const metres = metresBetween(A.lat, A.lng, after.lat, after.lng);
|
||||
assert.ok(
|
||||
Math.abs(metres - A.speed * 450) < 1,
|
||||
`advanced ${metres.toFixed(1)} m instead of ${(A.speed * 450).toFixed(1)}`,
|
||||
);
|
||||
// Due east means the latitude does not move.
|
||||
assert.ok(Math.abs(after.lat - A.lat) < 1e-9, "a course of 090 changed the latitude");
|
||||
assert.ok(after.lng > A.lng, "a course of 090 went west");
|
||||
});
|
||||
|
||||
it("does not arrive at the second fix, which is what an interpolator does", () => {
|
||||
// The crispest statement of the rule. An interpolator hands back exactly `B`
|
||||
// at the end of the interval; a dead-reckoner hands back wherever the
|
||||
// reported course took the ship, which here is five kilometres away because
|
||||
// the ship turned and the course did not say so until the next fix.
|
||||
const atInterval = reckonVessel(A, 900);
|
||||
const missBy = metresBetween(atInterval.lat, atInterval.lng, B.lat, B.lng);
|
||||
assert.ok(missBy > 3_000, `reckoning landed ${missBy.toFixed(0)} m from the second fix`);
|
||||
});
|
||||
|
||||
it("never lands on the chord between two fixes", () => {
|
||||
// The geometric statement of "no interpolation": walk the interval and
|
||||
// assert every reckoned point stays clear of the segment A->B. It cannot be
|
||||
// otherwise, because `reckonVessel` is handed one fix and has no second
|
||||
// point to reach toward — but that is the property being pinned.
|
||||
//
|
||||
// From 150 s rather than from zero, because the first fix *is* an endpoint
|
||||
// of the chord and the ship genuinely was there: a clearance test that
|
||||
// started at t=0 would be asserting the ship was never at its own reported
|
||||
// position.
|
||||
let closest = Infinity;
|
||||
for (let t = 150; t <= 900; t += 30) {
|
||||
const at = reckonVessel(A, t);
|
||||
closest = Math.min(closest, metresToSegment(at, A, B));
|
||||
}
|
||||
assert.ok(
|
||||
closest > 200,
|
||||
`a reckoned position came within ${closest.toFixed(0)} m of the chord between two fixes`,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops rather than sailing on for ever once the next fix is overdue", () => {
|
||||
const atLimit = reckonVessel(A, 900);
|
||||
const wayPast = reckonVessel(A, 4_000);
|
||||
assert.deepEqual(wayPast, atLimit);
|
||||
});
|
||||
|
||||
it("does not move a hull with no course, whatever its speed says", () => {
|
||||
const still = reckonVessel({ lat: 33.72, lng: -118.28, speed: 6, course: null }, 600);
|
||||
assert.deepEqual(still, { lat: 33.72, lng: -118.28 });
|
||||
});
|
||||
|
||||
it("does not move a stopped hull", () => {
|
||||
const still = reckonVessel({ lat: 33.72, lng: -118.28, speed: 0, course: 90 }, 600);
|
||||
assert.deepEqual(still, { lat: 33.72, lng: -118.28 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The empty state, designed first ---------------------------------------
|
||||
|
||||
describe("the harbour with nothing in it says which kind of nothing it is", () => {
|
||||
it("distinguishes an unconfigured feed from an empty board", () => {
|
||||
const unconfigured = promoteVessels(null, SAN_PEDRO, []);
|
||||
assert.equal(unconfigured.source, "none");
|
||||
assert.match(vesselSummary(unconfigured), /No vessel feed is configured/i);
|
||||
|
||||
const answered = promoteVessels(body([]), SAN_PEDRO, []);
|
||||
assert.equal(answered.source, "cloud1");
|
||||
assert.match(vesselSummary(answered), /answered/i);
|
||||
});
|
||||
|
||||
it("counts what it withheld rather than going blank", () => {
|
||||
const promotion = promoteVessels(
|
||||
body([
|
||||
wire({ id: "off", lat: 30.0, lon: -118.2, heading: 10 }),
|
||||
wire({ id: "bad", lat: Number.NaN, heading: 10 }),
|
||||
wire({ id: "blind", lat: 33.6, lon: -118.1, heading: null, course: null }),
|
||||
]),
|
||||
SAN_PEDRO,
|
||||
[],
|
||||
);
|
||||
assert.equal(promotion.drawn.length, 0);
|
||||
assert.equal(promotion.offBoard, 1);
|
||||
assert.equal(promotion.suppressed, 1);
|
||||
assert.equal(promotion.withoutOrientation, 1);
|
||||
const summary = vesselSummary(promotion);
|
||||
assert.match(summary, /outside the frame/);
|
||||
assert.match(summary, /unreadable/);
|
||||
});
|
||||
|
||||
it("never labels a hull laden or in ballast", () => {
|
||||
const promotion = promoteVessels(
|
||||
body([wire({ lat: BERTH.lat, lon: BERTH.lng })]),
|
||||
SAN_PEDRO,
|
||||
[BERTH],
|
||||
);
|
||||
// The owner asked "whether they are empty or full". The honest answer is a
|
||||
// port figure — 348,691 of 460,467 boxes left Los Angeles empty in July
|
||||
// 2026 — and it is never attached to a hull, because `vessels` carries no
|
||||
// draught column and the static AIS message is absent for most ships.
|
||||
assert.match(vesselSummary(promotion), /not a ship one/);
|
||||
assert.equal("draught" in (promotion.drawn[0] ?? {}), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The modelled harbour --------------------------------------------------
|
||||
|
||||
const PORT: Port = {
|
||||
id: "USLAX",
|
||||
name: "Port of Los Angeles",
|
||||
lat: 33.73,
|
||||
lng: -118.26,
|
||||
harborType: "CB",
|
||||
channel: [
|
||||
[33.705, -118.26],
|
||||
[33.72, -118.255],
|
||||
[33.74, -118.25],
|
||||
],
|
||||
berths: [
|
||||
{ id: "a", lat: 33.735, lng: -118.262, bearing: 118, maxLength: 400 },
|
||||
{ id: "b", lat: 33.737, lng: -118.259, bearing: 118, maxLength: 400 },
|
||||
{ id: "c", lat: 33.739, lng: -118.256, bearing: 118, maxLength: 340 },
|
||||
{ id: "d", lat: 33.741, lng: -118.253, bearing: 296, maxLength: 120 },
|
||||
],
|
||||
};
|
||||
|
||||
describe("the modelled harbour, which is what runs this round", () => {
|
||||
it("is deterministic — two people see the same ships", () => {
|
||||
const a = modelHarbour([PORT], { seed: 115, atMs: 1_000_000 });
|
||||
const b = modelHarbour([PORT], { seed: 115, atMs: 1_000_000 });
|
||||
assert.deepEqual(a, b);
|
||||
const other = modelHarbour([PORT], { seed: 116, atMs: 1_000_000 });
|
||||
assert.notDeepEqual(other.vessels.map((v) => v.id), []);
|
||||
});
|
||||
|
||||
it("carries no name, no MMSI, no callsign and no destination", () => {
|
||||
// The store has CSCL INDIAN OCEAN and EVER LOVELY in it right now, and
|
||||
// hardcoding them would be the fire layer's twenty-two orange marks in a
|
||||
// nicer costume. Identity arrives with a licence entry or not at all.
|
||||
for (const vessel of modelHarbour([PORT], { seed: 115 }).vessels) {
|
||||
for (const forbidden of ["name", "mmsi", "callsign", "destination", "draught", "laden"]) {
|
||||
assert.equal(forbidden in vessel, false, `a modelled vessel carried ${forbidden}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("says it is modelled, and the panel says so too", () => {
|
||||
const modelled = modelHarbour([PORT], { seed: 115 });
|
||||
assert.equal(modelled.source, "modelled");
|
||||
assert.equal(modelled.intervalSeconds, 900);
|
||||
const promotion = promoteVessels(modelled, SAN_PEDRO, berthAnchors([PORT]));
|
||||
assert.match(vesselSummary(promotion), /Modelled/);
|
||||
assert.match(vesselSummary(promotion), /no names and no MMSIs/);
|
||||
});
|
||||
|
||||
it("volunteers no heading, so the berths have to do the work", () => {
|
||||
const modelled = modelHarbour([PORT], { seed: 115 });
|
||||
assert.ok(modelled.vessels.length > 0);
|
||||
for (const vessel of modelled.vessels) assert.equal(vessel.heading, null);
|
||||
|
||||
const promotion = promoteVessels(modelled, SAN_PEDRO, berthAnchors([PORT]));
|
||||
assert.ok(promotion.alongside > 0, "no modelled hull found its berth");
|
||||
for (const drawn of promotion.drawn) {
|
||||
if (!drawn.berthId) continue;
|
||||
const berth = PORT.berths?.find((b) => b.id === drawn.berthId);
|
||||
assert.equal(drawn.bearing, berth?.bearing);
|
||||
}
|
||||
});
|
||||
|
||||
it("puts a handful under way on the channel and the rest alongside", () => {
|
||||
const promotion = promoteVessels(
|
||||
modelHarbour([PORT], { seed: 115, underWayPerPort: 3 }),
|
||||
SAN_PEDRO,
|
||||
berthAnchors([PORT]),
|
||||
);
|
||||
assert.equal(promotion.makingWay, 3);
|
||||
assert.ok(promotion.alongside >= 2, "the quays came out empty");
|
||||
// Every moving hull has a course, or the layer could not reckon it and would
|
||||
// not draw a wake — which is the one thing that reads at board scale.
|
||||
for (const drawn of promotion.drawn) {
|
||||
if (drawn.speed > 0) assert.notEqual(drawn.course, null);
|
||||
}
|
||||
});
|
||||
|
||||
it("draws nothing at all for a board with no ports", () => {
|
||||
const modelled = modelHarbour(undefined, { seed: 115 });
|
||||
assert.deepEqual(modelled.vessels, []);
|
||||
const promotion = promoteVessels(modelled, SAN_PEDRO, []);
|
||||
assert.deepEqual(promotion.drawn, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Geometry helper -------------------------------------------------------
|
||||
|
||||
/** Metres from a point to the segment `a`-`b`, in the flat local approximation. */
|
||||
function metresToSegment(
|
||||
p: { lat: number; lng: number },
|
||||
a: { lat: number; lng: number },
|
||||
b: { lat: number; lng: number },
|
||||
): number {
|
||||
const scale = Math.cos((a.lat * Math.PI) / 180);
|
||||
const px = (p.lng - a.lng) * scale;
|
||||
const py = p.lat - a.lat;
|
||||
const bx = (b.lng - a.lng) * scale;
|
||||
const by = b.lat - a.lat;
|
||||
const denominator = bx * bx + by * by;
|
||||
const t = denominator > 0 ? Math.max(0, Math.min(1, (px * bx + py * by) / denominator)) : 0;
|
||||
const dx = px - bx * t;
|
||||
const dy = py - by * t;
|
||||
return Math.hypot(dx, dy) * 111_320;
|
||||
}
|
||||
Reference in New Issue
Block a user