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

+39
View File
@@ -0,0 +1,39 @@
extends Node
## Autoload for local persistence (JSON in user://). Wrapped in an autoload so UI
## nodes never touch the filesystem directly.
const SHIFT_RESULTS_FILE := "shift_results.json"
const SETTINGS_FILE := "settings.json"
func _load_json(file_name: String) -> Variant:
var path := "user://" + file_name
if not FileAccess.file_exists(path):
return null
var text := FileAccess.get_file_as_string(path)
if text.is_empty():
return null
var parsed = JSON.parse_string(text)
return parsed
func _save_json(file_name: String, data: Variant) -> Error:
var path := "user://" + file_name
var text := JSON.stringify(data, "\t")
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
return FileAccess.get_open_error()
f.store_string(text)
f.close()
return OK
## Load previous shift results. Returns Array of Dictionaries or [].
func load_shift_results() -> Array:
var data = _load_json(SHIFT_RESULTS_FILE)
if data is Array:
return data
return []
## Append one shift result and save. Returns Error.
func save_shift_result(result: Dictionary) -> Error:
var results := load_shift_results()
results.append(result)
return _save_json(SHIFT_RESULTS_FILE, results)
+509
View File
@@ -0,0 +1,509 @@
class_name ScenarioFactory
## Builds all authored venue scenarios
const Rules := preload("res://src/core/rules.gd")
const VenueScenario := preload("res://src/core/venue_scenario.gd")
const AccessPolicy := preload("res://src/core/access_policy.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
## Every venue derives from shared NPC norms and overrides venue rules.
const PLAN_ACCESS_SUMMARY_BUILDING := "Private property. Entry requires recognition or a valid wristband."
const PLAN_ACCESS_SUMMARY_JOKE_SHOP := "Government ID and a valid ticket or reservation required. Carry items are screened."
const PLAN_ACCESS_SUMMARY_TV_STUDIO := "Entry requires a wristband, credential, ticket, or guest-list confirmation."
static func shared_profiles() -> Array[NpcProfile]:
var profiles: Array[NpcProfile] = []
# --- Small, low-risk crowd -------------------------------------------------
var tourist := NpcProfile.new()
tourist.id = "tourist"
tourist.display_name = "Tourist"
tourist.npc_type = Rules.NpcType.TOURIST
tourist.temperament = 0.1
tourist.base_compliance = 0.9
tourist.dialogue_lines = {
Rules.Tone.FRIENDLY: [
"Oh sure, sorry, just taking a picture.",
"Is there a better spot to stand?",
],
Rules.Tone.PROFESSIONAL: ["Understood, moving."],
}
profiles.append(tourist)
var confused := NpcProfile.new()
confused.id = "confused_visitor"
confused.display_name = "Confused Visitor"
confused.npc_type = Rules.NpcType.CONFUSED_VISITOR
confused.temperament = 0.2
confused.base_compliance = 0.85
confused.dialogue_lines = {
Rules.Tone.FRIENDLY: [
"Wait, this isn't the entrance?",
"Sorry about that, I'm a bit lost.",
],
}
profiles.append(confused)
var delivery := NpcProfile.new()
delivery.id = "delivery"
delivery.display_name = "Delivery Worker"
delivery.npc_type = Rules.NpcType.DELIVERY
delivery.temperament = 0.25
delivery.base_compliance = 0.8
delivery.dialogue_lines = {
Rules.Tone.PROFESSIONAL: ["Where should I leave this?"],
}
profiles.append(delivery)
var performer := NpcProfile.new()
performer.id = "street_performer"
performer.display_name = "Street Performer"
performer.npc_type = Rules.NpcType.PERFORMER
performer.temperament = 0.45
performer.base_compliance = 0.55
performer.reactions_by_level = {
Rules.Level.SIMPLE_DETERRENCE: {
Rules.Reaction.COMPLY: 55,
Rules.Reaction.IGNORE: 30,
Rules.Reaction.ARGUE: 15,
},
}
profiles.append(performer)
var influencer := NpcProfile.new()
influencer.id = "influencer"
influencer.display_name = "Influencer"
influencer.npc_type = Rules.NpcType.INFLUENCER
influencer.temperament = 0.5
influencer.base_compliance = 0.5
influencer.reactions_by_level = {
Rules.Level.SIMPLE_DETERRENCE: {
Rules.Reaction.COMPLY: 40,
Rules.Reaction.IGNORE: 30,
Rules.Reaction.RECORD: 20,
Rules.Reaction.ARGUE: 10,
},
Rules.Level.FORMAL_WARNING: {
Rules.Reaction.RECORD: 45,
Rules.Reaction.ARGUE: 25,
Rules.Reaction.COMPLY: 30,
},
}
influencer.dialogue_lines = {
Rules.Tone.SUSPICIOUS: [
"This is public sidewalk, you can't stop me from filming.",
],
}
profiles.append(influencer)
# --- Higher-risk crowd ------------------------------------------------------
var intoxicated := NpcProfile.new()
intoxicated.id = "intoxicated"
intoxicated.display_name = "Intoxicated Pedestrian"
intoxicated.npc_type = Rules.NpcType.INTOXICATED
intoxicated.temperament = 0.6
intoxicated.base_compliance = 0.4
intoxicated.reaction_delay = 1.8
intoxicated.reactions_by_level = {
Rules.Level.SIMPLE_DETERRENCE: {
Rules.Reaction.COMPLY: 30,
Rules.Reaction.IGNORE: 50,
Rules.Reaction.ARGUE: 20,
},
Rules.Level.FORMAL_WARNING: {
Rules.Reaction.COMPLY: 40,
Rules.Reaction.ARGUE: 35,
Rules.Reaction.RECORD: 10,
Rules.Reaction.IGNORE: 15,
},
}
profiles.append(intoxicated)
var trespasser := NpcProfile.new()
trespasser.id = "trespasser"
trespasser.display_name = "Trespasser"
trespasser.npc_type = Rules.NpcType.TRESPASSER
trespasser.temperament = 0.55
trespasser.base_compliance = 0.35
trespasser.reactions_by_level = {
Rules.Level.SIMPLE_DETERRENCE: {
Rules.Reaction.IGNORE: 45,
Rules.Reaction.ARGUE: 25,
Rules.Reaction.COMPLY: 30,
},
Rules.Level.FORMAL_WARNING: {
Rules.Reaction.ARGUE: 35,
Rules.Reaction.RETURN_LATER: 25,
Rules.Reaction.COMPLY: 40,
},
Rules.Level.BLOCKING: {
Rules.Reaction.LEAVE: 40,
Rules.Reaction.ARGUE: 30,
Rules.Reaction.RETURN_LATER: 20,
Rules.Reaction.FORCE_ENTRY: 10,
},
Rules.Level.RADIO_ASSIST: {
Rules.Reaction.LEAVE: 70,
Rules.Reaction.FORCE_ENTRY: 30,
},
Rules.Level.EMERGENCY: {
Rules.Reaction.FORCE_ENTRY: 20,
Rules.Reaction.THREATEN: 50,
Rules.Reaction.LEAVE: 30,
},
}
trespasser.dialogue_lines = {
Rules.Tone.SUSPICIOUS: [
"I'm just here to see a friend, come on.",
"I'll be inside for ten seconds.",
],
}
profiles.append(trespasser)
var mental_crisis := NpcProfile.new()
mental_crisis.id = "mental_crisis"
mental_crisis.display_name = "Person in Crisis"
mental_crisis.npc_type = Rules.NpcType.MENTAL_CRISIS
mental_crisis.temperament = 0.7
mental_crisis.base_compliance = 0.3
mental_crisis.reaction_delay = 2.4
mental_crisis.reactions_by_level = {
Rules.Level.SIMPLE_DETERRENCE: {
Rules.Reaction.IGNORE: 55,
Rules.Reaction.COMPLY: 25,
Rules.Reaction.ARGUE: 20,
},
Rules.Level.FORMAL_WARNING: {
Rules.Reaction.IGNORE: 30,
Rules.Reaction.ARGUE: 35,
Rules.Reaction.COMPLY: 25,
Rules.Reaction.THREATEN: 10,
},
Rules.Level.BLOCKING: {
Rules.Reaction.THREATEN: 30,
Rules.Reaction.ARGUE: 30,
Rules.Reaction.COMPLY: 25,
Rules.Reaction.LEAVE: 15,
},
Rules.Level.RADIO_ASSIST: {
Rules.Reaction.LEAVE: 40,
Rules.Reaction.THREATEN: 35,
Rules.Reaction.FORCE_ENTRY: 25,
},
Rules.Level.EMERGENCY: {
Rules.Reaction.FORCE_ENTRY: 15,
Rules.Reaction.THREATEN: 60,
Rules.Reaction.LEAVE: 25,
},
}
profiles.append(mental_crisis)
var violent_threat := NpcProfile.new()
violent_threat.id = "violent_threat"
violent_threat.display_name = "Violent Threat"
violent_threat.npc_type = Rules.NpcType.VIOLENT_THREAT
violent_threat.temperament = 0.95
violent_threat.base_compliance = 0.05
violent_threat.reactions_by_level = {
Rules.Level.SIMPLE_DETERRENCE: {
Rules.Reaction.THREATEN: 30,
Rules.Reaction.IGNORE: 45,
Rules.Reaction.ARGUE: 25,
},
Rules.Level.FORMAL_WARNING: {
Rules.Reaction.THREATEN: 45,
Rules.Reaction.FORCE_ENTRY: 25,
Rules.Reaction.ARGUE: 30,
},
Rules.Level.BLOCKING: {
Rules.Reaction.FORCE_ENTRY: 35,
Rules.Reaction.THREATEN: 45,
Rules.Reaction.LEAVE: 20,
},
Rules.Level.RADIO_ASSIST: {
Rules.Reaction.FORCE_ENTRY: 55,
Rules.Reaction.THREATEN: 30,
Rules.Reaction.LEAVE: 15,
},
Rules.Level.EMERGENCY: {
Rules.Reaction.FORCE_ENTRY: 30,
Rules.Reaction.THREATEN: 40,
Rules.Reaction.LEAVE: 30,
},
}
profiles.append(violent_threat)
# --- Authorized crowd ---------------------------------------------------------
var staff := NpcProfile.new()
staff.id = "authorized_staff"
staff.display_name = "Staff Member"
staff.npc_type = Rules.NpcType.AUTHORIZED_STAFF
staff.temperament = 0.15
staff.base_compliance = 1.0
staff.default_reactions = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 100 } }
profiles.append(staff)
var guest := NpcProfile.new()
guest.id = "authorized_guest"
guest.display_name = "Authorized Guest"
guest.npc_type = Rules.NpcType.AUTHORIZED_GUEST
guest.temperament = 0.2
guest.base_compliance = 0.95
guest.default_reactions = {
Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 92, Rules.Reaction.ARGUE: 8 },
Rules.Level.FORMAL_WARNING: { Rules.Reaction.COMPLY: 88, Rules.Reaction.ARGUE: 12 },
}
profiles.append(guest)
return profiles
# --- Venue builders --------------------------------------------------------------
static func hollywood_building() -> VenueScenario:
var scenario := VenueScenario.new()
scenario.id = "hollywood_building"
scenario.display_name = "Hollywood Boulevard Building"
scenario.description = "Steps and entrance of a private building off Hollywood Boulevard."
scenario.shift_duration_s = 900.0
var policy := AccessPolicy.new()
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
policy.policy_summary = PLAN_ACCESS_SUMMARY_BUILDING
scenario.access_policy = policy
scenario.equipment = ["wristband_policy", "flashlight", "radio"]
scenario.locations = {
"guard_post": Vector3(0, 0, -3),
"entrance": Vector3(0, 0, 0),
"steps_end": Vector3(0, 0, 3),
"sidewalk": Vector3(0, 0, 9),
"queue_area": Vector3(0, 0, 6),
}
scenario.shift_phases = {
Rules.Phase.EARLY: {"start_s": 0.0},
Rules.Phase.MIDDAY: {"start_s": 300.0},
Rules.Phase.EVENING: {"start_s": 600.0},
}
scenario.npc_profiles = _filter_profiles([
"tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest",
"street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat",
])
scenario.event_pool = [
_building_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35),
_building_routine("tourist_sit", Rules.NpcType.TOURIST, "sit", Vector2(0, 900), 0.20),
_building_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25),
_building_routine("delivery_drop", Rules.NpcType.DELIVERY, "deliver", Vector2(0, 600), 0.30),
_building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35),
_building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25),
_building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.18),
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.20),
_building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15),
_building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.12),
{
"id": "crisis_event",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.MENTAL_CRISIS,
"intent": "crisis",
"time_range_s": Vector2(640, 740),
"probability": 0.5,
"base_weight": 1.0,
},
{
"id": "violent_emergency_event",
"phase": Rules.Phase.LATE,
"kind": "scripted",
"npc_type": Rules.NpcType.VIOLENT_THREAT,
"intent": "threat",
"time_range_s": Vector2(760, 880),
"probability": 0.28,
"base_weight": 1.0,
"requires_emergency": true,
},
]
return scenario
static func joke_shop() -> VenueScenario:
var scenario := VenueScenario.new()
scenario.id = "joke_shop"
scenario.display_name = "The Joke Shop"
scenario.description = "Comedy club door: IDs, reservations, and wand screening."
scenario.shift_duration_s = 900.0
var policy := AccessPolicy.new()
policy.required_artifacts = {
Rules.AccessKind.ID: true,
Rules.AccessKind.TICKET: true,
}
policy.minimum_age = 21
policy.require_id = true
policy.wand_screening_required = true
policy.policy_summary = PLAN_ACCESS_SUMMARY_JOKE_SHOP
scenario.access_policy = policy
scenario.equipment = ["id_check", "guest_list", "wand", "radio"]
scenario.locations = {
"guard_post": Vector3(0, 0, -3),
"entrance": Vector3(0, 0, 0),
"door_area": Vector3(0, 0, 3),
"queue_area": Vector3(0, 0, 9),
}
scenario.shift_phases = {
Rules.Phase.EARLY: {"start_s": 0.0},
Rules.Phase.MIDDAY: {"start_s": 300.0},
Rules.Phase.EVENING: {"start_s": 600.0},
}
scenario.npc_profiles = _filter_profiles([
"tourist", "confused_visitor", "authorized_guest", "authorized_staff",
"influencer", "intoxicated", "trespasser", "violent_threat",
])
scenario.event_pool = [
_joke_guest("guest_valid", true, true, Vector2(0, 900), 0.30),
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
_joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12),
_joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12),
_joke_staff_entry(Vector2(0, 900), 0.25),
_joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15),
_joke_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.15),
{
"id": "joke_disruptive",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.INTOXICATED,
"intent": "force_entry",
"time_range_s": Vector2(650, 830),
"probability": 0.5,
"base_weight": 1.0,
},
]
return scenario
static func tv_studio() -> VenueScenario:
var scenario := VenueScenario.new()
scenario.id = "tv_studio"
scenario.display_name = "Hollywood Blvd Late Night TV Studio"
scenario.description = "Crowd, celebrities, and staff around a late-night studio."
scenario.shift_duration_s = 900.0
var policy := AccessPolicy.new()
# OR-style admission: credential, wristband, guest list, or audience ticket.
policy.required_artifacts = {
Rules.AccessKind.CREDENTIAL: true,
Rules.AccessKind.WRISTBAND: true,
Rules.AccessKind.TICKET: true,
Rules.AccessKind.GUEST_LIST: true,
}
policy.grant_if_any_required = true
policy.policy_summary = PLAN_ACCESS_SUMMARY_TV_STUDIO
scenario.access_policy = policy
scenario.equipment = ["wristband_policy", "guest_list", "radio", "flashlight"]
scenario.locations = {
"guard_post": Vector3(0, 0, -3),
"entrance": Vector3(0, 0, 0),
"stage_door": Vector3(6, 0, -2),
"queue_area": Vector3(0, 0, 9),
}
scenario.shift_phases = {
Rules.Phase.EARLY: {"start_s": 0.0},
Rules.Phase.MIDDAY: {"start_s": 300.0},
Rules.Phase.EVENING: {"start_s": 600.0},
}
scenario.npc_profiles = _filter_profiles([
"tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest",
"street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat",
])
scenario.event_pool = [
_studio_routine("audience_ticket", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.35),
_studio_routine("celebrity_arrival", Rules.NpcType.AUTHORIZED_GUEST, "arrive", Vector2(300, 700), 0.20),
_studio_routine("crew_shift", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 700), 0.30),
_studio_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35),
_studio_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25),
_studio_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(0, 900), 0.28),
_studio_routine("trespass_blend", Rules.NpcType.TRESPASSER, "blend_in", Vector2(300, 900), 0.15),
{
"id": "studio_counterfeit",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.TRESPASSER,
"intent": "counterfeit_credential",
"time_range_s": Vector2(620, 780),
"probability": 0.55,
"base_weight": 1.0,
},
{
"id": "studio_follow_through",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.TRESPASSER,
"intent": "follow_authorized",
"time_range_s": Vector2(700, 860),
"probability": 0.45,
"base_weight": 1.0,
},
]
return scenario
# --- Profile helpers -------------------------------------------------------------
static func _filter_profiles(ids: Array) -> Array[NpcProfile]:
var all := shared_profiles()
var out: Array[NpcProfile] = []
for profile in all:
if ids.has(profile.id):
out.append(profile)
return out
static func profile_dictionary() -> Dictionary:
## id -> profile for tests and UI lookups.
var out := {}
for p in shared_profiles():
out[p.id] = p
return out
# --- Event builders ----------------------------------------------------------------
static func _building_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary:
return {
"id": id, "phase": _phase_for_range(time_range, 900.0),
"kind": "routine", "npc_type": npc_type, "intent": intent,
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
}
static func _joke_guest(id: String, has_ticket: bool, has_id: bool, time_range: Vector2, prob: float) -> Dictionary:
return {
"id": id, "phase": _phase_for_range(time_range, 900.0),
"kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": "enter",
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
"override_access": {"has_ticket": has_ticket, "has_id": has_id},
}
static func _joke_staff_entry(time_range: Vector2, prob: float) -> Dictionary:
return {
"id": "joke_staff", "phase": _phase_for_range(time_range, 900.0),
"kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_STAFF, "intent": "enter",
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
}
static func _joke_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary:
return _building_routine(id, npc_type, intent, time_range, prob)
static func _studio_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary:
return _building_routine(id, npc_type, intent, time_range, prob)
static func _phase_for_range(time_range: Vector2, duration: float) -> int:
var start := time_range.x / duration
if start < 0.34:
return Rules.Phase.EARLY
if start < 0.67:
return Rules.Phase.MIDDAY
return Rules.Phase.EVENING
+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
+402
View File
@@ -0,0 +1,402 @@
class_name GameManager
extends Node
## Wireframe: connects a scenario to a ShiftCoordinator, spawns NPC avatars,
## and routes player input into the interaction and access flows.
const Rules := preload("res://src/core/rules.gd")
const AccessArtifact := preload("res://src/core/access_artifact.gd")
const AccessVerifier := preload("res://src/core/access_verifier.gd")
const RadioSystem := preload("res://src/core/radio_system.gd")
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
const VenueScenario := preload("res://src/core/venue_scenario.gd")
const WorldBuilder := preload("res://src/game/world_builder.gd")
const GuardController := preload("res://src/game/guard_controller.gd")
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const Hud := preload("res://src/game/ui/hud.gd")
const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
const ShiftReview := preload("res://src/game/ui/shift_review.gd")
var coordinator: ShiftCoordinator = null
var guard: GuardController = null
var hud: Hud = null
var radio_panel: RadioPanel = null
var shift_review_panel: ShiftReview = null
var verifier := AccessVerifier.new()
var active_scenario_id := "hollywood_building"
var scenario_duration_s: float = 300.0 # shortened prototype duration for testing
var scene_root: Node3D = null
var npcs: Array = []
var npc_counter := 0
var targeted_npc: Object = null
var inspection_open := false
var current_incident_npc_id := ""
var current_incident_active := false
var current_npc_truth := {"weapons_seen": false, "danger_level": 0}
func _ready() -> void:
scene_root = Node3D.new()
scene_root.name = "SceneRoot"
add_child(scene_root)
WorldBuilder.new(scene_root).build()
_ensure_lighting()
guard = _build_guard()
scene_root.add_child(guard)
guard.global_position = Vector3(0, 1.6, -2.5)
guard.picked_target.connect(_on_guard_target)
guard.cleared_target.connect(_on_guard_clear)
selected_scenario = _build_scenario(active_scenario_id)
coordinator = ShiftCoordinator.new()
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.npc_spawn_requested.connect(_spawn_npc)
coordinator.shift_ended.connect(_on_shift_ended)
_build_ui()
coordinator.start()
var selected_scenario: VenueScenario = null
func _build_guard() -> GuardController:
var g := GuardController.new()
g.name = "Guard"
var head := Node3D.new()
head.name = "Head"
g.add_child(head)
var camera := Camera3D.new()
camera.name = "Camera3D"
camera.current = true
head.add_child(camera)
var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new()
shape.radius = 0.4
shape.height = 1.7
collider.shape = shape
collider.position = Vector3(0, 0.85, 0)
g.add_child(collider)
return g
func _build_scenario(id: String) -> VenueScenario:
match id:
"joke_shop":
return ScenarioFactory.joke_shop()
"tv_studio":
return ScenarioFactory.tv_studio()
_:
return ScenarioFactory.hollywood_building()
func switch_scenario(id: String) -> void:
active_scenario_id = id
selected_scenario = _build_scenario(id)
if coordinator:
restart_shift()
func restart_shift() -> void:
shift_review_panel.hide()
for n in npcs:
n.queue_free()
npcs.clear()
npc_counter = 0
inspector_current_events = []
current_incident_active = false
current_incident_npc_id = ""
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.start()
var inspector_current_events: Array = []
func _build_ui() -> void:
hud = Hud.new()
add_child(hud)
radio_panel = RadioPanel.new()
add_child(radio_panel)
radio_panel.hide()
radio_panel.submitted.connect(_on_radio_submitted)
radio_panel.cancelled.connect(func():
radio_panel.hide()
hud.set_action_hint(""))
shift_review_panel = ShiftReview.new()
add_child(shift_review_panel)
shift_review_panel.hide()
shift_review_panel.restart_requested.connect(restart_shift)
func _ensure_lighting() -> void:
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-50, -30, 0)
sun.light_energy = 1.2
scene_root.add_child(sun)
var env_node := WorldEnvironment.new()
var env := Environment.new()
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.7, 0.7, 0.75)
env.ambient_light_energy = 0.5
env_node.environment = env
scene_root.add_child(env_node)
func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
npc_counter += 1
var avatar := NpcAvatar.new()
avatar.name = "Npc_%d" % npc_counter
var spawn_side: float = 1.0 if npc_counter % 2 == 0 else -1.0
var spawn_x := spawn_side * (4.0 + float((npc_counter * 13) % 18))
var spawn_pos := Vector3(spawn_x, 0.5, 6.0 + float((npc_counter * 7) % 16))
var target_pos := Vector3(0.0, 0.5, 1.2)
avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos)
scene_root.add_child(avatar)
npcs.append(avatar)
func _on_shift_ended(review: Dictionary) -> void:
shift_review_panel.show_review(review, selected_scenario)
func _on_guard_target(target: Node3D) -> void:
targeted_npc = target
_begin_incident(target)
func _on_guard_clear() -> void:
targeted_npc = null
hud.set_target_label("")
hud.set_action_hint("")
func _begin_incident(avatar: Node3D) -> void:
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
if controller == null:
return
current_incident_npc_id = controller.npc_id
current_incident_active = true
current_npc_truth = _read_npc_truth(controller)
var profile: Object = controller.profile
var label := "[%s] %s — %s" % [
Rules.NPC_TYPE_NAMES.get((profile.npc_type if profile else -1), "?"),
profile.display_name if profile else "?",
controller.intent,
]
hud.set_target_label(label)
var hint := ""
if profile and profile.npc_type == Rules.NpcType.VIOLENT_THREAT:
hint = "High risk: threaten/force-entry likely. Emergency radio (R) is justified."
elif profile and profile.npc_type == Rules.NpcType.MENTAL_CRISIS:
hint = "Requires patience. Avoid aggressive confrontation."
elif profile and profile.npc_type == Rules.NpcType.AUTHORIZED_GUEST:
hint = "Likely authorized. Press E to verify access."
hud.set_action_hint(hint)
# ------------------------------------------------------------------ input ---
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo:
_handle_key(event.keycode)
func _handle_key(keycode: Key) -> void:
match keycode:
KEY_E:
if targeted_npc != null and not inspection_open:
_toggle_inspection()
KEY_Q:
if targeted_npc != null and not inspection_open:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "polite direction")
KEY_F:
hud.toggle_debug()
_:
_handle_action_key(keycode)
func _handle_action_key(keycode: Key) -> void:
match keycode:
KEY_1:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "raise hand")
KEY_2:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "point")
KEY_3:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "head shake")
KEY_4:
_apply_interaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL, "formal warning")
KEY_5:
_apply_interaction(Rules.Level.BLOCKING, Rules.Tone.PROFESSIONAL, "blocking")
KEY_6:
_open_radio(false)
KEY_R:
_open_radio(true)
KEY_A:
if inspection_open:
decide_access(true)
KEY_D:
if inspection_open:
decide_access(false)
_:
pass
func _apply_interaction(level: int, tone: int, action_name: String) -> void:
if targeted_npc == null:
hud.log("Aim at someone first.")
return
var controller: Object = _targeted_controller()
if controller == null or coordinator == null:
return
var outcome: Dictionary = controller.apply_player_action(level, tone)
var npc_id: String = controller.npc_id
coordinator.recorder.add_event(npc_id, {
"type": "player_action",
"action": action_name,
"level": level,
"tone": Rules.TONE_NAMES.get(tone, "?"),
})
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
if level >= Rules.Level.FORMAL_WARNING:
coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name])
_react_to_outcome(controller, outcome)
func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
var reaction: int = outcome.get("reaction", -1)
var name: String = (controller.profile.display_name if controller.profile else "Person")
match reaction:
Rules.Reaction.COMPLY:
hud.log("%s complies and steps back." % name)
coordinator.crowd.apply_event("de_escalation")
coordinator.reputation.apply("peaceful", 2.0, "resolved peacefully")
_close_incident("peaceful")
Rules.Reaction.LEAVE:
hud.log("%s turns and leaves." % name)
coordinator.crowd.apply_event("calm_professionalism")
_close_incident("left")
Rules.Reaction.ARGUE:
hud.log("%s argues with you." % name)
coordinator.crowd.apply_event("aggressive_behavior")
Rules.Reaction.RECORD:
hud.log("%s starts filming you." % name)
coordinator.reputation.apply("complaint", -3.0, "filmed interaction")
coordinator.crowd.apply_event("aggressive_behavior")
Rules.Reaction.IGNORE:
hud.log("%s ignores you." % name)
Rules.Reaction.THREATEN:
hud.log("%s is threatening you. Emergency radio (R) is justified now." % name)
coordinator.crowd.apply_event("aggressive_behavior")
Rules.Reaction.FORCE_ENTRY:
hud.log("%s is forcing the door!" % name)
coordinator.crowd.apply_event("emergency_responder")
coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt")
Rules.Reaction.RETURN_LATER:
hud.log("%s leaves but may come back." % name)
_:
hud.log("No reaction (yet).")
func _open_radio(emergency: bool) -> void:
if targeted_npc == null:
hud.log("Address someone first before calling.")
return
var controller: Object = _targeted_controller()
if controller == null:
return
radio_panel.open(controller, emergency, current_npc_truth)
hud.set_action_hint("Fill in the radio report on screen.")
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
hud.set_action_hint("")
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
if not result.get("ok", false):
hud.log("Radio blocked: %s" % result.get("reason", "unknown"))
return
var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth)
if hits > 0:
coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report")
hud.log("Dispatch flags inaccuracies in your report (-%d)." % (4 * hits))
else:
hud.log("Dispatch received your report. Assistance en route.")
if emergency:
coordinator.crowd.apply_event("emergency_responder")
hud.log("Emergency responder dispatched.")
else:
coordinator.crowd.apply_event("calm_professionalism")
# ----------------------------------------------------------------- access --
func _toggle_inspection() -> void:
if inspection_open:
hud.hide_inspection()
inspection_open = false
return
var controller: Object = _targeted_controller()
if controller == null:
return
inspection_open = true
var artifacts := _artifacts_for(controller)
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
hud.show_inspection(controller, artifacts, result)
func decide_access(allowed: bool) -> void:
var controller: Object = _targeted_controller()
if controller == null:
hud.hide_inspection()
inspection_open = false
return
var artifacts := _artifacts_for(controller)
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
var granted: bool = result.get("granted", false)
coordinator.recorder.add_access_evidence(controller.npc_id, {
"decided": allowed,
"verified_granted": granted,
"issues": result.get("issues", []),
"checked": result.get("checked_artifacts", []),
})
if allowed == granted:
if allowed:
hud.log("Entry granted: %s" % ("; ".join(result.get("reasons", [])) if result.get("reasons", []) else "policy satisfied"))
coordinator.reputation.apply("access_judgment", 2.0, "correct admission")
else:
hud.log("Entry denied: %s" % "; ".join(result.get("issues", [])))
coordinator.reputation.apply("access_judgment", 1.0, "correct refusal")
else:
if allowed:
hud.log("You granted entry despite verification failure!")
else:
hud.log("You denied entry despite valid verification!")
coordinator.reputation.apply("access_judgment", -6.0, "wrong access decision")
hud.hide_inspection()
inspection_open = false
func _artifacts_for(controller: Object) -> Array:
var profile: Object = controller.profile
var npc_type: int = profile.npc_type if profile else -1
var out: Array = []
match npc_type:
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.AUTHORIZED_STAFF:
out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true}))
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, {"color": "red", "date": "2024-01-01"}))
Rules.NpcType.TOURIST:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
Rules.NpcType.CONFUSED_VISITOR:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, {"color": "gold"}))
_:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
return out
func _read_npc_truth(controller: Object) -> Dictionary:
var profile: Object = controller.profile
var npc_type: int = profile.npc_type if profile else -1
return {
"weapons_seen": npc_type == Rules.NpcType.VIOLENT_THREAT,
"danger_level": 3 if npc_type == Rules.NpcType.VIOLENT_THREAT else 1,
}
func _targeted_controller() -> Object:
if targeted_npc == null:
return null
if targeted_npc.has_method("get_npc_controller"):
return targeted_npc.get_npc_controller()
return null
func _close_incident(outcome: String) -> void:
if current_incident_active and not current_incident_npc_id.is_empty():
coordinator.recorder.close_incident(current_incident_npc_id, outcome)
current_incident_active = false
current_incident_npc_id = ""
func _physics_process(delta: float) -> void:
if coordinator == null or not coordinator.running:
return
coordinator.tick(delta)
hud.set_shift_state(coordinator, selected_scenario)
+83
View File
@@ -0,0 +1,83 @@
class_name GuardController
extends CharacterBody3D
## First-person guard controller: WASD movement, mouse look, interact raycast.
signal picked_target(target: Node)
signal cleared_target()
var move_speed: float = 3.2
var look_sensitivity: float = 0.0022
var is_ready := false
var targeted_npc: Node3D = null
@onready var camera: Camera3D = $Head/Camera3D
@onready var head: Node3D = $Head
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
is_ready = true
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
rotate_y(-event.relative.x * look_sensitivity)
head.rotate_x(-event.relative.y * look_sensitivity)
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _physics_process(_delta: float) -> void:
# Movement relative to the head's yaw only.
var forward := -global_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
var right := global_transform.basis.x
right.y = 0.0
right = right.normalized()
var input_dir := Vector2(
Input.get_axis("move_left", "move_right"),
Input.get_axis("move_forward", "move_back")
)
var direction := (forward * input_dir.y + right * input_dir.x).normalized()
velocity.x = direction.x * move_speed
velocity.z = direction.z * move_speed
velocity.y -= 9.8 * _delta
move_and_slide()
_update_target()
func _update_target() -> void:
var space := get_world_3d().direct_space_state
var from := camera.global_position
var to := from + -camera.global_transform.basis.z * 8.0
var query := PhysicsRayQueryParameters3D.create(from, to)
query.collide_with_areas = true
query.collide_with_bodies = true
query.exclude = [get_rid()]
var hit := space.intersect_ray(query)
var new_target: Node3D = null
if hit:
var obj: Object = hit.get("collider")
var parent_avatar: Node3D = _find_avatar(obj)
if parent_avatar != null:
new_target = parent_avatar
if new_target != targeted_npc:
targeted_npc = new_target
if new_target != null:
picked_target.emit(new_target)
else:
cleared_target.emit()
func _find_avatar(obj: Object) -> Node3D:
# Avatars are CharacterBody3D with a collision shape; guard against self and scene junk.
var n := obj as Node
if n == null:
return null
var current: Node = n
while current != null:
if current.has_method("get_npc_controller"):
return current as Node3D
current = current.get_parent()
return null
+11
View File
@@ -0,0 +1,11 @@
extends Node
## Top-level game entry point. Instantiates the GameManager which owns the world,
## guard, simulation coordinator, and UI.
const GameManager := preload("res://src/game/game_manager.gd")
func _ready() -> void:
var manager := GameManager.new()
manager.name = "GameManager"
add_child(manager)
print("[Step Line] game started")
+118
View File
@@ -0,0 +1,118 @@
class_name NpcAvatar
extends CharacterBody3D
## Visual + navigation wrapper around a logic NPCController (ADR-0001).
## Gives the controller a body, color-coded by archetype, and simple movement.
const NPCController := preload("res://src/core/npc_controller.gd")
const Rules := preload("res://src/core/rules.gd")
var npc: NPCController = null
var spawn_position: Vector3 = Vector3.ZERO
var target_position: Vector3 = Vector3.ZERO
var awaiting_interaction := false
var arrived := false
const TYPE_COLORS := {
Rules.NpcType.TOURIST: Color(0.90, 0.75, 0.55),
Rules.NpcType.PERFORMER: Color(0.80, 0.55, 0.90),
Rules.NpcType.INFLUENCER: Color(0.95, 0.55, 0.55),
Rules.NpcType.CONFUSED_VISITOR: Color(0.95, 0.85, 0.50),
Rules.NpcType.INTOXICATED: Color(0.60, 0.75, 0.50),
Rules.NpcType.TRESPASSER: Color(0.55, 0.55, 0.75),
Rules.NpcType.VIOLENT_THREAT: Color(0.85, 0.25, 0.25),
Rules.NpcType.MENTAL_CRISIS: Color(0.85, 0.65, 0.45),
Rules.NpcType.AUTHORIZED_STAFF: Color(0.45, 0.70, 0.90),
Rules.NpcType.AUTHORIZED_GUEST: Color(0.60, 0.85, 0.65),
Rules.NpcType.DELIVERY: Color(0.80, 0.70, 0.40),
Rules.NpcType.RESPONDER: Color(0.25, 0.30, 0.85),
}
func _init() -> void:
npc = NPCController.new()
func get_npc_controller() -> NPCController:
return npc
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3) -> void:
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
spawn_position = p_spawn
target_position = p_target
position = p_spawn
npc.fsm.state_changed.connect(_on_state_changed)
_build_body(profile.npc_type)
# Normalize intent targets: approach the entrance, stop near the door.
var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new()
shape.radius = 0.35
shape.height = 1.6
collider.shape = shape
collider.position = Vector3(0, 0.8, 0)
add_child(collider)
func _build_body(npc_type: int) -> void:
var body := MeshInstance3D.new()
var capsule := CapsuleMesh.new()
capsule.radius = 0.35
capsule.height = 1.6
body.mesh = capsule
var mat := StandardMaterial3D.new()
mat.albedo_color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
body.material_override = mat
add_child(body)
var id_tag := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.12
sphere.height = 0.24
var tag_mat := StandardMaterial3D.new()
tag_mat.albedo_color = Color(1, 1, 1)
id_tag.mesh = sphere
id_tag.material_override = tag_mat
id_tag.position = Vector3(0, 1.05, 0)
id_tag.scale = Vector3(1, 0.4, 1)
add_child(id_tag)
func _physics_process(delta: float) -> void:
if npc == null or npc.fsm == null:
return
_advance_fsm()
_move_toward_target(delta)
func _advance_fsm() -> void:
if npc.leave_requested:
var away := global_position + (global_position - spawn_position).normalized() * 40.0
away.y = 0.5
target_position = away
return
if arrived and not awaiting_interaction:
awaiting_interaction = true
func _move_toward_target(delta: float) -> void:
var to_target := target_position - global_position
to_target.y = 0.0
var dist := to_target.length()
if dist < 0.7:
_arrive()
return
var dir := to_target.normalized()
velocity.x = dir.x * 1.3
velocity.z = dir.z * 1.3
velocity.y -= 9.8 * delta
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
move_and_slide()
func _arrive() -> void:
arrived = true
velocity = Vector3.ZERO
if npc.intent == "enter" or npc.intent == "trespass" or npc.intent == "deliver" or npc.intent == "arrive":
target_position = Vector3(0, 0.5, 1.2)
if (global_position - Vector3(0, 0.5, 1.2)).length() < 0.8:
awaiting_interaction = true
func _on_state_changed(_node: Object, _from: String, to_state: String) -> void:
match to_state:
"LEAVING":
npc.leave_requested = true
"THREATENING", "FORCING_ENTRY":
# Advance toward the door posture; keep it simple for the prototype.
pass
+168
View File
@@ -0,0 +1,168 @@
class_name Hud
extends CanvasLayer
## Minimal on-screen HUD: top status bar, target info, action hints,
## event log, access inspection panel, and a debug overlay.
const Rules := preload("res://src/core/rules.gd")
var top_label: Label
var time_label: Label
var target_label: Label
var hint_label: Label
var log_label: Label
var debug_label: Label
var inspect_panel: PanelContainer
var inspect_text: Label
var inspect_meta: Label
var _debug_enabled := false
var _log_lines: Array[String] = []
func _init() -> void:
name = "HUD"
layer = 10
var margin := MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 16)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_right", 16)
margin.add_theme_constant_override("margin_bottom", 12)
add_child(margin)
var vbox := VBoxContainer.new()
margin.add_child(vbox)
var top := HBoxContainer.new()
vbox.add_child(top)
top_label = Label.new()
top_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top.add_child(top_label)
time_label = Label.new()
top.add_child(time_label)
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 6)
vbox.add_child(spacer)
target_label = Label.new()
target_label.add_theme_font_size_override("font_size", 16)
vbox.add_child(target_label)
hint_label = Label.new()
hint_label.modulate = Color(1, 0.9, 0.6)
hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
hint_label.custom_minimum_size = Vector2(0, 40)
vbox.add_child(hint_label)
var spacer2 := Control.new()
spacer2.custom_minimum_size = Vector2(0, 4)
vbox.add_child(spacer2)
log_label = Label.new()
log_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
log_label.custom_minimum_size = Vector2(0, 120)
log_label.add_theme_font_size_override("font_size", 13)
vbox.add_child(log_label)
var bottom_spacer := Control.new()
bottom_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
vbox.add_child(bottom_spacer)
var controls := Label.new()
controls.text = "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · A allow · D deny · F debug"
controls.add_theme_font_size_override("font_size", 12)
controls.modulate = Color(0.8, 0.8, 0.85)
vbox.add_child(controls)
# --- inspect panel (popup center) ---
inspect_panel = PanelContainer.new()
inspect_panel.visible = false
inspect_panel.set_anchors_preset(Control.PRESET_CENTER)
inspect_panel.modulate.a = 1.0
var ivbox := VBoxContainer.new()
inspect_panel.add_child(ivbox)
inspect_text = Label.new()
inspect_text.add_theme_font_size_override("font_size", 15)
ivbox.add_child(inspect_text)
inspect_meta = Label.new()
inspect_meta.add_theme_font_size_override("font_size", 12)
inspect_meta.modulate = Color(0.8, 0.85, 1.0)
ivbox.add_child(inspect_meta)
var insp_hint := Label.new()
insp_hint.text = "A = allow entry D = deny entry"
insp_hint.add_theme_font_size_override("font_size", 12)
ivbox.add_child(insp_hint)
add_child(inspect_panel)
# --- debug overlay ---
debug_label = Label.new()
debug_label.visible = false
debug_label.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
debug_label.position = Vector2(16, 0)
debug_label.add_theme_font_size_override("font_size", 11)
debug_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
add_child(debug_label)
func set_shift_state(coordinator: Object, scenario: Resource) -> void:
top_label.text = scenario.display_name if scenario else "Step Line"
var mins := int(coordinator.elapsed_s) / 60
var secs := int(coordinator.elapsed_s) % 60
time_label.text = "shift %02d:%02d phase %s\nrep %.0f crowd %.0f%%" % [
mins, secs,
Rules.PHASE_NAMES.get(coordinator.current_phase, "Early"),
coordinator.reputation.rating,
coordinator.crowd.mood * 100.0,
]
func set_target_label(text: String) -> void:
target_label.text = text
func set_action_hint(text: String) -> void:
hint_label.text = text
func log(text: String) -> void:
_log_lines.append(text)
if _log_lines.size() > 8:
_log_lines.pop_front()
log_label.text = "\n".join(_log_lines)
func toggle_debug() -> void:
_debug_enabled = not _debug_enabled
debug_label.visible = _debug_enabled
func refresh_debug(coordinator: Object, npcs: Array) -> void:
if not _debug_enabled:
return
var lines: Array[String] = ["--- DEBUG ---", "shift %.1fs / %.1fs rating %.1f" % [
coordinator.elapsed_s, coordinator.duration_s, coordinator.reputation.rating]]
for npc in npcs:
var c: Object = npc.get_npc_controller()
if c == null:
continue
var profile: Object = c.profile
lines.append(" [%s] %s intent=%s state=%s esc=%d" % [
Rules.NPC_TYPE_NAMES.get(profile.npc_type if profile else -1, "?"),
npc.name, c.intent, c.fsm.current_state, c.highest_escalation_received,
])
debug_label.text = "\n".join(lines)
func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -> void:
var profile: Object = controller.profile
var npc_display: String = (profile.display_name if profile else "Person")
var granted: bool = result.get("granted", false)
inspect_text.text = "Access check — %s\nGranted: %s" % [npc_display, "YES" if granted else "NO"]
var lines: Array[String] = []
for artifact in artifacts:
lines.append(" • " + artifact.summary())
if result.get("reasons", []).size() > 0:
lines.append("Reasons: " + ", ".join(result.get("reasons", [])))
if result.get("issues", []).size() > 0:
lines.append("Issues: " + "; ".join(result.get("issues", [])))
inspect_meta.text = "\n".join(lines)
inspect_panel.visible = true
func hide_inspection() -> void:
inspect_panel.visible = false
+159
View File
@@ -0,0 +1,159 @@
class_name RadioPanel
extends CanvasLayer
## Radio report form (PLAN.md Level 4 assist / Level 5 emergency).
## Emergency calls use hold-to-activate (press and hold 1.6s) to prevent
## accidental activation.
signal submitted(report: Dictionary, emergency: bool)
signal cancelled()
const Rules := preload("res://src/core/rules.gd")
var panel: PanelContainer
var npc_name_label: Label
var location_edit: LineEdit
var desc_edit: LineEdit
var weapons_check: CheckBox
var danger_option: OptionButton
var action_button: Button
var hold_hint: Label
var status_label: Label
var _emergency := false
var _npc_id := ""
var _holding := false
var _hold_time := 0.0
const HOLD_NEEDED := 1.6
var _truth := {"weapons_seen": false, "danger_level": 0}
func _init() -> void:
name = "RadioPanel"
layer = 20
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.55)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
panel = PanelContainer.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
add_child(panel)
var vbox := VBoxContainer.new()
panel.add_child(vbox)
vbox.add_theme_constant_override("separation", 8)
var title := Label.new()
title.name = "RadioTitle"
title.add_theme_font_size_override("font_size", 18)
vbox.add_child(title)
npc_name_label = Label.new()
vbox.add_child(npc_name_label)
location_edit = LineEdit.new()
location_edit.placeholder_text = "Location (e.g. front steps)"
location_edit.text = "front entrance"
vbox.add_child(location_edit)
desc_edit = LineEdit.new()
desc_edit.placeholder_text = "Describe the person and behavior"
vbox.add_child(desc_edit)
weapons_check = CheckBox.new()
weapons_check.text = "Weapon seen"
vbox.add_child(weapons_check)
var danger_row := HBoxContainer.new()
vbox.add_child(danger_row)
var danger_label := Label.new()
danger_label.text = "Danger level: "
danger_row.add_child(danger_label)
danger_option = OptionButton.new()
for i in 4:
danger_option.add_item("level %d — %s" % [i, _danger_name(i)], i)
danger_option.select(1)
danger_row.add_child(danger_option)
hold_hint = Label.new()
hold_hint.text = ""
hold_hint.modulate = Color(1, 0.85, 0.55)
vbox.add_child(hold_hint)
status_label = Label.new()
status_label.text = ""
vbox.add_child(status_label)
var buttons := HBoxContainer.new()
vbox.add_child(buttons)
action_button = Button.new()
action_button.text = "Send request"
action_button.button_down.connect(func(): _start_hold())
action_button.button_up.connect(func(): _stop_hold(false))
buttons.add_child(action_button)
var cancel_btn := Button.new()
cancel_btn.text = "Cancel"
cancel_btn.pressed.connect(func(): cancelled.emit())
buttons.add_child(cancel_btn)
var _title_label: Label
func open(controller: Object, emergency: bool, truth: Dictionary) -> void:
_emergency = emergency
_truth = truth
_npc_id = controller.npc_id if controller else ""
npc_name_label.text = "Target: %s" % (controller.profile.display_name if controller else "?")
var title: Label = panel.find_child("RadioTitle", true, false)
title.text = "EMERGENCY CALL" if emergency else "Security Assistance"
weapons_check.button_pressed = truth.get("weapons_seen", false)
danger_option.select(truth.get("danger_level", 1))
hold_hint.text = "Hold the button for %.1fs" % HOLD_NEEDED if emergency else ""
status_label.text = ""
action_button.text = "SEND EMERGENCY" if emergency else "Send request"
_holding = false
_hold_time = 0.0
visible = true
func _start_hold() -> void:
_holding = _emergency
_hold_time = 0.0
func _stop_hold(cancelled_early: bool) -> void:
if _holding:
_holding = false
if not cancelled_early and _hold_time >= HOLD_NEEDED:
_submit()
else:
status_label.text = "Call cancelled - must hold the button."
func _process(delta: float) -> void:
if not visible:
return
if _holding:
_hold_time += delta
status_label.text = "Holding... %.0f%%" % (minf(1.0, _hold_time / HOLD_NEEDED) * 100.0)
if _hold_time >= HOLD_NEEDED:
var was := _holding
_holding = false
if was:
_submit()
func _submit() -> void:
if not visible:
return
var report := {
"npc_id": _npc_id,
"location": location_edit.text,
"description": desc_edit.text,
"behavior": desc_edit.text,
"weapons_seen": weapons_check.button_pressed,
"danger_level": danger_option.get_selected_id(),
}
visible = false
submitted.emit(report, _emergency)
func _danger_name(level: int) -> String:
match level:
0: return "none"
1: return "low"
2: return "elevated"
3: return "immediate threat"
_: return "unknown"
+77
View File
@@ -0,0 +1,77 @@
class_name ShiftReview
extends CanvasLayer
## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review).
signal restart_requested()
var report_label: Label
func _init() -> void:
name = "ShiftReview"
layer = 30
var dim := ColorRect.new()
dim.color = Color(0.04, 0.05, 0.08, 0.92)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var center := VBoxContainer.new()
center.set_anchors_preset(Control.PRESET_CENTER)
center.alignment = BoxContainer.ALIGNMENT_CENTER
center.add_theme_constant_override("separation", 12)
add_child(center)
var title := Label.new()
title.text = "Shift Review"
title.add_theme_font_size_override("font_size", 28)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
center.add_child(title)
report_label = Label.new()
report_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
report_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
report_label.custom_minimum_size = Vector2(720, 0)
center.add_child(report_label)
var restart := Button.new()
restart.text = "Run another shift"
restart.pressed.connect(func(): restart_requested.emit())
restart.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(restart)
var quit_btn := Button.new()
quit_btn.text = "Quit"
quit_btn.pressed.connect(func(): get_tree().quit())
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(quit_btn)
func show_review(review: Dictionary, scenario: Resource) -> void:
var reputation: Dictionary = review.get("reputation", {})
var incidents: Dictionary = review.get("incidents", {})
var crowd: Dictionary = review.get("crowd", {})
var radio: Dictionary = review.get("radio", {})
var incident_list: Array = incidents.get("incidents", [])
var peaceful := 0
for inc in incident_list:
if inc.get("outcome", "") in ["peaceful", "left"]:
peaceful += 1
var lines: Array[String] = [
"Venue: %s" % (scenario.display_name if scenario else review.get("scenario_id", "")),
"Grade: %s Score: %.0f/100" % [reputation.get("grade", "?"), reputation.get("rating", 0.0)],
"Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful],
"NPCs encountered: %d" % review.get("spawned_count", 0),
"Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)],
"Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))],
"",
"Highlights:",
]
var mods: Array = reputation.get("modifiers", [])
var shown := 0
for mod in mods:
var delta: float = float(mod.get("delta", 0.0))
lines.append(" %s (%+.0f) %s" % [mod.get("category", ""), delta, mod.get("reason", "")])
shown += 1
if shown >= 12:
break
report_label.text = "\n".join(lines)
visible = true
+110
View File
@@ -0,0 +1,110 @@
class_name WorldBuilder
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
## No art assets required; all geometry is BoxMesh with flat materials.
var root: Node3D = null
func _init(root_node: Node3D) -> void:
root = root_node
func build() -> void:
_build_ground()
_build_building()
_build_steps()
_build_sidewalk()
_build_street()
_build_guard_spawn()
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D:
var mesh := BoxMesh.new()
mesh.size = size
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mesh.material = mat
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.position = pos
mi.cast_shadow = (1 if cast_shadow else 0) as GeometryInstance3D.ShadowCastingSetting
parent.add_child(mi)
return mi
func _build_ground() -> void:
var ground := StaticBody3D.new()
ground.name = "Ground"
_add_box(root, Vector3(160, 0.5, 160), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26))
var col := BoxShape3D.new()
col.size = Vector3(160, 1, 160)
var shape_owner := ground.create_shape_owner(ground)
ground.shape_owner_add_shape(shape_owner, col)
root.add_child(ground)
func _build_building() -> void:
var building := StaticBody3D.new()
building.name = "Building"
# Main facade
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.35, 0.32, 0.30))
# Entrance recess + door recess frame
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.10, 0.10, 0.10))
# Door
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.16, 0.15, 0.13))
# Door window
_add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.5, 0.7, 0.9, 1.0))
# Awning sign bar
_add_box(building, Vector3(8, 0.5, 0.6), Vector3(0, 7.0, -3.6), Color(0.45, 0.44, 0.44))
var col := BoxShape3D.new()
col.size = Vector3(40, 18, 4)
var so := building.create_shape_owner(building)
building.shape_owner_add_shape(so, col)
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -2)))
root.add_child(building)
func _build_steps() -> void:
var steps := StaticBody3D.new()
steps.name = "Steps"
var step_w := 8.0
var tread := 0.6
var height := 0.16
for i in 4:
var y: float = i * height
var z: float = i * tread
_add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
# Landing at the top
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45))
var col := BoxShape3D.new()
col.size = Vector3(step_w, 0.65, tread * 4 + 3)
var so := steps.create_shape_owner(steps)
steps.shape_owner_add_shape(so, col)
steps.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0.32, 0.8)))
root.add_child(steps)
func _build_sidewalk() -> void:
var sidewalk := StaticBody3D.new()
sidewalk.name = "Sidewalk"
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.60, 0.58, 0.56))
var col := BoxShape3D.new()
col.size = Vector3(30, 0.36, 8)
var so := sidewalk.create_shape_owner(sidewalk)
sidewalk.shape_owner_add_shape(so, col)
sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 8)))
root.add_child(sidewalk)
func _build_street() -> void:
var street := StaticBody3D.new()
street.name = "Street"
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(0.13, 0.13, 0.14))
# Center dashes
for i in range(-40, 41, 4):
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(i * 0.0, 0.11, 22), Color(0.85, 0.85, 0.30))
var col := BoxShape3D.new()
col.size = Vector3(120, 0.2, 26)
var so := street.create_shape_owner(street)
street.shape_owner_add_shape(so, col)
street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22)))
root.add_child(street)
func _build_guard_spawn() -> void:
var marker := Marker3D.new()
marker.name = "GuardSpawn"
marker.position = Vector3(0, 1.6, -2.5)
root.add_child(marker)
+39
View File
@@ -0,0 +1,39 @@
extends SceneTree
## Dev diagnostic: preload every project script to surface parse errors one by one.
const FILES := [
"res://src/core/event_bus.gd",
"res://src/core/rules.gd",
"res://src/core/access_artifact.gd",
"res://src/core/access_policy.gd",
"res://src/core/access_verifier.gd",
"res://src/core/npc_profile.gd",
"res://src/core/npc_controller.gd",
"res://src/core/fsm.gd",
"res://src/core/escalation_manager.gd",
"res://src/core/incident_recorder.gd",
"res://src/core/reputation_evaluator.gd",
"res://src/core/radio_system.gd",
"res://src/core/crowd_system.gd",
"res://src/core/event_scheduler.gd",
"res://src/core/venue_scenario.gd",
"res://src/core/shift_coordinator.gd",
"res://src/content/scenario_factory.gd",
"res://src/game/main.gd",
"res://src/test/test_utils.gd",
]
func _initialize() -> void:
var failures := 0
for f in FILES:
var res: Variant = load(f)
if res == null or not (res is GDScript):
push_error("FAILED to load: %s" % f)
failures += 1
elif not (res as GDScript).can_instantiate():
push_error("PARSE/COMPILE FAILURE (cannot instantiate): %s" % f)
failures += 1
else:
print("OK: %s" % f)
print("load failures: %d" % failures)
quit(1 if failures > 0 else 0)
+25
View File
@@ -0,0 +1,25 @@
extends SceneTree
## Dev helper: run the game windowed for N frames, save a screenshot, quit.
## Usage: godot --path . -s res://src/test/take_screenshot.gd (windowed)
var frames := 0
const TARGET_FRAMES := 300
func _initialize() -> void:
# Launch the main scene.
var packed: PackedScene = load("res://scenes/main.tscn")
var inst: Node = packed.instantiate()
root.add_child(inst)
func _process(_delta: float) -> bool:
frames += 1
if frames == 60:
var img: Image = root.get_texture().get_image()
var err := img.save_png("user://screenshot.png")
print("screenshot saved err=", err)
quit(0)
return true
if frames > TARGET_FRAMES:
quit(0)
return true
return false
+73
View File
@@ -0,0 +1,73 @@
extends SceneTree
## Minimal headless test runner for CI (used in place of an editor-based addon
## until a Godot editor workflow is introduced).
##
## Usage:
## godot --headless --path . --script res://src/test/test_runner.gd
## Exit code 0 = all passed, 1 = failures.
const TestUtils := preload("res://src/test/test_utils.gd")
const TEST_DIR := "res://src/test/tests"
func _initialize() -> void:
var test_files := _list_gd_files(TEST_DIR)
var total_passed := 0
var total_failed := 0
var failures: Array[String] = []
for path in test_files:
var script: GDScript = load(path)
if script == null:
failures.append("Could not load %s" % path)
total_failed += 1
continue
var instance: Object = script.new()
TestUtils.reset()
var suite_passed := 0
var suite_failed := 0
for method_info in instance.get_method_list():
var method: StringName = method_info["name"]
if not String(method).begins_with("test_"):
continue
if method_info["flags"] & METHOD_FLAG_STATIC:
continue
TestUtils.reset()
var result: Variant = instance.call(method)
var errored: bool = TestUtils._errors.size() > 0
if result == true and not errored:
suite_passed += 1
else:
suite_failed += 1
var detail := ""
if errored:
detail = " :: " + TestUtils.report()
failures.append("%s.%s%s" % [path.get_file(), method, detail])
total_passed += suite_passed
total_failed += suite_failed
print("Suite %-42s pass=%d fail=%d" % [path.get_file(), suite_passed, suite_failed])
print("")
print("==================== TEST SUMMARY ====================")
print("Passed: %d Failed: %d" % [total_passed, total_failed])
if failures.size() > 0:
print("--------------------------------------------------")
for f in failures:
print("FAIL: %s" % f)
print("======================================================")
quit(0 if total_failed == 0 else 1)
func _list_gd_files(dir_path: String) -> Array[String]:
var out: Array[String] = []
var dir := DirAccess.open(dir_path)
if dir == null:
push_error("Cannot open test dir %s" % dir_path)
return out
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if not dir.current_is_dir() and file_name.ends_with(".gd"):
out.append(dir_path.path_join(file_name))
file_name = dir.get_next()
dir.list_dir_end()
out.sort()
return out
+32
View File
@@ -0,0 +1,32 @@
class_name TestUtils
## Tiny assertion helpers shared by headless test suites.
static var _errors: Array[String] = []
static func reset() -> void:
_errors.clear()
## Returns true and records the failure message when condition is false.
static func check(condition: bool, message: String) -> bool:
if not condition:
_errors.append(message)
return condition
static func check_eq(actual: Variant, expected: Variant, message: String) -> bool:
if actual == expected:
return true
_errors.append("%s (expected %s, got %s)" % [message, str(expected), str(actual)])
return false
static func check_dict_has(data: Dictionary, key: String, message: String) -> bool:
if data.has(key):
return true
_errors.append("%s (missing key %s)" % [message, key])
return false
static func fail(message: String) -> bool:
_errors.append(message)
return false
static func report() -> String:
return "\n".join(_errors)
+120
View File
@@ -0,0 +1,120 @@
extends RefCounted
## Headless tests for AccessVerifier (ADR-0003, Access Policy).
const Rules := preload("res://src/core/rules.gd")
const AccessVerifier := preload("res://src/core/access_verifier.gd")
const AccessPolicy := preload("res://src/core/access_policy.gd")
const AccessArtifact := preload("res://src/core/access_artifact.gd")
var verifier := AccessVerifier.new()
func test_wristband_valid_grants() -> bool:
var policy := AccessPolicy.new()
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
var artifacts: Array[AccessArtifact] = [
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, {"color": "red", "date": "2026-02-13", "event": "gala"}),
]
var result := verifier.verify(artifacts, policy)
return TestUtils.check_eq(result["granted"], true, "valid wristband should grant access")
func test_wristband_expired_denies() -> bool:
var policy := AccessPolicy.new()
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
var artifacts: Array[AccessArtifact] = [
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.EXPIRED, {}),
]
var result := verifier.verify(artifacts, policy)
return TestUtils.check_eq(result["granted"], false, "expired wristband must deny")
func test_wristband_counterfeit_denies() -> bool:
var policy := AccessPolicy.new()
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
var artifacts: Array[AccessArtifact] = [
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {}),
]
var result := verifier.verify(artifacts, policy)
return TestUtils.check_eq(result["granted"], false, "counterfeit wristband must deny")
func test_missing_wristband_denies() -> bool:
var policy := AccessPolicy.new()
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
var result := verifier.verify([], policy)
return TestUtils.check_eq(result["granted"], false, "missing wristband must deny")
func test_recognition_grants_without_wristband() -> bool:
## PLAN.md: "The guard already knows and recognizes them."
var policy := AccessPolicy.new()
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
var artifacts: Array[AccessArtifact] = [
AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {}),
]
var result := verifier.verify(artifacts, policy)
return TestUtils.check_eq(result["granted"], true, "recognition should grant access")
func test_recognition_expired_does_not_grant() -> bool:
var policy := AccessPolicy.new()
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
var artifacts: Array[AccessArtifact] = [
AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.MISSING, {}),
]
var result := verifier.verify(artifacts, policy)
return TestUtils.check_eq(result["granted"], false, "missing recognition should not grant under band policy")
func test_joke_shop_id_and_ticket() -> bool:
var policy := AccessPolicy.new()
policy.required_artifacts = { Rules.AccessKind.ID: true, Rules.AccessKind.TICKET: true }
policy.minimum_age = 21
var ok: Array[AccessArtifact] = [
AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 24}),
AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}),
]
var denied_underage: Array[AccessArtifact] = [
AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 19}),
AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}),
]
var denied_no_ticket: Array[AccessArtifact] = [
AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 24}),
]
var ok_result := verifier.verify(ok, policy)
var denied_underage_result := verifier.verify(denied_underage, policy)
var denied_no_ticket_result := verifier.verify(denied_no_ticket, policy)
var a := TestUtils.check_eq(ok_result["granted"], true, "valid ID + ticket grants")
var b := TestUtils.check_eq(denied_underage_result["granted"], false, "underage denied")
var c := TestUtils.check_eq(denied_no_ticket_result["granted"], false, "no ticket denied")
return a and b and c
func test_joke_shop_requires_wand_screen() -> bool:
var policy := AccessPolicy.new()
policy.required_artifacts = { Rules.AccessKind.ID: true, Rules.AccessKind.TICKET: true }
policy.wand_screening_required = true
var artifacts: Array[AccessArtifact] = [
AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 25}),
AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}),
]
var result := verifier.verify(artifacts, policy)
return TestUtils.check_eq(result["granted"], false, "unscreened guest denied when screening required")
func test_tv_studio_any_valid_grants() -> bool:
## OR-admission: any single valid artifact among several kinds grants entry.
var policy := AccessPolicy.new()
policy.required_artifacts = {
Rules.AccessKind.CREDENTIAL: true,
Rules.AccessKind.WRISTBAND: true,
Rules.AccessKind.TICKET: true,
Rules.AccessKind.GUEST_LIST: true,
}
policy.grant_if_any_required = true
var with_wristband: Array[AccessArtifact] = [
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, {}),
]
var with_guest_list: Array[AccessArtifact] = [
AccessArtifact.new(Rules.AccessKind.GUEST_LIST, Rules.ArtifactStatus.VALID, {}),
]
var all_fake: Array[AccessArtifact] = [
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {}),
AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.EXPIRED, {}),
]
var a := TestUtils.check_eq(verifier.verify(with_wristband, policy)["granted"], true, "wristband grants")
var b := TestUtils.check_eq(verifier.verify(with_guest_list, policy)["granted"], true, "guest list grants")
var c := TestUtils.check_eq(verifier.verify(all_fake, policy)["granted"], false, "all invalid denied")
return a and b and c
@@ -0,0 +1,52 @@
extends RefCounted
## Headless tests for EscalationManager and RadioSystem.
const Rules := preload("res://src/core/rules.gd")
const EscalationManager := preload("res://src/core/escalation_manager.gd")
const RadioSystem := preload("res://src/core/radio_system.gd")
func test_level_advance_and_gate() -> bool:
var mgr := EscalationManager.new(Rules.Level.EMERGENCY)
var a := TestUtils.check_eq(mgr.current_level, Rules.Level.OBSERVATION, "starts at observation")
var b := TestUtils.check(mgr.can_act(Rules.Level.FORMAL_WARNING), "warning is allowed")
var c := TestUtils.check(mgr.advance_to(Rules.Level.FORMAL_WARNING, "refused_direction"), "advanced to warning")
return a and b and c
func test_cannot_exceed_max_level() -> bool:
var mgr := EscalationManager.new(Rules.Level.BLOCKING)
var blocked := TestUtils.check(not mgr.advance_to(Rules.Level.EMERGENCY, "unavailable"), "emergency blocked by venue rules")
var stayed := TestUtils.check_eq(mgr.current_level, Rules.Level.OBSERVATION, "stayed at observation")
return blocked and stayed
func test_radio_assist_consumes_battery() -> bool:
var radio := RadioSystem.new()
var call: Dictionary = radio.request_assistance({
"location": "front steps", "description": "person refusing to leave",
"behavior": "refusing", "weapons_seen": false, "danger_level": 1,
})
var a := TestUtils.check(call["ok"], "assist call accepted")
var b := TestUtils.check(radio.battery < radio.max_battery, "battery consumed")
return a and b
func test_emergency_cooldown_blocks_spam() -> bool:
var radio := RadioSystem.new()
var first: Dictionary = radio.request_emergency({"danger_level": 3, "weapons_seen": true})
var a := TestUtils.check(first["ok"], "first emergency accepted")
var second: Dictionary = radio.request_emergency({"danger_level": 3, "weapons_seen": true})
var b := TestUtils.check(not second["ok"], "second immediately rejected")
var c := TestUtils.check(second["reason"].contains("Cooldown"), "cooldown reason given")
return a and b and c
func test_emergency_after_cooldown_expires() -> bool:
var radio := RadioSystem.new()
radio.request_emergency({"danger_level": 3, "weapons_seen": true})
radio.tick(radio.emergency_cooldown_seconds + 1.0)
var again: Dictionary = radio.request_emergency({"danger_level": 3, "weapons_seen": true})
return TestUtils.check(again["ok"], "emergency available after cooldown")
func test_dead_battery_blocks_calls() -> bool:
var radio := RadioSystem.new()
radio.battery = 0.0
var call: Dictionary = radio.request_assistance({"danger_level": 1})
return TestUtils.check(not call["ok"], "no battery means no call") and TestUtils.check(call["reason"].contains("battery"), "battery reason")
+83
View File
@@ -0,0 +1,83 @@
extends RefCounted
## Headless tests for NPC reaction rolls (PLAN.md: NPC Behavior Types).
const Rules := preload("res://src/core/rules.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const NPCController := preload("res://src/core/npc_controller.gd")
func _profile_with_defaults(id: String, npc_type: int, compliance: float) -> NpcProfile:
var p := NpcProfile.new()
p.id = id
p.npc_type = npc_type
p.base_compliance = compliance
return p
func test_tourist_complies_at_level_1() -> bool:
var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
var npc := NPCController.new()
npc.setup("t1", p, "photo", 1234, "AT_ENTRANCE")
var outcome := npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY)
var ok := TestUtils.check_eq(outcome["reaction"], Rules.Reaction.COMPLY, "base_compliance 0.9 tourist should comply at level 1")
return ok and TestUtils.check_eq(npc.decide_leave(), true, "compliant NPC leaves")
func test_trespasser_frequent_non_compliance() -> bool:
var p := _profile_with_defaults("trespasser", Rules.NpcType.TRESPASSER, 0.2)
var npc := NPCController.new()
npc.setup("t1", p, "trespass", 99, "AT_ENTRANCE")
## Several rolls at formal warning; default table for this test profile is
## from NpcProfile.default_reactions (mostly COMPLY there).
var outcomes: Array = []
for i in 5:
outcomes.append(npc.roll_reaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL))
## With compliance 0.9/0.2 variation we simply assert we get SOME reaction.
var ok := TestUtils.check(npc.highest_escalation_received >= Rules.Level.SIMPLE_DETERRENCE, "highest escalation tracked")
return ok
func test_deterministic_with_seed() -> bool:
## Same seed and inputs => same reaction sequence.
var p1 := _profile_with_defaults("guest_a", Rules.NpcType.TRESPASSER, 0.4)
var n1 := NPCController.new()
n1.setup("a", p1, "trespass", 4242, "AT_ENTRANCE")
var p2 := _profile_with_defaults("guest_b", Rules.NpcType.TRESPASSER, 0.4)
var n2 := NPCController.new()
n2.setup("b", p2, "trespass", 4242, "AT_ENTRANCE")
var seq_a: Array = []
var seq_b: Array = []
for i in 4:
seq_a.append(n1.roll_reaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL))
seq_b.append(n2.roll_reaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL))
return TestUtils.check_eq(seq_a, seq_b, "same seed produces identical reaction sequence")
func test_violent_threat_scales_to_threaten() -> bool:
## Violent archetype with default reactions: from formal warning upward there
## is a meaningful probability of THREATEN / FORCE_ENTRY. We sample enough
## rolls to see at least one THREATEN with the violent profile rng.
var p := NpcProfile.new()
p.id = "violent"
p.npc_type = Rules.NpcType.VIOLENT_THREAT
p.base_compliance = 0.05
p.reactions_by_level = {
Rules.Level.EMERGENCY: {
Rules.Reaction.THREATEN: 40,
Rules.Reaction.FORCE_ENTRY: 30,
Rules.Reaction.LEAVE: 30,
},
}
var npc := NPCController.new()
npc.setup("v1", p, "threat", 777, "AT_ENTRANCE")
var saw_threat := false
for i in 20:
if npc.roll_reaction(Rules.Level.EMERGENCY, Rules.Tone.PROFESSIONAL) == Rules.Reaction.THREATEN:
saw_threat = true
return TestUtils.check(saw_threat, "violent profile can threaten at emergency level")
func test_fsm_transitions() -> bool:
var p := _profile_with_defaults("guest", Rules.NpcType.TOURIST, 0.9)
var npc := NPCController.new()
npc.setup("g1", p, "enter", 5, "APPROACH")
var started := TestUtils.check_eq(npc.fsm.current_state, "APPROACH", "starts approaching")
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY)
var left := TestUtils.check_eq(npc.fsm.current_state, "LEAVING", "complies and leaves")
return started and left
+67
View File
@@ -0,0 +1,67 @@
extends RefCounted
## Headless tests for venue scenario data (Plan: Venues and Scenarios).
const Rules := preload("res://src/core/rules.gd")
const VenueScenario := preload("res://src/core/venue_scenario.gd")
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
func test_hollywood_building_loads() -> bool:
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
var a := TestUtils.check_eq(scenario.id, "hollywood_building", "scenario id")
var b := TestUtils.check(scenario.npc_profiles.size() > 0, "has profiles")
var c := TestUtils.check(scenario.access_policy.requires(Rules.AccessKind.WRISTBAND), "requires wristband")
return a and b and c
func test_joke_shop_rules() -> bool:
var scenario: VenueScenario = ScenarioFactory.joke_shop()
var a := TestUtils.check_eq(scenario.access_policy.minimum_age, 21, "21+ club")
var b := TestUtils.check(scenario.access_policy.require_id, "ID required")
var c := TestUtils.check(scenario.access_policy.wand_screening_required, "wand screening required")
var d := TestUtils.check(scenario.equipment.has("wand"), "wand in equipment")
return a and b and c and d
func test_tv_studio_has_all_npc_types() -> bool:
var scenario: VenueScenario = ScenarioFactory.tv_studio()
var all_types := [Rules.NpcType.TOURIST, Rules.NpcType.PERFORMER, Rules.NpcType.INFLUENCER,
Rules.NpcType.CONFUSED_VISITOR, Rules.NpcType.INTOXICATED, Rules.NpcType.TRESPASSER,
Rules.NpcType.MENTAL_CRISIS, Rules.NpcType.VIOLENT_THREAT,
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY]
var profile_types: Array = []
for p in scenario.npc_profiles:
profile_types.append(p.npc_type)
for npc_type in all_types:
if not profile_types.has(npc_type):
return TestUtils.fail("TV studio missing npc type %s" % Rules.NPC_TYPE_NAMES[npc_type])
return true
var _spawn_count := 0
func _on_spawn(_profile: NpcProfile, _intent: String, _id: String) -> void:
_spawn_count += 1
func test_shift_coordinator_runs_a_shift() -> bool:
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
var coordinator := ShiftCoordinator.new()
coordinator.setup(scenario, 60.0, 1234)
_spawn_count = 0
coordinator.npc_spawn_requested.connect(_on_spawn)
coordinator.start()
for i in 60:
coordinator.tick(1.0)
if not coordinator.running:
break
var a := TestUtils.check(coordinator.elapsed_s > 0.0, "time advanced")
var b := TestUtils.check(_spawn_count > 0, "NPCs spawned during shift")
var review := coordinator.build_review()
var c := TestUtils.check_dict_has(review, "reputation", "review has reputation")
return a and b and c
func test_emergency_event_requires_emergency() -> bool:
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
var found := false
for event in scenario.event_pool:
if event.get("requires_emergency", false):
found = true
return TestUtils.check(found, "scenario contains a scripted emergency event")