Implement control pass: focus cone, action wheel, named inputs, boot fixes
- 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:
1 parent
69484866db
commit
f11b441c07
28 files changed
+1433
-249
No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
Reference in new issue
Block a user