extends Node signal game_id_changed(new_id: String) # Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}). # Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}. # Tiles use format: [{x, y, z, type}]. var current_snapshot: Dictionary = {} # D-085 (#258): Active game session identifier. Format: -- # Set by SessionManager.new_game() or SessionManager.resume_game(). # Empty string when no session is active (main menu state). var current_game_id: String = "": set(v): current_game_id = v game_id_changed.emit(v) var current_tick: int = 0 var player_position: Vector2 = Vector2.ZERO var visible_entities: Array = [] var visible_tiles: Array = [] var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups # v2 fields (D-015, D-031) var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty var player_facing: String = "North" # 8-directional facing direction var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral" # Player entity ID — the first entity is assumed to be the player (will be # refined when the server assigns explicit player entity IDs). var player_entity_id: int = 1 # #241: Follow target — entity_id of the NPC the player is following, -1 when not following. # Stub for server ticket #241 (Follow verb). Client reads this for camera/UI behavior. var follow_target_id: int = -1 # v4 fields (#404/#405) var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}] # v5 fields (#414) var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null # #122 (D-032): Character lattice profile — selects monologue text colour palette. # "lattice_augmented" = detective, "lattice_baseline" = smuggler. # Server sends this field as part of the player's capability snapshot. var lattice_profile: String = "lattice_baseline" # v6 fields (#449, D-053, D-065) var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch var player_inventory: Array = [] # [{item_id, name, slot}] # v7 fields (#435, D-061/D-062) var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, options: [{text, response_id, priority}]} or null # D-064: true while dialogue box is visible or fading out (300ms). # InputMapper suppresses movement when this is true. var dialogue_active: bool = false # v8 fields (#496): Gauntlet mode — room timer + personal bests var room_id: Variant = null # String room_id from snapshot, null in non-gauntlet mode var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode flag # OQ-07 (#522): Insert active state — false suppresses verb labels (z-layer 6). # Cursor shape changes still fire when false (D-056 option a). # v0.1 assumption: always true — both playable characters (detective and smuggler) # have neural inserts. Future characters without inserts would receive false from # the server's "insert_active" snapshot field, disabling all z-layer-6 UI. var insert_active: bool = true # #507: RNG seed for replay determinism — populated from snapshot "rng_seed" field. # Null in v0.1 (server does not yet send this field; protocol change required). var rng_seed: Variant = null # v15 fields (#554, D-085): save/load result from server. # {success: bool, kind: "save"|"load", error: Variant} or null. # One-shot: consumed by main.gd after display, then set back to null. var save_result: Variant = null # #257: Pending load path — set by main menu "Load Game" selection. # main.gd sends LOAD_GAME on startup if non-empty, then clears this field. # Format: user://saves//.sav or "" if no pending load. var pending_load_path: String = "" # v7 fields (#431, D-059/D-060) var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}] # v8 fields (#305, D-028): NPC follow-up after player dialogue choice var dialogue_response: Variant = null # {line_id, text, speaker_entity_id} # v9 fields (#535, D-078): Overheard NPC-to-NPC conversations var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}] var conversation_ended: Array = [] # [{speaker_id, target_id}] # v10 fields (#151, D-013): Discovered POIs from server (#148/#149). # Format: [{poi_id, name, x, y, z, category}]. Persists between snapshots unless # server explicitly sends an empty array (cleared locations are not typical in v0.1). # Populated from snapshot "poi_list" field — only updated when field present. var discovered_pois: Array = [] # v14 fields (#174, #242): Character-filtered examine result. # {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds. var current_examine_result: Variant = null # v14 fields (#264, D-041): Player knowledge graph dump for journal panel. # {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}], # facts: [{fact_id, confidence, source, state, acquired_tick}]} var player_knowledge: Variant = null # #126, D-018: Medium-range sound events for fog-edge directional indicators. # Format: [{x, y, event_type, range_category}] — server sends current medium events per tick. var medium_sound_events: Array = [] # #125, D-018: Close-range sound events for positional 2D audio. # Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd. var close_sound_events: Array = [] # D-071 (#530): Consecutive ticks without player position change. # D-020: Server-authoritative — read from snapshot "stationary_ticks" field. # Fallback: client-side accumulation (deprecated, remove when server populates field). # ListeningFocus boost activates at 30+ ticks (main.gd manages the dip). var stationary_ticks: int = 0 # DEPRECATED: Only used by client-side accumulation fallback. Remove with fallback. var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position # D-073 (#529): Server-authoritative zone_id from the player's current tile. # D-020: Read directly from snapshot "zone_id" field. # Fallback: client-side tile lookup (deprecated, remove when server populates field). # Empty string when zone_id field absent. var current_zone_id: String = "" func apply_snapshot(snapshot: Dictionary) -> void: current_snapshot = snapshot if snapshot.has("tick"): current_tick = snapshot.tick if snapshot.has("entities"): visible_entities = snapshot.entities # Derive player position from the entity with kind.variant == "Player" var found_player := false for entity in visible_entities: if entity.has("kind") and entity.kind is Dictionary and entity.kind.get("variant") == "Player": player_position = Vector2(entity.x, entity.y) if entity.has("entity_id"): player_entity_id = entity.entity_id found_player = true break if not found_player and visible_entities.size() > 0: push_warning("GameState: no Player entity found in %d entities" % [ visible_entities.size()]) # D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost. # Prefer server-sent value; fall back to client-side accumulation until server populates. if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int: # D-020: direct field assignment from server-authoritative snapshot. stationary_ticks = snapshot.stationary_ticks else: # DEPRECATED fallback — client-side accumulation. Remove when server sends # "stationary_ticks" in ObserverSnapshot (D-020 violation: derives behavior- # driving state on the client). Server tracks this in ListeningFocus component. if player_position == _prev_player_position: stationary_ticks += 1 else: stationary_ticks = 0 _prev_player_position = player_position # Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles" if snapshot.has("tiles"): visible_tiles = snapshot.tiles elif snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: # Live server: visible_tiles now includes type from tile_kind field var has_type := false if snapshot.visible_tiles.size() > 0 and snapshot.visible_tiles[0] is Dictionary: has_type = snapshot.visible_tiles[0].has("type") if has_type: visible_tiles = snapshot.visible_tiles if snapshot.has("visible_positions"): visible_positions.clear() for pos in snapshot.visible_positions: visible_positions[Vector2i(pos.x, pos.y)] = true # v2: game_time (D-031) if snapshot.has("game_time") and snapshot.game_time is Dictionary: game_time = snapshot.game_time # v2: player_facing (D-015) if snapshot.has("player_facing") and snapshot.player_facing is String: player_facing = snapshot.player_facing # v4: nearby_interactions (#404/#405) if snapshot.has("nearby_interactions") and snapshot.nearby_interactions is Array: nearby_interactions = snapshot.nearby_interactions else: nearby_interactions = [] # v5: current_monologue (#414) if snapshot.has("current_monologue") and snapshot.current_monologue is Dictionary: current_monologue = snapshot.current_monologue else: current_monologue = null # #122: lattice_profile — character insert capability level for monologue colour if snapshot.has("lattice_profile") and snapshot.lattice_profile is String: lattice_profile = snapshot.lattice_profile # v6: player_stance (#449, D-053) if snapshot.has("player_stance") and snapshot.player_stance is String: player_stance = snapshot.player_stance # v6: player_inventory (#449, D-065) if snapshot.has("player_inventory") and snapshot.player_inventory is Array: player_inventory = snapshot.player_inventory else: player_inventory = [] # v7: current_dialogue (#434, D-061) if snapshot.has("current_dialogue") and snapshot.current_dialogue is Dictionary: current_dialogue = snapshot.current_dialogue else: current_dialogue = null # v7: pending_recognitions (#431, D-059/D-060) if snapshot.has("pending_recognitions") and snapshot.pending_recognitions is Array: pending_recognitions = snapshot.pending_recognitions else: pending_recognitions = [] # v9: conversation_events (#535, D-078) — overheard NPC-to-NPC lines if snapshot.has("conversation_events") and snapshot.conversation_events is Array: conversation_events = snapshot.conversation_events else: conversation_events = [] # v9: conversation_ended (#535, D-078) — pairs whose conversation ended if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array: conversation_ended = snapshot.conversation_ended else: conversation_ended = [] # v8: dialogue_response (#305, D-028) — NPC follow-up after player choice if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary: dialogue_response = snapshot.dialogue_response else: dialogue_response = null # v8: gauntlet mode (#496) — room_id and gauntlet_mode if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true: gauntlet_mode = true else: gauntlet_mode = false if snapshot.has("room_id") and snapshot.room_id is String: room_id = snapshot.room_id else: room_id = null # OQ-07 (#522): insert_active — defaults true (v0.1 always has insert). # Server may send false for characters without an insert in future sprints. if snapshot.has("insert_active") and snapshot.insert_active is bool: insert_active = snapshot.insert_active else: insert_active = true # #507: rng_seed — server sends current RNG seed for replay determinism. # Field: "rng_seed" (u64 as integer). Null if server does not include it. if snapshot.has("rng_seed"): rng_seed = snapshot.rng_seed else: rng_seed = null # D-018: Sound events from server — partition by range_category. # #126: Medium → fog-edge directional indicators. # #125: Close → positional 2D audio via AudioManager. if snapshot.has("sound_events") and snapshot.sound_events is Array: medium_sound_events = [] close_sound_events = [] for se in snapshot.sound_events: if not se is Dictionary: continue var rc: String = se.get("range_category", "") if rc == "Medium": medium_sound_events.append(se) elif rc == "Close": close_sound_events.append(se) else: medium_sound_events = [] close_sound_events = [] # v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player. # Accepts "discovered_pois" or "poi_list" key — both map to the same client field. # Only update if the field is present — absence means "no change since last tick". if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array: discovered_pois = snapshot.discovered_pois elif snapshot.has("poi_list") and snapshot.poi_list is Array: discovered_pois = snapshot.poi_list # v14: examine_result (#174, #242) — character-filtered observation from Examine verb. if snapshot.has("examine_result") and snapshot.examine_result is Dictionary: current_examine_result = snapshot.examine_result else: current_examine_result = null # v15: save_result (#554, D-085) — one-shot save/load confirmation from server. if snapshot.has("save_result") and snapshot.save_result is Dictionary: save_result = snapshot.save_result else: save_result = null # v14: player_knowledge (#264, D-041) — partial KG dump for journal panel. # Only update when field is present (null means no change, server sends when KG changes). if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary: player_knowledge = snapshot.player_knowledge # D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade. # Prefer server-sent top-level value; fall back to client-side tile lookup until # server populates top-level "zone_id" in ObserverSnapshot. if snapshot.has("zone_id") and snapshot.zone_id is String: # D-020: direct field assignment from server-authoritative snapshot. current_zone_id = snapshot.zone_id else: # DEPRECATED fallback — client-side tile lookup. Remove when server sends # top-level "zone_id" in ObserverSnapshot (D-020 violation: derives zone # identity on the client via tile iteration). Server sends zone_id per # VisibleTile but not as a top-level snapshot field. var _tile_by_coord: Dictionary = {} for vtile in visible_tiles: if vtile is Dictionary and vtile.has("x") and vtile.has("y"): _tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile var player_pos_key := Vector2i(int(player_position.x), int(player_position.y)) var player_tile = _tile_by_coord.get(player_pos_key, null) current_zone_id = player_tile.get("zone_id", "") if player_tile else "" # v2: visible_tiles with visibility sectors # Derives visible_positions when not explicitly provided (real server mode) if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: visibility_sectors.clear() var has_explicit_positions := snapshot.has("visible_positions") if not has_explicit_positions: visible_positions.clear() for vtile in snapshot.visible_tiles: if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"): continue var pos := Vector2i(vtile.x, vtile.y) if vtile.has("visibility"): visibility_sectors[pos] = vtile.visibility if not has_explicit_positions: visible_positions[pos] = true