class_name Protocol ## MessagePack codec for the Rust↔Godot wire protocol (D-020). ## ## Encodes/decodes ObserverSnapshot and PlayerInput to match ## rmp_serde's named-field encoding of server/src/bridge/types.rs. ## ## Wire format notes (rmp_serde with to_vec_named): ## Structs → msgpack maps with string keys ## Unit enum variants (no data) → bare strings ("MoveNorth", "Npc") ## Data enum variants → single-element maps ({"UsePerceptionMode": "thermal"}) ## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs. ## Reject snapshots where version != this value. const PROTOCOL_VERSION: int = 5 # -- Decode: bytes from server → GDScript types -------------------------------- ## Decode an ObserverSnapshot from MessagePack bytes. ## Returns decoded snapshot Dictionary or null on error. ## v2 fields (version, game_time, player_facing, visible_tiles) default to null/empty ## when decoding v1 snapshots for backward compatibility. static func decode_snapshot(bytes: PackedByteArray) -> Variant: var result = Messagepack.decode(bytes) if result.status != null: push_error("Protocol: msgpack decode failed: %s" % result.status) return null var raw = result.value if not raw is Dictionary or not raw.has("tick") or not raw.has("entities"): push_error("Protocol: snapshot missing required fields") return null # Version check: reject snapshots from incompatible server var version: Variant = raw.get("version") if version != PROTOCOL_VERSION: push_error("Protocol: version mismatch (got %s, expected %s). Server and client are out of sync." % [version, PROTOCOL_VERSION]) return null var entities: Array[Dictionary] = [] var raw_entities: Array = raw["entities"] var dropped := 0 for raw_entity in raw_entities: var entity = _decode_entity(raw_entity) if entity != null: entities.append(entity) else: dropped += 1 if dropped > 0: push_error("Protocol: %d/%d entities failed to decode (D-010 information boundary violation)" % [dropped, raw_entities.size()]) # GDScript int is signed 64-bit. Rust tick is u64 but will not exceed 2^63 # in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031). var tick: int = raw["tick"] # version already checked above; game_time for HUD display var game_time: Variant = raw.get("game_time") # player_facing: FacingDirection is a unit enum → bare string in rmp_serde var player_facing: Variant = null var raw_facing: Variant = raw.get("player_facing") if raw_facing is String: player_facing = raw_facing # visible_tiles: Array of {x, y, z, visibility, tile_kind} var visible_tiles: Array = [] var raw_vtiles: Variant = raw.get("visible_tiles") if raw_vtiles is Array: for raw_tile in raw_vtiles: if raw_tile is Dictionary and raw_tile.has("x") and raw_tile.has("y") and raw_tile.has("z"): var tile_entry := { "x": int(raw_tile["x"]), "y": int(raw_tile["y"]), "z": int(raw_tile["z"]), } var vis: Variant = raw_tile.get("visibility") if vis is String: tile_entry["visibility"] = vis # tile_kind: Floor/Wall/Door/Object — map to client tile type strings var kind: Variant = raw_tile.get("tile_kind") if kind is String: tile_entry["type"] = kind.to_lower() else: tile_entry["type"] = "floor" visible_tiles.append(tile_entry) # v4: nearby_interactions (#404/#405) var nearby_interactions: Array = [] var raw_interactions: Variant = raw.get("nearby_interactions") if raw_interactions is Array: for raw_ni in raw_interactions: var ni = _decode_nearby_interaction(raw_ni) if ni != null: nearby_interactions.append(ni) # v5: current_monologue (#414) var current_monologue: Variant = null var raw_monologue: Variant = raw.get("current_monologue") if raw_monologue is Dictionary and raw_monologue.has("text"): current_monologue = { "id": str(raw_monologue.get("id", "")), "text": str(raw_monologue["text"]), "duration_seconds": float(raw_monologue.get("duration_seconds", 5.0)), } return { "tick": tick, "entities": entities, "decode_errors": dropped, "version": version, "game_time": game_time, "player_facing": player_facing, "visible_tiles": visible_tiles, "nearby_interactions": nearby_interactions, "current_monologue": current_monologue, } ## Decode a single VisibleEntity from a raw msgpack map. static func _decode_entity(raw: Dictionary) -> Variant: if not raw.has("entity_id") or not raw.has("x") or not raw.has("y") \ or not raw.has("z") or not raw.has("kind"): push_warning("Protocol: entity missing required fields: %s" % str(raw.keys())) return null var entity_id: int = raw["entity_id"] # v2: visibility sector (Forward/Peripheral). null for v1 entities. var visibility: Variant = null var raw_vis: Variant = raw.get("visibility") if raw_vis is String: visibility = raw_vis # v4: relationship (D-033) and observation state var relationship: String = "Unknown" var raw_rel: Variant = raw.get("relationship") if raw_rel is String: relationship = raw_rel var observation: Variant = "Visible" var raw_obs: Variant = raw.get("observation") if raw_obs is String: observation = raw_obs elif raw_obs is Dictionary: observation = _decode_enum_variant(raw_obs) return { "entity_id": entity_id, "x": float(raw["x"]), "y": float(raw["y"]), "z": int(raw["z"]), "kind": _decode_enum_variant(raw["kind"]), "visibility": visibility, "relationship": relationship, "observation": observation, } ## Decode a NearbyInteraction from a raw msgpack map. ## Returns {entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]} or null. static func _decode_nearby_interaction(raw) -> Variant: if not raw is Dictionary: return null if not raw.has("entity_id") or not raw.has("verbs"): push_warning("Protocol: nearby_interaction missing required fields: %s" % str(raw.keys())) return null var verbs: Array[Dictionary] = [] var raw_verbs: Variant = raw.get("verbs") if raw_verbs is Array: for rv in raw_verbs: var verb = _decode_verb_option(rv) if verb != null: verbs.append(verb) if verbs.is_empty(): return null var entity_type: String = "" var raw_et: Variant = raw.get("entity_type") if raw_et is String: entity_type = raw_et return { "entity_id": int(raw["entity_id"]), "entity_type": entity_type, "distance": int(raw.get("distance", 0)), "verbs": verbs, } ## Decode a VerbOption from a raw msgpack map. ## Returns {kind, label, priority, available} or null. static func _decode_verb_option(raw) -> Variant: if not raw is Dictionary or not raw.has("label"): return null var kind: String = "" var raw_kind: Variant = raw.get("kind") if raw_kind is String: kind = raw_kind return { "kind": kind, "label": str(raw["label"]), "priority": int(raw.get("priority", 0)), "available": bool(raw.get("available", true)), } ## Decode an enum variant from rmp_serde's encoding. ## Unit variants are bare strings, data variants are single-element maps. ## Returns { "variant": String, "data": Variant } in both cases. static func _decode_enum_variant(raw) -> Dictionary: if raw is String: return { "variant": raw, "data": null } elif raw is Dictionary and raw.size() == 1: var variant_name: String = raw.keys()[0] return { "variant": variant_name, "data": raw[variant_name] } else: push_warning("Protocol: unexpected enum encoding: %s" % str(raw)) return { "variant": "Unknown", "data": raw } # -- Encode: GDScript types → bytes to server ---------------------------------- ## Encode a PlayerInput to MessagePack bytes. ## action_name: one of "MoveNorth", "MoveSouth", "MoveEast", "MoveWest", ## "Interact", "UsePerceptionMode", "Pause", "Unpause" ## action_data: null for unit variants, String for UsePerceptionMode static func encode_player_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray: var action_value: Variant if action_data != null: # Data variant → single-element map action_value = { action_name: action_data } else: # Unit variant → bare string action_value = action_name var input := { "tick": tick, "action": action_value, } var result = Messagepack.encode(input) if result.status != null: push_error("Protocol: msgpack encode failed: %s" % result.status) return PackedByteArray() return result.value ## Encode an array of PlayerInputs to MessagePack bytes (Vec wire format). ## Server expects one framed message per tick containing all inputs as a msgpack array. ## Each entry: { "tick": int, "action_name": String, "action_data": Variant (optional) } static func encode_player_inputs(inputs: Array) -> PackedByteArray: var wire_inputs: Array = [] for input in inputs: var action_name: String = input["action_name"] var action_data: Variant = input.get("action_data") var action_value: Variant if action_data != null: action_value = { action_name: action_data } else: action_value = action_name wire_inputs.append({ "tick": input["tick"], "action": action_value, }) var result = Messagepack.encode(wire_inputs) if result.status != null: push_error("Protocol: msgpack encode 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: var result = Messagepack.decode(bytes) if result.status != null: push_error("Protocol: msgpack decode failed: %s" % result.status) return null var raw = result.value if not raw is Dictionary or not raw.has("tick") or not raw.has("action"): push_error("Protocol: player_input missing required fields") return null var tick: int = raw["tick"] return { "tick": tick, "action": _decode_enum_variant(raw["action"]), }