feat(mapeditor): Route roads by slope, carving walkable switchbacks into steep terrain #267

Merged
panda merged 6 commits from feat/road-router into main 2026-08-03 21:04:26 +02:00
Collaborator

Summary

Roads drawn in the editor were impassable on real elevation. The first attempt graded a straight Z-ramp between the two ends; on a slope steeper than length × MaxWalkStepZ no such ramp stays walkable AND touches the ground at both ends, so an end floated and the road was unreachable. Following the ground exactly (no Z change) fixed floating but left steep mountains impassable — there's no walkable way straight up a cliff.

Taking inspiration from reality (a real mountain road is cut into the hillside with switchbacks), Route now carves. You lay a road two ways:

  • By hand — the Single brush, freeform (paints road terrain on the existing ground).
  • Route (A→B) — a brush mode offered only for a road terrain: click a start, click an end, and the editor builds a walkable road with an A* over the state (cell, roadZ). From a cell at road height z it may move to a buildable neighbour at any road height within ±MaxWalkStepZ of z (the server's CanStep grade) AND within ±Carve of that neighbour's existing ground (the earthwork budget). Cost = horizontal distance + a penalty per Z of cut/fill, so the road hugs the ground where it can and only carves to hold the grade — long, terrain-hugging switchbacks emerge on steep faces. Both ends sit on the existing ground; the carved Z is stamped along the path. A Carve slider (default 30) sets how deep it may cut/fill — raise it to climb steeper faces. If no walkable road fits even so (e.g. a one-cell vertical cliff, where cut + fill still leaves a step) the map is left untouched and the readout says so.

This re-introduces controlled Z-writing for roads (bounded by Carve), superseding the earlier "roads never touch Z" step — a deliberate reversal after testing showed dramatic mountains need earthworks, exactly like real roads. A gentle slope carves nothing, so the carving router subsumes a plain terrain-following road.

Follow-up (not in this PR): tunnels / mines need a multi-level world model (more than one walkable Z per cell) — the current map is a single-Z heightfield. That's a foundational epic, tracked separately; this PR stays surface-only.

Screenshots / recording

The visible surface is the map editor (a MonoGame desktop app) and Route is a two-click GUI interaction on a slope — the debug harness drives the game client over TCP, not the editor's mouse/ImGui, so it can't reach this state headlessly (noted, not faked). Behaviour is pinned by unit tests (below). I can produce an in-game walk-through if a routed switchback is painted and the world Saved — then I drive the client up it from a fresh DB.

How it was tested

  • RoadRouterTests (10 cases): start==end → a single point on the ground; flat → shortest diagonal, zero carve; a walkable slope followed with zero carve; both ends sit on the existing ground; a steep face (8/cell) climbed by a longer, carved switchback (path length > straight, every step within grade, every cut ≤ Carve, reaches the top); a one-cell vertical cliff can't be bridged within the budget → null; unbuildable endpoint → null; exhausted search budget → null; deterministic.
  • dotnet test tests/IsoMmo.MapEditor.Core.Tests → 70/70 green, 0 warnings (warnings-as-errors).
  • dotnet build tools/IsoMmo.MapEditor → 0 warnings / 0 errors.
  • dotnet csharpier check . → clean (515 files, full output). python scripts/check-doc-refs.py → resolves.

Design review — persisted Invariants Check

Editor-tool change; most gameplay invariants N/A (each falsifiable).

  • Scope ✓ the carve-switchback road tool the owner approved; tunnels/mines deliberately deferred to a design issue. · Server-authoritative N/A (offline authoring) — the carved Z is authored content; runtime walkability stays the server's CanStep, which the router uses as its edge gate, so a routed road can't be one the server rejects. · GM auth / Identity / Protocol / String catalog N/A. · Single-threaded sim / World.cs gate N/A (tool, no World). · Screen gate ✓ logic in pure RoadRouter (Core); EditorGame coordinates clicks → router → stamp. · Client engine-independence ✓ RoadRouter pure in MapEditor.Core, unit-tested. · Act on the instance ✓ pure function; no side-collection (reads ZAt/TerrainIdAt, writes SetTerrain/SetZ). · Extend by type ✓ one algorithm, no switch. · Persistence (GameServer) ✓ carved Z persists via the existing chunked format (like any Raise/Set Z); no format change. · Persistence (Auth) / Process separation N/A. · Typed options ✓ CarvePenalty/budget are named consts; Carve is a bounded editor field; reuses WorldRules.MaxWalkStepZ. · Broadcasts/AoI / Assets / Asset naming N/A. · ModernUO standard grid A* over the existing UO-style step-height gate; the multi-level model tunnels would need is called out as the separate epic. · Docs & DoD same change ✓ docs/map-editor.md road section rewritten here.

No ✗, no HARD GATE tripped.

Definition of Done

  • A road terrain shows a Route (A→B) mode with a Carve slider; two clicks build a road whose every consecutive cell is within MaxWalkStepZ and every cut/fill within Carve, both ends on the existing ground — walkable in-game end-to-end, including a switchback on a face too steep to climb head-on.
  • Where the terrain is already a walkable slope, Route carves nothing (road Z == ground Z).
  • No walkable road within the carve/search budget → map unchanged + readout message (never a floating/dead-end road).
  • RoadRouterTests assert the grade+carve invariants on every returned step, the switchback lengthening, the vertical-cliff null, and determinism.

Checklist

  • just lint passes (CSharpier clean + affected projects build, zero warnings)
  • just test green for the affected project (70/70)
  • Whole solution builds — only local copy-locks from the running dev stack; the editor tool and Core build clean here, CI builds the whole solution on a fresh machine
  • Multi-platform preserved (pure .NET, no OS-specific dependency)
  • Tests added/updated (RoadRouterTests, 10 cases)
  • Linked the related issue (#246); its DoD (above) is met

Supersedes #266 (the Z-ramp grader), which is closed.

## Summary Roads drawn in the editor were impassable on real elevation. The first attempt graded a straight Z-ramp between the two ends; on a slope steeper than `length × MaxWalkStepZ` no such ramp stays walkable AND touches the ground at both ends, so an end floated and the road was unreachable. Following the ground exactly (no Z change) fixed floating but left steep mountains impassable — there's no walkable way straight up a cliff. Taking inspiration from reality (a real mountain road is **cut into the hillside with switchbacks**), **Route now carves**. You lay a road two ways: - **By hand** — the Single brush, freeform (paints road terrain on the existing ground). - **Route (A→B)** — a brush mode offered only for a road terrain: click a start, click an end, and the editor builds a walkable road with an A\* over the state `(cell, roadZ)`. From a cell at road height `z` it may move to a buildable neighbour at any road height within `±MaxWalkStepZ` of `z` (the server's `CanStep` grade) AND within `±Carve` of that neighbour's existing ground (the earthwork budget). Cost = horizontal distance + a penalty per Z of cut/fill, so the road **hugs the ground where it can and only carves to hold the grade** — long, terrain-hugging **switchbacks** emerge on steep faces. Both ends sit on the existing ground; the carved Z is stamped along the path. A **Carve** slider (default 30) sets how deep it may cut/fill — raise it to climb steeper faces. If no walkable road fits even so (e.g. a one-cell vertical cliff, where cut + fill still leaves a step) the map is left untouched and the readout says so. This re-introduces **controlled** Z-writing for roads (bounded by Carve), superseding the earlier "roads never touch Z" step — a deliberate reversal after testing showed dramatic mountains need earthworks, exactly like real roads. A gentle slope carves nothing, so the carving router subsumes a plain terrain-following road. **Follow-up (not in this PR):** tunnels / mines need a **multi-level world model** (more than one walkable Z per cell) — the current map is a single-Z heightfield. That's a foundational epic, tracked separately; this PR stays surface-only. ## Screenshots / recording The visible surface is the map editor (a MonoGame desktop app) and Route is a two-click GUI interaction on a slope — the debug harness drives the *game* client over TCP, not the editor's mouse/ImGui, so it can't reach this state headlessly (noted, not faked). Behaviour is pinned by unit tests (below). I can produce an **in-game** walk-through if a routed switchback is painted and the world Saved — then I drive the client up it from a fresh DB. ## How it was tested - `RoadRouterTests` (10 cases): start==end → a single point on the ground; flat → shortest diagonal, zero carve; a walkable slope followed with zero carve; **both ends sit on the existing ground**; **a steep face (8/cell) climbed by a longer, carved switchback** (path length > straight, every step within grade, every cut ≤ Carve, reaches the top); **a one-cell vertical cliff can't be bridged within the budget → null**; unbuildable endpoint → null; exhausted search budget → null; deterministic. - `dotnet test tests/IsoMmo.MapEditor.Core.Tests` → **70/70 green**, 0 warnings (warnings-as-errors). - `dotnet build tools/IsoMmo.MapEditor` → 0 warnings / 0 errors. - `dotnet csharpier check .` → clean (515 files, full output). `python scripts/check-doc-refs.py` → resolves. ## Design review — persisted Invariants Check Editor-tool change; most gameplay invariants N/A (each falsifiable). - **Scope** ✓ the carve-switchback road tool the owner approved; tunnels/mines deliberately deferred to a design issue. · **Server-authoritative** N/A (offline authoring) — the carved Z is authored content; runtime walkability stays the server's `CanStep`, which the router uses as its edge gate, so a routed road can't be one the server rejects. · **GM auth / Identity / Protocol / String catalog** N/A. · **Single-threaded sim / `World.cs` gate** N/A (tool, no `World`). · **`Screen` gate** ✓ logic in pure `RoadRouter` (Core); `EditorGame` coordinates clicks → router → stamp. · **Client engine-independence** ✓ `RoadRouter` pure in `MapEditor.Core`, unit-tested. · **Act on the instance** ✓ pure function; no side-collection (reads `ZAt`/`TerrainIdAt`, writes `SetTerrain`/`SetZ`). · **Extend by type** ✓ one algorithm, no switch. · **Persistence (GameServer)** ✓ carved Z persists via the existing chunked format (like any Raise/Set Z); no format change. · **Persistence (Auth) / Process separation** N/A. · **Typed options** ✓ `CarvePenalty`/budget are named consts; Carve is a bounded editor field; reuses `WorldRules.MaxWalkStepZ`. · **Broadcasts/AoI / Assets / Asset naming** N/A. · **ModernUO** standard grid A\* over the existing UO-style step-height gate; the multi-level model tunnels would need is called out as the separate epic. · **Docs & DoD same change** ✓ `docs/map-editor.md` road section rewritten here. No `✗`, no HARD GATE tripped. ## Definition of Done - A road terrain shows a **Route (A→B)** mode with a **Carve** slider; two clicks build a road whose every consecutive cell is within `MaxWalkStepZ` and every cut/fill within Carve, both ends on the existing ground — walkable in-game end-to-end, including a switchback on a face too steep to climb head-on. - Where the terrain is already a walkable slope, Route carves nothing (road Z == ground Z). - No walkable road within the carve/search budget → map unchanged + readout message (never a floating/dead-end road). - `RoadRouterTests` assert the grade+carve invariants on every returned step, the switchback lengthening, the vertical-cliff null, and determinism. ## Checklist - [x] `just lint` passes (CSharpier clean + affected projects build, zero warnings) - [x] `just test` green for the affected project (70/70) - [ ] Whole solution builds — only local copy-locks from the running dev stack; the editor tool and Core build clean here, CI builds the whole solution on a fresh machine - [x] Multi-platform preserved (pure .NET, no OS-specific dependency) - [x] Tests added/updated (`RoadRouterTests`, 10 cases) - [x] Linked the related issue (#246); its DoD (above) is met Supersedes #266 (the Z-ramp grader), which is closed.
feat(mapeditor): route roads by slope (walkable A->B pathing); roads no longer terraform Z
All checks were successful
ci / Lua content lint (pull_request) Successful in 10s
ci / Lint & Test (pull_request) Successful in 5m8s
73d56f19aa
feat(mapeditor): Route carves walkable switchbacks into steep terrain (cut/fill up to Carve depth)
All checks were successful
ci / Lua content lint (pull_request) Successful in 10s
ci / Lint & Test (pull_request) Successful in 5m18s
eefa84203f
panda changed title from feat(mapeditor): route roads by slope (walkable A→B pathing); roads no longer terraform Z to feat(mapeditor): Route roads by slope, carving walkable switchbacks into steep terrain 2026-08-03 13:24:33 +02:00
fix(mapeditor): road router scales to map-distance climbs (vertical-climb heuristic) + reports budget-vs-no-route
All checks were successful
ci / Lua content lint (pull_request) Successful in 10s
ci / Lint & Test (pull_request) Successful in 4m53s
d00894bf5e
perf(mapeditor): bound road search to a climb-sized corridor + 3-candidate heights; fast, correct no-route on impossible terrain
All checks were successful
ci / Lua content lint (pull_request) Successful in 11s
ci / Lint & Test (pull_request) Successful in 5m22s
dc5179df7c
feat(mapeditor): freehand road draws + grades + carves the stroke (draw switchbacks up a vertical face the auto-router can't climb)
All checks were successful
ci / Lua content lint (pull_request) Successful in 17s
ci / Lint & Test (pull_request) Successful in 5m44s
bf0d13903a
fix(mapeditor): freehand road follows the ground and cuts the steep parts (no floating embankment)
All checks were successful
ci / Lua content lint (pull_request) Successful in 10s
ci / Lint & Test (pull_request) Successful in 4m42s
fc2597f67d
panda merged commit 447319789d into main 2026-08-03 21:04:26 +02:00
panda deleted branch feat/road-router 2026-08-03 21:04:26 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo!267
No description provided.