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
+66 -1
View File
@@ -13,7 +13,8 @@ extends Node
## Reject snapshots where version != this value.
## v20: adds settings_response field to ObserverSnapshot (#627, D-138).
## v21: adds economy_snapshot field to ObserverSnapshot (#822, D-181).
const PROTOCOL_VERSION: int = 21
## v23: adds bookmark_catalog field to ObserverSnapshot (#614).
const PROTOCOL_VERSION: int = 23
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -379,6 +380,41 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"category": str(raw_ticker.get("category", "")),
}
# v23: bookmark_catalog (#614) — one-shot response to RequestBookmarkCatalog.
# {bookmarks: [{id, title, subtitle, flavor, default_location, allowed_locations,
# allowed_locations_cultures, career, starting_capital_tractus}]} or null.
var bookmark_catalog: Variant = null
var raw_bmc: Variant = raw.get("bookmark_catalog")
if raw_bmc is Dictionary and raw_bmc.get("bookmarks") is Array:
var bm_entries: Array = []
for raw_bm in raw_bmc["bookmarks"]:
if not raw_bm is Dictionary or not raw_bm.has("id"):
continue
var al: Array = []
var raw_al: Variant = raw_bm.get("allowed_locations")
if raw_al is Array:
for loc in raw_al:
al.append(str(loc))
var alc: Array = []
var raw_alc: Variant = raw_bm.get("allowed_locations_cultures")
if raw_alc is Array:
for cul in raw_alc:
alc.append(str(cul))
bm_entries.append(
{
"id": str(raw_bm["id"]),
"title": str(raw_bm.get("title", "")),
"subtitle": str(raw_bm.get("subtitle", "")),
"flavor": str(raw_bm.get("flavor", "")),
"default_location": str(raw_bm.get("default_location", "")),
"allowed_locations": al,
"allowed_locations_cultures": alc,
"career": str(raw_bm.get("career", "tycoon")),
"starting_capital_tractus": int(raw_bm.get("starting_capital_tractus", 0)),
}
)
bookmark_catalog = {"bookmarks": bm_entries}
# TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020).
# Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs).
# When server populates this field, client-side accumulation fallback in game_state.gd
@@ -471,6 +507,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"triangle_crisis_events": triangle_crisis_events,
"current_ticker": current_ticker,
"settings_response": settings_response,
"bookmark_catalog": bookmark_catalog,
}
@@ -699,6 +736,34 @@ static func encode_change_settings(enabled: bool) -> PackedByteArray:
return result.value
## Encode a RequestBookmarkCatalog action (#614).
## Unit variant — no payload. Server responds with bookmark_catalog in the next snapshot.
static func encode_request_bookmark_catalog() -> PackedByteArray:
var entries: Array = [{"tick": 0, "action_name": "RequestBookmarkCatalog", "action_data": null}]
var result = Messagepack.encode(entries)
if result.status != null:
push_error("Protocol: encode_request_bookmark_catalog failed: %s" % result.status)
return PackedByteArray()
return result.value
## Encode a ConfirmBookmark action (#614, #680).
## Struct variant with bookmark_id and starting_location_id.
static func encode_confirm_bookmark(bookmark_id: String, starting_location_id: String) -> PackedByteArray:
var entries: Array = [
{
"tick": 0,
"action_name": "ConfirmBookmark",
"action_data": {"bookmark_id": bookmark_id, "starting_location_id": starting_location_id},
}
]
var result = Messagepack.encode(entries)
if result.status != null:
push_error("Protocol: encode_confirm_bookmark failed: %s" % result.status)
return PackedByteArray()
return result.value
## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios).
## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null.
static func decode_player_input(bytes: PackedByteArray) -> Variant: