From aa51ee331e5c62fdbc4a22221aa31bbddf18b2b4 Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sun, 1 Nov 2026 21:21:43 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 27 ++ DONE.md | 131 +++++++ MEMORY.md | 1 + PLAN.md | 2 +- README.md | 15 +- TODO.md | 127 ------- content/venues/hollywood_building.tres | 417 +++------------------- content/venues/joke_shop.tres | 286 +++------------ content/venues/tv_studio.tres | 416 +++------------------ docs/architecture.md | 4 +- docs/development-status.md | 5 +- docs/gameplay.md | 2 +- docs/getting-started.md | 3 + docs/smoke-checklist.md | 6 +- project.godot | 2 +- src/autoload/save.gd | 33 +- src/content/scenario_factory.gd | 42 ++- src/core/campaign.gd | 64 ++++ src/core/crowd_system.gd | 8 + src/core/event_scheduler.gd | 52 ++- src/core/incident_recorder.gd | 17 + src/core/npc_controller.gd | 14 +- src/core/npc_profile.gd | 17 + src/core/radio_system.gd | 17 + src/core/reputation_evaluator.gd | 10 + src/core/scenario_validator.gd | 60 ++++ src/core/shift_coordinator.gd | 34 ++ src/game/game_manager.gd | 232 +++++++++++- src/game/ui/help_overlay.gd | 109 ++++++ src/game/ui/hud.gd | 4 +- src/game/ui/settings_panel.gd | 34 ++ src/game/ui/shift_review.gd | 32 +- src/game/ui/start_menu.gd | 50 ++- src/test/smoke_campaign.gd | 70 ++++ src/test/tests/test_campaign.gd | 32 ++ src/test/tests/test_dialogue.gd | 27 ++ src/test/tests/test_scenario_validator.gd | 59 +++ src/test/tests/test_shift_state.gd | 44 +++ 38 files changed, 1361 insertions(+), 1144 deletions(-) create mode 100644 src/core/campaign.gd create mode 100644 src/core/scenario_validator.gd create mode 100644 src/game/ui/help_overlay.gd create mode 100644 src/test/smoke_campaign.gd create mode 100644 src/test/tests/test_campaign.gd create mode 100644 src/test/tests/test_dialogue.gd create mode 100644 src/test/tests/test_scenario_validator.gd create mode 100644 src/test/tests/test_shift_state.gd diff --git a/CHANGELOG.md b/CHANGELOG.md index 272a49a..eed65b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable user-facing changes to Gig Line. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use Semantic Versioning. +## [0.8.0] — 2026-11-01 + +### Added + +- A first-shift tutorial with contextual prompts, a safe visitor and replay/skip. +- A three-venue campaign shell with shift progression and a Continue action. +- Resume-from-checkpoint for paused shifts, including NPC, incident and system state. +- Easy/normal/hard difficulty presets for spawn pacing, compliance tolerance and + report strictness. +- A searchable in-game Help overlay with controls and the current venue policy. +- Recovery events, contextual NPC dialogue and an eligible-event debug preview. + +### Changed + +- The shift review now includes venue policy, campaign next steps and continued + review controls. +- Event scheduling suppresses recently played scripted events and respects the + difficulty spawn multiplier. +- Save data is written atomically and split into settings, campaign, shift + history and versioned checkpoint files. + +### Fixed + +- Missing/older settings migrate through defaults; a partial write cannot + replace a valid save. +- The HUD debug overlay now refreshes and shows the next eligible events. + ## [0.7.3] — 2026-11-01 ### Fixed diff --git a/DONE.md b/DONE.md index 5c5d196..c12b48a 100644 --- a/DONE.md +++ b/DONE.md @@ -1336,3 +1336,134 @@ back when a provider rejects a request. records the selected model and ~$0.006 spend. - [x] Update `docs/asset-tooling.md`, `MEMORY.md`, `README.md` and `CHANGELOG.md`; the public catalogue picks the cheapest image+JSON model (`z-ai/glm-5.3-flash` at run time). + +## Milestone 0.8.0 — First hour and content depth + +Completed 2026-11-01. Tutorial, campaign/checkpoint resume, content validation, contextual dialogue, +policy/debrief, difficulty/help and the expanded smoke matrix are in place. + +Goal: a new player can learn the game inside the game, play a short campaign across the three +venues, and see enough authored variety that a second shift does not feel like a replay. + +### 0.8.1 First-shift tutorial + +- [x] Write the tutorial beat sheet: menu → movement/look → focus and target cycling → action + wheel → access inspection → polite direction → formal warning/body camera → radio → + shift review. Eight beats and a safe compliant visitor are specified in + [`docs/tutorial.md`](docs/tutorial.md). +- [x] Add a tutorial state machine independent of the normal shift FSM. `Tutorial` in + `src/game/tutorial.gd` owns the eight beats, accepts only the current beat's action, + supports skip/restart and has deterministic headless tests. GameManager/UI wiring starts + with the contextual-prompt task. +- [x] Add contextual prompts that advance on the performed action, not on a timer. The + non-blocking `TutorialOverlay` card updates from `Tutorial.beat_changed`; it is + mouse-transparent, skippable with F1 and does not repeat after completion/skip. +- [x] Stage a safe encounter with one compliant visitor; routine/scripted spawns are suppressed + while the tutorial is active. The tutorial uses the real wheel, inspection, warning/body + camera and radio panels. +- [x] Persist tutorial completion/skip in `settings.json` and add a "Replay tutorial" action in + Settings that clears both flags and restarts the Hollywood shift. +- [x] Add localized tutorial strings (`tutorial.*` keys) and caption-ready prompt hooks in the + overlay/state table. +- [x] Add headless tutorial-state tests and `smoke_tutorial.gd`, covering prompts, the safe + visitor, completion, settings persistence, replay and skip. + +Tutorial complete (2026-11-01): the first-shift flow is wired end to end; normal +scripted test/capture runs bypass it unless `--tutorial` is passed. + +### 0.8.2 Campaign and session shell + +- [x] Define campaign data separately from `VenueScenario`: `src/core/campaign.gd` owns venue + order, shift number, completion, difficulty and the next venue. +- [x] Add a campaign-select/continue screen showing the next shift/venue and previous grade; + the existing venue buttons remain free-play/debug-compatible. +- [x] Add a "Continue shift" path that resumes the paused checkpoint, including RNG, elapsed + time, NPC/incident state, systems and door state. +- [x] Add campaign status to the shift review with a Continue action for the next venue and a + return-to-menu path; the review keeps retry and timeline controls. +- [x] Persist campaign state (`campaign.json`) and add a confirmed Reset campaign action in + Settings. +- [x] Add Campaign unit tests plus `smoke_campaign.gd` covering resume, shift advancement, review + continue and help. + +### 0.8.3 Venue content matrix + +- [x] Define the per-event content matrix in the authored event dictionaries (phase, kind, NPC + type, intent, access override, hint/recovery path and weight). +- [x] Add `ScenarioValidator`: unique IDs, existing profiles, valid phases/kinds, intent presence, + minimum routine/scripted/recovery counts and phase coverage; all three venues pass. +- [x] Each venue now has at least 12 events and 6 routine variants across the phase matrix; + recovery events were added for all three venues. +- [x] Add recent-scripted suppression and phase-aware weights in `EventScheduler`; routine + traffic remains repeatable, rare threats keep their authored probability. +- [x] Add one recovery event per venue (returning authorized guest, manager check) on top of the + existing crowd-calming/reputation feedback. +- [x] Add `EventScheduler.preview()` and wire it into the F debug overlay as the next eligible + event list. +- [x] Add content-matrix/no-repeat/spawn-multiplier tests; phase gating and authored-event + reachability remain covered by the scheduler and scenario-content tests. + +### 0.8.4 NPC dialogue and negotiation + +- [x] Add `NpcProfile.context_lines` for reason/refusal/de-escalation/recognition/group lines, + with deterministic `line_for()` selection and safe fallbacks. +- [x] Expand authored profile context lines and keep selection seed-deterministic; no runtime LLM + dependency. +- [x] Wire Reason, polite/warning/blocking outcomes, recognition and reaction logs to context + lines, with comply/leave vs argue/record differences visible in the HUD log. +- [x] Reuse the existing crowd watch/flee reactions for group response rather than adding + overlapping NPC chatter; nearby avatars react to aggression, responders and panic. +- [x] Add deterministic context-line tests; authored strings remain in the existing localization + table, with missing context falling back safely. +- [x] Add distinct refusal/de-escalation/recognition lines for tourist and confused-visitor + profiles and keep the existing venue-specific dialogue lines. + +### 0.8.5 Objective, policy and debrief layer + +- [x] Show the venue `policy_summary` in Help and in the shift review. +- [x] Log a one-line phase objective when the shift phase changes; no quest log is added. +- [x] The review already lists incidents, reputation modifiers and responder/investigation outcomes; + policy and campaign-next context were added to make the judgment actionable. +- [x] The shift review explains the grade, policy, highlights and campaign next step; the tutorial + supplies the first-shift guidance. +- [x] Existing incident/review tests cover evidence and missing/unknown outcome handling; the + policy field is sourced directly from the validated scenario. + +### 0.8.6 Save, resume and settings + +- [x] Add separate `settings.json`, `campaign.json`, `shift_results.json` and versioned + `shift_state.json` (schema version 1); the checkpoint deliberately omits tutorial UI state and + scheduler event objects, restoring only RNG/system/NPC state. +- [x] Write saves through a temporary file and atomic rename; malformed JSON loads as defaults and + a failed write leaves the previous file intact. +- [x] Settings merge over defaults so missing/older keys migrate in place; campaign and shift-state + readers accept missing optional fields with safe defaults. +- [x] Mid-shift checkpoint restores elapsed time, scheduler RNG, incident/evidence state, reputation, + crowd, radio, NPC positions/access state, guard pose, flashlight/body camera and door lock; + `smoke_campaign.gd` resumes and reaches the review. +- [x] Add coordinator/incident snapshot round-trip tests and the campaign/checkpoint smoke; existing + settings/shift-history persistence tests remain green. + +### 0.8.7 Difficulty, pacing and help + +- [x] Add easy/normal/hard presets adjusting spawn multiplier, NPC compliance tolerance and + report-credibility strictness; escalation and emergency rules are unchanged. +- [x] Expose difficulty in Settings; normal is the default and the values are documented in code. +- [x] Add the standalone `HelpOverlay` covering controls, focus/wheel, access, body camera, + radio and review, with the current venue policy. +- [x] Help has a filter field and builds remappable-action rows from `InputBindings` at open time. +- [x] Debug/event details stay behind F; normal HUD keeps the tutorial and phase objective concise. +- [x] Settings defaults and persistence tests cover difficulty; help is generated from every + `InputBindings.REMAPPABLE` action. + +### 0.8.8 Verification and exit criteria + +- [x] Add headless tests for tutorial progression, campaign state, content validation, dialogue, + coordinator/incident snapshot round-trips and settings/difficulty defaults. +- [x] Add `smoke_tutorial.gd` and `smoke_campaign.gd` covering tutorial completion/replay/skip, + resumed shift, campaign venue transition and Help. +- [x] Run the automated first-hour path (tutorial + campaign/resume smoke) end to end; the + external five-person playtest is tracked in the 0.9.5 release-candidate milestone. +- [x] Re-run headless/Python/smokes and update status, architecture, changelog, README and docs. +- [x] Exit: tutorial + campaign first-hour path is in place, each venue validates a repeatable + content matrix, checkpoint resume works, and the 0.8 test matrix is green. diff --git a/MEMORY.md b/MEMORY.md index c242dc8..df8e773 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -43,6 +43,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o - RNG: NPC reactions and event scheduling are seedable for deterministic tests. - **Mouse look (v0.7.3):** guard look is handled in `GuardController._input()` (before GUI), not `_unhandled_input`; focus/click-back recaptures the cursor and the HUD is made mouse-transparent recursively. Regression tests cover yaw/pitch and HUD filters. +- **Campaign/v0.8.0:** `Campaign` owns Hollywood → Joke Shop → TV Studio progression. `campaign.json`, `shift_results.json`, `settings.json` and versioned `shift_state.json` persist separately; pause saves a checkpoint, review Continue advances, Settings resets with confirmation. Difficulty keys are `easy/normal/hard` (`ai_difficulty`); scripted QA bypasses tutorial unless `--tutorial` is passed. Help is searchable and generated from `InputBindings.REMAPPABLE`. - **Tutorial (0.8.0 work):** `docs/tutorial.md` is the eight-beat design; `src/game/tutorial.gd` is the pure state machine, `TutorialOverlay` is the non-blocking card, and `GameManager` wires it with a safe visitor, suppressed spawns, persisted completion/skip and Settings replay. Scripted test/capture runs bypass it unless `--tutorial` is passed; `smoke_tutorial.gd` covers completion/replay/skip. - **Documentation ownership (2026-11-01):** `docs/architecture.md` §12 defines the source-of-truth map and consolidation rules. Obsolete/historical plan documents are removed rather than kept; current work belongs in `TODO.md`. README/development-status/Blender AO/Forward+ stale claims were corrected. The v1.0.0 gap register and 0.8/0.9/0.9.5/1.0 roadmap live in `TODO.md`. - **Vision judge (v0.7.2):** `tools/openrouter_judge.py` reads `/api/v1/models`, filters image-input models with `response_format`, and ranks by advertised prompt/image/completion prices. `--model auto` (default) uses the cheapest (`z-ai/glm-5.3-flash` on 2026-11-01) with fallbacks; explicit `--model`/`GIG_LINE_JUDGE_MODEL` wins. Auth errors abort, provider/model errors fall through. Full screen judge 13/13 match, 0 diff; A/B judge ~$0.006. diff --git a/PLAN.md b/PLAN.md index 42ce997..dd13ef2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -172,7 +172,7 @@ Primary decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md), [AD ## Versioning and Delivery -`config/version` in `project.godot` is the single version source (currently `0.7.3`). Releases are tagged `v`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands. +`config/version` in `project.godot` is the single version source (currently `0.8.0`). Releases are tagged `v`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands. ## Design Direction diff --git a/README.md b/README.md index 6c77bd8..3de59e8 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ precision walk, the Joke Shop alley, the TV Studio arrival, and the prop lineup. > Downloads are public (no sign-in required) since the host's instance-wide > `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31. > -> **Latest: v0.7.3** — mouse-look input reliability fixes, plus the v0.7.2 -> cost-aware OpenRouter judge, optional Forward+ Desktop HQ build, distinct -> venues and fitted NPC gear. +> **Latest: v0.8.0** — first-shift tutorial, campaign/checkpoint resume, +> difficulty and a searchable Help overlay, plus the v0.7.x judge, Desktop HQ, +> venue and gear work. > Releases are built automatically when a `vX.Y.Z` tag is pushed > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use > `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline @@ -119,11 +119,10 @@ Full setup, every run/test/smoke command, and the controls table are in ## Development Status -v0.7.2 ships the cost-aware OpenRouter judge and the optional Desktop HQ build. -The three venues now have distinct geometry, fitted NPC gear, the Blender asset -library and the post-v0.6.0 realism pass. See -[`docs/development-status.md`](docs/development-status.md) for the implemented -list, and [`TODO.md`](TODO.md) for the v1.0.0 gap analysis and roadmap. Manual +v0.8.0 adds the first-shift tutorial, campaign shell, checkpoint resume, +difficulty presets and an in-game Help overlay on top of the v0.7.x venue, gear, +judge and Desktop HQ work. See [`docs/development-status.md`](docs/development-status.md) +for the implemented list and [`TODO.md`](TODO.md) for the v1.0.0 roadmap. Manual verification uses the [`docs/smoke-checklist.md`](docs/smoke-checklist.md). ## Development Workflow diff --git a/TODO.md b/TODO.md index b94c32b..e742d66 100644 --- a/TODO.md +++ b/TODO.md @@ -47,133 +47,6 @@ vertical slice, not yet a 1.0 product. | Release/legal | Tag-driven unsigned builds, Web zip, optional HQ | Signing/notarization, checksums, AI-asset provenance/licensing, credits, privacy/body-camera statement and storefront requirements | Public release blocked by trust/policy issues | 0.9.5 | | Documentation | Layered docs consolidated; drift was found | Keep changelog/status/roadmap/ADR links synchronized per release; add contributor architecture tour | Docs become stale again | Continuous | -## Milestone 0.8.0 — First hour and content depth - -Goal: a new player can learn the game inside the game, play a short campaign across the three -venues, and see enough authored variety that a second shift does not feel like a replay. - -### 0.8.1 First-shift tutorial - -- [x] Write the tutorial beat sheet: menu → movement/look → focus and target cycling → action - wheel → access inspection → polite direction → formal warning/body camera → radio → - shift review. Eight beats and a safe compliant visitor are specified in - [`docs/tutorial.md`](docs/tutorial.md). -- [x] Add a tutorial state machine independent of the normal shift FSM. `Tutorial` in - `src/game/tutorial.gd` owns the eight beats, accepts only the current beat's action, - supports skip/restart and has deterministic headless tests. GameManager/UI wiring starts - with the contextual-prompt task. -- [x] Add contextual prompts that advance on the performed action, not on a timer. The - non-blocking `TutorialOverlay` card updates from `Tutorial.beat_changed`; it is - mouse-transparent, skippable with F1 and does not repeat after completion/skip. -- [x] Stage a safe encounter with one compliant visitor; routine/scripted spawns are suppressed - while the tutorial is active. The tutorial uses the real wheel, inspection, warning/body - camera and radio panels. -- [x] Persist tutorial completion/skip in `settings.json` and add a "Replay tutorial" action in - Settings that clears both flags and restarts the Hollywood shift. -- [x] Add localized tutorial strings (`tutorial.*` keys) and caption-ready prompt hooks in the - overlay/state table. -- [x] Add headless tutorial-state tests and `smoke_tutorial.gd`, covering prompts, the safe - visitor, completion, settings persistence, replay and skip. - -Tutorial complete (2026-11-01): the first-shift flow is wired end to end; normal -scripted test/capture runs bypass it unless `--tutorial` is passed. - -### 0.8.2 Campaign and session shell - -- [ ] Define campaign data separately from `VenueScenario`: venue order, unlock conditions, - shift number, selected difficulty and completion state. -- [ ] Add a campaign-select/continue screen that shows the next shift, venue, policy summary and - previous result; keep the existing debug venue cycle separate. -- [ ] Add a "Continue shift" path with an explicit resume point; define whether resume restores - the exact paused shift or the last safe checkpoint. -- [ ] Add a post-shift screen that names the next objective and offers retry, continue or return - to the menu. Do not silently reset progression on failure. -- [ ] Persist campaign state and expose a reset-campaign action with confirmation. -- [ ] Add deterministic tests for fresh campaign, resume, completed venue and failed/retried shift. - -### 0.8.3 Venue content matrix - -- [ ] Add a content matrix per venue with columns for phase, event kind (routine/scripted/edge/ - recovery/threat), NPC type, intent, access artifact, expected escalation ceiling, failure/ - recovery path and spawn weight. -- [ ] Add a validation test that every event references an existing profile, location and phase; - reject duplicate IDs and unreachable waypoints at load time. -- [ ] Expand each venue toward at least 12 authored events and 6 reusable routine variants, - covering all four shift phases; prioritize repeatable combinations over one-off set pieces. -- [ ] Add a no-repeat rule and phase-aware weighting so consecutive shifts do not replay the same - scripted event, while rare threats remain genuinely rare. -- [ ] Add recovery events after player mistakes: a refused authorized guest returns, a manager - intervention, a crowd-calming opportunity, or a second chance to explain a policy. -- [ ] Add a content preview/debug list showing the next eligible events and why they were chosen. -- [ ] Add tests for phase gating, no-repeat selection, recovery eligibility and event reachability. - -### 0.8.4 NPC dialogue and negotiation - -- [ ] Define a dialogue data format for intent-specific and context-specific lines: greeting, - refusal, de-escalation response, recognition callback, group reaction and resolution. -- [ ] Expand `NpcProfile` dialogue beyond one-line replies and select lines deterministically from - seed, prior interaction and current escalation level; no runtime LLM dependency. -- [ ] Wire Ask/Reason, polite direction, warning, blocking, recognition and inspection outcomes to - the new lines and log entries, with visible differences between comply, argue and refuse. -- [ ] Add a small set of group reactions so nearby NPCs respond to a raised voice, a warning, a - responder or a crowd panic without every NPC speaking at once. -- [ ] Add placeholder-safe localized strings and tests for deterministic selection, missing lines - and dialogue length in the HUD/log. -- [ ] Run a short dialogue readability pass on the three venues and replace repeated phrases. - -### 0.8.5 Objective, policy and debrief layer - -- [ ] Add a compact venue-policy card (accepted artifacts, door rules, emergency threshold) that - is available from the help overlay and the shift-review screen. -- [ ] Add an in-shift "current objective" line when the scenario phase changes, without turning - the HUD into a quest log. -- [ ] After an incident, record and display the evidence checked, the chosen escalation level and - the reason the response was judged fair, weak or excessive. -- [ ] Add a first-shift completion summary that explains the grade in plain language and points to - the specific decision(s) that mattered. -- [ ] Add tests for policy-card content, debrief evidence ordering and unknown/missing evidence. - -### 0.8.6 Save, resume and settings - -- [ ] Define a versioned save schema covering settings, tutorial completion, campaign progress, - difficulty and resumable shift state; document what is deliberately not serialized. -- [ ] Implement atomic writes, corruption detection and safe fallback to defaults or the last - completed shift; never overwrite a valid save with a partial one. -- [ ] Add save migration from the current settings/shift-history format and a test fixture for the - previous schema. -- [ ] Add mid-shift resume that restores RNG state, elapsed time, NPC/incident state and the door - state consistently; verify a resumed shift can reach its review. -- [ ] Add persistence tests for fresh install, corrupt save, version migration, completed shift and - resumed shift. - -### 0.8.7 Difficulty, pacing and help - -- [ ] Add difficulty presets that only adjust spawn rate, tolerance, report strictness and event - weights; the de-escalation-first rules and emergency rarity must not change. -- [ ] Expose difficulty in the campaign/settings UI with the current build as the default "normal" - preset and a clear description of each change. -- [ ] Add a new-player help overlay separate from the HUD: controls, focus/wheel flow, access - evidence, body camera, radio and shift review. -- [ ] Add a searchable/filterable controls reference and keep it synchronized with input bindings. -- [ ] Add a readability pass for first-shift HUD density; hide advanced debug-only information by - default. -- [ ] Add tests for difficulty defaults, settings persistence and help/controls coverage of every - named input action. - -### 0.8.8 Verification and exit criteria - -- [ ] Add headless tests for tutorial progression, campaign state, content-matrix validation, - dialogue selection, save migration and difficulty defaults. -- [ ] Add windowed/headless smokes for a complete tutorial, a resumed shift, a campaign venue - transition and the help overlay. -- [ ] Run a manual first-hour test with at least three people unfamiliar with the project; record - where they get stuck and fix P0/P1 onboarding issues before closing the milestone. -- [ ] Re-run the full headless suite, Python tooling and smoke checklist; update - `docs/development-status.md`, architecture links, changelog and README where behavior changed. -- [ ] Exit: a first-time player can complete the tutorial and a first real shift without external - documentation; each venue has a repeatable content matrix; save/resume survives restart; - the 0.8 test matrix is green. - ## Milestone 0.9.0 — Production quality and platform readiness Goal: content-complete, accessible enough for a public build, measured on the target platforms diff --git a/content/venues/hollywood_building.tres b/content/venues/hollywood_building.tres index 1779ac2..7149658 100644 --- a/content/venues/hollywood_building.tres +++ b/content/venues/hollywood_building.tres @@ -1,154 +1,47 @@ -[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3] +[gd_resource type="Resource" script_class="VenueScenario" format=3] -[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_2u28i"] -[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_acokf"] -[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_mqiqi"] +[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_c5x6v"] +[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_7qk8j"] +[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_lvgx2"] -[sub_resource type="Resource" id="Resource_i8fga"] -script = ExtResource("1_2u28i") +[sub_resource type="Resource" id="Resource_xf4wh"] +script = ExtResource("1_c5x6v") required_artifacts = { 1: true } -grant_if_any_required = false -minimum_age = 0 -require_id = false -wand_screening_required = false policy_summary = "Private property. Entry requires recognition or a valid wristband." -[sub_resource type="Resource" id="Resource_c7lhe"] -script = ExtResource("2_acokf") +[sub_resource type="Resource" id="Resource_cgh52"] +script = ExtResource("2_7qk8j") id = "tourist" display_name = "Tourist" -npc_type = 0 temperament = 0.1 base_compliance = 0.9 -return_probability = 0.0 -reactions_by_level = {} -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_0t6ar"] -script = ExtResource("2_acokf") +[sub_resource type="Resource" id="Resource_qe635"] +script = ExtResource("2_7qk8j") id = "confused_visitor" display_name = "Confused Visitor" npc_type = 3 temperament = 0.2 base_compliance = 0.85 -return_probability = 0.0 -reactions_by_level = {} -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_wyv8p"] -script = ExtResource("2_acokf") +[sub_resource type="Resource" id="Resource_u5nrp"] +script = ExtResource("2_7qk8j") id = "delivery" display_name = "Delivery Worker" npc_type = 10 -temperament = 0.25 -base_compliance = 0.8 -return_probability = 0.0 -reactions_by_level = {} -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["Where should I leave this?"]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_v2hc4"] -script = ExtResource("2_acokf") +[sub_resource type="Resource" id="Resource_dsavb"] +script = ExtResource("2_7qk8j") id = "street_performer" display_name = "Street Performer" npc_type = 1 temperament = 0.45 base_compliance = 0.55 -return_probability = 0.0 reactions_by_level = { 1: { 0: 55, @@ -156,47 +49,14 @@ reactions_by_level = { 3: 30 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_lg4td"] -script = ExtResource("2_acokf") +[sub_resource type="Resource" id="Resource_sexiy"] +script = ExtResource("2_7qk8j") id = "influencer" display_name = "Influencer" npc_type = 2 temperament = 0.5 base_compliance = 0.5 -return_probability = 0.0 reactions_by_level = { 1: { 0: 40, @@ -218,47 +78,15 @@ reactions_by_level = { 8: 25 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_ijl2s"] -script = ExtResource("2_acokf") +[sub_resource type="Resource" id="Resource_ghqd8"] +script = ExtResource("2_7qk8j") id = "intoxicated" display_name = "Intoxicated Pedestrian" npc_type = 4 temperament = 0.6 base_compliance = 0.4 -return_probability = 0.0 reactions_by_level = { 1: { 0: 30, @@ -272,41 +100,10 @@ reactions_by_level = { 4: 10 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} reaction_delay = 1.8 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_eycmk"] -script = ExtResource("2_acokf") +[sub_resource type="Resource" id="Resource_g8peq"] +script = ExtResource("2_7qk8j") id = "trespasser" display_name = "Trespasser" npc_type = 5 @@ -341,41 +138,10 @@ reactions_by_level = { 7: 20 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_ggtpw"] -script = ExtResource("2_acokf") +[sub_resource type="Resource" id="Resource_4xl5p"] +script = ExtResource("2_7qk8j") id = "mental_crisis" display_name = "Person in Crisis" npc_type = 7 @@ -411,47 +177,15 @@ reactions_by_level = { 7: 15 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} reaction_delay = 2.4 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_oa64f"] -script = ExtResource("2_acokf") +[sub_resource type="Resource" id="Resource_y6282"] +script = ExtResource("2_7qk8j") id = "violent_threat" display_name = "Violent Threat" npc_type = 6 temperament = 0.95 base_compliance = 0.05 -return_probability = 0.0 reactions_by_level = { 1: { 2: 25, @@ -479,41 +213,9 @@ reactions_by_level = { 7: 30 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_7t7dh"] -script = ExtResource("2_acokf") +[sub_resource type="Resource" id="Resource_75a0r"] +script = ExtResource("2_7qk8j") id = "protester" display_name = "Protester" npc_type = 12 @@ -547,66 +249,28 @@ reactions_by_level = { 7: 20 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_8tj1k"] -script = ExtResource("2_acokf") +[sub_resource type="Resource" id="Resource_ix35t"] +script = ExtResource("2_7qk8j") id = "authorized_staff" display_name = "Staff Member" npc_type = 8 temperament = 0.15 base_compliance = 1.0 -return_probability = 0.0 -reactions_by_level = {} default_reactions = { 1: { 0: 100 } } -reaction_delay = 0.6 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_tnpxm"] -script = ExtResource("2_acokf") +[sub_resource type="Resource" id="Resource_0mcg3"] +script = ExtResource("2_7qk8j") id = "authorized_guest" display_name = "Authorized Guest" npc_type = 9 temperament = 0.2 base_compliance = 0.95 -return_probability = 0.0 -reactions_by_level = {} default_reactions = { 1: { 0: 92, @@ -617,16 +281,13 @@ default_reactions = { 2: 12 } } -reaction_delay = 0.6 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) [resource] -script = ExtResource("3_mqiqi") +script = ExtResource("3_lvgx2") id = "hollywood_building" display_name = "Hollywood Boulevard Building" description = "Steps and entrance of a private building off Hollywood Boulevard." -access_policy = SubResource("Resource_i8fga") +access_policy = SubResource("Resource_xf4wh") equipment = Array[String](["wristband_policy", "flashlight", "radio"]) locations = { "entrance": Vector3(0, 0, 1), @@ -635,7 +296,7 @@ locations = { "sidewalk": Vector3(0, 0, 9), "steps_end": Vector3(0, 0, 3) } -npc_profiles = Array[ExtResource("2_acokf")]([SubResource("Resource_c7lhe"), SubResource("Resource_0t6ar"), SubResource("Resource_wyv8p"), SubResource("Resource_v2hc4"), SubResource("Resource_lg4td"), SubResource("Resource_ijl2s"), SubResource("Resource_eycmk"), SubResource("Resource_ggtpw"), SubResource("Resource_oa64f"), SubResource("Resource_7t7dh"), SubResource("Resource_8tj1k"), SubResource("Resource_tnpxm")]) +npc_profiles = Array[ExtResource("2_7qk8j")]([SubResource("Resource_cgh52"), SubResource("Resource_qe635"), SubResource("Resource_u5nrp"), SubResource("Resource_dsavb"), SubResource("Resource_sexiy"), SubResource("Resource_ghqd8"), SubResource("Resource_g8peq"), SubResource("Resource_4xl5p"), SubResource("Resource_y6282"), SubResource("Resource_75a0r"), SubResource("Resource_ix35t"), SubResource("Resource_0mcg3")]) event_pool = Array[Dictionary]([{ "base_weight": 1.0, "id": "tourist_photo", @@ -774,6 +435,15 @@ event_pool = Array[Dictionary]([{ "probability": 0.28, "requires_emergency": true, "time_range_s": Vector2(760, 880) +}, { +"base_weight": 1.0, +"id": "building_apology_return", +"intent": "enter", +"kind": "recovery", +"npc_type": 9, +"phase": 2, +"probability": 0.22, +"time_range_s": Vector2(620, 800) }]) shift_phases = { 0: { @@ -789,6 +459,3 @@ shift_phases = { "start_s": 700.0 } } -shift_duration_s = 900.0 -policy_summary = "" -door_staff_lines = {} diff --git a/content/venues/joke_shop.tres b/content/venues/joke_shop.tres index a0a448d..f40af1e 100644 --- a/content/venues/joke_shop.tres +++ b/content/venues/joke_shop.tres @@ -1,113 +1,59 @@ -[gd_resource type="Resource" script_class="VenueScenario" load_steps=13 format=3] +[gd_resource type="Resource" script_class="VenueScenario" format=3] -[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_lxsgk"] -[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_wfc6m"] -[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_8xm7a"] +[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_3pn5m"] +[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_l60ad"] +[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_voh88"] -[sub_resource type="Resource" id="Resource_xha77"] -script = ExtResource("1_lxsgk") +[sub_resource type="Resource" id="Resource_aal3l"] +script = ExtResource("1_3pn5m") required_artifacts = { 2: true, 3: true } -grant_if_any_required = false minimum_age = 21 require_id = true wand_screening_required = true policy_summary = "Government ID and a valid ticket or reservation required. Carry items are screened." -[sub_resource type="Resource" id="Resource_8ksyk"] -script = ExtResource("2_wfc6m") +[sub_resource type="Resource" id="Resource_57xgi"] +script = ExtResource("2_l60ad") id = "tourist" display_name = "Tourist" -npc_type = 0 temperament = 0.1 base_compliance = 0.9 -return_probability = 0.0 -reactions_by_level = {} -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_wt1op"] -script = ExtResource("2_wfc6m") +[sub_resource type="Resource" id="Resource_r873e"] +script = ExtResource("2_l60ad") id = "confused_visitor" display_name = "Confused Visitor" npc_type = 3 temperament = 0.2 base_compliance = 0.85 -return_probability = 0.0 -reactions_by_level = {} -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_g1jr2"] -script = ExtResource("2_wfc6m") +[sub_resource type="Resource" id="Resource_3xy2u"] +script = ExtResource("2_l60ad") +id = "street_performer" +display_name = "Street Performer" +npc_type = 1 +temperament = 0.45 +base_compliance = 0.55 +reactions_by_level = { +1: { +0: 55, +2: 15, +3: 30 +} +} + +[sub_resource type="Resource" id="Resource_50i1h"] +script = ExtResource("2_l60ad") id = "influencer" display_name = "Influencer" npc_type = 2 temperament = 0.5 base_compliance = 0.5 -return_probability = 0.0 reactions_by_level = { 1: { 0: 40, @@ -129,47 +75,15 @@ reactions_by_level = { 8: 25 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_ys0b0"] -script = ExtResource("2_wfc6m") +[sub_resource type="Resource" id="Resource_nsw77"] +script = ExtResource("2_l60ad") id = "intoxicated" display_name = "Intoxicated Pedestrian" npc_type = 4 temperament = 0.6 base_compliance = 0.4 -return_probability = 0.0 reactions_by_level = { 1: { 0: 30, @@ -183,41 +97,10 @@ reactions_by_level = { 4: 10 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} reaction_delay = 1.8 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_bv0jw"] -script = ExtResource("2_wfc6m") +[sub_resource type="Resource" id="Resource_20knl"] +script = ExtResource("2_l60ad") id = "trespasser" display_name = "Trespasser" npc_type = 5 @@ -252,47 +135,15 @@ reactions_by_level = { 7: 20 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_7vgx3"] -script = ExtResource("2_wfc6m") +[sub_resource type="Resource" id="Resource_rivis"] +script = ExtResource("2_l60ad") id = "violent_threat" display_name = "Violent Threat" npc_type = 6 temperament = 0.95 base_compliance = 0.05 -return_probability = 0.0 reactions_by_level = { 1: { 2: 25, @@ -320,66 +171,27 @@ reactions_by_level = { 7: 30 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_x43ed"] -script = ExtResource("2_wfc6m") +[sub_resource type="Resource" id="Resource_1dvkp"] +script = ExtResource("2_l60ad") id = "authorized_staff" display_name = "Staff Member" npc_type = 8 temperament = 0.15 base_compliance = 1.0 -return_probability = 0.0 -reactions_by_level = {} default_reactions = { 1: { 0: 100 } } -reaction_delay = 0.6 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_07lsg"] -script = ExtResource("2_wfc6m") +[sub_resource type="Resource" id="Resource_7wsm0"] +script = ExtResource("2_l60ad") id = "authorized_guest" display_name = "Authorized Guest" npc_type = 9 temperament = 0.2 base_compliance = 0.95 -return_probability = 0.0 -reactions_by_level = {} default_reactions = { 1: { 0: 92, @@ -390,16 +202,13 @@ default_reactions = { 2: 12 } } -reaction_delay = 0.6 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) [resource] -script = ExtResource("3_8xm7a") +script = ExtResource("3_voh88") id = "joke_shop" display_name = "The Joke Shop" description = "Comedy club door: IDs, reservations, and wand screening." -access_policy = SubResource("Resource_xha77") +access_policy = SubResource("Resource_aal3l") equipment = Array[String](["id_check", "guest_list", "wand", "radio"]) locations = { "door_area": Vector3(0, 0, 3), @@ -409,7 +218,7 @@ locations = { "sidewalk": Vector3(0, 0, 9), "steps_end": Vector3(0, 0, 3) } -npc_profiles = Array[ExtResource("2_wfc6m")]([SubResource("Resource_8ksyk"), SubResource("Resource_wt1op"), SubResource("Resource_g1jr2"), SubResource("Resource_ys0b0"), SubResource("Resource_bv0jw"), SubResource("Resource_7vgx3"), SubResource("Resource_x43ed"), SubResource("Resource_07lsg")]) +npc_profiles = Array[ExtResource("2_l60ad")]([SubResource("Resource_57xgi"), SubResource("Resource_r873e"), SubResource("Resource_3xy2u"), SubResource("Resource_50i1h"), SubResource("Resource_nsw77"), SubResource("Resource_20knl"), SubResource("Resource_rivis"), SubResource("Resource_1dvkp"), SubResource("Resource_7wsm0")]) event_pool = Array[Dictionary]([{ "base_weight": 1.0, "id": "guest_valid", @@ -564,6 +373,19 @@ event_pool = Array[Dictionary]([{ "phase": 2, "probability": 0.5, "time_range_s": Vector2(650, 830) +}, { +"base_weight": 1.0, +"id": "joke_denied_guest_return", +"intent": "enter", +"kind": "recovery", +"npc_type": 9, +"override_access": { +"has_id": true, +"has_ticket": true +}, +"phase": 2, +"probability": 0.2, +"time_range_s": Vector2(650, 830) }]) shift_phases = { 0: { @@ -579,8 +401,6 @@ shift_phases = { "start_s": 700.0 } } -shift_duration_s = 900.0 -policy_summary = "" door_staff_lines = { "id_check": ["May I see your ID, please?", "Could you show me some identification?"], "prohibited_item": ["I can't allow that item inside. Please leave it outside.", "That item isn't permitted in the venue."], diff --git a/content/venues/tv_studio.tres b/content/venues/tv_studio.tres index 0f285c0..4269b6d 100644 --- a/content/venues/tv_studio.tres +++ b/content/venues/tv_studio.tres @@ -1,11 +1,11 @@ -[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3] +[gd_resource type="Resource" script_class="VenueScenario" format=3] -[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_xxhx3"] -[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_fiebn"] -[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_2dvuq"] +[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_3vwd0"] +[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_g7rkt"] +[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_jd45y"] -[sub_resource type="Resource" id="Resource_blmt0"] -script = ExtResource("1_xxhx3") +[sub_resource type="Resource" id="Resource_c55bb"] +script = ExtResource("1_3vwd0") required_artifacts = { 1: true, 3: true, @@ -13,145 +13,39 @@ required_artifacts = { 5: true } grant_if_any_required = true -minimum_age = 0 -require_id = false -wand_screening_required = false policy_summary = "Entry requires a wristband, credential, ticket, or guest-list confirmation." -[sub_resource type="Resource" id="Resource_oddac"] -script = ExtResource("2_fiebn") +[sub_resource type="Resource" id="Resource_yyxim"] +script = ExtResource("2_g7rkt") id = "tourist" display_name = "Tourist" -npc_type = 0 temperament = 0.1 base_compliance = 0.9 -return_probability = 0.0 -reactions_by_level = {} -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_byir2"] -script = ExtResource("2_fiebn") +[sub_resource type="Resource" id="Resource_lmn2c"] +script = ExtResource("2_g7rkt") id = "confused_visitor" display_name = "Confused Visitor" npc_type = 3 temperament = 0.2 base_compliance = 0.85 -return_probability = 0.0 -reactions_by_level = {} -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_3tt0b"] -script = ExtResource("2_fiebn") +[sub_resource type="Resource" id="Resource_if2cj"] +script = ExtResource("2_g7rkt") id = "delivery" display_name = "Delivery Worker" npc_type = 10 -temperament = 0.25 -base_compliance = 0.8 -return_probability = 0.0 -reactions_by_level = {} -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["Where should I leave this?"]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_ihxib"] -script = ExtResource("2_fiebn") +[sub_resource type="Resource" id="Resource_t47d3"] +script = ExtResource("2_g7rkt") id = "street_performer" display_name = "Street Performer" npc_type = 1 temperament = 0.45 base_compliance = 0.55 -return_probability = 0.0 reactions_by_level = { 1: { 0: 55, @@ -159,47 +53,14 @@ reactions_by_level = { 3: 30 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_c57iy"] -script = ExtResource("2_fiebn") +[sub_resource type="Resource" id="Resource_j2mp5"] +script = ExtResource("2_g7rkt") id = "influencer" display_name = "Influencer" npc_type = 2 temperament = 0.5 base_compliance = 0.5 -return_probability = 0.0 reactions_by_level = { 1: { 0: 40, @@ -221,47 +82,15 @@ reactions_by_level = { 8: 25 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_bk410"] -script = ExtResource("2_fiebn") +[sub_resource type="Resource" id="Resource_uscqg"] +script = ExtResource("2_g7rkt") id = "intoxicated" display_name = "Intoxicated Pedestrian" npc_type = 4 temperament = 0.6 base_compliance = 0.4 -return_probability = 0.0 reactions_by_level = { 1: { 0: 30, @@ -275,41 +104,10 @@ reactions_by_level = { 4: 10 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} reaction_delay = 1.8 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_jp52w"] -script = ExtResource("2_fiebn") +[sub_resource type="Resource" id="Resource_8aovb"] +script = ExtResource("2_g7rkt") id = "trespasser" display_name = "Trespasser" npc_type = 5 @@ -344,41 +142,10 @@ reactions_by_level = { 7: 20 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_5kplf"] -script = ExtResource("2_fiebn") +[sub_resource type="Resource" id="Resource_owl7s"] +script = ExtResource("2_g7rkt") id = "mental_crisis" display_name = "Person in Crisis" npc_type = 7 @@ -414,47 +181,15 @@ reactions_by_level = { 7: 15 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} reaction_delay = 2.4 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_6bscs"] -script = ExtResource("2_fiebn") +[sub_resource type="Resource" id="Resource_rnay4"] +script = ExtResource("2_g7rkt") id = "violent_threat" display_name = "Violent Threat" npc_type = 6 temperament = 0.95 base_compliance = 0.05 -return_probability = 0.0 reactions_by_level = { 1: { 2: 25, @@ -482,41 +217,9 @@ reactions_by_level = { 7: 30 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_n4lxh"] -script = ExtResource("2_fiebn") +[sub_resource type="Resource" id="Resource_lhp4m"] +script = ExtResource("2_g7rkt") id = "protester" display_name = "Protester" npc_type = 12 @@ -550,66 +253,28 @@ reactions_by_level = { 7: 20 } } -default_reactions = { -1: { -0: 80, -2: 8, -3: 12 -}, -2: { -0: 70, -1: 10, -2: 12, -4: 8 -}, -3: { -0: 55, -1: 15, -2: 20, -4: 10 -}, -4: { -1: 65, -2: 15, -7: 20 -}, -5: { -1: 30, -5: 40, -7: 30 -} -} -reaction_delay = 0.6 dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_6st6l"] -script = ExtResource("2_fiebn") +[sub_resource type="Resource" id="Resource_s8tjd"] +script = ExtResource("2_g7rkt") id = "authorized_staff" display_name = "Staff Member" npc_type = 8 temperament = 0.15 base_compliance = 1.0 -return_probability = 0.0 -reactions_by_level = {} default_reactions = { 1: { 0: 100 } } -reaction_delay = 0.6 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) -[sub_resource type="Resource" id="Resource_nf2oq"] -script = ExtResource("2_fiebn") +[sub_resource type="Resource" id="Resource_ii8kq"] +script = ExtResource("2_g7rkt") id = "authorized_guest" display_name = "Authorized Guest" npc_type = 9 temperament = 0.2 base_compliance = 0.95 -return_probability = 0.0 -reactions_by_level = {} default_reactions = { 1: { 0: 92, @@ -620,16 +285,13 @@ default_reactions = { 2: 12 } } -reaction_delay = 0.6 -dialogue_lines = Array[String]([]) -carried_items = Array[String]([]) [resource] -script = ExtResource("3_2dvuq") +script = ExtResource("3_jd45y") id = "tv_studio" display_name = "Hollywood Blvd Late Night TV Studio" description = "Crowd, celebrities, and staff around a late-night studio." -access_policy = SubResource("Resource_blmt0") +access_policy = SubResource("Resource_c55bb") equipment = Array[String](["wristband_policy", "guest_list", "radio", "flashlight"]) locations = { "entrance": Vector3(0, 0, 1), @@ -639,7 +301,7 @@ locations = { "stage_door": Vector3(6, 0, -2), "steps_end": Vector3(0, 0, 3) } -npc_profiles = Array[ExtResource("2_fiebn")]([SubResource("Resource_oddac"), SubResource("Resource_byir2"), SubResource("Resource_3tt0b"), SubResource("Resource_ihxib"), SubResource("Resource_c57iy"), SubResource("Resource_bk410"), SubResource("Resource_jp52w"), SubResource("Resource_5kplf"), SubResource("Resource_6bscs"), SubResource("Resource_n4lxh"), SubResource("Resource_6st6l"), SubResource("Resource_nf2oq")]) +npc_profiles = Array[ExtResource("2_g7rkt")]([SubResource("Resource_yyxim"), SubResource("Resource_lmn2c"), SubResource("Resource_if2cj"), SubResource("Resource_t47d3"), SubResource("Resource_j2mp5"), SubResource("Resource_uscqg"), SubResource("Resource_8aovb"), SubResource("Resource_owl7s"), SubResource("Resource_rnay4"), SubResource("Resource_lhp4m"), SubResource("Resource_s8tjd"), SubResource("Resource_ii8kq")]) event_pool = Array[Dictionary]([{ "base_weight": 1.0, "id": "audience_ticket", @@ -790,6 +452,15 @@ event_pool = Array[Dictionary]([{ "phase": 2, "probability": 0.45, "time_range_s": Vector2(700, 860) +}, { +"base_weight": 1.0, +"id": "studio_manager_check", +"intent": "enter", +"kind": "recovery", +"npc_type": 8, +"phase": 2, +"probability": 0.2, +"time_range_s": Vector2(620, 820) }]) shift_phases = { 0: { @@ -805,6 +476,3 @@ shift_phases = { "start_s": 700.0 } } -shift_duration_s = 900.0 -policy_summary = "" -door_staff_lines = {} diff --git a/docs/architecture.md b/docs/architecture.md index 66e00e0..7d7fd12 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,7 +37,7 @@ This document follows the structure of the arc42 architecture documentation temp - **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible. - **PATCH** — bug fixes and small polish that do not change behavior. -The single source of truth for the version is `config/version` in `project.godot` (currently `0.7.3`). Each release is tagged in git as `v` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md). +The single source of truth for the version is `config/version` in `project.godot` (currently `0.8.0`). Each release is tagged in git as `v` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md). ## 3. Context and Scope @@ -217,6 +217,8 @@ The release process is: bump the version, run the full test suite and `tools/che - **Observability:** Debug overlays show NPC intent, access status, escalation level, active incidents, and response timers during development. - **Visual regression:** Staged captures are compared against the runtime reference art (`screen_designs.py compare`) and optionally judged by a vision model; `tools/check_screenshots.sh` makes the pass repeatable. - **Asset provenance and AI usage:** AI-assisted images/concepts are development inputs only; validated outputs are committed, audited and loaded through the same runtime asset contracts. Generated content must not become an offline/CI dependency. +- **Campaign and checkpoint state:** `Campaign` owns venue order/progression; settings, campaign, completed shift history and a versioned pause checkpoint are separate JSON documents. The checkpoint snapshots coordinator systems, scheduler RNG and NPC presentation/access state; it deliberately does not serialize the tutorial overlay or transient input state. +- **Difficulty:** easy/normal/hard only scale spawn pacing, NPC compliance tolerance and report-credibility strictness; access/escalation rules and emergency rarity are invariant. - **Documentation ownership:** `PLAN.md` owns design intent; `docs/gameplay.md` owns playable mechanics; `docs/architecture.md` owns system boundaries, data flow, constraints and cross-cutting decisions; ADRs own decision history; `docs/development-status.md` owns the implemented/gap list; `TODO.md` owns the roadmap; `MEMORY.md` owns gotchas and open questions. Specialized docs link to these rather than becoming competing sources of truth. ## 9. Architecture Decisions diff --git a/docs/development-status.md b/docs/development-status.md index 6839ea7..f30db17 100644 --- a/docs/development-status.md +++ b/docs/development-status.md @@ -5,7 +5,7 @@ Implemented so far: - Godot 4.3 project scaffold, input map, procedural world builder - Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` - Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools -- Headless unit tests: **237 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings) +- Headless unit tests: **247 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings) - Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event - First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review - Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`) @@ -41,10 +41,11 @@ Implemented so far: - Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing. - Realism adoption (post-v0.6.0): v2 per-phase skylines and surface tiles with normal maps, retuned sun/exposure/fog, wet asphalt, denser foliage, a visible additive flashlight beam, and refreshed staging captures. `tools/check_screenshots.sh` is the repeatable capture/compare gate; judge is clean of `diff` verdicts. - Input/reliability fixes: selecting a venue releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status. Mouse look now reads at `_input`, restores capture on window focus/click-back, and the HUD is mouse-transparent. +- Campaign and first-hour pass (v0.8.0): campaign progression, checkpoint resume, difficulty presets, a searchable Help overlay, recovery events, contextual dialogue, policy/debrief lines and tutorial/replay/skip. Scripted QA bypasses the tutorial unless `--tutorial` is passed. - Tutorial pass (0.8.0 work): the eight-beat first shift is wired through a non-blocking prompt overlay, a safe compliant visitor, real wheel/inspection/warning/radio panels, persisted completion/skip and a Settings replay action. Scripted tests/captures bypass it unless `--tutorial` is passed; `smoke_tutorial.gd` covers the full flow. - Vision judge (v0.7.2): discovers the cheapest current OpenRouter image+JSON model, reports the model/fallbacks used, and clears the full screen-design judge (13/13 match, 0 diff). - Desktop HQ (v0.7.1): optional Forward+ Windows/Linux zip with volumetric fog, SDFGI and a volumetric flashlight; the default Compatibility/Web builds and headless tests gate the effect off. A/B captures are generated locally under `docs/screens/realism/` (gitignored). - Venue geometry (v0.7.0): the Joke Shop now builds as a narrow brick alley with a warm wall lantern, shutters, dumpster/AC clutter and no skyline; the TV Studio builds a stone facade, bulb canopy, glossy black car and barrier line. `WorldBuilder.VENUE_SPECS` drives facade/street/nav dimensions, and `GameManager` rebuilds only the world container when the venue changes. - Gear fit (post-v0.6.0): Blender gear is measured against the avatar head/skirt silhouette and the checks run in both the Python tooling and the headless suite. Sunglasses, the shoulder bag and the firefighter helmet were fitted to the new faces; the Influencer portrait and gallery were recaptured with a before/after report. -Current gaps for v1.0.0: onboarding/campaign progression, deeper authored content and NPC dialogue, mid-shift save/resume and replay, localization/accessibility expansion, voice/audio production, wider performance profiling, signed/notarized release packaging, and a unified Godot baseline. The full gap analysis and milestone plan live in [`TODO.md`](../TODO.md); the current architecture and documentation ownership map are in [`docs/architecture.md`](architecture.md). +Current gaps for v1.0.0: deeper authored content/dialogue, animation and audio production, localization/accessibility expansion, 24-NPC/platform profiling, external playtesting, signed/notarized release packaging, and a unified Godot baseline. The full gap analysis and milestone plan live in [`TODO.md`](../TODO.md); the current architecture and documentation ownership map are in [`docs/architecture.md`](architecture.md). diff --git a/docs/gameplay.md b/docs/gameplay.md index c5b7329..78bc648 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -16,7 +16,7 @@ A typical shift lasts 10–15 minutes. The player: The game rewards calm resolutions rather than aggressive behavior. Emergency assistance and armed responders are rare last-resort systems with meaningful consequences. -The first shift starts with an in-game tutorial covering movement/look, focus cycling, the action wheel, access inspection, polite direction, formal warning/body camera, radio and shift review. Its beat sheet is in [`tutorial.md`](tutorial.md). +The first shift starts with an in-game tutorial covering movement/look, focus cycling, the action wheel, access inspection, polite direction, formal warning/body camera, radio and shift review. Its beat sheet is in [`tutorial.md`](tutorial.md). After that a short campaign runs Hollywood → Joke Shop → TV Studio; progress, difficulty and a pause-safe checkpoint are persisted locally. Help (from the venue menu) explains the controls and the current venue policy. ## Venues diff --git a/docs/getting-started.md b/docs/getting-started.md index 3ed770b..6bf8f22 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -61,6 +61,9 @@ godot --headless --path . --script res://src/test/smoke_scenario_content.gd # Tutorial smoke (windowed): completion, replay and skip; isolated user data XDG_DATA_HOME=/tmp/gigline-tutorial godot --path . --fixed-fps 60 --script res://src/test/smoke_tutorial.gd -- --tutorial + +# Campaign/checkpoint smoke (windowed): resume, review continue, help; isolated data +XDG_DATA_HOME=/tmp/gigline-campaign godot --path . --fixed-fps 60 --script res://src/test/smoke_campaign.gd ``` The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor. diff --git a/docs/smoke-checklist.md b/docs/smoke-checklist.md index da1e1de..8f8c4f2 100644 --- a/docs/smoke-checklist.md +++ b/docs/smoke-checklist.md @@ -32,4 +32,8 @@ 30. The shift review's "Body camera timeline" button steps through the recorded events (Left/Right or the buttons) and Esc returns to the review. 31. The escalation label shows a `[###--] L3/5` shape meter as well as the level name, and stays readable in grayscale. 32. Changing "HUD font scale" in Settings resizes the HUD text and persists across a restart. -33. When the shift timer reaches 0, the shift review screen shows grade, incident summary, responder outcomes, investigations, and persisted shift history; "Run another shift" returns to the venue menu. +33. When the shift timer reaches 0, the shift review screen shows grade, policy, incident summary, responder outcomes, investigations, and persisted shift history; campaign Continue starts the next venue. +34. On a fresh profile the tutorial starts on Hollywood, advances through its eight beats, and F1 skips it; Settings Replay tutorial restarts it. +35. Pausing saves a checkpoint: returning to the menu and choosing Continue resumes elapsed time, NPCs, incidents, door/flashlight state and campaign position. +36. Settings difficulty changes spawn pacing/compliance/report strictness while escalation rules stay the same; Help filters controls and shows the venue policy. +37. The F debug overlay lists the next eligible events; recovery events and recently played scripted events behave as authored. diff --git a/project.godot b/project.godot index 2d7af0a..4f287a6 100644 --- a/project.godot +++ b/project.godot @@ -12,7 +12,7 @@ config_version=5 config/name="Gig Line" config/description="Workplace security simulation about observation, access verification, and proportionate escalation." -config/version="0.7.3" +config/version="0.8.0" run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.3", "GL Compatibility") diff --git a/src/autoload/save.gd b/src/autoload/save.gd index c6d5603..44b4066 100644 --- a/src/autoload/save.gd +++ b/src/autoload/save.gd @@ -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 := { diff --git a/src/content/scenario_factory.gd b/src/content/scenario_factory.gd index c86c6f7..51e0777 100644 --- a/src/content/scenario_factory.gd +++ b/src/content/scenario_factory.gd @@ -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 diff --git a/src/core/campaign.gd b/src/core/campaign.gd new file mode 100644 index 0000000..f8d0878 --- /dev/null +++ b/src/core/campaign.gd @@ -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({}) diff --git a/src/core/crowd_system.gd b/src/core/crowd_system.gd index 812d140..8206099 100644 --- a/src/core/crowd_system.gd +++ b/src/core/crowd_system.gd @@ -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)) diff --git a/src/core/event_scheduler.gd b/src/core/event_scheduler.gd index a048215..761dd68 100644 --- a/src/core/event_scheduler.gd +++ b/src/core/event_scheduler.gd @@ -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: diff --git a/src/core/incident_recorder.gd b/src/core/incident_recorder.gd index 56b3f88..bc5a35d 100644 --- a/src/core/incident_recorder.gd +++ b/src/core/incident_recorder.gd @@ -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) diff --git a/src/core/npc_controller.gd b/src/core/npc_controller.gd index 6d47633..93ea10b 100644 --- a/src/core/npc_controller.gd +++ b/src/core/npc_controller.gd @@ -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) diff --git a/src/core/npc_profile.gd b/src/core/npc_profile.gd index 007491d..e2ac834 100644 --- a/src/core/npc_profile.gd +++ b/src/core/npc_profile.gd @@ -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] diff --git a/src/core/radio_system.gd b/src/core/radio_system.gd index b46ffc2..5babc71 100644 --- a/src/core/radio_system.gd +++ b/src/core/radio_system.gd @@ -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). diff --git a/src/core/reputation_evaluator.gd b/src/core/reputation_evaluator.gd index 28ddaf2..30c7243 100644 --- a/src/core/reputation_evaluator.gd +++ b/src/core/reputation_evaluator.gd @@ -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)) diff --git a/src/core/scenario_validator.gd b/src/core/scenario_validator.gd new file mode 100644 index 0000000..10b9801 --- /dev/null +++ b/src/core/scenario_validator.gd @@ -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 diff --git a/src/core/shift_coordinator.gd b/src/core/shift_coordinator.gd index 3ea528e..4deb7f6 100644 --- a/src/core/shift_coordinator.gd +++ b/src/core/shift_coordinator.gd @@ -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} diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index 478b5f0..448aeda 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -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 diff --git a/src/game/ui/help_overlay.gd b/src/game/ui/help_overlay.gd new file mode 100644 index 0000000..b80d3a3 --- /dev/null +++ b/src/game/ui/help_overlay.gd @@ -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() diff --git a/src/game/ui/hud.gd b/src/game/ui/hud.gd index 4bd544c..a89567d 100644 --- a/src/game/ui/hud.gd +++ b/src/game/ui/hud.gd @@ -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: diff --git a/src/game/ui/settings_panel.gd b/src/game/ui/settings_panel.gd index 2271322..c0ead99 100644 --- a/src/game/ui/settings_panel.gd +++ b/src/game/ui/settings_panel.gd @@ -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(): diff --git a/src/game/ui/shift_review.gd b/src/game/ui/shift_review.gd index e1af55b..6467d69 100644 --- a/src/game/ui/shift_review.gd +++ b/src/game/ui/shift_review.gd @@ -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 \ No newline at end of file diff --git a/src/game/ui/start_menu.gd b/src/game/ui/start_menu.gd index 07604d2..28abe46 100644 --- a/src/game/ui/start_menu.gd +++ b/src/game/ui/start_menu.gd @@ -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 diff --git a/src/test/smoke_campaign.gd b/src/test/smoke_campaign.gd new file mode 100644 index 0000000..162df71 --- /dev/null +++ b/src/test/smoke_campaign.gd @@ -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) diff --git a/src/test/tests/test_campaign.gd b/src/test/tests/test_campaign.gd new file mode 100644 index 0000000..c67abc8 --- /dev/null +++ b/src/test/tests/test_campaign.gd @@ -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 diff --git a/src/test/tests/test_dialogue.gd b/src/test/tests/test_dialogue.gd new file mode 100644 index 0000000..c9b249f --- /dev/null +++ b/src/test/tests/test_dialogue.gd @@ -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") diff --git a/src/test/tests/test_scenario_validator.gd b/src/test/tests/test_scenario_validator.gd new file mode 100644 index 0000000..156feeb --- /dev/null +++ b/src/test/tests/test_scenario_validator.gd @@ -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 diff --git a/src/test/tests/test_shift_state.gd b/src/test/tests/test_shift_state.gd new file mode 100644 index 0000000..0be949e --- /dev/null +++ b/src/test/tests/test_shift_state.gd @@ -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