fix(simulation): a river course was one D8 hop, not a river (T-1237)
D-261 asked for contiguous river strokes, and the client got them by chaining hops back together after the fact. That could not work: each hop was warped independently, so a shared confluence point arrived as two points that no longer coincided -- 375 hops rejoined into 260 pieces, and Ferrath's Global map showed scratches rather than watercourses. The join belongs before invention, so it now happens on the server. river_course::build_paths walks the D8 cell graph into whole rivers from headwater to mouth, edge-drain, or junction with an already-walked river (including the joint cell, so a tributary visibly meets its trunk). step_canvas emits one course per river instead of one per cell, which also drops the per-hop warp and resampling -- a path's shape is the terrain's, so there is nothing left to invent. It is cheaper too: one point per river cell rather than three. The client's _chain_runs() and its endpoint index are deleted. Runs survive only for the reason D-261 gives them -- water splits a course, and a river crossing a lake is genuinely two strokes that must not be rejoined. Pinned by a real-terrain test on GJ380c rather than a synthetic graph, because the bug was caught by eye on real terrain: a lake must have an outflow that runs to sea level, and no such line existed. It asserts the property the eye was checking -- rivers are long, at least one reaches the sea, and every path is a contiguous walk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -289,8 +289,6 @@ func _prepare_courses() -> void:
|
||||
# cut short by water and have no mouth of their own.
|
||||
_flush_run(run, run_is_first, first_world_m, terminus)
|
||||
|
||||
# Chain the runs into rivers BEFORE culling — see _chain_runs().
|
||||
_prepared_courses = _chain_runs(_prepared_courses)
|
||||
_cull_short(_prepared_courses)
|
||||
|
||||
|
||||
@@ -314,89 +312,26 @@ func _flush_run(
|
||||
|
||||
|
||||
|
||||
## Join runs that meet end-to-end into whole rivers, then cull.
|
||||
## Drop runs too short to read as a line (D-261).
|
||||
##
|
||||
## THE UNIT PROBLEM. A server "course" is an EDGE of the river network — the
|
||||
## stretch between two confluences — not a river. Culling per course therefore
|
||||
## culls per segment, and a 1,000 km river assembled from fifty 20 km edges
|
||||
## vanishes entirely because no single edge clears the threshold. Measured on
|
||||
## Ferrath's Global canvas: 375 courses, 180 surviving the water clip, and
|
||||
## ZERO surviving a per-course length cull.
|
||||
## THE UNIT PROBLEM, and why there is no chaining step here any more. This layer
|
||||
## briefly joined runs end-to-end, because a server "course" used to be a single
|
||||
## D8 hop and culling per course culled per hop — a 1,000 km river assembled from
|
||||
## fifty 20 km fragments vanished entirely, since no fragment cleared the
|
||||
## threshold. Measured on Ferrath Global: 375 courses, 180 surviving the water
|
||||
## clip, ZERO surviving the length cull.
|
||||
##
|
||||
## Chaining also delivers the other half of D-261's "contiguous" requirement.
|
||||
## Per-course contiguity only makes each edge unbroken; it is the joining that
|
||||
## makes a river read as one line rather than a row of dashes.
|
||||
## Chaining was the wrong fix and only half-worked (375 hops rejoined into 260
|
||||
## pieces) because the server warped each hop independently, so a shared
|
||||
## confluence point arrived as two points that no longer coincided. The join
|
||||
## belongs before invention, and now happens there: the server emits one course
|
||||
## per RIVER (river_course::build_paths). A course is therefore already a whole
|
||||
## watercourse when it gets here, and this cull measures a river — which is what
|
||||
## D-261 says it measures.
|
||||
##
|
||||
## Greedy: take any unused run, extend it downstream while some unused run
|
||||
## starts where it ends, then upstream likewise. O(n) lookups via an endpoint
|
||||
## index rather than an O(n^2) scan.
|
||||
func _chain_runs(runs: Array) -> Array:
|
||||
if runs.size() < 2:
|
||||
return runs
|
||||
var starts: Dictionary = {} # quantised start point -> [run indices]
|
||||
for i in range(runs.size()):
|
||||
var key: String = _point_key((runs[i]["points"] as PackedVector2Array)[0])
|
||||
if not starts.has(key):
|
||||
starts[key] = []
|
||||
(starts[key] as Array).append(i)
|
||||
|
||||
var used: Dictionary = {}
|
||||
var chained: Array = []
|
||||
for i in range(runs.size()):
|
||||
if used.has(i):
|
||||
continue
|
||||
used[i] = true
|
||||
var entry: Dictionary = runs[i]
|
||||
var pts := PackedVector2Array(entry["points"])
|
||||
# Extend downstream: repeatedly find an unused run beginning where this
|
||||
# one ends. The terminus travels with the LAST link, since that is the
|
||||
# end that actually reaches the sea.
|
||||
var terminus: String = str(entry["terminus"])
|
||||
while true:
|
||||
var next_i: int = _take_run_starting_at(starts, used, pts[pts.size() - 1])
|
||||
if next_i < 0:
|
||||
break
|
||||
var nxt: Dictionary = runs[next_i]
|
||||
var npts: PackedVector2Array = nxt["points"]
|
||||
for j in range(1, npts.size()): # skip the shared joint
|
||||
pts.append(npts[j])
|
||||
terminus = str(nxt["terminus"])
|
||||
chained.append(
|
||||
{
|
||||
"points": pts,
|
||||
"terminus": terminus,
|
||||
# Taper only if this chain BEGINS at a true source. Extending
|
||||
# downstream never changes where the chain starts, so the head
|
||||
# run's own judgement still holds.
|
||||
"taper": bool(entry["taper"]),
|
||||
}
|
||||
)
|
||||
return chained
|
||||
|
||||
|
||||
## First unused run whose start coincides with `at`, or -1.
|
||||
static func _take_run_starting_at(starts: Dictionary, used: Dictionary, at: Vector2) -> int:
|
||||
var key: String = _point_key(at)
|
||||
if not starts.has(key):
|
||||
return -1
|
||||
for idx_raw: Variant in (starts[key] as Array):
|
||||
var idx: int = idx_raw
|
||||
if not used.has(idx):
|
||||
used[idx] = true
|
||||
return idx
|
||||
return -1
|
||||
|
||||
|
||||
## Quantise a screen point to a joinable key. Confluence endpoints come from
|
||||
## the same server coordinate and survive an identical linear projection, so
|
||||
## they agree closely; rounding to a tenth of a pixel absorbs float drift
|
||||
## without gluing genuinely separate rivers together.
|
||||
static func _point_key(p: Vector2) -> String:
|
||||
return "%d:%d" % [roundi(p.x * 10.0), roundi(p.y * 10.0)]
|
||||
|
||||
|
||||
## Drop chains too short to read as a line (D-261). Done AFTER chaining, so the
|
||||
## measurement is of a river rather than of one of its segments.
|
||||
## Runs still exist, but only for the reason D-261 gives them: water splits a
|
||||
## course. A river crossing a lake is genuinely two visible strokes, and those
|
||||
## must NOT be rejoined.
|
||||
func _cull_short(chains: Array) -> void:
|
||||
var keep: Array = []
|
||||
for entry_raw: Variant in chains:
|
||||
|
||||
Reference in New Issue
Block a user