Ship the 0.8.0 first-hour and campaign milestone
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:
1 parent
86332cd401
commit
aa51ee331e
38 files changed
+1361
-1144
No files matched your search
@@ -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({})
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user