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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -978,7 +978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
|
||||
Reference in New Issue
Block a user