feat(ui): step-canvas map component — RTT terrain + stepped zoom (D-255, T-1182)

The two-layer client rebuild per D-255(a)(b)(e), replacing the
_canvas.scale continuous-zoom model with one viewer, one path, all six
rungs:

- step_canvas_protocol.gd: StepCanvasRequest/Response codec against
  the T-1181 wire contract — incl. the discovered png_bytes subtlety
  (rmp_serde without serde_bytes emits a msgpack int-array, not bin;
  decode repacks via PackedByteArray before load_png_from_buffer) and
  the extent-echo rule (read the server-clamped extent, never assume
  the requested one).
- step_canvas/ component: transport (six-rung ladder, cursor-anchored
  scroll steps, edge-scroll/WASD pan with re-request on edge crossing,
  hard reset-to-Global), RTT terrain layer (Image.set_pixel colorize
  per the c1 measured ruling, texture.update reuse on step-cross,
  NEAREST coarse / LINEAR fine per rung), unscaled screen-space
  annotation sibling (courses + settlement markers at literal px),
  in-memory LRU cache (Tier 1; T-1183 layers the disk tiers beneath),
  request lifecycle (pending retry, staleness gate, extent echo).
- Full _canvas.scale retirement in the same change: the zoom-scaled
  canvas model, the _zs compensation family, select_rung /
  MAX_COVERAGE_M / compute_tile_grid, the orbital-mosaic-vs-window
  two-path split, _view_zoom/_canonical_fit_zoom — 10 source files
  deleted; their 14 test suites deleted with them (T-1157 dead-goldens
  rule; replacement visual-capture coverage is re-scoped T-1157).
- Surviving surfaces kept per the ticket: atlas_window_cache.gd's LRU
  shape (the ticket's named file atlas_window_tile_set.gd was the
  retiring orchestrator; the real LRU shape lives in
  atlas_window_cache.gd — cited in step_canvas_cache.gd), overlay
  colors, legend/overlay-bar chrome, AtlasViewer descend geometry.

Determinism boundary per D-255(e): the client interpolates only within
the closed server-supplied input set. 7 new gdUnit suites (164 cases)
incl. a real extent-echo bug caught by its own test during
implementation. Full client suite green (exit 0) with the live-gated
suites running against a worktree server build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 08:53:48 +02:00
co-authored by Claude Fable 5
parent e0a3ab35cf
commit d28d24fd26
43 changed files with 2858 additions and 12241 deletions
+212
View File
@@ -0,0 +1,212 @@
## T-1182 tests: StepCanvasRequest/StepCanvasResponse wire codec
## (step_canvas_protocol.gd, delegated via protocol.gd) — the D-255(c)
## tagged-envelope carrier. Mirrors test_atlas_data_delivery.gd's own
## encode/decode round-trip + decode_inbound classification conventions.
class_name TestStepCanvasProtocol
extends GdUnitTestSuite
# =============================================================================
# Encode
# =============================================================================
func test_encode_step_canvas_request_carries_discriminator_and_fields() -> void:
var bytes := Protocol.encode_step_canvas_request(
"GJ380c", "District", Vector2i(10, 20), Vector2i(64, 64), 512
)
assert_int(bytes.size()).is_greater(0)
var decoded = Messagepack.decode(bytes)
assert_that(decoded.status == null).is_true()
var raw: Dictionary = decoded.value
assert_bool(raw.get("step_canvas")).is_true()
assert_str(raw.get("body_id")).is_equal("GJ380c")
assert_str(raw.get("rung")).is_equal("District")
assert_that(raw.get("center")).is_equal([10, 20])
assert_that(raw.get("extent")).is_equal([64, 64])
assert_int(raw.get("min_wl_m")).is_equal(512)
## The rung is sent as a bare string (rmp_serde's unit-variant convention),
## never a raw integer — a Global request must carry the literal tag
## "Global", matching step_canvas.rs's own StepCanvasRung enum encoding.
func test_encode_step_canvas_request_global_rung_is_bare_string() -> void:
var bytes := Protocol.encode_step_canvas_request(
"GJ380c", "Global", Vector2i.ZERO, Vector2i.ZERO, 0
)
var decoded = Messagepack.decode(bytes)
assert_str(decoded.value.get("rung")).is_equal("Global")
# =============================================================================
# Decode — status variants
# =============================================================================
func test_step_canvas_response_from_raw_decodes_ready_status() -> void:
var raw := {
"body_id": "GJ380c",
"rung": "District",
"center": [10, 20],
"extent": [64, 64],
"min_wl_m": 0,
"status": "Ready",
"canvas": null,
}
var decoded = Protocol.step_canvas_response_from_raw(raw)
assert_str(decoded["status"]).is_equal("Ready")
assert_str(decoded["error"]).is_equal("")
assert_str(decoded["rung"]).is_equal("District")
assert_that(decoded["center"]).is_equal(Vector2i(10, 20))
assert_that(decoded["extent"]).is_equal(Vector2i(64, 64))
func test_step_canvas_response_from_raw_decodes_pending_status() -> void:
var raw := {"body_id": "GJ380c", "rung": "Chunk", "status": "Pending"}
var decoded = Protocol.step_canvas_response_from_raw(raw)
assert_str(decoded["status"]).is_equal("Pending")
assert_that(decoded["canvas"]).is_null()
func test_step_canvas_response_from_raw_decodes_error_status() -> void:
var raw := {
"body_id": "GJ380c", "rung": "Region", "status": {"Error": "no heightmap"}
}
var decoded = Protocol.step_canvas_response_from_raw(raw)
assert_str(decoded["status"]).is_equal("Error")
assert_str(decoded["error"]).is_equal("no heightmap")
## Not a step-canvas response (no "rung" key) -> null, so decode_inbound's
## dispatch doesn't misroute a plain AtlasLayerResponse here.
func test_step_canvas_response_from_raw_returns_null_without_rung_key() -> void:
var raw := {"body_id": "GJ380c", "status": "Ready"}
assert_that(Protocol.step_canvas_response_from_raw(raw)).is_null()
# =============================================================================
# decode_inbound classification — the "rung" discriminator must win BEFORE
# the generic "status"-only AtlasLayerResponse fallback (a step-canvas
# response also carries "status").
# =============================================================================
func test_decode_inbound_classifies_step_canvas() -> void:
var raw := {"body_id": "GJ380c", "rung": "District", "status": "Pending"}
var encoded = Messagepack.encode(raw)
var inbound: Dictionary = Protocol.decode_inbound(encoded.value)
assert_str(inbound["kind"]).is_equal("step_canvas")
func test_decode_inbound_still_classifies_plain_atlas_response() -> void:
var raw := {"body_id": "GJ380c", "status": "Ready"}
var encoded = Messagepack.encode(raw)
var inbound: Dictionary = Protocol.decode_inbound(encoded.value)
assert_str(inbound["kind"]).is_equal("atlas")
# =============================================================================
# EncodedStepCanvas — the PNG-per-field array-of-int wire shape
# (step_canvas.rs's png_bytes: Vec<u8> with NO serde_bytes anywhere in this
# codebase serializes via serialize_seq, a msgpack ARRAY of ints, never a
# `bin` blob — decode_png_field()/​_decode_encoded_canvas() must repack that
# Array into a PackedByteArray, not expect messagepack.gd's bin_8/16/32 path).
# =============================================================================
func test_decode_png_field_repacks_array_of_ints_to_packed_byte_array() -> void:
# A 1x1 all-black L8 PNG's real byte stream, as it would arrive already
# decoded off the wire (a plain Array of ints, one per byte) — using real
# PNG magic bytes so a downstream Image.load_png_from_buffer() call
# would also succeed, not just this repack step in isolation.
var png_bytes := PackedByteArray([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
var as_array: Array = []
for b in png_bytes:
as_array.append(b)
var field_raw := {"png_bytes": as_array}
var result: PackedByteArray = Protocol.step_canvas_response_from_raw(
{
"body_id": "GJ380c",
"rung": "Chunk",
"status": "Ready",
"canvas":
{
"width": 1,
"height": 1,
"morphology": field_raw,
"elev_q": {},
"temp_dc": {"values": []},
"moisture_q": {},
"vegetation": {},
"settlement_id": {"values": []},
"glaciation": {},
"flooded_q": {},
"courses": [],
"cliffs": [],
},
}
)["canvas"]["morphology"]
assert_that(result).is_equal(png_bytes)
func test_decode_encoded_canvas_passes_through_temp_dc_and_settlement_id_as_arrays() -> void:
var raw := {
"body_id": "GJ380c",
"rung": "Chunk",
"status": "Ready",
"canvas":
{
"width": 2,
"height": 1,
"morphology": {},
"elev_q": {},
"temp_dc": {"values": [120, -32768]},
"moisture_q": {},
"vegetation": {},
"settlement_id": {"values": [0, 7]},
"glaciation": {},
"flooded_q": {},
"courses": [],
"cliffs": [],
},
}
var decoded = Protocol.step_canvas_response_from_raw(raw)
var canvas: Dictionary = decoded["canvas"]
assert_that(canvas["temp_dc"]).is_equal([120, -32768])
assert_that(canvas["settlement_id"]).is_equal([0, 7])
func test_decode_encoded_canvas_passes_through_courses_and_cliffs_unshaped() -> void:
var courses := [{"edge_id": 1, "class": 2, "points": [[0, 0], [100, 100]], "terminus": "Mouth"}]
var cliffs := [{"point": [5, 5], "channel_depth_dm": 10, "cliff_edge": true}]
var raw := {
"body_id": "GJ380c",
"rung": "Chunk",
"status": "Ready",
"canvas":
{
"width": 1,
"height": 1,
"morphology": {},
"elev_q": {},
"temp_dc": {"values": []},
"moisture_q": {},
"vegetation": {},
"settlement_id": {"values": []},
"glaciation": {},
"flooded_q": {},
"courses": courses,
"cliffs": cliffs,
},
}
var decoded = Protocol.step_canvas_response_from_raw(raw)
var canvas: Dictionary = decoded["canvas"]
assert_that(canvas["courses"]).is_equal(courses)
assert_that(canvas["cliffs"]).is_equal(cliffs)
func test_decode_png_field_malformed_input_returns_empty_packed_byte_array() -> void:
assert_that(Protocol._scp().decode_png_field(null)).is_equal(PackedByteArray())
assert_that(Protocol._scp().decode_png_field({"png_bytes": "not an array"})).is_equal(
PackedByteArray()
)