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 57d127b6a..6d9f70243 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 1729742f0..6b2f4c4f5 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 3dff2bb55..376daff0d 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index a92c410bb..54bd601ac 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index bf6627076..e648490a9 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 57d127b6a..6d9f70243 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_full.msgpack b/client/tests/fixtures/msgpack/snapshot_full.msgpack index a6f1bd929..bc21bd235 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_full.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_minimal.msgpack b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack index ac3bd511f..3a7b418d0 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_minimal.msgpack and b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 7b0438135..0e3c35c2b 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 6e5dfc25f..b35bca377 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 83093a587..31b244945 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index e7fad6a21..6c1f4b7ca 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_with_bookmark_catalog.msgpack b/client/tests/fixtures/msgpack/snapshot_with_bookmark_catalog.msgpack new file mode 100644 index 000000000..91984c0e5 Binary files /dev/null and b/client/tests/fixtures/msgpack/snapshot_with_bookmark_catalog.msgpack differ 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")