feat(client): protocol v23 — bookmark_catalog decode + bookmark actions (Workstream 3)

Adds client-side wire support for the bookmark catalog (#614) and the
two associated player actions. PROTOCOL_VERSION bumps from 21 to 23:
- v22 (server): RequestBookmarkCatalog + ConfirmBookmark player actions
- v23 (server): bookmark_catalog field on ObserverSnapshot

Decode:
- protocol.gd decode_snapshot extracts optional bookmark_catalog.
  Defensive parse of BookmarkWire fields (id, title, subtitle, flavor,
  default_location, allowed_locations, allowed_locations_cultures,
  career, starting_capital_tractus). Missing or malformed → null.
- snapshot_handler.gd caches the catalog into GameState.bookmark_catalog
  on each snapshot (server pushes on tick 0; re-fetchable via
  RequestBookmarkCatalog).
- GameState gains bookmark_catalog: Array = [] (untyped per autoload
  parse-order discipline; default empty so callers can iterate without
  null checks).

Encode:
- encode_request_bookmark_catalog() — unit variant, sent to trigger a
  re-push if the cached catalog is missing.
- encode_confirm_bookmark(bookmark_id, starting_location_id) — struct
  variant matching server rmp_serde shape. Called from character
  creation on Start (lands in Workstream 6).

Tests:
- 5 new cases in test_protocol.gd: hand-built bookmark_catalog decode
  (all 9 fields asserted), fixture-based decode round-trip, missing-
  field null behavior, RequestBookmarkCatalog encode roundtrip,
  ConfirmBookmark encode roundtrip.
- All 12 existing snapshot fixtures regenerated from server via
  `cargo test --test gen_fixtures -- --ignored`. The new
  snapshot_with_bookmark_catalog.msgpack fixture was generated by the
  same pass.

Verification:
- gdlint clean
- godot --headless --path client --quit — no SCRIPT ERROR
- test_protocol 62/62, test_client_p3 24/24, test_implant_nav_stack
  52/52, test_implant_registry 42/42, test_implant_app_lifecycle 36/36

Workstream 4 (Option A sequencing via loading_screen + SimBridge
connect) lands next.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 00:25:10 +02:00
co-authored by Claude Opus 4.6
parent f24d08f756
commit 41e895796c
17 changed files with 185 additions and 1 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+106
View File
@@ -383,3 +383,109 @@ func test_decode_diagonal_fixtures() -> void:
assert_that(input.tick).is_equal(100)
assert_that(input.action.variant).is_equal(pair[1])
assert_that(input.action.data).is_null()
# -- v23: BookmarkCatalog decode -----------------------------------------------
func test_decode_snapshot_with_bookmark_catalog() -> void:
# Hand-built dict — fixture generation requires server work, skip round-trip (#614).
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"bookmark_catalog": {
"bookmarks": [
{
"id": "bm_tycoon_arion",
"title": "The Arion Run",
"subtitle": "Mid-range freight corridor",
"flavor": "You have contacts. Use them.",
"default_location": "loc_arion_prime",
"allowed_locations": ["loc_arion_prime", "loc_vethis_station"],
"allowed_locations_cultures": ["arion", "vethis"],
"career": "tycoon",
"starting_capital_tractus": 50000,
},
],
},
}
var encoded: Variant = Messagepack.encode(raw)
assert_that(encoded.status).is_null()
var snapshot: Variant = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_not_null()
assert_that(snapshot.bookmark_catalog).is_not_null()
var bmc: Dictionary = snapshot.bookmark_catalog
assert_that(bmc.has("bookmarks")).is_true()
assert_that(bmc["bookmarks"].size()).is_equal(1)
var bm: Dictionary = bmc["bookmarks"][0]
assert_that(bm["id"]).is_equal("bm_tycoon_arion")
assert_that(bm["title"]).is_equal("The Arion Run")
assert_that(bm["default_location"]).is_equal("loc_arion_prime")
assert_that(bm["allowed_locations"].size()).is_equal(2)
assert_that(bm["allowed_locations"][0]).is_equal("loc_arion_prime")
assert_that(bm["allowed_locations_cultures"][1]).is_equal("vethis")
assert_that(bm["career"]).is_equal("tycoon")
assert_that(bm["starting_capital_tractus"]).is_equal(50000)
func test_decode_snapshot_bookmark_catalog_fixture() -> void:
# Cross-language round-trip: Rust-generated fixture (#614).
var bytes = _load_fixture("snapshot_with_bookmark_catalog")
var snapshot: Variant = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.bookmark_catalog).is_not_null()
var bmc: Dictionary = snapshot.bookmark_catalog
assert_that(bmc["bookmarks"].size()).is_greater(0)
var bm: Dictionary = bmc["bookmarks"][0]
assert_that(bm.has("id")).is_true()
assert_that(bm.has("title")).is_true()
assert_that(bm.has("allowed_locations")).is_true()
assert_that(bm["career"]).is_equal("tycoon")
func test_decode_snapshot_no_bookmark_catalog_is_null() -> void:
# Snapshot without bookmark_catalog key → field should be null.
var raw := {
"tick": 2,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
}
var encoded: Variant = Messagepack.encode(raw)
var snapshot: Variant = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_not_null()
assert_that(snapshot.bookmark_catalog).is_null()
# -- v23: RequestBookmarkCatalog + ConfirmBookmark encoding --------------------
func test_encode_request_bookmark_catalog_roundtrip() -> void:
var bytes := Protocol.encode_request_bookmark_catalog()
assert_that(bytes.size()).is_greater(0)
var raw: Variant = Messagepack.decode(bytes)
assert_that(raw.status).is_null()
assert_that(raw.value is Array).is_true()
assert_that(raw.value.size()).is_equal(1)
var entry: Dictionary = raw.value[0]
assert_that(entry["action_name"]).is_equal("RequestBookmarkCatalog")
assert_that(entry.get("action_data")).is_null()
func test_encode_confirm_bookmark_roundtrip() -> void:
var bytes := Protocol.encode_confirm_bookmark("bm_tycoon_arion", "loc_arion_prime")
assert_that(bytes.size()).is_greater(0)
var raw: Variant = Messagepack.decode(bytes)
assert_that(raw.status).is_null()
assert_that(raw.value is Array).is_true()
var entry: Dictionary = raw.value[0]
assert_that(entry["action_name"]).is_equal("ConfirmBookmark")
var data: Dictionary = entry["action_data"]
assert_that(data["bookmark_id"]).is_equal("bm_tycoon_arion")
assert_that(data["starting_location_id"]).is_equal("loc_arion_prime")