fix(mapeditor): road grading follows the terrain profile (no floating/dead-ending roads) #266

Closed
panda wants to merge 1 commit from fix/road-grading into main
Collaborator

Summary

Painting a road across hilly terrain in the map editor was producing broken roads: with high Z the road floated over the ground, and on a steep run it would dead-end at a Z the destination terrain never reaches (the grader "cannò il calcolo del dislivello"). Root cause: RoadGrader drew a single straight ramp between the two end anchors and ignored the terrain profile in between, so anywhere the ground dipped or the two ends were far apart in Z, the road left the surface.

This rewrites RoadGrader.Grade to follow the terrain walkably instead of bridging it. It reads the terrain Z under every stroke cell and runs two slope-limited passes over that profile: a forward pass from the start and a backward pass anchored at the end, each clamping every consecutive step to ±WorldRules.MaxWalkStepZ. The result hugs the relief — it descends into a walkable dip and climbs back out instead of floating over it — while both ends still sit exactly on the ground and no step ever exceeds the walk cap. Where the terrain is genuinely too steep to climb walkably, it cuts a minimal grade (a cutting) rather than dead-ending; the designer makes the road longer / switchbacks for a big climb.

The editor call-site (EditorGame.GradeRoad) now feeds the per-cell terrain Z (_model.ZAt) into the grader instead of just the two endpoints.

Behaviour is pure geometry over the stroke's terrain profile — unit-tested, no engine/rendering involved.

Screenshots / recording

N/A — the visible surface is the map editor (not the game client), and the effect is authored road elevation. The debug-harness drives the game client, not the editor GUI, and reproducing this needs a human painting a road stroke across steep terrain. The behaviour is fully pinned by unit tests over the terrain profile (RoadGraderTests); the design doc (docs/map-editor.md) describes the new grading.

How it was tested

  • Rewrote RoadGraderTests (7 cases): flat terrain → flat road; a walkable slope followed exactly; a walkable dip followed down-and-back-up (the anti-float case, [10,5,10]); both ends sit on the terrain; an unwalkable spike ([0,100,0]) cut to a walkable grade with both ends grounded; every consecutive step within the cap for an arbitrary jagged profile; single-cell = the terrain and zero-cells = empty.
  • dotnet test tests/IsoMmo.MapEditor.Core.Tests → 68/68 green (0 warnings, warnings-as-errors).
  • dotnet csharpier check . → clean (515 files, full output — no "Was not formatted").
  • python scripts/check-doc-refs.py → all references resolve.
  • Whole-solution build is only blocked locally by the running dev stack locking *.dll (Auth/Client/GameServer processes) — pure copy locks (MSB3021/3027), not compile errors; CI builds clean on a fresh machine.

Invariants Check

  • Scope — ✓ fixes the existing road-grading behaviour the owner reported; no new system.
  • Server-authoritative — N/A — this is offline map-authoring (editor tool), no player intent; the graded Z is authored content, still validated by the runtime's own walkability at play time.
  • GM authorization — N/A — no admin action / JWT surface.
  • Identity model — N/A — no accounts/characters.
  • Protocol versioned — N/A — no wire shape / protocol enum / DTO touched (RoadGrader is int[]→int[] over local Z).
  • String catalog — N/A — no user-facing runtime text; only a code comment + doc prose.
  • Single-threaded sim — N/A — editor tool, not the World sim.
  • World.cs HARD GATE — N/A — World untouched.
  • Screen HARD GATE — N/A — editor is not a client Screen; the grading logic lives in RoadGrader (Core), the call-site in EditorGame only reads _model.ZAt and delegates.
  • Client engine-independence — ✓ the algorithm is pure RoadGrader in IsoMmo.MapEditor.Core (no MonoGame), unit-tested; EditorGame holds only the glue.
  • Gameplay/Networking separation — N/A — neither touched.
  • Act on the instance — ✓ no side-collection; the grader is a pure function, the editor reads Z from the model instance (_model.ZAt).
  • Extend by type, not switch — N/A — no new variant/family; a single algorithm replacement, no switch introduced.
  • Typed content-def fields — N/A — no content-def field.
  • Server-paced actions — N/A — no timed/channeled action.
  • Persistence (GameServer) — N/A — no save format change; graded Z persists through the existing chunked map format unchanged.
  • Persistence (Auth) — N/A — no EF/schema change.
  • Process separation — N/A — Auth/GameServer untouched.
  • Typed options — ✓ reuses WorldRules.MaxWalkStepZ (the existing walk-step constant), no new ad-hoc tunable, no IConfiguration read.
  • Broadcasts / AoI — N/A — no broadcast/observable event.
  • Multi-platform — ✓ pure .NET in Core + editor tool; no OS-specific dependency.
  • Assets required — N/A — no art/asset.
  • Asset naming — N/A — no new asset id.
  • ModernUO as reference — N/A — trivial geometry, no architectural call.
  • Docs & DoD in same change — ✓ docs/map-editor.md road-grading paragraph updated in this change to describe the terrain-following behaviour.

Checklist

  • just lint passes (CSharpier clean + affected project builds with zero warnings)
  • just test green for the affected project (68/68)
  • The whole solution builds (client and tools included) — only local copy-locks from the running dev stack; CI builds clean
  • Multi-platform preserved (pure .NET, no OS-specific dependency)
  • Tests added/updated for this change (RoadGraderTests rewritten, 7 cases)
  • Linked the related issue (#246) and its DoD is met
## Summary Painting a road across hilly terrain in the map editor was producing broken roads: with high Z the road **floated** over the ground, and on a steep run it would **dead-end** at a Z the destination terrain never reaches (the grader "cannò il calcolo del dislivello"). Root cause: `RoadGrader` drew a single straight ramp between the two end anchors and ignored the terrain profile in between, so anywhere the ground dipped or the two ends were far apart in Z, the road left the surface. This rewrites `RoadGrader.Grade` to **follow the terrain walkably** instead of bridging it. It reads the terrain Z under every stroke cell and runs two slope-limited passes over that profile: a forward pass from the start and a backward pass anchored at the end, each clamping every consecutive step to `±WorldRules.MaxWalkStepZ`. The result hugs the relief — it descends into a walkable dip and climbs back out instead of floating over it — while both ends still sit exactly on the ground and no step ever exceeds the walk cap. Where the terrain is genuinely too steep to climb walkably, it cuts a minimal grade (a cutting) rather than dead-ending; the designer makes the road longer / switchbacks for a big climb. The editor call-site (`EditorGame.GradeRoad`) now feeds the per-cell terrain Z (`_model.ZAt`) into the grader instead of just the two endpoints. Behaviour is pure geometry over the stroke's terrain profile — unit-tested, no engine/rendering involved. ## Screenshots / recording N/A — the visible surface is the **map editor** (not the game client), and the effect is authored road elevation. The debug-harness drives the *game* client, not the editor GUI, and reproducing this needs a human painting a road stroke across steep terrain. The behaviour is fully pinned by unit tests over the terrain profile (`RoadGraderTests`); the design doc (`docs/map-editor.md`) describes the new grading. ## How it was tested - Rewrote `RoadGraderTests` (7 cases): flat terrain → flat road; a walkable slope followed exactly; a walkable **dip** followed down-and-back-up (the anti-float case, `[10,5,10]`); both ends sit on the terrain; an unwalkable spike (`[0,100,0]`) cut to a walkable grade with both ends grounded; every consecutive step within the cap for an arbitrary jagged profile; single-cell = the terrain and zero-cells = empty. - `dotnet test tests/IsoMmo.MapEditor.Core.Tests` → **68/68 green** (0 warnings, warnings-as-errors). - `dotnet csharpier check .` → clean (515 files, full output — no "Was not formatted"). - `python scripts/check-doc-refs.py` → all references resolve. - Whole-solution build is only blocked locally by the running dev stack locking `*.dll` (Auth/Client/GameServer processes) — pure copy locks (MSB3021/3027), not compile errors; CI builds clean on a fresh machine. ## Invariants Check - **Scope** — ✓ fixes the existing road-grading behaviour the owner reported; no new system. - **Server-authoritative** — N/A — this is offline map-authoring (editor tool), no player intent; the graded Z is authored content, still validated by the runtime's own walkability at play time. - **GM authorization** — N/A — no admin action / JWT surface. - **Identity model** — N/A — no accounts/characters. - **Protocol versioned** — N/A — no wire shape / protocol enum / DTO touched (`RoadGrader` is `int[]→int[]` over local Z). - **String catalog** — N/A — no user-facing runtime text; only a code comment + doc prose. - **Single-threaded sim** — N/A — editor tool, not the `World` sim. - **`World.cs` HARD GATE** — N/A — `World` untouched. - **`Screen` HARD GATE** — N/A — editor is not a client `Screen`; the grading logic lives in `RoadGrader` (Core), the call-site in `EditorGame` only reads `_model.ZAt` and delegates. - **Client engine-independence** — ✓ the algorithm is pure `RoadGrader` in `IsoMmo.MapEditor.Core` (no MonoGame), unit-tested; `EditorGame` holds only the glue. - **Gameplay/Networking separation** — N/A — neither touched. - **Act on the instance** — ✓ no side-collection; the grader is a pure function, the editor reads Z from the model instance (`_model.ZAt`). - **Extend by type, not switch** — N/A — no new variant/family; a single algorithm replacement, no switch introduced. - **Typed content-def fields** — N/A — no content-def field. - **Server-paced actions** — N/A — no timed/channeled action. - **Persistence (GameServer)** — N/A — no save format change; graded Z persists through the existing chunked map format unchanged. - **Persistence (Auth)** — N/A — no EF/schema change. - **Process separation** — N/A — Auth/GameServer untouched. - **Typed options** — ✓ reuses `WorldRules.MaxWalkStepZ` (the existing walk-step constant), no new ad-hoc tunable, no `IConfiguration` read. - **Broadcasts / AoI** — N/A — no broadcast/observable event. - **Multi-platform** — ✓ pure .NET in Core + editor tool; no OS-specific dependency. - **Assets required** — N/A — no art/asset. - **Asset naming** — N/A — no new asset id. - **ModernUO as reference** — N/A — trivial geometry, no architectural call. - **Docs & DoD in same change** — ✓ `docs/map-editor.md` road-grading paragraph updated in this change to describe the terrain-following behaviour. ## Checklist - [x] `just lint` passes (CSharpier clean + affected project builds with zero warnings) - [x] `just test` green for the affected project (68/68) - [ ] The whole solution builds (client and tools included) — only local copy-locks from the running dev stack; CI builds clean - [x] Multi-platform preserved (pure .NET, no OS-specific dependency) - [x] Tests added/updated for this change (`RoadGraderTests` rewritten, 7 cases) - [x] Linked the related issue (#246) and its DoD is met
fix(mapeditor): road grading follows the terrain profile (no floating/dead-ending roads)
All checks were successful
ci / Lua content lint (pull_request) Successful in 11s
ci / Lint & Test (pull_request) Successful in 5m7s
98d808e190
Author
Collaborator

Superseded by #267. That change takes a different (and correct) approach: roads no longer terraform Z at all — they follow the existing ground, and a new Route (A→B) mode routes a walkable path (A*, switchbacking on steep slopes) using the same CanStep gate the server enforces. The Z-ramp grader here couldn't stay walkable on a climb steeper than its length. Closing in favour of #267.

Superseded by #267. That change takes a different (and correct) approach: roads no longer terraform Z at all — they follow the existing ground, and a new **Route (A→B)** mode routes a walkable path (A*, switchbacking on steep slopes) using the same `CanStep` gate the server enforces. The Z-ramp grader here couldn't stay walkable on a climb steeper than its length. Closing in favour of #267.
panda closed this pull request 2026-08-03 01:10:39 +02:00
All checks were successful
ci / Lua content lint (pull_request) Successful in 11s
ci / Lint & Test (pull_request) Successful in 5m7s
Required
Details

Pull request closed

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!266
No description provided.