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

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

No files matched your search

+27
View File
@@ -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 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use
Semantic Versioning. 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 ## [0.7.3] — 2026-11-01
### Fixed ### Fixed
+131
View File
@@ -1336,3 +1336,134 @@ back when a provider rejects a request.
records the selected model and ~$0.006 spend. records the selected model and ~$0.006 spend.
- [x] Update `docs/asset-tooling.md`, `MEMORY.md`, `README.md` and `CHANGELOG.md`; the - [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). 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.
+1
View File
@@ -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. - 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. - **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. - **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`. - **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. - **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.
+1 -1
View File
@@ -172,7 +172,7 @@ Primary decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md), [AD
## Versioning and Delivery ## Versioning and Delivery
`config/version` in `project.godot` is the single version source (currently `0.7.3`). Releases are tagged `v<version>`; 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<version>`; 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 ## Design Direction
+7 -8
View File
@@ -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 > Downloads are public (no sign-in required) since the host's instance-wide
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31. > `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
> >
> **Latest: v0.7.3** — mouse-look input reliability fixes, plus the v0.7.2 > **Latest: v0.8.0** — first-shift tutorial, campaign/checkpoint resume,
> cost-aware OpenRouter judge, optional Forward+ Desktop HQ build, distinct > difficulty and a searchable Help overlay, plus the v0.7.x judge, Desktop HQ,
> venues and fitted NPC gear. > venue and gear work.
> Releases are built automatically when a `vX.Y.Z` tag is pushed > Releases are built automatically when a `vX.Y.Z` tag is pushed
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline > `./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 ## Development Status
v0.7.2 ships the cost-aware OpenRouter judge and the optional Desktop HQ build. v0.8.0 adds the first-shift tutorial, campaign shell, checkpoint resume,
The three venues now have distinct geometry, fitted NPC gear, the Blender asset difficulty presets and an in-game Help overlay on top of the v0.7.x venue, gear,
library and the post-v0.6.0 realism pass. See judge and Desktop HQ work. See [`docs/development-status.md`](docs/development-status.md)
[`docs/development-status.md`](docs/development-status.md) for the implemented for the implemented list and [`TODO.md`](TODO.md) for the v1.0.0 roadmap. Manual
list, and [`TODO.md`](TODO.md) for the v1.0.0 gap analysis and roadmap. Manual
verification uses the [`docs/smoke-checklist.md`](docs/smoke-checklist.md). verification uses the [`docs/smoke-checklist.md`](docs/smoke-checklist.md).
## Development Workflow ## Development Workflow
-127
View File
@@ -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 | | 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 | | 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 ## Milestone 0.9.0 — Production quality and platform readiness
Goal: content-complete, accessible enough for a public build, measured on the target platforms Goal: content-complete, accessible enough for a public build, measured on the target platforms
+42 -375
View File
@@ -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/access_policy.gd" id="1_c5x6v"]
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_acokf"] [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_mqiqi"] [ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_lvgx2"]
[sub_resource type="Resource" id="Resource_i8fga"] [sub_resource type="Resource" id="Resource_xf4wh"]
script = ExtResource("1_2u28i") script = ExtResource("1_c5x6v")
required_artifacts = { required_artifacts = {
1: true 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." policy_summary = "Private property. Entry requires recognition or a valid wristband."
[sub_resource type="Resource" id="Resource_c7lhe"] [sub_resource type="Resource" id="Resource_cgh52"]
script = ExtResource("2_acokf") script = ExtResource("2_7qk8j")
id = "tourist" id = "tourist"
display_name = "Tourist" display_name = "Tourist"
npc_type = 0
temperament = 0.1 temperament = 0.1
base_compliance = 0.9 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."]) 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"] [sub_resource type="Resource" id="Resource_qe635"]
script = ExtResource("2_acokf") script = ExtResource("2_7qk8j")
id = "confused_visitor" id = "confused_visitor"
display_name = "Confused Visitor" display_name = "Confused Visitor"
npc_type = 3 npc_type = 3
temperament = 0.2 temperament = 0.2
base_compliance = 0.85 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."]) 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"] [sub_resource type="Resource" id="Resource_u5nrp"]
script = ExtResource("2_acokf") script = ExtResource("2_7qk8j")
id = "delivery" id = "delivery"
display_name = "Delivery Worker" display_name = "Delivery Worker"
npc_type = 10 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?"]) dialogue_lines = Array[String](["Where should I leave this?"])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_v2hc4"] [sub_resource type="Resource" id="Resource_dsavb"]
script = ExtResource("2_acokf") script = ExtResource("2_7qk8j")
id = "street_performer" id = "street_performer"
display_name = "Street Performer" display_name = "Street Performer"
npc_type = 1 npc_type = 1
temperament = 0.45 temperament = 0.45
base_compliance = 0.55 base_compliance = 0.55
return_probability = 0.0
reactions_by_level = { reactions_by_level = {
1: { 1: {
0: 55, 0: 55,
@@ -156,47 +49,14 @@ reactions_by_level = {
3: 30 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"] [sub_resource type="Resource" id="Resource_sexiy"]
script = ExtResource("2_acokf") script = ExtResource("2_7qk8j")
id = "influencer" id = "influencer"
display_name = "Influencer" display_name = "Influencer"
npc_type = 2 npc_type = 2
temperament = 0.5 temperament = 0.5
base_compliance = 0.5 base_compliance = 0.5
return_probability = 0.0
reactions_by_level = { reactions_by_level = {
1: { 1: {
0: 40, 0: 40,
@@ -218,47 +78,15 @@ reactions_by_level = {
8: 25 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."]) 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"] [sub_resource type="Resource" id="Resource_ghqd8"]
script = ExtResource("2_acokf") script = ExtResource("2_7qk8j")
id = "intoxicated" id = "intoxicated"
display_name = "Intoxicated Pedestrian" display_name = "Intoxicated Pedestrian"
npc_type = 4 npc_type = 4
temperament = 0.6 temperament = 0.6
base_compliance = 0.4 base_compliance = 0.4
return_probability = 0.0
reactions_by_level = { reactions_by_level = {
1: { 1: {
0: 30, 0: 30,
@@ -272,41 +100,10 @@ reactions_by_level = {
4: 10 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 reaction_delay = 1.8
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_eycmk"] [sub_resource type="Resource" id="Resource_g8peq"]
script = ExtResource("2_acokf") script = ExtResource("2_7qk8j")
id = "trespasser" id = "trespasser"
display_name = "Trespasser" display_name = "Trespasser"
npc_type = 5 npc_type = 5
@@ -341,41 +138,10 @@ reactions_by_level = {
7: 20 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."]) 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"] [sub_resource type="Resource" id="Resource_4xl5p"]
script = ExtResource("2_acokf") script = ExtResource("2_7qk8j")
id = "mental_crisis" id = "mental_crisis"
display_name = "Person in Crisis" display_name = "Person in Crisis"
npc_type = 7 npc_type = 7
@@ -411,47 +177,15 @@ reactions_by_level = {
7: 15 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 reaction_delay = 2.4
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_oa64f"] [sub_resource type="Resource" id="Resource_y6282"]
script = ExtResource("2_acokf") script = ExtResource("2_7qk8j")
id = "violent_threat" id = "violent_threat"
display_name = "Violent Threat" display_name = "Violent Threat"
npc_type = 6 npc_type = 6
temperament = 0.95 temperament = 0.95
base_compliance = 0.05 base_compliance = 0.05
return_probability = 0.0
reactions_by_level = { reactions_by_level = {
1: { 1: {
2: 25, 2: 25,
@@ -479,41 +213,9 @@ reactions_by_level = {
7: 30 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"] [sub_resource type="Resource" id="Resource_75a0r"]
script = ExtResource("2_acokf") script = ExtResource("2_7qk8j")
id = "protester" id = "protester"
display_name = "Protester" display_name = "Protester"
npc_type = 12 npc_type = 12
@@ -547,66 +249,28 @@ reactions_by_level = {
7: 20 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."]) 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"] [sub_resource type="Resource" id="Resource_ix35t"]
script = ExtResource("2_acokf") script = ExtResource("2_7qk8j")
id = "authorized_staff" id = "authorized_staff"
display_name = "Staff Member" display_name = "Staff Member"
npc_type = 8 npc_type = 8
temperament = 0.15 temperament = 0.15
base_compliance = 1.0 base_compliance = 1.0
return_probability = 0.0
reactions_by_level = {}
default_reactions = { default_reactions = {
1: { 1: {
0: 100 0: 100
} }
} }
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_tnpxm"] [sub_resource type="Resource" id="Resource_0mcg3"]
script = ExtResource("2_acokf") script = ExtResource("2_7qk8j")
id = "authorized_guest" id = "authorized_guest"
display_name = "Authorized Guest" display_name = "Authorized Guest"
npc_type = 9 npc_type = 9
temperament = 0.2 temperament = 0.2
base_compliance = 0.95 base_compliance = 0.95
return_probability = 0.0
reactions_by_level = {}
default_reactions = { default_reactions = {
1: { 1: {
0: 92, 0: 92,
@@ -617,16 +281,13 @@ default_reactions = {
2: 12 2: 12
} }
} }
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[resource] [resource]
script = ExtResource("3_mqiqi") script = ExtResource("3_lvgx2")
id = "hollywood_building" id = "hollywood_building"
display_name = "Hollywood Boulevard Building" display_name = "Hollywood Boulevard Building"
description = "Steps and entrance of a private building off Hollywood Boulevard." 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"]) equipment = Array[String](["wristband_policy", "flashlight", "radio"])
locations = { locations = {
"entrance": Vector3(0, 0, 1), "entrance": Vector3(0, 0, 1),
@@ -635,7 +296,7 @@ locations = {
"sidewalk": Vector3(0, 0, 9), "sidewalk": Vector3(0, 0, 9),
"steps_end": Vector3(0, 0, 3) "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]([{ event_pool = Array[Dictionary]([{
"base_weight": 1.0, "base_weight": 1.0,
"id": "tourist_photo", "id": "tourist_photo",
@@ -774,6 +435,15 @@ event_pool = Array[Dictionary]([{
"probability": 0.28, "probability": 0.28,
"requires_emergency": true, "requires_emergency": true,
"time_range_s": Vector2(760, 880) "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 = { shift_phases = {
0: { 0: {
@@ -789,6 +459,3 @@ shift_phases = {
"start_s": 700.0 "start_s": 700.0
} }
} }
shift_duration_s = 900.0
policy_summary = ""
door_staff_lines = {}
+53 -233
View File
@@ -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/access_policy.gd" id="1_3pn5m"]
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_wfc6m"] [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_8xm7a"] [ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_voh88"]
[sub_resource type="Resource" id="Resource_xha77"] [sub_resource type="Resource" id="Resource_aal3l"]
script = ExtResource("1_lxsgk") script = ExtResource("1_3pn5m")
required_artifacts = { required_artifacts = {
2: true, 2: true,
3: true 3: true
} }
grant_if_any_required = false
minimum_age = 21 minimum_age = 21
require_id = true require_id = true
wand_screening_required = true wand_screening_required = true
policy_summary = "Government ID and a valid ticket or reservation required. Carry items are screened." policy_summary = "Government ID and a valid ticket or reservation required. Carry items are screened."
[sub_resource type="Resource" id="Resource_8ksyk"] [sub_resource type="Resource" id="Resource_57xgi"]
script = ExtResource("2_wfc6m") script = ExtResource("2_l60ad")
id = "tourist" id = "tourist"
display_name = "Tourist" display_name = "Tourist"
npc_type = 0
temperament = 0.1 temperament = 0.1
base_compliance = 0.9 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."]) 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"] [sub_resource type="Resource" id="Resource_r873e"]
script = ExtResource("2_wfc6m") script = ExtResource("2_l60ad")
id = "confused_visitor" id = "confused_visitor"
display_name = "Confused Visitor" display_name = "Confused Visitor"
npc_type = 3 npc_type = 3
temperament = 0.2 temperament = 0.2
base_compliance = 0.85 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."]) 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"] [sub_resource type="Resource" id="Resource_3xy2u"]
script = ExtResource("2_wfc6m") 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" id = "influencer"
display_name = "Influencer" display_name = "Influencer"
npc_type = 2 npc_type = 2
temperament = 0.5 temperament = 0.5
base_compliance = 0.5 base_compliance = 0.5
return_probability = 0.0
reactions_by_level = { reactions_by_level = {
1: { 1: {
0: 40, 0: 40,
@@ -129,47 +75,15 @@ reactions_by_level = {
8: 25 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."]) 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"] [sub_resource type="Resource" id="Resource_nsw77"]
script = ExtResource("2_wfc6m") script = ExtResource("2_l60ad")
id = "intoxicated" id = "intoxicated"
display_name = "Intoxicated Pedestrian" display_name = "Intoxicated Pedestrian"
npc_type = 4 npc_type = 4
temperament = 0.6 temperament = 0.6
base_compliance = 0.4 base_compliance = 0.4
return_probability = 0.0
reactions_by_level = { reactions_by_level = {
1: { 1: {
0: 30, 0: 30,
@@ -183,41 +97,10 @@ reactions_by_level = {
4: 10 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 reaction_delay = 1.8
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_bv0jw"] [sub_resource type="Resource" id="Resource_20knl"]
script = ExtResource("2_wfc6m") script = ExtResource("2_l60ad")
id = "trespasser" id = "trespasser"
display_name = "Trespasser" display_name = "Trespasser"
npc_type = 5 npc_type = 5
@@ -252,47 +135,15 @@ reactions_by_level = {
7: 20 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."]) 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"] [sub_resource type="Resource" id="Resource_rivis"]
script = ExtResource("2_wfc6m") script = ExtResource("2_l60ad")
id = "violent_threat" id = "violent_threat"
display_name = "Violent Threat" display_name = "Violent Threat"
npc_type = 6 npc_type = 6
temperament = 0.95 temperament = 0.95
base_compliance = 0.05 base_compliance = 0.05
return_probability = 0.0
reactions_by_level = { reactions_by_level = {
1: { 1: {
2: 25, 2: 25,
@@ -320,66 +171,27 @@ reactions_by_level = {
7: 30 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"] [sub_resource type="Resource" id="Resource_1dvkp"]
script = ExtResource("2_wfc6m") script = ExtResource("2_l60ad")
id = "authorized_staff" id = "authorized_staff"
display_name = "Staff Member" display_name = "Staff Member"
npc_type = 8 npc_type = 8
temperament = 0.15 temperament = 0.15
base_compliance = 1.0 base_compliance = 1.0
return_probability = 0.0
reactions_by_level = {}
default_reactions = { default_reactions = {
1: { 1: {
0: 100 0: 100
} }
} }
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_07lsg"] [sub_resource type="Resource" id="Resource_7wsm0"]
script = ExtResource("2_wfc6m") script = ExtResource("2_l60ad")
id = "authorized_guest" id = "authorized_guest"
display_name = "Authorized Guest" display_name = "Authorized Guest"
npc_type = 9 npc_type = 9
temperament = 0.2 temperament = 0.2
base_compliance = 0.95 base_compliance = 0.95
return_probability = 0.0
reactions_by_level = {}
default_reactions = { default_reactions = {
1: { 1: {
0: 92, 0: 92,
@@ -390,16 +202,13 @@ default_reactions = {
2: 12 2: 12
} }
} }
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[resource] [resource]
script = ExtResource("3_8xm7a") script = ExtResource("3_voh88")
id = "joke_shop" id = "joke_shop"
display_name = "The Joke Shop" display_name = "The Joke Shop"
description = "Comedy club door: IDs, reservations, and wand screening." 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"]) equipment = Array[String](["id_check", "guest_list", "wand", "radio"])
locations = { locations = {
"door_area": Vector3(0, 0, 3), "door_area": Vector3(0, 0, 3),
@@ -409,7 +218,7 @@ locations = {
"sidewalk": Vector3(0, 0, 9), "sidewalk": Vector3(0, 0, 9),
"steps_end": Vector3(0, 0, 3) "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]([{ event_pool = Array[Dictionary]([{
"base_weight": 1.0, "base_weight": 1.0,
"id": "guest_valid", "id": "guest_valid",
@@ -564,6 +373,19 @@ event_pool = Array[Dictionary]([{
"phase": 2, "phase": 2,
"probability": 0.5, "probability": 0.5,
"time_range_s": Vector2(650, 830) "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 = { shift_phases = {
0: { 0: {
@@ -579,8 +401,6 @@ shift_phases = {
"start_s": 700.0 "start_s": 700.0
} }
} }
shift_duration_s = 900.0
policy_summary = ""
door_staff_lines = { door_staff_lines = {
"id_check": ["May I see your ID, please?", "Could you show me some identification?"], "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."], "prohibited_item": ["I can't allow that item inside. Please leave it outside.", "That item isn't permitted in the venue."],
+42 -374
View File
@@ -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/access_policy.gd" id="1_3vwd0"]
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_fiebn"] [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_2dvuq"] [ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_jd45y"]
[sub_resource type="Resource" id="Resource_blmt0"] [sub_resource type="Resource" id="Resource_c55bb"]
script = ExtResource("1_xxhx3") script = ExtResource("1_3vwd0")
required_artifacts = { required_artifacts = {
1: true, 1: true,
3: true, 3: true,
@@ -13,145 +13,39 @@ required_artifacts = {
5: true 5: true
} }
grant_if_any_required = 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." policy_summary = "Entry requires a wristband, credential, ticket, or guest-list confirmation."
[sub_resource type="Resource" id="Resource_oddac"] [sub_resource type="Resource" id="Resource_yyxim"]
script = ExtResource("2_fiebn") script = ExtResource("2_g7rkt")
id = "tourist" id = "tourist"
display_name = "Tourist" display_name = "Tourist"
npc_type = 0
temperament = 0.1 temperament = 0.1
base_compliance = 0.9 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."]) 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"] [sub_resource type="Resource" id="Resource_lmn2c"]
script = ExtResource("2_fiebn") script = ExtResource("2_g7rkt")
id = "confused_visitor" id = "confused_visitor"
display_name = "Confused Visitor" display_name = "Confused Visitor"
npc_type = 3 npc_type = 3
temperament = 0.2 temperament = 0.2
base_compliance = 0.85 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."]) 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"] [sub_resource type="Resource" id="Resource_if2cj"]
script = ExtResource("2_fiebn") script = ExtResource("2_g7rkt")
id = "delivery" id = "delivery"
display_name = "Delivery Worker" display_name = "Delivery Worker"
npc_type = 10 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?"]) dialogue_lines = Array[String](["Where should I leave this?"])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_ihxib"] [sub_resource type="Resource" id="Resource_t47d3"]
script = ExtResource("2_fiebn") script = ExtResource("2_g7rkt")
id = "street_performer" id = "street_performer"
display_name = "Street Performer" display_name = "Street Performer"
npc_type = 1 npc_type = 1
temperament = 0.45 temperament = 0.45
base_compliance = 0.55 base_compliance = 0.55
return_probability = 0.0
reactions_by_level = { reactions_by_level = {
1: { 1: {
0: 55, 0: 55,
@@ -159,47 +53,14 @@ reactions_by_level = {
3: 30 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"] [sub_resource type="Resource" id="Resource_j2mp5"]
script = ExtResource("2_fiebn") script = ExtResource("2_g7rkt")
id = "influencer" id = "influencer"
display_name = "Influencer" display_name = "Influencer"
npc_type = 2 npc_type = 2
temperament = 0.5 temperament = 0.5
base_compliance = 0.5 base_compliance = 0.5
return_probability = 0.0
reactions_by_level = { reactions_by_level = {
1: { 1: {
0: 40, 0: 40,
@@ -221,47 +82,15 @@ reactions_by_level = {
8: 25 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."]) 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"] [sub_resource type="Resource" id="Resource_uscqg"]
script = ExtResource("2_fiebn") script = ExtResource("2_g7rkt")
id = "intoxicated" id = "intoxicated"
display_name = "Intoxicated Pedestrian" display_name = "Intoxicated Pedestrian"
npc_type = 4 npc_type = 4
temperament = 0.6 temperament = 0.6
base_compliance = 0.4 base_compliance = 0.4
return_probability = 0.0
reactions_by_level = { reactions_by_level = {
1: { 1: {
0: 30, 0: 30,
@@ -275,41 +104,10 @@ reactions_by_level = {
4: 10 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 reaction_delay = 1.8
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_jp52w"] [sub_resource type="Resource" id="Resource_8aovb"]
script = ExtResource("2_fiebn") script = ExtResource("2_g7rkt")
id = "trespasser" id = "trespasser"
display_name = "Trespasser" display_name = "Trespasser"
npc_type = 5 npc_type = 5
@@ -344,41 +142,10 @@ reactions_by_level = {
7: 20 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."]) 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"] [sub_resource type="Resource" id="Resource_owl7s"]
script = ExtResource("2_fiebn") script = ExtResource("2_g7rkt")
id = "mental_crisis" id = "mental_crisis"
display_name = "Person in Crisis" display_name = "Person in Crisis"
npc_type = 7 npc_type = 7
@@ -414,47 +181,15 @@ reactions_by_level = {
7: 15 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 reaction_delay = 2.4
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_6bscs"] [sub_resource type="Resource" id="Resource_rnay4"]
script = ExtResource("2_fiebn") script = ExtResource("2_g7rkt")
id = "violent_threat" id = "violent_threat"
display_name = "Violent Threat" display_name = "Violent Threat"
npc_type = 6 npc_type = 6
temperament = 0.95 temperament = 0.95
base_compliance = 0.05 base_compliance = 0.05
return_probability = 0.0
reactions_by_level = { reactions_by_level = {
1: { 1: {
2: 25, 2: 25,
@@ -482,41 +217,9 @@ reactions_by_level = {
7: 30 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"] [sub_resource type="Resource" id="Resource_lhp4m"]
script = ExtResource("2_fiebn") script = ExtResource("2_g7rkt")
id = "protester" id = "protester"
display_name = "Protester" display_name = "Protester"
npc_type = 12 npc_type = 12
@@ -550,66 +253,28 @@ reactions_by_level = {
7: 20 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."]) 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"] [sub_resource type="Resource" id="Resource_s8tjd"]
script = ExtResource("2_fiebn") script = ExtResource("2_g7rkt")
id = "authorized_staff" id = "authorized_staff"
display_name = "Staff Member" display_name = "Staff Member"
npc_type = 8 npc_type = 8
temperament = 0.15 temperament = 0.15
base_compliance = 1.0 base_compliance = 1.0
return_probability = 0.0
reactions_by_level = {}
default_reactions = { default_reactions = {
1: { 1: {
0: 100 0: 100
} }
} }
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_nf2oq"] [sub_resource type="Resource" id="Resource_ii8kq"]
script = ExtResource("2_fiebn") script = ExtResource("2_g7rkt")
id = "authorized_guest" id = "authorized_guest"
display_name = "Authorized Guest" display_name = "Authorized Guest"
npc_type = 9 npc_type = 9
temperament = 0.2 temperament = 0.2
base_compliance = 0.95 base_compliance = 0.95
return_probability = 0.0
reactions_by_level = {}
default_reactions = { default_reactions = {
1: { 1: {
0: 92, 0: 92,
@@ -620,16 +285,13 @@ default_reactions = {
2: 12 2: 12
} }
} }
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[resource] [resource]
script = ExtResource("3_2dvuq") script = ExtResource("3_jd45y")
id = "tv_studio" id = "tv_studio"
display_name = "Hollywood Blvd Late Night TV Studio" display_name = "Hollywood Blvd Late Night TV Studio"
description = "Crowd, celebrities, and staff around a late-night 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"]) equipment = Array[String](["wristband_policy", "guest_list", "radio", "flashlight"])
locations = { locations = {
"entrance": Vector3(0, 0, 1), "entrance": Vector3(0, 0, 1),
@@ -639,7 +301,7 @@ locations = {
"stage_door": Vector3(6, 0, -2), "stage_door": Vector3(6, 0, -2),
"steps_end": Vector3(0, 0, 3) "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]([{ event_pool = Array[Dictionary]([{
"base_weight": 1.0, "base_weight": 1.0,
"id": "audience_ticket", "id": "audience_ticket",
@@ -790,6 +452,15 @@ event_pool = Array[Dictionary]([{
"phase": 2, "phase": 2,
"probability": 0.45, "probability": 0.45,
"time_range_s": Vector2(700, 860) "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 = { shift_phases = {
0: { 0: {
@@ -805,6 +476,3 @@ shift_phases = {
"start_s": 700.0 "start_s": 700.0
} }
} }
shift_duration_s = 900.0
policy_summary = ""
door_staff_lines = {}
+3 -1
View File
@@ -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. - **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible.
- **PATCH** — bug fixes and small polish that do not change behavior. - **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<version>` (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<version>` (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 ## 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. - **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. - **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. - **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. - **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 ## 9. Architecture Decisions
+3 -2
View File
@@ -5,7 +5,7 @@ Implemented so far:
- Godot 4.3 project scaffold, input map, procedural world builder - 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` - 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 - 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 - 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 - 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`) - 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. - 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. - 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. - 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. - 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). - 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). - 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. - 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. - 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).
+1 -1
View File
@@ -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 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 ## Venues
+3
View File
@@ -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 # 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 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. The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
+5 -1
View File
@@ -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. 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. 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. 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.
+1 -1
View File
@@ -12,7 +12,7 @@ config_version=5
config/name="Gig Line" config/name="Gig Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." 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" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility") config/features=PackedStringArray("4.3", "GL Compatibility")
+31 -2
View File
@@ -4,6 +4,8 @@ extends Node
const SHIFT_RESULTS_FILE := "shift_results.json" const SHIFT_RESULTS_FILE := "shift_results.json"
const SETTINGS_FILE := "settings.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: func _load_json(file_name: String) -> Variant:
var path := "user://" + file_name 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: func _save_json(file_name: String, data: Variant) -> Error:
var path := "user://" + file_name var path := "user://" + file_name
var temp := path + ".tmp"
var text := JSON.stringify(data, "\t") var text := JSON.stringify(data, "\t")
var f := FileAccess.open(path, FileAccess.WRITE) var f := FileAccess.open(temp, FileAccess.WRITE)
if f == null: if f == null:
return FileAccess.get_open_error() return FileAccess.get_open_error()
f.store_string(text) f.store_string(text)
f.close() 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 []. ## Load previous shift results. Returns Array of Dictionaries or [].
func load_shift_results() -> Array: func load_shift_results() -> Array:
@@ -38,6 +44,29 @@ func save_shift_result(result: Dictionary) -> Error:
results.append(result) results.append(result)
return _save_json(SHIFT_RESULTS_FILE, results) 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 ## Defaults for player settings. Kept in code so a missing or partial
## settings.json still produces a usable config. ## settings.json still produces a usable config.
const DEFAULT_SETTINGS := { const DEFAULT_SETTINGS := {
+41 -1
View File
@@ -21,6 +21,11 @@ static func shared_profiles() -> Array[NpcProfile]:
tourist.npc_type = Rules.NpcType.TOURIST tourist.npc_type = Rules.NpcType.TOURIST
tourist.temperament = 0.1 tourist.temperament = 0.1
tourist.base_compliance = 0.9 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 = [ tourist.dialogue_lines = [
"Oh sure, sorry, just taking a picture.", "Oh sure, sorry, just taking a picture.",
"Is there a better spot to stand?", "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.npc_type = Rules.NpcType.CONFUSED_VISITOR
confused.temperament = 0.2 confused.temperament = 0.2
confused.base_compliance = 0.85 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 = [ confused.dialogue_lines = [
"Wait, this isn't the entrance?", "Wait, this isn't the entrance?",
"Sorry about that, I'm a bit lost.", "Sorry about that, I'm a bit lost.",
@@ -389,6 +398,16 @@ static func hollywood_building() -> VenueScenario:
"base_weight": 1.0, "base_weight": 1.0,
"requires_emergency": true, "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 return scenario
@@ -451,7 +470,7 @@ static func joke_shop() -> VenueScenario:
scenario.npc_profiles = _filter_profiles([ scenario.npc_profiles = _filter_profiles([
"tourist", "confused_visitor", "authorized_guest", "authorized_staff", "tourist", "confused_visitor", "authorized_guest", "authorized_staff",
"influencer", "intoxicated", "trespasser", "violent_threat", "street_performer", "influencer", "intoxicated", "trespasser", "violent_threat",
]) ])
scenario.event_pool = [ scenario.event_pool = [
@@ -533,6 +552,17 @@ static func joke_shop() -> VenueScenario:
"probability": 0.5, "probability": 0.5,
"base_weight": 1.0, "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 return scenario
@@ -648,6 +678,16 @@ static func tv_studio() -> VenueScenario:
"probability": 0.45, "probability": 0.45,
"base_weight": 1.0, "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 return scenario
+64
View File
@@ -0,0 +1,64 @@
class_name Campaign
extends RefCounted
## Small campaign/session progression model. Venue rules stay in VenueScenario;
## this only owns order, completed shifts, difficulty and the next venue.
const ORDER := ["hollywood_building", "joke_shop", "tv_studio"]
var index := 0
var shift_number := 1
var completed: Array[String] = []
var difficulty := "normal"
var complete := false
var last_grade := ""
func setup(data: Dictionary = {}) -> void:
index = clampi(int(data.get("index", 0)), 0, ORDER.size())
shift_number = maxi(1, int(data.get("shift_number", 1)))
difficulty = str(data.get("difficulty", "normal"))
completed.clear()
for item in data.get("completed", []):
var value := str(item)
if value in ORDER and value not in completed:
completed.append(value)
complete = bool(data.get("complete", index >= ORDER.size()))
last_grade = str(data.get("last_grade", ""))
func next_venue() -> String:
if complete or index >= ORDER.size():
return ""
return ORDER[index]
func summary() -> Dictionary:
return {
"index": index,
"next_venue": next_venue(),
"shift_number": shift_number,
"difficulty": difficulty,
"complete": complete,
"completed": completed.duplicate(),
"last_grade": last_grade,
}
## Record one finished shift. Idempotent per venue for a single campaign run.
func record_shift(scenario_id: String, review: Dictionary = {}) -> bool:
if complete:
return false
if scenario_id != next_venue():
return false
if scenario_id not in completed:
completed.append(scenario_id)
var rep: Dictionary = review.get("reputation", {})
last_grade = str(rep.get("grade", last_grade))
index += 1
shift_number = index + 1
if index >= ORDER.size():
complete = true
return true
func set_difficulty(value: String) -> void:
if value in ["easy", "normal", "hard"]:
difficulty = value
func reset() -> void:
setup({})
+8
View File
@@ -65,3 +65,11 @@ func summary() -> Dictionary:
"attention": attention, "attention": attention,
"panic": crowd_panic, "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))
+46 -4
View File
@@ -11,6 +11,9 @@ signal event_ready(event_data: Dictionary)
var rng := RandomNumberGenerator.new() var rng := RandomNumberGenerator.new()
var _scenario: VenueScenario = null var _scenario: VenueScenario = null
var _weighted_pool: Array[Dictionary] = [] 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: func _init() -> void:
pass pass
@@ -38,17 +41,47 @@ func pull_due(elapsed_s: float, phase: int) -> Array[Dictionary]:
continue continue
var prob: float = event.get("probability", 0.0) var prob: float = event.get("probability", 0.0)
var base: float = event.get("base_weight", 1.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 spawn_rate: float = event.get("spawn_rate", 0.0)
var due_event := false
if spawn_rate > 0.0: if spawn_rate > 0.0:
# Rate-based spawning: emit at most one per second window. # 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: if rng.randf() < prob:
due.append(event.duplicate()) due_event = true
else: else:
if rng.randf() < prob * base: if rng.randf() < clampf(prob * base * spawn_rate_multiplier, 0.0, 1.0):
due_event = true
if due_event:
due.append(event.duplicate()) 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 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]: func fallback_routine_traffic(phase: int) -> Array[Dictionary]:
## Cheap routine traffic so the street never feels empty. ## Cheap routine traffic so the street never feels empty.
var out: Array[Dictionary] = [] var out: Array[Dictionary] = []
@@ -56,10 +89,19 @@ func fallback_routine_traffic(phase: int) -> Array[Dictionary]:
if pool.is_empty(): if pool.is_empty():
return out return out
for i in 3: 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()) out.append(pool[rng.randi_range(0, pool.size() - 1)].duplicate())
return out 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: func _routine_pool(phase: int) -> Array:
var out: Array = [] var out: Array = []
for event in _weighted_pool: for event in _weighted_pool:
+17
View File
@@ -96,3 +96,20 @@ func to_dict() -> Dictionary:
"incidents": incidents.duplicate(true), "incidents": incidents.duplicate(true),
"open_count": _open_incidents.size(), "open_count": _open_incidents.size(),
} }
func snapshot() -> Dictionary:
return {
"incidents": incidents.duplicate(true),
"open_incidents": _open_incidents.duplicate(true),
}
func restore(data: Dictionary) -> void:
incidents.clear()
_open_incidents.clear()
for incident in data.get("incidents", []):
if incident is Dictionary:
incidents.append((incident as Dictionary).duplicate(true))
for npc_id in (data.get("open_incidents", {}) as Dictionary).keys():
var incident = (data["open_incidents"] as Dictionary)[npc_id]
if incident is Dictionary:
_open_incidents[str(npc_id)] = (incident as Dictionary).duplicate(true)
+10 -4
View File
@@ -71,10 +71,13 @@ func _build_fsm(initial_state: String) -> void:
fsm.add_transition("THREATENING", "FORCING_ENTRY") fsm.add_transition("THREATENING", "FORCING_ENTRY")
fsm.start(initial_state) fsm.start(initial_state)
func roll_reaction(level: int) -> int: func roll_reaction(level: int, compliance_bonus: float = 0.0) -> int:
## Weighted pick from profile's reaction table for `level`. ## 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) highest_escalation_received = maxi(highest_escalation_received, level)
var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate() 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 roll_value := rng.randf()
var total := 0.0 var total := 0.0
for key in weights.keys(): for key in weights.keys():
@@ -88,9 +91,12 @@ func roll_reaction(level: int) -> int:
return key return key
return Rules.Reaction.LEAVE 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. ## 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 = performed_reaction
reaction_set = true reaction_set = true
_update_state_for_reaction(performed_reaction) _update_state_for_reaction(performed_reaction)
+17
View File
@@ -61,10 +61,27 @@ const Rules := preload("res://src/core/rules.gd")
## Dialogue lines this NPC uses. Falls back to generic lines when empty. ## Dialogue lines this NPC uses. Falls back to generic lines when empty.
@export var dialogue_lines: Array[String] = [] @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). ## Items the NPC is carrying, used by the handheld wand (The Joke Shop).
## Values are item names: "keys", "belt", "phone", "weapon", "knife", etc. ## Values are item names: "keys", "belt", "phone", "weapon", "knife", etc.
@export var carried_items: Array[String] = [] @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: func pickup_reactions_per_level(level: int) -> Dictionary:
if reactions_by_level.has(level): if reactions_by_level.has(level):
return reactions_by_level[level] return reactions_by_level[level]
+17
View File
@@ -123,6 +123,23 @@ func tick(delta: float) -> void:
if event_bus: if event_bus:
event_bus.responder_arrived.emit(call) 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 ## Determine the responder's resolution outcome for a call. The responder
## commands, restrains, evacuates, or arrests based on the reported danger ## commands, restrains, evacuates, or arrests based on the reported danger
## level and whether a weapon was seen (PLAN.md: Level 5 Emergency Response). ## level and whether a weapon was seen (PLAN.md: Level 5 Emergency Response).
+10
View File
@@ -52,3 +52,13 @@ func summary() -> Dictionary:
"grade": grade_for(rating), "grade": grade_for(rating),
"modifiers": _modifiers.duplicate(true), "modifiers": _modifiers.duplicate(true),
} }
func snapshot() -> Dictionary:
return {"rating": rating, "modifiers": _modifiers.duplicate(true)}
func restore(data: Dictionary) -> void:
rating = clampf(float(data.get("rating", 70.0)), 0.0, 100.0)
_modifiers.clear()
for modifier in data.get("modifiers", []):
if modifier is Dictionary:
_modifiers.append((modifier as Dictionary).duplicate(true))
+60
View File
@@ -0,0 +1,60 @@
class_name ScenarioValidator
extends RefCounted
## Validates authored venue data before a shift. Keeps content-matrix failures
## out of runtime: every event must reference a known profile and a valid phase,
## event ids must be unique, and the matrix must cover enough kinds/phases.
const Rules := preload("res://src/core/rules.gd")
static func validate(scenario: Resource) -> Array[String]:
var problems: Array[String] = []
if scenario == null:
return ["scenario is null"]
var profile_types := {}
for profile in scenario.npc_profiles:
if profile != null:
profile_types[int(profile.npc_type)] = true
var ids := {}
var routine := 0
var scripted := 0
var recovery := 0
var phases := {}
for event in scenario.event_pool:
if not (event is Dictionary):
problems.append("event is not a dictionary")
continue
var event_id := str(event.get("id", ""))
if event_id == "":
problems.append("event has no id")
elif ids.has(event_id):
problems.append("duplicate event id: %s" % event_id)
ids[event_id] = true
var phase := int(event.get("phase", -1))
if phase < Rules.Phase.EARLY or phase > Rules.Phase.LATE:
problems.append("%s has invalid phase %d" % [event_id, phase])
var npc_type := int(event.get("npc_type", -1))
if not profile_types.has(npc_type):
problems.append("%s references missing profile type %d" % [event_id, npc_type])
if str(event.get("intent", "")) == "":
problems.append("%s has no intent" % event_id)
var kind := str(event.get("kind", "routine"))
if kind == "routine":
routine += 1
elif kind == "scripted":
scripted += 1
elif kind == "recovery":
recovery += 1
elif kind != "routine":
problems.append("%s has unknown kind %s" % [event_id, kind])
phases[phase] = true
if scenario.event_pool.size() < 12:
problems.append("event matrix has %d events; need at least 12" % scenario.event_pool.size())
if routine < 6:
problems.append("event matrix needs at least 6 routine events")
if scripted < 2:
problems.append("event matrix needs at least 2 scripted events")
if recovery < 1:
problems.append("event matrix needs at least 1 recovery event")
if phases.size() < 2:
problems.append("event matrix must cover at least 2 shift phases")
return problems
+34
View File
@@ -155,5 +155,39 @@ func build_review() -> Dictionary:
"completed_at_ms": Time.get_ticks_msec(), "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: func spawn_context_dict(profile: NpcProfile, intent: String) -> Dictionary:
return {"profile": profile, "intent": intent} return {"profile": profile, "intent": intent}
+227 -5
View File
@@ -32,6 +32,8 @@ const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
const EventTimeline := preload("res://src/game/ui/event_timeline.gd") const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
const Tutorial := preload("res://src/game/tutorial.gd") const Tutorial := preload("res://src/game/tutorial.gd")
const TutorialOverlay := preload("res://src/game/ui/tutorial_overlay.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 var world_builder: WorldBuilder = null
## Dedicated container for the procedural world, so switching venues can rebuild ## 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 settings_panel: SettingsPanel = null
var event_timeline: EventTimeline = null var event_timeline: EventTimeline = null
var tutorial_overlay: TutorialOverlay = null var tutorial_overlay: TutorialOverlay = null
var help_overlay: HelpOverlay = null
var tutorial: Tutorial = null var tutorial: Tutorial = null
var tutorial_active := false var tutorial_active := false
var campaign: Campaign = null
var _resuming := false
var _resume_pending: Dictionary = {}
var _tutorial_start_pos := Vector3.ZERO var _tutorial_start_pos := Vector3.ZERO
var _tutorial_start_yaw := 0.0 var _tutorial_start_yaw := 0.0
var _tutorial_start_pitch := 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 scenario_duration_s: float = 900.0
var _wand_cooldown := 0.0 var _wand_cooldown := 0.0
const WAND_COOLDOWN_S := 0.8 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 scene_root: Node3D = null
var npcs: Array = [] var npcs: Array = []
var npc_counter := 0 var npc_counter := 0
@@ -168,12 +180,15 @@ func _ready() -> void:
scenario_duration_s = selected_scenario.shift_duration_s scenario_duration_s = selected_scenario.shift_duration_s
coordinator = ShiftCoordinator.new() coordinator = ShiftCoordinator.new()
coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.scheduler.spawn_rate_multiplier = float(_difficulty()["spawn"])
coordinator.npc_spawn_requested.connect(_spawn_npc) coordinator.npc_spawn_requested.connect(_spawn_npc)
coordinator.shift_ended.connect(_on_shift_ended) coordinator.shift_ended.connect(_on_shift_ended)
_connect_radio() _connect_radio()
_connect_crowd() _connect_crowd()
_build_ui() _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 # 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). # that it does (otherwise the saved font scale is ignored until the next apply).
_apply_settings(player_settings) _apply_settings(player_settings)
@@ -275,6 +290,8 @@ func _rebuild_world_for_venue(id: String) -> void:
func restart_shift() -> void: func restart_shift() -> void:
if shift_review_panel: if shift_review_panel:
shift_review_panel.hide() shift_review_panel.hide()
if Save:
Save.clear_shift_state()
_reset_shift_state() _reset_shift_state()
_start_shift() _start_shift()
@@ -298,6 +315,7 @@ func _reset_shift_state() -> void:
hud.set_body_camera(false) hud.set_body_camera(false)
scenario_duration_s = selected_scenario.shift_duration_s scenario_duration_s = selected_scenario.shift_duration_s
coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.scheduler.spawn_rate_multiplier = float(_difficulty()["spawn"])
_connect_radio() _connect_radio()
_connect_crowd() _connect_crowd()
_last_phase = -1 _last_phase = -1
@@ -328,11 +346,13 @@ func _show_start_menu() -> void:
guard.input_locked = true guard.input_locked = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
if start_menu: if start_menu:
start_menu.open() start_menu.open(campaign.summary() if campaign else {})
func _on_venue_selected(scenario_id: String) -> void: func _on_venue_selected(scenario_id: String) -> void:
if start_menu and start_menu.visible: if start_menu and start_menu.visible:
start_menu.close() start_menu.close()
if not _resuming and Save:
Save.clear_shift_state()
active_scenario_id = scenario_id active_scenario_id = scenario_id
_venue_index = maxi(0, VENUE_ORDER.find(scenario_id)) _venue_index = maxi(0, VENUE_ORDER.find(scenario_id))
selected_scenario = _build_scenario(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) hud.set_wand_icon(false)
_reset_shift_state() _reset_shift_state()
_start_shift() _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. ## 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: func _apply_settings(settings: Dictionary) -> void:
player_settings = settings player_settings = settings
if guard: if guard:
@@ -355,13 +384,30 @@ func _apply_settings(settings: Dictionary) -> void:
func _on_settings_applied(settings: Dictionary) -> void: func _on_settings_applied(settings: Dictionary) -> void:
_apply_settings(settings) _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: if start_menu and start_menu.visible:
start_menu.open() start_menu.open(campaign.summary() if campaign else {})
elif _shift_running: elif _shift_running:
_open_pause() _open_pause()
## Clear the tutorial completion/skip flags and restart the Hollywood shift so ## Clear the tutorial completion/skip flags and restart the Hollywood shift so
## the next player can replay the first-shift lesson from Settings. ## 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: func _on_replay_tutorial() -> void:
player_settings["tutorial_completed"] = false player_settings["tutorial_completed"] = false
player_settings["tutorial_skipped"] = false player_settings["tutorial_skipped"] = false
@@ -373,6 +419,10 @@ func _on_replay_tutorial() -> void:
pause_overlay.close() pause_overlay.close()
_on_venue_selected("hollywood_building") _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: func _open_settings() -> void:
if start_menu: if start_menu:
start_menu.close() start_menu.close()
@@ -384,6 +434,7 @@ func _open_settings() -> void:
func _open_pause() -> void: func _open_pause() -> void:
if pause_overlay == null or not _shift_running: if pause_overlay == null or not _shift_running:
return return
_save_shift_checkpoint()
if _close_top_modal(): if _close_top_modal():
pass pass
pause_overlay.open() pause_overlay.open()
@@ -427,6 +478,7 @@ func _build_ui() -> void:
add_child(shift_review_panel) add_child(shift_review_panel)
shift_review_panel.hide() shift_review_panel.hide()
shift_review_panel.restart_requested.connect(_on_review_restart) 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.timeline_requested.connect(_open_timeline)
shift_review_panel.visible = false shift_review_panel.visible = false
@@ -443,6 +495,7 @@ func _build_ui() -> void:
settings_panel.hide() settings_panel.hide()
settings_panel.applied.connect(_on_settings_applied) settings_panel.applied.connect(_on_settings_applied)
settings_panel.replay_tutorial_requested.connect(_on_replay_tutorial) settings_panel.replay_tutorial_requested.connect(_on_replay_tutorial)
settings_panel.reset_campaign_requested.connect(_on_reset_campaign)
event_timeline = EventTimeline.new() event_timeline = EventTimeline.new()
add_child(event_timeline) add_child(event_timeline)
@@ -452,10 +505,16 @@ func _build_ui() -> void:
tutorial_overlay = TutorialOverlay.new() tutorial_overlay = TutorialOverlay.new()
add_child(tutorial_overlay) add_child(tutorial_overlay)
help_overlay = HelpOverlay.new()
add_child(help_overlay)
help_overlay.closed.connect(_refresh_freeze)
start_menu = StartMenu.new() start_menu = StartMenu.new()
add_child(start_menu) add_child(start_menu)
start_menu.hide() start_menu.hide()
start_menu.venue_selected.connect(_on_venue_selected) 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.settings_requested.connect(_open_settings)
start_menu.quit_requested.connect(func(): get_tree().quit()) start_menu.quit_requested.connect(func(): get_tree().quit())
@@ -476,10 +535,147 @@ func _on_timeline_closed() -> void:
_timeline_from_review = false _timeline_from_review = false
_refresh_freeze() _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. ## "Run another shift" on the review screen returns to the venue menu.
func _on_review_restart() -> void: func _on_review_restart() -> void:
_show_start_menu() _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: func _add_crosshair() -> void:
var layer := CanvasLayer.new() var layer := CanvasLayer.new()
layer.layer = 9 layer.layer = 9
@@ -558,7 +754,7 @@ func _apply_phase_lighting() -> void:
## captures and smokes pass --script and are explicitly exempt. A tutorial smoke ## captures and smokes pass --script and are explicitly exempt. A tutorial smoke
## can opt back in with a user `--tutorial` argument. ## can opt back in with a user `--tutorial` argument.
func _maybe_start_tutorial() -> void: 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 return
if bool(player_settings.get("tutorial_completed", false)) \ if bool(player_settings.get("tutorial_completed", false)) \
or bool(player_settings.get("tutorial_skipped", 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). # Persist the finished shift locally (Save autoload, user:// JSON).
if Save: if Save:
Save.save_shift_result(review) 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") _tutorial_advance("review_opened")
_refresh_freeze() _refresh_freeze()
@@ -1323,7 +1526,8 @@ func _apply_interaction(level: int, action_name: String) -> void:
if controller == null or coordinator == null: if controller == null or coordinator == null:
return return
_ensure_incident(controller) _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 var npc_id: String = controller.npc_id
coordinator.recorder.add_event(npc_id, { coordinator.recorder.add_event(npc_id, {
"type": "player_action", "type": "player_action",
@@ -1348,6 +1552,15 @@ func _apply_interaction(level: int, action_name: String) -> void:
hud.set_escalation(level) hud.set_escalation(level)
if audio: if audio:
audio.play_escalation(level) 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) _react_to_outcome(controller, outcome)
## Level 1 eye contact: a distinct, quieter cue than raise-hand (PLAN.md). It ## Level 1 eye contact: a distinct, quieter cue than raise-hand (PLAN.md). It
@@ -1368,6 +1581,9 @@ func _recognize_target() -> void:
return return
_ensure_incident(controller) _ensure_incident(controller)
controller.player_recognized = true 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, { coordinator.recorder.add_access_evidence(controller.npc_id, {
"kind": "recognition", "kind": "recognition",
"source": "guard_decision", "source": "guard_decision",
@@ -1486,6 +1702,7 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
return return
_tutorial_advance("radio_submitted") _tutorial_advance("radio_submitted")
var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth) var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth)
hits = int(ceil(float(hits) * float(_difficulty()["report"])))
if hits > 0: if hits > 0:
coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report") coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report")
hud.log(Strings.fmt("radio.inaccurate", [4 * hits])) hud.log(Strings.fmt("radio.inaccurate", [4 * hits]))
@@ -1757,7 +1974,12 @@ func _physics_process(delta: float) -> void:
if coordinator.current_phase != _last_phase: if coordinator.current_phase != _last_phase:
_last_phase = coordinator.current_phase _last_phase = coordinator.current_phase
_apply_phase_lighting() _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) 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()
## Perception pass: NPCs notice the guard's proximity (PLAN.md: Level 0 ## Perception pass: NPCs notice the guard's proximity (PLAN.md: Level 0
+109
View File
@@ -0,0 +1,109 @@
class_name HelpOverlay
extends CanvasLayer
## New-player help: controls, interaction flow and the current venue policy.
const Strings := preload("res://src/core/strings.gd")
const InputBindings := preload("res://src/core/input_bindings.gd")
signal closed()
var _root: Control = null
var _search: LineEdit = null
var _body: Label = null
var _policy: String = ""
var _lines: Array[String] = []
const CONTROL_LINES := [
"Movement: W/A/S/D · mouse look · Shift precision walk",
"Focus: crosshair cone · Tab / mouse wheel cycles nearby people",
"Action wheel: Space or left click · A/D or arrows step · Space confirms",
"Inspect access: E · Wand: C · Polite direction: Q",
"Warning: 4 · Blocking: 5 · Security radio: 6",
"Emergency radio: R, hold the on-screen button to confirm",
"Body camera: B · Flashlight: G · Door lock: L",
"Pause: Esc · Debug overlay: F",
]
func _init() -> void:
layer = 23
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "HelpRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var dim := ColorRect.new()
dim.color = Color(0.02, 0.03, 0.05, 0.86)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(center)
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(620, 0)
column.add_theme_constant_override("separation", 10)
center.add_child(column)
var title := Label.new()
title.text = "Help"
title.add_theme_font_size_override("font_size", 30)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
_search = LineEdit.new()
_search.placeholder_text = "Filter controls…"
_search.text_changed.connect(_refresh)
column.add_child(_search)
_body = Label.new()
_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_body.custom_minimum_size = Vector2(600, 330)
_body.vertical_alignment = VERTICAL_ALIGNMENT_TOP
column.add_child(_body)
var close := Button.new()
close.text = "Close"
close.pressed.connect(func(): close_panel())
close.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
column.add_child(close)
func open(policy: String = "") -> void:
_policy = policy
visible = true
_root.visible = true
_search.text = ""
_refresh("")
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func close_panel() -> void:
visible = false
_root.visible = false
closed.emit()
func _refresh(filter: String) -> void:
_lines = []
var needle := filter.strip_edges().to_lower()
var dynamic := []
for action in InputBindings.REMAPPABLE:
var label := InputBindings.label_for(InputBindings.current_descriptor(action))
dynamic.append("%s: %s" % [str(action).replace("_", " ").capitalize(), label])
for line in (CONTROL_LINES + dynamic):
if needle == "" or line.to_lower().contains(needle):
_lines.append(line)
var text := "\n".join(_lines)
if _policy != "":
text += "\n\nVenue policy: " + _policy
_body.text = text
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo \
and event.keycode == KEY_ESCAPE:
close_panel()
get_viewport().set_input_as_handled()
+3 -1
View File
@@ -549,7 +549,7 @@ func toggle_debug() -> void:
_debug_enabled = not _debug_enabled _debug_enabled = not _debug_enabled
debug_label.visible = _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: if not _debug_enabled:
return return
var lines: Array[String] = ["--- DEBUG ---", "shift %.1fs / %.1fs rating %.1f" % [ 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, "?"), Rules.NPC_TYPE_NAMES.get(profile.npc_type if profile else -1, "?"),
npc.name, c.intent, c.fsm.current_state, c.highest_escalation_received, 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) debug_label.text = "\n".join(lines)
func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -> void: func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -> void:
+34
View File
@@ -11,6 +11,7 @@ const UiArt := preload("res://src/game/ui/art.gd")
signal applied(settings: Dictionary) signal applied(settings: Dictionary)
signal replay_tutorial_requested() signal replay_tutorial_requested()
signal reset_campaign_requested()
signal closed() signal closed()
var _root: Control = null var _root: Control = null
@@ -20,6 +21,7 @@ var _sensitivity_value: Label = null
var _font_scale_value: Label = null var _font_scale_value: Label = null
var _invert_y: CheckButton = null var _invert_y: CheckButton = null
var _head_bob: CheckButton = null var _head_bob: CheckButton = null
var _difficulty: OptionButton = null
var _bind_buttons := {} var _bind_buttons := {}
var _listening_action := "" var _listening_action := ""
var settings: Dictionary = {} var settings: Dictionary = {}
@@ -87,6 +89,18 @@ func _build() -> void:
_head_bob.toggled.connect(_on_head_bob_toggled) _head_bob.toggled.connect(_on_head_bob_toggled)
column.add_child(_head_bob) 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() var replay_row := HBoxContainer.new()
replay_row.alignment = BoxContainer.ALIGNMENT_CENTER replay_row.alignment = BoxContainer.ALIGNMENT_CENTER
column.add_child(replay_row) column.add_child(replay_row)
@@ -95,6 +109,11 @@ func _build() -> void:
replay.text = "Replay tutorial" replay.text = "Replay tutorial"
replay.pressed.connect(func(): replay_tutorial_requested.emit()) replay.pressed.connect(func(): replay_tutorial_requested.emit())
replay_row.add_child(replay) 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() var controls_title := Label.new()
controls_title.text = "Controls (click a binding, then press a key or pad button)" 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))) _invert_y.set_pressed_no_signal(bool(settings.get("invert_y", false)))
if _head_bob: if _head_bob:
_head_bob.set_pressed_no_signal(bool(settings.get("head_bob", true))) _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 = "" _listening_action = ""
_refresh_bind_buttons() _refresh_bind_buttons()
_sync_labels() _sync_labels()
@@ -198,6 +220,18 @@ func _on_invert_y_toggled(pressed: bool) -> void:
func _on_head_bob_toggled(pressed: bool) -> void: func _on_head_bob_toggled(pressed: bool) -> void:
settings["head_bob"] = pressed 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: func _refresh_bind_buttons() -> void:
var remaps: Dictionary = settings.get("input_bindings", {}) var remaps: Dictionary = settings.get("input_bindings", {})
for action in _bind_buttons.keys(): for action in _bind_buttons.keys():
+31 -1
View File
@@ -6,12 +6,15 @@ const Strings := preload("res://src/core/strings.gd")
const UiArt := preload("res://src/game/ui/art.gd") const UiArt := preload("res://src/game/ui/art.gd")
signal restart_requested() signal restart_requested()
signal continue_requested()
signal timeline_requested(review: Dictionary) signal timeline_requested(review: Dictionary)
## Kept so the timeline button can hand the full review back to the game manager. ## Kept so the timeline button can hand the full review back to the game manager.
var _review: Dictionary = {} var _review: Dictionary = {}
var report_label: Label var report_label: Label
var _campaign_label: Label = null
var _continue_button: Button = null
func _init() -> void: func _init() -> void:
name = "ShiftReview" name = "ShiftReview"
@@ -46,6 +49,17 @@ func _init() -> void:
report_label.custom_minimum_size = Vector2(720, 0) report_label.custom_minimum_size = Vector2(720, 0)
center.add_child(report_label) 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() var timeline := Button.new()
timeline.name = "TimelineButton" timeline.name = "TimelineButton"
timeline.text = Strings.lookup("review.timeline_button") timeline.text = Strings.lookup("review.timeline_button")
@@ -64,6 +78,7 @@ func _init() -> void:
quit_btn.pressed.connect(func(): get_tree().quit()) quit_btn.pressed.connect(func(): get_tree().quit())
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
_style_review_button(timeline) _style_review_button(timeline)
_style_review_button(_continue_button)
_style_review_button(restart) _style_review_button(restart)
_style_review_button(quit_btn) _style_review_button(quit_btn)
center.add_child(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("focus", hover)
button.add_theme_stylebox_override("pressed", 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 _review = review
var reputation: Dictionary = review.get("reputation", {}) var reputation: Dictionary = review.get("reputation", {})
var incidents: Dictionary = review.get("incidents", {}) var incidents: Dictionary = review.get("incidents", {})
@@ -115,6 +131,7 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
var lines: Array[String] = [ var lines: Array[String] = [
"Venue: %s" % (scenario.display_name if scenario else review.get("scenario_id", "")), "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)], "Grade: %s Score: %.0f/100" % [reputation.get("grade", "?"), reputation.get("rating", 0.0)],
history_line, history_line,
"Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful], "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: if shown >= 12:
break break
report_label.text = "\n".join(lines) 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 visible = true
+49 -1
View File
@@ -5,6 +5,8 @@ extends CanvasLayer
## (the `V` bind stays as a debug shortcut). ## (the `V` bind stays as a debug shortcut).
signal venue_selected(scenario_id: String) signal venue_selected(scenario_id: String)
signal continue_requested()
signal help_requested()
signal settings_requested() signal settings_requested()
signal quit_requested() signal quit_requested()
@@ -19,6 +21,9 @@ const VENUES := [
var _index := 0 var _index := 0
var _buttons: Array[Button] = [] var _buttons: Array[Button] = []
var _root: Control = null var _root: Control = null
var _status: Label = null
var _continue_button: Button = null
var _campaign: Dictionary = {}
func _ready() -> void: func _ready() -> void:
layer = 20 layer = 20
@@ -88,6 +93,20 @@ func _build() -> void:
subtitle.add_theme_color_override("font_color", Color(0.72, 0.75, 0.82)) subtitle.add_theme_color_override("font_color", Color(0.72, 0.75, 0.82))
column.add_child(subtitle) 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(): for i in VENUES.size():
var venue: Dictionary = VENUES[i] var venue: Dictionary = VENUES[i]
var button := Button.new() var button := Button.new()
@@ -109,6 +128,12 @@ func _build() -> void:
row.add_theme_constant_override("separation", 12) row.add_theme_constant_override("separation", 12)
column.add_child(row) 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() var settings_button := Button.new()
settings_button.name = "SettingsButton" settings_button.name = "SettingsButton"
settings_button.text = "Settings" settings_button.text = "Settings"
@@ -164,12 +189,35 @@ func _focus_index(i: int) -> void:
_index = clampi(i, 0, _buttons.size() - 1) _index = clampi(i, 0, _buttons.size() - 1)
_buttons[_index].grab_focus() _buttons[_index].grab_focus()
func open() -> void: func open(campaign: Dictionary = {}) -> void:
_campaign = campaign.duplicate(true)
visible = true visible = true
_root.visible = true _root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) 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) _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: func close() -> void:
visible = false visible = false
_root.visible = false _root.visible = false
+70
View File
@@ -0,0 +1,70 @@
extends SceneTree
## Campaign shell + checkpoint smoke. Use an isolated user-data dir:
## XDG_DATA_HOME=/tmp/gigline-campaign godot --path . --fixed-fps 60 \
## --script res://src/test/smoke_campaign.gd
var gm: Node = null
var frames := 0
var step := 0
var failures: Array[String] = []
func _initialize() -> void:
root.size = Vector2i(960, 540)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frames += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
match step:
0:
if frames < 5: return false
gm.call("_on_venue_selected", "hollywood_building")
gm.coordinator.elapsed_s = 123.0
gm.call("_save_shift_checkpoint")
gm.call("_show_start_menu")
_check(gm.start_menu.visible, "campaign menu opens")
_check(gm.start_menu.find_child("ContinueButton", true, false) != null,
"continue button exists")
step = 1
frames = 0
1:
if frames < 4: return false
gm.call("_on_campaign_continue")
step = 2
frames = 0
2:
if frames < 20: return false
_check(gm.active_scenario_id == "hollywood_building", "resume keeps the venue")
_check(gm.coordinator.elapsed_s >= 123.0, "resume keeps elapsed time")
gm.call("_on_shift_ended", gm.coordinator.build_review())
_check(gm.campaign.shift_number == 2, "campaign advances after a shift")
_check(gm.campaign.next_venue() == "joke_shop", "next venue is the Joke Shop")
gm.call("_on_review_continue")
step = 3
frames = 0
3:
if frames < 20: return false
_check(gm.active_scenario_id == "joke_shop", "review continue starts the next venue")
gm.call("_open_help")
_check(gm.help_overlay.visible, "help overlay opens")
gm.help_overlay.close_panel()
_finish()
return false
func _check(condition: bool, message: String) -> void:
if condition: print("ok: " + message)
else: _fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
if failures.is_empty():
print("SMOKE OK: campaign, checkpoint resume, review continue, help")
quit(0)
else:
for message in failures: printerr("SMOKE FAIL: " + message)
quit(1)
+32
View File
@@ -0,0 +1,32 @@
extends RefCounted
## Campaign progression and serialization tests.
const Campaign := preload("res://src/core/campaign.gd")
func test_campaign_advances_in_order_and_completes() -> bool:
var campaign := Campaign.new()
campaign.setup()
var a := TestUtils.check_eq(campaign.next_venue(), "hollywood_building", "starts at Hollywood")
var b := TestUtils.check(campaign.record_shift("hollywood_building", {"reputation": {"grade": "B"}}),
"records the first shift")
var c := TestUtils.check_eq(campaign.next_venue(), "joke_shop", "advances to the Joke Shop")
campaign.record_shift("joke_shop", {"reputation": {"grade": "A"}})
campaign.record_shift("tv_studio", {"reputation": {"grade": "A"}})
var d := TestUtils.check(campaign.complete and campaign.next_venue() == "", "completes after three venues")
var e := TestUtils.check_eq(campaign.last_grade, "A", "stores the last grade")
return a and b and c and d and e
func test_campaign_round_trips_and_resets() -> bool:
var campaign := Campaign.new()
campaign.setup({"index": 1, "shift_number": 2, "difficulty": "hard",
"completed": ["hollywood_building"], "last_grade": "C"})
var data := campaign.summary()
var restored := Campaign.new()
restored.setup(data)
var a := TestUtils.check_eq(restored.next_venue(), "joke_shop", "restores the next venue")
var b := TestUtils.check_eq(restored.difficulty, "hard", "restores difficulty")
var c := TestUtils.check_eq(restored.completed, ["hollywood_building"], "restores completion")
restored.reset()
var d := TestUtils.check(not restored.complete and restored.next_venue() == "hollywood_building",
"reset returns to the first venue")
return a and b and c and d
+27
View File
@@ -0,0 +1,27 @@
extends RefCounted
## Context-line selection is deterministic and falls back safely.
const NpcProfile := preload("res://src/core/npc_profile.gd")
const NpcController := preload("res://src/core/npc_controller.gd")
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
func test_context_lines_are_deterministic_and_fallback() -> bool:
var profile := ScenarioFactory.hollywood_building().profile_for(0)
var rng_a := RandomNumberGenerator.new(); rng_a.seed = 7
var rng_b := RandomNumberGenerator.new(); rng_b.seed = 7
var a := profile.line_for("refusal", rng_a)
var b := profile.line_for("refusal", rng_b)
var c := profile.line_for("reason", rng_a)
var d := TestUtils.check(a != "" and a == b, "context lines are deterministic")
var e := TestUtils.check(c != "", "reason falls back to dialogue_lines")
var f := TestUtils.check(profile.line_for("missing", rng_a) == "",
"unknown context with no fallback is empty")
return d and e and f
func test_request_reason_uses_reason_context() -> bool:
var profile := ScenarioFactory.hollywood_building().profile_for(0)
var controller := NpcController.new()
controller.setup("dialogue_test", profile, "enter", 3)
var reply := controller.request_reason()
return TestUtils.check(reply == controller.last_reason_reply and reply != "",
"request_reason stores the authored reply")
+59
View File
@@ -0,0 +1,59 @@
extends RefCounted
## Content-matrix validation for all authored venues.
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
const ScenarioValidator := preload("res://src/core/scenario_validator.gd")
const EventScheduler := preload("res://src/core/event_scheduler.gd")
func test_all_authored_venues_validate() -> bool:
var problems: Array[String] = []
for scenario in [ScenarioFactory.hollywood_building(), ScenarioFactory.joke_shop(),
ScenarioFactory.tv_studio()]:
for problem in ScenarioValidator.validate(scenario):
problems.append("%s: %s" % [scenario.id, problem])
return TestUtils.check(problems.is_empty(),
"venue content matrices validate (%s)" % str(problems))
func test_validator_rejects_duplicate_and_missing_profile() -> bool:
var scenario := ScenarioFactory.hollywood_building()
scenario.event_pool.append({
"id": "tourist_photo", "phase": 0, "kind": "routine",
"npc_type": 999, "intent": "photo",
})
var problems := ScenarioValidator.validate(scenario)
var a := TestUtils.check(problems.any(func(p): return str(p).contains("duplicate")),
"duplicate ids are rejected")
var b := TestUtils.check(problems.any(func(p): return str(p).contains("missing profile")),
"missing profiles are rejected")
return a and b
func test_scripted_events_do_not_repeat_immediately() -> bool:
var scenario := ScenarioFactory.hollywood_building()
scenario.event_pool = [{
"id": "matrix_probe", "phase": 0, "kind": "scripted",
"npc_type": 0, "intent": "enter", "time_range_s": Vector2(0, 900),
"probability": 1.0, "base_weight": 1.0,
}]
var scheduler := EventScheduler.new()
scheduler.setup(scenario, 7)
var first := scheduler.pull_due(10.0, 0)
var second := scheduler.pull_due(11.0, 0)
var a := TestUtils.check(first.size() == 1, "first scripted event is eligible")
var b := TestUtils.check(second.is_empty(), "same scripted event is suppressed next pull")
return a and b
func test_spawn_multiplier_scales_routine_traffic() -> bool:
var scenario := ScenarioFactory.hollywood_building()
var scheduler := EventScheduler.new()
scheduler.setup(scenario, 3)
scheduler.spawn_rate_multiplier = 0.0
var quiet := 0
for i in 20:
quiet += scheduler.fallback_routine_traffic(0).size()
var a := TestUtils.check(quiet == 0, "zero difficulty multiplier suppresses routine traffic")
scheduler.spawn_rate_multiplier = 1.0
var active := 0
for i in 20:
active += scheduler.fallback_routine_traffic(0).size()
var b := TestUtils.check(active > 0, "normal difficulty sends routine traffic")
return a and b
+44
View File
@@ -0,0 +1,44 @@
extends RefCounted
## Mid-shift checkpoint data survives a JSON round-trip and restores the
## coordinator's systems without replaying completed interactions.
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
const IncidentRecorder := preload("res://src/core/incident_recorder.gd")
const Rules := preload("res://src/core/rules.gd")
func test_coordinator_snapshot_round_trip() -> bool:
var scenario := ScenarioFactory.hollywood_building()
var first := ShiftCoordinator.new()
first.setup(scenario, 900.0, 42)
first.start()
first.elapsed_s = 123.0
first.recorder.start_incident("guest", "focus")
first.recorder.add_escalation("guest", Rules.Level.FORMAL_WARNING, "warning")
first.recorder.close_incident("guest", "peaceful")
first.reputation.apply("peaceful", 5.0, "test")
first.crowd.mood = 0.4
first.radio.battery = 70.0
var payload: Dictionary = JSON.parse_string(JSON.stringify(first.snapshot()))
var second := ShiftCoordinator.new()
second.setup(scenario, 900.0, 42)
second.restore(payload)
var a := TestUtils.check(is_equal_approx(second.elapsed_s, 123.0), "elapsed time restores")
var b := TestUtils.check_eq(second.recorder.to_dict()["open_count"], 0, "incident state restores")
var c := TestUtils.check_eq(second.recorder.to_dict()["incidents"].size(), 1,
"closed incidents restore")
var d := TestUtils.check(is_equal_approx(second.reputation.rating, 75.0), "reputation restores")
var e := TestUtils.check(is_equal_approx(second.crowd.mood, 0.4), "crowd mood restores")
var f := TestUtils.check(is_equal_approx(second.radio.battery, 70.0), "radio battery restores")
return a and b and c and d and e and f
func test_open_incident_snapshot_restores_evidence() -> bool:
var recorder := IncidentRecorder.new()
recorder.start_incident("guest", "focus")
recorder.add_access_evidence("guest", {"kind": "wristband", "status": "valid"})
var restored := IncidentRecorder.new()
restored.restore(JSON.parse_string(JSON.stringify(recorder.snapshot())))
var a := TestUtils.check(restored.has_open_incident("guest"), "open incident restores")
restored.add_event("guest", {"type": "player_action"})
var b := TestUtils.check(restored.open_incidents_count() == 1, "restored incident accepts events")
return a and b