Show the whole Division mainline. district now has option for auto hiding on phases where not needed. scaffold for multi-player geometry showing division. regions on mainline cards implemented. bug fix on laying rail cards. optimized replay (carried links forward). Add sounds, first pass

This commit is contained in:
Jesse
2026-08-04 11:26:18 -04:00
parent ec792dfc15
commit c3c5c59a45
16 changed files with 1249 additions and 129 deletions
+122 -5
View File
@@ -17,6 +17,7 @@ import {
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
REGIONS_PER_MAINLINE_CARD,
OFFICE_ORDER,
SPACE_USE_CARDS,
industryProfile,
@@ -86,7 +87,35 @@ export type FacilityView = {
jammed: boolean;
};
export type TrainChip = { label: string; consist: string[] };
export type TrainChip = {
label: string;
consist: string[];
/**
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
* Moves rather than by Stages, so it occupies a card outright.
*/
region?: number;
direction?: string;
};
/**
* One card of a player's Running Track, as the Division sees it.
*
* The through route between the Limits IS the Running Track — everything hanging beneath it is
* secondary, and a train crossing the Division never touches it. So the Division map expands an
* Office into this row and leaves sidings, industries and the load pipeline to the district view.
*/
export type RunningCardView = {
row: number;
col: number;
kind: string;
label: string;
/** Port pairs, from the engine's `connectionsFor`, so the drawn rail cannot invent a join. */
links: string[];
/** Trains standing ON this card of the Running Track. */
trains: TrainChip[];
};
export type DivisionView = {
kind: string;
label: string;
@@ -104,6 +133,19 @@ export type DivisionView = {
* on that grade has no visible meaning.
*/
gradeUp: string | null;
/** Mainline cards only: how many regions the card is divided into (§2.1 — two). */
regions?: number;
/** Office nodes only: the Running Track, Limits to Limits, west to east. */
running?: RunningCardView[];
/** Office nodes only: whose district this is. */
owner?: number;
/**
* Office nodes only: crews working BELOW the Running Track.
*
* A crew down a siding has no position on the through route — projecting one would be a lie — so
* it is reported against the district as a whole and drawn exactly where it is in the zoomed view.
*/
switching?: TrainChip[];
};
export type Frame = {
@@ -431,12 +473,43 @@ export function snapshot(
// with how long each still has to run.
const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade';
/**
* WHERE ON THE CARD, from what the crossing already cost.
*
* §2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage.
* The engine crosses in `crossingStages` Stages instead, which varies by card speed, train
* speed, passengers and modifiers — so the printed model is recovered by treating the entry
* point as the thing that varies, exactly as the cards do:
*
* entry = REGIONS - stagesTotal position = entry + elapsed
*
* A 60 card is one Stage, so the train enters at the second region and is gone — which is
* what "Start positions further along the card" means on the printed art. A 30 card is two
* Stages, giving one region per Stage, which is §8.2 exactly. A slow train needing three
* Stages cannot fit three steps into two regions, so it holds in the first for a Stage: the
* card's distance is fixed and the train is simply slow across it.
*/
const place = (t: { stagesRemaining: number; stagesTotal: number }): number => {
// `entry` may be NEGATIVE — a slow train needing three Stages cannot fit three steps into
// two regions, so it notionally starts before the card and spends the extra Stage getting
// to the first region. Clamping only the final position keeps that Stage at the START,
// where being slow shows; clamping `entry` first would have parked it at the exit instead.
const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal;
const elapsed = t.stagesTotal - t.stagesRemaining;
return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed));
};
return {
kind: 'ml',
label: name,
regions: REGIONS_PER_MAINLINE_CARD,
trains: [n.transits.map((t) => {
const chip = trainChip(s, t.tray);
return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
return {
...chip,
label: `${chip.label} (${t.stagesRemaining})`,
region: place(t),
direction: t.direction,
};
})],
capacity: MAINLINE_PROFILES.find((m) => m.kind === n.card)?.trainsMayPass ? 2 : 1,
modifiers: [
@@ -446,13 +519,57 @@ export function snapshot(
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
};
}
const oa = areaOf(s, n.owner);
// Where every crew in this district actually is: on a Running Track card, or below it.
const onRunning = new Map<string, TrainChip[]>();
const below: TrainChip[] = [];
for (const [id, tray] of s.trays) {
const pos = tray.position;
if (pos.at !== 'grid' || pos.owner !== n.owner) continue;
const c = trainChip(s, id);
if (pos.coord.row === oa.runningRow) {
const k = `${pos.coord.row},${pos.coord.col}`;
onRunning.set(k, [...(onRunning.get(k) ?? []), c]);
} else {
below.push(c);
}
}
// Limits to Limits, west to east. §2.1 — the Running Track runs BETWEEN the Limits, so the
// signs are the ends of the through route rather than obstacles on it.
const running: RunningCardView[] = [];
for (let col = oa.limitsWest.col; col <= oa.limitsEast.col; col++) {
const card = oa.grid.get(`${oa.runningRow},${col}`);
if (!card) continue;
const g = card.geometry;
let label: string;
if (g.kind === 'office') label = officeProfile(oa.tier).name;
else if (g.kind === 'limits') label = 'Limits';
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? prettyKey(g.facility);
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? prettyKey(g.modifier);
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
else label = geometryLabel(g.geometry);
running.push({
row: oa.runningRow,
col,
kind: g.kind,
label,
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
trains: onRunning.get(`${oa.runningRow},${col}`) ?? [],
});
}
return {
kind: 'office',
label: officeProfile(areaOf(s, n.owner).tier).name,
trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
capacity: officeProfile(areaOf(s, n.owner).tier).adTracks,
label: officeProfile(oa.tier).name,
trains: [oa.adOccupancy.map((id) => trainChip(s, id))],
capacity: officeProfile(oa.tier).adTracks,
modifiers: [],
gradeUp: null,
owner: n.owner,
running,
switching: below,
};
});