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

+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