feat(client): WRONG button 60-tick ring buffer captures (#507)

Upgrades bug_report_dialog.gd from single-tick MVP to 60-tick rolling
history. Pre-allocated ring buffers for inputs and snapshots. Outputs
inputs.jsonl (replay-compatible), snapshots.jsonl, and seed.txt on F12.
Inter-frame input accumulation ensures no inputs lost between server ticks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 12:05:42 +01:00
co-authored by Claude Opus 4.6
parent 931399f248
commit c96f1463dc
3 changed files with 243 additions and 6 deletions
+25
View File
@@ -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.
+217 -5
View File
@@ -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<PlayerInput> 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
+1 -1
View File
@@ -978,7 +978,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.9"
version = "0.1.10"
dependencies = [
"bevy_app",
"bevy_ecs",