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

+22
View File
@@ -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]
+32
View File
@@ -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)
+123
View File
@@ -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
+53
View File
@@ -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,
}
+37
View File
@@ -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
+17
View File
@@ -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)
+68
View File
@@ -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
+47
View File
@@ -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
+85
View File
@@ -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(),
}
+115
View File
@@ -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,
}
+70
View File
@@ -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, {})
+106
View File
@@ -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.
+54
View File
@@ -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),
}
+139
View File
@@ -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")
+135
View File
@@ -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}
+21
View File
@@ -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.")
+49
View File
@@ -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