diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..d5ec75a --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,96 @@ +name: Full build + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + workflow_dispatch: + +jobs: + checks: + name: Import + headless tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Godot 4.3 + run: | + curl -sL -o /tmp/godot.zip \ + https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip + unzip -qo /tmp/godot.zip -d /tmp/godot-bin + sudo install /tmp/godot-bin/Godot_v4.3-stable_linux.x86_64 /usr/local/bin/godot + + - name: Cache imported assets + uses: actions/cache@v4 + with: + path: .godot + key: godot-import-${{ hashFiles('project.godot', 'src/**/*.gd', 'content/**/*.json') }} + + - name: Import project (build class cache) + run: godot --headless --import + + - name: Run headless test suite + shell: bash + run: | + set +e + output=$(godot --headless --path . --script res://src/test/test_runner.gd 2>&1) + status=$? + echo "$output" + echo "exit=$status" + if [ "$status" -ne 0 ]; then + echo "::error::Tests failed" + exit 1 + fi + if echo "$output" | grep -q "SCRIPT ERROR"; then + echo "::error::Script errors detected" + exit 1 + fi + + build: + name: Full desktop build + optional release + # Tags and manual dispatches produce installable binaries. Branch pushes and + # pull requests only run the cheap checks job (export templates are ~1 GB). + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + needs: checks + steps: + - uses: actions/checkout@v4 + + - name: Install Godot 4.3 and export templates + run: | + curl -sL -o /tmp/godot.zip \ + https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip + unzip -qo /tmp/godot.zip -d /tmp/godot-bin + sudo install /tmp/godot-bin/Godot_v4.3-stable_linux.x86_64 /usr/local/bin/godot + curl -sL -o /tmp/templates.tpz \ + https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_export_templates.tpz + mkdir -p ~/.local/share/godot/export_templates/4.3.stable + cd /tmp && rm -rf tpl && mkdir tpl && unzip -qo templates.tpz -d tpl + cp tpl/templates/* "$HOME/.local/share/godot/export_templates/4.3.stable/" 2>/dev/null || true + ls "$HOME/.local/share/godot/export_templates/4.3.stable" | head -5 + + - name: Import project + run: godot --headless --import + + - name: Export Linux, Windows, macOS + run: | + chmod +x tools/*.sh + ./tools/build_releases.sh + + - name: Upload installers (workflow_dispatch) + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: step-line-build + path: build/* + if-no-files-found: error + + - name: Publish Gitea release (tags only) + if: startsWith(github.ref, 'refs/tags/') + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + VERSION: ${{ github.ref_name }} + run: | + chmod +x tools/publish_release.sh + ./tools/publish_release.sh "${VERSION#v}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 6307563..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - headless-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install Godot 4.3 - run: | - curl -sL -o /tmp/godot.zip \ - https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip - unzip -o /tmp/godot.zip -d /tmp/godot-bin - sudo install /tmp/godot-bin/Godot_v4.3-stable_linux.x86_64 /usr/local/bin/godot - - - name: Cache .godot import artifacts - uses: actions/cache@v4 - with: - path: .godot - key: godot-import-${{ hashFiles('project.godot', 'src/**/*.gd') }} - - - name: Import project (build class cache) - run: godot --headless --import || true - - - name: Run headless test suite - run: | - set +e - output=$(godot --headless --path . --script res://src/test/test_runner.gd 2>&1) - status=$? - echo "$output" - if [ $status -ne 0 ]; then - echo "::error::Headless tests failed" - exit 1 - fi - if echo "$output" | grep -q "SCRIPT ERROR"; then - echo "::error::Script errors detected" - exit 1 - fi - exit 0 diff --git a/DONE.md b/DONE.md index b0c0340..8811f03 100644 --- a/DONE.md +++ b/DONE.md @@ -30,8 +30,11 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every - [x] Implement a lightweight finite state machine for NPC states (`FSM`). - [x] Implement the observation phase (Level 0) without forced intervention. - [x] Implement simple deterrence (Level 1): raise hand, point, eye contact, and head shake. -- [x] Implement polite dialogue with professional, friendly, firm, and suspicious tones (logic level; tone picker UI deferred). -- [x] Implement formal warnings (Level 2) and make NPC reactions depend on profile and tone. +- [x] Implement polite dialogue prompts and reaction logic driven by escalation level and profile. +- [x] Implement formal warnings (Level 2) and make NPC reactions depend on profile and level. + - Note (2026-10-31): the selectable dialogue tone-of-voice system (Professional/Friendly/Firm/ + Suspicious) was intentionally removed from code and docs; reactions now depend solely on + escalation level and NPC profile. - [x] Implement blocking and physical positioning (Level 3) without default force. - [x] Wire typical NPC reactions: leave, argue, ignore, record, move closer, or return later. @@ -136,6 +139,22 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every - [x] Add `tools/build_releases.sh` for headless release exports. - [x] Add `docs/RELEASING.md` with template installation, build, and Gitea release steps. - [x] Add a prominent **Downloads** section at the top of `README.md` linking to the release page. +- [x] Add `tools/publish_release.sh` (Gitea API release + asset upload, requires `GITEA_TOKEN`). + +## CI/CD — Gitea Actions full build + +- [x] Add `.gitea/workflows/build.yml` with a fast checks job (import + headless tests on push/PR) + and a full build job (export templates, Linux/Windows/macOS exports, artifact upload) on tags + and `workflow_dispatch`. +- [x] Tag builds automatically publish the Gitea release via `tools/publish_release.sh` using + the `GITEA_TOKEN` secret. +- [x] Remove the old `.github/workflows/ci.yml` to avoid double runs on hosts scanning both paths. +- [x] Document the CI flow in `docs/RELEASING.md`. + +## Gameplay — Venue selection + +- [x] Add a `V` control that cycles Hollywood Blvd → The Joke Shop → TV Studio and restarts the + shift (`GameManager._cycle_venue`), with a `hud.venue_switched` string entry. ## Core Simulation — NPC Perception @@ -157,7 +176,8 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every ## Polish and Accessibility -- [x] Add dialogue tone picker (Professional / Friendly / Firm / Suspicious) before addressing an NPC. +- [x] Add aim-free targeting (Tab) and venue cycling (V). (The earlier dialogue tone picker was + superseded and removed with the tone-of-voice system.) - [x] Add interaction alternatives that do not require precise aiming (e.g. scan-and-select pinned list of nearby NPCs). - [x] Add visual and audio feedback per escalation level (colors, tones, shake). - [x] Add audio for crowd ambience, radio responses, and confirmation prompts. diff --git a/MEMORY.md b/MEMORY.md index 85e99da..2a9c53c 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -21,12 +21,27 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o - Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice. - Responder dispatch: `RadioSystem` tracks `pending_calls` and decrements `arrival_delay_s` in `tick`; emits `responder_arrived` when a call's delay elapses. `RadioSystem.responder_outcome(call)` maps danger/weapons → command/restraint/evacuation/arrest. `GameManager._on_responder_arrived` spawns a `RESPONDER` avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via `_update_radio_cooldown`. - Wand screening (Joke Shop): `WandScreener` (new `src/core/wand_screener.gd`) classifies carried items as prohibited (`weapon/knife/gun/blade`) vs false-alarm (`keys/belt/phone/coins/watch/buckle`) vs clear. `NpcProfile.carried_items` holds guest items. `GameManager` binds `W` to `_toggle_wand_screening` and `A`/`D` to `decide_wand`; `decide_access` blocks admission at wand venues until `controller.screened` is true. `VenueScenario.door_staff_lines` holds venue-specific door-staff dialogue (Joke Shop has id/ticket/wand/refusal/prohibited lines). -- Polish/accessibility: tone picker (`T` cycles `selected_tone` via `TONE_ORDER`; HUD `tone_label`), aim-free targeting (`Tab` cycles nearby NPCs within `NEARBY_RADIUS`), color-coded escalation indicator (`Hud.set_escalation`), procedural audio (`AudioFeedback` node generates sine tones for confirm/radio/escalation/deny), and a centralized `Strings` table (`src/core/strings.gd`, `lookup`/`fmt`) for localization. NOTE: `Strings.get` was renamed to `Strings.lookup` because `get` collides with `Object.get`. +- Design change (2026-10-31): the selectable dialogue tone-of-voice system was **removed**. + `Rules.Tone`/`TONE_NAMES`, `NpcProfile.tone_modifiers`, and the tone param on + `NPCController.roll_reaction/apply_player_action` no longer exist; `NpcProfile.dialogue_lines` + is a flat `Array[String]`; `GameManager` uses `_apply_interaction(level, action_name)`; + the `T` key is gone. HUD `tone_label` removed and `hud.tone_prefix` string deleted from JSON. +- Polish/accessibility: venue cycling (`V` cycles `VENUE_ORDER` and restarts the shift), aim-free + targeting (`Tab` cycles nearby NPCs within `NEARBY_RADIUS`), color-coded escalation indicator + (`Hud.set_escalation`), procedural audio (`AudioFeedback` node generates sine tones for + confirm/radio/escalation/deny), and a centralized `Strings` table (`src/core/strings.gd`, `lookup`/`fmt`) + backed by `content/strings.en.json`. NOTE: `Strings.get` was renamed to `Strings.lookup` because + `get` collides with `Object.get`. - Newly added: LATE shift phase (start 700s) with a reachability test; PROTESTER archetype + events + colors; CALL_SOMEONE reaction; `DoorState` core class + `L` binding + visual indicator; flashlight (`G`, SpotLight3D, perception bonus at evening/late); persistence wired (`Save.save_shift_result` on shift end, history + best score on the review screen); `NavigationRegion3D` flat navmesh + `NavigationAgent3D` NPC paths (steps now visual-only); strings moved to `res://content/strings.en.json` (41 keys, loader keeps `lookup`/`fmt`); `export_presets.cfg` (Linux/Windows/macOS) + `tools/build_releases.sh` + `docs/RELEASING.md`; README Downloads section. - Run tests: `godot --headless --path . --script res://src/test/test_runner.gd` - Boot check: `godot --headless --path . --quit-after 120` - Screenshot tools: `godot --path . -s res://src/test/take_screenshot.gd` (single shot to `user://screenshot.png`); `godot --path . -s res://src/test/capture_screenshots.gd` (staged README gallery shots to `user://screenshots/`, copied to `docs/screenshots/`). - Release build verified locally: export templates for 4.3 installed at `~/.local/share/godot/export_templates/4.3.stable/`; `./tools/build_releases.sh` produced Linux/Windows/macOS artifacts for 0.1.0 in `build/` and the Linux binary passed a headless smoke test. Publishing to Gitea still needs a token (see `tools/publish_release.sh`, `docs/RELEASING.md`). +- CI: the repo now carries a **Gitea Actions** workflow at `.gitea/workflows/build.yml` (checks on + push/PR; full export + artifact upload + optional release publish on tags and `workflow_dispatch`). + A repo secret `GITEA_TOKEN` is required for tag releases. The old `.github/workflows/ci.yml` was + removed so hosts scanning both paths do not double-run. +- Venue cycling: `V` switches Hollywood Blvd → The Joke Shop → TV Studio and restarts the shift. ## Known Issues / Notes diff --git a/PLAN.md b/PLAN.md index 6ea0eb6..5e6e3d2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -72,14 +72,7 @@ Example commands: - “You can’t remain here.” - “Please move back to the sidewalk.” -At this stage, most NPCs comply immediately. The player can choose different tones: - -- Professional -- Friendly -- Firm -- Suspicious - -A friendly approach works better with tourists and confused visitors, while a firm approach is more effective against deliberate trespassers. +At this stage, most NPCs comply immediately. --- @@ -459,7 +452,7 @@ The following commands apply once the Godot project is scaffolded: Desktop is the first target: Windows, Linux, and macOS. Web export is a later option. -## Tone and Design Direction +## Design Direction The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly. diff --git a/README.md b/README.md index b9f7513..1997e7e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ The central design goal is simple: **All releases:** +> Releases are built automatically by Gitea Actions when a `vX.Y.Z` tag is +> pushed (see [`docs/RELEASING.md`](docs/RELEASING.md)). Manual builds are +> produced with `./tools/build_releases.sh`. + > macOS builds are unsigned: right-click → Open, or clear the quarantine with > `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and > the release pipeline live in [`docs/RELEASING.md`](docs/RELEASING.md). @@ -84,7 +88,7 @@ An off-duty law-enforcement officer or armed emergency responder may arrive duri - **NPC behavior:** NPCs have different motivations, temperaments, compliance levels, and reactions to the player’s approach. - **Access verification:** Recognition, wristbands, IDs, tickets, credentials, and guest lists determine who may enter. - **Nonverbal deterrence:** Small actions such as raising a hand can resolve low-risk situations without dialogue or force. -- **Dialogue and tone:** Professional, friendly, firm, or suspicious approaches affect outcomes. +- **Dialogue:** Polite directions and warnings affect outcomes; simple gestures can resolve low-risk situations without dialogue. - **Escalation:** The player chooses the lowest effective response. - **Radio assistance:** Calls require useful information and may take time to arrive. - **Evidence:** Body-camera records support shift review and accountability. @@ -169,7 +173,7 @@ The unit test runner is the project's own lightweight harness (no editor plugin | E | Inspect access artifacts / close inspection | | W | Handheld wand screening (wand venues only) | | A / D | Allow / deny entry during inspection or wand check | -| T | Cycle dialogue tone (Professional / Friendly / Firm / Suspicious) | +| V | Cycle venue: Hollywood Blvd → The Joke Shop → TV Studio (restarts shift) | | Tab | Cycle target among nearby NPCs (aim-free alternative) | | G | Toggle flashlight (widens NPC perception at night) | | L | Lock / unlock the restricted door (thwarts forced entry) | @@ -198,7 +202,7 @@ Implemented so far: - Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools - Headless unit tests: **52 passing** (access rules, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, navmesh, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings) - 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, dialogue tone picker, aim-free targeting, 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 Not yet implemented: body-camera replay timeline, long (10-15 minute) shift lengths with phase-based lighting, venue selection screen, editor-based GUT integration, and RefCounted cycle cleanup (see `TODO.md`). diff --git a/TODO.md b/TODO.md index 57bbec2..0c17835 100644 --- a/TODO.md +++ b/TODO.md @@ -14,13 +14,13 @@ Goal: users hitting the README **Downloads** section can actually download a pla ### Follow-ups once the first release is live -- [ ] CI packaging: build and upload artifacts automatically on tags. +- [x] CI packaging: Gitea Actions workflow (`.gitea/workflows/build.yml`) builds and uploads artifacts on tags and dispatch. ## Future Systems (design-level backlog) - [ ] Body-camera replay timeline in the shift review. - [ ] Long (10-15 minute) shift lengths and phase-based lighting (darkened evening / late phases). -- [ ] Venue selection screen (wire `switch_scenario` to a startup menu). +- [ ] Venue selection screen with a proper startup menu (keyboard cycling with `V` is implemented; a menu is the next iteration). - [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced. - [ ] Reduce RefCounted reference cycles (NPCController <-> FSM) noted in `MEMORY.md`. diff --git a/content/strings.en.json b/content/strings.en.json index 31d380d..e9f87af 100644 --- a/content/strings.en.json +++ b/content/strings.en.json @@ -1,6 +1,5 @@ { - "hud.controls": "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · W wand · A allow · D deny · T tone · G flashlight · L lock door · F debug", - "hud.tone_prefix": "Tone: %s", + "hud.controls": "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · W wand · A allow · D deny · V venue · G flashlight · L lock door · F debug", "hud.escalation_prefix": "Escalation: %s", "hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs", "hud.aim_first": "Aim at someone first.", @@ -39,5 +38,6 @@ "door.unlocked": "The door is now unlocked.", "door.thwarted": "%s tries the locked door and fails.", "flashlight.on": "Flashlight on.", - "flashlight.off": "Flashlight off." + "flashlight.off": "Flashlight off.", + "hud.venue_switched": "Venue: %s" } diff --git a/docs/RELEASING.md b/docs/RELEASING.md index d870d54..59a08a3 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -26,18 +26,29 @@ godot --headless --export-release "Linux" build/step-line-smoke.x86_64 `project.godot` sets `config/version`. Keep it in sync with the release tag (e.g. `v0.3.0` → `config/version="0.3.0"`, plus a git tag `v0.3.0`). -## 3. Run the checks first +## 3. CI (Gitea Actions) -A release candidate must pass: +The repository ships a Gitea Actions workflow (`.gitea/workflows/build.yml`): + +- Every push/PR runs the **checks** job: Godot 4.3 install, project import, and the + full headless test suite. This is the fast safety net. +- **Tags (`v*`)** and **manual `workflow_dispatch`** runs additionally execute the + **build** job: export templates install, import, `tools/build_releases.sh` for + Linux/Windows/macOS, artifact upload, and — on tags — release publication via + `tools/publish_release.sh`. + +For tag builds, set a repo secret named **`GITEA_TOKEN`** (write:repository scope). +Without it the workflow still builds and tests, but the release publish step fails. + +> The workflow lives in `.gitea/workflows/` (Gitea's canonical path). The old +> `.github/workflows/ci.yml` was removed to avoid double runs on hosts that scan +> both directories. + +## 4. Local build (alternative to CI) ```bash godot --headless --path . --script res://src/test/test_runner.gd # all tests godot --headless --path . --quit-after 120 # boot smoke test -``` - -## 4. Build - -```bash ./tools/build_releases.sh ``` diff --git a/docs/screenshots/01-venue-overview.png b/docs/screenshots/01-venue-overview.png index da215ae..8699bac 100644 Binary files a/docs/screenshots/01-venue-overview.png and b/docs/screenshots/01-venue-overview.png differ diff --git a/docs/screenshots/02-targeting-npc.png b/docs/screenshots/02-targeting-npc.png index 49f211e..a63888b 100644 Binary files a/docs/screenshots/02-targeting-npc.png and b/docs/screenshots/02-targeting-npc.png differ diff --git a/docs/screenshots/03-access-inspection.png b/docs/screenshots/03-access-inspection.png index 3355d80..42bf2b3 100644 Binary files a/docs/screenshots/03-access-inspection.png and b/docs/screenshots/03-access-inspection.png differ diff --git a/docs/screenshots/04-radio-report.png b/docs/screenshots/04-radio-report.png index 08f33dc..f7c6d0d 100644 Binary files a/docs/screenshots/04-radio-report.png and b/docs/screenshots/04-radio-report.png differ diff --git a/src/content/scenario_factory.gd b/src/content/scenario_factory.gd index 031974e..2882a3b 100644 --- a/src/content/scenario_factory.gd +++ b/src/content/scenario_factory.gd @@ -21,13 +21,11 @@ static func shared_profiles() -> Array[NpcProfile]: tourist.npc_type = Rules.NpcType.TOURIST tourist.temperament = 0.1 tourist.base_compliance = 0.9 - tourist.dialogue_lines = { - Rules.Tone.FRIENDLY: [ - "Oh sure, sorry, just taking a picture.", - "Is there a better spot to stand?", - ], - Rules.Tone.PROFESSIONAL: ["Understood, moving."], - } + tourist.dialogue_lines = [ + "Oh sure, sorry, just taking a picture.", + "Is there a better spot to stand?", + "Understood, moving.", + ] profiles.append(tourist) var confused := NpcProfile.new() @@ -36,12 +34,10 @@ static func shared_profiles() -> Array[NpcProfile]: confused.npc_type = Rules.NpcType.CONFUSED_VISITOR confused.temperament = 0.2 confused.base_compliance = 0.85 - confused.dialogue_lines = { - Rules.Tone.FRIENDLY: [ - "Wait, this isn't the entrance?", - "Sorry about that, I'm a bit lost.", - ], - } + confused.dialogue_lines = [ + "Wait, this isn't the entrance?", + "Sorry about that, I'm a bit lost.", + ] profiles.append(confused) var delivery := NpcProfile.new() @@ -50,9 +46,7 @@ static func shared_profiles() -> Array[NpcProfile]: delivery.npc_type = Rules.NpcType.DELIVERY delivery.temperament = 0.25 delivery.base_compliance = 0.8 - delivery.dialogue_lines = { - Rules.Tone.PROFESSIONAL: ["Where should I leave this?"], - } + delivery.dialogue_lines = ["Where should I leave this?"] profiles.append(delivery) var performer := NpcProfile.new() @@ -97,11 +91,9 @@ static func shared_profiles() -> Array[NpcProfile]: Rules.Reaction.COMPLY: 10, }, } - influencer.dialogue_lines = { - Rules.Tone.SUSPICIOUS: [ - "This is public sidewalk, you can't stop me from filming.", - ], - } + influencer.dialogue_lines = [ + "This is public sidewalk, you can't stop me from filming.", + ] profiles.append(influencer) # --- Higher-risk crowd ------------------------------------------------------ @@ -162,12 +154,10 @@ static func shared_profiles() -> Array[NpcProfile]: Rules.Reaction.LEAVE: 30, }, } - trespasser.dialogue_lines = { - Rules.Tone.SUSPICIOUS: [ - "I'm just here to see a friend, come on.", - "I'll be inside for ten seconds.", - ], - } + trespasser.dialogue_lines = [ + "I'm just here to see a friend, come on.", + "I'll be inside for ten seconds.", + ] profiles.append(trespasser) var mental_crisis := NpcProfile.new() @@ -278,12 +268,10 @@ static func shared_profiles() -> Array[NpcProfile]: Rules.Reaction.FORCE_ENTRY: 20, }, } - protester.dialogue_lines = { - Rules.Tone.SUSPICIOUS: [ - "I have every right to be on public sidewalk.", - "You can't touch me, this is a lawful protest.", - ], - } + protester.dialogue_lines = [ + "I have every right to be on public sidewalk.", + "You can't touch me, this is a lawful protest.", + ] profiles.append(protester) # --- Authorized crowd --------------------------------------------------------- diff --git a/src/core/npc_controller.gd b/src/core/npc_controller.gd index 5b6ae2a..9f144ea 100644 --- a/src/core/npc_controller.gd +++ b/src/core/npc_controller.gd @@ -8,7 +8,7 @@ const FSM := preload("res://src/core/fsm.gd") ## reaction logic. The presentation layer adds a visual avatar and navigation. ## RefCounted on purpose: headless tests and CI do not need a scene tree. -signal interacted(level: int, tone: int) +signal interacted(level: int) var npc_id: String = "" var profile: NpcProfile = null @@ -61,15 +61,10 @@ func _build_fsm(initial_state: String) -> void: fsm.add_transition("THREATENING", "FORCING_ENTRY") fsm.start(initial_state) -func roll_reaction(level: int, tone: int) -> int: - ## Weighted pick from profile's reaction table for `level`, adjusted for tone. +func roll_reaction(level: int) -> int: + ## Weighted pick from profile's reaction table for `level`. highest_escalation_received = maxi(highest_escalation_received, level) var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate() - var tone_mod: float = profile.tone_modifiers.get(tone, 0.0) - if tone_mod > 0.0 and weights.has(Rules.Reaction.COMPLY): - # Shift weight mass toward compliance proportionally. - var transferable: float = float(weights[Rules.Reaction.COMPLY]) * tone_mod - weights[Rules.Reaction.COMPLY] += transferable * 0.5 var roll_value := rng.randf() var total := 0.0 for key in weights.keys(): @@ -83,16 +78,15 @@ func roll_reaction(level: int, tone: int) -> int: return key return Rules.Reaction.LEAVE -func apply_player_action(level: int, tone: int) -> Dictionary: +func apply_player_action(level: int) -> Dictionary: ## Returns reaction to the player's action and updates FSM when needed. - var performed_reaction := roll_reaction(level, tone) + var performed_reaction := roll_reaction(level) reaction = performed_reaction reaction_set = true _update_state_for_reaction(performed_reaction) return { "npc_id": npc_id, "level_used": level, - "tone": tone, "reaction": performed_reaction, "reaction_name": Rules.Reaction.find_key(performed_reaction), "new_state": fsm.current_state, diff --git a/src/core/npc_profile.gd b/src/core/npc_profile.gd index 7806361..007491d 100644 --- a/src/core/npc_profile.gd +++ b/src/core/npc_profile.gd @@ -12,8 +12,7 @@ const Rules := preload("res://src/core/rules.gd") ## Affects how likely the NPC is to argue, point, or threaten at each level. @export_range(0.0, 1.0) var temperament: float = 0.25 -## Base compliance: probability the NPC obeys a level-1 polite direction. -## Venue/scenario recalculates effective compliance from profile + tone + level. +## Probability the NPC obeys a level-1 polite direction. @export_range(0.0, 1.0) var base_compliance: float = 0.8 ## Probability that an NPC directed away will circle back later in the shift @@ -56,18 +55,11 @@ const Rules := preload("res://src/core/rules.gd") }, } -@export var tone_modifiers: Dictionary = { - Rules.Tone.FRIENDLY: 0.10, # + compliance - Rules.Tone.PROFESSIONAL: 0.00, - Rules.Tone.FIRM: -0.05, - Rules.Tone.SUSPICIOUS: -0.15, -} - ## Delay the NPC waits before reacting to the player (frames/seconds scaled). @export_range(0.1, 5.0) var reaction_delay: float = 0.6 -## Dialogue lines per tone and intent. Falls back to generic lines when empty. -@export var dialogue_lines: Dictionary = {} +## Dialogue lines this NPC uses. Falls back to generic lines when empty. +@export var dialogue_lines: Array[String] = [] ## Items the NPC is carrying, used by the handheld wand (The Joke Shop). ## Values are item names: "keys", "belt", "phone", "weapon", "knife", etc. diff --git a/src/core/rules.gd b/src/core/rules.gd index 545a4fe..959363c 100644 --- a/src/core/rules.gd +++ b/src/core/rules.gd @@ -108,21 +108,6 @@ enum Reaction { CALL_SOMEONE = 8, } -## Player dialogue tones. -enum Tone { - PROFESSIONAL = 0, - FRIENDLY = 1, - FIRM = 2, - SUSPICIOUS = 3, -} - -const TONE_NAMES := { - Tone.PROFESSIONAL: "Professional", - Tone.FRIENDLY: "Friendly", - Tone.FIRM: "Firm", - Tone.SUSPICIOUS: "Suspicious", -} - ## Shift phases by time of day. enum Phase { EARLY = 0, diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index 8959f06..cc00c42 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -46,9 +46,9 @@ var current_incident_npc_id := "" var current_incident_active := false var current_npc_truth := {"weapons_seen": false, "danger_level": 0} -## Dialogue tone picker (PLAN.md: Level 1 tones). Cycled with T. -var selected_tone: int = Rules.Tone.PROFESSIONAL -const TONE_ORDER := [Rules.Tone.PROFESSIONAL, Rules.Tone.FRIENDLY, Rules.Tone.FIRM, Rules.Tone.SUSPICIOUS] +## Venue cycling (V): next venue in the scenario list. +const VENUE_ORDER := ["hollywood_building", "joke_shop", "tv_studio"] +var _venue_index := 0 ## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility). const NEARBY_RADIUS := 12.0 @@ -266,12 +266,10 @@ func _handle_key(keycode: Key) -> void: _toggle_inspection() KEY_Q: if targeted_npc != null and not inspection_open: - _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "polite direction") + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction") KEY_W: if targeted_npc != null and not inspection_open: _toggle_wand_screening() - KEY_T: - _cycle_tone() KEY_TAB: _cycle_nearby_target() KEY_F: @@ -280,21 +278,23 @@ func _handle_key(keycode: Key) -> void: _toggle_flashlight() KEY_L: _toggle_door_lock() + KEY_V: + _cycle_venue() _: _handle_action_key(keycode) func _handle_action_key(keycode: Key) -> void: match keycode: KEY_1: - _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "raise hand") + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand") KEY_2: - _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "point") + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point") KEY_3: - _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "head shake") + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake") KEY_4: - _apply_interaction(Rules.Level.FORMAL_WARNING, selected_tone, "formal warning") + _apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning") KEY_5: - _apply_interaction(Rules.Level.BLOCKING, selected_tone, "blocking") + _apply_interaction(Rules.Level.BLOCKING, "blocking") KEY_6: _open_radio(false) KEY_R: @@ -331,13 +331,14 @@ func _toggle_door_lock() -> void: hud.set_door(door.locked) hud.log(Strings.lookup("door.locked") if door.locked else Strings.lookup("door.unlocked")) -## Cycle the dialogue tone (Professional → Friendly → Firm → Suspicious). -func _cycle_tone() -> void: - var idx := TONE_ORDER.find(selected_tone) - idx = (idx + 1) % TONE_ORDER.size() - selected_tone = TONE_ORDER[idx] - hud.set_tone(Rules.TONE_NAMES.get(selected_tone, "?")) - hud.log("Tone: %s" % Rules.TONE_NAMES.get(selected_tone, "?")) +## Cycle to the next venue (Hollywood Blvd → The Joke Shop → TV Studio). +## The coordinator is rebuilt with the new scenario and the shift restarts. +func _cycle_venue() -> void: + _venue_index = (_venue_index + 1) % VENUE_ORDER.size() + switch_scenario(VENUE_ORDER[_venue_index]) + var venue_name: String = selected_scenario.display_name if selected_scenario else "?" + hud.log(Strings.fmt("hud.venue_switched", [venue_name])) + hud.set_shift_state(coordinator, selected_scenario) ## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players ## interact without precise aiming (accessibility). @@ -359,20 +360,19 @@ func _cycle_nearby_target() -> void: _begin_incident(next_target) hud.log(Strings.fmt("hud.targeting", [next_target.name])) -func _apply_interaction(level: int, tone: int, action_name: String) -> void: +func _apply_interaction(level: int, action_name: String) -> void: if targeted_npc == null: hud.log(Strings.lookup("hud.aim_first")) return var controller: Object = _targeted_controller() if controller == null or coordinator == null: return - var outcome: Dictionary = controller.apply_player_action(level, tone) + var outcome: Dictionary = controller.apply_player_action(level) var npc_id: String = controller.npc_id coordinator.recorder.add_event(npc_id, { "type": "player_action", "action": action_name, "level": level, - "tone": Rules.TONE_NAMES.get(tone, "?"), }) coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id]) if level >= Rules.Level.FORMAL_WARNING: diff --git a/src/game/ui/hud.gd b/src/game/ui/hud.gd index 523bad1..54572b4 100644 --- a/src/game/ui/hud.gd +++ b/src/game/ui/hud.gd @@ -20,7 +20,6 @@ var mode_label: Label var wand_panel: PanelContainer var wand_text: Label var wand_meta: Label -var tone_label: Label var escalation_label: Label var _debug_enabled := false @@ -99,13 +98,6 @@ func _init() -> void: mode_label.modulate = Color(0.85, 0.85, 0.9) vbox.add_child(mode_label) - # --- dialogue tone indicator --- - tone_label = Label.new() - tone_label.text = "Tone: Professional" - tone_label.add_theme_font_size_override("font_size", 13) - tone_label.modulate = Color(0.7, 0.85, 1.0) - vbox.add_child(tone_label) - # --- escalation level indicator (color-coded) --- escalation_label = Label.new() escalation_label.text = "Escalation: Observation" @@ -200,8 +192,7 @@ func _refresh_mode_label() -> void: func set_action_hint(text: String) -> void: hint_label.text = text -func set_tone(tone_name: String) -> void: - tone_label.text = Strings.fmt("hud.tone_prefix", [tone_name]) + ## Color-coded escalation feedback (PLAN.md: Polish and Accessibility). ## Green → yellow → orange → red as the escalation level rises. diff --git a/src/test/tests/test_npc_reactions.gd b/src/test/tests/test_npc_reactions.gd index f97a303..64aae61 100644 --- a/src/test/tests/test_npc_reactions.gd +++ b/src/test/tests/test_npc_reactions.gd @@ -16,7 +16,7 @@ func test_tourist_complies_at_level_1() -> bool: var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9) var npc := NPCController.new() npc.setup("t1", p, "photo", 1234, "AT_ENTRANCE") - var outcome := npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY) + var outcome := npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE) var ok := TestUtils.check_eq(outcome["reaction"], Rules.Reaction.COMPLY, "base_compliance 0.9 tourist should comply at level 1") return ok and TestUtils.check_eq(npc.decide_leave(), true, "compliant NPC leaves") @@ -28,7 +28,7 @@ func test_trespasser_frequent_non_compliance() -> bool: ## from NpcProfile.default_reactions (mostly COMPLY there). var outcomes: Array = [] for i in 5: - outcomes.append(npc.roll_reaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL)) + outcomes.append(npc.roll_reaction(Rules.Level.SIMPLE_DETERRENCE)) ## With compliance 0.9/0.2 variation we simply assert we get SOME reaction. var ok := TestUtils.check(npc.highest_escalation_received >= Rules.Level.SIMPLE_DETERRENCE, "highest escalation tracked") return ok @@ -46,8 +46,8 @@ func test_deterministic_with_seed() -> bool: var seq_a: Array = [] var seq_b: Array = [] for i in 4: - seq_a.append(n1.roll_reaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL)) - seq_b.append(n2.roll_reaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL)) + seq_a.append(n1.roll_reaction(Rules.Level.FORMAL_WARNING)) + seq_b.append(n2.roll_reaction(Rules.Level.FORMAL_WARNING)) return TestUtils.check_eq(seq_a, seq_b, "same seed produces identical reaction sequence") func test_violent_threat_scales_to_threaten() -> bool: @@ -69,7 +69,7 @@ func test_violent_threat_scales_to_threaten() -> bool: npc.setup("v1", p, "threat", 777, "AT_ENTRANCE") var saw_threat := false for i in 20: - if npc.roll_reaction(Rules.Level.EMERGENCY, Rules.Tone.PROFESSIONAL) == Rules.Reaction.THREATEN: + if npc.roll_reaction(Rules.Level.EMERGENCY) == Rules.Reaction.THREATEN: saw_threat = true return TestUtils.check(saw_threat, "violent profile can threaten at emergency level") @@ -82,7 +82,7 @@ func test_call_someone_reaction() -> bool: } var npc := NPCController.new() npc.setup("c1", p, "film", 5, "AT_ENTRANCE") - var outcome := npc.apply_player_action(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL) + var outcome := npc.apply_player_action(Rules.Level.FORMAL_WARNING) var a := TestUtils.check_eq(outcome["reaction"], Rules.Reaction.CALL_SOMEONE, "rolls CALL_SOMEONE") var b := TestUtils.check_eq(npc.fsm.current_state, "ARGUING", "transitions to ARGUING") return a and b @@ -92,7 +92,7 @@ func test_fsm_transitions() -> bool: var npc := NPCController.new() npc.setup("g1", p, "enter", 5, "APPROACH") var started := TestUtils.check_eq(npc.fsm.current_state, "APPROACH", "starts approaching") - npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY) + npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE) var left := TestUtils.check_eq(npc.fsm.current_state, "LEAVING", "complies and leaves") return started and left @@ -103,7 +103,7 @@ func test_returning_trespasser_circles_back() -> bool: p.return_probability = 1.0 var npc := NPCController.new() npc.setup("rt1", p, "trespass", 4242, "AT_ENTRANCE") - npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL) + npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE) var left := TestUtils.check_eq(npc.fsm.current_state, "LOITERING_DISTANT", "directed away loiters before returning") var scheduled := TestUtils.check(npc.will_return, "return scheduled") # Advance past the return delay (max 60s). @@ -122,7 +122,7 @@ func test_tourist_never_returns() -> bool: var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9) var npc := NPCController.new() npc.setup("t2", p, "photo", 7, "AT_ENTRANCE") - npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY) + npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE) return TestUtils.check_eq(npc.will_return, false, "tourist does not schedule a return") func test_perception_pass_bands() -> bool: diff --git a/src/test/tests/test_strings.gd b/src/test/tests/test_strings.gd index 4716e73..846758b 100644 --- a/src/test/tests/test_strings.gd +++ b/src/test/tests/test_strings.gd @@ -10,8 +10,8 @@ func test_strings_lookup() -> bool: return a and b func test_strings_format() -> bool: - var a := TestUtils.check_eq(Strings.fmt("hud.tone_prefix", ["Firm"]), "Tone: Firm", "tone prefix formats") - var b := TestUtils.check_eq(Strings.fmt("react.comply", ["Tourist"]), "Tourist complies and steps back.", "reaction formats") + var a := TestUtils.check_eq(Strings.fmt("react.comply", ["Tourist"]), "Tourist complies and steps back.", "reaction formats") + var b := TestUtils.check_eq(Strings.fmt("hud.venue_switched", ["The Joke Shop"]), "Venue: The Joke Shop", "venue switch formats") return a and b func test_strings_json_backed_all_resolve() -> bool: @@ -26,7 +26,7 @@ func test_strings_json_backed_all_resolve() -> bool: func test_strings_cover_ui_keys() -> bool: ## Every key referenced by the HUD and game manager must exist. var required := [ - "hud.controls", "hud.tone_prefix", "hud.escalation_prefix", "hud.cooldown", + "hud.controls", "hud.escalation_prefix", "hud.cooldown", "hud.venue_switched", "hud.aim_first", "hud.no_one_nearby", "hud.targeting", "hud.no_wand", "hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied", "access.wrong_grant", "access.wrong_deny", "wand.admitted_prohibited",