@dreadwood-types/types 0.1.191 → 0.1.193

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,8 +24,13 @@
24
24
  * client draws the same train from the clock sent with the chunks (`trainClock`). The trains run the same timetable
25
25
  * WESTERN_TRAIN_COUNT apart. Stops other than the platform are data (WESTERN_TRAIN_STOPS, `enabled: false`) until their
26
26
  * places are built; switching one on adds its dwell to the cycle.
27
+ *
28
+ * THE CIRCUIT VERSION (slice 2-0, plan .cursor/task-plans/2026-10-11-western-phase2-circuit-plan.md): the legs, stops and
29
+ * train count are one WesternTrainCircuit with a version and a key. The game deploys on a push while the backend waits
30
+ * for a release, so the server sends its circuit's key (`trainCircuit`, with the clock) and the client draws and times
31
+ * THAT circuit (westernCircuitForKey); every train sum takes the circuit as its last argument (the server's by default).
27
32
  */
28
- import { CAPITAL_BUILDING_MODELS, HEIGHT_STEP, UNDERGROUND_END_X } from './constants.js';
33
+ import { CAPITAL_BUILDING_MODELS, CHUNK_SIZE, CHUNK_VIEW_RADIUS, HEIGHT_STEP, UNDERGROUND_END_X, worldRegionAt } from './constants.js';
29
34
  import { WESTERN_CARS, WESTERN_CAUSEWAY, WESTERN_GANGWAYS, WESTERN_GAP_ROW, WESTERN_JOINTS, WESTERN_KIT_DECK_METRES, WESTERN_KNOLL, WESTERN_PLATFORM, WESTERN_PLATFORM_PIECES, WESTERN_PRECINCT_HEIGHT, WESTERN_RAKE_SLOTS, WESTERN_SEA_END, WESTERN_SEA_KERB, WESTERN_STEPS, WESTERN_TRACK, WESTERN_TRACK_Z, WESTERN_TUNNEL_MOUTH, westernCausewayHeight, } from './westernEntrance.js';
30
35
  /* ------------------------------------------------------------------ *
31
36
  * The docked rake (the cart-local frame).
@@ -198,20 +203,50 @@ export function inWesternIsleStub(x, z) {
198
203
  return (x >= b.minX && x <= b.maxX && z >= b.minZ && z <= b.maxZ) ||
199
204
  (x >= WESTERN_ISLE_CAUSEWAY.minX && x <= WESTERN_ISLE_CAUSEWAY.maxX && z >= WESTERN_ISLE_CAUSEWAY.minZ && z <= WESTERN_ISLE_CAUSEWAY.maxZ);
200
205
  }
206
+ /* ------------------------------------------------------------------ *
207
+ * The timetable's constants.
208
+ * ------------------------------------------------------------------ */
209
+ /** Every train counts from this instant (a fixed date, so a restart never moves a train). */
210
+ export const WESTERN_TRAIN_EPOCH_MS = Date.UTC(2026, 9, 10, 0, 0, 0);
211
+ /** Cruising speed (tiles a second) and the rate it gets up to it and slows from it. */
212
+ export const WESTERN_TRAIN_TOP_SPEED = 6;
213
+ export const WESTERN_TRAIN_ACCEL = 0.5;
214
+ /** The bell rings this long before a departure; the step plates shut this long before it. */
215
+ export const WESTERN_TRAIN_BELL_BEFORE_MS = 15_000;
216
+ export const WESTERN_TRAIN_DOORS_SHUT_BEFORE_MS = 5_000;
217
+ /** The whistle and the bell are heard this far (tiles) from the loco. */
218
+ export const WESTERN_TRAIN_SOUND_RANGE = 40;
219
+ export const WESTERN_TRAIN_MOVING_TEXT = 'The train is moving.';
220
+ export const WESTERN_TRAIN_DOORS_SHUT_TEXT = 'The doors are shut: the train is about to leave.';
221
+ export const WESTERN_TRAIN_ABOARD_COMBAT_TEXT = 'There is no fighting aboard the train.';
222
+ export const WESTERN_TRAIN_ALIGHT_MOVING_TEXT = 'The train is moving. Wait until it stops at the platform.';
223
+ /** A stop's arriving announcement goes to its riders this long before the train stands there. */
224
+ export const WESTERN_TRAIN_ANNOUNCE_BEFORE_MS = 10_000;
225
+ export const WESTERN_TRAIN_MEMBERS_ONLY_TEXT = 'Only members may step off here. Stay aboard: the train runs on to the next stop.';
226
+ export const WESTERN_BORE_A = {
227
+ id: 'bore-a',
228
+ x: WESTERN_TUNNEL_X,
229
+ z0: WESTERN_TUNNEL_START_Z,
230
+ length: WESTERN_TUNNEL_LENGTH,
231
+ halfWidth: WESTERN_TUNNEL_HALF_WIDTH,
232
+ floorHeight: WESTERN_TUNNEL_FLOOR_HEIGHT,
233
+ sconceEvery: WESTERN_TUNNEL_SCONCE_EVERY,
234
+ idPrefix: 'western-tunnel',
235
+ };
236
+ /** A bore's floor tiles, walls excluded (bore A's is WESTERN_TUNNEL_BOX). */
237
+ export function westernBoreBox(bore) {
238
+ return { minX: bore.x - bore.halfWidth, maxX: bore.x + bore.halfWidth, minZ: bore.z0, maxZ: bore.z0 + bore.length - 1 };
239
+ }
201
240
  const SURFACE_LENGTH = WESTERN_SEA_END.x - (WESTERN_TRACK.minX - 1) + 1; // x 199 to x 26 (the mouth tile): 174
202
241
  /** The spike's third leg: the isle causeway's sea end west to the tile in front of the stub's portal (102 tiles). */
203
242
  const ISLE_LENGTH = WESTERN_ISLE_SEA_END_X - (WESTERN_ISLE_MOUTH.x + 1) + 1;
204
243
  const ISLE_C0 = SURFACE_LENGTH + WESTERN_TUNNEL_LENGTH;
205
- export const WESTERN_TRAIN_SEGMENTS = [
206
- { id: 'surface', c0: 0, length: SURFACE_LENGTH, x0: WESTERN_SEA_END.x, z0: WESTERN_TRACK_Z, dx: -1, dz: 0 },
207
- { id: 'tunnel', c0: SURFACE_LENGTH, length: WESTERN_TUNNEL_LENGTH, x0: WESTERN_TUNNEL_X, z0: WESTERN_TUNNEL_START_Z, dx: 0, dz: 1 },
208
- { id: 'isle', c0: ISLE_C0, length: ISLE_LENGTH, x0: WESTERN_ISLE_SEA_END_X, z0: WESTERN_ISLE_TRACK_Z, dx: -1, dz: 0 },
209
- ];
210
244
  /** c of the nose when the train stands at the isle stub (the nose on the platform's west end, as at home). */
211
245
  export const WESTERN_ISLE_STOP_C = ISLE_C0 + (WESTERN_ISLE_SEA_END_X - WESTERN_ISLE_PLATFORM.minX);
212
- export const WESTERN_TRAIN_CIRCUIT_LENGTH = WESTERN_TRAIN_SEGMENTS.reduce((n, s) => n + s.length, 0);
213
246
  /** c of the nose when the train stands at the platform (loco's nose at x 30). */
214
247
  export const WESTERN_TRAIN_PLATFORM_C = WESTERN_SEA_END.x - WESTERN_TRAIN_NOSE_X;
248
+ /** The surface leg's x range the rake is drawn on (the mouth hides it to the west, the sea to the east). */
249
+ export const WESTERN_TRAIN_SURFACE_DRAW = { minX: 18, maxX: WESTERN_SEA_END.x + 8 };
215
250
  /** The lateral unit: the docked frame's +z (the sea side) for a leg heading (dx, dz). */
216
251
  function lateralOf(seg) {
217
252
  return { lx: seg.dz, lz: -seg.dx };
@@ -221,16 +256,256 @@ export function westernTrainSegmentYaw(seg) {
221
256
  // A model facing +z yawed by t faces (sin t, cos t); the docked heading (-1, 0) is t = -pi/2.
222
257
  return Math.atan2(seg.dx, seg.dz) + Math.PI / 2;
223
258
  }
224
- export function wrapWesternC(c) {
225
- const n = WESTERN_TRAIN_CIRCUIT_LENGTH;
226
- return ((c % n) + n) % n;
259
+ /** Duration (ms) of a run of D tiles from a stop to a stop: up to speed, cruise, slow to a stand. */
260
+ export function westernTrainRunMs(distance) {
261
+ const v = WESTERN_TRAIN_TOP_SPEED, a = WESTERN_TRAIN_ACCEL;
262
+ const ramp = (v * v) / a; // tiles spent getting up to speed and slowing again
263
+ const s = distance >= ramp ? distance / v + v / a : 2 * Math.sqrt(distance / a);
264
+ return s * 1000;
265
+ }
266
+ /** Tiles covered after t ms of a run of D tiles. */
267
+ export function westernTrainRunTiles(distance, tMs) {
268
+ const v = WESTERN_TRAIN_TOP_SPEED, a = WESTERN_TRAIN_ACCEL;
269
+ const total = westernTrainRunMs(distance) / 1000;
270
+ const t = Math.max(0, Math.min(total, tMs / 1000));
271
+ const peak = Math.min(v, Math.sqrt(distance * a));
272
+ const ta = peak / a;
273
+ if (t <= ta)
274
+ return 0.5 * a * t * t;
275
+ if (t >= total - ta)
276
+ return distance - 0.5 * a * (total - t) ** 2;
277
+ return 0.5 * a * ta * ta + peak * (t - ta);
278
+ }
279
+ const wrapOn = (c, n) => ((c % n) + n) % n;
280
+ function runningStops(stops) {
281
+ return stops.filter((s) => s.enabled).sort((a, b) => a.c - b.c);
282
+ }
283
+ function timetableOf(stops, length) {
284
+ const running = runningStops(stops);
285
+ const legs = [];
286
+ let t = 0;
287
+ running.forEach((stop, i) => {
288
+ const next = running[(i + 1) % running.length];
289
+ const runTiles = wrapOn(next.c - stop.c, length) || length;
290
+ const startMs = t;
291
+ const departMs = startMs + stop.dwellMs;
292
+ const endMs = departMs + westernTrainRunMs(runTiles);
293
+ legs.push({ stop, startMs, departMs, runTiles, endMs });
294
+ t = endMs;
295
+ });
296
+ return { cycleMs: t, legs };
297
+ }
298
+ /** 32-bit FNV-1a, hex: the circuit key's hash (stable across engines, no crypto needed). */
299
+ function fnv1a(text) {
300
+ let h = 0x811c9dc5;
301
+ for (let i = 0; i < text.length; i++) {
302
+ h ^= text.charCodeAt(i);
303
+ h = Math.imul(h, 0x01000193) >>> 0;
304
+ }
305
+ return h.toString(16).padStart(8, '0');
227
306
  }
228
- export function westernTrainSegmentAt(c) {
229
- const w = wrapWesternC(c);
230
- for (const seg of WESTERN_TRAIN_SEGMENTS)
307
+ /**
308
+ * Build and check a circuit. Throws on a circuit the line cannot run: legs that are not unit steps along x or z, a
309
+ * surface leg not heading west (the causeway profile is the home line's), stops off the track or on the same c, no home
310
+ * stop or more than one, a home stop whose rake is not the docked frame itself, a train count outside 1-8.
311
+ */
312
+ export function defineWesternCircuit(def) {
313
+ if (!Number.isInteger(def.version) || def.version < 1)
314
+ throw new Error(`circuit version ${def.version}: a whole number from 1`);
315
+ if (!Number.isInteger(def.trainCount) || def.trainCount < 1 || def.trainCount > 8)
316
+ throw new Error(`circuit v${def.version}: ${def.trainCount} trains (1-8)`);
317
+ if (def.segments.length === 0)
318
+ throw new Error(`circuit v${def.version}: no legs`);
319
+ let c0 = 0;
320
+ const segments = def.segments.map((s) => {
321
+ if (Math.abs(s.dx) + Math.abs(s.dz) !== 1 || !Number.isInteger(s.dx) || !Number.isInteger(s.dz))
322
+ throw new Error(`leg ${s.id}: not a unit step along x or z`);
323
+ if (!Number.isInteger(s.length) || s.length < 1)
324
+ throw new Error(`leg ${s.id}: length ${s.length}`);
325
+ if (s.kind === 'surface' && s.dx !== -1)
326
+ throw new Error(`leg ${s.id}: a surface leg heads west (the causeway profile)`);
327
+ if (s.kind === 'bore' && (!s.bore || s.dx !== 0 || s.x0 !== s.bore.x))
328
+ throw new Error(`leg ${s.id}: a bore leg runs down its bore's line`);
329
+ const seg = { ...s, c0, drawBefore: s.drawBefore ?? 4, drawAfter: s.drawAfter ?? 4 };
330
+ c0 += s.length;
331
+ return seg;
332
+ });
333
+ if (new Set(segments.map((s) => s.id)).size !== segments.length)
334
+ throw new Error(`circuit v${def.version}: two legs share an id`);
335
+ const length = c0;
336
+ const track = { segments, length };
337
+ const stops = def.stops.map((s) => ({ ...s }));
338
+ if (new Set(stops.map((s) => s.id)).size !== stops.length)
339
+ throw new Error(`circuit v${def.version}: two stops share an id`);
340
+ for (const s of stops)
341
+ if (!(s.c >= 0 && s.c < length))
342
+ throw new Error(`stop ${s.id}: c ${s.c} is off the track (0-${length - 1})`);
343
+ const running = runningStops(stops);
344
+ if (new Set(running.map((s) => s.c)).size !== running.length)
345
+ throw new Error(`circuit v${def.version}: two running stops on one c`);
346
+ const homes = stops.filter((s) => s.home);
347
+ if (homes.length !== 1 || !homes[0].enabled)
348
+ throw new Error(`circuit v${def.version}: exactly one home stop, running`);
349
+ const home = homes[0];
350
+ // The home stop's rake is the docked frame: standing there, every docked tile is its own world tile.
351
+ for (const [x, z] of [[WESTERN_TRAIN_RIDE_BOX.minX, WESTERN_TRAIN_RIDE_BOX.minZ], [WESTERN_TRAIN_RIDE_BOX.maxX, WESTERN_TRAIN_RIDE_BOX.maxZ]]) {
352
+ const w = riderTileOn(track, home.c, x, z);
353
+ if (w.x !== x || w.y !== z)
354
+ throw new Error(`circuit v${def.version}: the home stop's rake is not the docked frame`);
355
+ }
356
+ const homeLeg = segmentOn(track, home.c);
357
+ const timetable = timetableOf(stops, length);
358
+ const stopSteps = running.filter((s) => s.steps).flatMap((s) => stepsOn(track, s));
359
+ const bores = [];
360
+ for (const s of segments)
361
+ if (s.bore && !bores.includes(s.bore))
362
+ bores.push(s.bore);
363
+ const canon = JSON.stringify({
364
+ v: def.version,
365
+ n: def.trainCount,
366
+ clock: [WESTERN_TRAIN_EPOCH_MS, WESTERN_TRAIN_TOP_SPEED, WESTERN_TRAIN_ACCEL, WESTERN_TRAIN_DOORS_SHUT_BEFORE_MS, WESTERN_TRAIN_NOSE_X, WESTERN_TRAIN_RAKE_LENGTH],
367
+ legs: segments.map((s) => [s.id, s.kind, s.c0, s.length, s.x0, s.z0, s.dx, s.dz, s.drawBefore, s.drawAfter,
368
+ s.bore ? [s.bore.id, s.bore.x, s.bore.z0, s.bore.length, s.bore.halfWidth, s.bore.floorHeight] : null]),
369
+ stops: stops.map((s) => [s.id, s.c, s.dwellMs, s.enabled, !!s.home, !!s.steps, s.stepPrefix ?? null, !!s.membersOnly]),
370
+ });
371
+ return {
372
+ version: def.version,
373
+ key: `v${def.version}-${fnv1a(canon)}`,
374
+ segments,
375
+ length,
376
+ stops,
377
+ trainCount: def.trainCount,
378
+ timetable,
379
+ home,
380
+ homeLeg,
381
+ stopSteps,
382
+ bores,
383
+ };
384
+ }
385
+ /* The position sums on a track (exported below with the server's circuit as their default). */
386
+ function segmentOn(track, c) {
387
+ const w = wrapOn(c, track.length);
388
+ for (const seg of track.segments)
231
389
  if (w >= seg.c0 && w < seg.c0 + seg.length)
232
390
  return seg;
233
- return WESTERN_TRAIN_SEGMENTS[0];
391
+ return track.segments[0];
392
+ }
393
+ function riderTileOn(track, noseC, dockedX, dockedZ) {
394
+ const c = westernDockedC(noseC, dockedX);
395
+ const p = westernTrainPointOn(segmentOn(track, c), wrapOn(c, track.length), dockedZ - WESTERN_TRACK_Z);
396
+ return { x: Math.round(p.x), y: Math.round(p.z) };
397
+ }
398
+ function stepsOn(track, stop) {
399
+ return WESTERN_STEPS.map((s) => {
400
+ const plate = riderTileOn(track, stop.c, s.x, s.z);
401
+ return {
402
+ stopId: stop.id,
403
+ car: s.car,
404
+ id: `${stop.stepPrefix ?? `${stop.id}-step`}-${s.car}`,
405
+ x: plate.x,
406
+ z: plate.y,
407
+ platform: riderTileOn(track, stop.c, s.platform[0], s.platform[1]),
408
+ doorway: { x: s.doorway[0], y: s.doorway[1] },
409
+ };
410
+ });
411
+ }
412
+ /**
413
+ * VERSION 1: the line as it ran from 2026-10-10 (the moving train) and 2026-10-10 (Lantern Wake): the home platform's
414
+ * surface leg, bore A south under the sea, Lantern Wake's leg west into its rock cap, two trains. Frozen: never edit.
415
+ */
416
+ export const WESTERN_TRAIN_CIRCUIT_V1 = defineWesternCircuit({
417
+ version: 1,
418
+ trainCount: 2,
419
+ segments: [
420
+ {
421
+ id: 'surface', kind: 'surface', length: SURFACE_LENGTH, x0: WESTERN_SEA_END.x, z0: WESTERN_TRACK_Z, dx: -1, dz: 0,
422
+ cover: { area: 'western-line', title: 'The Western Entrance' },
423
+ // The mouth hides the rake to the west, the sea to the east (WESTERN_TRAIN_SURFACE_DRAW, in tiles along the leg).
424
+ drawBefore: WESTERN_TRAIN_SURFACE_DRAW.maxX - WESTERN_SEA_END.x,
425
+ drawAfter: WESTERN_SEA_END.x - WESTERN_TRAIN_SURFACE_DRAW.minX - SURFACE_LENGTH,
426
+ },
427
+ {
428
+ id: 'tunnel', kind: 'bore', bore: WESTERN_BORE_A, length: WESTERN_TUNNEL_LENGTH, x0: WESTERN_TUNNEL_X, z0: WESTERN_TUNNEL_START_Z, dx: 0, dz: 1,
429
+ cover: { area: 'western-line', title: 'Under the sea' },
430
+ },
431
+ {
432
+ id: 'isle', kind: 'surface', length: ISLE_LENGTH, x0: WESTERN_ISLE_SEA_END_X, z0: WESTERN_ISLE_TRACK_Z, dx: -1, dz: 0,
433
+ // Coming up into the Western Kingdoms: the land's own title card (a small train over its name, phase 1b).
434
+ cover: { area: 'western-kingdoms-train' },
435
+ },
436
+ ],
437
+ stops: [
438
+ { id: 'platform', name: 'The Western Entrance', c: WESTERN_TRAIN_PLATFORM_C, dwellMs: 60_000, enabled: true, home: true, note: 'The platform on the headland: boarding by the step plates.' },
439
+ // Lantern Wake, the Western Kingdoms' first stop (phase 0c's stub, made the real station in phase 1c-1; types
440
+ // lanternWake.ts). The id stays 'western-isle' so nothing keyed on it moves.
441
+ { id: 'western-isle', name: 'Lantern Wake', c: WESTERN_ISLE_STOP_C, dwellMs: 30_000, enabled: true, steps: true, stepPrefix: 'lw-station-step', note: "Lantern Wake: the Western Kingdoms' first stop, the station on the south-east bay where the train climbs out of the sea." },
442
+ ],
443
+ });
444
+ /** Every circuit this build knows, oldest first: a client follows whichever of them its server runs. */
445
+ export const WESTERN_TRAIN_CIRCUITS = [WESTERN_TRAIN_CIRCUIT_V1];
446
+ /** The circuit this build's SERVER runs (the client's default before the server has said). */
447
+ export const WESTERN_TRAIN_CIRCUIT = WESTERN_TRAIN_CIRCUIT_V1;
448
+ /** Every bore of every known circuit (lighting and sounds: a bore the server has not cut is never reached). */
449
+ export const WESTERN_BORES = (() => {
450
+ const out = [];
451
+ for (const c of WESTERN_TRAIN_CIRCUITS)
452
+ for (const b of c.bores)
453
+ if (!out.some((o) => o.id === b.id))
454
+ out.push(b);
455
+ return out;
456
+ })();
457
+ /** In a bore (its floor or its wall tiles), of every known circuit or the bores given. */
458
+ export function inWesternBore(x, z, bores = WESTERN_BORES) {
459
+ for (const b of bores) {
460
+ const B = westernBoreBox(b);
461
+ if (x >= B.minX - 1 && x <= B.maxX + 1 && z >= B.minZ - 1 && z <= B.maxZ + 1)
462
+ return true;
463
+ }
464
+ return false;
465
+ }
466
+ /** The version a key names (`v3-...` is 3), or null for a key that is not one. */
467
+ export function westernCircuitVersionOf(key) {
468
+ const m = typeof key === 'string' ? /^v(\d+)-[0-9a-f]{8}$/.exec(key) : null;
469
+ return m ? Number(m[1]) : null;
470
+ }
471
+ /**
472
+ * The circuit a server runs, from the key it sent: no key is a server from before the version (slice 2-0), which runs
473
+ * version 1; a key this build knows is that circuit; anything else is null (a newer server than this page, or a circuit
474
+ * changed without a new version): the client then draws no moving train and asks for a reload.
475
+ */
476
+ export function westernCircuitForKey(key, known = WESTERN_TRAIN_CIRCUITS) {
477
+ if (key === undefined || key === null || key === '')
478
+ return known.find((c) => c.version === 1) ?? null;
479
+ return known.find((c) => c.key === key) ?? null;
480
+ }
481
+ /**
482
+ * How this build stands with the server's circuit: `same` (the one it would run), `older` (the server runs an older
483
+ * circuit this build still knows: follow it), `newer` (the server runs a later version than any this build knows: reload),
484
+ * `unknown` (a version this build knows with different numbers: someone changed a circuit without a new version).
485
+ */
486
+ export function westernCircuitAgreement(key, own = WESTERN_TRAIN_CIRCUIT, known = WESTERN_TRAIN_CIRCUITS) {
487
+ const circuit = westernCircuitForKey(key, known);
488
+ if (circuit)
489
+ return { status: circuit.key === own.key ? 'same' : circuit.version < own.version ? 'older' : 'newer', circuit };
490
+ const v = westernCircuitVersionOf(key);
491
+ const top = Math.max(0, ...known.map((c) => c.version));
492
+ return { status: v !== null && v > top ? 'newer' : 'unknown', circuit: null };
493
+ }
494
+ /** What a client tells its player when it cannot follow the server's circuit. */
495
+ export const WESTERN_TRAIN_RELOAD_TEXT = 'The Western Line has changed since this page loaded. Reload the page to see the trains.';
496
+ /* The server's circuit, as the constants the line has always had (code that must follow the SERVER's circuit on the
497
+ * client passes the circuit it was sent instead of reading these). */
498
+ export const WESTERN_TRAIN_SEGMENTS = WESTERN_TRAIN_CIRCUIT.segments;
499
+ export const WESTERN_TRAIN_CIRCUIT_LENGTH = WESTERN_TRAIN_CIRCUIT.length;
500
+ /** How many trains run the circuit, evenly spaced in time (James: "1-3 trains running constantly"). */
501
+ export const WESTERN_TRAIN_COUNT = WESTERN_TRAIN_CIRCUIT.trainCount;
502
+ export const WESTERN_TRAIN_STOPS = WESTERN_TRAIN_CIRCUIT.stops;
503
+ export const WESTERN_TRAIN_CYCLE_MS = WESTERN_TRAIN_CIRCUIT.timetable.cycleMs;
504
+ export function wrapWesternC(c, circuit = WESTERN_TRAIN_CIRCUIT) {
505
+ return wrapOn(c, circuit.length);
506
+ }
507
+ export function westernTrainSegmentAt(c, circuit = WESTERN_TRAIN_CIRCUIT) {
508
+ return segmentOn(circuit, c);
234
509
  }
235
510
  /** A point on a leg (c may run past its ends: the client extrapolates a piece into the sea or the rock). */
236
511
  export function westernTrainPointOn(seg, c, lateral) {
@@ -239,24 +514,23 @@ export function westernTrainPointOn(seg, c, lateral) {
239
514
  return { x: seg.x0 + seg.dx * along + lx * lateral, z: seg.z0 + seg.dz * along + lz * lateral };
240
515
  }
241
516
  /** The world point of track coordinate c (wrapped) and a lateral offset (docked z - track z). */
242
- export function westernTrainWorldPoint(c, lateral) {
243
- const segment = westernTrainSegmentAt(c);
244
- return { ...westernTrainPointOn(segment, wrapWesternC(c), lateral), segment };
517
+ export function westernTrainWorldPoint(c, lateral, circuit = WESTERN_TRAIN_CIRCUIT) {
518
+ const segment = segmentOn(circuit, c);
519
+ return { ...westernTrainPointOn(segment, wrapOn(c, circuit.length), lateral), segment };
245
520
  }
246
- /** The ground under the track at c on a leg (terrain steps; the causeway steps down to the waterline, then the sea). */
521
+ /** The ground under the track at c on a leg: a bore's floor, or the causeway stepping down to the waterline, then the sea. */
247
522
  export function westernTrainGroundHeight(seg, c) {
248
- if (seg.id === 'tunnel')
249
- return WESTERN_TUNNEL_FLOOR_HEIGHT;
523
+ if (seg.kind === 'bore')
524
+ return seg.bore?.floorHeight ?? WESTERN_TUNNEL_FLOOR_HEIGHT;
525
+ // A surface leg comes up out of the sea at its x0 (heading west) and climbs the home line's causeway, moved along x.
250
526
  const x = seg.x0 + seg.dx * (c - seg.c0);
251
- // Both open-air legs come up out of the sea at their x0 and climb a causeway to the station's ground.
252
- const isle = seg.id === 'isle';
253
- const seaEnd = isle ? WESTERN_ISLE_SEA_END_X : WESTERN_SEA_END.x;
254
- const causewayFrom = isle ? WESTERN_ISLE_CAUSEWAY.minX : WESTERN_CAUSEWAY.minX;
255
- const causeway = isle ? westernIsleCausewayHeight : westernCausewayHeight;
527
+ const seaEnd = seg.x0;
528
+ const shift = seaEnd - WESTERN_SEA_END.x;
529
+ const causewayFrom = WESTERN_CAUSEWAY.minX + shift;
256
530
  if (x > seaEnd)
257
531
  return -(x - seaEnd) * 1.4; // under the sea past the line's end
258
532
  const lo = Math.floor(x), t = x - lo;
259
- const h = (xi) => (xi >= causewayFrom ? causeway(Math.min(xi, seaEnd)) : WESTERN_PRECINCT_HEIGHT);
533
+ const h = (xi) => (xi >= causewayFrom ? westernCausewayHeight(Math.min(xi, seaEnd) - shift) : WESTERN_PRECINCT_HEIGHT);
260
534
  return h(lo) * (1 - t) + h(lo + 1) * t;
261
535
  }
262
536
  /** Metres: the deck a rider stands on at c (the ground plus the kit's deck height). */
@@ -268,195 +542,208 @@ export function westernDockedC(noseC, dockedX) {
268
542
  return noseC - (dockedX - WESTERN_TRAIN_NOSE_X);
269
543
  }
270
544
  /** The world tile of a docked tile when the nose stands at integer c (the server's step). */
271
- export function westernRiderWorldTile(noseC, dockedX, dockedZ) {
272
- const p = westernTrainWorldPoint(westernDockedC(noseC, dockedX), dockedZ - WESTERN_TRACK_Z);
273
- return { x: Math.round(p.x), y: Math.round(p.z) };
545
+ export function westernRiderWorldTile(noseC, dockedX, dockedZ, circuit = WESTERN_TRAIN_CIRCUIT) {
546
+ return riderTileOn(circuit, noseC, dockedX, dockedZ);
274
547
  }
275
548
  /**
276
549
  * A world tile back to the docked frame of a train whose nose is at c (a rider's click on the moving deck), or null when
277
550
  * the tile is not on the rake.
278
551
  */
279
- export function westernWorldToDocked(noseC, x, z) {
280
- for (const seg of WESTERN_TRAIN_SEGMENTS) {
552
+ export function westernWorldToDocked(noseC, x, z, circuit = WESTERN_TRAIN_CIRCUIT) {
553
+ for (const seg of circuit.segments) {
281
554
  const { lx, lz } = lateralOf(seg);
282
555
  const along = (x - seg.x0) * seg.dx + (z - seg.z0) * seg.dz;
283
556
  const lateral = (x - seg.x0) * lx + (z - seg.z0) * lz;
284
557
  if (along < 0 || along >= seg.length || Math.abs(lateral) > WESTERN_TRAIN_HALF_ACROSS)
285
558
  continue;
286
- const behind = wrapWesternC(noseC - (seg.c0 + along));
559
+ const behind = wrapOn(noseC - (seg.c0 + along), circuit.length);
287
560
  if (behind > WESTERN_TRAIN_RAKE_LENGTH - 1)
288
561
  continue;
289
562
  return { x: WESTERN_TRAIN_NOSE_X + behind, y: WESTERN_TRACK_Z + lateral };
290
563
  }
291
564
  return null;
292
565
  }
566
+ /** The compass side of the track a stop's platform is on (the docked platform side, turned with the leg). */
567
+ export function westernStopPlatformSide(stop, circuit = WESTERN_TRAIN_CIRCUIT) {
568
+ const { lx, lz } = lateralOf(segmentOn(circuit, stop.c));
569
+ // The platform is lateral -1.
570
+ if (-lz < 0)
571
+ return 'north';
572
+ if (-lz > 0)
573
+ return 'south';
574
+ return -lx > 0 ? 'east' : 'west';
575
+ }
576
+ /** How far (tiles, Chebyshev) a rider may be carried in one step before it counts as a jump needing the cover: the view ring. */
577
+ export const WESTERN_TRAIN_JUMP_TILES = CHUNK_VIEW_RADIUS * CHUNK_SIZE;
578
+ /**
579
+ * A rider's tile carried from one server step to the next: a JUMP (the loading cover first) when it lands in another
580
+ * region of the grid OR farther than the view ring (two bores in the same region, slice 2-0), with the cover of the leg
581
+ * it lands on; null for an ordinary step.
582
+ */
583
+ export function westernRiderJump(prevNoseStep, noseStep, dockedX, dockedZ, circuit = WESTERN_TRAIN_CIRCUIT) {
584
+ const from = riderTileOn(circuit, prevNoseStep, dockedX, dockedZ);
585
+ const to = riderTileOn(circuit, noseStep, dockedX, dockedZ);
586
+ const far = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y)) > WESTERN_TRAIN_JUMP_TILES;
587
+ if (!far && worldRegionAt(from.x) === worldRegionAt(to.x))
588
+ return null;
589
+ const segment = segmentOn(circuit, westernDockedC(noseStep, dockedX));
590
+ return { from, to, segment, cover: segment.cover };
591
+ }
293
592
  /* ------------------------------------------------------------------ *
294
593
  * The timetable.
295
594
  * ------------------------------------------------------------------ */
296
- /** Every train counts from this instant (a fixed date, so a restart never moves a train). */
297
- export const WESTERN_TRAIN_EPOCH_MS = Date.UTC(2026, 9, 10, 0, 0, 0);
298
- /** How many trains run the circuit, evenly spaced in time (James: "1-3 trains running constantly"). */
299
- export const WESTERN_TRAIN_COUNT = 2;
300
- /** Cruising speed (tiles a second) and the rate it gets up to it and slows from it. */
301
- export const WESTERN_TRAIN_TOP_SPEED = 6;
302
- export const WESTERN_TRAIN_ACCEL = 0.5;
303
- /** The bell rings this long before a departure; the step plates shut this long before it. */
304
- export const WESTERN_TRAIN_BELL_BEFORE_MS = 15_000;
305
- export const WESTERN_TRAIN_DOORS_SHUT_BEFORE_MS = 5_000;
306
- /** The whistle and the bell are heard this far (tiles) from the loco. */
307
- export const WESTERN_TRAIN_SOUND_RANGE = 40;
308
- export const WESTERN_TRAIN_MOVING_TEXT = 'The train is moving.';
309
- export const WESTERN_TRAIN_DOORS_SHUT_TEXT = 'The doors are shut: the train is about to leave.';
310
- export const WESTERN_TRAIN_ABOARD_COMBAT_TEXT = 'There is no fighting aboard the train.';
311
- export const WESTERN_TRAIN_ALIGHT_MOVING_TEXT = 'The train is moving. Wait until it stops at the platform.';
312
- export const WESTERN_TRAIN_STOPS = [
313
- { id: 'platform', name: 'The Western Entrance', c: WESTERN_TRAIN_PLATFORM_C, dwellMs: 60_000, enabled: true, note: 'The platform on the headland: boarding by the step plates.' },
314
- // Lantern Wake, the Western Kingdoms' first stop (phase 0c's stub, made the real station in phase 1c-1; types
315
- // lanternWake.ts). The id stays 'western-isle' so nothing keyed on it moves.
316
- { id: 'western-isle', name: 'Lantern Wake', c: WESTERN_ISLE_STOP_C, dwellMs: 30_000, enabled: true, steps: true, stepPrefix: 'lw-station-step', note: "Lantern Wake: the Western Kingdoms' first stop, the station on the south-east bay where the train climbs out of the sea." },
317
- { id: 'dwarf-hold', name: 'The Dwarf Hold', c: SURFACE_LENGTH + 300, dwellMs: 45_000, enabled: false, note: 'An underground dwarf dungeon beside the bore; its exit is blocked for now.' },
318
- { id: 'capital', name: 'The Capital', c: SURFACE_LENGTH + 450, dwellMs: 60_000, enabled: false, note: 'Not reachable by a surface line (the city is ~900 rows north); needs an underground halt on this bore and a stair or lift from the city.' },
319
- ];
320
- /** Duration (ms) of a run of D tiles from a stop to a stop: up to speed, cruise, slow to a stand. */
321
- export function westernTrainRunMs(distance) {
322
- const v = WESTERN_TRAIN_TOP_SPEED, a = WESTERN_TRAIN_ACCEL;
323
- const ramp = (v * v) / a; // tiles spent getting up to speed and slowing again
324
- const s = distance >= ramp ? distance / v + v / a : 2 * Math.sqrt(distance / a);
325
- return s * 1000;
326
- }
327
- /** Tiles covered after t ms of a run of D tiles. */
328
- export function westernTrainRunTiles(distance, tMs) {
329
- const v = WESTERN_TRAIN_TOP_SPEED, a = WESTERN_TRAIN_ACCEL;
330
- const total = westernTrainRunMs(distance) / 1000;
331
- const t = Math.max(0, Math.min(total, tMs / 1000));
332
- const peak = Math.min(v, Math.sqrt(distance * a));
333
- const ta = peak / a;
334
- if (t <= ta)
335
- return 0.5 * a * t * t;
336
- if (t >= total - ta)
337
- return distance - 0.5 * a * (total - t) ** 2;
338
- return 0.5 * a * ta * ta + peak * (t - ta);
339
- }
340
595
  /** The cycle: for each running stop in order, its dwell then the run to the next running stop (wrapping). */
341
- export function westernTrainTimetable(stops = WESTERN_TRAIN_STOPS) {
342
- const running = stops.filter((s) => s.enabled).sort((a, b) => a.c - b.c);
343
- const legs = [];
344
- let t = 0;
345
- running.forEach((stop, i) => {
346
- const next = running[(i + 1) % running.length];
347
- const runTiles = wrapWesternC(next.c - stop.c) || WESTERN_TRAIN_CIRCUIT_LENGTH;
348
- const startMs = t;
349
- const departMs = startMs + stop.dwellMs;
350
- const endMs = departMs + westernTrainRunMs(runTiles);
351
- legs.push({ stop, startMs, departMs, runTiles, endMs });
352
- t = endMs;
353
- });
354
- return { cycleMs: t, legs };
596
+ export function westernTrainTimetable(stops = WESTERN_TRAIN_CIRCUIT.stops, length = WESTERN_TRAIN_CIRCUIT.length) {
597
+ return timetableOf(stops, length);
355
598
  }
356
- const TIMETABLE = westernTrainTimetable();
357
- export const WESTERN_TRAIN_CYCLE_MS = TIMETABLE.cycleMs;
358
- /** Is any piece of the rake on the surface leg with the nose at c? */
359
- function rakeOnSurface(noseC) {
360
- const surface = WESTERN_TRAIN_SEGMENTS[0];
599
+ /** Is any piece of the rake on the leg with the nose at c? */
600
+ function rakeOnLeg(noseC, leg) {
361
601
  const tail = noseC - (WESTERN_TRAIN_RAKE_LENGTH - 1);
362
- return noseC >= surface.c0 && tail < surface.c0 + surface.length;
602
+ return noseC >= leg.c0 && tail < leg.c0 + leg.length;
363
603
  }
364
- /** Where train `train` (0-based) is at server time `nowMs`. */
365
- export function westernTrainAt(nowMs, train = 0, timetable = TIMETABLE) {
604
+ /** Where train `train` (0-based) is at server time `nowMs`, on a circuit (the server's by default). */
605
+ export function westernTrainAt(nowMs, train = 0, circuit = WESTERN_TRAIN_CIRCUIT) {
606
+ const timetable = circuit.timetable;
607
+ const home = circuit.home;
366
608
  const cycle = timetable.cycleMs;
367
- const offset = (train * cycle) / WESTERN_TRAIN_COUNT;
609
+ const offset = (train * cycle) / circuit.trainCount;
368
610
  const t = (((nowMs - WESTERN_TRAIN_EPOCH_MS + offset) % cycle) + cycle) % cycle;
369
- const platform = timetable.legs.find((l) => l.stop.id === 'platform');
370
- const msToPlatform = platform ? ((platform.startMs - t) % cycle + cycle) % cycle : 0;
371
- for (const leg of timetable.legs) {
611
+ const homeLeg = timetable.legs.find((l) => l.stop.id === home.id);
612
+ const msToPlatform = homeLeg ? ((homeLeg.startMs - t) % cycle + cycle) % cycle : 0;
613
+ const legs = timetable.legs;
614
+ for (let i = 0; i < legs.length; i++) {
615
+ const leg = legs[i];
372
616
  if (t < leg.startMs || t >= leg.endMs)
373
617
  continue;
374
618
  if (t < leg.departMs) {
375
- const noseC = wrapWesternC(leg.stop.c);
619
+ const noseC = wrapOn(leg.stop.c, circuit.length);
620
+ const atHome = leg.stop.id === home.id;
376
621
  return {
377
622
  train, noseC, noseStep: Math.floor(noseC), moving: false,
378
- phase: leg.stop.id === 'platform' ? 'boarding' : 'away',
623
+ phase: atHome ? 'boarding' : 'away',
379
624
  stopId: leg.stop.id, msToDepart: leg.departMs - t,
380
- msToPlatform: leg.stop.id === 'platform' ? 0 : msToPlatform, cycleMs: t,
625
+ msToPlatform: atHome ? 0 : msToPlatform, cycleMs: t,
626
+ nextStopId: leg.stop.id, msToNextStop: 0,
381
627
  };
382
628
  }
383
629
  const ran = westernTrainRunTiles(leg.runTiles, t - leg.departMs);
384
- const noseC = wrapWesternC(leg.stop.c + ran);
385
- const onSurface = rakeOnSurface(noseC);
630
+ const noseC = wrapOn(leg.stop.c + ran, circuit.length);
631
+ const onHomeLeg = rakeOnLeg(noseC, circuit.homeLeg);
386
632
  // The last instant of a run into the platform is still the approach (its noseC lands exactly on the platform's c).
387
633
  const arriving = leg.runTiles - ran < 0.5;
388
- const phase = !onSurface ? 'away' : noseC < WESTERN_TRAIN_PLATFORM_C || arriving ? 'approaching' : 'departing';
389
- return { train, noseC, noseStep: Math.floor(noseC), moving: true, phase, stopId: null, msToDepart: null, msToPlatform, cycleMs: t };
634
+ const phase = !onHomeLeg ? 'away' : noseC < home.c || arriving ? 'approaching' : 'departing';
635
+ const next = legs[(i + 1) % legs.length];
636
+ return {
637
+ train, noseC, noseStep: Math.floor(noseC), moving: true, phase, stopId: null, msToDepart: null, msToPlatform, cycleMs: t,
638
+ nextStopId: next.stop.id, msToNextStop: leg.endMs - t,
639
+ };
390
640
  }
391
641
  // Rounding at the cycle's very end: treat as the first leg's start.
392
642
  const first = timetable.legs[0];
643
+ const atHome = first.stop.id === home.id;
393
644
  return {
394
- train, noseC: wrapWesternC(first.stop.c), noseStep: Math.floor(wrapWesternC(first.stop.c)), moving: false,
395
- phase: first.stop.id === 'platform' ? 'boarding' : 'away', stopId: first.stop.id, msToDepart: first.stop.dwellMs,
396
- msToPlatform: 0, cycleMs: 0,
645
+ train, noseC: wrapOn(first.stop.c, circuit.length), noseStep: Math.floor(wrapOn(first.stop.c, circuit.length)), moving: false,
646
+ phase: atHome ? 'boarding' : 'away', stopId: first.stop.id, msToDepart: first.stop.dwellMs,
647
+ msToPlatform: 0, cycleMs: 0, nextStopId: first.stop.id, msToNextStop: 0,
397
648
  };
398
649
  }
399
- /** The train standing at the platform at `nowMs`, or null. */
400
- export function westernTrainAtPlatform(nowMs) {
401
- for (let i = 0; i < WESTERN_TRAIN_COUNT; i++) {
402
- const s = westernTrainAt(nowMs, i);
650
+ /** Every train of a circuit at `nowMs`. */
651
+ export function westernTrainsAt(nowMs, circuit = WESTERN_TRAIN_CIRCUIT) {
652
+ return Array.from({ length: circuit.trainCount }, (_, i) => westernTrainAt(nowMs, i, circuit));
653
+ }
654
+ /** The stop a train stands at, or the next running stop it reaches. */
655
+ export function westernNextStop(state, circuit = WESTERN_TRAIN_CIRCUIT) {
656
+ return circuit.stops.find((s) => s.id === state.nextStopId) ?? null;
657
+ }
658
+ /** The running stop after this one (wrapping). */
659
+ export function westernStopAfter(stopId, circuit = WESTERN_TRAIN_CIRCUIT) {
660
+ const running = runningStops(circuit.stops);
661
+ const i = running.findIndex((s) => s.id === stopId);
662
+ return i < 0 ? null : running[(i + 1) % running.length];
663
+ }
664
+ /** A stop's name as said mid-sentence ("for the Western Entrance"). */
665
+ export function westernStopNamePhrase(stop) {
666
+ return stop.name.startsWith('The ') ? `the ${stop.name.slice(4)}` : stop.name;
667
+ }
668
+ /**
669
+ * The closest two trains come on a circuit (tiles from one's nose to the tail ahead of it, along the line), sampled over
670
+ * a cycle; Infinity with one train. The line's own test wants at least 20.
671
+ */
672
+ export function westernTrainMinGap(circuit = WESTERN_TRAIN_CIRCUIT, stepMs = 100) {
673
+ let minGap = Infinity;
674
+ const L = circuit.length;
675
+ for (let t = 0; t < circuit.timetable.cycleMs; t += stepMs) {
676
+ const states = westernTrainsAt(WESTERN_TRAIN_EPOCH_MS + t, circuit);
677
+ for (const a of states)
678
+ for (const b of states) {
679
+ if (a === b)
680
+ continue;
681
+ const gap = wrapOn(a.noseC - (WESTERN_TRAIN_RAKE_LENGTH - 1) - b.noseC, L);
682
+ if (gap < L / 2)
683
+ minGap = Math.min(minGap, gap);
684
+ }
685
+ }
686
+ return minGap;
687
+ }
688
+ /** The train standing at the home platform at `nowMs`, or null. */
689
+ export function westernTrainAtPlatform(nowMs, circuit = WESTERN_TRAIN_CIRCUIT) {
690
+ for (let i = 0; i < circuit.trainCount; i++) {
691
+ const s = westernTrainAt(nowMs, i, circuit);
403
692
  if (s.phase === 'boarding')
404
693
  return s;
405
694
  }
406
695
  return null;
407
696
  }
408
697
  /** Ms until the next train stands at the platform (0 if one is there). */
409
- export function westernTrainNextArrivalMs(nowMs) {
698
+ export function westernTrainNextArrivalMs(nowMs, circuit = WESTERN_TRAIN_CIRCUIT) {
410
699
  let best = Infinity;
411
- for (let i = 0; i < WESTERN_TRAIN_COUNT; i++)
412
- best = Math.min(best, westernTrainAt(nowMs, i).msToPlatform);
700
+ for (let i = 0; i < circuit.trainCount; i++)
701
+ best = Math.min(best, westernTrainAt(nowMs, i, circuit).msToPlatform);
413
702
  return best;
414
703
  }
415
704
  /** The refusal text for the step plates while no train can be boarded. */
416
- export function westernTrainNoBoardingText(nowMs) {
417
- const here = westernTrainAtPlatform(nowMs);
705
+ export function westernTrainNoBoardingText(nowMs, circuit = WESTERN_TRAIN_CIRCUIT) {
706
+ const here = westernTrainAtPlatform(nowMs, circuit);
418
707
  if (here)
419
708
  return WESTERN_TRAIN_DOORS_SHUT_TEXT;
420
- for (let i = 0; i < WESTERN_TRAIN_COUNT; i++) {
421
- const s = westernTrainAt(nowMs, i);
709
+ for (let i = 0; i < circuit.trainCount; i++) {
710
+ const s = westernTrainAt(nowMs, i, circuit);
422
711
  if (s.phase === 'approaching' || s.phase === 'departing')
423
712
  return WESTERN_TRAIN_MOVING_TEXT;
424
713
  }
425
- const secs = Math.max(1, Math.round(westernTrainNextArrivalMs(nowMs) / 1000));
714
+ const secs = Math.max(1, Math.round(westernTrainNextArrivalMs(nowMs, circuit) / 1000));
426
715
  return `No train stands at the platform. The next one is due in about ${secs} seconds.`;
427
716
  }
428
717
  /**
429
718
  * The station's word on the next train (the station hub, 2026-10-10: the All Aboard board's click and the station
430
719
  * master's "Next train?"): a train standing with its doors open, shut, or the next one due.
431
720
  */
432
- export function westernStationNextTrainText(nowMs) {
433
- const here = westernTrainAtPlatform(nowMs);
721
+ export function westernStationNextTrainText(nowMs, circuit = WESTERN_TRAIN_CIRCUIT) {
722
+ const here = westernTrainAtPlatform(nowMs, circuit);
434
723
  if (here) {
435
724
  const secs = Math.round(((here.msToDepart ?? 0) - WESTERN_TRAIN_DOORS_SHUT_BEFORE_MS) / 1000);
436
725
  return secs >= 1
437
726
  ? `A train is standing at the platform: its doors shut in about ${secs} seconds.`
438
727
  : 'A train is standing at the platform, but its doors are shut: it is about to leave.';
439
728
  }
440
- const secs = Math.max(1, Math.round(westernTrainNextArrivalMs(nowMs) / 1000));
729
+ const secs = Math.max(1, Math.round(westernTrainNextArrivalMs(nowMs, circuit) / 1000));
441
730
  return `The next train is due at the platform in about ${secs} seconds.`;
442
731
  }
443
732
  const COUNT_WORDS = ['No', 'One', 'Two', 'Three', 'Four'];
444
733
  /** What the All Aboard board says: the line's rules from the timetable, then the next train. */
445
- export function westernStationNoticeText(nowMs) {
446
- const dwell = Math.round((WESTERN_TRAIN_STOPS.find((s) => s.id === 'platform')?.dwellMs ?? 0) / 1000);
447
- const every = Math.round(WESTERN_TRAIN_CYCLE_MS / WESTERN_TRAIN_COUNT / 1000);
448
- const trains = COUNT_WORDS[WESTERN_TRAIN_COUNT] ?? String(WESTERN_TRAIN_COUNT);
734
+ export function westernStationNoticeText(nowMs, circuit = WESTERN_TRAIN_CIRCUIT) {
735
+ const dwell = Math.round(circuit.home.dwellMs / 1000);
736
+ const every = Math.round(circuit.timetable.cycleMs / circuit.trainCount / 1000);
737
+ const trains = COUNT_WORDS[circuit.trainCount] ?? String(circuit.trainCount);
449
738
  return `ALL ABOARD the Western Line. ${trains} trains run the loop under the sea, one about every ${every} seconds, and each stands ${dwell} seconds at the platform. ` +
450
739
  `Board by the brass step plates while a train stands; the doors shut ${WESTERN_TRAIN_DOORS_SHUT_BEFORE_MS / 1000} seconds before it leaves. No fighting aboard. No fares while the line is new. ` +
451
- westernStationNextTrainText(nowMs);
740
+ westernStationNextTrainText(nowMs, circuit);
452
741
  }
453
742
  /** Can the step plates be used at `nowMs` (a train standing, more than the shut time to go)? */
454
- export function westernTrainDoorsOpen(nowMs) {
455
- const here = westernTrainAtPlatform(nowMs);
743
+ export function westernTrainDoorsOpen(nowMs, circuit = WESTERN_TRAIN_CIRCUIT) {
744
+ const here = westernTrainAtPlatform(nowMs, circuit);
456
745
  return !!here && (here.msToDepart ?? 0) > WESTERN_TRAIN_DOORS_SHUT_BEFORE_MS;
457
746
  }
458
- /** The surface leg's x range the rake is drawn on (the mouth hides it to the west, the sea to the east). */
459
- export const WESTERN_TRAIN_SURFACE_DRAW = { minX: 18, maxX: WESTERN_SEA_END.x + 8 };
460
747
  /** The platform tile in front of the nearest step plate to a docked x (where someone left on the docked rake is put). */
461
748
  export function westernPlatformTileNear(x) {
462
749
  let best = WESTERN_STEPS[0];
@@ -465,54 +752,44 @@ export function westernPlatformTileNear(x) {
465
752
  best = s;
466
753
  return { x: best.platform[0], y: best.platform[1] };
467
754
  }
468
- export function westernStopSteps(stop) {
469
- return WESTERN_STEPS.map((s) => {
470
- const plate = westernRiderWorldTile(stop.c, s.x, s.z);
471
- return {
472
- stopId: stop.id,
473
- car: s.car,
474
- id: `${stop.stepPrefix ?? `${stop.id}-step`}-${s.car}`,
475
- x: plate.x,
476
- z: plate.y,
477
- platform: westernRiderWorldTile(stop.c, s.platform[0], s.platform[1]),
478
- doorway: { x: s.doorway[0], y: s.doorway[1] },
479
- };
480
- });
755
+ export function westernStopSteps(stop, circuit = WESTERN_TRAIN_CIRCUIT) {
756
+ return stepsOn(circuit, stop);
481
757
  }
482
- /** Every running stop's own plates (not the home platform's). */
483
- export const WESTERN_STOP_STEPS = WESTERN_TRAIN_STOPS.filter((s) => s.enabled && s.steps).flatMap(westernStopSteps);
484
- export function westernStopStepAt(x, z) {
485
- return WESTERN_STOP_STEPS.find((s) => s.x === x && s.z === z) ?? null;
758
+ /** Every running stop's own plates (not the home platform's), on the server's circuit. */
759
+ export const WESTERN_STOP_STEPS = WESTERN_TRAIN_CIRCUIT.stopSteps;
760
+ export function westernStopStepAt(x, z, circuit = WESTERN_TRAIN_CIRCUIT) {
761
+ return circuit.stopSteps.find((s) => s.x === x && s.z === z) ?? null;
486
762
  }
487
- export function westernStopStepById(id) {
488
- return WESTERN_STOP_STEPS.find((s) => s.id === id) ?? null;
763
+ export function westernStopStepById(id, circuit = WESTERN_TRAIN_CIRCUIT) {
764
+ return circuit.stopSteps.find((s) => s.id === id) ?? null;
489
765
  }
490
766
  /** The train standing at a stop (not the home platform) at `nowMs`, or null. */
491
- export function westernTrainAtStop(nowMs, stopId) {
492
- for (let i = 0; i < WESTERN_TRAIN_COUNT; i++) {
493
- const s = westernTrainAt(nowMs, i);
767
+ export function westernTrainAtStop(nowMs, stopId, circuit = WESTERN_TRAIN_CIRCUIT) {
768
+ for (let i = 0; i < circuit.trainCount; i++) {
769
+ const s = westernTrainAt(nowMs, i, circuit);
494
770
  if (!s.moving && s.stopId === stopId)
495
771
  return s;
496
772
  }
497
773
  return null;
498
774
  }
499
775
  /** The train at a stop with its doors open (more than the shut time to go), or null. */
500
- export function westernStopDoorsOpen(nowMs, stopId) {
501
- const s = westernTrainAtStop(nowMs, stopId);
776
+ export function westernStopDoorsOpen(nowMs, stopId, circuit = WESTERN_TRAIN_CIRCUIT) {
777
+ const s = westernTrainAtStop(nowMs, stopId, circuit);
502
778
  return s && (s.msToDepart ?? 0) > WESTERN_TRAIN_DOORS_SHUT_BEFORE_MS ? s : null;
503
779
  }
504
780
  /**
505
781
  * Ms until the next train stands at a stop (0 while one stands there), from the timetable; Infinity for a stop that is
506
782
  * not running. The station master's sum for the home platform is westernTrainNextArrivalMs.
507
783
  */
508
- export function westernStopNextArrivalMs(nowMs, stopId, timetable = TIMETABLE) {
784
+ export function westernStopNextArrivalMs(nowMs, stopId, circuit = WESTERN_TRAIN_CIRCUIT) {
785
+ const timetable = circuit.timetable;
509
786
  const leg = timetable.legs.find((l) => l.stop.id === stopId);
510
787
  if (!leg)
511
788
  return Infinity;
512
789
  const cycle = timetable.cycleMs;
513
790
  let best = Infinity;
514
- for (let i = 0; i < WESTERN_TRAIN_COUNT; i++) {
515
- const offset = (i * cycle) / WESTERN_TRAIN_COUNT;
791
+ for (let i = 0; i < circuit.trainCount; i++) {
792
+ const offset = (i * cycle) / circuit.trainCount;
516
793
  const t = (((nowMs - WESTERN_TRAIN_EPOCH_MS + offset) % cycle) + cycle) % cycle;
517
794
  if (t >= leg.startMs && t < leg.departMs)
518
795
  return 0;
@@ -521,10 +798,27 @@ export function westernStopNextArrivalMs(nowMs, stopId, timetable = TIMETABLE) {
521
798
  return best;
522
799
  }
523
800
  /** Why a stop's plates refuse at `nowMs`. */
524
- export function westernStopNoBoardingText(nowMs, stopId) {
525
- if (westernTrainAtStop(nowMs, stopId))
801
+ export function westernStopNoBoardingText(nowMs, stopId, circuit = WESTERN_TRAIN_CIRCUIT) {
802
+ if (westernTrainAtStop(nowMs, stopId, circuit))
526
803
  return WESTERN_TRAIN_DOORS_SHUT_TEXT;
527
- const stop = WESTERN_TRAIN_STOPS.find((s) => s.id === stopId);
804
+ const stop = circuit.stops.find((s) => s.id === stopId);
528
805
  return `No train stands at ${stop?.name ?? 'this platform'}.`;
529
806
  }
807
+ /**
808
+ * A stop of any known circuit whose closed pocket (a hall reached only by its train) holds this tile and which the given
809
+ * circuit does not run: someone saved there wakes at home (the stop was switched off, or the server runs an older circuit).
810
+ */
811
+ export function westernClosedPocketAt(x, z, circuit = WESTERN_TRAIN_CIRCUIT, known = WESTERN_TRAIN_CIRCUITS) {
812
+ for (const c of [circuit, ...known]) {
813
+ for (const s of c.stops) {
814
+ const p = s.pocket;
815
+ if (!p || x < p.minX || x > p.maxX || z < p.minZ || z > p.maxZ)
816
+ continue;
817
+ if (circuit.stops.some((r) => r.id === s.id && r.enabled))
818
+ continue;
819
+ return s;
820
+ }
821
+ }
822
+ return null;
823
+ }
530
824
  //# sourceMappingURL=westernTrain.js.map