Remove dialogue tone-of-voice system, add Gitea Actions full build, add venue cycling
- Delete Rules.Tone/TONE_NAMES, NpcProfile.tone_modifiers, tone params on NPCController.roll_reaction/apply_player_action; dialogue_lines flattened to Array[String]; HUD tone label, T binding, and hud.tone_prefix removed - Docs updated everywhere (PLAN, README, DONE, MEMORY); PLAN section heading 'Tone and Design Direction' -> 'Design Direction' - Add .gitea/workflows/build.yml: checks job (import+tests) on push/PR; full build job (export templates, Linux/Windows/macOS exports, artifact upload, and tag-based Gitea release publish via GITEA_TOKEN) on tags/dispatch - Remove old .github/workflows/ci.yml to avoid double runs - Add venue cycling (V) across all three scenarios with hud.venue_switched - Refresh README screenshots to match the new HUD - 53/53 tests pass, boot smoke clean
No files matched your search
@@ -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}"
|
||||||
@@ -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
|
|
||||||
@@ -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 a lightweight finite state machine for NPC states (`FSM`).
|
||||||
- [x] Implement the observation phase (Level 0) without forced intervention.
|
- [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 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 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 tone.
|
- [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] Implement blocking and physical positioning (Level 3) without default force.
|
||||||
- [x] Wire typical NPC reactions: leave, argue, ignore, record, move closer, or return later.
|
- [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 `tools/build_releases.sh` for headless release exports.
|
||||||
- [x] Add `docs/RELEASING.md` with template installation, build, and Gitea release steps.
|
- [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 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
|
## Core Simulation — NPC Perception
|
||||||
|
|
||||||
@@ -157,7 +176,8 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
|
|||||||
|
|
||||||
## Polish and Accessibility
|
## 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 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 visual and audio feedback per escalation level (colors, tones, shake).
|
||||||
- [x] Add audio for crowd ambience, radio responses, and confirmation prompts.
|
- [x] Add audio for crowd ambience, radio responses, and confirmation prompts.
|
||||||
|
|||||||
@@ -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.
|
- 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`.
|
- 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).
|
- 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.
|
- 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`
|
- Run tests: `godot --headless --path . --script res://src/test/test_runner.gd`
|
||||||
- Boot check: `godot --headless --path . --quit-after 120`
|
- 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/`).
|
- 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`).
|
- 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
|
## Known Issues / Notes
|
||||||
|
|
||||||
|
|||||||
@@ -72,14 +72,7 @@ Example commands:
|
|||||||
- “You can’t remain here.”
|
- “You can’t remain here.”
|
||||||
- “Please move back to the sidewalk.”
|
- “Please move back to the sidewalk.”
|
||||||
|
|
||||||
At this stage, most NPCs comply immediately. The player can choose different tones:
|
At this stage, most NPCs comply immediately.
|
||||||
|
|
||||||
- Professional
|
|
||||||
- Friendly
|
|
||||||
- Firm
|
|
||||||
- Suspicious
|
|
||||||
|
|
||||||
A friendly approach works better with tourists and confused visitors, while a firm approach is more effective against deliberate trespassers.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ The central design goal is simple:
|
|||||||
|
|
||||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/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
|
> macOS builds are unsigned: right-click → Open, or clear the quarantine with
|
||||||
> `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and
|
> `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and
|
||||||
> the release pipeline live in [`docs/RELEASING.md`](docs/RELEASING.md).
|
> 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.
|
- **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.
|
- **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.
|
- **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.
|
- **Escalation:** The player chooses the lowest effective response.
|
||||||
- **Radio assistance:** Calls require useful information and may take time to arrive.
|
- **Radio assistance:** Calls require useful information and may take time to arrive.
|
||||||
- **Evidence:** Body-camera records support shift review and accountability.
|
- **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 |
|
| E | Inspect access artifacts / close inspection |
|
||||||
| W | Handheld wand screening (wand venues only) |
|
| W | Handheld wand screening (wand venues only) |
|
||||||
| A / D | Allow / deny entry during inspection or wand check |
|
| 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) |
|
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
|
||||||
| G | Toggle flashlight (widens NPC perception at night) |
|
| G | Toggle flashlight (widens NPC perception at night) |
|
||||||
| L | Lock / unlock the restricted door (thwarts forced entry) |
|
| 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
|
- 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)
|
- 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
|
- 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`).
|
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`).
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ Goal: users hitting the README **Downloads** section can actually download a pla
|
|||||||
|
|
||||||
### Follow-ups once the first release is live
|
### 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)
|
## Future Systems (design-level backlog)
|
||||||
|
|
||||||
- [ ] Body-camera replay timeline in the shift review.
|
- [ ] Body-camera replay timeline in the shift review.
|
||||||
- [ ] Long (10-15 minute) shift lengths and phase-based lighting (darkened evening / late phases).
|
- [ ] 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.
|
- [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced.
|
||||||
- [ ] Reduce RefCounted reference cycles (NPCController <-> FSM) noted in `MEMORY.md`.
|
- [ ] Reduce RefCounted reference cycles (NPCController <-> FSM) noted in `MEMORY.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -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.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.tone_prefix": "Tone: %s",
|
|
||||||
"hud.escalation_prefix": "Escalation: %s",
|
"hud.escalation_prefix": "Escalation: %s",
|
||||||
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
||||||
"hud.aim_first": "Aim at someone first.",
|
"hud.aim_first": "Aim at someone first.",
|
||||||
@@ -39,5 +38,6 @@
|
|||||||
"door.unlocked": "The door is now unlocked.",
|
"door.unlocked": "The door is now unlocked.",
|
||||||
"door.thwarted": "%s tries the locked door and fails.",
|
"door.thwarted": "%s tries the locked door and fails.",
|
||||||
"flashlight.on": "Flashlight on.",
|
"flashlight.on": "Flashlight on.",
|
||||||
"flashlight.off": "Flashlight off."
|
"flashlight.off": "Flashlight off.",
|
||||||
|
"hud.venue_switched": "Venue: %s"
|
||||||
}
|
}
|
||||||
@@ -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
|
`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`).
|
(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
|
```bash
|
||||||
godot --headless --path . --script res://src/test/test_runner.gd # all tests
|
godot --headless --path . --script res://src/test/test_runner.gd # all tests
|
||||||
godot --headless --path . --quit-after 120 # boot smoke test
|
godot --headless --path . --quit-after 120 # boot smoke test
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./tools/build_releases.sh
|
./tools/build_releases.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 77 KiB |
@@ -21,13 +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.dialogue_lines = {
|
tourist.dialogue_lines = [
|
||||||
Rules.Tone.FRIENDLY: [
|
"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?",
|
"Understood, moving.",
|
||||||
],
|
]
|
||||||
Rules.Tone.PROFESSIONAL: ["Understood, moving."],
|
|
||||||
}
|
|
||||||
profiles.append(tourist)
|
profiles.append(tourist)
|
||||||
|
|
||||||
var confused := NpcProfile.new()
|
var confused := NpcProfile.new()
|
||||||
@@ -36,12 +34,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.dialogue_lines = {
|
confused.dialogue_lines = [
|
||||||
Rules.Tone.FRIENDLY: [
|
"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.",
|
]
|
||||||
],
|
|
||||||
}
|
|
||||||
profiles.append(confused)
|
profiles.append(confused)
|
||||||
|
|
||||||
var delivery := NpcProfile.new()
|
var delivery := NpcProfile.new()
|
||||||
@@ -50,9 +46,7 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
delivery.npc_type = Rules.NpcType.DELIVERY
|
delivery.npc_type = Rules.NpcType.DELIVERY
|
||||||
delivery.temperament = 0.25
|
delivery.temperament = 0.25
|
||||||
delivery.base_compliance = 0.8
|
delivery.base_compliance = 0.8
|
||||||
delivery.dialogue_lines = {
|
delivery.dialogue_lines = ["Where should I leave this?"]
|
||||||
Rules.Tone.PROFESSIONAL: ["Where should I leave this?"],
|
|
||||||
}
|
|
||||||
profiles.append(delivery)
|
profiles.append(delivery)
|
||||||
|
|
||||||
var performer := NpcProfile.new()
|
var performer := NpcProfile.new()
|
||||||
@@ -97,11 +91,9 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
Rules.Reaction.COMPLY: 10,
|
Rules.Reaction.COMPLY: 10,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
influencer.dialogue_lines = {
|
influencer.dialogue_lines = [
|
||||||
Rules.Tone.SUSPICIOUS: [
|
"This is public sidewalk, you can't stop me from filming.",
|
||||||
"This is public sidewalk, you can't stop me from filming.",
|
]
|
||||||
],
|
|
||||||
}
|
|
||||||
profiles.append(influencer)
|
profiles.append(influencer)
|
||||||
|
|
||||||
# --- Higher-risk crowd ------------------------------------------------------
|
# --- Higher-risk crowd ------------------------------------------------------
|
||||||
@@ -162,12 +154,10 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
Rules.Reaction.LEAVE: 30,
|
Rules.Reaction.LEAVE: 30,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
trespasser.dialogue_lines = {
|
trespasser.dialogue_lines = [
|
||||||
Rules.Tone.SUSPICIOUS: [
|
"I'm just here to see a friend, come on.",
|
||||||
"I'm just here to see a friend, come on.",
|
"I'll be inside for ten seconds.",
|
||||||
"I'll be inside for ten seconds.",
|
]
|
||||||
],
|
|
||||||
}
|
|
||||||
profiles.append(trespasser)
|
profiles.append(trespasser)
|
||||||
|
|
||||||
var mental_crisis := NpcProfile.new()
|
var mental_crisis := NpcProfile.new()
|
||||||
@@ -278,12 +268,10 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
Rules.Reaction.FORCE_ENTRY: 20,
|
Rules.Reaction.FORCE_ENTRY: 20,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
protester.dialogue_lines = {
|
protester.dialogue_lines = [
|
||||||
Rules.Tone.SUSPICIOUS: [
|
"I have every right to be on public sidewalk.",
|
||||||
"I have every right to be on public sidewalk.",
|
"You can't touch me, this is a lawful protest.",
|
||||||
"You can't touch me, this is a lawful protest.",
|
]
|
||||||
],
|
|
||||||
}
|
|
||||||
profiles.append(protester)
|
profiles.append(protester)
|
||||||
|
|
||||||
# --- Authorized crowd ---------------------------------------------------------
|
# --- Authorized crowd ---------------------------------------------------------
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const FSM := preload("res://src/core/fsm.gd")
|
|||||||
## reaction logic. The presentation layer adds a visual avatar and navigation.
|
## reaction logic. The presentation layer adds a visual avatar and navigation.
|
||||||
## RefCounted on purpose: headless tests and CI do not need a scene tree.
|
## 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 npc_id: String = ""
|
||||||
var profile: NpcProfile = null
|
var profile: NpcProfile = null
|
||||||
@@ -61,15 +61,10 @@ 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, tone: int) -> int:
|
func roll_reaction(level: int) -> int:
|
||||||
## Weighted pick from profile's reaction table for `level`, adjusted for tone.
|
## Weighted pick from profile's reaction table for `level`.
|
||||||
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()
|
||||||
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 roll_value := rng.randf()
|
||||||
var total := 0.0
|
var total := 0.0
|
||||||
for key in weights.keys():
|
for key in weights.keys():
|
||||||
@@ -83,16 +78,15 @@ func roll_reaction(level: int, tone: int) -> int:
|
|||||||
return key
|
return key
|
||||||
return Rules.Reaction.LEAVE
|
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.
|
## 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 = performed_reaction
|
||||||
reaction_set = true
|
reaction_set = true
|
||||||
_update_state_for_reaction(performed_reaction)
|
_update_state_for_reaction(performed_reaction)
|
||||||
return {
|
return {
|
||||||
"npc_id": npc_id,
|
"npc_id": npc_id,
|
||||||
"level_used": level,
|
"level_used": level,
|
||||||
"tone": tone,
|
|
||||||
"reaction": performed_reaction,
|
"reaction": performed_reaction,
|
||||||
"reaction_name": Rules.Reaction.find_key(performed_reaction),
|
"reaction_name": Rules.Reaction.find_key(performed_reaction),
|
||||||
"new_state": fsm.current_state,
|
"new_state": fsm.current_state,
|
||||||
|
|||||||
@@ -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.
|
## 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
|
@export_range(0.0, 1.0) var temperament: float = 0.25
|
||||||
|
|
||||||
## Base compliance: probability the NPC obeys a level-1 polite direction.
|
## Probability the NPC obeys a level-1 polite direction.
|
||||||
## Venue/scenario recalculates effective compliance from profile + tone + level.
|
|
||||||
@export_range(0.0, 1.0) var base_compliance: float = 0.8
|
@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
|
## 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).
|
## Delay the NPC waits before reacting to the player (frames/seconds scaled).
|
||||||
@export_range(0.1, 5.0) var reaction_delay: float = 0.6
|
@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.
|
## Dialogue lines this NPC uses. Falls back to generic lines when empty.
|
||||||
@export var dialogue_lines: Dictionary = {}
|
@export var dialogue_lines: Array[String] = []
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -108,21 +108,6 @@ enum Reaction {
|
|||||||
CALL_SOMEONE = 8,
|
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.
|
## Shift phases by time of day.
|
||||||
enum Phase {
|
enum Phase {
|
||||||
EARLY = 0,
|
EARLY = 0,
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ var current_incident_npc_id := ""
|
|||||||
var current_incident_active := false
|
var current_incident_active := false
|
||||||
var current_npc_truth := {"weapons_seen": false, "danger_level": 0}
|
var current_npc_truth := {"weapons_seen": false, "danger_level": 0}
|
||||||
|
|
||||||
## Dialogue tone picker (PLAN.md: Level 1 tones). Cycled with T.
|
## Venue cycling (V): next venue in the scenario list.
|
||||||
var selected_tone: int = Rules.Tone.PROFESSIONAL
|
const VENUE_ORDER := ["hollywood_building", "joke_shop", "tv_studio"]
|
||||||
const TONE_ORDER := [Rules.Tone.PROFESSIONAL, Rules.Tone.FRIENDLY, Rules.Tone.FIRM, Rules.Tone.SUSPICIOUS]
|
var _venue_index := 0
|
||||||
|
|
||||||
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
|
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
|
||||||
const NEARBY_RADIUS := 12.0
|
const NEARBY_RADIUS := 12.0
|
||||||
@@ -266,12 +266,10 @@ func _handle_key(keycode: Key) -> void:
|
|||||||
_toggle_inspection()
|
_toggle_inspection()
|
||||||
KEY_Q:
|
KEY_Q:
|
||||||
if targeted_npc != null and not inspection_open:
|
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:
|
KEY_W:
|
||||||
if targeted_npc != null and not inspection_open:
|
if targeted_npc != null and not inspection_open:
|
||||||
_toggle_wand_screening()
|
_toggle_wand_screening()
|
||||||
KEY_T:
|
|
||||||
_cycle_tone()
|
|
||||||
KEY_TAB:
|
KEY_TAB:
|
||||||
_cycle_nearby_target()
|
_cycle_nearby_target()
|
||||||
KEY_F:
|
KEY_F:
|
||||||
@@ -280,21 +278,23 @@ func _handle_key(keycode: Key) -> void:
|
|||||||
_toggle_flashlight()
|
_toggle_flashlight()
|
||||||
KEY_L:
|
KEY_L:
|
||||||
_toggle_door_lock()
|
_toggle_door_lock()
|
||||||
|
KEY_V:
|
||||||
|
_cycle_venue()
|
||||||
_:
|
_:
|
||||||
_handle_action_key(keycode)
|
_handle_action_key(keycode)
|
||||||
|
|
||||||
func _handle_action_key(keycode: Key) -> void:
|
func _handle_action_key(keycode: Key) -> void:
|
||||||
match keycode:
|
match keycode:
|
||||||
KEY_1:
|
KEY_1:
|
||||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "raise hand")
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
|
||||||
KEY_2:
|
KEY_2:
|
||||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "point")
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
|
||||||
KEY_3:
|
KEY_3:
|
||||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "head shake")
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake")
|
||||||
KEY_4:
|
KEY_4:
|
||||||
_apply_interaction(Rules.Level.FORMAL_WARNING, selected_tone, "formal warning")
|
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
|
||||||
KEY_5:
|
KEY_5:
|
||||||
_apply_interaction(Rules.Level.BLOCKING, selected_tone, "blocking")
|
_apply_interaction(Rules.Level.BLOCKING, "blocking")
|
||||||
KEY_6:
|
KEY_6:
|
||||||
_open_radio(false)
|
_open_radio(false)
|
||||||
KEY_R:
|
KEY_R:
|
||||||
@@ -331,13 +331,14 @@ func _toggle_door_lock() -> void:
|
|||||||
hud.set_door(door.locked)
|
hud.set_door(door.locked)
|
||||||
hud.log(Strings.lookup("door.locked") if door.locked else Strings.lookup("door.unlocked"))
|
hud.log(Strings.lookup("door.locked") if door.locked else Strings.lookup("door.unlocked"))
|
||||||
|
|
||||||
## Cycle the dialogue tone (Professional → Friendly → Firm → Suspicious).
|
## Cycle to the next venue (Hollywood Blvd → The Joke Shop → TV Studio).
|
||||||
func _cycle_tone() -> void:
|
## The coordinator is rebuilt with the new scenario and the shift restarts.
|
||||||
var idx := TONE_ORDER.find(selected_tone)
|
func _cycle_venue() -> void:
|
||||||
idx = (idx + 1) % TONE_ORDER.size()
|
_venue_index = (_venue_index + 1) % VENUE_ORDER.size()
|
||||||
selected_tone = TONE_ORDER[idx]
|
switch_scenario(VENUE_ORDER[_venue_index])
|
||||||
hud.set_tone(Rules.TONE_NAMES.get(selected_tone, "?"))
|
var venue_name: String = selected_scenario.display_name if selected_scenario else "?"
|
||||||
hud.log("Tone: %s" % Rules.TONE_NAMES.get(selected_tone, "?"))
|
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
|
## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players
|
||||||
## interact without precise aiming (accessibility).
|
## interact without precise aiming (accessibility).
|
||||||
@@ -359,20 +360,19 @@ func _cycle_nearby_target() -> void:
|
|||||||
_begin_incident(next_target)
|
_begin_incident(next_target)
|
||||||
hud.log(Strings.fmt("hud.targeting", [next_target.name]))
|
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:
|
if targeted_npc == null:
|
||||||
hud.log(Strings.lookup("hud.aim_first"))
|
hud.log(Strings.lookup("hud.aim_first"))
|
||||||
return
|
return
|
||||||
var controller: Object = _targeted_controller()
|
var controller: Object = _targeted_controller()
|
||||||
if controller == null or coordinator == null:
|
if controller == null or coordinator == null:
|
||||||
return
|
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
|
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",
|
||||||
"action": action_name,
|
"action": action_name,
|
||||||
"level": level,
|
"level": level,
|
||||||
"tone": Rules.TONE_NAMES.get(tone, "?"),
|
|
||||||
})
|
})
|
||||||
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
|
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
|
||||||
if level >= Rules.Level.FORMAL_WARNING:
|
if level >= Rules.Level.FORMAL_WARNING:
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ var mode_label: Label
|
|||||||
var wand_panel: PanelContainer
|
var wand_panel: PanelContainer
|
||||||
var wand_text: Label
|
var wand_text: Label
|
||||||
var wand_meta: Label
|
var wand_meta: Label
|
||||||
var tone_label: Label
|
|
||||||
var escalation_label: Label
|
var escalation_label: Label
|
||||||
|
|
||||||
var _debug_enabled := false
|
var _debug_enabled := false
|
||||||
@@ -99,13 +98,6 @@ func _init() -> void:
|
|||||||
mode_label.modulate = Color(0.85, 0.85, 0.9)
|
mode_label.modulate = Color(0.85, 0.85, 0.9)
|
||||||
vbox.add_child(mode_label)
|
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 level indicator (color-coded) ---
|
||||||
escalation_label = Label.new()
|
escalation_label = Label.new()
|
||||||
escalation_label.text = "Escalation: Observation"
|
escalation_label.text = "Escalation: Observation"
|
||||||
@@ -200,8 +192,7 @@ func _refresh_mode_label() -> void:
|
|||||||
func set_action_hint(text: String) -> void:
|
func set_action_hint(text: String) -> void:
|
||||||
hint_label.text = text
|
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).
|
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
|
||||||
## Green → yellow → orange → red as the escalation level rises.
|
## Green → yellow → orange → red as the escalation level rises.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func test_tourist_complies_at_level_1() -> bool:
|
|||||||
var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
|
var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
|
||||||
var npc := NPCController.new()
|
var npc := NPCController.new()
|
||||||
npc.setup("t1", p, "photo", 1234, "AT_ENTRANCE")
|
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")
|
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")
|
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).
|
## from NpcProfile.default_reactions (mostly COMPLY there).
|
||||||
var outcomes: Array = []
|
var outcomes: Array = []
|
||||||
for i in 5:
|
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.
|
## 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")
|
var ok := TestUtils.check(npc.highest_escalation_received >= Rules.Level.SIMPLE_DETERRENCE, "highest escalation tracked")
|
||||||
return ok
|
return ok
|
||||||
@@ -46,8 +46,8 @@ func test_deterministic_with_seed() -> bool:
|
|||||||
var seq_a: Array = []
|
var seq_a: Array = []
|
||||||
var seq_b: Array = []
|
var seq_b: Array = []
|
||||||
for i in 4:
|
for i in 4:
|
||||||
seq_a.append(n1.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, Rules.Tone.PROFESSIONAL))
|
seq_b.append(n2.roll_reaction(Rules.Level.FORMAL_WARNING))
|
||||||
return TestUtils.check_eq(seq_a, seq_b, "same seed produces identical reaction sequence")
|
return TestUtils.check_eq(seq_a, seq_b, "same seed produces identical reaction sequence")
|
||||||
|
|
||||||
func test_violent_threat_scales_to_threaten() -> bool:
|
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")
|
npc.setup("v1", p, "threat", 777, "AT_ENTRANCE")
|
||||||
var saw_threat := false
|
var saw_threat := false
|
||||||
for i in 20:
|
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
|
saw_threat = true
|
||||||
return TestUtils.check(saw_threat, "violent profile can threaten at emergency level")
|
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()
|
var npc := NPCController.new()
|
||||||
npc.setup("c1", p, "film", 5, "AT_ENTRANCE")
|
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 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")
|
var b := TestUtils.check_eq(npc.fsm.current_state, "ARGUING", "transitions to ARGUING")
|
||||||
return a and b
|
return a and b
|
||||||
@@ -92,7 +92,7 @@ func test_fsm_transitions() -> bool:
|
|||||||
var npc := NPCController.new()
|
var npc := NPCController.new()
|
||||||
npc.setup("g1", p, "enter", 5, "APPROACH")
|
npc.setup("g1", p, "enter", 5, "APPROACH")
|
||||||
var started := TestUtils.check_eq(npc.fsm.current_state, "APPROACH", "starts approaching")
|
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")
|
var left := TestUtils.check_eq(npc.fsm.current_state, "LEAVING", "complies and leaves")
|
||||||
return started and left
|
return started and left
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ func test_returning_trespasser_circles_back() -> bool:
|
|||||||
p.return_probability = 1.0
|
p.return_probability = 1.0
|
||||||
var npc := NPCController.new()
|
var npc := NPCController.new()
|
||||||
npc.setup("rt1", p, "trespass", 4242, "AT_ENTRANCE")
|
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 left := TestUtils.check_eq(npc.fsm.current_state, "LOITERING_DISTANT", "directed away loiters before returning")
|
||||||
var scheduled := TestUtils.check(npc.will_return, "return scheduled")
|
var scheduled := TestUtils.check(npc.will_return, "return scheduled")
|
||||||
# Advance past the return delay (max 60s).
|
# 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 p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
|
||||||
var npc := NPCController.new()
|
var npc := NPCController.new()
|
||||||
npc.setup("t2", p, "photo", 7, "AT_ENTRANCE")
|
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")
|
return TestUtils.check_eq(npc.will_return, false, "tourist does not schedule a return")
|
||||||
|
|
||||||
func test_perception_pass_bands() -> bool:
|
func test_perception_pass_bands() -> bool:
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ func test_strings_lookup() -> bool:
|
|||||||
return a and b
|
return a and b
|
||||||
|
|
||||||
func test_strings_format() -> bool:
|
func test_strings_format() -> bool:
|
||||||
var a := TestUtils.check_eq(Strings.fmt("hud.tone_prefix", ["Firm"]), "Tone: Firm", "tone prefix 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("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
|
return a and b
|
||||||
|
|
||||||
func test_strings_json_backed_all_resolve() -> bool:
|
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:
|
func test_strings_cover_ui_keys() -> bool:
|
||||||
## Every key referenced by the HUD and game manager must exist.
|
## Every key referenced by the HUD and game manager must exist.
|
||||||
var required := [
|
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.aim_first", "hud.no_one_nearby", "hud.targeting", "hud.no_wand",
|
||||||
"hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied",
|
"hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied",
|
||||||
"access.wrong_grant", "access.wrong_deny", "wand.admitted_prohibited",
|
"access.wrong_grant", "access.wrong_deny", "wand.admitted_prohibited",
|
||||||
|
|||||||