diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 8368bb2a5..6223d1b1f 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -22,6 +22,7 @@ var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same t var _last_dialogue_tick: int = -1 var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber) var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport +var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival func _ready() -> void: print("The Settled Reach — client initialized") @@ -81,6 +82,16 @@ func _process(_delta: float) -> void: if world_renderer and world_renderer.has_method("update_from_state"): world_renderer.update_from_state() + # OQ-07 (#522): propagate insert state to all z-layer-6 display nodes. + # Cursor shape still fires (D-056 option a) — only verb labels suppressed. + var _insert := GameState.insert_active + if cursor_renderer and cursor_renderer.has_method("set_insert_active"): + cursor_renderer.set_insert_active(_insert) + if interaction_list and interaction_list.has_method("set_insert_active"): + interaction_list.set_insert_active(_insert) + if interaction_prompt and interaction_prompt.has_method("set_insert_active"): + interaction_prompt.set_insert_active(_insert) + # D-057: Update interaction list from game state # Suppress during dialogue — player is in conversation, verb list is noise if interaction_list and interaction_list.has_method("update_from_state"): @@ -135,6 +146,8 @@ func _process(_delta: float) -> void: camera.reset_smoothing() # Send queued input to simulation + # #507: Server-bound inputs are accumulated into _pending_record_inputs across frames. + # At 60fps/10tps, inputs on non-snapshot frames must not be lost from the ring buffer. var inputs = InputMapper.flush_queue() for input in inputs: # #495: F12 WRONG button — client-only, trigger bug report capture @@ -164,6 +177,18 @@ func _process(_delta: float) -> void: "verb": null, } SimBridge.send_input(input) + _pending_record_inputs.append(input) + + # #507: Record tick data to ring buffer — once per server tick (snapshot arrival). + # Flushes all inputs accumulated since the last snapshot (across multiple display frames), + # then clears the accumulator for the next tick. + if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"): + bug_report_dialog.record_tick( + GameState.current_tick, + JSON.stringify(GameState.current_snapshot), + _pending_record_inputs + ) + _pending_record_inputs.clear() # Consume-once per tick: show monologue text, then clear. diff --git a/client/ui/bug_report_dialog.gd b/client/ui/bug_report_dialog.gd index 40ee7a669..383795217 100644 --- a/client/ui/bug_report_dialog.gd +++ b/client/ui/bug_report_dialog.gd @@ -1,8 +1,23 @@ extends Control -## #495: WRONG button (F12) MVP — bug report capture dialog. -## On F12: pause sim, show one-line prompt, save snapshot + render + description, unpause. +## #507: WRONG button — full 60-tick capture: ring buffer, snapshot history, replay seed. +## Upgrade of the Sprint 9 MVP (#495). +## +## On F12: pause sim, show one-line prompt, save all ring buffer data, unpause. ## Output: user://bug-reports/gauntlet-t{tick}-{timestamp}/ +## - snapshot.json — single-tick point-in-time (MVP compat) +## - render.txt — client-side text render +## - description.txt — tester notes + room/tick/seed metadata +## - inputs.jsonl — last 60 ticks of PlayerInput (replay-compatible JSONL) +## - snapshots.jsonl — last 60 ticks of ObserverSnapshot (one JSON per line) +## - seed.txt — RNG seed for deterministic replay +## +## Ring buffer: pre-allocated RING_SIZE arrays at startup. record_tick() is the +## public API for main.gd. _push_tick_inputs() / _push_tick_snapshot() are the +## internal implementations, exposed for unit testing (test_bug_report_ring_buffer.gd). +## +## Spec: inputs.jsonl is compatible with tooling/test-client --replay (replay.rs). +## Format: one JSON array per line, each array = Vec for that tick. signal capture_completed signal capture_cancelled @@ -16,14 +31,167 @@ const BOX_WIDTH := 500 const BOX_HEIGHT := 120 const PADDING := 16 +# #507: Ring buffer capacity — 60 ticks of history +const RING_SIZE := 60 + var _line_edit: LineEdit = null var _active: bool = false +# #507: Pre-allocated ring buffers (no per-tick allocation after _ready). +# Input ring: replay-format PlayerInput arrays, one per tick. +# Snapshot ring: ObserverSnapshot JSON strings, one per tick. +# Separate heads and counts so each buffer can be tested independently. +var _input_ring: Array = [] # Array[Array] — each slot: Array of {tick, action} dicts +var _input_head: int = 0 # Next write index (0..RING_SIZE-1) +var _input_count: int = 0 # Filled slot count (0..RING_SIZE) + +var _snapshot_ring: Array = [] # Array[String] — each slot: JSON-serialized ObserverSnapshot +var _snapshot_head: int = 0 +var _snapshot_count: int = 0 + func _ready() -> void: visible = false mouse_filter = Control.MOUSE_FILTER_STOP + # Pre-allocate ring buffers — resize then fill sentinels. + # The ring array itself never grows after _ready. Each write replaces the GDScript + # reference in an existing slot (not a new allocation of the ring), though the input + # Array stored per slot is a fresh ref each tick. + _input_ring.resize(RING_SIZE) + _snapshot_ring.resize(RING_SIZE) + for i in range(RING_SIZE): + _input_ring[i] = [] + _snapshot_ring[i] = "" + + +# --------------------------------------------------------------------------- +# Public API for main.gd: record one tick's data +# --------------------------------------------------------------------------- + +## Record one tick. Called from main.gd on every server tick (snapshot arrival). +## - tick: current server tick number +## - snapshot_json: JSON.stringify(GameState.current_snapshot) +## - mapper_inputs: Array of InputMapper dicts (BUG_REPORT/OPEN_MENU excluded). +## These are in raw InputMapper format and will be converted to replay format. +func record_tick(tick: int, snapshot_json: String, mapper_inputs: Array) -> void: + # Convert mapper inputs to replay-compatible format, then push both buffers. + var replay_inputs: Array = [] + for inp in mapper_inputs: + var ri := _to_replay_format(inp, tick) + if not ri.is_empty(): + replay_inputs.append(ri) + _push_tick_inputs(tick, replay_inputs) + _push_tick_snapshot(snapshot_json) + + +# --------------------------------------------------------------------------- +# Internal ring buffer operations (also exposed for tests) +# --------------------------------------------------------------------------- + +## Push replay-format inputs for one tick. inputs is Array of {tick, action} dicts. +## Overwrites oldest entry when buffer is full (circular eviction). +## tick param is unused here — each input dict carries its own tick field. +## Kept in the signature so tests and callers can pass tick for symmetry with record_tick(). +func _push_tick_inputs(_tick: int, inputs: Array) -> void: + _input_ring[_input_head] = inputs + _input_head = (_input_head + 1) % RING_SIZE + if _input_count < RING_SIZE: + _input_count += 1 + + +## Push a JSON-serialized ObserverSnapshot string for one tick. +func _push_tick_snapshot(snapshot_json: String) -> void: + _snapshot_ring[_snapshot_head] = snapshot_json + _snapshot_head = (_snapshot_head + 1) % RING_SIZE + if _snapshot_count < RING_SIZE: + _snapshot_count += 1 + + +## Format the input ring buffer as JSONL for writing to inputs.jsonl. +## Returns a String with one JSON array per line, oldest to newest. +## Each line: Array of {tick, action} replay-format PlayerInput objects. +func _format_inputs_jsonl() -> String: + var lines: PackedStringArray = [] + var start := (_input_head - _input_count + RING_SIZE) % RING_SIZE + for i in range(_input_count): + var idx := (start + i) % RING_SIZE + lines.append(JSON.stringify(_input_ring[idx])) + return "\n".join(lines) + + +## Format the snapshot ring buffer as JSONL for writing to snapshots.jsonl. +## Returns a String with one JSON string per line, oldest to newest. +func _format_snapshots_jsonl() -> String: + var lines: PackedStringArray = [] + var start := (_snapshot_head - _snapshot_count + RING_SIZE) % RING_SIZE + for i in range(_snapshot_count): + var idx := (start + i) % RING_SIZE + lines.append(_snapshot_ring[idx]) + return "\n".join(lines) + + +## Return the RNG seed for seed.txt. Never returns null. +## Uses GameState.rng_seed if available; falls back to "unavailable" string. +func _get_current_seed() -> Variant: + if GameState.rng_seed != null: + return GameState.rng_seed + return "unavailable" + + +## Convert one InputMapper dict to replay-compatible PlayerInput dict. +## Returns empty dict for client-only actions (BUG_REPORT, OPEN_MENU). +## Replay format: {"tick": N, "action": "MoveNorth"} or +## {"tick": N, "action": {"Interact": {"target_entity_id": ..., "verb": ...}}} +func _to_replay_format(input: Dictionary, tick: int) -> Dictionary: + var action_enum: int = input.get("action", -1) + var wire: String = SimBridge._action_enum_to_wire(action_enum) + if wire.is_empty(): + return {} # Client-only action (BUG_REPORT, OPEN_MENU) + + var result := {"tick": tick} + var action_data: Variant = input.get("action_data") + + match wire: + "Interact": + # Rust PlayerAction::Interact { target_entity_id, verb } + result["action"] = {"Interact": action_data if action_data is Dictionary else {}} + "SetFacing": + # Rust PlayerAction::SetFacing(String) — wrap direction string + var facing := "" + if action_data is Dictionary: + facing = str(action_data.get("facing", "")) + result["action"] = {"SetFacing": facing} + _: + # Simple enum variants: "MoveNorth", "Pause", "TeleportToHub", etc. + result["action"] = wire + + return result + + +# --------------------------------------------------------------------------- +# Test-accessible accessors (ring buffer introspection) +# --------------------------------------------------------------------------- + +func _get_buffer_capacity() -> int: + return RING_SIZE + +func _get_snapshot_buffer_capacity() -> int: + return RING_SIZE + +func _get_input_buffer() -> Array: + return _input_ring + +func _get_filled_input_count() -> int: + return _input_count + +func _get_filled_snapshot_count() -> int: + return _snapshot_count + + +# --------------------------------------------------------------------------- +# UI / capture flow +# --------------------------------------------------------------------------- func start_capture() -> void: if _active: @@ -94,7 +262,7 @@ func _save_report(description: String) -> void: var files_saved := 0 - # 1. snapshot.json — full current snapshot as JSON + # 1. snapshot.json — single-tick point-in-time (MVP compat, #495) var snapshot_path := base_path + "/snapshot.json" var snapshot_file := FileAccess.open(snapshot_path, FileAccess.WRITE) if snapshot_file: @@ -115,6 +283,7 @@ func _save_report(description: String) -> void: push_error("BugReport: failed to write %s" % render_path) # 3. description.txt — tester description + metadata + # Room: uses GameState.room_id (v0.1: room name is the map identifier) var desc_path := base_path + "/description.txt" var desc_file := FileAccess.open(desc_path, FileAccess.WRITE) if desc_file: @@ -125,12 +294,53 @@ func _save_report(description: String) -> void: desc_file.store_string("Facing: %s\n" % GameState.player_facing) desc_file.store_string("Position: %s\n" % str(GameState.player_position)) desc_file.store_string("Timestamp: %s\n" % Time.get_datetime_string_from_system()) + desc_file.store_string("RingBufferTicks: %d\n" % _input_count) desc_file.close() files_saved += 1 else: push_error("BugReport: failed to write %s" % desc_path) - push_warning("BugReport: saved %d/3 files to %s" % [files_saved, base_path]) + # 4. inputs.jsonl — last N ticks of PlayerInput (replay-compatible) + # One JSON array per line. Empty array = idle tick. + # Compatible with tooling/test-client --replay (replay.rs). + var inputs_path := base_path + "/inputs.jsonl" + var inputs_file := FileAccess.open(inputs_path, FileAccess.WRITE) + if inputs_file: + inputs_file.store_string(_format_inputs_jsonl()) + inputs_file.close() + files_saved += 1 + else: + push_error("BugReport: failed to write %s" % inputs_path) + + # 5. snapshots.jsonl — last N ticks of ObserverSnapshot, oldest to newest. + var snaps_path := base_path + "/snapshots.jsonl" + var snaps_file := FileAccess.open(snaps_path, FileAccess.WRITE) + if snaps_file: + snaps_file.store_string(_format_snapshots_jsonl()) + snaps_file.close() + files_saved += 1 + else: + push_error("BugReport: failed to write %s" % snaps_path) + + # 6. seed.txt — RNG seed for deterministic replay. + # Server must include "rng_seed" (u64) in ObserverSnapshot for this to be populated. + # If absent: includes a note on the required protocol change. + var seed_path := base_path + "/seed.txt" + var seed_file := FileAccess.open(seed_path, FileAccess.WRITE) + if seed_file: + var seed_val: Variant = _get_current_seed() + seed_file.store_string(str(seed_val) + "\n") + if seed_val == "unavailable": + seed_file.store_string( + "# Server protocol change required: add 'rng_seed' (u64) field to ObserverSnapshot.\n" + ) + seed_file.close() + files_saved += 1 + else: + push_error("BugReport: failed to write %s" % seed_path) + + push_warning("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [ + files_saved, base_path, _input_count]) ## Simplified client-side text render of the current snapshot. @@ -200,7 +410,9 @@ func _draw() -> void: HORIZONTAL_ALIGNMENT_LEFT, -1, LABEL_FONT_SIZE, TEXT_COLOR) -# -- Public API --------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- func is_active() -> bool: return _active diff --git a/server/Cargo.lock b/server/Cargo.lock index ae9e05069..40f0aa4bf 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -978,7 +978,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.9" +version = "0.1.10" dependencies = [ "bevy_app", "bevy_ecs",