- Rename _insert to insert_state in main.gd (Hoshe #1) - Add after_each() to test_bug_report_ring_buffer.gd for GameState cleanup on assertion failure (Hoshe #2) - Add after_each() to test_insert_off_behavior.gd for stance/interaction restore on assertion failure (Hoshe #3) - Fix assertion message: "unknown" → "unavailable" (Hoshe #4) - Document memory ceiling of 60 JSON snapshots in ring buffer (Hoshe #5) - Add precision warning for u64 rng_seed via JSON float (Hoshe #6) - Promote _action_enum_to_wire to public action_enum_to_wire (Tyre #1) - Add @warning_ignore for unused _tick parameter (Tyre #5) - Document insert_active assumption for future no-insert characters (Tyre #4) - Restructure OQ-07 decision amendments as bullet points (Tyre #7) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
424 lines
15 KiB
GDScript
424 lines
15 KiB
GDScript
extends Control
|
|
|
|
## #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
|
|
|
|
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.85)
|
|
const BORDER_COLOR := Color("#4a9ebb")
|
|
const TEXT_COLOR := Color("#c8d0e0")
|
|
const FONT_SIZE := 14
|
|
const LABEL_FONT_SIZE := 13
|
|
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.
|
|
# Memory ceiling: 60 snapshot JSON strings (each ~2-8KB depending on entity count)
|
|
# + 60 input arrays (negligible). Worst case ~480KB resident. Acceptable for a
|
|
# debug tool that is always active during Gauntlet play.
|
|
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).
|
|
@warning_ignore("unused_parameter")
|
|
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.
|
|
## Note: rng_seed is u64 on the server. JSON encodes u64 as a number, which
|
|
## loses precision above 2^53 via float intermediary. When the server field
|
|
## lands, consider string-encoding the seed to preserve all 64 bits.
|
|
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:
|
|
return
|
|
_active = true
|
|
visible = true
|
|
|
|
# Pause the simulation
|
|
SimBridge.send_input({
|
|
"action": InputMapper.Action.PAUSE,
|
|
"timestamp_msec": Time.get_ticks_msec(),
|
|
})
|
|
|
|
# Create the LineEdit dynamically
|
|
_line_edit = LineEdit.new()
|
|
_line_edit.placeholder_text = "Describe the issue..."
|
|
_line_edit.size = Vector2(BOX_WIDTH - PADDING * 2, 30)
|
|
_line_edit.position = Vector2(
|
|
(get_viewport_rect().size.x - BOX_WIDTH) / 2.0 + PADDING,
|
|
(get_viewport_rect().size.y - BOX_HEIGHT) / 2.0 + 50
|
|
)
|
|
_line_edit.add_theme_font_size_override("font_size", FONT_SIZE)
|
|
_line_edit.text_submitted.connect(_on_text_submitted)
|
|
add_child(_line_edit)
|
|
_line_edit.grab_focus()
|
|
|
|
|
|
func _on_text_submitted(text: String) -> void:
|
|
_save_report(text)
|
|
_close()
|
|
capture_completed.emit()
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if not _active:
|
|
return
|
|
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
|
_close()
|
|
capture_cancelled.emit()
|
|
get_viewport().set_input_as_handled()
|
|
|
|
|
|
func _close() -> void:
|
|
_active = false
|
|
visible = false
|
|
if _line_edit:
|
|
_line_edit.queue_free()
|
|
_line_edit = null
|
|
|
|
# Unpause the simulation
|
|
SimBridge.send_input({
|
|
"action": InputMapper.Action.UNPAUSE,
|
|
"timestamp_msec": Time.get_ticks_msec(),
|
|
})
|
|
|
|
|
|
func _save_report(description: String) -> void:
|
|
var tick := GameState.current_tick
|
|
var timestamp := Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_")
|
|
var dir_name := "gauntlet-t%d-%s" % [tick, timestamp]
|
|
var base_path := "user://bug-reports/" + dir_name
|
|
|
|
# Ensure directory exists
|
|
var dir_err := DirAccess.make_dir_recursive_absolute(base_path)
|
|
if dir_err != OK:
|
|
push_error("BugReport: failed to create directory %s (error %d)" % [base_path, dir_err])
|
|
return
|
|
|
|
var files_saved := 0
|
|
|
|
# 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:
|
|
snapshot_file.store_string(JSON.stringify(GameState.current_snapshot, "\t"))
|
|
snapshot_file.close()
|
|
files_saved += 1
|
|
else:
|
|
push_error("BugReport: failed to write %s" % snapshot_path)
|
|
|
|
# 2. render.txt — simplified client-side text render of snapshot
|
|
var render_path := base_path + "/render.txt"
|
|
var render_file := FileAccess.open(render_path, FileAccess.WRITE)
|
|
if render_file:
|
|
render_file.store_string(_render_snapshot_text())
|
|
render_file.close()
|
|
files_saved += 1
|
|
else:
|
|
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:
|
|
desc_file.store_string("Description: %s\n" % description)
|
|
desc_file.store_string("Tick: %d\n" % tick)
|
|
desc_file.store_string("Room: %s\n" % str(GameState.room_id if GameState.room_id else "none"))
|
|
desc_file.store_string("Stance: %s\n" % GameState.player_stance)
|
|
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)
|
|
|
|
# 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.
|
|
## MVP version — full fidelity via server's format_snapshot_text() is a stretch goal.
|
|
func _render_snapshot_text() -> String:
|
|
var lines: PackedStringArray = []
|
|
lines.append("=== Snapshot t%d ===" % GameState.current_tick)
|
|
lines.append("Player: %s facing %s (%s)" % [
|
|
str(GameState.player_position), GameState.player_facing, GameState.player_stance])
|
|
|
|
if GameState.game_time.size() > 0:
|
|
lines.append("Time: day %s, %s, %s" % [
|
|
str(GameState.game_time.get("day", "?")),
|
|
str(GameState.game_time.get("day_phase", "?")),
|
|
str(GameState.game_time.get("tick_rate", "?"))])
|
|
|
|
lines.append("")
|
|
lines.append("Entities (%d):" % GameState.visible_entities.size())
|
|
for entity in GameState.visible_entities:
|
|
var kind_str: String = ""
|
|
if entity.has("kind") and entity.kind is Dictionary:
|
|
kind_str = entity.kind.get("variant", "?")
|
|
elif entity.has("kind") and entity.kind is String:
|
|
kind_str = entity.kind
|
|
var vis: String = entity.get("visibility", "?")
|
|
lines.append(" #%s %s at (%s, %s) [%s]" % [
|
|
str(entity.get("entity_id", "?")),
|
|
kind_str,
|
|
str(entity.get("x", "?")),
|
|
str(entity.get("y", "?")),
|
|
vis])
|
|
|
|
lines.append("")
|
|
lines.append("Visible tiles: %d" % GameState.visible_tiles.size())
|
|
|
|
if GameState.current_monologue != null:
|
|
lines.append("Monologue: %s" % str(GameState.current_monologue.get("text", "")))
|
|
if GameState.current_dialogue != null:
|
|
lines.append("Dialogue: %s says '%s'" % [
|
|
str(GameState.current_dialogue.get("npc_name", "?")),
|
|
str(GameState.current_dialogue.get("speech", ""))])
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
func _draw() -> void:
|
|
if not _active:
|
|
return
|
|
var viewport_size := get_viewport_rect().size
|
|
# Full-screen dim
|
|
draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR)
|
|
|
|
# Center box
|
|
var box_pos := Vector2(
|
|
(viewport_size.x - BOX_WIDTH) / 2.0,
|
|
(viewport_size.y - BOX_HEIGHT) / 2.0
|
|
)
|
|
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
|
|
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
|
|
draw_rect(box_rect, BORDER_COLOR, false, 1.0)
|
|
|
|
# Title
|
|
var font := ThemeDB.fallback_font
|
|
draw_string(font,
|
|
box_pos + Vector2(PADDING, 24),
|
|
"WRONG — Describe the issue (Enter to save, Esc to cancel):",
|
|
HORIZONTAL_ALIGNMENT_LEFT, -1, LABEL_FONT_SIZE, TEXT_COLOR)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func is_active() -> bool:
|
|
return _active
|