From 41e895796c772290b1848758a623e325d7bd3326 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 20 Apr 2026 00:25:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20protocol=20v23=20=E2=80=94=20bo?= =?UTF-8?q?okmark=5Fcatalog=20decode=20+=20bookmark=20actions=20(Workstrea?= =?UTF-8?q?m=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/scripts/autoloads/game_state.gd | 7 ++ client/scripts/protocol/protocol.gd | 67 ++++++++++- client/scripts/snapshot_handler.gd | 6 + .../msgpack/snapshot_boundary_tick_0.msgpack | Bin 397 -> 356 bytes .../snapshot_boundary_tick_127.msgpack | Bin 397 -> 356 bytes .../snapshot_boundary_tick_2b31m1.msgpack | Bin 401 -> 360 bytes .../snapshot_boundary_tick_2b32.msgpack | Bin 405 -> 364 bytes .../snapshot_boundary_tick_32767.msgpack | Bin 399 -> 358 bytes .../fixtures/msgpack/snapshot_empty.msgpack | Bin 397 -> 356 bytes .../fixtures/msgpack/snapshot_full.msgpack | Bin 1257 -> 1216 bytes .../fixtures/msgpack/snapshot_minimal.msgpack | Bin 498 -> 457 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 802 -> 761 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 495 -> 454 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 498 -> 457 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 662 -> 621 bytes .../snapshot_with_bookmark_catalog.msgpack | Bin 0 -> 599 bytes client/tests/test_protocol.gd | 106 ++++++++++++++++++ 17 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 client/tests/fixtures/msgpack/snapshot_with_bookmark_catalog.msgpack diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index a4e8bb363..2be6bba7c 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -122,6 +122,13 @@ var settings_response: Variant = null # Null when no economy data in the current snapshot. var economy_snapshot: Variant = null +# v23 fields (#614): Bookmark catalog from server. +# One-shot response to RequestBookmarkCatalog. Array of bookmark Dictionaries: +# [{id, title, subtitle, flavor, default_location, allowed_locations, +# allowed_locations_cultures, career, starting_capital_tractus}] +# Empty array when no catalog has been received yet. +var bookmark_catalog: Array = [] + # v7 fields (#431, D-059/D-060) var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}] diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index b7ba592ad..798dde0b7 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -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: diff --git a/client/scripts/snapshot_handler.gd b/client/scripts/snapshot_handler.gd index a1de2d407..75d1f0e7c 100644 --- a/client/scripts/snapshot_handler.gd +++ b/client/scripts/snapshot_handler.gd @@ -219,6 +219,12 @@ static func apply(snapshot: Dictionary) -> void: else: GameState.economy_snapshot = null + # v23: bookmark_catalog (#614) — one-shot response to RequestBookmarkCatalog. + if snapshot.has("bookmark_catalog") and snapshot.bookmark_catalog is Dictionary: + var bmc: Dictionary = snapshot.bookmark_catalog + if bmc.get("bookmarks") is Array: + GameState.bookmark_catalog = bmc["bookmarks"] + # #718: character_visual_descriptor — restored from server snapshot on save/load. if ( snapshot.has("character_visual_descriptor") diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 57d127b6ad14a74d63d06e02dfd2d7e0e97ec16c..6d9f70243b0c74ebe7a3d6b9ef55b0b022b69486 100644 GIT binary patch delta 25 gcmeBWe!|3ak3o2OS!z*nW`3UdMjjEy$)1cZ0CRr`D*ylh delta 66 zcmaFD)XU6sk3n>KS!z*nW`3T?MjjDHjm^pVc_4wr5};6gYFTPtN%4eDsDgPZsVS4? G8C?NSFBzu* diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 1729742f03bc5415f7de198dd6265ea1935192ad..6b2f4c4f5f4d20c6c275ab2d41487072f7c3eddc 100644 GIT binary patch delta 25 gcmeBWe!|3ak3o2OS!z*nW`3UdMjjEy$)1cZ0CRr`D*ylh delta 66 zcmaFD)XU6sk3n>KS!z*nW`3T?MjjDHjm^pVc_4wr5};6gYFTPtN%4eDsDgPZsVS4? G8C?NSFBzu* diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 3dff2bb55c8173fa6e355d23cd70919edd2c55cf..376daff0d8dd08772c78ad348200811af56f2185 100644 GIT binary patch delta 25 gcmbQp{DO(+9)s}mvecsD%=|p@jXV;JlYJRo0CZyrI{*Lx delta 66 zcmaFCG?AI-9)sxevecsD%=|o&jXV;J8k>{z^FRWLB|xG0)UwpPlHv)QPzCc+Qd1@? HGr9r*QU4jY diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index a92c410bb148df36cc118990ee552bf71f4eff05..54bd601ac2a5b688c0544d6ce7ceddcfc982f7c4 100644 GIT binary patch delta 25 gcmbQr{Dz6=9)s}mvecsD%=|p@jXW}plLHxD0Ch(QO8@`> delta 66 zcmaFEG?kg>9)sxevecsD%=|o&jXW}p8k>{z^FRWLB|xG0)UwpPlHv)QPzCc+Qd1_Y HGr9r*RL>d5 diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index bf6627076eb38721c5d49cf3a96dfe906e2f2f22..e648490a96b16daef3007f3784a8dfbd1a5b5793 100644 GIT binary patch delta 25 gcmeBYe#XRek3o2OS!z*nW`3UdMjkQ7$=-}E0CVvOGXMYp delta 66 zcmaFH)X&Uwk3n>KS!z*nW`3T?MjkOnjm^pVc_4wr5};6gYFTPtN%4eDsDgPZsVS2c G8C?NTml>}B diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 57d127b6ad14a74d63d06e02dfd2d7e0e97ec16c..6d9f70243b0c74ebe7a3d6b9ef55b0b022b69486 100644 GIT binary patch delta 25 gcmeBWe!|3ak3o2OS!z*nW`3UdMjjEy$)1cZ0CRr`D*ylh delta 66 zcmaFD)XU6sk3n>KS!z*nW`3T?MjjDHjm^pVc_4wr5};6gYFTPtN%4eDsDgPZsVS4? G8C?NSFBzu* diff --git a/client/tests/fixtures/msgpack/snapshot_full.msgpack b/client/tests/fixtures/msgpack/snapshot_full.msgpack index a6f1bd9297f74904b3fc025ca7aa75e4b7cbdca2..bc21bd2358f74425dab21cdf68b6be2f5bb806f4 100644 GIT binary patch delta 26 icmaFKd4QAW9)sBOvecsD%=|p@jXYbKHos!}&j{z^FRWLB|xG0)UwpPlHv)QPzCc+Qd1_k HFuDQ&b#xlM diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 7b043813544b391ed50234867da60624b3b4203d..0e3c35c2b44e8a7bef554b13239f2e5201cc9d88 100644 GIT binary patch delta 25 hcmZ3)_LG(89)s}mvecsD%=|p@jXW!vCf{Rn0RVkH35Ngx delta 66 zcmey#x`>VE9)sxevecsD%=|o&jXW!vG&U#a=Ya$gOMpW0sb#5oCB+jqp$g`uq^3;X H!sH47c%vHw diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 6e5dfc25f74d6af0026edf72cbcd4794d22a44d3..b35bca3775262dc492104460966f8c0ffc55d145 100644 GIT binary patch delta 25 hcmaFQe2kgr9)s}mvecsD%=|p@jXY(HlUFmk004dG2{Zrz delta 66 zcmX@c{GOTT9)sxevecsD%=|o&jXY(H8k>{z^FRWLB|xG0)UwpPlHv)QPzCc+Qd1^3 HGP(i)b66U% diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 83093a587a897a43952fdbe074e4b245226c3d71..31b2449457d786ebb700a46f33afb93e56170463 100644 GIT binary patch delta 25 hcmeywe3F^x9)s}mvecsD%=|p@jXaf%lh-l2004fH2|xe< delta 66 zcmX@f{E3<89)sxevecsD%=|o&jXaf%8k>{z^FRWLB|xG0)UwpPlHv)QPzCc+Qd1_k HFuDQ&b#xlM diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index e7fad6a21ab5c41e66606e8bdf69ffa4400a1d68..6c1f4b7ca7494993f077806f1ab25218ad2de263 100644 GIT binary patch delta 25 gcmbQn`j&;~9)s}mvecsD%=|p@jXbhUlY^LC0CnRCRR910 delta 66 zcmaFMGL4nz9)sxevecsD%=|o&jXbhU8k>{z^FRWLB|xG0)UwpPlHv)QPzCc+Qd1^t HFu4K%R z|9$zX1MiD6i3>Wjpm{(pC=_vuMF%auTGt+C#9wz6){uNOnhA3L)w}n_>o+%APIJw8 zq-F6a{QgfcR?9V&EN*Ki{Ue&xg(^vo=GPV&k)Q1T(w%V38@JFq4Q68AO_D#)&L03z C9|Oq% literal 0 HcmV?d00001 diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd index 7dd843dd7..cb53e9d55 100644 --- a/client/tests/test_protocol.gd +++ b/client/tests/test_protocol.gd @@ -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")