Implement control pass: focus cone, action wheel, named inputs, boot fixes
Full build / Import + headless tests (push) Successful in 22s
Full build / Full desktop build + optional release (push) Skipped

- Venue-correct access artifacts (AccessLoadout) and screened-aware verify
- Cone focus on NPC heads with billboarded ring + display name (ADR-0008)
- Action wheel with de-escalation-first slices (ADR-0009); Esc/RMB cancel
- Procedural NPC face/hair/neck/shoes (ADR-0010); named input actions
- Fix GameManager parse error (radial typed as ActionRadial)
- Fix first-person camera height and guard spawning inside the building
- Move building volume back so the door/awning/marquee are visible
- Pull the event scheduler once per simulated second; cap live NPCs
- Regenerate README screenshots; update TODO/DONE/MEMORY/README; 77 tests

Adds src/test/smoke_focus_radial.gd for the windowed focus/radial smoke.
This commit is contained in:
zwitschi committed 2026-10-31 15:58:01 +01:00
1 parent 69484866db
commit f11b441c07
28 files changed
+1433 -249

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
extends RefCounted
## Builds the access artifacts a person actually presents.
## Event `override_access` wins; otherwise a venue-appropriate default from
## type + policy. Recognition is never attached to every guest.
const Rules := preload("res://src/core/rules.gd")
const AccessArtifact := preload("res://src/core/access_artifact.gd")
const AccessPolicy := preload("res://src/core/access_policy.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
static func build(profile: NpcProfile, policy: AccessPolicy, override: Dictionary = {}) -> Array:
var npc_type: int = profile.npc_type if profile else Rules.NpcType.TOURIST
var out: Array = []
if override.get("recognized", false) or (override.is_empty() and npc_type == Rules.NpcType.AUTHORIZED_STAFF):
out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true}))
if not override.is_empty():
_append_override(out, profile, override)
return out
if policy != null and (policy.require_id or policy.requires(Rules.AccessKind.ID)):
_append_id_ticket_defaults(out, profile, npc_type, policy)
return out
if policy != null and policy.grant_if_any_required:
_append_or_defaults(out, npc_type)
return out
_append_wristband_defaults(out, npc_type)
return out
static func roll_carried_items(seed_value: int, override: Dictionary = {}) -> Array:
if override.has("carried_items"):
return override["carried_items"]
var rng := RandomNumberGenerator.new()
rng.seed = seed_value if seed_value != 0 else 1
var roll := rng.randf()
if roll < 0.08:
return ["keys", "knife"]
if roll < 0.40:
return ["keys"]
if roll < 0.62:
return ["phone"]
if roll < 0.80:
return ["belt"]
return []
static func _append_override(out: Array, profile: NpcProfile, override: Dictionary) -> void:
if override.has("has_id") or override.has("id_status"):
var has_id := bool(override.get("has_id", true))
var status: int = int(override.get("id_status", Rules.ArtifactStatus.VALID if has_id else Rules.ArtifactStatus.MISSING))
if not has_id:
status = Rules.ArtifactStatus.MISSING
var attrs := {}
if status != Rules.ArtifactStatus.MISSING:
attrs["age"] = int(override.get("age", 24))
attrs["name"] = profile.display_name if profile else "Guest"
out.append(AccessArtifact.new(Rules.AccessKind.ID, status, attrs))
if override.has("has_ticket") or override.has("ticket_status"):
var has_ticket := bool(override.get("has_ticket", true))
var status: int = int(override.get("ticket_status", Rules.ArtifactStatus.VALID if has_ticket else Rules.ArtifactStatus.MISSING))
if not has_ticket:
status = Rules.ArtifactStatus.MISSING
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, status, {}))
if override.has("has_wristband") or override.has("wristband_status"):
var status := _present_status(override, "has_wristband", "wristband_status")
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, status, _band_attrs(status)))
if override.has("has_credential") or override.has("credential_status"):
var status := _present_status(override, "has_credential", "credential_status")
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, status, {"role": "staff"}))
if override.has("has_guest_list") or override.has("guest_list_status"):
var status := _present_status(override, "has_guest_list", "guest_list_status")
out.append(AccessArtifact.new(Rules.AccessKind.GUEST_LIST, status, {}))
static func _present_status(override: Dictionary, has_key: String, status_key: String) -> int:
if override.has(status_key):
return int(override[status_key])
return Rules.ArtifactStatus.VALID if bool(override.get(has_key, false)) else Rules.ArtifactStatus.MISSING
static func _append_id_ticket_defaults(out: Array, profile: NpcProfile, npc_type: int, policy: AccessPolicy) -> void:
if npc_type == Rules.NpcType.AUTHORIZED_STAFF:
return
if npc_type == Rules.NpcType.AUTHORIZED_GUEST:
out.append(AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {
"age": 24, "name": profile.display_name if profile else "Guest",
}))
if policy.requires(Rules.AccessKind.TICKET):
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}))
return
out.append(AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.MISSING, {}))
if policy.requires(Rules.AccessKind.TICKET):
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.MISSING, {}))
static func _append_or_defaults(out: Array, npc_type: int) -> void:
match npc_type:
Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY:
if not _has_kind(out, Rules.AccessKind.RECOGNITION):
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "staff"}))
Rules.NpcType.AUTHORIZED_GUEST:
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}))
Rules.NpcType.TRESPASSER:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, _band_attrs(Rules.ArtifactStatus.COUNTERFEIT)))
Rules.NpcType.CONFUSED_VISITOR:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, _band_attrs(Rules.ArtifactStatus.AMBIGUOUS)))
_:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
static func _append_wristband_defaults(out: Array, npc_type: int) -> void:
match npc_type:
Rules.NpcType.AUTHORIZED_STAFF:
pass
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.DELIVERY:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, _band_attrs(Rules.ArtifactStatus.VALID)))
if npc_type == Rules.NpcType.DELIVERY:
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"}))
Rules.NpcType.TRESPASSER:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, _band_attrs(Rules.ArtifactStatus.COUNTERFEIT)))
Rules.NpcType.CONFUSED_VISITOR:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, _band_attrs(Rules.ArtifactStatus.AMBIGUOUS)))
_:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
static func _band_attrs(status: int) -> Dictionary:
match status:
Rules.ArtifactStatus.VALID:
return {"color": "red", "date": "2026-02-13", "event": "gala"}
Rules.ArtifactStatus.COUNTERFEIT:
return {"color": "red", "date": "2024-01-01"}
Rules.ArtifactStatus.AMBIGUOUS:
return {"color": "gold"}
_:
return {}
static func _has_kind(artifacts: Array, kind: int) -> bool:
for artifact in artifacts:
if artifact is AccessArtifact and artifact.kind == kind:
return true
return false
+2 -2
View File
@@ -15,7 +15,7 @@ func _init() -> void:
pass
## Verify a single person's set of artifacts (person_artifacts: Array[AccessArtifact]).
func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary:
func verify(person_artifacts: Array, policy: AccessPolicy, already_screened: bool = false) -> Dictionary:
var reasons: Array[String] = []
var issues: Array[String] = []
var checked: Array[String] = []
@@ -48,7 +48,7 @@ func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary:
# Wand screening is processed by the interaction layer (false alarms), but a
# policy can hard-require it; a refused screen blocks entry.
if policy.wand_screening_required and not person_screened(person_artifacts):
if policy.wand_screening_required and not already_screened and not person_screened(person_artifacts):
issues.append("Screening required")
return _result(false, reasons, issues, checked)
+3
View File
@@ -34,6 +34,9 @@ var guard_noticed := false
## Whether this NPC has passed the handheld wand screening (The Joke Shop).
var screened := false
## Event-level access payload (`has_id`, `has_ticket`, statuses). Empty means
## venue/type defaults from AccessLoadout.
var access_override: Dictionary = {}
func _init() -> void:
fsm = FSM.new(self)
+13 -3
View File
@@ -13,7 +13,7 @@ const EventScheduler := preload("res://src/core/event_scheduler.gd")
## Drives one shift: advances time, schedules events, owns the shared systems
## (recorder, reputation, radio, crowd), and produces the shift review.
signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String)
signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary)
signal shift_started(payload: Dictionary)
signal shift_ended(review: Dictionary)
signal phase_changed(phase: int)
@@ -34,6 +34,10 @@ var scheduler := EventScheduler.new()
var spawned_count := 0
var spawn_seed := 0
var _shift_finished := false
## The scheduler rolls once per simulated second. `tick()` runs at physics rate,
## so accumulate time and only pull due events on whole-second boundaries.
const SPAWN_INTERVAL_S := 1.0
var _spawn_accum_s := 0.0
func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int = 0) -> void:
scenario = p_scenario
@@ -56,6 +60,7 @@ func start() -> void:
running = true
_shift_finished = false
elapsed_s = 0.0
_spawn_accum_s = 0.0
current_phase = _phase_for_time(0.0)
event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase})
shift_started.emit({"scenario_id": scenario.id, "duration_s": duration_s})
@@ -77,7 +82,12 @@ func tick(delta: float) -> void:
if elapsed_s >= duration_s:
_finish_shift()
return
_spawn_due(delta)
_spawn_accum_s += delta
if _spawn_accum_s >= SPAWN_INTERVAL_S:
_spawn_accum_s -= SPAWN_INTERVAL_S
if _spawn_accum_s > SPAWN_INTERVAL_S:
_spawn_accum_s = 0.0
_spawn_due(SPAWN_INTERVAL_S)
func _phase_for_time(t: float) -> int:
if scenario == null or scenario.shift_phases.is_empty():
@@ -102,7 +112,7 @@ func _spawn_due(delta: float) -> void:
var profile := scenario.profile_for(event.get("npc_type", Rules.NpcType.TOURIST))
if profile == null:
continue
npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id)
npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id, event)
func _finish_shift() -> void:
if _shift_finished: