From 52da5ee73444e0de06d3114b1877e6147b8626e7 Mon Sep 17 00:00:00 2001 From: Nils van Lueck Date: Wed, 2 Sep 2026 17:04:45 +0200 Subject: [PATCH] feat(menu): pick the run first and derive its route from it - A scenario names its line, a service the line of its plan, so both are offered under that route and never on another; runs whose route no installed mod brought are not offered at all - Only the steps a run leaves open are walked: a prepared scenario is one question, the free run remains route -> vehicle - `Page::Scenario` is now the first step (`Run`), date/weather move to a `Setup` page; `--menu scenario` stays as an alias - `--line` still overrides the run's own route - Update README, STATUS and screenshot skill to the new flow --- .claude/skills/screenshot/SKILL.md | 2 +- README.md | 23 +- STATUS.md | 32 +- crates/app/src/menu.rs | 769 ++++++++++++++++++++++------- crates/app/src/world.rs | 8 +- crates/i18n/locales/de/main.ftl | 21 +- crates/i18n/locales/en/main.ftl | 21 +- 7 files changed, 662 insertions(+), 214 deletions(-) diff --git a/.claude/skills/screenshot/SKILL.md b/.claude/skills/screenshot/SKILL.md index 4a126bf..c33f3fb 100644 --- a/.claude/skills/screenshot/SKILL.md +++ b/.claude/skills/screenshot/SKILL.md @@ -54,7 +54,7 @@ Bild (KI-Züge sind gefahren, Luftbildkacheln sind geladen). 300 Frames für gel |---|---| | `--hud ` | `full`, `reduced` oder `off` — die drei Stufen der Anzeige (F7). Schreibt die Einstellungsdatei nicht. | | `--overlays` | Öffnet Tastenhilfe (F5) und Diagnose (F6) von Anfang an — sonst nur per Tastendruck erreichbar. | -| `--menu [seite]` | Fotografiert das Hauptmenü statt der Welt dahinter. Seite optional: `root` (Vorgabe), `line`, `loco`, `scenario`, `mods`, `settings`, `controls`. | +| `--menu [seite]` | Fotografiert das Hauptmenü statt der Welt dahinter. Seite optional: `root` (Vorgabe), `run` (die Fahrtauswahl, früher `scenario`), `line`, `loco`, `setup`, `mods`, `settings`, `controls`. | | `--camera ` | `outside` für die Außenkamera (Fahrzeugmodelle), `walk` für zu Fuß, `fly` für die Freikamera des Konsolenbefehls `fly` — alles sonst nur per Taste oder Konsole erreichbar. | | `--fly R,H,V` | Nur mit `--camera fly`: wo die Freikamera steht, in Metern **rechts**, **über** und **vor** dem Zug. Vorgabe `25,6,0`. | | `--look R,H,V` | Wohin sie schaut, im selben Bezug. Vorgabe `0,2,0` — der Zug. Ohne die beiden kann eine Aufnahme nur den Zug zeigen, und alles über etwa zehn Metern läuft oben aus dem Bild. | diff --git a/README.md b/README.md index be02b6f..658c841 100644 --- a/README.md +++ b/README.md @@ -44,20 +44,25 @@ cargo run -p app -- --connect 127.0.0.1:27015 # join one ``` Without arguments the simulator opens on a title screen: wordmark over the backdrop, and four -verbs — **Drive**, **Mods**, **Settings**, **Quit**. Drive walks line → vehicle → run in -three steps, shown as a numbered rail across the top with what was picked under each. Beside -the list a detail pane reads the highlighted entry out of the loaded content: length, permitted -speed and signals of a line; mass, running-gear limit, drive and brake of a vehicle; start time, -timetable and events of a scenario. The third step offers **two kinds of run**: a scenario, and -under a heading of its own every playable **service of an operating day** — a whole 24-hour -timetable that starts over at midnight (see *Timetable runs* below). Picking a service opens -one more page, where the **date** and the **weather** are set before the run starts. +verbs — **Drive**, **Mods**, **Settings**, **Quit**. Drive begins with **the run**, and +everything else follows from it. The list offers **two kinds of run**: a scenario, and every +playable **service of an operating day** — a whole 24-hour timetable that starts over at +midnight (see *Timetable runs* below). Both stand under the **route** they take, and picking +one picks that route with them: a scenario names the line it plays on, a service the line of +its plan, so the route is derived rather than asked for. Only what the run leaves open is a +step of its own — the line where the run names none (which is what the free run is), the +vehicle where the run does not bring one, and for a service the **date** and the **weather** +before it starts. The steps stand in a numbered rail across the top with what was picked under +each, so a prepared scenario is one question and the free run is three. Beside the list a +detail pane reads the highlighted entry out of the loaded content: length, permitted speed and +signals of a line; mass, running-gear limit, drive and brake of a vehicle; start time, +timetable and events of a scenario; train number, departure and arrival of a service. `↑`/`↓` or the mouse select, `Enter` or a left click confirms, `←`/`→` dial a setting, `Esc` goes one step back and leaves at the title screen; `F9` opens the mod manager in-game. Any run flag (`--line`, `--loco`, `--scenario`, `--day`, `--frames`, `--screenshot`, …) skips the menu entirely, so the invocations above stay non-interactive — `--menu` puts it back in front, optionally on a named page -(`--menu settings`, also `root`, `line`, `loco`, `scenario`, `run`, `mods`), which is the only +(`--menu settings`, also `root`, `run`, `line`, `loco`, `setup`, `mods`), which is the only way to photograph the menu itself. The picture behind the menu lives in `crates/app/images/` and is compiled into the binary. The diff --git a/STATUS.md b/STATUS.md index 48fb05e..6096e32 100644 --- a/STATUS.md +++ b/STATUS.md @@ -16,7 +16,7 @@ As of 2026-08-31 · `cargo test --workspace`: **1136 tests green** · clippy and | **M5** | LZB 80 + AFB, MFA, tap-changer loco | **done** — LZB with guidance, braking curve, end and failure procedures, with and without PZB, full/partial block mode and CIR-ELKE; BR 110 present; **AFB** as vehicle equipment (`VehicleSpec::afb`): holds the dial speed with traction, dynamic brake and — where that does not suffice — the air brake, and under LZB guidance runs down the braking curve because the LZB's v-soll caps the dial; MFA values and lamps ship as indicators — HUD text, `gauge:`/`lamp:` instruments and render-to-texture displays in the 3D cab (see M6) | | **M6** | Interactive 3D cab, start-up procedure, audio, weather/night | **done** — interactive 3D cab: per-vehicle cab data (eye point + controls binding glTF nodes to a closed input registry incl. wipers, lights and display softkeys), mouse picking with drag/click/scroll gestures per control kind, hover glow, HUD readout, operating clicks via `Control(…)` sound quantities; instruments: gauges/lamps of the safety systems (`gauge:`/`lamp:` indicators, MFA pointers), `digit:` seven-segment counters, and **displays rendered to texture** (declarative widget lists in RON, a Lua `display(ctx)` hook with nested menus and clickable softkeys, or an HTML/CSS/JS page per screen — parsed, flex-laid-out and scripted in-engine by the `html-display` crate, no browser embedded); edited in the vehicle editor with viewport preview; start-up chain operable via keyboard and mouse; **weather** (plan 14.1): `sim_core::weather` holds it as physical quantities — cover and cloud base, precipitation kind and rate [mm/h], wind speed and bearing, sight, temperature and a thunder rate — moved between thirteen named presets over a five-minute transition by the `SetWeather` scenario action, with the surface water and the lying snow integrated in the fixed step and the rail condition falling out of them (the first rain on a dry rail is greasy before it is merely wet). Nothing of it is replicated: between two scenario actions the weather is a pure function of the scenario clock, lightning included, so every client stands in the same rain and sees the same flash. Rendered as **clouds in two tiers, switched by a graphics setting** (`world_render::clouds`): both write a 2048 × 1024 equirectangular panorama through an offscreen camera and show it on a dome in the transparent phase, filtered cubically — a camera on the ground never enters a cloud, so a direction is all a cloud has to be a function of. The panorama is **amortised over sixteen frames** on a 4 × 4 Bayer slot, which is what pays for 0.18° a texel at fewer texels a frame than the 768 × 384 panorama it replaces, and **accumulated over about a second** — two buffers swapping roles each frame, every march blended into its texel, with the ray sent through a new point of the texel, started a new way into its first step and aimed along a new line of the light cone each turn, so the blend converges on a filtered edge and a noise-free body instead of freezing one sample's raster into the sky; the history is read where the deck has drifted from over the turn, so a moving cloud is followed rather than smeared. Volumetric is a Nubis-style raymarch (gradient-Perlin-Worley shape at 128³ carved by a Worley detail volume — wisps at the base, billows above — sampled anisotropically so a deck billows upwards instead of extruding one horizontal slice, Beer attenuation with a powder term for front-lit views only, a dual-lobe phase for the silver lining, 96 steps along the ray and four multiple-scattering octaves, an ambient read from Bevy's own atmosphere cubemap with the sun diffused two-stream through the body of the cloud so a closed deck is a grey sky rather than a black slab, and an analytic aerial perspective that fades a far cloud into that same sky); the fallback reads the same field on three slices and walks the self-shadow across that height field, a dozen fetches against several hundred, so a weak machine loses the billows rather than the sharpness. The deck both **drifts** (at 2.5 × the reported ten-metre wind, which is roughly what blows above the friction of the ground) and **evolves** — the march walks the unused part of the shape volume's vertical axis over time, so clouds grow and dissolve instead of only sliding past, and the cover breathes with it; both are functions of the scenario clock and cost nothing over the network, **haze in the atmosphere itself** (a Koschmieder extinction as an extra `ScatteringMedium` term, so fog is blue at dusk and bright around the sun, plus an analytic near-field falloff below 8 km of sight, which the planetary look-up tables cannot resolve), **wet and snowed-on surfaces** (`weather.wgsl`, shared by the terrain and by an extension swapped over every mod material as it spawns: Lagarde's albedo darkening and roughness, procedural ripple normals where the drops land, snow by world normal with a ragged edge, and the dapple of the clouds on the ground), **rain and snow around the camera** (one draw call, thinned by the intensity in the shader, added rather than blended, leaning into the wind of the weather plus the train's own rush of air, and off in a tunnel because the track type says where one is), **lightning and thunder** (a strike read off the clock, lighting the cloud deck and the ground, with a `Thunder` sound quantity delayed by `distance / 343 m/s` and rolling longer the further it struck) **rain on the cab glass** (`world_render::windscreen`: the panes a vehicle names in its `cab:` block get their own material — a film that thickens with the weather, drops in a cell grid that crawl down the glass at a stand and are pushed up it by the airflow above about 15 km/h, and the strip the wiper leaves clear, sampled from the same sweep curve the blade is drawn with) and **ground mist** as a Bevy fog volume with the sun's shafts through it (a graphics setting of its own). `--weather ` places one for a screenshot; `mods/example/scenarios/regenfahrt.ron` shows a run into rain and fog; terrain from the DGM; **day/night cycle with a physically based sky** (`world_render::sky`): Bevy's implementation of Hillaire's scalable sky-and-atmosphere technique (transmittance, multiple-scattering, sky-view and aerial-perspective LUTs — Rayleigh and Mie scattering, so the blue noon, the red sunset and the haze over a distant valley all fall out of one model), the sun's disk drawn into it by the atmosphere itself, a moon disk half a degree wide shaded from the real sun direction (phase, terminator and earthshine out of the almanac), and the 8 900 naked-eye stars of the HYG catalogue as point sprites in J2000 equatorial coordinates plus a procedural Milky Way — turned into the local sky by the observer's latitude and the sidereal time, and extincted by air mass near the horizon; the scenario's start clock (date + time) and the georeferenced location are the whole input; **seasons** (ch. 14 "seasons v2"): the same start date colours ground textures and placeholder vegetation — meadows turn through October, ground, gravel and foliage go under snow from November to March — and a mod may add optional `autumn_model`/`winter_model` variants to its track objects, falling back to the year-round model where it ships none; **night lighting**: signal lamps glow (HDR + bloom on the main camera, emissive lenses), headlight cones at both train ends follow the light switch, the direction of travel and the darkness, red tail lamps (Zg 101) mark the opposite end, **mods' `_NIGHT` nodes** (lit windows, glowing signs) switch at dusk in every model, cab light on its own switch (`CabControl::Headlights`/`CabLight`, keys 9/0) and **instrument backlighting on its own dimmer** (`CabControl::InstrumentLight`, keys `,`/`.`) — a part on the new `Motion::Emissive`, which scales the emissive colour of the mod's own material by the dimmer instead of switching the node, so the dials come up out of the dark continuously (content per vehicle; the example BR 101 carries a backlit panel); **terrain texturing and vegetation** (ch. 14): texture splatting — per-vertex weights from slope and track distance blend three generated ground textures (grass/rock/gravel) in a `StandardMaterial` extension — and vegetation as **line content**: every tree its own `LineSource::trees` entry (3D objects from mods' `objects/*.ron`, placeholder for the unnamed), spawned as children of their terrain tile so they stream with it and batch into instanced draws; woods are baked into single trees by the editor, so each one stays individually editable; no recorded samples (the sources are generated — content, not code) | | **M7** | Pilot line from OSM/DGM, scenarios, scoring, save/load | **largely done** — scenario system, scoring, save/load and the OSM/DGM importer are in place; only a real pilot line is missing (data procurement) | -| **M8** | Mod runtime: declarative content plus Lua behaviour | **done** — loader with dependency order, vehicles/lines/compositions/scenarios/timetables/signal types/signal models/track types/track objects as RON, signal state machine as data, four Lua hooks (vehicle, signal aspect, line, scenario) with a sandbox, main menu with line, vehicle and scenario selection from the loaded mods (keyboard and mouse), mod manager on the same menu (a toggle applies on start) and under F9; reference mod under `mods/example` incl. a glTF model. Only distribution (`.crails` zip + installer) is still open | +| **M8** | Mod runtime: declarative content plus Lua behaviour | **done** — loader with dependency order, vehicles/lines/compositions/scenarios/timetables/signal types/signal models/track types/track objects as RON, signal state machine as data, four Lua hooks (vehicle, signal aspect, line, scenario) with a sandbox, main menu with run, line and vehicle selection from the loaded mods (keyboard and mouse), mod manager on the same menu (a toggle applies on start) and under F9; reference mod under `mods/example` incl. a glTF model. Only distribution (`.crails` zip + installer) is still open | ## What is in place @@ -307,8 +307,8 @@ As of 2026-08-31 · `cargo test --workspace`: **1136 tests green** · clippy and - **Operating days (ch. 11):** the second way a line is driven, beside the scenarios. An `OperatingDay` (`sim_core::day`, `days/*.ron` in a mod, plus a built-in one for the Musterbahn) is a whole day of **services** with wall-clock times that loop every 24 hours; - the run picker lists every playable one under a heading of its own, and the run starts two - minutes before the service departs. The rest of the plan runs around it: a service claims a + the run picker lists every playable one under a heading naming its route, and the run starts + two minutes before the service departs. The rest of the plan runs around it: a service claims a train `LEAD` before it leaves and gives it back `TAIL` after its last arrival, and between two workings the unit is **stabled** — not driven, not drawn, and skipped by the occupancy detection, so it is genuinely off the line. The next service that needs the same stock takes @@ -1663,10 +1663,15 @@ As of 2026-08-31 · `cargo test --workspace`: **1136 tests green** · clippy and scoring with it; without one only the scenario points count. Lines and scenarios have their own hooks (`on_load`, `on_frame`): the script decides *when* an event fires, the actions of that event stay declarative RON — an event with - `trigger: Never` waits for the script. **The main menu picks line, vehicle and scenario** - from the loaded mods — three list pages, each opening with the built-in default, so a - run starts even with nothing installed; lines and compositions share one list, since - `resolve_line` takes either name. **The mod manager lives on the same menu** (installed + `trigger: Never` waits for the script. **The main menu picks the run first, and the route + follows from it** — a scenario names the line it plays on, a service the line of its + operating day, so a run is offered under that route and never on another one (`Route` in + `crates/app/src/menu.rs`; `world::build` lets the run's own line beat the menu's, with only + `--line` above it). What the run leaves open is still a page: the line where it names none, + the vehicle where it brings no consists and no `vehicle:`, and for a service the date and + the weather. Each list opens with the built-in default, so a run starts even with nothing + installed; lines and compositions share one list, since `resolve_line` takes either name. + A run whose route no installed mod brought is not offered at all. **The mod manager lives on the same menu** (installed mods with version, on/off state, missing dependencies and the loading warnings); switching writes `enabled` back into `mod.ron` (that one field only) and takes effect when the run starts, because the world is built only on leaving the menu. F9 opens the @@ -2147,11 +2152,14 @@ Every simplification is marked with a `ponytail:` comment at the code site, with genuinely has to see every step moves into `Sim::step`. - **The main menu is built from plain Bevy UI nodes** — no `egui` in the simulator. A **title screen** (wordmark over the backdrop and four verbs set large) and behind it a - **full-width flow**: the three steps of picking a run stand in a numbered rail across the - top with what was picked under each, the list sits left, and a detail pane on the right - shows what the highlighted row actually is — length, permitted speed and signal count of - a line, mass, running-gear limit, drive and brake of a vehicle, start time, timetable and - event count of a scenario, all read off the same data the simulation runs on. There is + **full-width flow**: the steps of picking a run stand in a numbered rail across the top + with what was picked under each — as many as the run leaves open, so a scenario that + names its line and brings its own consists is one step and the free run is three. The + list sits left, and a detail pane on the right shows what the highlighted row actually + is — length, permitted speed and signal count of a line, mass, running-gear limit, drive + and brake of a vehicle, start time, timetable and event count of a scenario, train + number, departure and arrival of a service, all read off the same data the simulation + runs on. There is deliberately **no navigation rail down the side**: a rail plus a content pane is the shape of a web dashboard and reads as one whatever it is coloured; the step rail is the breadcrumb, and Esc is the way home. Keyboard and mouse drive the same selection index: diff --git a/crates/app/src/menu.rs b/crates/app/src/menu.rs index 987f6c4..f616ed0 100644 --- a/crates/app/src/menu.rs +++ b/crates/app/src/menu.rs @@ -5,11 +5,16 @@ //! down the left with a content pane beside it is the shape of a web dashboard, and it //! reads as one no matter how it is coloured. //! -//! **The flow** takes the whole screen. Picking a run walks line → vehicle → scenario; -//! which step that is stands in a numbered rail across the top, with what has been picked -//! under each — that is the breadcrumb, the back button and the progress bar in one. The -//! list sits left, and beside it a pane reads the highlighted entry out of the loaded -//! content. Esc walks the steps back and leaves at the title screen. +//! **The flow** takes the whole screen. It begins with the run — a scenario, a service +//! out of an operating day, or free rein — and everything else follows from it: a +//! scenario names the line it plays on, and a service names the line of its operating +//! day, so the route is derived rather than asked for. Only what the run leaves open is +//! still a step, so picking a prepared run can be the whole flow, while the free run +//! walks route and vehicle as before. Which step that is stands in a numbered rail across +//! the top, with what has been picked under each — that is the breadcrumb, the back +//! button and the progress bar in one. The list sits left, and beside it a pane reads the +//! highlighted entry out of the loaded content. Esc walks the steps back and leaves at +//! the title screen. //! //! Keyboard (↑/↓, ←/→, Enter, Esc) and mouse (wheel scrolls, hover selects, click //! confirms) drive the @@ -89,7 +94,7 @@ pub struct Selection { /// The service out of an operating day the player took instead of a scenario /// (plan ch. 11). pub service: Option, - /// The date and the weather they set for it on [`Page::Run`]. `None` = whatever the + /// The date and the weather they set for it on [`Page::Setup`]. `None` = whatever the /// plan itself says, which is what a run started from the command line gets. pub setup: Option, } @@ -97,7 +102,7 @@ pub struct Selection { /// The verbs on the title screen, in order: the key of the label, and the page it opens. /// Quit carries no page — it leaves. const VERBS: [(&str, Option); 4] = [ - ("menu-drive", Some(Page::Line)), + ("menu-drive", Some(Page::Run)), ("menu-mods", Some(Page::Mods)), ("menu-settings", Some(Page::Settings)), ("menu-quit", None), @@ -114,19 +119,23 @@ const PAUSE_VERBS: [&str; 4] = ["menu-resume", "menu-settings", "menu-title", "m const NOT_WHILE_DRIVING: [Setting; 1] = [Setting::Language]; /// Which screen the menu is showing. `Root` is the title screen; everything else is the -/// full-width flow behind it, and the first three are the steps of picking a run. +/// full-width flow behind it, and [`Page::Run`] opens the steps of picking a run. #[derive(Default, Clone, Copy, PartialEq, Eq, Debug)] enum Page { #[default] Root, /// The root of the overlay over a standing run. Pause, + /// The first step: which run is being driven — a scenario, a service out of an + /// operating day, or free rein. Everything after it depends on what it left open. + Run, + /// Which route the run takes. Only walked where the run names none. Line, + /// What is at the head of the train. Only walked where the run does not say. Loco, - Scenario, - /// Date and weather for a timetable run — the one step that is only walked when the - /// run picked is a service rather than a scenario. - Run, + /// Date and weather for a timetable run — a service lies at the same hour of the same + /// line every time it is taken, so those two are the player's. + Setup, Mods, Settings, /// The keyboard and the controllers, one row per action. A page of its own rather @@ -146,32 +155,81 @@ impl Page { matches!(self, Page::Root | Page::Pause) } - /// Where Esc goes. Everything leads back to the page of verbs, one step at a time; - /// from there the front end has nowhere to go and the overlay resumes the run. - fn back(self, overlay: bool) -> Option { + /// Where Esc goes. Inside the drive flow that is the step before this one, which is + /// not a fixed page any more — a scenario brings its own route and its own train, and + /// the steps it answered are not walked. Everything else leads back to the page of + /// verbs, from where the front end has nowhere to go and the overlay resumes the run. + fn back(self, overlay: bool, flow: &[Page]) -> Option { match self { Page::Root | Page::Pause => None, - Page::Line | Page::Mods | Page::Settings => Some(Page::home(overlay)), + Page::Mods | Page::Settings => Some(Page::home(overlay)), Page::Controls => Some(Page::Settings), - Page::Loco => Some(Page::Line), - Page::Scenario => Some(Page::Loco), - Page::Run => Some(Page::Scenario), + _ => match flow.iter().position(|page| *page == self) { + Some(0) | None => Some(Page::home(overlay)), + Some(at) => Some(flow[at - 1]), + }, + } + } + + /// The title the step rail shows over this step of the drive flow. + fn step_title(self) -> &'static str { + match self { + Page::Run => "menu-select-run", + Page::Line => "menu-select-line", + Page::Loco => "menu-select-loco", + Page::Setup => "menu-run-setup", + _ => "", } } - /// Position of this page in the three steps of picking a run. Setting up a timetable - /// run is the second half of the third step, not a fourth one — the rail would - /// otherwise promise a step that a scenario never walks. - fn step(self) -> Option { + /// Where the answer given on this step is remembered — [`MenuState::chosen`] is + /// indexed by it, so a step keeps its answer even while another run hides it. + fn slot(self) -> Option { match self { + Page::Run => Some(0), Page::Line => Some(1), Page::Loco => Some(2), - Page::Scenario | Page::Run => Some(3), + Page::Setup => Some(3), _ => None, } } } +/// Which of the steps behind the run a run leaves for the player to answer. +/// +/// A scenario that names its line and brings its own consists answers both by itself — +/// picking it is the whole flow. One that says neither asks for both, which is what the +/// free run is. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct Open { + /// The run names no route, so the player picks one ([`Page::Line`]). + line: bool, + /// The run does not say what is at the head, so the player picks ([`Page::Loco`]). + loco: bool, + /// It is a service, so date and weather are set before it starts ([`Page::Setup`]). + setup: bool, +} + +impl Open { + /// The pages of the flow, in the order they are walked. The run picker itself is + /// always the first of them. + fn flow(self) -> Vec { + let mut pages = vec![Page::Run]; + pages.extend(self.line.then_some(Page::Line)); + pages.extend(self.loco.then_some(Page::Loco)); + pages.extend(self.setup.then_some(Page::Setup)); + pages + } + + /// The page after `page`, or `None` where `page` is the last step and Enter starts + /// the run. + fn after(self, page: Page) -> Option { + let flow = self.flow(); + let at = flow.iter().position(|p| *p == page)?; + flow.get(at + 1).copied() + } +} + /// Which page the menu shows and which row is selected. #[derive(Resource, Default)] pub struct MenuState { @@ -193,9 +251,9 @@ pub struct MenuState { /// counter rather than an index — [`variant_of`] wraps it into what the vehicle has, /// so moving to a vehicle with fewer variants can never point past the end. variant: usize, - /// Labels of the line, the vehicle and the run already picked — the step rail shows + /// Labels of what has been picked, indexed by [`Page::slot`] — the step rail shows /// them under their step, and only the menu ever needs them as text. - chosen: [String; 3], + chosen: [String; 4], /// The two screens, shown one at a time. title_screen: Option, flow_screen: Option, @@ -226,8 +284,10 @@ impl Page { "root" => Some(Page::Root), "line" => Some(Page::Line), "loco" => Some(Page::Loco), - "scenario" => Some(Page::Scenario), - "run" => Some(Page::Run), + // `scenario` is what this page was called while it came last; the screenshots + // and the README still name it, and it is the same list. + "run" | "scenario" => Some(Page::Run), + "setup" => Some(Page::Setup), "mods" => Some(Page::Mods), "settings" => Some(Page::Settings), "controls" => Some(Page::Controls), @@ -343,7 +403,7 @@ enum RunOption { Preset, } -/// The rows of [`Page::Run`], in order — the second of them decides whether the third is +/// The rows of [`Page::Setup`], in order — the second of them decides whether the third is /// there at all. const RUN_OPTIONS: [RunOption; 3] = [RunOption::Date, RunOption::Weather, RunOption::Preset]; @@ -767,10 +827,11 @@ pub fn spawn_menu( mut selection: ResMut, menu: ResMut, ) { - // `--menu run` is a screenshot of a page that only exists once a service has been + // `--menu setup` is a screenshot of a page that only exists once a service has been // picked. A screenshot cannot pick one, so it is given the first of the built-in day // — the same reason `StartPage` exists at all. - if start.as_deref().map(|start| start.0.as_str()) == Some("run") && selection.service.is_none() + if start.as_deref().map(|start| start.0.as_str()) == Some("setup") + && selection.service.is_none() { let day = content::musterbahn_day(); if let Some((index, _)) = day.playable().next() { @@ -1106,7 +1167,7 @@ fn list_node(width: f32) -> Node { } } -/// The detail pane, shown on the three drive pages and collapsed on the other two — +/// The detail pane, shown on the pages of the drive flow and collapsed on the rest — /// there is nothing to say about a settings row that the row does not already say. fn detail_node(shown: bool) -> Node { Node { @@ -1237,11 +1298,10 @@ pub fn menu( ); } } - } else if menu.page == Page::Run && selection.service.is_none() { - // Nothing to set up: the page is the second half of picking a run, and the - // first half has not happened. - go(&mut menu, Page::Scenario); - } else if menu.page == Page::Run { + } else if menu.page == Page::Setup && selection.service.is_none() { + // Nothing to set up: the page belongs to a service, and no service was taken. + go(&mut menu, Page::Run); + } else if menu.page == Page::Setup { // ← / → dial the value, Enter starts — this is a step of the drive flow, and // in that flow Enter has meant "on you go" since the first page. if dial != 0 @@ -1275,10 +1335,34 @@ pub fn menu( exit.write(AppExit::Success); } }, + // The first step, and the one the others follow from: the run says which + // route it takes and, where it brings consists or names a vehicle, what + // runs on it. Only what it leaves open is still walked — a scenario that + // answered both starts from here. + Page::Run => { + let (route, open) = run_of(&mods.0, id.as_deref(), service.as_ref()); + // A service is set up before it is driven: the date and the weather + // are the player's, and the plan's own are what they start from. + selection.setup = service + .as_ref() + .and_then(|reference| resolve_day(&mods.0, &reference.day)) + .map(|day| day.setup()); + selection.scenario_id = id; + selection.service = service; + // The route the run named. An open one is cleared rather than kept: + // what the last run stood on is not an answer to this one. + selection.line_ref = route.line_ref(); + choose(&mut menu, Page::Run, label); + if !open.line { + choose(&mut menu, Page::Line, route_name(&mods.0.mods, &route)); + } + advance(&mut menu, &mut next, open.after(Page::Run)); + } Page::Line => { selection.line_ref = id; - menu.chosen[0] = label; - go(&mut menu, Page::Loco); + choose(&mut menu, Page::Line, label); + let to = next_step(&mods.0, &selection, Page::Line); + advance(&mut menu, &mut next, to); } Page::Loco => { // The dress belongs on the `Vehicle`, where it is deterministic state @@ -1287,34 +1371,13 @@ pub fn menu( let variants = variant_count(entry, &mods.0); selection.variant = (variants > 0).then(|| menu.variant % variants); selection.loco_id = id; - menu.chosen[1] = label; - go(&mut menu, Page::Scenario); - } - Page::Scenario => { - menu.chosen[2] = label; - match service { - // A service is set up before it is driven: the date and the - // weather are the player's, and the plan's own are what they - // start from. - Some(reference) => { - selection.setup = - resolve_day(&mods.0, &reference.day).map(|day| day.setup()); - selection.scenario_id = None; - selection.service = Some(reference); - go(&mut menu, Page::Run); - } - // A scenario brings its own hour and its own sky, and starts. - None => { - selection.scenario_id = id; - selection.service = None; - selection.setup = None; - next.set(GameState::Driving); - } - } + choose(&mut menu, Page::Loco, label); + let to = next_step(&mods.0, &selection, Page::Loco); + advance(&mut menu, &mut next, to); } // Handled above, before the confirm: the page has no rows that lead // anywhere, only values that are dialled. - Page::Run => {} + Page::Setup => {} Page::Mods => { mods_ui::toggle(&mut mods.0, menu.selected, &mut manager); // Reload right away, so the selection lists show what is enabled now. @@ -1334,7 +1397,10 @@ pub fn menu( } } if keys.just_pressed(KeyCode::Escape) { - match menu.page.back(overlay) { + // Which steps lie behind this one is the run's to say, so Esc asks the run + // that was taken rather than a fixed chain of pages. + let walked = flow(&mods.0, menu.page, &selection, None); + match menu.page.back(overlay, &walked) { Some(back) => go(&mut menu, back), // Esc on the overlay's own root is the way out of the pause: the run goes on. None if overlay => next.set(GameState::Driving), @@ -1363,8 +1429,20 @@ pub fn menu( && items .get(menu.selected) .is_some_and(|entry| variant_count(entry, &mods.0) > 0); + // The rail is the flow of the run under the cursor, not a fixed three steps: a + // scenario that brings its route and its train asks one question, and the rail + // has to say so before the answer is given rather than after. + let walked = flow(&mods.0, page, &selection, items.get(menu.selected)); + let last = walked.last() == Some(&page); show_screen(&mut commands, &menu, page); - build_steps(&mut commands, &fonts, menu.steps, page, &menu.chosen); + build_steps( + &mut commands, + &fonts, + menu.steps, + page, + &menu.chosen, + &walked, + ); build_rows(&mut commands, &fonts, rows, &items, &menu); build_detail( &mut commands, @@ -1377,8 +1455,9 @@ pub fn menu( &mods.0, &selection, menu.variant, + last, ); - build_hints(&mut commands, &fonts, hints, page, variants); + build_hints(&mut commands, &fonts, hints, page, variants, last); menu.drawn = Some(print); } @@ -1434,6 +1513,36 @@ fn go(menu: &mut MenuState, page: Page) { menu.rebinding = None; } +/// Walks to the next step of the drive flow, or starts the run where there is none left. +fn advance(menu: &mut MenuState, next: &mut NextState, page: Option) { + match page { + Some(page) => go(menu, page), + None => next.set(GameState::Driving), + } +} + +/// Remembers what a step was answered with, and forgets the answers behind it — a second +/// walk through the flow must not show what the first one left standing. +fn choose(menu: &mut MenuState, page: Page, label: String) { + let Some(slot) = page.slot() else { return }; + menu.chosen[slot] = label; + for later in &mut menu.chosen[slot + 1..] { + later.clear(); + } +} + +/// The step after `page` for the run the selection holds, or `None` where `page` is the +/// last one and the run starts. +fn next_step(runtime: &mod_runtime::ModRuntime, selection: &Selection, page: Page) -> Option { + run_of( + runtime, + selection.scenario_id.as_deref(), + selection.service.as_ref(), + ) + .1 + .after(page) +} + /// The first selectable row from `from` in direction `dir`, wrapping. The settings page /// is the only one with headings, which are drawn but never land on the cursor. fn selectable(items: &[Entry], from: usize, dir: i32) -> usize { @@ -1584,29 +1693,31 @@ fn scroll_into_view( // Drawing // --------------------------------------------------------------------------------- -/// The three steps of the drive section, in order: their titles, and which of them the -/// player has already answered. -const STEPS: [&str; 3] = ["menu-select-line", "menu-select-loco", "menu-select-run"]; - -/// The numbered rail across the top of the flow: which of the three steps this is, and -/// what has been answered for the ones behind it. Breadcrumb, progress bar and the reason -/// there is no navigation rail down the side, in one row. +/// The numbered rail across the top of the flow: which step this is, and what has been +/// answered for the ones behind it. Breadcrumb, progress bar and the reason there is no +/// navigation rail down the side, in one row. +/// +/// `walked` is the flow of the run being picked, which is not the same length every time: +/// a scenario that names its line and brings its own consists is one step, the free run +/// is three. A step that is never walked is never promised. fn build_steps( commands: &mut Commands, fonts: &Fonts, steps: Option, page: Page, - chosen: &[String; 3], + chosen: &[String; 4], + walked: &[Page], ) { let Some(steps) = steps else { return }; commands.entity(steps).despawn_related::(); - let Some(current) = page.step() else { + let Some(current) = walked.iter().position(|step| *step == page) else { return; }; - for (index, title) in STEPS.iter().enumerate() { + for (index, step_page) in walked.iter().enumerate() { let number = index + 1; - let here = number == current; - let done = number < current; + let title = step_page.step_title(); + let here = index == current; + let done = index < current; if index > 0 { commands.spawn(( Node { @@ -1683,7 +1794,10 @@ fn build_steps( )); // Under the label: what was picked for that step, so the answers stay on screen // while the next question is asked. - let answer = chosen.get(index).filter(|c| !c.is_empty()); + let answer = step_page + .slot() + .and_then(|slot| chosen.get(slot)) + .filter(|c| !c.is_empty()); commands.spawn(( text( fonts, @@ -2108,7 +2222,14 @@ fn build_value( /// The key hints, as separate chips rather than one string padded with double spaces — /// those collapse the moment the face is proportional. -fn build_hints(commands: &mut Commands, fonts: &Fonts, hints: Entity, page: Page, variants: bool) { +fn build_hints( + commands: &mut Commands, + fonts: &Fonts, + hints: Entity, + page: Page, + variants: bool, + last: bool, +) { commands.entity(hints).despawn_related::(); let mut keys: Vec<(&str, &str)> = vec![("↑/↓", "menu-hint-select")]; if page == Page::Controls { @@ -2121,7 +2242,7 @@ fn build_hints(commands: &mut Commands, fonts: &Fonts, hints: Entity, page: Page if page == Page::Settings { keys.push(("←/→", "menu-hint-change")); keys.push(("Enter", "menu-hint-next")); - } else if page == Page::Run { + } else if page == Page::Setup { keys.push(("←/→", "menu-hint-change")); keys.push(("Enter", "menu-hint-start")); } else { @@ -2133,9 +2254,11 @@ fn build_hints(commands: &mut Commands, fonts: &Fonts, hints: Entity, page: Page keys.push(( "Enter", match page { - Page::Scenario => "menu-hint-confirm", Page::Mods => "menu-hint-toggle", Page::Root | Page::Pause => "menu-hint-open", + // On the last step of the flow Enter is the start — which step that is + // depends on what the run left open. + _ if last => "menu-hint-start", _ => "menu-hint-confirm", }, )); @@ -2199,6 +2322,8 @@ fn build_detail( runtime: &mod_runtime::ModRuntime, selection: &Selection, variant: usize, + // Whether this is the last step of the flow — what the button at the foot says. + last: bool, ) { commands.entity(detail).despawn_related::(); let Some(facts) = entry.and_then(|entry| facts(page, entry, runtime, selection, variant)) @@ -2325,7 +2450,7 @@ fn build_detail( commands.spawn(( text( fonts, - t!(if matches!(page, Page::Scenario | Page::Run) { + t!(if last { "menu-action-start" } else { "menu-action-next" @@ -2347,8 +2472,8 @@ fn title(page: Page) -> String { Page::Root | Page::Pause => String::new(), Page::Line => t!("menu-select-line"), Page::Loco => t!("menu-select-loco"), - Page::Scenario => t!("menu-select-run"), - Page::Run => t!("menu-run-setup"), + Page::Run => t!("menu-select-run"), + Page::Setup => t!("menu-run-setup"), Page::Mods => t!("mods-title"), Page::Settings => t!("menu-settings"), Page::Controls => t!("ctl-title"), @@ -2365,8 +2490,8 @@ fn caption(page: Page, runtime: &mod_runtime::ModRuntime, manager: &ModManager) Page::Mods => mods_ui::details(runtime, manager, true), Page::Line => t!("menu-select-line-hint"), Page::Loco => t!("menu-select-loco-hint"), - Page::Scenario => t!("menu-select-run-hint"), - Page::Run => t!("menu-run-setup-hint"), + Page::Run => t!("menu-select-run-hint"), + Page::Setup => t!("menu-run-setup-hint"), } } @@ -2395,12 +2520,8 @@ fn monogram(name: &str) -> String { } } -/// Every operating day the run picker offers, the built-in one first. -/// -/// A plan that belongs to another line is left out: its stops and origins are indices -/// into that line's track graph, and offering them here would be offering a run that -/// starts somewhere else entirely. -fn days(runtime: &mod_runtime::ModRuntime, line: Option<&str>) -> Vec<(String, OperatingDay)> { +/// Every operating day the run picker knows, the built-in one first. +fn days(runtime: &mod_runtime::ModRuntime) -> Vec<(String, OperatingDay)> { std::iter::once((BUILTIN_DAY.to_string(), content::musterbahn_day())) .chain( runtime @@ -2409,10 +2530,116 @@ fn days(runtime: &mod_runtime::ModRuntime, line: Option<&str>) -> Vec<(String, O .iter() .map(|(id, day)| (id.clone(), day.clone())), ) - .filter(|(_, day)| day.line.is_none() || day.line.as_deref() == line) .collect() } +/// The route a run takes — what the run picker derives from the run instead of asking +/// for it. A scenario's stops, origins and event triggers are indices into one line's +/// track graph; put on another they address whatever happens to lie at that number, so +/// the route is the run's to name, not the player's to guess. +#[derive(Clone, PartialEq, Eq, Debug)] +enum Route { + /// The example line the simulator brings itself. The built-in operating day runs on + /// it and on nothing else — it names no line only because that line has no mod id. + Builtin, + /// A line or a composition out of a mod, by id. + Mod(String), + /// The run names none, so the player is asked and the run is put on the answer. What + /// the free run is, and what a mod's day or scenario without a `line:` stays. + Open, +} + +impl Route { + /// The route a `line:` field names. + fn named(line: Option<&str>) -> Route { + line.map_or(Route::Open, |id| Route::Mod(id.to_string())) + } + + /// What [`Selection::line_ref`] becomes for it — `None` is the built-in line, and an + /// open route starts there too until the player says otherwise. + fn line_ref(&self) -> Option { + match self { + Route::Mod(id) => Some(id.clone()), + Route::Builtin | Route::Open => None, + } + } +} + +/// The name a route is shown under. An id no installed mod brought stays the id, so a +/// run pointing at content that is not there still says what it is missing. +fn route_name(mods: &mod_runtime::Mods, route: &Route) -> String { + match route { + Route::Builtin => t!("menu-line-builtin"), + Route::Open => t!("menu-route-open"), + Route::Mod(id) => mods + .lines + .get(id) + .map(|line| line.name.clone()) + .or_else(|| mods.compositions.get(id).map(|c| c.name.clone())) + .unwrap_or_else(|| id.clone()), + } +} + +/// The route a run takes and what it leaves for the player to answer, read back out of +/// the loaded content. The free run — neither a scenario nor a service — leaves all of it. +fn run_of( + runtime: &mod_runtime::ModRuntime, + scenario_id: Option<&str>, + service: Option<&ServiceRef>, +) -> (Route, Open) { + let (route, loco, setup) = if let Some(reference) = service { + let day = resolve_day(runtime, &reference.day); + let route = if reference.day == BUILTIN_DAY { + Route::Builtin + } else { + Route::named(day.as_ref().and_then(|day| day.line.as_deref())) + }; + // A working that names its vehicle has answered the question — the timetable says + // what runs, and `world::build` takes it over the menu's pick. + let loco = day + .as_ref() + .and_then(|day| day.services.get(reference.index)) + .is_none_or(|service| service.vehicle.is_none()); + (route, loco, true) + } else if let Some(id) = scenario_id { + let scenario = runtime.mods.scenarios.get(id); + // A scenario with consists of its own puts the player's train on the line itself; + // the vehicle picked in the menu is never asked for then. + ( + Route::named(scenario.and_then(|s| s.line.as_deref())), + scenario.is_none_or(|s| s.consists.is_empty()), + false, + ) + } else { + (Route::Open, true, false) + }; + let open = Open { + line: route == Route::Open, + loco, + setup, + }; + (route, open) +} + +/// The steps of the flow as they stand: derived from the run under the cursor while the +/// run picker is open, and from the run already taken on every step behind it. +fn flow( + runtime: &mod_runtime::ModRuntime, + page: Page, + selection: &Selection, + entry: Option<&Entry>, +) -> Vec { + let (scenario, service) = if page == Page::Run { + match entry { + Some(entry) => (entry.id.as_deref(), entry.service.as_ref()), + None => (None, None), + } + } else { + (selection.scenario_id.as_deref(), selection.service.as_ref()) + }; + run_of(runtime, scenario, service).1.flow() +} + /// The mod an id belongs to (`example:modul_ost` → `example`). fn origin(id: &str) -> String { id.split_once(':').map_or(id, |(m, _)| m).to_string() @@ -2484,52 +2711,87 @@ fn entries( .map(|(id, spec)| named(id, &spec.name, vehicle_meta(spec))), ) .collect(), - // Two kinds of run under one question: a scenario, which brings its own hour and - // its own task, and a service out of an operating day, which is one working of a - // timetable that runs all day and starts over at midnight (plan ch. 11). - Page::Scenario => { - let mut rows: Vec = - std::iter::once(builtin("menu-scenario-none", String::new())) - .chain(mods.scenarios.iter().map(|(id, scenario)| { + // The first question, and the one the rest follow from. Two kinds of run under + // it: a scenario, which brings its own hour and its own task, and a service out + // of an operating day, which is one working of a timetable that runs all day and + // starts over at midnight (plan ch. 11). Both name the route they take, so they + // stand under it — picking one is picking the line as well. + Page::Run => { + // The free run first, as on every selection page: the built-in default, which + // names neither a route nor a train and therefore walks the whole flow. + let mut rows = vec![builtin("menu-scenario-none", String::new())]; + let days = days(runtime); + // The routes in the order the content is loaded in: the mods' lines and + // compositions by id, then the example line the simulator brings itself, and + // last the runs that name no route at all. The built-in day is nineteen hours + // of services — put first it would bury every mod below it, and unlike the + // free run at the top it is not the default anything falls back to. + // + // A run whose route is not installed falls under no heading and is not + // offered: it could only be started on the wrong line. + let routes = mods + .lines + .keys() + .chain(mods.compositions.keys()) + .map(|id| Route::Mod(id.clone())) + .chain([Route::Builtin, Route::Open]); + for route in routes { + let scenarios: Vec<(&String, &sim_core::scenario::Scenario)> = mods + .scenarios + .iter() + .filter(|(_, scenario)| Route::named(scenario.line.as_deref()) == route) + .collect(); + if !scenarios.is_empty() { + rows.push(heading(t!( + "menu-scenario-heading", + route = route_name(mods, &route) + ))); + rows.extend(scenarios.into_iter().map(|(id, scenario)| { named( id, &scenario.name, format!("{:02}:{:02}", scenario.start.hour, scenario.start.minute), ) - })) - .collect(); - for (id, day) in days(runtime, selection.line_ref.as_deref()) { - if day.playable().next().is_none() { - continue; + })); } - rows.push(Entry { - label: t!("menu-day-heading", name = day.name.clone()), - heading: true, - ..default() - }); - for (index, service) in day.playable() { - let (from, to) = service.route(); - rows.push(Entry { - label: t!("menu-service", from = from, to = to), - meta: format!( - "{} {} – {}", - service.number, - clock_label(service.departure()), - clock_label(service.arrival()) - ), - monogram: category_mark(service), - chip: if id == BUILTIN_DAY { - t!("menu-chip-builtin") - } else { - origin(&id) - }, - hint: service.description.clone(), - service: Some(ServiceRef { - day: id.clone(), - index, - }), - ..default() - }); + for (id, day) in &days { + let day_route = if id == BUILTIN_DAY { + Route::Builtin + } else { + Route::named(day.line.as_deref()) + }; + if day_route != route || day.playable().next().is_none() { + continue; + } + rows.push(heading(t!( + "menu-day-heading", + name = day.name.clone(), + route = route_name(mods, &route) + ))); + for (index, service) in day.playable() { + let (from, to) = service.route(); + rows.push(Entry { + label: t!("menu-service", from = from, to = to), + meta: format!( + "{} {} – {}", + service.number, + clock_label(service.departure()), + clock_label(service.arrival()) + ), + monogram: category_mark(service), + chip: if id == BUILTIN_DAY { + t!("menu-chip-builtin") + } else { + origin(id) + }, + hint: service.description.clone(), + service: Some(ServiceRef { + day: id.clone(), + index, + }), + ..default() + }); + } } } rows @@ -2537,7 +2799,7 @@ fn entries( // The one step a scenario never walks: what day the service runs on and what the // weather does over it. The preset row is only there while a weather is named — // a row that says "none" and cannot be dialled is a dead control. - Page::Run => { + Page::Setup => { let setup = selection.setup.unwrap_or_default(); RUN_OPTIONS .into_iter() @@ -2694,6 +2956,15 @@ fn lever_bound(input: Option) -> String { format!("{none:>7} {axis:<13}") } +/// A section heading: drawn over the rows below it, never landed on by the cursor. +fn heading(label: String) -> Entry { + Entry { + label, + heading: true, + ..default() + } +} + fn named(id: &str, name: &str, meta: String) -> Entry { Entry { label: name.to_string(), @@ -2910,7 +3181,29 @@ fn facts( ..base(rows) }) } - Page::Scenario => { + Page::Run => { + // A working out of an operating day: what the timetable says about it. Its + // date and its weather are not here — they are the next step's question. + if let Some(reference) = &entry.service { + let day = resolve_day(runtime, &reference.day)?; + let service = day.services.get(reference.index)?; + let route = if reference.day == BUILTIN_DAY { + Route::Builtin + } else { + Route::named(day.line.as_deref()) + }; + return Some(Facts { + body: service.description.clone(), + rows: vec![ + (t!("menu-fact-train"), service.number.clone()), + (t!("menu-fact-departure"), clock_label(service.departure())), + (t!("menu-fact-arrival"), clock_label(service.arrival())), + (t!("menu-fact-stops"), service.stops.len().to_string()), + (t!("menu-fact-line"), route_name(mods, &route)), + ], + ..base(Vec::new()) + }); + } // The free run is the absence of a scenario, not a missing one — it gets the // pane and the start button like every other row. let Some(scenario) = entry.id.as_ref().and_then(|id| mods.scenarios.get(id)) else { @@ -2939,7 +3232,7 @@ fn facts( ), ( t!("menu-fact-line"), - scenario.line.clone().unwrap_or_else(|| t!("common-none")), + route_name(mods, &Route::named(scenario.line.as_deref())), ), (t!("menu-fact-events"), scenario.events.len().to_string()), ], @@ -2948,7 +3241,7 @@ fn facts( } // The pane on the setup page describes the service being set up, not the row — // the rows are its date and its weather, and they say what they are themselves. - Page::Run => { + Page::Setup => { let reference = selection.service.as_ref()?; let day = resolve_day(runtime, &reference.day)?; let service = day.services.get(reference.index)?; @@ -3065,7 +3358,7 @@ mod tests { fn selection_pages_are_never_empty() { let runtime = mod_runtime::ModRuntime::load("does-not-exist"); let (graphics, audio, gameplay) = default(); - for page in [Page::Line, Page::Loco, Page::Scenario, Page::Settings] { + for page in [Page::Line, Page::Loco, Page::Run, Page::Settings] { let items = entries( page, false, @@ -3080,7 +3373,7 @@ mod tests { assert!(!items.is_empty(), "{page:?} is empty"); } // The defaults carry no id — `setup` reads that as "use the built-in". - for page in [Page::Line, Page::Loco, Page::Scenario] { + for page in [Page::Line, Page::Loco, Page::Run] { let items = entries( page, false, @@ -3194,24 +3487,26 @@ mod tests { let mut app = app(); assert_eq!(page(&app), Page::Root, "the menu opens on the title screen"); key(&mut app, KeyCode::Enter); - assert_eq!(page(&app), Page::Line); + assert_eq!(page(&app), Page::Run, "the run comes first"); + // The free run names neither a route nor a train, so it is the one run that still + // asks both questions. key(&mut app, KeyCode::Enter); - assert_eq!(page(&app), Page::Loco); - key(&mut app, KeyCode::Escape); assert_eq!(page(&app), Page::Line); + key(&mut app, KeyCode::Escape); + assert_eq!(page(&app), Page::Run); // What the row observers set: a click confirms exactly like Enter, and once. app.world_mut().resource_mut::().clicked = true; app.update(); - assert_eq!(page(&app), Page::Loco); + assert_eq!(page(&app), Page::Line); app.update(); - assert_eq!(page(&app), Page::Loco, "the click was consumed"); + assert_eq!(page(&app), Page::Line, "the click was consumed"); key(&mut app, KeyCode::Enter); - assert_eq!(page(&app), Page::Scenario); - key(&mut app, KeyCode::Escape); assert_eq!(page(&app), Page::Loco); + key(&mut app, KeyCode::Escape); + assert_eq!(page(&app), Page::Line); key(&mut app, KeyCode::Enter); key(&mut app, KeyCode::Enter); assert_eq!( @@ -3223,40 +3518,164 @@ mod tests { assert!(selection.scenario_id.is_none(), "no scenario was picked"); } - /// Picking a service out of an operating day does not start the run: it opens the - /// one step a scenario never walks, where the date and the weather are set. + /// The route is the run's to name, not the player's to guess: a scenario stands under + /// the line it plays on, taking it puts the selection on that line, and the step that + /// would have asked for one is not walked at all. Before, every scenario was offered + /// whatever line had been picked, and the run was then built on the wrong graph. #[test] - fn a_timetable_run_is_set_up_before_it_starts() { + fn picking_a_scenario_settles_the_route_it_plays_on() { let mut app = app(); - for _ in 0..3 { - key(&mut app, KeyCode::Enter); - } - assert_eq!(page(&app), Page::Scenario); + key(&mut app, KeyCode::Enter); + assert_eq!(page(&app), Page::Run); - // Walk down to the first service — past the free run, the mods' scenarios and - // the heading over the day's services, which the cursor never lands on. + // The Bördefahrt: its own line, no consists of its own — route settled, vehicle + // still open, so exactly one step is left. let runtime = mod_runtime::ModRuntime::load("../../mods"); - let items = entries( - Page::Scenario, + let items = list_of(Page::Run, &runtime, &Selection::default()); + let at = items + .iter() + .position(|entry| entry.id.as_deref() == Some("example:boerdefahrt")) + .expect("the example mod ships the Bördefahrt"); + // It stands under a heading naming its line, not under any other. + let heading = items[..at] + .iter() + .rfind(|entry| entry.heading) + .expect("a heading over it"); + assert!( + heading.label.contains(&route_name( + &runtime.mods, + &Route::Mod("example:boerde".into()) + )), + "the Bördefahrt stands under {:?}", + heading.label + ); + + walk_to(&mut app, &items, at); + key(&mut app, KeyCode::Enter); + let selection = app.world().resource::(); + assert_eq!( + selection.line_ref.as_deref(), + Some("example:boerde"), + "the line came with the scenario" + ); + assert_eq!( + selection.scenario_id.as_deref(), + Some("example:boerdefahrt") + ); + assert_eq!(page(&app), Page::Loco, "and the route was never asked for"); + // Esc goes back past the step that was skipped, not into it. + key(&mut app, KeyCode::Escape); + assert_eq!(page(&app), Page::Run); + } + + /// Every run in the list stands under the route it takes, and a run whose route no + /// installed mod brought is not offered at all — it could only start on another line. + #[test] + fn every_run_stands_under_its_route() { + let mut runtime = mod_runtime::ModRuntime::load("../../mods"); + let items = list_of(Page::Run, &runtime, &Selection::default()); + let mut heading = String::new(); + let mut checked = 0; + for entry in &items { + if entry.heading { + heading.clone_from(&entry.label); + continue; + } + // The free run is the one row without a run behind it. + if entry.id.is_none() && entry.service.is_none() { + continue; + } + let (route, open) = run_of(&runtime, entry.id.as_deref(), entry.service.as_ref()); + assert!( + heading.contains(&route_name(&runtime.mods, &route)), + "{} stands under {heading:?}", + entry.label + ); + assert_eq!( + open.line, + route == Route::Open, + "{}: a named route is not asked for again", + entry.label + ); + checked += 1; + } + assert!(checked > 0, "the example mod ships runs"); + // A scenario whose line is not installed is left out rather than started on + // whatever else is there. + runtime.mods.scenarios.insert( + "test:orphan".into(), + sim_core::scenario::Scenario { + name: "Waise".into(), + line: Some("nowhere:at-all".into()), + ..default() + }, + ); + let items = list_of(Page::Run, &runtime, &Selection::default()); + assert!( + !items + .iter() + .any(|entry| entry.id.as_deref() == Some("test:orphan")), + "a run without its route is not offered" + ); + } + + /// Puts the cursor on row `at` of a freshly opened page. ↓ moves by selectable rows, + /// so the headings in between are not presses of their own. + fn walk_to(app: &mut App, items: &[Entry], at: usize) { + for _ in 0..items[..at].iter().filter(|entry| !entry.heading).count() { + key(app, KeyCode::ArrowDown); + } + } + + /// The rows of a page, with the defaults for everything the run picker does not read. + fn list_of(page: Page, runtime: &mod_runtime::ModRuntime, selection: &Selection) -> Vec { + entries( + page, false, - &runtime, - &Selection::default(), + runtime, + selection, &default(), &default(), &default(), &Binds::default(), None, - ); + ) + } + + /// Picking a service out of an operating day does not start it: the plan says where + /// it runs, so the route is settled, but what is at the head and which day it plays + /// on are still the player's. + #[test] + fn a_timetable_run_is_set_up_before_it_starts() { + let mut app = app(); + key(&mut app, KeyCode::Enter); + assert_eq!(page(&app), Page::Run); + + // Walk down to the first service — past the free run and the heading over the + // day's services, which the cursor never lands on. + let runtime = mod_runtime::ModRuntime::load("../../mods"); + let items = list_of(Page::Run, &runtime, &Selection::default()); let first = items .iter() - .position(|entry| entry.service.is_some()) + .position(|entry| { + entry + .service + .as_ref() + .is_some_and(|reference| reference.day == BUILTIN_DAY) + }) .expect("the built-in operating day offers services"); assert!(items[first - 1].heading, "under a heading of its own"); - for _ in 0..first { - key(&mut app, KeyCode::ArrowDown); - } + walk_to(&mut app, &items, first); key(&mut app, KeyCode::Enter); - assert_eq!(page(&app), Page::Run, "a service is set up, not started"); + // The built-in day is the built-in line's timetable, so the route step is skipped + // and the vehicle comes next. + assert_eq!(page(&app), Page::Loco); + assert!( + app.world().resource::().line_ref.is_none(), + "on the line the plan belongs to" + ); + key(&mut app, KeyCode::Enter); + assert_eq!(page(&app), Page::Setup, "a service is set up, not started"); assert_eq!( *app.world().resource::>().get(), GameState::Menu @@ -3289,16 +3708,18 @@ mod tests { WeatherChoice::Fixed(Preset::Cloudy) )); - // Esc goes back to the run list, which opens at the top again … + // Esc walks the flow back, one step at a time, to the run list — which opens at + // the top again … + key(&mut app, KeyCode::Escape); + assert_eq!(page(&app), Page::Loco); key(&mut app, KeyCode::Escape); - assert_eq!(page(&app), Page::Scenario); + assert_eq!(page(&app), Page::Run); // … so the service has to be walked to a second time. Enter on the setup page // starts the run — that is what the button in the pane says as well. - for _ in 0..first { - key(&mut app, KeyCode::ArrowDown); - } + walk_to(&mut app, &items, first); key(&mut app, KeyCode::Enter); - assert_eq!(page(&app), Page::Run); + key(&mut app, KeyCode::Enter); + assert_eq!(page(&app), Page::Setup); key(&mut app, KeyCode::Enter); assert_eq!( *app.world().resource::>().get(), @@ -3313,7 +3734,7 @@ mod tests { /// The rows the run picker's setup page is showing right now. fn rows(app: &App) -> Vec { entries( - Page::Run, + Page::Setup, false, &app.world().resource::().0, app.world().resource::(), diff --git a/crates/app/src/world.rs b/crates/app/src/world.rs index 2f80d5e..ee5faa3 100644 --- a/crates/app/src/world.rs +++ b/crates/app/src/world.rs @@ -128,15 +128,19 @@ pub fn build(mods: &mut ModRuntime, selection: &crate::menu::Selection) -> World has }) }); + // The route the run itself names comes before the one the menu holds: a scenario's + // stops, origins and event triggers are indices into one line's track graph, and put + // on another they address whatever happens to lie at that number. Only `--line` on + // the command line still overrules it, so the documented CLI invocations stand. let line_ref = arg("--line") - .or_else(|| selection.line_ref.clone()) .or_else(|| { scenario_id .as_ref() .and_then(|id| mods.mods.scenarios.get(id)) .and_then(|s| s.line.clone()) }) - .or_else(|| plan.as_ref().and_then(|plan| plan.line.clone())); + .or_else(|| plan.as_ref().and_then(|plan| plan.line.clone())) + .or_else(|| selection.line_ref.clone()); let resolved = line_ref.and_then(|id| match mods.mods.resolve_line(&id) { Ok(composed) => { for note in &composed.notes { diff --git a/crates/i18n/locales/de/main.ftl b/crates/i18n/locales/de/main.ftl index 1ec1544..3182ad7 100644 --- a/crates/i18n/locales/de/main.ftl +++ b/crates/i18n/locales/de/main.ftl @@ -1369,7 +1369,7 @@ hud-network-connecting = verbinde … menu-tagline = Deutsche Eisenbahnsimulation menu-drive = Fahren -menu-drive-hint = Modul, Fahrzeug und Fahrt +menu-drive-hint = Fahrt, Strecke und Fahrzeug menu-mods = Mods menu-mods-hint = Inhalte ein- und ausschalten menu-settings = Einstellungen @@ -1383,12 +1383,17 @@ menu-resume-hint = Weiter, wo der Zug steht menu-title = Zurück zum Hauptmenü menu-title-hint = Beendet die Fahrt und baut die Welt ab — nicht gewerteter Fortschritt im Szenario geht verloren. menu-step = Schritt { $step } von { $total } -menu-select-line = Modul auswählen -menu-select-line-hint = Wo die Fahrt stattfindet. +menu-select-line = Strecke auswählen +menu-select-line-hint = Die Fahrt nennt keine Strecke — hier wird sie gewählt. menu-select-loco = Fahrzeug auswählen menu-select-loco-hint = Was an der Spitze des Zuges läuft. menu-select-run = Fahrt auswählen -menu-select-run-hint = Ein Szenario, eine Leistung aus dem Fahrplan des Tages — oder freie Hand. +menu-select-run-hint = Ein Szenario, eine Leistung aus dem Fahrplan des Tages — oder freie Hand. Die Strecke ergibt sich daraus. +# Die Überschriften, unter denen die Fahrten stehen: jede nennt die Strecke, auf der sie +# liegt — wer darunter eine Fahrt wählt, wählt damit auch die Strecke. +menu-scenario-heading = Szenarien · { $route } +# Worunter eine Fahrt steht, die keine Strecke nennt — dann wird der Spieler gefragt. +menu-route-open = Beliebige Strecke # Die eingebauten Inhalte, auf die der Simulator zurückfällt, wenn nichts gewählt wird. # Das Fähnchen an der Zeile sagt das, der Name selbst muss es nicht mehr. menu-chip-builtin = Integriert @@ -1396,7 +1401,7 @@ menu-chip-composition = Komposition menu-line-builtin = Beispielmodul menu-loco-builtin = BR 101 menu-scenario-none = Kein Szenario — freie Fahrt -menu-free-run = Kein Fahrplan und keine Wertung: das Modul, das Fahrzeug, und wohin Sie damit fahren. +menu-free-run = Kein Fahrplan und keine Wertung: die Strecke, das Fahrzeug, und wohin Sie damit fahren. # Die Tastenhinweise in der Fußleiste: ein Fähnchen je Taste. menu-hint-select = auswählen menu-hint-confirm = bestätigen @@ -1421,7 +1426,7 @@ menu-fact-drive = Antrieb menu-fact-brake = Bremse menu-fact-start = Beginn menu-fact-timetable = Fahrplan -menu-fact-line = Modul +menu-fact-line = Strecke menu-fact-events = Ereignisse menu-fact-km = { $value } km menu-fact-m = { $value } m @@ -1437,8 +1442,8 @@ menu-fact-kmh = { $value } km/h # derselben Strecke — deshalb werden Datum und Wetter hier eingestellt, auf dem # Schritt zwischen Auswahl und Start. -# Die Überschrift über den Leistungen eines Betriebstags in der Fahrtauswahl. -menu-day-heading = Fahrplan · { $name } +# Die Überschrift über den Leistungen eines Betriebstags, unter seiner Strecke. +menu-day-heading = Fahrplan { $name } · { $route } # Eine Leistung darin: wo sie beginnt und wo sie endet. menu-service = { $from } – { $to } menu-run-setup = Fahrt einrichten diff --git a/crates/i18n/locales/en/main.ftl b/crates/i18n/locales/en/main.ftl index 4a01e65..3982c3e 100644 --- a/crates/i18n/locales/en/main.ftl +++ b/crates/i18n/locales/en/main.ftl @@ -1368,7 +1368,7 @@ hud-network-connecting = connecting … menu-tagline = German railway simulation menu-drive = Drive -menu-drive-hint = Module, vehicle and run +menu-drive-hint = Run, route and vehicle menu-mods = Mods menu-mods-hint = Switch installed content on and off menu-settings = Settings @@ -1382,12 +1382,17 @@ menu-resume-hint = Carry on where the train stands menu-title = Back to the main menu menu-title-hint = Ends the run and takes down the world — unsaved progress in the scenario is lost. menu-step = Step { $step } of { $total } -menu-select-line = Select module -menu-select-line-hint = Where the run takes place. +menu-select-line = Select route +menu-select-line-hint = The run names no route, so this is where it takes place. menu-select-loco = Select vehicle menu-select-loco-hint = What is at the head of the train. menu-select-run = Select run -menu-select-run-hint = A scenario, a service out of the day's timetable, or free rein. +menu-select-run-hint = A scenario, a service out of the day's timetable, or free rein — the route follows from it. +# The headings the runs stand under: every one of them names the route it is on, and +# picking a run under it is picking that route as well. +menu-scenario-heading = Scenarios · { $route } +# What a run that names no route stands under — the player is asked for one instead. +menu-route-open = Any route # The built-in content the simulator falls back on when nothing is picked. The chip on # the row says so, so the name itself no longer has to. menu-chip-builtin = Built in @@ -1395,7 +1400,7 @@ menu-chip-composition = Composition menu-line-builtin = Example module menu-loco-builtin = BR 101 menu-scenario-none = No scenario — free run -menu-free-run = No timetable and no scoring: the module, the vehicle, and wherever you take it. +menu-free-run = No timetable and no scoring: the route, the vehicle, and wherever you take it. # The key hints in the footer bar: one chip per key. menu-hint-select = select menu-hint-confirm = confirm @@ -1420,7 +1425,7 @@ menu-fact-drive = Drive menu-fact-brake = Brake menu-fact-start = Start menu-fact-timetable = Timetable -menu-fact-line = Module +menu-fact-line = Route menu-fact-events = Events menu-fact-km = { $value } km menu-fact-m = { $value } m @@ -1435,8 +1440,8 @@ menu-fact-kmh = { $value } km/h # same hour of the same line every time — so the date and the weather are set # here, on the step between picking the run and starting it. -# The heading over an operating day's services in the run list. -menu-day-heading = Timetable · { $name } +# The heading over an operating day's services in the run list, under its route. +menu-day-heading = Timetable { $name } · { $route } # One service in that list: where it starts and where it ends. menu-service = { $from } – { $to } menu-run-setup = Set up the run