Implement prototype: venue-driven simulation core, scenarios, tests, and playable entrance scene
CI / headless-tests (push) Canceled after 49s

- Godot 4.3 project scaffold with first-person guard, procedural world,
  interaction HUD, access inspection, radio forms, debug overlay, shift review
- Simulation core: ShiftCoordinator, VenueScenario, EventScheduler, NPCController/FSM,
  AccessVerifier, EscalationManager, RadioSystem, IncidentRecorder,
  ReputationEvaluator, CrowdSystem, EventBus
- Three authored scenarios (Hollywood Blvd building, The Joke Shop, TV studio)
- Custom headless test runner (25 tests) replacing editor-dependent GUT,
  plus GitHub Actions CI
- Documentation workflow live: README, PLAN (tech stack), TODO/DONE, MEMORY
This commit is contained in:
zwitschi committed 2026-10-31 11:37:54 +01:00
1 parent e23f284ad4
commit 439e0a4281
45 files changed
+4368 -7

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
class_name Hud
extends CanvasLayer
## Minimal on-screen HUD: top status bar, target info, action hints,
## event log, access inspection panel, and a debug overlay.
const Rules := preload("res://src/core/rules.gd")
var top_label: Label
var time_label: Label
var target_label: Label
var hint_label: Label
var log_label: Label
var debug_label: Label
var inspect_panel: PanelContainer
var inspect_text: Label
var inspect_meta: Label
var _debug_enabled := false
var _log_lines: Array[String] = []
func _init() -> void:
name = "HUD"
layer = 10
var margin := MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 16)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_right", 16)
margin.add_theme_constant_override("margin_bottom", 12)
add_child(margin)
var vbox := VBoxContainer.new()
margin.add_child(vbox)
var top := HBoxContainer.new()
vbox.add_child(top)
top_label = Label.new()
top_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top.add_child(top_label)
time_label = Label.new()
top.add_child(time_label)
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 6)
vbox.add_child(spacer)
target_label = Label.new()
target_label.add_theme_font_size_override("font_size", 16)
vbox.add_child(target_label)
hint_label = Label.new()
hint_label.modulate = Color(1, 0.9, 0.6)
hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
hint_label.custom_minimum_size = Vector2(0, 40)
vbox.add_child(hint_label)
var spacer2 := Control.new()
spacer2.custom_minimum_size = Vector2(0, 4)
vbox.add_child(spacer2)
log_label = Label.new()
log_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
log_label.custom_minimum_size = Vector2(0, 120)
log_label.add_theme_font_size_override("font_size", 13)
vbox.add_child(log_label)
var bottom_spacer := Control.new()
bottom_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
vbox.add_child(bottom_spacer)
var controls := Label.new()
controls.text = "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · A allow · D deny · F debug"
controls.add_theme_font_size_override("font_size", 12)
controls.modulate = Color(0.8, 0.8, 0.85)
vbox.add_child(controls)
# --- inspect panel (popup center) ---
inspect_panel = PanelContainer.new()
inspect_panel.visible = false
inspect_panel.set_anchors_preset(Control.PRESET_CENTER)
inspect_panel.modulate.a = 1.0
var ivbox := VBoxContainer.new()
inspect_panel.add_child(ivbox)
inspect_text = Label.new()
inspect_text.add_theme_font_size_override("font_size", 15)
ivbox.add_child(inspect_text)
inspect_meta = Label.new()
inspect_meta.add_theme_font_size_override("font_size", 12)
inspect_meta.modulate = Color(0.8, 0.85, 1.0)
ivbox.add_child(inspect_meta)
var insp_hint := Label.new()
insp_hint.text = "A = allow entry D = deny entry"
insp_hint.add_theme_font_size_override("font_size", 12)
ivbox.add_child(insp_hint)
add_child(inspect_panel)
# --- debug overlay ---
debug_label = Label.new()
debug_label.visible = false
debug_label.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
debug_label.position = Vector2(16, 0)
debug_label.add_theme_font_size_override("font_size", 11)
debug_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
add_child(debug_label)
func set_shift_state(coordinator: Object, scenario: Resource) -> void:
top_label.text = scenario.display_name if scenario else "Step Line"
var mins := int(coordinator.elapsed_s) / 60
var secs := int(coordinator.elapsed_s) % 60
time_label.text = "shift %02d:%02d phase %s\nrep %.0f crowd %.0f%%" % [
mins, secs,
Rules.PHASE_NAMES.get(coordinator.current_phase, "Early"),
coordinator.reputation.rating,
coordinator.crowd.mood * 100.0,
]
func set_target_label(text: String) -> void:
target_label.text = text
func set_action_hint(text: String) -> void:
hint_label.text = text
func log(text: String) -> void:
_log_lines.append(text)
if _log_lines.size() > 8:
_log_lines.pop_front()
log_label.text = "\n".join(_log_lines)
func toggle_debug() -> void:
_debug_enabled = not _debug_enabled
debug_label.visible = _debug_enabled
func refresh_debug(coordinator: Object, npcs: Array) -> void:
if not _debug_enabled:
return
var lines: Array[String] = ["--- DEBUG ---", "shift %.1fs / %.1fs rating %.1f" % [
coordinator.elapsed_s, coordinator.duration_s, coordinator.reputation.rating]]
for npc in npcs:
var c: Object = npc.get_npc_controller()
if c == null:
continue
var profile: Object = c.profile
lines.append(" [%s] %s intent=%s state=%s esc=%d" % [
Rules.NPC_TYPE_NAMES.get(profile.npc_type if profile else -1, "?"),
npc.name, c.intent, c.fsm.current_state, c.highest_escalation_received,
])
debug_label.text = "\n".join(lines)
func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -> void:
var profile: Object = controller.profile
var npc_display: String = (profile.display_name if profile else "Person")
var granted: bool = result.get("granted", false)
inspect_text.text = "Access check — %s\nGranted: %s" % [npc_display, "YES" if granted else "NO"]
var lines: Array[String] = []
for artifact in artifacts:
lines.append(" • " + artifact.summary())
if result.get("reasons", []).size() > 0:
lines.append("Reasons: " + ", ".join(result.get("reasons", [])))
if result.get("issues", []).size() > 0:
lines.append("Issues: " + "; ".join(result.get("issues", [])))
inspect_meta.text = "\n".join(lines)
inspect_panel.visible = true
func hide_inspection() -> void:
inspect_panel.visible = false
+159
View File
@@ -0,0 +1,159 @@
class_name RadioPanel
extends CanvasLayer
## Radio report form (PLAN.md Level 4 assist / Level 5 emergency).
## Emergency calls use hold-to-activate (press and hold 1.6s) to prevent
## accidental activation.
signal submitted(report: Dictionary, emergency: bool)
signal cancelled()
const Rules := preload("res://src/core/rules.gd")
var panel: PanelContainer
var npc_name_label: Label
var location_edit: LineEdit
var desc_edit: LineEdit
var weapons_check: CheckBox
var danger_option: OptionButton
var action_button: Button
var hold_hint: Label
var status_label: Label
var _emergency := false
var _npc_id := ""
var _holding := false
var _hold_time := 0.0
const HOLD_NEEDED := 1.6
var _truth := {"weapons_seen": false, "danger_level": 0}
func _init() -> void:
name = "RadioPanel"
layer = 20
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.55)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
panel = PanelContainer.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
add_child(panel)
var vbox := VBoxContainer.new()
panel.add_child(vbox)
vbox.add_theme_constant_override("separation", 8)
var title := Label.new()
title.name = "RadioTitle"
title.add_theme_font_size_override("font_size", 18)
vbox.add_child(title)
npc_name_label = Label.new()
vbox.add_child(npc_name_label)
location_edit = LineEdit.new()
location_edit.placeholder_text = "Location (e.g. front steps)"
location_edit.text = "front entrance"
vbox.add_child(location_edit)
desc_edit = LineEdit.new()
desc_edit.placeholder_text = "Describe the person and behavior"
vbox.add_child(desc_edit)
weapons_check = CheckBox.new()
weapons_check.text = "Weapon seen"
vbox.add_child(weapons_check)
var danger_row := HBoxContainer.new()
vbox.add_child(danger_row)
var danger_label := Label.new()
danger_label.text = "Danger level: "
danger_row.add_child(danger_label)
danger_option = OptionButton.new()
for i in 4:
danger_option.add_item("level %d — %s" % [i, _danger_name(i)], i)
danger_option.select(1)
danger_row.add_child(danger_option)
hold_hint = Label.new()
hold_hint.text = ""
hold_hint.modulate = Color(1, 0.85, 0.55)
vbox.add_child(hold_hint)
status_label = Label.new()
status_label.text = ""
vbox.add_child(status_label)
var buttons := HBoxContainer.new()
vbox.add_child(buttons)
action_button = Button.new()
action_button.text = "Send request"
action_button.button_down.connect(func(): _start_hold())
action_button.button_up.connect(func(): _stop_hold(false))
buttons.add_child(action_button)
var cancel_btn := Button.new()
cancel_btn.text = "Cancel"
cancel_btn.pressed.connect(func(): cancelled.emit())
buttons.add_child(cancel_btn)
var _title_label: Label
func open(controller: Object, emergency: bool, truth: Dictionary) -> void:
_emergency = emergency
_truth = truth
_npc_id = controller.npc_id if controller else ""
npc_name_label.text = "Target: %s" % (controller.profile.display_name if controller else "?")
var title: Label = panel.find_child("RadioTitle", true, false)
title.text = "EMERGENCY CALL" if emergency else "Security Assistance"
weapons_check.button_pressed = truth.get("weapons_seen", false)
danger_option.select(truth.get("danger_level", 1))
hold_hint.text = "Hold the button for %.1fs" % HOLD_NEEDED if emergency else ""
status_label.text = ""
action_button.text = "SEND EMERGENCY" if emergency else "Send request"
_holding = false
_hold_time = 0.0
visible = true
func _start_hold() -> void:
_holding = _emergency
_hold_time = 0.0
func _stop_hold(cancelled_early: bool) -> void:
if _holding:
_holding = false
if not cancelled_early and _hold_time >= HOLD_NEEDED:
_submit()
else:
status_label.text = "Call cancelled - must hold the button."
func _process(delta: float) -> void:
if not visible:
return
if _holding:
_hold_time += delta
status_label.text = "Holding... %.0f%%" % (minf(1.0, _hold_time / HOLD_NEEDED) * 100.0)
if _hold_time >= HOLD_NEEDED:
var was := _holding
_holding = false
if was:
_submit()
func _submit() -> void:
if not visible:
return
var report := {
"npc_id": _npc_id,
"location": location_edit.text,
"description": desc_edit.text,
"behavior": desc_edit.text,
"weapons_seen": weapons_check.button_pressed,
"danger_level": danger_option.get_selected_id(),
}
visible = false
submitted.emit(report, _emergency)
func _danger_name(level: int) -> String:
match level:
0: return "none"
1: return "low"
2: return "elevated"
3: return "immediate threat"
_: return "unknown"
+77
View File
@@ -0,0 +1,77 @@
class_name ShiftReview
extends CanvasLayer
## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review).
signal restart_requested()
var report_label: Label
func _init() -> void:
name = "ShiftReview"
layer = 30
var dim := ColorRect.new()
dim.color = Color(0.04, 0.05, 0.08, 0.92)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var center := VBoxContainer.new()
center.set_anchors_preset(Control.PRESET_CENTER)
center.alignment = BoxContainer.ALIGNMENT_CENTER
center.add_theme_constant_override("separation", 12)
add_child(center)
var title := Label.new()
title.text = "Shift Review"
title.add_theme_font_size_override("font_size", 28)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
center.add_child(title)
report_label = Label.new()
report_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
report_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
report_label.custom_minimum_size = Vector2(720, 0)
center.add_child(report_label)
var restart := Button.new()
restart.text = "Run another shift"
restart.pressed.connect(func(): restart_requested.emit())
restart.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(restart)
var quit_btn := Button.new()
quit_btn.text = "Quit"
quit_btn.pressed.connect(func(): get_tree().quit())
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(quit_btn)
func show_review(review: Dictionary, scenario: Resource) -> void:
var reputation: Dictionary = review.get("reputation", {})
var incidents: Dictionary = review.get("incidents", {})
var crowd: Dictionary = review.get("crowd", {})
var radio: Dictionary = review.get("radio", {})
var incident_list: Array = incidents.get("incidents", [])
var peaceful := 0
for inc in incident_list:
if inc.get("outcome", "") in ["peaceful", "left"]:
peaceful += 1
var lines: Array[String] = [
"Venue: %s" % (scenario.display_name if scenario else review.get("scenario_id", "")),
"Grade: %s Score: %.0f/100" % [reputation.get("grade", "?"), reputation.get("rating", 0.0)],
"Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful],
"NPCs encountered: %d" % review.get("spawned_count", 0),
"Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)],
"Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))],
"",
"Highlights:",
]
var mods: Array = reputation.get("modifiers", [])
var shown := 0
for mod in mods:
var delta: float = float(mod.get("delta", 0.0))
lines.append(" %s (%+.0f) %s" % [mod.get("category", ""), delta, mod.get("reason", "")])
shown += 1
if shown >= 12:
break
report_label.text = "\n".join(lines)
visible = true