Ship the 0.8.0 first-hour and campaign milestone
Full build / Import + headless tests (push) Successful in 41s
Full build / Full desktop build + optional release (push) Successful in 4m18s

Add the campaign shell, pause-safe checkpoint resume, post-shift campaign
continue, confirmed campaign reset, difficulty presets, searchable Help,
content-matrix validation, recovery events, contextual dialogue, policy/debrief
context and eligible-event previews. Extend persistence with atomic separate
files and add campaign/checkpoint/tutorial smokes. Headless 247/247, Python
94/94, tutorial/campaign/session/scenario smokes green.
This commit is contained in:
zwitschi committed 2026-11-01 21:21:43 +01:00
1 parent 86332cd401
commit aa51ee331e
38 files changed
+1361 -1144

No files matched your search

+31 -2
View File
@@ -4,6 +4,8 @@ extends Node
const SHIFT_RESULTS_FILE := "shift_results.json"
const SETTINGS_FILE := "settings.json"
const CAMPAIGN_FILE := "campaign.json"
const SHIFT_STATE_FILE := "shift_state.json"
func _load_json(file_name: String) -> Variant:
var path := "user://" + file_name
@@ -17,13 +19,17 @@ func _load_json(file_name: String) -> Variant:
func _save_json(file_name: String, data: Variant) -> Error:
var path := "user://" + file_name
var temp := path + ".tmp"
var text := JSON.stringify(data, "\t")
var f := FileAccess.open(path, FileAccess.WRITE)
var f := FileAccess.open(temp, FileAccess.WRITE)
if f == null:
return FileAccess.get_open_error()
f.store_string(text)
f.close()
return OK
# Replace the target only after the complete JSON has been written, so a
# crash cannot leave a partial save in place.
return DirAccess.rename_absolute(ProjectSettings.globalize_path(temp),
ProjectSettings.globalize_path(path))
## Load previous shift results. Returns Array of Dictionaries or [].
func load_shift_results() -> Array:
@@ -38,6 +44,29 @@ func save_shift_result(result: Dictionary) -> Error:
results.append(result)
return _save_json(SHIFT_RESULTS_FILE, results)
## Campaign/session progression (separate from settings and shift history).
func load_campaign() -> Dictionary:
var data = _load_json(CAMPAIGN_FILE)
return data if data is Dictionary else {}
func save_campaign(data: Dictionary) -> Error:
return _save_json(CAMPAIGN_FILE, data)
func reset_campaign() -> Error:
return _save_json(CAMPAIGN_FILE, {})
## Mid-shift checkpoint. Kept separate so a completed shift history is never
## overwritten by a resumable snapshot.
func load_shift_state() -> Dictionary:
var data = _load_json(SHIFT_STATE_FILE)
return data if data is Dictionary else {}
func save_shift_state(data: Dictionary) -> Error:
return _save_json(SHIFT_STATE_FILE, data)
func clear_shift_state() -> Error:
return _save_json(SHIFT_STATE_FILE, {})
## Defaults for player settings. Kept in code so a missing or partial
## settings.json still produces a usable config.
const DEFAULT_SETTINGS := {
+41 -1
View File
@@ -21,6 +21,11 @@ static func shared_profiles() -> Array[NpcProfile]:
tourist.npc_type = Rules.NpcType.TOURIST
tourist.temperament = 0.1
tourist.base_compliance = 0.9
tourist.context_lines = {
"refusal": ["I just want a photo, I'm not staying.", "I didn't know this entrance was restricted."],
"deescalation": ["Okay, I'll move to the sidewalk.", "Sorry, I'll put the camera away."],
"recognition": ["You remember me from the tour, right?"],
}
tourist.dialogue_lines = [
"Oh sure, sorry, just taking a picture.",
"Is there a better spot to stand?",
@@ -34,6 +39,10 @@ static func shared_profiles() -> Array[NpcProfile]:
confused.npc_type = Rules.NpcType.CONFUSED_VISITOR
confused.temperament = 0.2
confused.base_compliance = 0.85
confused.context_lines = {
"refusal": ["I'm sure the event is here. Let me check my phone."],
"deescalation": ["Oh, I understand. I'll check with the box office."],
}
confused.dialogue_lines = [
"Wait, this isn't the entrance?",
"Sorry about that, I'm a bit lost.",
@@ -389,6 +398,16 @@ static func hollywood_building() -> VenueScenario:
"base_weight": 1.0,
"requires_emergency": true,
},
{
"id": "building_apology_return",
"phase": Rules.Phase.EVENING,
"kind": "recovery",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "enter",
"time_range_s": Vector2(620, 800),
"probability": 0.22,
"base_weight": 1.0,
},
]
return scenario
@@ -451,7 +470,7 @@ static func joke_shop() -> VenueScenario:
scenario.npc_profiles = _filter_profiles([
"tourist", "confused_visitor", "authorized_guest", "authorized_staff",
"influencer", "intoxicated", "trespasser", "violent_threat",
"street_performer", "influencer", "intoxicated", "trespasser", "violent_threat",
])
scenario.event_pool = [
@@ -533,6 +552,17 @@ static func joke_shop() -> VenueScenario:
"probability": 0.5,
"base_weight": 1.0,
},
{
"id": "joke_denied_guest_return",
"phase": Rules.Phase.EVENING,
"kind": "recovery",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "enter",
"time_range_s": Vector2(650, 830),
"probability": 0.2,
"base_weight": 1.0,
"override_access": {"has_id": true, "has_ticket": true},
},
]
return scenario
@@ -648,6 +678,16 @@ static func tv_studio() -> VenueScenario:
"probability": 0.45,
"base_weight": 1.0,
},
{
"id": "studio_manager_check",
"phase": Rules.Phase.EVENING,
"kind": "recovery",
"npc_type": Rules.NpcType.AUTHORIZED_STAFF,
"intent": "enter",
"time_range_s": Vector2(620, 820),
"probability": 0.2,
"base_weight": 1.0,
},
]
return scenario
+64
View File
@@ -0,0 +1,64 @@
class_name Campaign
extends RefCounted
## Small campaign/session progression model. Venue rules stay in VenueScenario;
## this only owns order, completed shifts, difficulty and the next venue.
const ORDER := ["hollywood_building", "joke_shop", "tv_studio"]
var index := 0
var shift_number := 1
var completed: Array[String] = []
var difficulty := "normal"
var complete := false
var last_grade := ""
func setup(data: Dictionary = {}) -> void:
index = clampi(int(data.get("index", 0)), 0, ORDER.size())
shift_number = maxi(1, int(data.get("shift_number", 1)))
difficulty = str(data.get("difficulty", "normal"))
completed.clear()
for item in data.get("completed", []):
var value := str(item)
if value in ORDER and value not in completed:
completed.append(value)
complete = bool(data.get("complete", index >= ORDER.size()))
last_grade = str(data.get("last_grade", ""))
func next_venue() -> String:
if complete or index >= ORDER.size():
return ""
return ORDER[index]
func summary() -> Dictionary:
return {
"index": index,
"next_venue": next_venue(),
"shift_number": shift_number,
"difficulty": difficulty,
"complete": complete,
"completed": completed.duplicate(),
"last_grade": last_grade,
}
## Record one finished shift. Idempotent per venue for a single campaign run.
func record_shift(scenario_id: String, review: Dictionary = {}) -> bool:
if complete:
return false
if scenario_id != next_venue():
return false
if scenario_id not in completed:
completed.append(scenario_id)
var rep: Dictionary = review.get("reputation", {})
last_grade = str(rep.get("grade", last_grade))
index += 1
shift_number = index + 1
if index >= ORDER.size():
complete = true
return true
func set_difficulty(value: String) -> void:
if value in ["easy", "normal", "hard"]:
difficulty = value
func reset() -> void:
setup({})
+8
View File
@@ -65,3 +65,11 @@ func summary() -> Dictionary:
"attention": attention,
"panic": crowd_panic,
}
func snapshot() -> Dictionary:
return summary()
func restore(data: Dictionary) -> void:
mood = clampf(float(data.get("mood", 0.05)), 0.0, 1.0)
attention = clampf(float(data.get("attention", 0.0)), 0.0, 1.0)
crowd_panic = bool(data.get("panic", mood >= 0.75))
+47 -5
View File
@@ -11,6 +11,9 @@ signal event_ready(event_data: Dictionary)
var rng := RandomNumberGenerator.new()
var _scenario: VenueScenario = null
var _weighted_pool: Array[Dictionary] = []
## Difficulty/pacing knob: <1 is calmer, >1 sends more events and routine traffic.
var spawn_rate_multiplier := 1.0
var _recent_scripted: Array[String] = []
func _init() -> void:
pass
@@ -38,17 +41,47 @@ func pull_due(elapsed_s: float, phase: int) -> Array[Dictionary]:
continue
var prob: float = event.get("probability", 0.0)
var base: float = event.get("base_weight", 1.0)
var event_id := str(event.get("id", ""))
if str(event.get("kind", "routine")) == "scripted" and event_id in _recent_scripted:
continue
var spawn_rate: float = event.get("spawn_rate", 0.0)
var due_event := false
if spawn_rate > 0.0:
# Rate-based spawning: emit at most one per second window.
if rng.randf() < spawn_rate:
if rng.randf() < clampf(spawn_rate * spawn_rate_multiplier, 0.0, 1.0):
if rng.randf() < prob:
due.append(event.duplicate())
due_event = true
else:
if rng.randf() < prob * base:
due.append(event.duplicate())
if rng.randf() < clampf(prob * base * spawn_rate_multiplier, 0.0, 1.0):
due_event = true
if due_event:
due.append(event.duplicate())
if str(event.get("kind", "routine")) == "scripted" and event_id != "":
_recent_scripted.append(event_id)
if _recent_scripted.size() > 4:
_recent_scripted.pop_front()
return due
## Debug/content preview: events that could be selected now, without consuming
## RNG or marking anything as recently played.
func preview(elapsed_s: float, phase: int) -> Array[String]:
var out: Array[String] = []
if _scenario == null:
return out
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 int(event.get("phase", -1)) != phase:
continue
var event_id := str(event.get("id", ""))
if str(event.get("kind", "routine")) == "scripted" and event_id in _recent_scripted:
continue
out.append("%s (%s)" % [event_id, str(event.get("kind", "routine"))])
if out.size() >= 8:
break
return out
func fallback_routine_traffic(phase: int) -> Array[Dictionary]:
## Cheap routine traffic so the street never feels empty.
var out: Array[Dictionary] = []
@@ -56,10 +89,19 @@ func fallback_routine_traffic(phase: int) -> Array[Dictionary]:
if pool.is_empty():
return out
for i in 3:
if rng.randf() < 0.35:
if rng.randf() < clampf(0.35 * spawn_rate_multiplier, 0.0, 1.0):
out.append(pool[rng.randi_range(0, pool.size() - 1)].duplicate())
return out
func snapshot() -> Dictionary:
return {"rng_state": rng.state, "recent_scripted": _recent_scripted.duplicate()}
func restore(data: Dictionary) -> void:
rng.state = int(data.get("rng_state", rng.state))
_recent_scripted.clear()
for event_id in data.get("recent_scripted", []):
_recent_scripted.append(str(event_id))
func _routine_pool(phase: int) -> Array:
var out: Array = []
for event in _weighted_pool:
+17
View File
@@ -96,3 +96,20 @@ func to_dict() -> Dictionary:
"incidents": incidents.duplicate(true),
"open_count": _open_incidents.size(),
}
func snapshot() -> Dictionary:
return {
"incidents": incidents.duplicate(true),
"open_incidents": _open_incidents.duplicate(true),
}
func restore(data: Dictionary) -> void:
incidents.clear()
_open_incidents.clear()
for incident in data.get("incidents", []):
if incident is Dictionary:
incidents.append((incident as Dictionary).duplicate(true))
for npc_id in (data.get("open_incidents", {}) as Dictionary).keys():
var incident = (data["open_incidents"] as Dictionary)[npc_id]
if incident is Dictionary:
_open_incidents[str(npc_id)] = (incident as Dictionary).duplicate(true)
+10 -4
View File
@@ -71,10 +71,13 @@ func _build_fsm(initial_state: String) -> void:
fsm.add_transition("THREATENING", "FORCING_ENTRY")
fsm.start(initial_state)
func roll_reaction(level: int) -> int:
## Weighted pick from profile's reaction table for `level`.
func roll_reaction(level: int, compliance_bonus: float = 0.0) -> int:
## Weighted pick from profile's reaction table for `level`. Difficulty can
## nudge compliance without changing the authored weights.
highest_escalation_received = maxi(highest_escalation_received, level)
var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate()
if compliance_bonus != 0.0:
weights[Rules.Reaction.COMPLY] = maxf(0.0, float(weights.get(Rules.Reaction.COMPLY, 0)) + compliance_bonus * 100.0)
var roll_value := rng.randf()
var total := 0.0
for key in weights.keys():
@@ -88,9 +91,12 @@ func roll_reaction(level: int) -> int:
return key
return Rules.Reaction.LEAVE
func apply_player_action(level: int) -> Dictionary:
func context_line(context: String) -> String:
return profile.line_for(context, rng) if profile else ""
func apply_player_action(level: int, compliance_bonus: float = 0.0) -> Dictionary:
## Returns reaction to the player's action and updates FSM when needed.
var performed_reaction := roll_reaction(level)
var performed_reaction := roll_reaction(level, compliance_bonus)
reaction = performed_reaction
reaction_set = true
_update_state_for_reaction(performed_reaction)
+17
View File
@@ -61,10 +61,27 @@ const Rules := preload("res://src/core/rules.gd")
## Dialogue lines this NPC uses. Falls back to generic lines when empty.
@export var dialogue_lines: Array[String] = []
## Context-specific dialogue: keys such as "reason", "refusal", "deescalation",
## "recognition" and "group". Missing contexts fall back to `dialogue_lines`.
@export var context_lines: Dictionary = {}
## Items the NPC is carrying, used by the handheld wand (The Joke Shop).
## Values are item names: "keys", "belt", "phone", "weapon", "knife", etc.
@export var carried_items: Array[String] = []
func line_for(context: String, rng: RandomNumberGenerator = null) -> String:
var pool: Array = []
if context_lines.has(context):
pool = context_lines[context]
elif context == "reason":
pool = dialogue_lines
if pool.is_empty():
return ""
var index := 0
if rng != null:
index = rng.randi_range(0, pool.size() - 1)
return str(pool[index])
func pickup_reactions_per_level(level: int) -> Dictionary:
if reactions_by_level.has(level):
return reactions_by_level[level]
+17
View File
@@ -123,6 +123,23 @@ func tick(delta: float) -> void:
if event_bus:
event_bus.responder_arrived.emit(call)
func snapshot() -> Dictionary:
return {
"battery": battery,
"emergency_cooldown_remaining": emergency_cooldown_remaining,
"accepting_calls": accepting_calls,
"pending_calls": pending_calls.duplicate(true),
}
func restore(data: Dictionary) -> void:
battery = clampf(float(data.get("battery", 100.0)), 0.0, max_battery)
emergency_cooldown_remaining = maxf(0.0, float(data.get("emergency_cooldown_remaining", 0.0)))
accepting_calls = bool(data.get("accepting_calls", true))
pending_calls.clear()
for call in data.get("pending_calls", []):
if call is Dictionary:
pending_calls.append((call as Dictionary).duplicate(true))
## Determine the responder's resolution outcome for a call. The responder
## commands, restrains, evacuates, or arrests based on the reported danger
## level and whether a weapon was seen (PLAN.md: Level 5 Emergency Response).
+10
View File
@@ -52,3 +52,13 @@ func summary() -> Dictionary:
"grade": grade_for(rating),
"modifiers": _modifiers.duplicate(true),
}
func snapshot() -> Dictionary:
return {"rating": rating, "modifiers": _modifiers.duplicate(true)}
func restore(data: Dictionary) -> void:
rating = clampf(float(data.get("rating", 70.0)), 0.0, 100.0)
_modifiers.clear()
for modifier in data.get("modifiers", []):
if modifier is Dictionary:
_modifiers.append((modifier as Dictionary).duplicate(true))
+60
View File
@@ -0,0 +1,60 @@
class_name ScenarioValidator
extends RefCounted
## Validates authored venue data before a shift. Keeps content-matrix failures
## out of runtime: every event must reference a known profile and a valid phase,
## event ids must be unique, and the matrix must cover enough kinds/phases.
const Rules := preload("res://src/core/rules.gd")
static func validate(scenario: Resource) -> Array[String]:
var problems: Array[String] = []
if scenario == null:
return ["scenario is null"]
var profile_types := {}
for profile in scenario.npc_profiles:
if profile != null:
profile_types[int(profile.npc_type)] = true
var ids := {}
var routine := 0
var scripted := 0
var recovery := 0
var phases := {}
for event in scenario.event_pool:
if not (event is Dictionary):
problems.append("event is not a dictionary")
continue
var event_id := str(event.get("id", ""))
if event_id == "":
problems.append("event has no id")
elif ids.has(event_id):
problems.append("duplicate event id: %s" % event_id)
ids[event_id] = true
var phase := int(event.get("phase", -1))
if phase < Rules.Phase.EARLY or phase > Rules.Phase.LATE:
problems.append("%s has invalid phase %d" % [event_id, phase])
var npc_type := int(event.get("npc_type", -1))
if not profile_types.has(npc_type):
problems.append("%s references missing profile type %d" % [event_id, npc_type])
if str(event.get("intent", "")) == "":
problems.append("%s has no intent" % event_id)
var kind := str(event.get("kind", "routine"))
if kind == "routine":
routine += 1
elif kind == "scripted":
scripted += 1
elif kind == "recovery":
recovery += 1
elif kind != "routine":
problems.append("%s has unknown kind %s" % [event_id, kind])
phases[phase] = true
if scenario.event_pool.size() < 12:
problems.append("event matrix has %d events; need at least 12" % scenario.event_pool.size())
if routine < 6:
problems.append("event matrix needs at least 6 routine events")
if scripted < 2:
problems.append("event matrix needs at least 2 scripted events")
if recovery < 1:
problems.append("event matrix needs at least 1 recovery event")
if phases.size() < 2:
problems.append("event matrix must cover at least 2 shift phases")
return problems
+34
View File
@@ -155,5 +155,39 @@ func build_review() -> Dictionary:
"completed_at_ms": Time.get_ticks_msec(),
}
func snapshot() -> Dictionary:
return {
"scenario_id": scenario.id if scenario else "",
"duration_s": duration_s,
"elapsed_s": elapsed_s,
"current_phase": current_phase,
"spawned_count": spawned_count,
"responder_outcomes": responder_outcomes.duplicate(),
"investigations": investigations.duplicate(),
"recorder": recorder.snapshot(),
"reputation": reputation.snapshot(),
"crowd": crowd.snapshot(),
"radio": radio.snapshot(),
"scheduler": scheduler.snapshot(),
}
func restore(data: Dictionary) -> void:
duration_s = float(data.get("duration_s", duration_s))
elapsed_s = float(data.get("elapsed_s", 0.0))
current_phase = int(data.get("current_phase", Rules.Phase.EARLY))
spawned_count = int(data.get("spawned_count", 0))
responder_outcomes.clear()
for value in data.get("responder_outcomes", []):
responder_outcomes.append(str(value))
investigations.clear()
for value in data.get("investigations", []):
investigations.append(str(value))
recorder.restore(data.get("recorder", {}))
reputation.restore(data.get("reputation", {}))
crowd.restore(data.get("crowd", {}))
radio.restore(data.get("radio", {}))
scheduler.restore(data.get("scheduler", {}))
_shift_finished = false
func spawn_context_dict(profile: NpcProfile, intent: String) -> Dictionary:
return {"profile": profile, "intent": intent}
+227 -5
View File
@@ -32,6 +32,8 @@ const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
const Tutorial := preload("res://src/game/tutorial.gd")
const TutorialOverlay := preload("res://src/game/ui/tutorial_overlay.gd")
const Campaign := preload("res://src/core/campaign.gd")
const HelpOverlay := preload("res://src/game/ui/help_overlay.gd")
var world_builder: WorldBuilder = null
## Dedicated container for the procedural world, so switching venues can rebuild
@@ -94,8 +96,12 @@ var pause_overlay: PauseOverlay = null
var settings_panel: SettingsPanel = null
var event_timeline: EventTimeline = null
var tutorial_overlay: TutorialOverlay = null
var help_overlay: HelpOverlay = null
var tutorial: Tutorial = null
var tutorial_active := false
var campaign: Campaign = null
var _resuming := false
var _resume_pending: Dictionary = {}
var _tutorial_start_pos := Vector3.ZERO
var _tutorial_start_yaw := 0.0
var _tutorial_start_pitch := 0.0
@@ -115,6 +121,12 @@ var active_scenario_id := "hollywood_building"
var scenario_duration_s: float = 900.0
var _wand_cooldown := 0.0
const WAND_COOLDOWN_S := 0.8
## Difficulty only changes pacing/forgiveness; the de-escalation rules stay fixed.
const DIFFICULTY := {
"easy": {"spawn": 0.7, "compliance": 0.12, "report": 0.7},
"normal": {"spawn": 1.0, "compliance": 0.0, "report": 1.0},
"hard": {"spawn": 1.35, "compliance": -0.08, "report": 1.5},
}
var scene_root: Node3D = null
var npcs: Array = []
var npc_counter := 0
@@ -168,12 +180,15 @@ func _ready() -> void:
scenario_duration_s = selected_scenario.shift_duration_s
coordinator = ShiftCoordinator.new()
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.scheduler.spawn_rate_multiplier = float(_difficulty()["spawn"])
coordinator.npc_spawn_requested.connect(_spawn_npc)
coordinator.shift_ended.connect(_on_shift_ended)
_connect_radio()
_connect_crowd()
_build_ui()
campaign = Campaign.new()
campaign.setup(Save.load_campaign() if Save else {})
# The HUD does not exist yet when settings are loaded, so push them again now
# that it does (otherwise the saved font scale is ignored until the next apply).
_apply_settings(player_settings)
@@ -275,6 +290,8 @@ func _rebuild_world_for_venue(id: String) -> void:
func restart_shift() -> void:
if shift_review_panel:
shift_review_panel.hide()
if Save:
Save.clear_shift_state()
_reset_shift_state()
_start_shift()
@@ -298,6 +315,7 @@ func _reset_shift_state() -> void:
hud.set_body_camera(false)
scenario_duration_s = selected_scenario.shift_duration_s
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.scheduler.spawn_rate_multiplier = float(_difficulty()["spawn"])
_connect_radio()
_connect_crowd()
_last_phase = -1
@@ -328,11 +346,13 @@ func _show_start_menu() -> void:
guard.input_locked = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
if start_menu:
start_menu.open()
start_menu.open(campaign.summary() if campaign else {})
func _on_venue_selected(scenario_id: String) -> void:
if start_menu and start_menu.visible:
start_menu.close()
if not _resuming and Save:
Save.clear_shift_state()
active_scenario_id = scenario_id
_venue_index = maxi(0, VENUE_ORDER.find(scenario_id))
selected_scenario = _build_scenario(scenario_id)
@@ -341,8 +361,17 @@ func _on_venue_selected(scenario_id: String) -> void:
hud.set_wand_icon(false)
_reset_shift_state()
_start_shift()
if not _resume_pending.is_empty():
var pending := _resume_pending
_resume_pending = {}
_restore_shift_state(pending)
_resuming = false
## Apply loaded settings to the guard and HUD.
func _difficulty() -> Dictionary:
return DIFFICULTY.get(str(player_settings.get("ai_difficulty", "normal")),
DIFFICULTY["normal"])
func _apply_settings(settings: Dictionary) -> void:
player_settings = settings
if guard:
@@ -355,13 +384,30 @@ func _apply_settings(settings: Dictionary) -> void:
func _on_settings_applied(settings: Dictionary) -> void:
_apply_settings(settings)
if campaign:
campaign.set_difficulty(str(settings.get("ai_difficulty", "normal")))
if Save:
Save.save_campaign(campaign.summary())
if coordinator:
coordinator.scheduler.spawn_rate_multiplier = float(_difficulty()["spawn"])
if start_menu and start_menu.visible:
start_menu.open()
start_menu.open(campaign.summary() if campaign else {})
elif _shift_running:
_open_pause()
## Clear the tutorial completion/skip flags and restart the Hollywood shift so
## the next player can replay the first-shift lesson from Settings.
func _on_reset_campaign() -> void:
if Save:
Save.reset_campaign()
if campaign:
campaign.setup({})
if settings_panel:
settings_panel.close()
if pause_overlay:
pause_overlay.close()
_show_start_menu()
func _on_replay_tutorial() -> void:
player_settings["tutorial_completed"] = false
player_settings["tutorial_skipped"] = false
@@ -373,6 +419,10 @@ func _on_replay_tutorial() -> void:
pause_overlay.close()
_on_venue_selected("hollywood_building")
func _open_help() -> void:
if help_overlay:
help_overlay.open(selected_scenario.policy_summary if selected_scenario else "")
func _open_settings() -> void:
if start_menu:
start_menu.close()
@@ -384,6 +434,7 @@ func _open_settings() -> void:
func _open_pause() -> void:
if pause_overlay == null or not _shift_running:
return
_save_shift_checkpoint()
if _close_top_modal():
pass
pause_overlay.open()
@@ -427,6 +478,7 @@ func _build_ui() -> void:
add_child(shift_review_panel)
shift_review_panel.hide()
shift_review_panel.restart_requested.connect(_on_review_restart)
shift_review_panel.continue_requested.connect(_on_review_continue)
shift_review_panel.timeline_requested.connect(_open_timeline)
shift_review_panel.visible = false
@@ -443,6 +495,7 @@ func _build_ui() -> void:
settings_panel.hide()
settings_panel.applied.connect(_on_settings_applied)
settings_panel.replay_tutorial_requested.connect(_on_replay_tutorial)
settings_panel.reset_campaign_requested.connect(_on_reset_campaign)
event_timeline = EventTimeline.new()
add_child(event_timeline)
@@ -452,10 +505,16 @@ func _build_ui() -> void:
tutorial_overlay = TutorialOverlay.new()
add_child(tutorial_overlay)
help_overlay = HelpOverlay.new()
add_child(help_overlay)
help_overlay.closed.connect(_refresh_freeze)
start_menu = StartMenu.new()
add_child(start_menu)
start_menu.hide()
start_menu.venue_selected.connect(_on_venue_selected)
start_menu.continue_requested.connect(_on_campaign_continue)
start_menu.help_requested.connect(_open_help)
start_menu.settings_requested.connect(_open_settings)
start_menu.quit_requested.connect(func(): get_tree().quit())
@@ -476,10 +535,147 @@ func _on_timeline_closed() -> void:
_timeline_from_review = false
_refresh_freeze()
## Pause-safe checkpoint: enough state to resume the current shift without
## replaying completed interactions. The scheduler RNG is restored so future
## routine traffic does not repeat the same sequence.
func _save_shift_checkpoint() -> void:
if Save == null or not _shift_running or coordinator == null:
return
var npc_rows: Array = []
for avatar in npcs:
if not is_instance_valid(avatar):
continue
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
var profile: Object = controller.profile if controller else null
npc_rows.append({
"npc_id": str(controller.npc_id) if controller else "",
"npc_type": int(profile.npc_type) if profile else Rules.NpcType.TOURIST,
"profile_id": str(profile.id) if profile else "",
"intent": str(controller.intent) if controller else "roam",
"position": _vec3_array(avatar.global_position),
"target": _vec3_array(avatar.target_position),
"carried_items": controller.profile.carried_items.duplicate() if controller and controller.profile else [],
"leave_requested": bool(controller.leave_requested) if controller else false,
"player_recognized": bool(controller.player_recognized) if controller else false,
"access_override": (controller.access_override as Dictionary).duplicate(true) if controller else {},
"ephemeral": bool(avatar.ephemeral),
"lifetime_s": float(avatar.lifetime_s),
})
var _save_err := Save.save_shift_state({
"version": 1,
"scenario_id": active_scenario_id,
"guard_position": _vec3_array(guard.global_position) if guard else [0.0, 0.0, 0.0],
"guard_yaw": guard.rotation.y if guard else 0.0,
"head_pitch": guard.head.rotation.x if guard else 0.0,
"door_locked": door.locked,
"flashlight_on": flashlight_on,
"body_camera_on": body_camera_on,
"npc_counter": npc_counter,
"coordinator": coordinator.snapshot(),
"npcs": npc_rows,
})
func _vec3_array(value: Vector3) -> Array:
return [value.x, value.y, value.z]
func _vec3_from(value: Variant, fallback: Vector3 = Vector3.ZERO) -> Vector3:
if value is Vector3:
return value
if value is Array and (value as Array).size() >= 3:
return Vector3(float(value[0]), float(value[1]), float(value[2]))
return fallback
func _resume_shift(state: Dictionary) -> void:
if state.is_empty() or not state.has("scenario_id"):
return
_resume_pending = state
_resuming = true
_on_venue_selected(str(state["scenario_id"]))
func _restore_shift_state(state: Dictionary) -> void:
if coordinator == null or not state.has("coordinator"):
return
coordinator.restore(state.get("coordinator", {}))
coordinator.running = true
npc_counter = int(state.get("npc_counter", 0))
npcs.clear()
for row in state.get("npcs", []):
if not (row is Dictionary):
continue
var data: Dictionary = row
var profile: NpcProfile = selected_scenario.profile_for(int(data.get("npc_type", Rules.NpcType.TOURIST)))
if profile == null:
profile = NpcProfile.new()
profile.id = str(data.get("profile_id", "guest"))
profile.display_name = str(data.get("profile_id", "Guest")).replace("_", " ").capitalize()
profile.npc_type = int(data.get("npc_type", Rules.NpcType.TOURIST))
_spawn_npc(profile, str(data.get("intent", "roam")), str(data.get("npc_id", "resumed")),
{"kind": "scripted"})
if npcs.is_empty():
continue
var avatar: Node3D = npcs[npcs.size() - 1]
avatar.global_position = _vec3_from(data.get("position"), avatar.global_position)
avatar.target_position = _vec3_from(data.get("target"), avatar.target_position)
var controller: Object = avatar.get_npc_controller()
if controller:
controller.leave_requested = bool(data.get("leave_requested", false))
controller.player_recognized = bool(data.get("player_recognized", false))
controller.access_override = (data.get("access_override", {}) as Dictionary).duplicate(true)
controller.profile.carried_items = []
for item in data.get("carried_items", []):
controller.profile.carried_items.append(str(item))
avatar.ephemeral = bool(data.get("ephemeral", false))
avatar.lifetime_s = float(data.get("lifetime_s", 60.0))
# Restore presentation state after the world/NPC reset.
if guard:
guard.global_position = _vec3_from(state.get("guard_position"), guard.global_position)
guard.rotation.y = float(state.get("guard_yaw", guard.rotation.y))
guard.head.rotation.x = float(state.get("head_pitch", 0.0))
door.set_locked(bool(state.get("door_locked", false)))
if world_builder:
world_builder.set_door_locked(door.locked)
flashlight_on = bool(state.get("flashlight_on", false))
body_camera_on = bool(state.get("body_camera_on", false))
if hud:
hud.set_door(door.locked)
hud.set_flashlight(flashlight_on)
hud.set_body_camera(body_camera_on)
if guard:
var lamp := guard.get_node_or_null("Head/Flashlight") as SpotLight3D
if lamp:
lamp.visible = flashlight_on
_last_phase = -1
_apply_phase_lighting()
_refresh_freeze()
## "Run another shift" on the review screen returns to the venue menu.
func _on_review_restart() -> void:
_show_start_menu()
## Campaign "continue" starts the next venue; completing the last venue returns
## to the free-play menu.
func _on_review_continue() -> void:
if shift_review_panel:
shift_review_panel.visible = false
var next_id := campaign.next_venue() if campaign else ""
if next_id == "":
_show_start_menu()
return
_on_venue_selected(next_id)
func _on_campaign_continue() -> void:
var saved := Save.load_shift_state() if Save else {}
if not saved.is_empty() and saved.has("scenario_id"):
_resume_shift(saved)
return
if campaign == null:
return
var next_id := campaign.next_venue()
if next_id == "":
_show_start_menu()
else:
_on_venue_selected(next_id)
func _add_crosshair() -> void:
var layer := CanvasLayer.new()
layer.layer = 9
@@ -558,7 +754,7 @@ func _apply_phase_lighting() -> void:
## captures and smokes pass --script and are explicitly exempt. A tutorial smoke
## can opt back in with a user `--tutorial` argument.
func _maybe_start_tutorial() -> void:
if tutorial_active or active_scenario_id != "hollywood_building":
if tutorial_active or _resuming or active_scenario_id != "hollywood_building":
return
if bool(player_settings.get("tutorial_completed", false)) \
or bool(player_settings.get("tutorial_skipped", false)):
@@ -839,7 +1035,14 @@ func _on_shift_ended(review: Dictionary) -> void:
# Persist the finished shift locally (Save autoload, user:// JSON).
if Save:
Save.save_shift_result(review)
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
Save.clear_shift_state()
if campaign:
campaign.record_shift(active_scenario_id, review)
if Save:
Save.save_campaign(campaign.summary())
shift_review_panel.show_review(review, selected_scenario,
Save.load_shift_results() if Save else [],
campaign.summary() if campaign else {})
_tutorial_advance("review_opened")
_refresh_freeze()
@@ -1323,7 +1526,8 @@ func _apply_interaction(level: int, action_name: String) -> void:
if controller == null or coordinator == null:
return
_ensure_incident(controller)
var outcome: Dictionary = controller.apply_player_action(level)
var outcome: Dictionary = controller.apply_player_action(
level, float(_difficulty()["compliance"]))
var npc_id: String = controller.npc_id
coordinator.recorder.add_event(npc_id, {
"type": "player_action",
@@ -1348,6 +1552,15 @@ func _apply_interaction(level: int, action_name: String) -> void:
hud.set_escalation(level)
if audio:
audio.play_escalation(level)
var context := ""
if outcome.get("reaction", -1) in [Rules.Reaction.COMPLY, Rules.Reaction.LEAVE]:
context = "deescalation"
elif outcome.get("reaction", -1) in [Rules.Reaction.ARGUE, Rules.Reaction.RECORD]:
context = "refusal"
if context != "":
var line: String = controller.context_line(context)
if line != "":
hud.log("\"%s\"" % line)
_react_to_outcome(controller, outcome)
## Level 1 eye contact: a distinct, quieter cue than raise-hand (PLAN.md). It
@@ -1368,6 +1581,9 @@ func _recognize_target() -> void:
return
_ensure_incident(controller)
controller.player_recognized = true
var recognition_line: String = controller.context_line("recognition")
if recognition_line != "":
hud.log("\"%s\"" % recognition_line)
coordinator.recorder.add_access_evidence(controller.npc_id, {
"kind": "recognition",
"source": "guard_decision",
@@ -1486,6 +1702,7 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
return
_tutorial_advance("radio_submitted")
var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth)
hits = int(ceil(float(hits) * float(_difficulty()["report"])))
if hits > 0:
coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report")
hud.log(Strings.fmt("radio.inaccurate", [4 * hits]))
@@ -1757,7 +1974,12 @@ func _physics_process(delta: float) -> void:
if coordinator.current_phase != _last_phase:
_last_phase = coordinator.current_phase
_apply_phase_lighting()
if hud and not tutorial_active:
hud.log("Objective: %s — keep the entrance clear and verify access." % str(
Rules.PHASE_NAMES.get(coordinator.current_phase, "Early")))
hud.set_shift_state(coordinator, selected_scenario)
var preview := coordinator.scheduler.preview(coordinator.elapsed_s, coordinator.current_phase)
hud.refresh_debug(coordinator, npcs, preview)
_perception_pass()
## Perception pass: NPCs notice the guard's proximity (PLAN.md: Level 0
+109
View File
@@ -0,0 +1,109 @@
class_name HelpOverlay
extends CanvasLayer
## New-player help: controls, interaction flow and the current venue policy.
const Strings := preload("res://src/core/strings.gd")
const InputBindings := preload("res://src/core/input_bindings.gd")
signal closed()
var _root: Control = null
var _search: LineEdit = null
var _body: Label = null
var _policy: String = ""
var _lines: Array[String] = []
const CONTROL_LINES := [
"Movement: W/A/S/D · mouse look · Shift precision walk",
"Focus: crosshair cone · Tab / mouse wheel cycles nearby people",
"Action wheel: Space or left click · A/D or arrows step · Space confirms",
"Inspect access: E · Wand: C · Polite direction: Q",
"Warning: 4 · Blocking: 5 · Security radio: 6",
"Emergency radio: R, hold the on-screen button to confirm",
"Body camera: B · Flashlight: G · Door lock: L",
"Pause: Esc · Debug overlay: F",
]
func _init() -> void:
layer = 23
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "HelpRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var dim := ColorRect.new()
dim.color = Color(0.02, 0.03, 0.05, 0.86)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(center)
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(620, 0)
column.add_theme_constant_override("separation", 10)
center.add_child(column)
var title := Label.new()
title.text = "Help"
title.add_theme_font_size_override("font_size", 30)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
_search = LineEdit.new()
_search.placeholder_text = "Filter controls…"
_search.text_changed.connect(_refresh)
column.add_child(_search)
_body = Label.new()
_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_body.custom_minimum_size = Vector2(600, 330)
_body.vertical_alignment = VERTICAL_ALIGNMENT_TOP
column.add_child(_body)
var close := Button.new()
close.text = "Close"
close.pressed.connect(func(): close_panel())
close.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
column.add_child(close)
func open(policy: String = "") -> void:
_policy = policy
visible = true
_root.visible = true
_search.text = ""
_refresh("")
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func close_panel() -> void:
visible = false
_root.visible = false
closed.emit()
func _refresh(filter: String) -> void:
_lines = []
var needle := filter.strip_edges().to_lower()
var dynamic := []
for action in InputBindings.REMAPPABLE:
var label := InputBindings.label_for(InputBindings.current_descriptor(action))
dynamic.append("%s: %s" % [str(action).replace("_", " ").capitalize(), label])
for line in (CONTROL_LINES + dynamic):
if needle == "" or line.to_lower().contains(needle):
_lines.append(line)
var text := "\n".join(_lines)
if _policy != "":
text += "\n\nVenue policy: " + _policy
_body.text = text
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo \
and event.keycode == KEY_ESCAPE:
close_panel()
get_viewport().set_input_as_handled()
+3 -1
View File
@@ -549,7 +549,7 @@ func toggle_debug() -> void:
_debug_enabled = not _debug_enabled
debug_label.visible = _debug_enabled
func refresh_debug(coordinator: Object, npcs: Array) -> void:
func refresh_debug(coordinator: Object, npcs: Array, preview: Array = []) -> void:
if not _debug_enabled:
return
var lines: Array[String] = ["--- DEBUG ---", "shift %.1fs / %.1fs rating %.1f" % [
@@ -563,6 +563,8 @@ func refresh_debug(coordinator: Object, npcs: Array) -> void:
Rules.NPC_TYPE_NAMES.get(profile.npc_type if profile else -1, "?"),
npc.name, c.intent, c.fsm.current_state, c.highest_escalation_received,
])
if not preview.is_empty():
lines.append(" next eligible: " + ", ".join(preview))
debug_label.text = "\n".join(lines)
func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -> void:
+34
View File
@@ -11,6 +11,7 @@ const UiArt := preload("res://src/game/ui/art.gd")
signal applied(settings: Dictionary)
signal replay_tutorial_requested()
signal reset_campaign_requested()
signal closed()
var _root: Control = null
@@ -20,6 +21,7 @@ var _sensitivity_value: Label = null
var _font_scale_value: Label = null
var _invert_y: CheckButton = null
var _head_bob: CheckButton = null
var _difficulty: OptionButton = null
var _bind_buttons := {}
var _listening_action := ""
var settings: Dictionary = {}
@@ -87,6 +89,18 @@ func _build() -> void:
_head_bob.toggled.connect(_on_head_bob_toggled)
column.add_child(_head_bob)
var diff_row := HBoxContainer.new()
column.add_child(diff_row)
var diff_label := Label.new()
diff_label.text = "Difficulty / pacing"
diff_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
diff_row.add_child(diff_label)
_difficulty = OptionButton.new()
for item in ["easy", "normal", "hard"]:
_difficulty.add_item(item.capitalize())
_difficulty.item_selected.connect(_on_difficulty_selected)
diff_row.add_child(_difficulty)
var replay_row := HBoxContainer.new()
replay_row.alignment = BoxContainer.ALIGNMENT_CENTER
column.add_child(replay_row)
@@ -95,6 +109,11 @@ func _build() -> void:
replay.text = "Replay tutorial"
replay.pressed.connect(func(): replay_tutorial_requested.emit())
replay_row.add_child(replay)
var reset_campaign := Button.new()
reset_campaign.name = "ResetCampaignButton"
reset_campaign.text = "Reset campaign"
reset_campaign.pressed.connect(_confirm_reset_campaign)
replay_row.add_child(reset_campaign)
var controls_title := Label.new()
controls_title.text = "Controls (click a binding, then press a key or pad button)"
@@ -167,6 +186,9 @@ func open(current: Dictionary) -> void:
_invert_y.set_pressed_no_signal(bool(settings.get("invert_y", false)))
if _head_bob:
_head_bob.set_pressed_no_signal(bool(settings.get("head_bob", true)))
if _difficulty:
var difficulty := str(settings.get("ai_difficulty", "normal"))
_difficulty.select(["easy", "normal", "hard"].find(difficulty))
_listening_action = ""
_refresh_bind_buttons()
_sync_labels()
@@ -198,6 +220,18 @@ func _on_invert_y_toggled(pressed: bool) -> void:
func _on_head_bob_toggled(pressed: bool) -> void:
settings["head_bob"] = pressed
func _on_difficulty_selected(index: int) -> void:
settings["ai_difficulty"] = ["easy", "normal", "hard"][clampi(index, 0, 2)]
func _confirm_reset_campaign() -> void:
var dialog := ConfirmationDialog.new()
dialog.title = "Reset campaign"
dialog.dialog_text = "Clear campaign progress and start again from the Hollywood shift?"
dialog.confirmed.connect(func(): reset_campaign_requested.emit())
add_child(dialog)
dialog.popup_centered()
dialog.confirmed.connect(dialog.queue_free)
func _refresh_bind_buttons() -> void:
var remaps: Dictionary = settings.get("input_bindings", {})
for action in _bind_buttons.keys():
+31 -1
View File
@@ -6,12 +6,15 @@ const Strings := preload("res://src/core/strings.gd")
const UiArt := preload("res://src/game/ui/art.gd")
signal restart_requested()
signal continue_requested()
signal timeline_requested(review: Dictionary)
## Kept so the timeline button can hand the full review back to the game manager.
var _review: Dictionary = {}
var report_label: Label
var _campaign_label: Label = null
var _continue_button: Button = null
func _init() -> void:
name = "ShiftReview"
@@ -46,6 +49,17 @@ func _init() -> void:
report_label.custom_minimum_size = Vector2(720, 0)
center.add_child(report_label)
_campaign_label = Label.new()
_campaign_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_campaign_label.add_theme_color_override("font_color", Color(0.62, 0.8, 0.95))
center.add_child(_campaign_label)
_continue_button = Button.new()
_continue_button.text = "Continue campaign"
_continue_button.pressed.connect(func(): continue_requested.emit())
_continue_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(_continue_button)
var timeline := Button.new()
timeline.name = "TimelineButton"
timeline.text = Strings.lookup("review.timeline_button")
@@ -64,6 +78,7 @@ func _init() -> void:
quit_btn.pressed.connect(func(): get_tree().quit())
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
_style_review_button(timeline)
_style_review_button(_continue_button)
_style_review_button(restart)
_style_review_button(quit_btn)
center.add_child(quit_btn)
@@ -84,7 +99,8 @@ func _style_review_button(button: Button) -> void:
button.add_theme_stylebox_override("focus", hover)
button.add_theme_stylebox_override("pressed", hover)
func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void:
func show_review(review: Dictionary, scenario: Resource, history: Array = [],
campaign_summary: Dictionary = {}) -> void:
_review = review
var reputation: Dictionary = review.get("reputation", {})
var incidents: Dictionary = review.get("incidents", {})
@@ -115,6 +131,7 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
var lines: Array[String] = [
"Venue: %s" % (scenario.display_name if scenario else review.get("scenario_id", "")),
"Policy: %s" % (scenario.policy_summary if scenario else "See venue policy."),
"Grade: %s Score: %.0f/100" % [reputation.get("grade", "?"), reputation.get("rating", 0.0)],
history_line,
"Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful],
@@ -135,4 +152,17 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
if shown >= 12:
break
report_label.text = "\n".join(lines)
var next_venue := str(campaign_summary.get("next_venue", ""))
var campaign_complete := bool(campaign_summary.get("complete", false))
if _campaign_label:
if campaign_complete:
_campaign_label.text = "Campaign complete. Free-play venues remain available."
elif next_venue != "":
_campaign_label.text = "Campaign shift %d complete · next: %s" % [
int(campaign_summary.get("shift_number", 1)) - 1,
next_venue.replace("_", " ").capitalize()]
else:
_campaign_label.text = ""
if _continue_button:
_continue_button.visible = not campaign_complete and next_venue != ""
visible = true
+49 -1
View File
@@ -5,6 +5,8 @@ extends CanvasLayer
## (the `V` bind stays as a debug shortcut).
signal venue_selected(scenario_id: String)
signal continue_requested()
signal help_requested()
signal settings_requested()
signal quit_requested()
@@ -19,6 +21,9 @@ const VENUES := [
var _index := 0
var _buttons: Array[Button] = []
var _root: Control = null
var _status: Label = null
var _continue_button: Button = null
var _campaign: Dictionary = {}
func _ready() -> void:
layer = 20
@@ -88,6 +93,20 @@ func _build() -> void:
subtitle.add_theme_color_override("font_color", Color(0.72, 0.75, 0.82))
column.add_child(subtitle)
_status = Label.new()
_status.name = "CampaignStatus"
_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_status.add_theme_color_override("font_color", Color(0.62, 0.78, 0.9))
column.add_child(_status)
_continue_button = Button.new()
_continue_button.name = "ContinueButton"
_continue_button.custom_minimum_size = Vector2(460, 52)
_continue_button.pressed.connect(_on_continue_pressed)
_style_venue_button(_continue_button)
column.add_child(_continue_button)
for i in VENUES.size():
var venue: Dictionary = VENUES[i]
var button := Button.new()
@@ -109,6 +128,12 @@ func _build() -> void:
row.add_theme_constant_override("separation", 12)
column.add_child(row)
var help_button := Button.new()
help_button.name = "HelpButton"
help_button.text = "Help"
help_button.pressed.connect(func(): help_requested.emit())
row.add_child(help_button)
var settings_button := Button.new()
settings_button.name = "SettingsButton"
settings_button.text = "Settings"
@@ -164,12 +189,35 @@ func _focus_index(i: int) -> void:
_index = clampi(i, 0, _buttons.size() - 1)
_buttons[_index].grab_focus()
func open() -> void:
func open(campaign: Dictionary = {}) -> void:
_campaign = campaign.duplicate(true)
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
var next_id := str(_campaign.get("next_venue", ""))
var complete := bool(_campaign.get("complete", false))
if _status:
if complete:
_status.text = "Campaign complete — pick any venue for a free shift."
elif next_id != "":
_status.text = "Campaign shift %d · next: %s" % [
int(_campaign.get("shift_number", 1)), _venue_label(next_id)]
if _continue_button:
var next_label := _venue_label(next_id)
_continue_button.text = "Continue shift — %s" % next_label if next_id != "" else "Campaign complete"
_continue_button.visible = not complete
_focus_index(_index)
func _venue_label(id: String) -> String:
for venue in VENUES:
if str(venue["id"]) == id:
return str(venue["label"])
return id.replace("_", " ").capitalize()
func _on_continue_pressed() -> void:
close()
continue_requested.emit()
func close() -> void:
visible = false
_root.visible = false
+70
View File
@@ -0,0 +1,70 @@
extends SceneTree
## Campaign shell + checkpoint smoke. Use an isolated user-data dir:
## XDG_DATA_HOME=/tmp/gigline-campaign godot --path . --fixed-fps 60 \
## --script res://src/test/smoke_campaign.gd
var gm: Node = null
var frames := 0
var step := 0
var failures: Array[String] = []
func _initialize() -> void:
root.size = Vector2i(960, 540)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frames += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
match step:
0:
if frames < 5: return false
gm.call("_on_venue_selected", "hollywood_building")
gm.coordinator.elapsed_s = 123.0
gm.call("_save_shift_checkpoint")
gm.call("_show_start_menu")
_check(gm.start_menu.visible, "campaign menu opens")
_check(gm.start_menu.find_child("ContinueButton", true, false) != null,
"continue button exists")
step = 1
frames = 0
1:
if frames < 4: return false
gm.call("_on_campaign_continue")
step = 2
frames = 0
2:
if frames < 20: return false
_check(gm.active_scenario_id == "hollywood_building", "resume keeps the venue")
_check(gm.coordinator.elapsed_s >= 123.0, "resume keeps elapsed time")
gm.call("_on_shift_ended", gm.coordinator.build_review())
_check(gm.campaign.shift_number == 2, "campaign advances after a shift")
_check(gm.campaign.next_venue() == "joke_shop", "next venue is the Joke Shop")
gm.call("_on_review_continue")
step = 3
frames = 0
3:
if frames < 20: return false
_check(gm.active_scenario_id == "joke_shop", "review continue starts the next venue")
gm.call("_open_help")
_check(gm.help_overlay.visible, "help overlay opens")
gm.help_overlay.close_panel()
_finish()
return false
func _check(condition: bool, message: String) -> void:
if condition: print("ok: " + message)
else: _fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
if failures.is_empty():
print("SMOKE OK: campaign, checkpoint resume, review continue, help")
quit(0)
else:
for message in failures: printerr("SMOKE FAIL: " + message)
quit(1)
+32
View File
@@ -0,0 +1,32 @@
extends RefCounted
## Campaign progression and serialization tests.
const Campaign := preload("res://src/core/campaign.gd")
func test_campaign_advances_in_order_and_completes() -> bool:
var campaign := Campaign.new()
campaign.setup()
var a := TestUtils.check_eq(campaign.next_venue(), "hollywood_building", "starts at Hollywood")
var b := TestUtils.check(campaign.record_shift("hollywood_building", {"reputation": {"grade": "B"}}),
"records the first shift")
var c := TestUtils.check_eq(campaign.next_venue(), "joke_shop", "advances to the Joke Shop")
campaign.record_shift("joke_shop", {"reputation": {"grade": "A"}})
campaign.record_shift("tv_studio", {"reputation": {"grade": "A"}})
var d := TestUtils.check(campaign.complete and campaign.next_venue() == "", "completes after three venues")
var e := TestUtils.check_eq(campaign.last_grade, "A", "stores the last grade")
return a and b and c and d and e
func test_campaign_round_trips_and_resets() -> bool:
var campaign := Campaign.new()
campaign.setup({"index": 1, "shift_number": 2, "difficulty": "hard",
"completed": ["hollywood_building"], "last_grade": "C"})
var data := campaign.summary()
var restored := Campaign.new()
restored.setup(data)
var a := TestUtils.check_eq(restored.next_venue(), "joke_shop", "restores the next venue")
var b := TestUtils.check_eq(restored.difficulty, "hard", "restores difficulty")
var c := TestUtils.check_eq(restored.completed, ["hollywood_building"], "restores completion")
restored.reset()
var d := TestUtils.check(not restored.complete and restored.next_venue() == "hollywood_building",
"reset returns to the first venue")
return a and b and c and d
+27
View File
@@ -0,0 +1,27 @@
extends RefCounted
## Context-line selection is deterministic and falls back safely.
const NpcProfile := preload("res://src/core/npc_profile.gd")
const NpcController := preload("res://src/core/npc_controller.gd")
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
func test_context_lines_are_deterministic_and_fallback() -> bool:
var profile := ScenarioFactory.hollywood_building().profile_for(0)
var rng_a := RandomNumberGenerator.new(); rng_a.seed = 7
var rng_b := RandomNumberGenerator.new(); rng_b.seed = 7
var a := profile.line_for("refusal", rng_a)
var b := profile.line_for("refusal", rng_b)
var c := profile.line_for("reason", rng_a)
var d := TestUtils.check(a != "" and a == b, "context lines are deterministic")
var e := TestUtils.check(c != "", "reason falls back to dialogue_lines")
var f := TestUtils.check(profile.line_for("missing", rng_a) == "",
"unknown context with no fallback is empty")
return d and e and f
func test_request_reason_uses_reason_context() -> bool:
var profile := ScenarioFactory.hollywood_building().profile_for(0)
var controller := NpcController.new()
controller.setup("dialogue_test", profile, "enter", 3)
var reply := controller.request_reason()
return TestUtils.check(reply == controller.last_reason_reply and reply != "",
"request_reason stores the authored reply")
+59
View File
@@ -0,0 +1,59 @@
extends RefCounted
## Content-matrix validation for all authored venues.
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
const ScenarioValidator := preload("res://src/core/scenario_validator.gd")
const EventScheduler := preload("res://src/core/event_scheduler.gd")
func test_all_authored_venues_validate() -> bool:
var problems: Array[String] = []
for scenario in [ScenarioFactory.hollywood_building(), ScenarioFactory.joke_shop(),
ScenarioFactory.tv_studio()]:
for problem in ScenarioValidator.validate(scenario):
problems.append("%s: %s" % [scenario.id, problem])
return TestUtils.check(problems.is_empty(),
"venue content matrices validate (%s)" % str(problems))
func test_validator_rejects_duplicate_and_missing_profile() -> bool:
var scenario := ScenarioFactory.hollywood_building()
scenario.event_pool.append({
"id": "tourist_photo", "phase": 0, "kind": "routine",
"npc_type": 999, "intent": "photo",
})
var problems := ScenarioValidator.validate(scenario)
var a := TestUtils.check(problems.any(func(p): return str(p).contains("duplicate")),
"duplicate ids are rejected")
var b := TestUtils.check(problems.any(func(p): return str(p).contains("missing profile")),
"missing profiles are rejected")
return a and b
func test_scripted_events_do_not_repeat_immediately() -> bool:
var scenario := ScenarioFactory.hollywood_building()
scenario.event_pool = [{
"id": "matrix_probe", "phase": 0, "kind": "scripted",
"npc_type": 0, "intent": "enter", "time_range_s": Vector2(0, 900),
"probability": 1.0, "base_weight": 1.0,
}]
var scheduler := EventScheduler.new()
scheduler.setup(scenario, 7)
var first := scheduler.pull_due(10.0, 0)
var second := scheduler.pull_due(11.0, 0)
var a := TestUtils.check(first.size() == 1, "first scripted event is eligible")
var b := TestUtils.check(second.is_empty(), "same scripted event is suppressed next pull")
return a and b
func test_spawn_multiplier_scales_routine_traffic() -> bool:
var scenario := ScenarioFactory.hollywood_building()
var scheduler := EventScheduler.new()
scheduler.setup(scenario, 3)
scheduler.spawn_rate_multiplier = 0.0
var quiet := 0
for i in 20:
quiet += scheduler.fallback_routine_traffic(0).size()
var a := TestUtils.check(quiet == 0, "zero difficulty multiplier suppresses routine traffic")
scheduler.spawn_rate_multiplier = 1.0
var active := 0
for i in 20:
active += scheduler.fallback_routine_traffic(0).size()
var b := TestUtils.check(active > 0, "normal difficulty sends routine traffic")
return a and b
+44
View File
@@ -0,0 +1,44 @@
extends RefCounted
## Mid-shift checkpoint data survives a JSON round-trip and restores the
## coordinator's systems without replaying completed interactions.
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
const IncidentRecorder := preload("res://src/core/incident_recorder.gd")
const Rules := preload("res://src/core/rules.gd")
func test_coordinator_snapshot_round_trip() -> bool:
var scenario := ScenarioFactory.hollywood_building()
var first := ShiftCoordinator.new()
first.setup(scenario, 900.0, 42)
first.start()
first.elapsed_s = 123.0
first.recorder.start_incident("guest", "focus")
first.recorder.add_escalation("guest", Rules.Level.FORMAL_WARNING, "warning")
first.recorder.close_incident("guest", "peaceful")
first.reputation.apply("peaceful", 5.0, "test")
first.crowd.mood = 0.4
first.radio.battery = 70.0
var payload: Dictionary = JSON.parse_string(JSON.stringify(first.snapshot()))
var second := ShiftCoordinator.new()
second.setup(scenario, 900.0, 42)
second.restore(payload)
var a := TestUtils.check(is_equal_approx(second.elapsed_s, 123.0), "elapsed time restores")
var b := TestUtils.check_eq(second.recorder.to_dict()["open_count"], 0, "incident state restores")
var c := TestUtils.check_eq(second.recorder.to_dict()["incidents"].size(), 1,
"closed incidents restore")
var d := TestUtils.check(is_equal_approx(second.reputation.rating, 75.0), "reputation restores")
var e := TestUtils.check(is_equal_approx(second.crowd.mood, 0.4), "crowd mood restores")
var f := TestUtils.check(is_equal_approx(second.radio.battery, 70.0), "radio battery restores")
return a and b and c and d and e and f
func test_open_incident_snapshot_restores_evidence() -> bool:
var recorder := IncidentRecorder.new()
recorder.start_incident("guest", "focus")
recorder.add_access_evidence("guest", {"kind": "wristband", "status": "valid"})
var restored := IncidentRecorder.new()
restored.restore(JSON.parse_string(JSON.stringify(recorder.snapshot())))
var a := TestUtils.check(restored.has_open_incident("guest"), "open incident restores")
restored.add_event("guest", {"type": "player_action"})
var b := TestUtils.check(restored.open_incidents_count() == 1, "restored incident accepts events")
return a and b