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:
@@ -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}]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user