Implement prototype: venue-driven simulation core, scenarios, tests, and playable entrance scene
CI / headless-tests (push) Canceled after 49s
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:
1 parent
e23f284ad4
commit
439e0a4281
45 files changed
+4368
-7
No files matched your search
@@ -0,0 +1,22 @@
|
||||
class_name AccessArtifact
|
||||
extends Resource
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
## An item or piece of evidence used to verify entry (PLAN.md: Authorized Access).
|
||||
|
||||
@export var kind: int = Rules.AccessKind.NONE
|
||||
@export var status: int = Rules.ArtifactStatus.MISSING
|
||||
|
||||
## Human-readable attributes the player can inspect, e.g. color, pattern, date,
|
||||
## event marking, DOB, name, ticket section. Populated by scenario content.
|
||||
@export var attributes: Dictionary = {}
|
||||
|
||||
func _init(p_kind: int = Rules.AccessKind.NONE, p_status: int = Rules.ArtifactStatus.MISSING, p_attributes: Dictionary = {}) -> void:
|
||||
kind = p_kind
|
||||
status = p_status
|
||||
attributes = p_attributes
|
||||
|
||||
func summary() -> String:
|
||||
var kind_name: String = Rules.ACCESS_KIND_NAMES.get(kind, "None")
|
||||
var status_name: String = Rules.ARTIFACT_STATUS_NAMES.get(status, "Unknown")
|
||||
return "%s (%s)" % [kind_name, status_name]
|
||||
@@ -0,0 +1,32 @@
|
||||
class_name AccessPolicy
|
||||
extends Resource
|
||||
## Venue admission rules (PLAN.md: access rules per venue).
|
||||
|
||||
## Minimum required artifacts. For the Hollywood Blvd building this may be
|
||||
## { WRISTBAND: true }. The Joke Shop may require { ID: true, TICKET: true }.
|
||||
@export var required_artifacts: Dictionary = {}
|
||||
|
||||
## OR-admission: when true, entry is granted as soon as ANY required artifact
|
||||
## kind is valid (e.g. TV studio). When false, every kind must be satisfied.
|
||||
@export var grant_if_any_required: bool = false
|
||||
|
||||
## Artifact kinds accepted as OR-equivalent: e.g. { GUEST_LIST: true } when a
|
||||
## reservation list can replace a ticket. If true, the verifier grants when any
|
||||
## listed kind is valid.
|
||||
@export_range(1, 8) var minimum_age: int = 0
|
||||
|
||||
## Wether identification is required or only checked when present.
|
||||
@export var require_id: bool = false
|
||||
|
||||
@export var wand_screening_required: bool = false
|
||||
|
||||
## Displayed rules summary used by dialogue/UI.
|
||||
@export var policy_summary: String = ""
|
||||
|
||||
func requires(kind: int) -> bool:
|
||||
return required_artifacts.get(kind, false)
|
||||
|
||||
func description() -> String:
|
||||
if not policy_summary.is_empty():
|
||||
return policy_summary
|
||||
return str(required_artifacts)
|
||||
@@ -0,0 +1,123 @@
|
||||
class_name AccessVerifier
|
||||
extends RefCounted
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const AccessPolicy := preload("res://src/core/access_policy.gd")
|
||||
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||
## Evaluates access artifacts against a venue AccessPolicy.
|
||||
## Pure logic: no UI, no Node dependency, fully headless-testable.
|
||||
|
||||
## Result dictionary contract:
|
||||
## { granted: bool, reasons: Array[String], issues: Array[String],
|
||||
## decisive_artifacts: Array[String], checked_artifacts: Array[String] }
|
||||
|
||||
func _init() -> void:
|
||||
pass
|
||||
|
||||
## Verify a single person's set of artifacts (person_artifacts: Array[AccessArtifact]).
|
||||
func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary:
|
||||
var reasons: Array[String] = []
|
||||
var issues: Array[String] = []
|
||||
var checked: Array[String] = []
|
||||
|
||||
for artifact in person_artifacts:
|
||||
if not (artifact is AccessArtifact):
|
||||
continue
|
||||
checked.append(artifact.summary())
|
||||
if artifact.kind == Rules.AccessKind.RECOGNITION and artifact.status == Rules.ArtifactStatus.VALID:
|
||||
reasons.append("Recognized by guard")
|
||||
elif artifact.status == Rules.ArtifactStatus.VALID:
|
||||
reasons.append("Valid %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact"))
|
||||
elif artifact.status == Rules.ArtifactStatus.EXPIRED:
|
||||
issues.append("Expired %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact"))
|
||||
elif artifact.status == Rules.ArtifactStatus.COUNTERFEIT:
|
||||
issues.append("Suspected counterfeit %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact"))
|
||||
elif artifact.status == Rules.ArtifactStatus.MISMATCHED:
|
||||
issues.append("%s does not match person" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact"))
|
||||
elif artifact.status == Rules.ArtifactStatus.AMBIGUOUS:
|
||||
issues.append("Ambiguous %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact"))
|
||||
elif artifact.status == Rules.ArtifactStatus.MISSING:
|
||||
issues.append("Missing %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact"))
|
||||
|
||||
# Age check.
|
||||
if policy.minimum_age > 0 and require_age_info(person_artifacts, policy):
|
||||
var age := person_age(person_artifacts)
|
||||
if age < policy.minimum_age:
|
||||
issues.append("Underage (%d < %d)" % [age, policy.minimum_age])
|
||||
return _result(false, reasons, issues, checked)
|
||||
|
||||
# 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):
|
||||
issues.append("Screening required")
|
||||
return _result(false, reasons, issues, checked)
|
||||
|
||||
# Recognition short-circuits if present and valid.
|
||||
var recognized := false
|
||||
for artifact in person_artifacts:
|
||||
if artifact is AccessArtifact:
|
||||
if artifact.kind == Rules.AccessKind.RECOGNITION and artifact.status == Rules.ArtifactStatus.VALID:
|
||||
recognized = true
|
||||
if recognized:
|
||||
return _result(true, reasons, issues, checked)
|
||||
|
||||
# Every required artifact kind must have a valid match (AND admission),
|
||||
# unless the policy grants on any single valid artifact (OR admission).
|
||||
var any_satisfied := false
|
||||
for kind in policy.required_artifacts.keys():
|
||||
var satisfied := false
|
||||
for artifact in person_artifacts:
|
||||
if artifact is AccessArtifact and artifact.kind == kind:
|
||||
if artifact.status == Rules.ArtifactStatus.VALID:
|
||||
satisfied = true
|
||||
any_satisfied = any_satisfied or satisfied
|
||||
if not satisfied:
|
||||
issues.append("Missing valid %s" % Rules.ACCESS_KIND_NAMES.get(kind, "credential"))
|
||||
if not policy.grant_if_any_required:
|
||||
return _result(false, reasons, issues, checked)
|
||||
if policy.grant_if_any_required and not any_satisfied:
|
||||
return _result(false, reasons, issues, checked)
|
||||
|
||||
if policy.require_id:
|
||||
var id_ok := false
|
||||
for artifact in person_artifacts:
|
||||
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.ID and artifact.status == Rules.ArtifactStatus.VALID:
|
||||
id_ok = true
|
||||
if not id_ok:
|
||||
issues.append("Valid ID required")
|
||||
return _result(false, reasons, issues, checked)
|
||||
|
||||
return _result(true, reasons, issues, checked)
|
||||
|
||||
func _result(granted: bool, reasons: Array, issues: Array, checked: Array) -> Dictionary:
|
||||
return {
|
||||
"granted": granted,
|
||||
"reasons": reasons,
|
||||
"issues": issues,
|
||||
"decisive_artifacts": reasons.duplicate(),
|
||||
"checked_artifacts": checked,
|
||||
}
|
||||
|
||||
func require_age_info(person_artifacts: Array, policy: AccessPolicy) -> bool:
|
||||
if policy.minimum_age <= 0:
|
||||
return false
|
||||
var id_present := false
|
||||
for artifact in person_artifacts:
|
||||
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.ID:
|
||||
id_present = true
|
||||
return id_present
|
||||
|
||||
func person_age(person_artifacts: Array) -> int:
|
||||
for artifact in person_artifacts:
|
||||
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.ID:
|
||||
var age: Variant = artifact.attributes.get("age", -1)
|
||||
if age is int and age > 0:
|
||||
return age
|
||||
return -1
|
||||
|
||||
func person_screened(person_artifacts: Array) -> bool:
|
||||
for artifact in person_artifacts:
|
||||
if artifact is AccessArtifact:
|
||||
if artifact.kind == Rules.AccessKind.CREDENTIAL and artifact.attributes.get("screened", false) == true:
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1,53 @@
|
||||
class_name CrowdSystem
|
||||
extends RefCounted
|
||||
## Models crowd mood and public reaction (PLAN.md: Public Crowd Reaction).
|
||||
|
||||
const EventBus := preload("res://src/core/event_bus.gd")
|
||||
## mood: 0.0 (calm) .. 1.0 (panic). attention: how many people are watching.
|
||||
|
||||
signal changed(payload: Dictionary)
|
||||
|
||||
var event_bus: EventBus = null
|
||||
var mood: float = 0.05
|
||||
var attention: float = 0.0
|
||||
var crowd_panic := false
|
||||
|
||||
func set_event_bus(bus: EventBus) -> void:
|
||||
event_bus = bus
|
||||
|
||||
## Apply a crowd event. type: "calm_professionalism", "aggressive_behavior",
|
||||
## "visible_weapon", "emergency_responder", "de_escalation".
|
||||
func apply_event(event_type: String) -> void:
|
||||
match event_type:
|
||||
"calm_professionalism":
|
||||
attention = maxf(0.0, attention - 0.05)
|
||||
mood = maxf(0.0, mood - 0.02)
|
||||
"de_escalation":
|
||||
mood = maxf(0.0, mood - 0.06)
|
||||
attention = clampf(attention + 0.05, 0.0, 1.0)
|
||||
"aggressive_behavior":
|
||||
attention = clampf(attention + 0.20, 0.0, 1.0)
|
||||
mood = clampf(mood + 0.12, 0.0, 1.0)
|
||||
"visible_weapon":
|
||||
attention = 1.0
|
||||
mood = clampf(mood + 0.35, 0.0, 1.0)
|
||||
"emergency_responder":
|
||||
attention = 1.0
|
||||
mood = clampf(mood + 0.25, 0.0, 1.0)
|
||||
_:
|
||||
pass
|
||||
crowd_panic = mood >= 0.75
|
||||
if event_bus:
|
||||
event_bus.crowd_changed.emit({
|
||||
"mood": mood,
|
||||
"attention": attention,
|
||||
"panic": crowd_panic,
|
||||
"event": event_type,
|
||||
})
|
||||
|
||||
func summary() -> Dictionary:
|
||||
return {
|
||||
"mood": mood,
|
||||
"attention": attention,
|
||||
"panic": crowd_panic,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
class_name EscalationManager
|
||||
extends RefCounted
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
## Tracks the current player escalation level for an interaction and which
|
||||
## actions are allowed. Rules derive from VenueScenario + NpcProfile.
|
||||
|
||||
var current_level: int = Rules.Level.OBSERVATION
|
||||
var max_allowed_level: int = Rules.Level.EMERGENCY
|
||||
var _history: Array[Dictionary] = []
|
||||
|
||||
## Reasons must be short rule strings, e.g. "refused_polite_direction".
|
||||
func _init(p_max_level: int = Rules.Level.EMERGENCY) -> void:
|
||||
max_allowed_level = p_max_level
|
||||
|
||||
func can_act(level: int) -> bool:
|
||||
return level >= 0 and level <= max_allowed_level
|
||||
|
||||
## Move to a target level if it is allowed. Actions can jump directly
|
||||
## (e.g. skipping straight to a warning) but never above max_allowed_level.
|
||||
func advance_to(level: int, reason: String) -> bool:
|
||||
if not can_act(level):
|
||||
return false
|
||||
if level < current_level:
|
||||
# De-escalation is allowed (level can drop if the NPC complies).
|
||||
current_level = level
|
||||
else:
|
||||
current_level = level
|
||||
_history.append({
|
||||
"level": level,
|
||||
"reason": reason,
|
||||
"at": Time.get_ticks_msec(),
|
||||
})
|
||||
return true
|
||||
|
||||
func history() -> Array[Dictionary]:
|
||||
return _history
|
||||
@@ -0,0 +1,17 @@
|
||||
class_name EventBus
|
||||
extends RefCounted
|
||||
## Instance-based event bus (ADR-0005 spirit). Owned by ShiftCoordinator and
|
||||
## passed to subsystems that need to notify observers. Subsystems emit only if
|
||||
## a bus is assigned, so headless tests never depend on global state.
|
||||
|
||||
signal incident_recorded(incident: Dictionary)
|
||||
signal player_action(action: Dictionary)
|
||||
signal escalation_changed(payload: Dictionary)
|
||||
signal radio_call(call: Dictionary)
|
||||
signal responder_arrived(responder: Dictionary)
|
||||
signal npc_state_changed(payload: Dictionary)
|
||||
signal shift_state_changed(payload: Dictionary)
|
||||
signal reputation_changed(payload: Dictionary)
|
||||
signal crowd_changed(payload: Dictionary)
|
||||
signal access_decided(payload: Dictionary)
|
||||
signal shift_review_ready(review: Dictionary)
|
||||
@@ -0,0 +1,68 @@
|
||||
class_name EventScheduler
|
||||
extends RefCounted
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
||||
## Schedules routine traffic and scripted events for a shift according to the
|
||||
## scenario's event pool (ADR-0001, Shift Structure).
|
||||
|
||||
signal event_ready(event_data: Dictionary)
|
||||
|
||||
var rng := RandomNumberGenerator.new()
|
||||
var _scenario: VenueScenario = null
|
||||
var _weighted_pool: Array[Dictionary] = []
|
||||
|
||||
func _init() -> void:
|
||||
pass
|
||||
|
||||
func setup(scenario: VenueScenario, seed_value: int = 0) -> void:
|
||||
_scenario = scenario
|
||||
if seed_value != 0:
|
||||
rng.seed = seed_value
|
||||
_weighted_pool = []
|
||||
for event in scenario.event_pool:
|
||||
if event.get("enabled", true):
|
||||
_weighted_pool.append(event)
|
||||
|
||||
## Pull events due for the current elapsed time and phase.
|
||||
## Returns Array[Dictionary] of event data to spawn.
|
||||
func pull_due(elapsed_s: float, phase: int) -> Array[Dictionary]:
|
||||
var due: Array[Dictionary] = []
|
||||
if _scenario == null:
|
||||
return due
|
||||
for event in _weighted_pool:
|
||||
var time_range: Vector2 = event.get("time_range_s", Vector2(0, _scenario.shift_duration_s))
|
||||
if elapsed_s < time_range.x or elapsed_s > time_range.y:
|
||||
continue
|
||||
if event.get("phase", -1) != phase:
|
||||
continue
|
||||
var prob: float = event.get("probability", 0.0)
|
||||
var base: float = event.get("base_weight", 1.0)
|
||||
var spawn_rate: float = event.get("spawn_rate", 0.0)
|
||||
if spawn_rate > 0.0:
|
||||
# Rate-based spawning: emit at most one per second window.
|
||||
if rng.randf() < spawn_rate:
|
||||
if rng.randf() < prob:
|
||||
due.append(event.duplicate())
|
||||
else:
|
||||
if rng.randf() < prob * base:
|
||||
due.append(event.duplicate())
|
||||
return due
|
||||
|
||||
func fallback_routine_traffic(phase: int) -> Array[Dictionary]:
|
||||
## Cheap routine traffic so the street never feels empty.
|
||||
var out: Array[Dictionary] = []
|
||||
var pool := _routine_pool(phase)
|
||||
if pool.is_empty():
|
||||
return out
|
||||
for i in 3:
|
||||
if rng.randf() < 0.35:
|
||||
out.append(pool[rng.randi_range(0, pool.size() - 1)].duplicate())
|
||||
return out
|
||||
|
||||
func _routine_pool(phase: int) -> Array:
|
||||
var out: Array = []
|
||||
for event in _weighted_pool:
|
||||
if event.get("kind", "routine") == "routine" and event.get("phase", -1) == phase:
|
||||
out.append(event)
|
||||
return out
|
||||
@@ -0,0 +1,47 @@
|
||||
class_name FSM
|
||||
extends RefCounted
|
||||
## Minimal generic finite state machine used by NPCController and ShiftCoordinator.
|
||||
## Deterministic and headless-testable.
|
||||
|
||||
signal state_changed(node: Object, from_state: String, to_state: String)
|
||||
|
||||
var current_state: String = ""
|
||||
var _transitions: Dictionary = {} # state_name -> Array[{to, guard: Callable}]
|
||||
var _owner_node: Object = null
|
||||
|
||||
func _init(owner_node: Object = null) -> void:
|
||||
_owner_node = owner_node
|
||||
|
||||
## Register a transition. guard is an optional Callable(node) -> bool.
|
||||
func add_transition(from_state: String, to_state: String, guard: Callable = Callable()) -> void:
|
||||
if not _transitions.has(from_state):
|
||||
_transitions[from_state] = []
|
||||
_transitions[from_state].append({"to": to_state, "guard": guard})
|
||||
|
||||
func start(initial_state: String) -> void:
|
||||
current_state = initial_state
|
||||
state_changed.emit(_owner_node, "", initial_state)
|
||||
|
||||
## Attempt to move to `to_state`. Returns true when executed.
|
||||
func transition_to(to_state: String) -> bool:
|
||||
if current_state == to_state:
|
||||
return false
|
||||
var enabled: Array = _transitions.get(current_state, [])
|
||||
for t in enabled:
|
||||
if t["to"] != to_state:
|
||||
continue
|
||||
var guard: Callable = t["guard"]
|
||||
if not guard.is_valid() or guard.call(_owner_node):
|
||||
var from := current_state
|
||||
current_state = to_state
|
||||
state_changed.emit(_owner_node, from, to_state)
|
||||
return true
|
||||
return false
|
||||
|
||||
func can_transition_to(to_state: String) -> bool:
|
||||
for t in _transitions.get(current_state, []):
|
||||
if t["to"] == to_state:
|
||||
var guard: Callable = t["guard"]
|
||||
if not guard.is_valid() or guard.call(_owner_node):
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1,85 @@
|
||||
class_name IncidentRecorder
|
||||
extends RefCounted
|
||||
|
||||
const EventBus := preload("res://src/core/event_bus.gd")
|
||||
|
||||
var event_bus: EventBus = null
|
||||
|
||||
func set_event_bus(bus: EventBus) -> void:
|
||||
event_bus = bus
|
||||
## Structured incident history (ADR-0005). Records observations, player actions,
|
||||
## access evidence, escalation levels, responses, and outcomes for evidence,
|
||||
## reputation, crowd reactions, and shift review.
|
||||
|
||||
var incidents: Array[Dictionary] = []
|
||||
var _open_incidents: Dictionary = {} # npc_id -> incident dict
|
||||
|
||||
## Begin an incident for an NPC. Returns incident_id.
|
||||
func start_incident(npc_id: String, trigger: String) -> String:
|
||||
var incident := {
|
||||
"id": "inc_%s_%d" % [npc_id, Time.get_ticks_usec()],
|
||||
"npc_id": npc_id,
|
||||
"trigger": trigger,
|
||||
"opened_at_ms": Time.get_ticks_msec(),
|
||||
"events": [],
|
||||
"access_evidence": [],
|
||||
"escalation_history": [],
|
||||
"outcome": null,
|
||||
}
|
||||
_open_incidents[npc_id] = incident
|
||||
return incident["id"]
|
||||
|
||||
func add_event(npc_id: String, event: Dictionary) -> void:
|
||||
if not _open_incidents.has(npc_id):
|
||||
return
|
||||
var incident: Dictionary = _open_incidents[npc_id]
|
||||
event["at_ms"] = Time.get_ticks_msec()
|
||||
incident["events"].append(event)
|
||||
if event_bus:
|
||||
event_bus.incident_recorded.emit(incident.duplicate(true))
|
||||
|
||||
func add_access_evidence(npc_id: String, evidence: Dictionary) -> void:
|
||||
if not _open_incidents.has(npc_id):
|
||||
return
|
||||
var incident: Dictionary = _open_incidents[npc_id]
|
||||
incident["access_evidence"].append(evidence)
|
||||
if event_bus:
|
||||
event_bus.access_decided.emit(evidence.duplicate(true))
|
||||
incident["events"].append({
|
||||
"type": "access_check",
|
||||
"at_ms": Time.get_ticks_msec(),
|
||||
"evidence": evidence,
|
||||
})
|
||||
|
||||
func add_escalation(npc_id: String, level: int, reason: String) -> void:
|
||||
if not _open_incidents.has(npc_id):
|
||||
return
|
||||
var incident: Dictionary = _open_incidents[npc_id]
|
||||
incident["escalation_history"].append({
|
||||
"level": level,
|
||||
"reason": reason,
|
||||
"at_ms": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
## Close an incident. outcome: e.g. "peaceful", "escort_off_property",
|
||||
## "assisted", "responded", "custody". Returns the closed incident.
|
||||
func close_incident(npc_id: String, outcome: String) -> Dictionary:
|
||||
if not _open_incidents.has(npc_id):
|
||||
return {}
|
||||
var incident: Dictionary = _open_incidents[npc_id]
|
||||
incident["outcome"] = outcome
|
||||
incident["closed_at_ms"] = Time.get_ticks_msec()
|
||||
incidents.append(incident)
|
||||
_open_incidents.erase(npc_id)
|
||||
if event_bus:
|
||||
event_bus.incident_recorded.emit(incident.duplicate(true))
|
||||
return incident
|
||||
|
||||
func open_incidents_count() -> int:
|
||||
return _open_incidents.size()
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"incidents": incidents.duplicate(true),
|
||||
"open_count": _open_incidents.size(),
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
class_name NPCController
|
||||
extends RefCounted
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
const FSM := preload("res://src/core/fsm.gd")
|
||||
## Simulation-side NPC (PLAN.md: NPC Behavior Types). Owns intent, an FSM, and
|
||||
## reaction logic. The presentation layer adds a visual avatar and navigation.
|
||||
## RefCounted on purpose: headless tests and CI do not need a scene tree.
|
||||
|
||||
signal interacted(level: int, tone: int)
|
||||
|
||||
var npc_id: String = ""
|
||||
var profile: NpcProfile = null
|
||||
var intent: String = "roam" # enter | photo | sit | roam | deliver | film | trespass
|
||||
var fsm: FSM = null
|
||||
var rng := RandomNumberGenerator.new()
|
||||
|
||||
var highest_escalation_received: int = Rules.Level.OBSERVATION
|
||||
var leave_requested := false
|
||||
var reaction: int = Rules.Reaction.COMPLY
|
||||
var reaction_set := false
|
||||
|
||||
func _init() -> void:
|
||||
fsm = FSM.new(self)
|
||||
|
||||
## initial_state: APPROACH, AT_ENTRANCE, LOITERING, QUEUE, ARGUING
|
||||
func setup(p_id: String, p_profile: NpcProfile, p_intent: String = "roam", seed_value: int = 0, initial_state: String = "APPROACH") -> void:
|
||||
npc_id = p_id
|
||||
profile = p_profile
|
||||
intent = p_intent
|
||||
rng.seed = seed_value if seed_value != 0 else hash(npc_id)
|
||||
_build_fsm(initial_state)
|
||||
|
||||
func _build_fsm(initial_state: String) -> void:
|
||||
fsm.add_transition("APPROACH", "AT_ENTRANCE")
|
||||
fsm.add_transition("AT_ENTRANCE", "ARGUING")
|
||||
fsm.add_transition("AT_ENTRANCE", "LEAVING")
|
||||
fsm.add_transition("AT_ENTRANCE", "THREATENING")
|
||||
fsm.add_transition("AT_ENTRANCE", "FORCING_ENTRY")
|
||||
fsm.add_transition("APPROACH", "LEAVING")
|
||||
fsm.add_transition("ARGUING", "LEAVING")
|
||||
fsm.add_transition("ARGUING", "THREATENING")
|
||||
fsm.add_transition("ARGUING", "FORCING_ENTRY")
|
||||
fsm.add_transition("LEAVING", "LOITERING_DISTANT") # potential return later
|
||||
fsm.add_transition("LOITERING_DISTANT", "APPROACH")
|
||||
fsm.add_transition("THREATENING", "FORCING_ENTRY")
|
||||
fsm.start(initial_state)
|
||||
|
||||
func roll_reaction(level: int, tone: int) -> int:
|
||||
## Weighted pick from profile's reaction table for `level`, adjusted for tone.
|
||||
highest_escalation_received = maxi(highest_escalation_received, level)
|
||||
var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate()
|
||||
var tone_mod: float = profile.tone_modifiers.get(tone, 0.0)
|
||||
if tone_mod > 0.0 and weights.has(Rules.Reaction.COMPLY):
|
||||
# Shift weight mass toward compliance proportionally.
|
||||
var transferable: float = float(weights[Rules.Reaction.COMPLY]) * tone_mod
|
||||
weights[Rules.Reaction.COMPLY] += transferable * 0.5
|
||||
var roll_value := rng.randf()
|
||||
var total := 0.0
|
||||
for key in weights.keys():
|
||||
total += weights[key]
|
||||
if total <= 0.0:
|
||||
return Rules.Reaction.ARGUE
|
||||
var cursor := 0.0
|
||||
for key in weights.keys():
|
||||
cursor += float(weights[key])
|
||||
if roll_value < cursor / total:
|
||||
return key
|
||||
return Rules.Reaction.LEAVE
|
||||
|
||||
func apply_player_action(level: int, tone: int) -> Dictionary:
|
||||
## Returns reaction to the player's action and updates FSM when needed.
|
||||
var performed_reaction := roll_reaction(level, tone)
|
||||
reaction = performed_reaction
|
||||
reaction_set = true
|
||||
_update_state_for_reaction(performed_reaction)
|
||||
return {
|
||||
"npc_id": npc_id,
|
||||
"level_used": level,
|
||||
"tone": tone,
|
||||
"reaction": performed_reaction,
|
||||
"reaction_name": Rules.Reaction.find_key(performed_reaction),
|
||||
"new_state": fsm.current_state,
|
||||
}
|
||||
|
||||
func _update_state_for_reaction(r: int) -> void:
|
||||
match r:
|
||||
Rules.Reaction.COMPLY, Rules.Reaction.LEAVE:
|
||||
leave_requested = true
|
||||
fsm.transition_to("LEAVING")
|
||||
Rules.Reaction.ARGUE, Rules.Reaction.RECORD:
|
||||
fsm.transition_to("ARGUING")
|
||||
Rules.Reaction.THREATEN:
|
||||
fsm.transition_to("THREATENING")
|
||||
Rules.Reaction.FORCE_ENTRY:
|
||||
fsm.transition_to("FORCING_ENTRY")
|
||||
Rules.Reaction.RETURN_LATER:
|
||||
fsm.transition_to("LOITERING_DISTANT")
|
||||
_:
|
||||
pass
|
||||
|
||||
func decide_leave() -> bool:
|
||||
return leave_requested
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"id": npc_id,
|
||||
"name": profile.display_name if profile else "",
|
||||
"type": profile.npc_type if profile else -1,
|
||||
"intent": intent,
|
||||
"state": fsm.current_state,
|
||||
"highest_escalation": highest_escalation_received,
|
||||
"reaction": reaction,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
class_name NpcProfile
|
||||
extends Resource
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
## Authorable behavior profile for one kind of NPC (PLAN.md: NPC Behavior Types).
|
||||
|
||||
@export var id: String = ""
|
||||
@export var display_name: String = ""
|
||||
@export var npc_type: int = Rules.NpcType.TOURIST
|
||||
|
||||
## How difficult temperament is (0 = very calm, 1 = very hot-headed).
|
||||
## Affects how likely the NPC is to argue, point, or threaten at each level.
|
||||
@export_range(0.0, 1.0) var temperament: float = 0.25
|
||||
|
||||
## Base compliance: probability the NPC obeys a level-1 polite direction.
|
||||
## Venue/scenario recalculates effective compliance from profile + tone + level.
|
||||
@export_range(0.0, 1.0) var base_compliance: float = 0.8
|
||||
|
||||
## Probability weighting of the NP C's reaction per player escalation level.
|
||||
## keys: escalation level int -> { ReactionEnum: weight }
|
||||
## Missing entries fall back to the profile's `default_reactions`.
|
||||
@export var reactions_by_level: Dictionary = {}
|
||||
|
||||
@export var default_reactions: Dictionary = {
|
||||
Rules.Level.SIMPLE_DETERRENCE: {
|
||||
Rules.Reaction.COMPLY: 80,
|
||||
Rules.Reaction.IGNORE: 12,
|
||||
Rules.Reaction.ARGUE: 8,
|
||||
},
|
||||
Rules.Level.FORMAL_WARNING: {
|
||||
Rules.Reaction.COMPLY: 70,
|
||||
Rules.Reaction.LEAVE: 10,
|
||||
Rules.Reaction.ARGUE: 12,
|
||||
Rules.Reaction.RECORD: 8,
|
||||
},
|
||||
Rules.Level.BLOCKING: {
|
||||
Rules.Reaction.COMPLY: 55,
|
||||
Rules.Reaction.LEAVE: 15,
|
||||
Rules.Reaction.ARGUE: 20,
|
||||
Rules.Reaction.RECORD: 10,
|
||||
},
|
||||
Rules.Level.RADIO_ASSIST: {
|
||||
Rules.Reaction.LEAVE: 65,
|
||||
Rules.Reaction.ARGUE: 15,
|
||||
Rules.Reaction.FORCE_ENTRY: 20,
|
||||
},
|
||||
Rules.Level.EMERGENCY: {
|
||||
Rules.Reaction.FORCE_ENTRY: 30,
|
||||
Rules.Reaction.THREATEN: 40,
|
||||
Rules.Reaction.LEAVE: 30,
|
||||
},
|
||||
}
|
||||
|
||||
@export var tone_modifiers: Dictionary = {
|
||||
Rules.Tone.FRIENDLY: 0.10, # + compliance
|
||||
Rules.Tone.PROFESSIONAL: 0.00,
|
||||
Rules.Tone.FIRM: -0.05,
|
||||
Rules.Tone.SUSPICIOUS: -0.15,
|
||||
}
|
||||
|
||||
## Delay the NPC waits before reacting to the player (frames/seconds scaled).
|
||||
@export_range(0.1, 5.0) var reaction_delay: float = 0.6
|
||||
|
||||
## Dialogue lines per tone and intent. Falls back to generic lines when empty.
|
||||
@export var dialogue_lines: Dictionary = {}
|
||||
|
||||
func pickup_reactions_per_level(level: int) -> Dictionary:
|
||||
if reactions_by_level.has(level):
|
||||
return reactions_by_level[level]
|
||||
return default_reactions.get(level, {})
|
||||
@@ -0,0 +1,106 @@
|
||||
class_name RadioSystem
|
||||
extends RefCounted
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const EventBus := preload("res://src/core/event_bus.gd")
|
||||
## Radio call handling (PLAN.md: Level 4 radio assist, Level 5 emergency).
|
||||
## - Level 4: security assistance with a report form, arrival delay.
|
||||
## - Level 5: emergency radio, hold-to-confirm enforced by calling layer,
|
||||
## cooldown and battery limits to prevent spam.
|
||||
## - Incorrect/exaggerated reports damage credibility (recorded via reporter).
|
||||
|
||||
signal call_made(call_data: Dictionary)
|
||||
signal responder_arrived(call_data: Dictionary)
|
||||
|
||||
var battery: float = 100.0
|
||||
var max_battery: float = 100.0
|
||||
var emergency_cooldown_remaining: float = 0.0
|
||||
var emergency_cooldown_seconds: float = 120.0
|
||||
var battery_cost_emergency: float = 30.0
|
||||
var battery_cost_assist: float = 10.0
|
||||
|
||||
var accepting_calls := true
|
||||
var event_bus: EventBus = null
|
||||
|
||||
func set_event_bus(bus: EventBus) -> void:
|
||||
event_bus = bus
|
||||
|
||||
## report: { npc_id, location, description, behavior, weapons_seen, danger_level: 0..3 }
|
||||
func request_assistance(report: Dictionary) -> Dictionary:
|
||||
var result := {
|
||||
"ok": false,
|
||||
"level": Rules.Level.RADIO_ASSIST,
|
||||
"reason": "",
|
||||
"call": {},
|
||||
}
|
||||
if not accepting_calls:
|
||||
result.reason = "Radio not accepting calls"
|
||||
return result
|
||||
if battery < battery_cost_assist:
|
||||
result.reason = "battery depleted"
|
||||
return result
|
||||
battery -= battery_cost_assist
|
||||
var call := {
|
||||
"type": "assist",
|
||||
"report": report.duplicate(),
|
||||
"placed_at_ms": Time.get_ticks_msec(),
|
||||
"arrival_delay_s": 8.0,
|
||||
"responder": "security",
|
||||
}
|
||||
result.ok = true
|
||||
result.call = call
|
||||
call_made.emit(call)
|
||||
if event_bus:
|
||||
event_bus.radio_call.emit(call)
|
||||
return result
|
||||
|
||||
## Emergency call. The caller (UI) must handle hold-to-confirm; this applies
|
||||
## cooldown and battery limits.
|
||||
func request_emergency(report: Dictionary) -> Dictionary:
|
||||
var result := {
|
||||
"ok": false,
|
||||
"level": Rules.Level.EMERGENCY,
|
||||
"reason": "",
|
||||
"call": {},
|
||||
}
|
||||
if not accepting_calls:
|
||||
result.reason = "Radio not accepting calls"
|
||||
return result
|
||||
if emergency_cooldown_remaining > 0.0:
|
||||
result.reason = "Cooldown active (%.1fs)" % emergency_cooldown_remaining
|
||||
return result
|
||||
if battery < battery_cost_emergency:
|
||||
result.reason = "battery depleted"
|
||||
return result
|
||||
battery -= battery_cost_emergency
|
||||
emergency_cooldown_remaining = emergency_cooldown_seconds
|
||||
var call := {
|
||||
"type": "emergency",
|
||||
"report": report.duplicate(),
|
||||
"placed_at_ms": Time.get_ticks_msec(),
|
||||
"arrival_delay_s": 15.0,
|
||||
"responder": "armed_responder",
|
||||
}
|
||||
result.ok = true
|
||||
result.call = call
|
||||
call_made.emit(call)
|
||||
if event_bus:
|
||||
event_bus.radio_call.emit(call)
|
||||
return result
|
||||
|
||||
## Report inaccuracies deserve a reputation hit. Caller uses this helper.
|
||||
static func credibility_hits(report: Dictionary, truth: Dictionary) -> int:
|
||||
var hits := 0
|
||||
if report.get("weapons_seen", false) != truth.get("weapons_seen", false):
|
||||
hits += 1
|
||||
var danger: int = report.get("danger_level", 0)
|
||||
var actual_danger: int = truth.get("danger_level", 0)
|
||||
if danger != actual_danger:
|
||||
hits += 1
|
||||
return hits
|
||||
|
||||
## Advance cooldown; called by the simulation tick.
|
||||
func tick(delta: float) -> void:
|
||||
if emergency_cooldown_remaining > 0.0:
|
||||
emergency_cooldown_remaining = maxf(0.0, emergency_cooldown_remaining - delta)
|
||||
# Battery could slowly recharge over a shift; keep it static for now.
|
||||
@@ -0,0 +1,54 @@
|
||||
class_name ReputationEvaluator
|
||||
extends RefCounted
|
||||
## Evaluates player performance (PLAN.md: Reputation Meter).
|
||||
## Scoring weights are data so they can be tuned per venue.
|
||||
|
||||
const EventBus := preload("res://src/core/event_bus.gd")
|
||||
|
||||
## rating: float starting 50 (out of 100). Errors subtract, though marks add.
|
||||
var rating: float = 70.0
|
||||
var _modifiers: Array[Dictionary] = []
|
||||
var event_bus: EventBus = null
|
||||
|
||||
func set_event_bus(bus: EventBus) -> void:
|
||||
event_bus = bus
|
||||
|
||||
## Apply a reputation change.
|
||||
## category: "peaceful", "force", "false_report", "accuracy", "complaint",
|
||||
## "security_failure", "property_damage", "access_judgment", "crowd"
|
||||
func apply(_category: String, delta: float, reason: String) -> void:
|
||||
# Clamp range: 0..100.
|
||||
var clamped := clampf(rating + delta, 0.0, 100.0)
|
||||
var applied: float = clamped - rating
|
||||
rating = clamped
|
||||
_modifiers.append({
|
||||
"category": _category,
|
||||
"delta": applied,
|
||||
"reason": reason,
|
||||
"at_ms": Time.get_ticks_msec(),
|
||||
})
|
||||
if event_bus:
|
||||
event_bus.reputation_changed.emit({
|
||||
"rating": rating,
|
||||
"delta": applied,
|
||||
"reason": reason,
|
||||
"category": _category,
|
||||
})
|
||||
|
||||
static func grade_for(rating_value: float) -> String:
|
||||
if rating_value >= 90.0:
|
||||
return "A"
|
||||
if rating_value >= 75.0:
|
||||
return "B"
|
||||
if rating_value >= 60.0:
|
||||
return "C"
|
||||
if rating_value >= 40.0:
|
||||
return "D"
|
||||
return "F"
|
||||
|
||||
func summary() -> Dictionary:
|
||||
return {
|
||||
"rating": rating,
|
||||
"grade": grade_for(rating),
|
||||
"modifiers": _modifiers.duplicate(true),
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
class_name Rules
|
||||
## Shared constants and helpers for the simulation rules.
|
||||
|
||||
func _init() -> void:
|
||||
push_error("Rules is a static utility class and must not be instantiated.")
|
||||
|
||||
## Escalation levels (0..5). Names must stay in sync with PLAN.md.
|
||||
enum Level {
|
||||
OBSERVATION = 0,
|
||||
SIMPLE_DETERRENCE = 1,
|
||||
FORMAL_WARNING = 2,
|
||||
BLOCKING = 3,
|
||||
RADIO_ASSIST = 4,
|
||||
EMERGENCY = 5,
|
||||
}
|
||||
|
||||
const LEVEL_NAMES := {
|
||||
Level.OBSERVATION: "Observation",
|
||||
Level.SIMPLE_DETERRENCE: "Simple Deterrence",
|
||||
Level.FORMAL_WARNING: "Formal Warning",
|
||||
Level.BLOCKING: "Blocking",
|
||||
Level.RADIO_ASSIST: "Radio Assist",
|
||||
Level.EMERGENCY: "Emergency",
|
||||
}
|
||||
|
||||
## NPC behavioral archetypes from PLAN.md.
|
||||
enum NpcType {
|
||||
TOURIST,
|
||||
PERFORMER,
|
||||
INFLUENCER,
|
||||
CONFUSED_VISITOR,
|
||||
INTOXICATED,
|
||||
TRESPASSER,
|
||||
VIOLENT_THREAT,
|
||||
MENTAL_CRISIS, # "51/50" style mental-health crisis character
|
||||
AUTHORIZED_STAFF,
|
||||
AUTHORIZED_GUEST,
|
||||
DELIVERY,
|
||||
RESPONDER,
|
||||
}
|
||||
|
||||
const NPC_TYPE_NAMES := {
|
||||
NpcType.TOURIST: "Tourist",
|
||||
NpcType.PERFORMER: "Street Performer",
|
||||
NpcType.INFLUENCER: "Influencer",
|
||||
NpcType.CONFUSED_VISITOR: "Confused Visitor",
|
||||
NpcType.INTOXICATED: "Intoxicated Pedestrian",
|
||||
NpcType.TRESPASSER: "Trespasser",
|
||||
NpcType.VIOLENT_THREAT: "Violent Threat",
|
||||
NpcType.MENTAL_CRISIS: "Mental Health Crisis",
|
||||
NpcType.AUTHORIZED_STAFF: "Authorized Staff",
|
||||
NpcType.AUTHORIZED_GUEST: "Authorized Guest",
|
||||
NpcType.DELIVERY: "Delivery",
|
||||
NpcType.RESPONDER: "Responder",
|
||||
}
|
||||
|
||||
## Access artifact kinds used by AccessVerifier.
|
||||
enum AccessKind {
|
||||
NONE = -1,
|
||||
RECOGNITION = 0,
|
||||
WRISTBAND = 1,
|
||||
ID = 2,
|
||||
TICKET = 3,
|
||||
CREDENTIAL = 4,
|
||||
GUEST_LIST = 5,
|
||||
}
|
||||
|
||||
const ACCESS_KIND_NAMES := {
|
||||
AccessKind.RECOGNITION: "Recognition",
|
||||
AccessKind.WRISTBAND: "Wristband",
|
||||
AccessKind.ID: "ID",
|
||||
AccessKind.TICKET: "Ticket",
|
||||
AccessKind.CREDENTIAL: "Credential",
|
||||
AccessKind.GUEST_LIST: "Guest List",
|
||||
}
|
||||
|
||||
## Artifact validity states.
|
||||
enum ArtifactStatus {
|
||||
VALID = 0,
|
||||
EXPIRED = 1,
|
||||
COUNTERFEIT = 2,
|
||||
MISMATCHED = 3,
|
||||
MISSING = 4,
|
||||
AMBIGUOUS = 5,
|
||||
}
|
||||
|
||||
const ARTIFACT_STATUS_NAMES := {
|
||||
ArtifactStatus.VALID: "Valid",
|
||||
ArtifactStatus.EXPIRED: "Expired",
|
||||
ArtifactStatus.COUNTERFEIT: "Counterfeit",
|
||||
ArtifactStatus.MISMATCHED: "Mismatched",
|
||||
ArtifactStatus.MISSING: "Missing",
|
||||
ArtifactStatus.AMBIGUOUS: "Ambiguous",
|
||||
}
|
||||
|
||||
## NPC reaction outcomes after player actions.
|
||||
enum Reaction {
|
||||
COMPLY = 0,
|
||||
LEAVE = 1,
|
||||
ARGUE = 2,
|
||||
IGNORE = 3,
|
||||
RECORD = 4,
|
||||
THREATEN = 5,
|
||||
RETURN_LATER = 6,
|
||||
FORCE_ENTRY = 7,
|
||||
}
|
||||
|
||||
## Player dialogue tones.
|
||||
enum Tone {
|
||||
PROFESSIONAL = 0,
|
||||
FRIENDLY = 1,
|
||||
FIRM = 2,
|
||||
SUSPICIOUS = 3,
|
||||
}
|
||||
|
||||
const TONE_NAMES := {
|
||||
Tone.PROFESSIONAL: "Professional",
|
||||
Tone.FRIENDLY: "Friendly",
|
||||
Tone.FIRM: "Firm",
|
||||
Tone.SUSPICIOUS: "Suspicious",
|
||||
}
|
||||
|
||||
## Shift phases by time of day.
|
||||
enum Phase {
|
||||
EARLY = 0,
|
||||
MIDDAY = 1,
|
||||
EVENING = 2,
|
||||
LATE = 3,
|
||||
}
|
||||
|
||||
const PHASE_NAMES := {
|
||||
Phase.EARLY: "Early",
|
||||
Phase.MIDDAY: "Midday",
|
||||
Phase.EVENING: "Evening",
|
||||
Phase.LATE: "Late",
|
||||
}
|
||||
|
||||
static func level_name(level: int) -> String:
|
||||
return LEVEL_NAMES.get(level, "Unknown")
|
||||
@@ -0,0 +1,135 @@
|
||||
class_name ShiftCoordinator
|
||||
extends Node
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const EventBus := preload("res://src/core/event_bus.gd")
|
||||
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
const IncidentRecorder := preload("res://src/core/incident_recorder.gd")
|
||||
const ReputationEvaluator := preload("res://src/core/reputation_evaluator.gd")
|
||||
const RadioSystem := preload("res://src/core/radio_system.gd")
|
||||
const CrowdSystem := preload("res://src/core/crowd_system.gd")
|
||||
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 shift_started(payload: Dictionary)
|
||||
signal shift_ended(review: Dictionary)
|
||||
signal phase_changed(phase: int)
|
||||
|
||||
var scenario: VenueScenario = null
|
||||
var duration_s: float = 900.0
|
||||
var elapsed_s: float = 0.0
|
||||
var current_phase: int = Rules.Phase.EARLY
|
||||
var running := false
|
||||
|
||||
var event_bus := EventBus.new()
|
||||
var recorder := IncidentRecorder.new()
|
||||
var reputation := ReputationEvaluator.new()
|
||||
var radio := RadioSystem.new()
|
||||
var crowd := CrowdSystem.new()
|
||||
var scheduler := EventScheduler.new()
|
||||
|
||||
var spawned_count := 0
|
||||
var spawn_seed := 0
|
||||
var _shift_finished := false
|
||||
|
||||
func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int = 0) -> void:
|
||||
scenario = p_scenario
|
||||
duration_s = p_duration_s
|
||||
spawn_seed = p_seed
|
||||
scheduler.setup(scenario, p_seed)
|
||||
event_bus = EventBus.new()
|
||||
radio = RadioSystem.new()
|
||||
reputation = ReputationEvaluator.new()
|
||||
crowd = CrowdSystem.new()
|
||||
recorder.set_event_bus(event_bus)
|
||||
radio.set_event_bus(event_bus)
|
||||
reputation.set_event_bus(event_bus)
|
||||
crowd.set_event_bus(event_bus)
|
||||
|
||||
func start() -> void:
|
||||
if scenario == null:
|
||||
push_error("ShiftCoordinator.start() requires a scenario.")
|
||||
return
|
||||
running = true
|
||||
_shift_finished = false
|
||||
elapsed_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})
|
||||
|
||||
func stop() -> void:
|
||||
running = false
|
||||
_finish_shift()
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
if not running:
|
||||
return
|
||||
elapsed_s += delta
|
||||
radio.tick(delta)
|
||||
var new_phase := _phase_for_time(elapsed_s)
|
||||
if new_phase != current_phase:
|
||||
current_phase = new_phase
|
||||
phase_changed.emit(current_phase)
|
||||
event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase})
|
||||
if elapsed_s >= duration_s:
|
||||
_finish_shift()
|
||||
return
|
||||
_spawn_due(delta)
|
||||
|
||||
func _phase_for_time(t: float) -> int:
|
||||
if scenario == null or scenario.shift_phases.is_empty():
|
||||
return Rules.Phase.EARLY
|
||||
var last_phase := Rules.Phase.EARLY
|
||||
var keys := scenario.shift_phases.keys()
|
||||
keys.sort()
|
||||
for phase in keys:
|
||||
var start_s: float = scenario.shift_phases[phase].get("start_s", 0.0)
|
||||
if t >= start_s:
|
||||
last_phase = phase
|
||||
return last_phase
|
||||
|
||||
func _spawn_due(delta: float) -> void:
|
||||
var due := scheduler.pull_due(elapsed_s, current_phase)
|
||||
var fallback := scheduler.fallback_routine_traffic(current_phase)
|
||||
var all := due
|
||||
all.append_array(fallback)
|
||||
for event in all:
|
||||
spawned_count += 1
|
||||
var npc_id := "%s_%d" % [scenario.id, spawned_count]
|
||||
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)
|
||||
|
||||
func _finish_shift() -> void:
|
||||
if _shift_finished:
|
||||
return
|
||||
_shift_finished = true
|
||||
running = false
|
||||
var review := build_review()
|
||||
event_bus.shift_state_changed.emit({"running": false, "review": review})
|
||||
shift_ended.emit(review)
|
||||
event_bus.shift_review_ready.emit(review)
|
||||
|
||||
func build_review() -> Dictionary:
|
||||
return {
|
||||
"scenario_id": scenario.id if scenario else "",
|
||||
"duration_s": duration_s,
|
||||
"elapsed_s": elapsed_s,
|
||||
"phase_at_end": current_phase,
|
||||
"spawned_count": spawned_count,
|
||||
"incidents": recorder.to_dict(),
|
||||
"reputation": reputation.summary(),
|
||||
"crowd": crowd.summary(),
|
||||
"radio": {
|
||||
"battery": radio.battery,
|
||||
"emergency_cooldown_s": radio.emergency_cooldown_remaining,
|
||||
},
|
||||
"completed_at_ms": Time.get_ticks_msec(),
|
||||
}
|
||||
|
||||
func spawn_context_dict(profile: NpcProfile, intent: String) -> Dictionary:
|
||||
return {"profile": profile, "intent": intent}
|
||||
@@ -0,0 +1,21 @@
|
||||
class_name SignalBus
|
||||
extends RefCounted
|
||||
## Static signal bus (ADR-0005 spirit: decoupled, deterministic).
|
||||
## Unlike an autoload, `SignalBus` resolves at parse time through preloads,
|
||||
## so it works identically under `--script`, in headless CI, and in the game.
|
||||
|
||||
static signal incident_recorded(incident: Dictionary)
|
||||
static signal player_action(action: Dictionary)
|
||||
static signal escalation_changed(payload: Dictionary)
|
||||
static signal radio_call(call: Dictionary)
|
||||
static signal responder_arrived(responder: Dictionary)
|
||||
static signal npc_state_changed(payload: Dictionary)
|
||||
static signal shift_state_changed(payload: Dictionary)
|
||||
static signal reputation_changed(payload: Dictionary)
|
||||
static signal crowd_changed(payload: Dictionary)
|
||||
static signal access_decided(payload: Dictionary)
|
||||
static signal shift_review_ready(review: Dictionary)
|
||||
static signal anything_happened(value: String)
|
||||
|
||||
func _init() -> void:
|
||||
push_error("SignalBus is a static utility class; use SignalBus.<signal> directly.")
|
||||
@@ -0,0 +1,49 @@
|
||||
class_name VenueScenario
|
||||
extends Resource
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const AccessPolicy := preload("res://src/core/access_policy.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
## Data-driven venue definition (ADR-0001). Provides locations, rules,
|
||||
## NPC pools, event pools, equipment, and shift phases.
|
||||
|
||||
@export var id: String = ""
|
||||
@export var display_name: String = ""
|
||||
@export_multiline var description: String = ""
|
||||
|
||||
@export var access_policy: AccessPolicy = AccessPolicy.new()
|
||||
|
||||
## Equipment available at this venue.
|
||||
@export var equipment: Array[String] = [] # e.g. "wristband_policy", "id_check", "wand", "flashlight"
|
||||
|
||||
## Named spots: "guard_post", "entrance", "steps", "sidewalk", "queue_area".
|
||||
@export var locations: Dictionary = {}
|
||||
|
||||
## NPC archetype definitions that appear in this scenario.
|
||||
@export var npc_profiles: Array[NpcProfile] = []
|
||||
|
||||
## Event pool. Each event: { id, phase: Rules.Phase, kind: "routine"|"scripted",
|
||||
## npc_type: int, intent: String, weight: float, probability: float,
|
||||
## time_range_s: Vector2, requires_emergency: bool }
|
||||
@export var event_pool: Array[Dictionary] = []
|
||||
|
||||
## Shift setup: phases map Rules.Phase -> { start_s, end_s, spawn_rate }.
|
||||
@export var shift_phases: Dictionary = {}
|
||||
|
||||
## Default duration of one shift in seconds (PLAN.md: 10-15 minutes).
|
||||
@export var shift_duration_s: float = 900.0
|
||||
|
||||
@export var policy_summary: String = ""
|
||||
|
||||
func profile_for(npc_type: int) -> NpcProfile:
|
||||
for p in npc_profiles:
|
||||
if p.npc_type == npc_type:
|
||||
return p
|
||||
return null
|
||||
|
||||
func events_in_phase(phase: int) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for event in event_pool:
|
||||
if event.get("phase", -1) == phase:
|
||||
result.append(event)
|
||||
return result
|
||||
Reference in new issue
Block a user