diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6307563 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +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/.gitignore b/.gitignore new file mode 100644 index 0000000..c1581c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Godot 4 +.godot/ +*.import +.android/ +export_presets.cfg + +# OS +.DS_Store +Thumbs.db + +# Editor +*.tmp +*.new + +# Logs and temp +*.log +user:// diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bf0ee12 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,184 @@ +# AGENTS.md + +## Purpose + +This document defines the development and collaboration workflow for agents and human contributors working on the security guard game. + +The project should be developed in small, reviewable steps. Agents must keep planning, task status, session memory, and project documentation up to date as work progresses. + +## Source-of-Truth Files + +- `PLAN.md` — The general product and game-design plan. Update it when the intended scope, mechanics, or priorities change. +- `TODO.md` — The active task list. Organize work into sections and use checkbox items for every actionable task. +- `DONE.md` — Completed task sections moved from `TODO.md`. Preserve the original section structure and checkboxes when moving finished work. +- `MEMORY.md` — Persistent session memory for important decisions, discoveries, unresolved questions, constraints, and useful implementation context. +- `README.md` — Root-level documentation for the project. It should explain what the game is, how to work on it, how to run it, and where to find project information. +- `AGENTS.md` — This collaboration and agent workflow guide. + +## Required Workflow + +### 1. Start by Inspecting the Repository + +Before making changes, an agent should: + +- Read `AGENTS.md`. +- Read `PLAN.md` and `README.md` if they exist. +- Read the relevant sections of `TODO.md` and `MEMORY.md`. +- Inspect the repository structure and existing implementation before proposing changes. +- Check the current version-control status when available. + +If `TODO.md`, `DONE.md`, or `MEMORY.md` does not exist, create it using the recommended format in this document before continuing. + +Do not assume that a task is unimplemented without checking the code and documentation first. + +### 2. Maintain `TODO.md` + +All planned implementation work must be represented in `TODO.md`. + +- Use headings to group related work into sections. +- Use checkbox items for each concrete task. +- Keep tasks small enough to complete and review independently. +- Add new tasks when discoveries expand the scope. +- Mark a task complete only after the implementation and relevant verification are finished. +- If any verification check fails, do not mark the task complete. Fix the failure, or if it cannot be fixed, add a follow-up task to `TODO.md`, record the failure in `MEMORY.md`, and report it in the handoff summary. +- Do not use prose status updates as a substitute for checkboxes. +- Revisit `TODO.md` regularly and mark checkbox items as complete `[x]` when the corresponding work is finished. + +Recommended format: + +```md +# TODO + +## Core Gameplay + +- [ ] Add player movement controls. +- [ ] Implement NPC approach behavior. +- [x] Define escalation levels. + +## Documentation + +- [ ] Document the test procedure in `README.md`. +``` + +### 3. Move Completed Sections to `DONE.md` + +When every task in a `TODO.md` section is complete: + +1. Verify the work and update the checkbox items to `[x]`. +2. Move the entire completed section, including its heading and checklist, to `DONE.md`. +3. Remove that section from `TODO.md`. +4. Add a short completion note or date when useful. + +Do not move partially completed sections. Leave them in `TODO.md` until all of their tasks are complete, or split the section into smaller sections first. + +### 4. Maintain `MEMORY.md` + +Use `MEMORY.md` for information that should persist between sessions but does not belong in the general plan or task list. + +Record items such as: + +- Important design and architecture decisions +- Current implementation state +- Known bugs and workarounds +- Commands needed to run, test, or build the project +- Decisions that future agents should not revisit unnecessarily +- Open questions and assumptions +- Files or systems that need special care + +Keep memory concise and factual. Remove obsolete entries instead of allowing contradictory notes to accumulate. + +Recommended format: + +```md +# MEMORY + +## Decisions + +- The emergency response is a rare last-resort system, not a general combat mechanic. + +## Current State + +- The game-design document exists in `PLAN.md`. + +## Open Questions + +- Choose the game engine before implementing the prototype. +``` + +### 5. Maintain `PLAN.md` + +`PLAN.md` describes what the project should achieve at a high level. It should contain the intended player experience, major systems, scope, and design constraints. + +- Update it when the project direction changes. +- Avoid using it as a detailed progress log. +- Convert actionable implementation work from the plan into tasks in `TODO.md`. +- Keep the plan consistent with decisions recorded in `MEMORY.md`. + +### 6. Maintain the Root `README.md` + +The root-level `README.md` is the project’s public-facing technical and project overview. It should document: + +- The game’s premise and current status +- The planned or implemented features +- Required tools and dependencies +- Setup instructions +- Run, build, and test commands +- Basic project structure +- Development workflow +- Links to `PLAN.md`, `TODO.md`, `DONE.md`, `MEMORY.md`, and this file where appropriate +- Known limitations and contribution guidance + +Update `README.md` whenever setup instructions, commands, project structure, or user-facing behavior changes. + +### 7. Implement in Small Increments + +For each task: + +1. Select or create a task in `TODO.md`. +2. Inspect the relevant files and dependencies. +3. Make the smallest coherent change that completes the task. +4. Run the relevant tests, checks, or manual verification. +5. Update documentation and `MEMORY.md` if the change creates lasting context. +6. Mark the task complete. +7. Move its section to `DONE.md` when the entire section is complete. + +Avoid unrelated refactors unless they are required for the task or explicitly approved. + +## Collaboration Rules + +- Prefer clear, reversible changes over broad rewrites. +- Preserve existing behavior unless the task requires changing it. +- Explain assumptions when requirements are ambiguous. +- Do not claim a task is complete without verification. +- Keep documentation synchronized with the implementation. +- Mention files changed and checks run in handoff summaries. +- Leave follow-up work as checkbox items in `TODO.md` rather than hiding it in prose. +- If blocked, document the blocker in `MEMORY.md` and add a corresponding task to `TODO.md`. +- Avoid overwriting another contributor’s work; inspect recent changes before editing shared files. + +## Verification Expectations + +must run every check below that applies to the changed files. Automated tests and formatting/linting/type checks apply to any change to source code. A smoke test applies to any change that affects runtime behavior. Documentation-only changes require only the documentation check. Skipped checks must be listed with a reason. +Before handing off work, an agent should, when applicable: + +- Run automated tests. +- Run formatting, linting, or type checks. +- Start the game or application and perform a basic smoke test. +- Check that documentation and task status reflect the actual state. +- Review the final diff for accidental changes or unfinished markers. + +If a check cannot be run, state why and record the missing verification in `MEMORY.md` or `TODO.md`. + +## Handoff Format + +A useful handoff should include: + +- A brief summary of what changed +- Files added or modified +- Tests or checks that were run +- Any known issues or unverified behavior +- The next recommended task from `TODO.md` + +## Safety and Design Constraints + +The game should emphasize observation, communication, de-escalation, and proportionate responses. Emergency assistance and weapons must remain rare last-resort mechanics with meaningful consequences, not default solutions for ordinary NPC behavior. diff --git a/DONE.md b/DONE.md new file mode 100644 index 0000000..082d014 --- /dev/null +++ b/DONE.md @@ -0,0 +1,75 @@ +# DONE + +Completed task sections moved from `TODO.md`. Sections are moved only when every item is finished and verified. + +## Project Setup + +- [x] Confirm Godot 4.x editor version to standardize on (Godot 4.3 stable, official build). +- [x] Initialize a git repository and add a sensible root `.gitignore` for Godot and export artifacts. +- [x] Scaffold the Godot project (`project.godot`, input map, folder structure). +- [x] Add the GUT test framework and a runnable headless test command. (Deviation: GUT requires the editor plugin; replaced with a lightweight custom harness in `src/test/test_runner.gd` that runs without the editor.) +- [x] Document setup, run, and test commands in `README.md`. + +## Prototype Scene + +- [x] Build a placeholder Hollywood Boulevard building entrance with steps, sidewalk, and a restricted door (procedural `WorldBuilder`). +- [x] Add a controllable guard character with first-person camera controls (`GuardController`). +- [x] Add basic keyboard/mouse input: move, look, interact, and cancel. +- [x] Add a debug overlay showing NPC intent, escalation level, and shift timer (`F` toggles). + +## Core Simulation — Systems + +- [x] Implement `ShiftCoordinator` to start, advance, and end a shift. +- [x] Implement `VenueScenario` as a `Resource` with locations, rules, NPC pools, and event pools. +- [x] Implement an event scheduler that emits routine traffic and occasional scripted events (`EventScheduler`). +- [x] Implement `NPCController` with movement toward waypoints, doors, and queues (movement handled by `NpcAvatar`). + +## NPC Interaction + +- [x] Define `NpcProfile` resources with intent, temperament, compliance, and escalation tolerances. +- [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 blocking and physical positioning (Level 3) without default force. +- [x] Wire typical NPC reactions: leave, argue, ignore, record, move closer, or return later. + +## Access Verification + +- [x] Implement `AccessVerifier` with input from recognition, wristbands, IDs, tickets, credentials, and guest lists. +- [x] Add wristband inspection UI showing color, pattern, date, and event marking. +- [x] Model valid, expired, counterfeit, mismatched, missing, and ambiguous access artifacts. +- [x] Record admit, redirect, hold, and refuse decisions for shift review. +- [x] Add feedback scoring for incorrectly denying an authorized person or admitting an unauthorized one. + +## Escalation and Radio — Core + +- [x] Implement `EscalationManager` with explicit levels 0 through 5 and available-action gating. +- [x] Implement the security-assistance radio call (Level 4) with a report form and arrival delay. +- [x] Implement the emergency radio (Level 5) with hold-to-confirm and a report form. +- [x] Add radio cooldown and battery limits to prevent spam. + +## Incident, Reputation, and Shift Review + +- [x] Implement `IncidentRecorder` emitting structured events (actor, behavior, action, evidence, escalation, outcome). +- [x] Implement body-camera evidence tied to incident events. +- [x] Implement `ReputationEvaluator` for peaceful resolutions, accuracy, complaints, damage, and entrance security. +- [x] Implement `CrowdSystem` reactions to calm, aggressive, armed, and emergency situations. +- [x] Implement the end-of-shift review screen summarizing decisions, consequences, and justification. + +## Scenario Content — Venue Data + +- [x] Author a minimal Hollywood Boulevard building scenario with tourist, confused-visitor, and simple-trespasser NPCs. +- [x] Author a rare violent-threat event that requires justified emergency escalation. +- [x] Add The Joke Shop scenario data: ID checks, guest lists, and wand-screening policy. +- [x] Add the Hollywood Boulevard Late Night TV Studio scenario data with the full NPC mix and credential checks. +- [x] Add a mental-health-crisis event with careful, respectful de-escalation guidance. + +## Tooling and Quality + +- [x] Add headless test coverage for access verification rules. +- [x] Add headless test coverage for escalation transitions and scoring. +- [x] Add fixed-seed scenario tests for deterministic NPC behavior. +- [x] Set up GitHub Actions to run headless tests on push (`ci.yml`). +- [x] Add a smoke-test checklist for launching one full shift (in `README.md`). diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 0000000..7b42ad3 --- /dev/null +++ b/MEMORY.md @@ -0,0 +1,34 @@ +# MEMORY + +Session memory for durable decisions, state, and gotchas. Keep concise, remove obsolete entries. + +## Decisions + +- Engine: Godot 4.3 stable (official editor build, `~/.local/bin/godot`). The system's preinstalled `godot-runner` (4.7.2, Fedora) cannot load projects from arbitrary paths because it was compiled without path overrides; use the official editor binary instead. +- Language: GDScript. All cross-file references use explicit `preload()` constants (not `class_name` globals) so scripts compile in headless `--script` runs without the editor's class cache. +- Test framework deviation: the project uses a custom minimal headless test runner (`src/test/test_runner.gd`) instead of GUT, because GUT needs the editor plugin. CI runs it with `--headless`. +- Eventing: no autoload signal bus. `ShiftCoordinator` owns an `EventBus` instance (`src/core/event_bus.gd`) passed to subsystems; subsystems emit only when a bus is assigned. Autoloads at parse time are unreliable under `--script`. +- Static signals are not available in Godot 4.3 GDScript ("Expected func or var after static") — do not use them. +- GDScript lambdas capture local variables **by value**; mutating a captured local inside a lambda does not affect the outer variable. Use member functions for accumulators in tests. +- Prototype shift duration defaults to 300 s for testing; production targets 10-15 minutes per PLAN.md. +- RNG: NPC reactions and event scheduling are seedable for deterministic tests. + +## Current State + +- Core simulation, three scenarios, headless tests (25 passing), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green. +- Run tests: `godot --headless --path . --script res://src/test/test_runner.gd` +- Boot check: `godot --headless --path . --quit-after 120` +- Screenshot tool: `godot --path . --script res://src/test/take_screenshot.gd` (writes to `user://screenshot.png`). + +## Known Issues / Notes + +- RefCounted reference cycle: `NPCController` holds an `FSM` that holds a back-reference to its owner, so test suites report "resources still in use at exit". Benign (process exit), but worth breaking later (e.g. `WeakRef` or explicit `dispose()`). +- Headless (dummy renderer) logs "Parameter m is null" from `mesh_get_surface_count`; it does not occur with a real GL driver and does not affect logic. +- NPC avatars all walk to the entrance and idle; per-intent waypoints are not implemented yet (see TODO). +- Wand screening (Joke Shop) is policy-only; the interaction is not yet in the prototype (see TODO). + +## Open Questions + +- Tone picker UI design and whether tones should be selectable per interaction. +- Whether the responder avatar should be playable/controllable or purely scripted. +- Localization target languages. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..28ca070 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,468 @@ +# Game Plan: Step Line + +## Premise + +You play a private security guard stationed at the top of the steps outside a fictional building on Hollywood Boulevard. Your job is to keep the entrance and steps clear while dealing with tourists, trespassers, performers, protesters, intoxicated pedestrians, and occasional determined intruders. + +The game is about **judgment, communication, and escalation management**, not simply attacking anyone who approaches. + +--- + +## Core Gameplay Loop + +1. Watch the stairs, entrance, and sidewalk. +2. Identify why each person is approaching. +3. Give verbal warnings and directions. +4. Escalate only when necessary. +5. Call for assistance in rare dangerous situations. +6. Maintain public safety, building security, and your employer’s reputation. + +People should not all behave the same way. Some are confused, some are rude, some are filming, and a very small number are deliberately attempting to enter. + +## Authorized Access + +Some people are authorized to enter the building. The security guard must verify them before granting access. + +An authorized person can be admitted if either: + +- The guard already knows and recognizes them. +- The person presents a valid event or staff wristband, which the guard checks before opening the entrance. + +The wristband check should require the player to inspect details such as its color, pattern, date, or event marking. NPCs may have valid wristbands, expired wristbands, counterfeit wristbands, or no wristband at all. + +The player should avoid relying on appearance or assumptions. A familiar person may still need verification if the building’s current policy requires it, while an unfamiliar person with a valid wristband may be admitted after a successful check. Incorrectly denying an authorized person harms the building’s reputation; admitting someone without authorization creates a security failure. + +--- + +# Escalation System + +## Level 0: Observation + +The guard watches NPC behavior and decides whether intervention is needed. + +Examples: + +- Someone taking a photograph from the sidewalk +- A tourist reading a map +- A person sitting briefly on the edge of the steps +- Someone approaching the entrance with a delivery package + +The player should avoid interrupting harmless behavior. Unnecessary confrontations reduce the guard’s performance rating. + +--- + +## Level 1: Simple Deterrence and Polite Direction + +The player can begin with a simple, nonverbal deterrent before speaking. These actions are quick and low-pressure, making them useful when someone is only approaching the steps casually or appears unsure. + +Possible actions include: + +- Raise an open hand to signal “stop” or “please wait” +- Make eye contact +- Point toward the public sidewalk +- Gesture toward the wristband-check area +- Shake the head gently when someone approaches a restricted entrance + +If the person continues, the player uses a short verbal prompt. + +Example commands: + +- “Please keep the stairs clear.” +- “The entrance is for authorized guests only.” +- “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. + +--- + +## Level 2: Formal Warning + +If the person remains on the steps or continues toward the entrance, the player issues a formal warning. + +Possible dialogue: + +> “This is private property. You have been asked to leave. Please move away from the entrance now.” + +The player may activate a visible security light or body camera. This creates a record of the interaction and can discourage repeat offenders. + +At this level, the NPC may: + +- Leave +- Argue +- Pretend not to understand +- Start recording the guard +- Call someone else +- Move closer to the door + +--- + +## Level 3: Blocking and Physical Positioning + +The guard moves into a defensive position between the NPC and the building. + +Gameplay actions: + +- Stand in front of the entrance +- Point toward the public sidewalk +- Lock or secure the door +- Request the person’s reason for entry +- Keep distance while maintaining a clear escape route +- Use a flashlight or radio warning tone at night + +The player should not automatically shove or strike people. Physical contact creates legal, employment, and public-relations consequences. + +This level is about **denying access without creating a fight**. + +--- + +## Level 4: Radio for Security Assistance + +If the person refuses to leave, attempts to force the door, threatens staff, or repeatedly returns, the player uses the radio. + +Possible assistance: + +- A second security guard +- Building management +- Police dispatch +- Nearby security personnel + +The radio call takes time, so the player must continue monitoring the person while waiting. The player can choose what information to report: + +- Location +- Description +- Behavior +- Whether weapons have been seen +- Whether anyone is in immediate danger + +Incorrect or exaggerated reports damage the guard’s credibility. + +--- + +## Level 5: Emergency Response + +This is reserved for immediate threats, such as: + +- A person displaying a weapon +- An attempted forced entry while people are inside +- An assault on the guard or another person +- A serious threat against occupants +- A violent struggle that cannot be safely disengaged + +The player presses and holds a clearly marked **Emergency Radio** button. A confirmation prompt prevents accidental activation. + +The call brings an off-duty law-enforcement officer or armed emergency responder to the location. This character has greater authority and can control a dangerous situation, but the call has serious consequences: + +- The building may be evacuated +- The street may be closed +- The guard’s actions are reviewed +- The building loses reputation +- The player may face an investigation if the call was unjustified +- The officer may use force only when the game’s threat assessment supports it + +The officer should not function as a convenient “stronger attack.” Their role is to handle a crisis the player cannot safely manage. In most scenarios, their arrival results in commands, restraint, evacuation, or arrest rather than automatic gunfire. + +--- + +# NPC Behavior Types + +## Tourists + +- Take photos +- Sit on steps +- Wander toward restricted doors +- Usually respond well to polite directions + +## Street Performers + +- May block stairs +- May become argumentative if moved +- Can be redirected to a permitted area + +## Influencers and Filmmakers + +- Film the guard +- Test boundaries for content +- May accuse the guard of overreacting +- Create public-relations risk + +## Confused Visitors + +- Have appointments or directions +- May genuinely believe the building is public +- Reward patient explanations + +## Intoxicated Pedestrians + +- Move unpredictably +- May ignore instructions +- Require additional distance and assistance + +## Trespassers + +- Repeatedly test entrances +- Use distractions +- May return after being told to leave + +## Violent Threats + +- Rare +- Escalate quickly +- Trigger the emergency response system only when their behavior indicates immediate danger + +## 51/50s + +Crazy Homeless Individuals may be referred to as "51/50s" in the context of the game, indicating a person who may be experiencing a mental health crisis and could pose a potential threat to themselves or others. + +- May loiter near entrances +- Can be unpredictable in behavior +- Often respond poorly to aggressive confrontation +- Require careful de-escalation and patience + +--- + +# Special Systems + +## Reputation Meter + +The player is evaluated on: + +- Number of incidents resolved peacefully +- Response time +- Accuracy of radio reports +- Number of unnecessary confrontations +- Public complaints +- Property damage +- Whether the entrance remained secure + +A player who calls armed assistance too often should score poorly, even if no one was injured. + +## Evidence and Body Camera + +Interactions are automatically recorded. After each shift, the player reviews incidents and sees whether their escalation was justified. + +This encourages players to think before acting. + +## Public Crowd Reaction + +Nearby pedestrians react to the guard’s behavior: + +- Calm professionalism attracts little attention +- Aggressive behavior creates a crowd +- Visible weapons or emergency responders cause panic +- Good de-escalation keeps the boulevard moving normally + +## Limited Radio Battery or Cooldown + +The emergency radio should not be infinitely available. A call has a cooldown and may require the player to give a short report first. + +--- + +# Shift Structure + +Each shift lasts 10–15 minutes. + +### Early Shift + +Mostly harmless tourists, delivery workers, and people sitting on the stairs. + +### Midday + +Crowds become heavier. More people attempt to enter the building or film interactions. + +### Evening + +Lighting decreases. Intoxicated pedestrians and aggressive groups become more common. + +### Late Shift + +A rare serious incident may occur, testing whether the player recognizes a genuine threat instead of overreacting to ordinary behavior. + +--- + +# Win and Loss Conditions + +## Successful Shift + +- Building remains secure +- Most incidents are resolved without physical force +- Public access remains clear +- Emergency calls are justified +- No bystanders are injured +- The player follows company policy + +## Poor Performance + +- Excessive use of force +- Repeatedly confronting innocent people +- Ignoring a real threat +- Making false radio reports +- Allowing repeated unauthorized entry +- Causing a crowd or panic + +A shift can be technically “secure” but still receive a poor rating if the guard behaves recklessly. + +--- + +# Venues and Scenarios + +The game should support multiple venues, each with its own access rules, crowd patterns, equipment, and escalation procedures. The core gameplay remains the same: observe, verify access, communicate clearly, and use the lowest effective level of escalation. + +## The Joke Shop + +**The Joke Shop** is a comedy club scenario where the player works as a door person at the entrance. The player is responsible for checking identification, confirming that guests are attending the correct show, and screening guests for weapons with a handheld security wand. + +### Primary Responsibilities + +- Check each guest’s government-issued ID where required. +- Confirm that the guest meets the venue’s age requirement. +- Verify that the ID appears valid and belongs to the person presenting it. +- Check tickets, reservations, or guest-list status. +- Use the handheld wand according to venue policy. +- Direct guests through the entrance without blocking the sidewalk. +- Refuse entry when a guest has an invalid ID, no valid admission, or a prohibited item. + +### Guest Outcomes + +A guest may have: + +- A valid ID and ticket +- A valid ID but no ticket or reservation +- An expired, damaged, or questionable ID +- An ID that does not match the guest +- A prohibited item detected by the wand +- A harmless item that causes a false alarm +- No ID when identification is required + +The player must make decisions based on the venue’s rules and observable evidence, not on a guest’s appearance or attitude. A calm guest with a valid ID should be admitted efficiently, while an aggressive guest with valid credentials may still require assistance if they threaten staff or other patrons. + +### Joke Shop Escalation + +1. **Nonverbal direction:** Raise a hand, point to the ID-check area, or gesture for the guest to wait. +2. **Routine explanation:** Explain the ID, ticket, or wand-check requirement. +3. **Repeat request:** Clearly state what is missing or what must happen before entry. +4. **Refusal of entry:** Keep the guest outside and direct them away from the entrance. +5. **Manager or security assistance:** Call for a supervisor or another staff member when a guest argues, refuses screening, or blocks the line. +6. **Emergency response:** Call emergency services only for an immediate threat, such as a weapon, assault, or serious attempt to force entry. + +The handheld wand should not automatically identify an item as a weapon. The player must interpret the alert, ask the guest to identify the item, and follow venue procedure. The goal is to create a believable screening challenge while keeping the emphasis on communication and proportionate responses. + +### Scenario Events + +- A busy weekend comedy show creates a long line. +- A performer arrives through the guest entrance and must be recognized or verified. +- A guest forgets their ID but appears on the reservation list. +- A fake or borrowed ID is presented. +- A wand alert comes from keys, a belt buckle, or a phone. +- A guest refuses the screening but insists on entering. +- A disruptive guest attempts to follow another person through the door. +- A late-arriving audience member tries to enter during a performance. + +## Hollywood Boulevard Late Night TV Studio + +This scenario takes place outside a late-night television studio on Hollywood Boulevard. The guard manages the front steps, studio entrance, guest arrivals, deliveries, audience members, and the public crowd gathered around the building. + +The studio encounters all of the NPC types described in the game plan: + +- **Tourists** stopping to take photos or watch arrivals +- **Street performers** occupying the steps or performing near the entrance +- **Influencers and filmmakers** recording the guard, guests, or celebrity arrivals +- **Confused visitors** looking for the wrong entrance or trying to attend a show without understanding the access rules +- **Intoxicated pedestrians** approaching the entrance or disrupting the crowd +- **Trespassers** testing the doors, trying to follow authorized guests, or returning after being directed away +- **Violent threats**, which are rare but require careful recognition and emergency escalation + +The studio also has authorized guests and staff. They must either be recognized by the guard or present a valid wristband, credential, guest-list confirmation, or other approved access marker. The player must check these credentials without holding up the audience line or allowing unauthorized people to enter. + +### Studio Responsibilities + +- Keep the front steps and entrance clear. +- Direct tourists and performers away from restricted areas. +- Check wristbands, credentials, tickets, or guest-list status. +- Admit authorized staff, performers, guests, and audience members. +- Manage influencers and filmmakers without unnecessarily obstructing lawful filming. +- Protect celebrity arrivals and production equipment from crowding. +- Coordinate with studio staff, production assistants, and additional security guards. +- Watch for attempts to follow authorized people through the entrance. +- Call for emergency assistance only when there is an immediate threat. + +### Studio Scenario Events + +- A crowd gathers to watch a celebrity arrive. +- Tourists stop on the steps and block the entrance for photographs. +- A street performer sets up in front of the audience queue. +- An influencer repeatedly ignores the restricted filming boundary. +- A confused visitor believes the studio is open to the public. +- An intoxicated pedestrian wanders toward the stage door. +- A trespasser tries to blend into an audience group. +- An authorized guest arrives without their wristband and must be verified another way. +- Someone presents a counterfeit wristband or credential. +- A violent threat appears during a crowded arrival, requiring the player to protect the entrance while calling for assistance. + +The scenario should test the player’s ability to distinguish ordinary Hollywood Boulevard activity from a genuine security problem. The best solution is usually a simple gesture, clear direction, or credential check rather than immediate escalation. + +Future venues can reuse this structure while changing the rules, equipment, staff roles, and types of access decisions. + +# Tech Stack + +## Engine: Godot 4 + +The game is built with the Godot Engine 4.x editor. Godot was chosen for its free and open-source license, built-in 3D rendering, navigation, animation, audio, and UI systems, and its low weight for fast prototyping and iteration. + +## Language: GDScript + +Gameplay logic is written in GDScript. It is tightly integrated with the engine, fast to iterate on, and avoids an external build toolchain for the prototype phase. + +## Architecture Mapping + +The venue-driven architecture maps onto Godot as follows: + +- **Nodes and scenes:** Each major system (`ShiftCoordinator`, `NPCController`, `InteractionSystem`, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`, `ShiftReview`) is a Godot node or scene. +- **Scenarios as resources:** `VenueScenario` and content such as `NpcProfile`, `Dialogue`, `EventPool`, and `AccessPolicy` are defined as custom Godot `Resource` types so venue content is data-driven and inspectable in the editor. +- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D` drive NPC and player movement. +- **NPC behavior:** Lightweight custom finite state machines built on the `NPCController` node, with behavior traits coming from `NpcProfile` resources. +- **UI:** Godot `Control` nodes and themes for prompts, access inspection, radio reports, dialogue, timers, and the shift review screen. +- **Audio:** Godot audio buses and `AudioStreamPlayer` nodes for voices, crowd noise, radio, and environment sound. + +## Content and Data + +- Scenario and entity configuration uses typed Godot `Resource` files (`.tres`), created either in the editor or via scripts. +- Localized dialogue and text are stored in JSON files when they need to be editable outside the editor. +- Save data and shift results use JSON in Godot's `user://` directory. + +## Testing + +- Unit and scenario tests use the GUT (Godot Unit Test) framework. +- Tests run in headless mode so rules, access verification, escalation, and scoring can be verified without rendering or user input. + +## Version Control and CI + +- Source is tracked with git. +- GitHub Actions runs headless Godot tests and, in later stages, exports desktop builds. + +## Planned Commands + +The following commands apply once the Godot project is scaffolded: + +- Open the editor: `godot --editor --path .` +- Run the game: `godot --path .` +- Run tests headlessly via GUT. + +## Export Targets + +Desktop is the first target: Windows, Linux, and macOS. Web export is a later option. + +# Tone and 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. + +A useful guiding rule: + +> **The player wins by choosing the lowest level of escalation that keeps everyone safe.** diff --git a/README.md b/README.md index a9a2ff0..12b1a3d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The central design goal is simple: > The player wins by choosing the lowest level of escalation that keeps everyone safe. -This is a planned game project. The repository currently contains the game design, architecture documentation, and technical plan. The selected engine is Godot 4 with GDScript; a playable prototype has not yet been implemented. +The project is under active development. The design and architecture are documented, and a playable prototype vertical slice exists (Godot 4.3 + GDScript): a procedural Hollywood Boulevard entrance, a first-person guard, simulated NPCs with intents and reactions, the six-level escalation model, access inspection and admission decisions, radio calls with credibility checks, reputation, crowd mood, and an end-of-shift review. ## Game Overview @@ -104,15 +104,45 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri ### Setup -1. Install the Godot 4.x editor. -2. (Once the project is scaffolded) open the project: `godot --editor --path .` +1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH. +2. Open or run the project from this directory. ### Run and Test -- Run the game: `godot --path .` -- Run tests headlessly via GUT (configured during project setup). +```bash +# Open in the editor +godot --editor --path . -These commands apply after `project.godot` and the Godot folder structure exist. +# Run the game +godot --path . + +# Headless smoke boot (120 frames then exit) +godot --headless --path . --quit-after 120 + +# Run all unit tests headlessly (exit code 0 = pass) +godot --headless --path . --script res://src/test/test_runner.gd + +# Save a screenshot to user:// (windowed) +godot --path . --script res://src/test/take_screenshot.gd +``` + +The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor. + +### Controls + +| Input | Action | +|---|---| +| W/A/S/D + mouse | Move and look | +| Esc | Release / capture mouse | +| Q | Polite direction (Level 1) | +| 1 / 2 / 3 | Simple gestures: raise hand, point, head shake (Level 1) | +| 4 | Formal warning (Level 2) | +| 5 | Blocking and positioning (Level 3) | +| 6 | Security assistance radio (Level 4) | +| R | Emergency radio, hold on-screen button ~1.6s (Level 5) | +| E | Inspect access artifacts / close inspection | +| A / D | Allow / deny entry during inspection | +| F | Toggle debug overlay | ## Architecture @@ -283,7 +313,28 @@ Architecture decisions are recorded as ADRs: ## Development Status -The project is in the pre-prototype stage: the design and architecture are documented, Godot 4 is the selected engine, and the next milestone is to create a minimal venue prototype implementing one complete NPC interaction from observation through shift review. +Implemented so far: + +- Godot 4.3 project scaffold, input map, procedural world builder +- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` +- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools +- Headless unit tests: 25 passing (access rules, escalation, radio cooldown/battery, NPC reaction determinism, scenario data, full-shift simulation) +- First-person prototype with interaction HUD, access inspection, radio report form (incl. emergency hold-to-confirm), debug overlay, and shift review + +Not yet implemented: wand-screening interaction, dispatched responder avatar in the world, dialogue tone picker, localization, audio, editor-based tooling. + +### Smoke Test Checklist + +1. `godot --path .` starts the game without script errors. +2. The guard moves and looks around; the building, steps, and sidewalk are visible. +3. NPCs spawn and approach the entrance during the first minutes. +4. Aiming at an NPC shows its archetype and intent in the HUD. +5. Pressing `Q` produces a reaction log entry and reputation feedback. +6. `E` opens access inspection showing artifacts and policy result; `A`/`D` record a decision. +7. `6` opens the assistance form; submitting shows dispatch feedback. +8. `R` opens the emergency form; the button must be held ~1.6s or the call cancels. +9. `F` toggles the debug overlay with NPC states and shift stats. +10. When the shift timer reaches 0, the shift review screen shows grade and incident summary. ## Development Workflow diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..7cd993f --- /dev/null +++ b/TODO.md @@ -0,0 +1,35 @@ +# TODO + +## Core Simulation — NPC Perception + +- [ ] Implement a perception pass so NPCs react to the guard's gestures and proximity. +- [ ] Give NPCs local waypoints per intent (photo spots, queue, stage door, sidewalk exit) instead of all walking to the entrance. +- [ ] Add returning-trespasser behavior: an NPC that was directed away may circle back later in the shift. + +## Escalation and Radio — Responder Dispatch + +- [ ] Spawn a dispatched security NPC / armed responder avatar that arrives at the venue after the call delay. +- [ ] Give the responder command, restraint, evacuation, and arrest outcomes that resolve the incident. +- [ ] Add a visible emergency-radio cooldown indicator in the HUD. + +## Scenario Content — Wand Screening (The Joke Shop) + +- [ ] Implement the handheld wand inspection interaction with alerts from keys, belts, phones, and weapons. +- [ ] Enforce screening-before-entry flow and record false alarm / prohibited item decisions. +- [ ] Add venue-specific dialogue lines for door staff. + +## Polish and Accessibility + +- [ ] Add dialogue tone picker (Professional / Friendly / Firm / Suspicious) before addressing an NPC. +- [ ] Add interaction alternatives that do not require precise aiming (e.g. scan-and-select pinned list of nearby NPCs). +- [ ] Add visual and audio feedback per escalation level (colors, tones, shake). +- [ ] Add audio for crowd ambience, radio responses, and confirmation prompts. +- [ ] Externalize dialogue and UI text for localization. + +## Future Systems (design-level backlog) + +- [ ] Body-camera replay timeline in the shift review. +- [ ] Long (10-15 minute) shift lengths and phase-based lighting. +- [ ] Venue selection screen; export presets for desktop targets. +- [ ] 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/project.godot b/project.godot new file mode 100644 index 0000000..5877567 --- /dev/null +++ b/project.godot @@ -0,0 +1,107 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. + +config_version=5 + +[application] + +config/name="Step Line" +config/description="Workplace security simulation about observation, access verification, and proportionate escalation." +config/version="0.1.0" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.3", "GL Compatibility") + +[autoload] + +Save="*res://src/autoload/save.gd" + +[display] + +window/size/viewport_width=1280 +window/size/viewport_height=720 + +[input] + +move_forward={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +move_back={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +move_left={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +move_right={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +interact={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) +, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} +cancel={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +gesture_raise_hand={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null) +] +} +gesture_point={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":50,"key_label":0,"unicode":50,"location":0,"echo":false,"script":null) +] +} +gesture_head_shake={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":51,"key_label":0,"unicode":51,"location":0,"echo":false,"script":null) +] +} +escalate_warning={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":52,"key_label":0,"unicode":52,"location":0,"echo":false,"script":null) +] +} +escalate_block={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":53,"key_label":0,"unicode":53,"location":0,"echo":false,"script":null) +] +} +radio_assist={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":54,"key_label":0,"unicode":54,"location":0,"echo":false,"script":null) +] +} +radio_emergency={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":87,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null) +] +} +print_debug={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null) +] +} + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..91d399f --- /dev/null +++ b/scenes/main.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://bstepmain00000"] + +[ext_resource type="Script" path="res://src/game/main.gd" id="1"] + +[node name="Main" type="Node"] +script = ExtResource("1") diff --git a/src/autoload/save.gd b/src/autoload/save.gd new file mode 100644 index 0000000..6c4f1a2 --- /dev/null +++ b/src/autoload/save.gd @@ -0,0 +1,39 @@ +extends Node +## Autoload for local persistence (JSON in user://). Wrapped in an autoload so UI +## nodes never touch the filesystem directly. + +const SHIFT_RESULTS_FILE := "shift_results.json" +const SETTINGS_FILE := "settings.json" + +func _load_json(file_name: String) -> Variant: + var path := "user://" + file_name + if not FileAccess.file_exists(path): + return null + var text := FileAccess.get_file_as_string(path) + if text.is_empty(): + return null + var parsed = JSON.parse_string(text) + return parsed + +func _save_json(file_name: String, data: Variant) -> Error: + var path := "user://" + file_name + var text := JSON.stringify(data, "\t") + var f := FileAccess.open(path, FileAccess.WRITE) + if f == null: + return FileAccess.get_open_error() + f.store_string(text) + f.close() + return OK + +## Load previous shift results. Returns Array of Dictionaries or []. +func load_shift_results() -> Array: + var data = _load_json(SHIFT_RESULTS_FILE) + if data is Array: + return data + return [] + +## Append one shift result and save. Returns Error. +func save_shift_result(result: Dictionary) -> Error: + var results := load_shift_results() + results.append(result) + return _save_json(SHIFT_RESULTS_FILE, results) diff --git a/src/content/scenario_factory.gd b/src/content/scenario_factory.gd new file mode 100644 index 0000000..71bf692 --- /dev/null +++ b/src/content/scenario_factory.gd @@ -0,0 +1,509 @@ +class_name ScenarioFactory +## Builds all authored venue scenarios + +const Rules := preload("res://src/core/rules.gd") +const VenueScenario := preload("res://src/core/venue_scenario.gd") +const AccessPolicy := preload("res://src/core/access_policy.gd") +const NpcProfile := preload("res://src/core/npc_profile.gd") +## Every venue derives from shared NPC norms and overrides venue rules. + +const PLAN_ACCESS_SUMMARY_BUILDING := "Private property. Entry requires recognition or a valid wristband." +const PLAN_ACCESS_SUMMARY_JOKE_SHOP := "Government ID and a valid ticket or reservation required. Carry items are screened." +const PLAN_ACCESS_SUMMARY_TV_STUDIO := "Entry requires a wristband, credential, ticket, or guest-list confirmation." + +static func shared_profiles() -> Array[NpcProfile]: + var profiles: Array[NpcProfile] = [] + + # --- Small, low-risk crowd ------------------------------------------------- + var tourist := NpcProfile.new() + tourist.id = "tourist" + tourist.display_name = "Tourist" + 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."], + } + profiles.append(tourist) + + var confused := NpcProfile.new() + confused.id = "confused_visitor" + confused.display_name = "Confused Visitor" + 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.", + ], + } + profiles.append(confused) + + var delivery := NpcProfile.new() + delivery.id = "delivery" + delivery.display_name = "Delivery Worker" + 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?"], + } + profiles.append(delivery) + + var performer := NpcProfile.new() + performer.id = "street_performer" + performer.display_name = "Street Performer" + performer.npc_type = Rules.NpcType.PERFORMER + performer.temperament = 0.45 + performer.base_compliance = 0.55 + performer.reactions_by_level = { + Rules.Level.SIMPLE_DETERRENCE: { + Rules.Reaction.COMPLY: 55, + Rules.Reaction.IGNORE: 30, + Rules.Reaction.ARGUE: 15, + }, + } + profiles.append(performer) + + var influencer := NpcProfile.new() + influencer.id = "influencer" + influencer.display_name = "Influencer" + influencer.npc_type = Rules.NpcType.INFLUENCER + influencer.temperament = 0.5 + influencer.base_compliance = 0.5 + influencer.reactions_by_level = { + Rules.Level.SIMPLE_DETERRENCE: { + Rules.Reaction.COMPLY: 40, + Rules.Reaction.IGNORE: 30, + Rules.Reaction.RECORD: 20, + Rules.Reaction.ARGUE: 10, + }, + Rules.Level.FORMAL_WARNING: { + Rules.Reaction.RECORD: 45, + Rules.Reaction.ARGUE: 25, + Rules.Reaction.COMPLY: 30, + }, + } + influencer.dialogue_lines = { + Rules.Tone.SUSPICIOUS: [ + "This is public sidewalk, you can't stop me from filming.", + ], + } + profiles.append(influencer) + + # --- Higher-risk crowd ------------------------------------------------------ + var intoxicated := NpcProfile.new() + intoxicated.id = "intoxicated" + intoxicated.display_name = "Intoxicated Pedestrian" + intoxicated.npc_type = Rules.NpcType.INTOXICATED + intoxicated.temperament = 0.6 + intoxicated.base_compliance = 0.4 + intoxicated.reaction_delay = 1.8 + intoxicated.reactions_by_level = { + Rules.Level.SIMPLE_DETERRENCE: { + Rules.Reaction.COMPLY: 30, + Rules.Reaction.IGNORE: 50, + Rules.Reaction.ARGUE: 20, + }, + Rules.Level.FORMAL_WARNING: { + Rules.Reaction.COMPLY: 40, + Rules.Reaction.ARGUE: 35, + Rules.Reaction.RECORD: 10, + Rules.Reaction.IGNORE: 15, + }, + } + profiles.append(intoxicated) + + var trespasser := NpcProfile.new() + trespasser.id = "trespasser" + trespasser.display_name = "Trespasser" + trespasser.npc_type = Rules.NpcType.TRESPASSER + trespasser.temperament = 0.55 + trespasser.base_compliance = 0.35 + trespasser.reactions_by_level = { + Rules.Level.SIMPLE_DETERRENCE: { + Rules.Reaction.IGNORE: 45, + Rules.Reaction.ARGUE: 25, + Rules.Reaction.COMPLY: 30, + }, + Rules.Level.FORMAL_WARNING: { + Rules.Reaction.ARGUE: 35, + Rules.Reaction.RETURN_LATER: 25, + Rules.Reaction.COMPLY: 40, + }, + Rules.Level.BLOCKING: { + Rules.Reaction.LEAVE: 40, + Rules.Reaction.ARGUE: 30, + Rules.Reaction.RETURN_LATER: 20, + Rules.Reaction.FORCE_ENTRY: 10, + }, + Rules.Level.RADIO_ASSIST: { + Rules.Reaction.LEAVE: 70, + Rules.Reaction.FORCE_ENTRY: 30, + }, + Rules.Level.EMERGENCY: { + Rules.Reaction.FORCE_ENTRY: 20, + Rules.Reaction.THREATEN: 50, + 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.", + ], + } + profiles.append(trespasser) + + var mental_crisis := NpcProfile.new() + mental_crisis.id = "mental_crisis" + mental_crisis.display_name = "Person in Crisis" + mental_crisis.npc_type = Rules.NpcType.MENTAL_CRISIS + mental_crisis.temperament = 0.7 + mental_crisis.base_compliance = 0.3 + mental_crisis.reaction_delay = 2.4 + mental_crisis.reactions_by_level = { + Rules.Level.SIMPLE_DETERRENCE: { + Rules.Reaction.IGNORE: 55, + Rules.Reaction.COMPLY: 25, + Rules.Reaction.ARGUE: 20, + }, + Rules.Level.FORMAL_WARNING: { + Rules.Reaction.IGNORE: 30, + Rules.Reaction.ARGUE: 35, + Rules.Reaction.COMPLY: 25, + Rules.Reaction.THREATEN: 10, + }, + Rules.Level.BLOCKING: { + Rules.Reaction.THREATEN: 30, + Rules.Reaction.ARGUE: 30, + Rules.Reaction.COMPLY: 25, + Rules.Reaction.LEAVE: 15, + }, + Rules.Level.RADIO_ASSIST: { + Rules.Reaction.LEAVE: 40, + Rules.Reaction.THREATEN: 35, + Rules.Reaction.FORCE_ENTRY: 25, + }, + Rules.Level.EMERGENCY: { + Rules.Reaction.FORCE_ENTRY: 15, + Rules.Reaction.THREATEN: 60, + Rules.Reaction.LEAVE: 25, + }, + } + profiles.append(mental_crisis) + + var violent_threat := NpcProfile.new() + violent_threat.id = "violent_threat" + violent_threat.display_name = "Violent Threat" + violent_threat.npc_type = Rules.NpcType.VIOLENT_THREAT + violent_threat.temperament = 0.95 + violent_threat.base_compliance = 0.05 + violent_threat.reactions_by_level = { + Rules.Level.SIMPLE_DETERRENCE: { + Rules.Reaction.THREATEN: 30, + Rules.Reaction.IGNORE: 45, + Rules.Reaction.ARGUE: 25, + }, + Rules.Level.FORMAL_WARNING: { + Rules.Reaction.THREATEN: 45, + Rules.Reaction.FORCE_ENTRY: 25, + Rules.Reaction.ARGUE: 30, + }, + Rules.Level.BLOCKING: { + Rules.Reaction.FORCE_ENTRY: 35, + Rules.Reaction.THREATEN: 45, + Rules.Reaction.LEAVE: 20, + }, + Rules.Level.RADIO_ASSIST: { + Rules.Reaction.FORCE_ENTRY: 55, + Rules.Reaction.THREATEN: 30, + Rules.Reaction.LEAVE: 15, + }, + Rules.Level.EMERGENCY: { + Rules.Reaction.FORCE_ENTRY: 30, + Rules.Reaction.THREATEN: 40, + Rules.Reaction.LEAVE: 30, + }, + } + profiles.append(violent_threat) + + # --- Authorized crowd --------------------------------------------------------- + var staff := NpcProfile.new() + staff.id = "authorized_staff" + staff.display_name = "Staff Member" + staff.npc_type = Rules.NpcType.AUTHORIZED_STAFF + staff.temperament = 0.15 + staff.base_compliance = 1.0 + staff.default_reactions = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 100 } } + profiles.append(staff) + + var guest := NpcProfile.new() + guest.id = "authorized_guest" + guest.display_name = "Authorized Guest" + guest.npc_type = Rules.NpcType.AUTHORIZED_GUEST + guest.temperament = 0.2 + guest.base_compliance = 0.95 + guest.default_reactions = { + Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 92, Rules.Reaction.ARGUE: 8 }, + Rules.Level.FORMAL_WARNING: { Rules.Reaction.COMPLY: 88, Rules.Reaction.ARGUE: 12 }, + } + profiles.append(guest) + + return profiles + +# --- Venue builders -------------------------------------------------------------- + +static func hollywood_building() -> VenueScenario: + var scenario := VenueScenario.new() + scenario.id = "hollywood_building" + scenario.display_name = "Hollywood Boulevard Building" + scenario.description = "Steps and entrance of a private building off Hollywood Boulevard." + scenario.shift_duration_s = 900.0 + + var policy := AccessPolicy.new() + policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true } + policy.policy_summary = PLAN_ACCESS_SUMMARY_BUILDING + scenario.access_policy = policy + + scenario.equipment = ["wristband_policy", "flashlight", "radio"] + scenario.locations = { + "guard_post": Vector3(0, 0, -3), + "entrance": Vector3(0, 0, 0), + "steps_end": Vector3(0, 0, 3), + "sidewalk": Vector3(0, 0, 9), + "queue_area": Vector3(0, 0, 6), + } + + scenario.shift_phases = { + Rules.Phase.EARLY: {"start_s": 0.0}, + Rules.Phase.MIDDAY: {"start_s": 300.0}, + Rules.Phase.EVENING: {"start_s": 600.0}, + } + + scenario.npc_profiles = _filter_profiles([ + "tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest", + "street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat", + ]) + + scenario.event_pool = [ + _building_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35), + _building_routine("tourist_sit", Rules.NpcType.TOURIST, "sit", Vector2(0, 900), 0.20), + _building_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25), + _building_routine("delivery_drop", Rules.NpcType.DELIVERY, "deliver", Vector2(0, 600), 0.30), + _building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35), + _building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25), + _building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.18), + _building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.20), + _building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15), + _building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.12), + { + "id": "crisis_event", + "phase": Rules.Phase.EVENING, + "kind": "scripted", + "npc_type": Rules.NpcType.MENTAL_CRISIS, + "intent": "crisis", + "time_range_s": Vector2(640, 740), + "probability": 0.5, + "base_weight": 1.0, + }, + { + "id": "violent_emergency_event", + "phase": Rules.Phase.LATE, + "kind": "scripted", + "npc_type": Rules.NpcType.VIOLENT_THREAT, + "intent": "threat", + "time_range_s": Vector2(760, 880), + "probability": 0.28, + "base_weight": 1.0, + "requires_emergency": true, + }, + ] + return scenario + +static func joke_shop() -> VenueScenario: + var scenario := VenueScenario.new() + scenario.id = "joke_shop" + scenario.display_name = "The Joke Shop" + scenario.description = "Comedy club door: IDs, reservations, and wand screening." + scenario.shift_duration_s = 900.0 + + var policy := AccessPolicy.new() + policy.required_artifacts = { + Rules.AccessKind.ID: true, + Rules.AccessKind.TICKET: true, + } + policy.minimum_age = 21 + policy.require_id = true + policy.wand_screening_required = true + policy.policy_summary = PLAN_ACCESS_SUMMARY_JOKE_SHOP + scenario.access_policy = policy + + scenario.equipment = ["id_check", "guest_list", "wand", "radio"] + scenario.locations = { + "guard_post": Vector3(0, 0, -3), + "entrance": Vector3(0, 0, 0), + "door_area": Vector3(0, 0, 3), + "queue_area": Vector3(0, 0, 9), + } + + scenario.shift_phases = { + Rules.Phase.EARLY: {"start_s": 0.0}, + Rules.Phase.MIDDAY: {"start_s": 300.0}, + Rules.Phase.EVENING: {"start_s": 600.0}, + } + + scenario.npc_profiles = _filter_profiles([ + "tourist", "confused_visitor", "authorized_guest", "authorized_staff", + "influencer", "intoxicated", "trespasser", "violent_threat", + ]) + + scenario.event_pool = [ + _joke_guest("guest_valid", true, true, Vector2(0, 900), 0.30), + _joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18), + _joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12), + _joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12), + _joke_staff_entry(Vector2(0, 900), 0.25), + _joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15), + _joke_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.15), + { + "id": "joke_disruptive", + "phase": Rules.Phase.EVENING, + "kind": "scripted", + "npc_type": Rules.NpcType.INTOXICATED, + "intent": "force_entry", + "time_range_s": Vector2(650, 830), + "probability": 0.5, + "base_weight": 1.0, + }, + ] + return scenario + +static func tv_studio() -> VenueScenario: + var scenario := VenueScenario.new() + scenario.id = "tv_studio" + scenario.display_name = "Hollywood Blvd Late Night TV Studio" + scenario.description = "Crowd, celebrities, and staff around a late-night studio." + scenario.shift_duration_s = 900.0 + + var policy := AccessPolicy.new() + # OR-style admission: credential, wristband, guest list, or audience ticket. + policy.required_artifacts = { + Rules.AccessKind.CREDENTIAL: true, + Rules.AccessKind.WRISTBAND: true, + Rules.AccessKind.TICKET: true, + Rules.AccessKind.GUEST_LIST: true, + } + policy.grant_if_any_required = true + policy.policy_summary = PLAN_ACCESS_SUMMARY_TV_STUDIO + scenario.access_policy = policy + + scenario.equipment = ["wristband_policy", "guest_list", "radio", "flashlight"] + scenario.locations = { + "guard_post": Vector3(0, 0, -3), + "entrance": Vector3(0, 0, 0), + "stage_door": Vector3(6, 0, -2), + "queue_area": Vector3(0, 0, 9), + } + + scenario.shift_phases = { + Rules.Phase.EARLY: {"start_s": 0.0}, + Rules.Phase.MIDDAY: {"start_s": 300.0}, + Rules.Phase.EVENING: {"start_s": 600.0}, + } + + scenario.npc_profiles = _filter_profiles([ + "tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest", + "street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat", + ]) + + scenario.event_pool = [ + _studio_routine("audience_ticket", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.35), + _studio_routine("celebrity_arrival", Rules.NpcType.AUTHORIZED_GUEST, "arrive", Vector2(300, 700), 0.20), + _studio_routine("crew_shift", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 700), 0.30), + _studio_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35), + _studio_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25), + _studio_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(0, 900), 0.28), + _studio_routine("trespass_blend", Rules.NpcType.TRESPASSER, "blend_in", Vector2(300, 900), 0.15), + { + "id": "studio_counterfeit", + "phase": Rules.Phase.EVENING, + "kind": "scripted", + "npc_type": Rules.NpcType.TRESPASSER, + "intent": "counterfeit_credential", + "time_range_s": Vector2(620, 780), + "probability": 0.55, + "base_weight": 1.0, + }, + { + "id": "studio_follow_through", + "phase": Rules.Phase.EVENING, + "kind": "scripted", + "npc_type": Rules.NpcType.TRESPASSER, + "intent": "follow_authorized", + "time_range_s": Vector2(700, 860), + "probability": 0.45, + "base_weight": 1.0, + }, + ] + return scenario + +# --- Profile helpers ------------------------------------------------------------- + +static func _filter_profiles(ids: Array) -> Array[NpcProfile]: + var all := shared_profiles() + var out: Array[NpcProfile] = [] + for profile in all: + if ids.has(profile.id): + out.append(profile) + return out + +static func profile_dictionary() -> Dictionary: + ## id -> profile for tests and UI lookups. + var out := {} + for p in shared_profiles(): + out[p.id] = p + return out + +# --- Event builders ---------------------------------------------------------------- + +static func _building_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary: + return { + "id": id, "phase": _phase_for_range(time_range, 900.0), + "kind": "routine", "npc_type": npc_type, "intent": intent, + "time_range_s": time_range, "probability": prob, "base_weight": 1.0, + } + +static func _joke_guest(id: String, has_ticket: bool, has_id: bool, time_range: Vector2, prob: float) -> Dictionary: + return { + "id": id, "phase": _phase_for_range(time_range, 900.0), + "kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": "enter", + "time_range_s": time_range, "probability": prob, "base_weight": 1.0, + "override_access": {"has_ticket": has_ticket, "has_id": has_id}, + } + +static func _joke_staff_entry(time_range: Vector2, prob: float) -> Dictionary: + return { + "id": "joke_staff", "phase": _phase_for_range(time_range, 900.0), + "kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_STAFF, "intent": "enter", + "time_range_s": time_range, "probability": prob, "base_weight": 1.0, + } + +static func _joke_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary: + return _building_routine(id, npc_type, intent, time_range, prob) + +static func _studio_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary: + return _building_routine(id, npc_type, intent, time_range, prob) + +static func _phase_for_range(time_range: Vector2, duration: float) -> int: + var start := time_range.x / duration + if start < 0.34: + return Rules.Phase.EARLY + if start < 0.67: + return Rules.Phase.MIDDAY + return Rules.Phase.EVENING diff --git a/src/core/access_artifact.gd b/src/core/access_artifact.gd new file mode 100644 index 0000000..4312421 --- /dev/null +++ b/src/core/access_artifact.gd @@ -0,0 +1,22 @@ +class_name AccessArtifact +extends Resource + +const Rules := preload("res://src/core/rules.gd") +## An item or piece of evidence used to verify entry (PLAN.md: Authorized Access). + +@export var kind: int = Rules.AccessKind.NONE +@export var status: int = Rules.ArtifactStatus.MISSING + +## Human-readable attributes the player can inspect, e.g. color, pattern, date, +## event marking, DOB, name, ticket section. Populated by scenario content. +@export var attributes: Dictionary = {} + +func _init(p_kind: int = Rules.AccessKind.NONE, p_status: int = Rules.ArtifactStatus.MISSING, p_attributes: Dictionary = {}) -> void: + kind = p_kind + status = p_status + attributes = p_attributes + +func summary() -> String: + var kind_name: String = Rules.ACCESS_KIND_NAMES.get(kind, "None") + var status_name: String = Rules.ARTIFACT_STATUS_NAMES.get(status, "Unknown") + return "%s (%s)" % [kind_name, status_name] diff --git a/src/core/access_policy.gd b/src/core/access_policy.gd new file mode 100644 index 0000000..0d81f0e --- /dev/null +++ b/src/core/access_policy.gd @@ -0,0 +1,32 @@ +class_name AccessPolicy +extends Resource +## Venue admission rules (PLAN.md: access rules per venue). + +## Minimum required artifacts. For the Hollywood Blvd building this may be +## { WRISTBAND: true }. The Joke Shop may require { ID: true, TICKET: true }. +@export var required_artifacts: Dictionary = {} + +## OR-admission: when true, entry is granted as soon as ANY required artifact +## kind is valid (e.g. TV studio). When false, every kind must be satisfied. +@export var grant_if_any_required: bool = false + +## Artifact kinds accepted as OR-equivalent: e.g. { GUEST_LIST: true } when a +## reservation list can replace a ticket. If true, the verifier grants when any +## listed kind is valid. +@export_range(1, 8) var minimum_age: int = 0 + +## Wether identification is required or only checked when present. +@export var require_id: bool = false + +@export var wand_screening_required: bool = false + +## Displayed rules summary used by dialogue/UI. +@export var policy_summary: String = "" + +func requires(kind: int) -> bool: + return required_artifacts.get(kind, false) + +func description() -> String: + if not policy_summary.is_empty(): + return policy_summary + return str(required_artifacts) diff --git a/src/core/access_verifier.gd b/src/core/access_verifier.gd new file mode 100644 index 0000000..0c2ef93 --- /dev/null +++ b/src/core/access_verifier.gd @@ -0,0 +1,123 @@ +class_name AccessVerifier +extends RefCounted + +const Rules := preload("res://src/core/rules.gd") +const AccessPolicy := preload("res://src/core/access_policy.gd") +const AccessArtifact := preload("res://src/core/access_artifact.gd") +## Evaluates access artifacts against a venue AccessPolicy. +## Pure logic: no UI, no Node dependency, fully headless-testable. + +## Result dictionary contract: +## { granted: bool, reasons: Array[String], issues: Array[String], +## decisive_artifacts: Array[String], checked_artifacts: Array[String] } + +func _init() -> void: + pass + +## Verify a single person's set of artifacts (person_artifacts: Array[AccessArtifact]). +func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary: + var reasons: Array[String] = [] + var issues: Array[String] = [] + var checked: Array[String] = [] + + for artifact in person_artifacts: + if not (artifact is AccessArtifact): + continue + checked.append(artifact.summary()) + if artifact.kind == Rules.AccessKind.RECOGNITION and artifact.status == Rules.ArtifactStatus.VALID: + reasons.append("Recognized by guard") + elif artifact.status == Rules.ArtifactStatus.VALID: + reasons.append("Valid %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact")) + elif artifact.status == Rules.ArtifactStatus.EXPIRED: + issues.append("Expired %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact")) + elif artifact.status == Rules.ArtifactStatus.COUNTERFEIT: + issues.append("Suspected counterfeit %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact")) + elif artifact.status == Rules.ArtifactStatus.MISMATCHED: + issues.append("%s does not match person" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact")) + elif artifact.status == Rules.ArtifactStatus.AMBIGUOUS: + issues.append("Ambiguous %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact")) + elif artifact.status == Rules.ArtifactStatus.MISSING: + issues.append("Missing %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact")) + + # Age check. + if policy.minimum_age > 0 and require_age_info(person_artifacts, policy): + var age := person_age(person_artifacts) + if age < policy.minimum_age: + issues.append("Underage (%d < %d)" % [age, policy.minimum_age]) + return _result(false, reasons, issues, checked) + + # Wand screening is processed by the interaction layer (false alarms), but a + # policy can hard-require it; a refused screen blocks entry. + if policy.wand_screening_required and not person_screened(person_artifacts): + issues.append("Screening required") + return _result(false, reasons, issues, checked) + + # Recognition short-circuits if present and valid. + var recognized := false + for artifact in person_artifacts: + if artifact is AccessArtifact: + if artifact.kind == Rules.AccessKind.RECOGNITION and artifact.status == Rules.ArtifactStatus.VALID: + recognized = true + if recognized: + return _result(true, reasons, issues, checked) + + # Every required artifact kind must have a valid match (AND admission), + # unless the policy grants on any single valid artifact (OR admission). + var any_satisfied := false + for kind in policy.required_artifacts.keys(): + var satisfied := false + for artifact in person_artifacts: + if artifact is AccessArtifact and artifact.kind == kind: + if artifact.status == Rules.ArtifactStatus.VALID: + satisfied = true + any_satisfied = any_satisfied or satisfied + if not satisfied: + issues.append("Missing valid %s" % Rules.ACCESS_KIND_NAMES.get(kind, "credential")) + if not policy.grant_if_any_required: + return _result(false, reasons, issues, checked) + if policy.grant_if_any_required and not any_satisfied: + return _result(false, reasons, issues, checked) + + if policy.require_id: + var id_ok := false + for artifact in person_artifacts: + if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.ID and artifact.status == Rules.ArtifactStatus.VALID: + id_ok = true + if not id_ok: + issues.append("Valid ID required") + return _result(false, reasons, issues, checked) + + return _result(true, reasons, issues, checked) + +func _result(granted: bool, reasons: Array, issues: Array, checked: Array) -> Dictionary: + return { + "granted": granted, + "reasons": reasons, + "issues": issues, + "decisive_artifacts": reasons.duplicate(), + "checked_artifacts": checked, + } + +func require_age_info(person_artifacts: Array, policy: AccessPolicy) -> bool: + if policy.minimum_age <= 0: + return false + var id_present := false + for artifact in person_artifacts: + if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.ID: + id_present = true + return id_present + +func person_age(person_artifacts: Array) -> int: + for artifact in person_artifacts: + if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.ID: + var age: Variant = artifact.attributes.get("age", -1) + if age is int and age > 0: + return age + return -1 + +func person_screened(person_artifacts: Array) -> bool: + for artifact in person_artifacts: + if artifact is AccessArtifact: + if artifact.kind == Rules.AccessKind.CREDENTIAL and artifact.attributes.get("screened", false) == true: + return true + return false diff --git a/src/core/crowd_system.gd b/src/core/crowd_system.gd new file mode 100644 index 0000000..d1a3cfd --- /dev/null +++ b/src/core/crowd_system.gd @@ -0,0 +1,53 @@ +class_name CrowdSystem +extends RefCounted +## Models crowd mood and public reaction (PLAN.md: Public Crowd Reaction). + +const EventBus := preload("res://src/core/event_bus.gd") +## mood: 0.0 (calm) .. 1.0 (panic). attention: how many people are watching. + +signal changed(payload: Dictionary) + +var event_bus: EventBus = null +var mood: float = 0.05 +var attention: float = 0.0 +var crowd_panic := false + +func set_event_bus(bus: EventBus) -> void: + event_bus = bus + +## Apply a crowd event. type: "calm_professionalism", "aggressive_behavior", +## "visible_weapon", "emergency_responder", "de_escalation". +func apply_event(event_type: String) -> void: + match event_type: + "calm_professionalism": + attention = maxf(0.0, attention - 0.05) + mood = maxf(0.0, mood - 0.02) + "de_escalation": + mood = maxf(0.0, mood - 0.06) + attention = clampf(attention + 0.05, 0.0, 1.0) + "aggressive_behavior": + attention = clampf(attention + 0.20, 0.0, 1.0) + mood = clampf(mood + 0.12, 0.0, 1.0) + "visible_weapon": + attention = 1.0 + mood = clampf(mood + 0.35, 0.0, 1.0) + "emergency_responder": + attention = 1.0 + mood = clampf(mood + 0.25, 0.0, 1.0) + _: + pass + crowd_panic = mood >= 0.75 + if event_bus: + event_bus.crowd_changed.emit({ + "mood": mood, + "attention": attention, + "panic": crowd_panic, + "event": event_type, + }) + +func summary() -> Dictionary: + return { + "mood": mood, + "attention": attention, + "panic": crowd_panic, + } diff --git a/src/core/escalation_manager.gd b/src/core/escalation_manager.gd new file mode 100644 index 0000000..5f3efd2 --- /dev/null +++ b/src/core/escalation_manager.gd @@ -0,0 +1,37 @@ +class_name EscalationManager +extends RefCounted + +const Rules := preload("res://src/core/rules.gd") +## Tracks the current player escalation level for an interaction and which +## actions are allowed. Rules derive from VenueScenario + NpcProfile. + +var current_level: int = Rules.Level.OBSERVATION +var max_allowed_level: int = Rules.Level.EMERGENCY +var _history: Array[Dictionary] = [] + +## Reasons must be short rule strings, e.g. "refused_polite_direction". +func _init(p_max_level: int = Rules.Level.EMERGENCY) -> void: + max_allowed_level = p_max_level + +func can_act(level: int) -> bool: + return level >= 0 and level <= max_allowed_level + +## Move to a target level if it is allowed. Actions can jump directly +## (e.g. skipping straight to a warning) but never above max_allowed_level. +func advance_to(level: int, reason: String) -> bool: + if not can_act(level): + return false + if level < current_level: + # De-escalation is allowed (level can drop if the NPC complies). + current_level = level + else: + current_level = level + _history.append({ + "level": level, + "reason": reason, + "at": Time.get_ticks_msec(), + }) + return true + +func history() -> Array[Dictionary]: + return _history diff --git a/src/core/event_bus.gd b/src/core/event_bus.gd new file mode 100644 index 0000000..27f1868 --- /dev/null +++ b/src/core/event_bus.gd @@ -0,0 +1,17 @@ +class_name EventBus +extends RefCounted +## Instance-based event bus (ADR-0005 spirit). Owned by ShiftCoordinator and +## passed to subsystems that need to notify observers. Subsystems emit only if +## a bus is assigned, so headless tests never depend on global state. + +signal incident_recorded(incident: Dictionary) +signal player_action(action: Dictionary) +signal escalation_changed(payload: Dictionary) +signal radio_call(call: Dictionary) +signal responder_arrived(responder: Dictionary) +signal npc_state_changed(payload: Dictionary) +signal shift_state_changed(payload: Dictionary) +signal reputation_changed(payload: Dictionary) +signal crowd_changed(payload: Dictionary) +signal access_decided(payload: Dictionary) +signal shift_review_ready(review: Dictionary) diff --git a/src/core/event_scheduler.gd b/src/core/event_scheduler.gd new file mode 100644 index 0000000..a048215 --- /dev/null +++ b/src/core/event_scheduler.gd @@ -0,0 +1,68 @@ +class_name EventScheduler +extends RefCounted + +const Rules := preload("res://src/core/rules.gd") +const VenueScenario := preload("res://src/core/venue_scenario.gd") +## Schedules routine traffic and scripted events for a shift according to the +## scenario's event pool (ADR-0001, Shift Structure). + +signal event_ready(event_data: Dictionary) + +var rng := RandomNumberGenerator.new() +var _scenario: VenueScenario = null +var _weighted_pool: Array[Dictionary] = [] + +func _init() -> void: + pass + +func setup(scenario: VenueScenario, seed_value: int = 0) -> void: + _scenario = scenario + if seed_value != 0: + rng.seed = seed_value + _weighted_pool = [] + for event in scenario.event_pool: + if event.get("enabled", true): + _weighted_pool.append(event) + +## Pull events due for the current elapsed time and phase. +## Returns Array[Dictionary] of event data to spawn. +func pull_due(elapsed_s: float, phase: int) -> Array[Dictionary]: + var due: Array[Dictionary] = [] + if _scenario == null: + return due + for event in _weighted_pool: + var time_range: Vector2 = event.get("time_range_s", Vector2(0, _scenario.shift_duration_s)) + if elapsed_s < time_range.x or elapsed_s > time_range.y: + continue + if event.get("phase", -1) != phase: + continue + var prob: float = event.get("probability", 0.0) + var base: float = event.get("base_weight", 1.0) + var spawn_rate: float = event.get("spawn_rate", 0.0) + if spawn_rate > 0.0: + # Rate-based spawning: emit at most one per second window. + if rng.randf() < spawn_rate: + if rng.randf() < prob: + due.append(event.duplicate()) + else: + if rng.randf() < prob * base: + due.append(event.duplicate()) + return due + +func fallback_routine_traffic(phase: int) -> Array[Dictionary]: + ## Cheap routine traffic so the street never feels empty. + var out: Array[Dictionary] = [] + var pool := _routine_pool(phase) + if pool.is_empty(): + return out + for i in 3: + if rng.randf() < 0.35: + out.append(pool[rng.randi_range(0, pool.size() - 1)].duplicate()) + return out + +func _routine_pool(phase: int) -> Array: + var out: Array = [] + for event in _weighted_pool: + if event.get("kind", "routine") == "routine" and event.get("phase", -1) == phase: + out.append(event) + return out diff --git a/src/core/fsm.gd b/src/core/fsm.gd new file mode 100644 index 0000000..b97bb17 --- /dev/null +++ b/src/core/fsm.gd @@ -0,0 +1,47 @@ +class_name FSM +extends RefCounted +## Minimal generic finite state machine used by NPCController and ShiftCoordinator. +## Deterministic and headless-testable. + +signal state_changed(node: Object, from_state: String, to_state: String) + +var current_state: String = "" +var _transitions: Dictionary = {} # state_name -> Array[{to, guard: Callable}] +var _owner_node: Object = null + +func _init(owner_node: Object = null) -> void: + _owner_node = owner_node + +## Register a transition. guard is an optional Callable(node) -> bool. +func add_transition(from_state: String, to_state: String, guard: Callable = Callable()) -> void: + if not _transitions.has(from_state): + _transitions[from_state] = [] + _transitions[from_state].append({"to": to_state, "guard": guard}) + +func start(initial_state: String) -> void: + current_state = initial_state + state_changed.emit(_owner_node, "", initial_state) + +## Attempt to move to `to_state`. Returns true when executed. +func transition_to(to_state: String) -> bool: + if current_state == to_state: + return false + var enabled: Array = _transitions.get(current_state, []) + for t in enabled: + if t["to"] != to_state: + continue + var guard: Callable = t["guard"] + if not guard.is_valid() or guard.call(_owner_node): + var from := current_state + current_state = to_state + state_changed.emit(_owner_node, from, to_state) + return true + return false + +func can_transition_to(to_state: String) -> bool: + for t in _transitions.get(current_state, []): + if t["to"] == to_state: + var guard: Callable = t["guard"] + if not guard.is_valid() or guard.call(_owner_node): + return true + return false diff --git a/src/core/incident_recorder.gd b/src/core/incident_recorder.gd new file mode 100644 index 0000000..aee1053 --- /dev/null +++ b/src/core/incident_recorder.gd @@ -0,0 +1,85 @@ +class_name IncidentRecorder +extends RefCounted + +const EventBus := preload("res://src/core/event_bus.gd") + +var event_bus: EventBus = null + +func set_event_bus(bus: EventBus) -> void: + event_bus = bus +## Structured incident history (ADR-0005). Records observations, player actions, +## access evidence, escalation levels, responses, and outcomes for evidence, +## reputation, crowd reactions, and shift review. + +var incidents: Array[Dictionary] = [] +var _open_incidents: Dictionary = {} # npc_id -> incident dict + +## Begin an incident for an NPC. Returns incident_id. +func start_incident(npc_id: String, trigger: String) -> String: + var incident := { + "id": "inc_%s_%d" % [npc_id, Time.get_ticks_usec()], + "npc_id": npc_id, + "trigger": trigger, + "opened_at_ms": Time.get_ticks_msec(), + "events": [], + "access_evidence": [], + "escalation_history": [], + "outcome": null, + } + _open_incidents[npc_id] = incident + return incident["id"] + +func add_event(npc_id: String, event: Dictionary) -> void: + if not _open_incidents.has(npc_id): + return + var incident: Dictionary = _open_incidents[npc_id] + event["at_ms"] = Time.get_ticks_msec() + incident["events"].append(event) + if event_bus: + event_bus.incident_recorded.emit(incident.duplicate(true)) + +func add_access_evidence(npc_id: String, evidence: Dictionary) -> void: + if not _open_incidents.has(npc_id): + return + var incident: Dictionary = _open_incidents[npc_id] + incident["access_evidence"].append(evidence) + if event_bus: + event_bus.access_decided.emit(evidence.duplicate(true)) + incident["events"].append({ + "type": "access_check", + "at_ms": Time.get_ticks_msec(), + "evidence": evidence, + }) + +func add_escalation(npc_id: String, level: int, reason: String) -> void: + if not _open_incidents.has(npc_id): + return + var incident: Dictionary = _open_incidents[npc_id] + incident["escalation_history"].append({ + "level": level, + "reason": reason, + "at_ms": Time.get_ticks_msec(), + }) + +## Close an incident. outcome: e.g. "peaceful", "escort_off_property", +## "assisted", "responded", "custody". Returns the closed incident. +func close_incident(npc_id: String, outcome: String) -> Dictionary: + if not _open_incidents.has(npc_id): + return {} + var incident: Dictionary = _open_incidents[npc_id] + incident["outcome"] = outcome + incident["closed_at_ms"] = Time.get_ticks_msec() + incidents.append(incident) + _open_incidents.erase(npc_id) + if event_bus: + event_bus.incident_recorded.emit(incident.duplicate(true)) + return incident + +func open_incidents_count() -> int: + return _open_incidents.size() + +func to_dict() -> Dictionary: + return { + "incidents": incidents.duplicate(true), + "open_count": _open_incidents.size(), + } diff --git a/src/core/npc_controller.gd b/src/core/npc_controller.gd new file mode 100644 index 0000000..60dad22 --- /dev/null +++ b/src/core/npc_controller.gd @@ -0,0 +1,115 @@ +class_name NPCController +extends RefCounted + +const Rules := preload("res://src/core/rules.gd") +const NpcProfile := preload("res://src/core/npc_profile.gd") +const FSM := preload("res://src/core/fsm.gd") +## Simulation-side NPC (PLAN.md: NPC Behavior Types). Owns intent, an FSM, and +## 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) + +var npc_id: String = "" +var profile: NpcProfile = null +var intent: String = "roam" # enter | photo | sit | roam | deliver | film | trespass +var fsm: FSM = null +var rng := RandomNumberGenerator.new() + +var highest_escalation_received: int = Rules.Level.OBSERVATION +var leave_requested := false +var reaction: int = Rules.Reaction.COMPLY +var reaction_set := false + +func _init() -> void: + fsm = FSM.new(self) + +## initial_state: APPROACH, AT_ENTRANCE, LOITERING, QUEUE, ARGUING +func setup(p_id: String, p_profile: NpcProfile, p_intent: String = "roam", seed_value: int = 0, initial_state: String = "APPROACH") -> void: + npc_id = p_id + profile = p_profile + intent = p_intent + rng.seed = seed_value if seed_value != 0 else hash(npc_id) + _build_fsm(initial_state) + +func _build_fsm(initial_state: String) -> void: + fsm.add_transition("APPROACH", "AT_ENTRANCE") + fsm.add_transition("AT_ENTRANCE", "ARGUING") + fsm.add_transition("AT_ENTRANCE", "LEAVING") + fsm.add_transition("AT_ENTRANCE", "THREATENING") + fsm.add_transition("AT_ENTRANCE", "FORCING_ENTRY") + fsm.add_transition("APPROACH", "LEAVING") + fsm.add_transition("ARGUING", "LEAVING") + fsm.add_transition("ARGUING", "THREATENING") + fsm.add_transition("ARGUING", "FORCING_ENTRY") + fsm.add_transition("LEAVING", "LOITERING_DISTANT") # potential return later + fsm.add_transition("LOITERING_DISTANT", "APPROACH") + 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. + 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(): + total += weights[key] + if total <= 0.0: + return Rules.Reaction.ARGUE + var cursor := 0.0 + for key in weights.keys(): + cursor += float(weights[key]) + if roll_value < cursor / total: + return key + return Rules.Reaction.LEAVE + +func apply_player_action(level: int, tone: int) -> Dictionary: + ## Returns reaction to the player's action and updates FSM when needed. + var performed_reaction := roll_reaction(level, tone) + 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, + } + +func _update_state_for_reaction(r: int) -> void: + match r: + Rules.Reaction.COMPLY, Rules.Reaction.LEAVE: + leave_requested = true + fsm.transition_to("LEAVING") + Rules.Reaction.ARGUE, Rules.Reaction.RECORD: + fsm.transition_to("ARGUING") + Rules.Reaction.THREATEN: + fsm.transition_to("THREATENING") + Rules.Reaction.FORCE_ENTRY: + fsm.transition_to("FORCING_ENTRY") + Rules.Reaction.RETURN_LATER: + fsm.transition_to("LOITERING_DISTANT") + _: + pass + +func decide_leave() -> bool: + return leave_requested + +func to_dict() -> Dictionary: + return { + "id": npc_id, + "name": profile.display_name if profile else "", + "type": profile.npc_type if profile else -1, + "intent": intent, + "state": fsm.current_state, + "highest_escalation": highest_escalation_received, + "reaction": reaction, + } diff --git a/src/core/npc_profile.gd b/src/core/npc_profile.gd new file mode 100644 index 0000000..680e599 --- /dev/null +++ b/src/core/npc_profile.gd @@ -0,0 +1,70 @@ +class_name NpcProfile +extends Resource + +const Rules := preload("res://src/core/rules.gd") +## Authorable behavior profile for one kind of NPC (PLAN.md: NPC Behavior Types). + +@export var id: String = "" +@export var display_name: String = "" +@export var npc_type: int = Rules.NpcType.TOURIST + +## How difficult temperament is (0 = very calm, 1 = very hot-headed). +## 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. +@export_range(0.0, 1.0) var base_compliance: float = 0.8 + +## Probability weighting of the NP C's reaction per player escalation level. +## keys: escalation level int -> { ReactionEnum: weight } +## Missing entries fall back to the profile's `default_reactions`. +@export var reactions_by_level: Dictionary = {} + +@export var default_reactions: Dictionary = { + Rules.Level.SIMPLE_DETERRENCE: { + Rules.Reaction.COMPLY: 80, + Rules.Reaction.IGNORE: 12, + Rules.Reaction.ARGUE: 8, + }, + Rules.Level.FORMAL_WARNING: { + Rules.Reaction.COMPLY: 70, + Rules.Reaction.LEAVE: 10, + Rules.Reaction.ARGUE: 12, + Rules.Reaction.RECORD: 8, + }, + Rules.Level.BLOCKING: { + Rules.Reaction.COMPLY: 55, + Rules.Reaction.LEAVE: 15, + Rules.Reaction.ARGUE: 20, + Rules.Reaction.RECORD: 10, + }, + Rules.Level.RADIO_ASSIST: { + Rules.Reaction.LEAVE: 65, + Rules.Reaction.ARGUE: 15, + Rules.Reaction.FORCE_ENTRY: 20, + }, + Rules.Level.EMERGENCY: { + Rules.Reaction.FORCE_ENTRY: 30, + Rules.Reaction.THREATEN: 40, + Rules.Reaction.LEAVE: 30, + }, +} + +@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 = {} + +func pickup_reactions_per_level(level: int) -> Dictionary: + if reactions_by_level.has(level): + return reactions_by_level[level] + return default_reactions.get(level, {}) diff --git a/src/core/radio_system.gd b/src/core/radio_system.gd new file mode 100644 index 0000000..0b26c2a --- /dev/null +++ b/src/core/radio_system.gd @@ -0,0 +1,106 @@ +class_name RadioSystem +extends RefCounted + +const Rules := preload("res://src/core/rules.gd") +const EventBus := preload("res://src/core/event_bus.gd") +## Radio call handling (PLAN.md: Level 4 radio assist, Level 5 emergency). +## - Level 4: security assistance with a report form, arrival delay. +## - Level 5: emergency radio, hold-to-confirm enforced by calling layer, +## cooldown and battery limits to prevent spam. +## - Incorrect/exaggerated reports damage credibility (recorded via reporter). + +signal call_made(call_data: Dictionary) +signal responder_arrived(call_data: Dictionary) + +var battery: float = 100.0 +var max_battery: float = 100.0 +var emergency_cooldown_remaining: float = 0.0 +var emergency_cooldown_seconds: float = 120.0 +var battery_cost_emergency: float = 30.0 +var battery_cost_assist: float = 10.0 + +var accepting_calls := true +var event_bus: EventBus = null + +func set_event_bus(bus: EventBus) -> void: + event_bus = bus + +## report: { npc_id, location, description, behavior, weapons_seen, danger_level: 0..3 } +func request_assistance(report: Dictionary) -> Dictionary: + var result := { + "ok": false, + "level": Rules.Level.RADIO_ASSIST, + "reason": "", + "call": {}, + } + if not accepting_calls: + result.reason = "Radio not accepting calls" + return result + if battery < battery_cost_assist: + result.reason = "battery depleted" + return result + battery -= battery_cost_assist + var call := { + "type": "assist", + "report": report.duplicate(), + "placed_at_ms": Time.get_ticks_msec(), + "arrival_delay_s": 8.0, + "responder": "security", + } + result.ok = true + result.call = call + call_made.emit(call) + if event_bus: + event_bus.radio_call.emit(call) + return result + +## Emergency call. The caller (UI) must handle hold-to-confirm; this applies +## cooldown and battery limits. +func request_emergency(report: Dictionary) -> Dictionary: + var result := { + "ok": false, + "level": Rules.Level.EMERGENCY, + "reason": "", + "call": {}, + } + if not accepting_calls: + result.reason = "Radio not accepting calls" + return result + if emergency_cooldown_remaining > 0.0: + result.reason = "Cooldown active (%.1fs)" % emergency_cooldown_remaining + return result + if battery < battery_cost_emergency: + result.reason = "battery depleted" + return result + battery -= battery_cost_emergency + emergency_cooldown_remaining = emergency_cooldown_seconds + var call := { + "type": "emergency", + "report": report.duplicate(), + "placed_at_ms": Time.get_ticks_msec(), + "arrival_delay_s": 15.0, + "responder": "armed_responder", + } + result.ok = true + result.call = call + call_made.emit(call) + if event_bus: + event_bus.radio_call.emit(call) + return result + +## Report inaccuracies deserve a reputation hit. Caller uses this helper. +static func credibility_hits(report: Dictionary, truth: Dictionary) -> int: + var hits := 0 + if report.get("weapons_seen", false) != truth.get("weapons_seen", false): + hits += 1 + var danger: int = report.get("danger_level", 0) + var actual_danger: int = truth.get("danger_level", 0) + if danger != actual_danger: + hits += 1 + return hits + +## Advance cooldown; called by the simulation tick. +func tick(delta: float) -> void: + if emergency_cooldown_remaining > 0.0: + emergency_cooldown_remaining = maxf(0.0, emergency_cooldown_remaining - delta) + # Battery could slowly recharge over a shift; keep it static for now. diff --git a/src/core/reputation_evaluator.gd b/src/core/reputation_evaluator.gd new file mode 100644 index 0000000..28ddaf2 --- /dev/null +++ b/src/core/reputation_evaluator.gd @@ -0,0 +1,54 @@ +class_name ReputationEvaluator +extends RefCounted +## Evaluates player performance (PLAN.md: Reputation Meter). +## Scoring weights are data so they can be tuned per venue. + +const EventBus := preload("res://src/core/event_bus.gd") + +## rating: float starting 50 (out of 100). Errors subtract, though marks add. +var rating: float = 70.0 +var _modifiers: Array[Dictionary] = [] +var event_bus: EventBus = null + +func set_event_bus(bus: EventBus) -> void: + event_bus = bus + +## Apply a reputation change. +## category: "peaceful", "force", "false_report", "accuracy", "complaint", +## "security_failure", "property_damage", "access_judgment", "crowd" +func apply(_category: String, delta: float, reason: String) -> void: + # Clamp range: 0..100. + var clamped := clampf(rating + delta, 0.0, 100.0) + var applied: float = clamped - rating + rating = clamped + _modifiers.append({ + "category": _category, + "delta": applied, + "reason": reason, + "at_ms": Time.get_ticks_msec(), + }) + if event_bus: + event_bus.reputation_changed.emit({ + "rating": rating, + "delta": applied, + "reason": reason, + "category": _category, + }) + +static func grade_for(rating_value: float) -> String: + if rating_value >= 90.0: + return "A" + if rating_value >= 75.0: + return "B" + if rating_value >= 60.0: + return "C" + if rating_value >= 40.0: + return "D" + return "F" + +func summary() -> Dictionary: + return { + "rating": rating, + "grade": grade_for(rating), + "modifiers": _modifiers.duplicate(true), + } diff --git a/src/core/rules.gd b/src/core/rules.gd new file mode 100644 index 0000000..5051482 --- /dev/null +++ b/src/core/rules.gd @@ -0,0 +1,139 @@ +class_name Rules +## Shared constants and helpers for the simulation rules. + +func _init() -> void: + push_error("Rules is a static utility class and must not be instantiated.") + +## Escalation levels (0..5). Names must stay in sync with PLAN.md. +enum Level { + OBSERVATION = 0, + SIMPLE_DETERRENCE = 1, + FORMAL_WARNING = 2, + BLOCKING = 3, + RADIO_ASSIST = 4, + EMERGENCY = 5, +} + +const LEVEL_NAMES := { + Level.OBSERVATION: "Observation", + Level.SIMPLE_DETERRENCE: "Simple Deterrence", + Level.FORMAL_WARNING: "Formal Warning", + Level.BLOCKING: "Blocking", + Level.RADIO_ASSIST: "Radio Assist", + Level.EMERGENCY: "Emergency", +} + +## NPC behavioral archetypes from PLAN.md. +enum NpcType { + TOURIST, + PERFORMER, + INFLUENCER, + CONFUSED_VISITOR, + INTOXICATED, + TRESPASSER, + VIOLENT_THREAT, + MENTAL_CRISIS, # "51/50" style mental-health crisis character + AUTHORIZED_STAFF, + AUTHORIZED_GUEST, + DELIVERY, + RESPONDER, +} + +const NPC_TYPE_NAMES := { + NpcType.TOURIST: "Tourist", + NpcType.PERFORMER: "Street Performer", + NpcType.INFLUENCER: "Influencer", + NpcType.CONFUSED_VISITOR: "Confused Visitor", + NpcType.INTOXICATED: "Intoxicated Pedestrian", + NpcType.TRESPASSER: "Trespasser", + NpcType.VIOLENT_THREAT: "Violent Threat", + NpcType.MENTAL_CRISIS: "Mental Health Crisis", + NpcType.AUTHORIZED_STAFF: "Authorized Staff", + NpcType.AUTHORIZED_GUEST: "Authorized Guest", + NpcType.DELIVERY: "Delivery", + NpcType.RESPONDER: "Responder", +} + +## Access artifact kinds used by AccessVerifier. +enum AccessKind { + NONE = -1, + RECOGNITION = 0, + WRISTBAND = 1, + ID = 2, + TICKET = 3, + CREDENTIAL = 4, + GUEST_LIST = 5, +} + +const ACCESS_KIND_NAMES := { + AccessKind.RECOGNITION: "Recognition", + AccessKind.WRISTBAND: "Wristband", + AccessKind.ID: "ID", + AccessKind.TICKET: "Ticket", + AccessKind.CREDENTIAL: "Credential", + AccessKind.GUEST_LIST: "Guest List", +} + +## Artifact validity states. +enum ArtifactStatus { + VALID = 0, + EXPIRED = 1, + COUNTERFEIT = 2, + MISMATCHED = 3, + MISSING = 4, + AMBIGUOUS = 5, +} + +const ARTIFACT_STATUS_NAMES := { + ArtifactStatus.VALID: "Valid", + ArtifactStatus.EXPIRED: "Expired", + ArtifactStatus.COUNTERFEIT: "Counterfeit", + ArtifactStatus.MISMATCHED: "Mismatched", + ArtifactStatus.MISSING: "Missing", + ArtifactStatus.AMBIGUOUS: "Ambiguous", +} + +## NPC reaction outcomes after player actions. +enum Reaction { + COMPLY = 0, + LEAVE = 1, + ARGUE = 2, + IGNORE = 3, + RECORD = 4, + THREATEN = 5, + RETURN_LATER = 6, + FORCE_ENTRY = 7, +} + +## 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, + MIDDAY = 1, + EVENING = 2, + LATE = 3, +} + +const PHASE_NAMES := { + Phase.EARLY: "Early", + Phase.MIDDAY: "Midday", + Phase.EVENING: "Evening", + Phase.LATE: "Late", +} + +static func level_name(level: int) -> String: + return LEVEL_NAMES.get(level, "Unknown") diff --git a/src/core/shift_coordinator.gd b/src/core/shift_coordinator.gd new file mode 100644 index 0000000..6089b57 --- /dev/null +++ b/src/core/shift_coordinator.gd @@ -0,0 +1,135 @@ +class_name ShiftCoordinator +extends Node + +const Rules := preload("res://src/core/rules.gd") +const EventBus := preload("res://src/core/event_bus.gd") +const VenueScenario := preload("res://src/core/venue_scenario.gd") +const NpcProfile := preload("res://src/core/npc_profile.gd") +const IncidentRecorder := preload("res://src/core/incident_recorder.gd") +const ReputationEvaluator := preload("res://src/core/reputation_evaluator.gd") +const RadioSystem := preload("res://src/core/radio_system.gd") +const CrowdSystem := preload("res://src/core/crowd_system.gd") +const EventScheduler := preload("res://src/core/event_scheduler.gd") +## Drives one shift: advances time, schedules events, owns the shared systems +## (recorder, reputation, radio, crowd), and produces the shift review. + +signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String) +signal shift_started(payload: Dictionary) +signal shift_ended(review: Dictionary) +signal phase_changed(phase: int) + +var scenario: VenueScenario = null +var duration_s: float = 900.0 +var elapsed_s: float = 0.0 +var current_phase: int = Rules.Phase.EARLY +var running := false + +var event_bus := EventBus.new() +var recorder := IncidentRecorder.new() +var reputation := ReputationEvaluator.new() +var radio := RadioSystem.new() +var crowd := CrowdSystem.new() +var scheduler := EventScheduler.new() + +var spawned_count := 0 +var spawn_seed := 0 +var _shift_finished := false + +func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int = 0) -> void: + scenario = p_scenario + duration_s = p_duration_s + spawn_seed = p_seed + scheduler.setup(scenario, p_seed) + event_bus = EventBus.new() + radio = RadioSystem.new() + reputation = ReputationEvaluator.new() + crowd = CrowdSystem.new() + recorder.set_event_bus(event_bus) + radio.set_event_bus(event_bus) + reputation.set_event_bus(event_bus) + crowd.set_event_bus(event_bus) + +func start() -> void: + if scenario == null: + push_error("ShiftCoordinator.start() requires a scenario.") + return + running = true + _shift_finished = false + elapsed_s = 0.0 + current_phase = _phase_for_time(0.0) + event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase}) + shift_started.emit({"scenario_id": scenario.id, "duration_s": duration_s}) + +func stop() -> void: + running = false + _finish_shift() + +func tick(delta: float) -> void: + if not running: + return + elapsed_s += delta + radio.tick(delta) + var new_phase := _phase_for_time(elapsed_s) + if new_phase != current_phase: + current_phase = new_phase + phase_changed.emit(current_phase) + event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase}) + if elapsed_s >= duration_s: + _finish_shift() + return + _spawn_due(delta) + +func _phase_for_time(t: float) -> int: + if scenario == null or scenario.shift_phases.is_empty(): + return Rules.Phase.EARLY + var last_phase := Rules.Phase.EARLY + var keys := scenario.shift_phases.keys() + keys.sort() + for phase in keys: + var start_s: float = scenario.shift_phases[phase].get("start_s", 0.0) + if t >= start_s: + last_phase = phase + return last_phase + +func _spawn_due(delta: float) -> void: + var due := scheduler.pull_due(elapsed_s, current_phase) + var fallback := scheduler.fallback_routine_traffic(current_phase) + var all := due + all.append_array(fallback) + for event in all: + spawned_count += 1 + var npc_id := "%s_%d" % [scenario.id, spawned_count] + var profile := scenario.profile_for(event.get("npc_type", Rules.NpcType.TOURIST)) + if profile == null: + continue + npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id) + +func _finish_shift() -> void: + if _shift_finished: + return + _shift_finished = true + running = false + var review := build_review() + event_bus.shift_state_changed.emit({"running": false, "review": review}) + shift_ended.emit(review) + event_bus.shift_review_ready.emit(review) + +func build_review() -> Dictionary: + return { + "scenario_id": scenario.id if scenario else "", + "duration_s": duration_s, + "elapsed_s": elapsed_s, + "phase_at_end": current_phase, + "spawned_count": spawned_count, + "incidents": recorder.to_dict(), + "reputation": reputation.summary(), + "crowd": crowd.summary(), + "radio": { + "battery": radio.battery, + "emergency_cooldown_s": radio.emergency_cooldown_remaining, + }, + "completed_at_ms": Time.get_ticks_msec(), + } + +func spawn_context_dict(profile: NpcProfile, intent: String) -> Dictionary: + return {"profile": profile, "intent": intent} diff --git a/src/core/signal_bus.gd b/src/core/signal_bus.gd new file mode 100644 index 0000000..604ab97 --- /dev/null +++ b/src/core/signal_bus.gd @@ -0,0 +1,21 @@ +class_name SignalBus +extends RefCounted +## Static signal bus (ADR-0005 spirit: decoupled, deterministic). +## Unlike an autoload, `SignalBus` resolves at parse time through preloads, +## so it works identically under `--script`, in headless CI, and in the game. + +static signal incident_recorded(incident: Dictionary) +static signal player_action(action: Dictionary) +static signal escalation_changed(payload: Dictionary) +static signal radio_call(call: Dictionary) +static signal responder_arrived(responder: Dictionary) +static signal npc_state_changed(payload: Dictionary) +static signal shift_state_changed(payload: Dictionary) +static signal reputation_changed(payload: Dictionary) +static signal crowd_changed(payload: Dictionary) +static signal access_decided(payload: Dictionary) +static signal shift_review_ready(review: Dictionary) +static signal anything_happened(value: String) + +func _init() -> void: + push_error("SignalBus is a static utility class; use SignalBus. directly.") diff --git a/src/core/venue_scenario.gd b/src/core/venue_scenario.gd new file mode 100644 index 0000000..8c05c3b --- /dev/null +++ b/src/core/venue_scenario.gd @@ -0,0 +1,49 @@ +class_name VenueScenario +extends Resource + +const Rules := preload("res://src/core/rules.gd") +const AccessPolicy := preload("res://src/core/access_policy.gd") +const NpcProfile := preload("res://src/core/npc_profile.gd") +## Data-driven venue definition (ADR-0001). Provides locations, rules, +## NPC pools, event pools, equipment, and shift phases. + +@export var id: String = "" +@export var display_name: String = "" +@export_multiline var description: String = "" + +@export var access_policy: AccessPolicy = AccessPolicy.new() + +## Equipment available at this venue. +@export var equipment: Array[String] = [] # e.g. "wristband_policy", "id_check", "wand", "flashlight" + +## Named spots: "guard_post", "entrance", "steps", "sidewalk", "queue_area". +@export var locations: Dictionary = {} + +## NPC archetype definitions that appear in this scenario. +@export var npc_profiles: Array[NpcProfile] = [] + +## Event pool. Each event: { id, phase: Rules.Phase, kind: "routine"|"scripted", +## npc_type: int, intent: String, weight: float, probability: float, +## time_range_s: Vector2, requires_emergency: bool } +@export var event_pool: Array[Dictionary] = [] + +## Shift setup: phases map Rules.Phase -> { start_s, end_s, spawn_rate }. +@export var shift_phases: Dictionary = {} + +## Default duration of one shift in seconds (PLAN.md: 10-15 minutes). +@export var shift_duration_s: float = 900.0 + +@export var policy_summary: String = "" + +func profile_for(npc_type: int) -> NpcProfile: + for p in npc_profiles: + if p.npc_type == npc_type: + return p + return null + +func events_in_phase(phase: int) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for event in event_pool: + if event.get("phase", -1) == phase: + result.append(event) + return result diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd new file mode 100644 index 0000000..7d8f8a7 --- /dev/null +++ b/src/game/game_manager.gd @@ -0,0 +1,402 @@ +class_name GameManager +extends Node +## Wireframe: connects a scenario to a ShiftCoordinator, spawns NPC avatars, +## and routes player input into the interaction and access flows. + +const Rules := preload("res://src/core/rules.gd") +const AccessArtifact := preload("res://src/core/access_artifact.gd") +const AccessVerifier := preload("res://src/core/access_verifier.gd") +const RadioSystem := preload("res://src/core/radio_system.gd") +const ScenarioFactory := preload("res://src/content/scenario_factory.gd") +const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd") +const VenueScenario := preload("res://src/core/venue_scenario.gd") +const WorldBuilder := preload("res://src/game/world_builder.gd") +const GuardController := preload("res://src/game/guard_controller.gd") +const NpcAvatar := preload("res://src/game/npc_avatar.gd") +const Hud := preload("res://src/game/ui/hud.gd") +const RadioPanel := preload("res://src/game/ui/radio_panel.gd") +const ShiftReview := preload("res://src/game/ui/shift_review.gd") + +var coordinator: ShiftCoordinator = null +var guard: GuardController = null +var hud: Hud = null +var radio_panel: RadioPanel = null +var shift_review_panel: ShiftReview = null +var verifier := AccessVerifier.new() + +var active_scenario_id := "hollywood_building" +var scenario_duration_s: float = 300.0 # shortened prototype duration for testing +var scene_root: Node3D = null +var npcs: Array = [] +var npc_counter := 0 +var targeted_npc: Object = null +var inspection_open := false +var current_incident_npc_id := "" +var current_incident_active := false +var current_npc_truth := {"weapons_seen": false, "danger_level": 0} + +func _ready() -> void: + scene_root = Node3D.new() + scene_root.name = "SceneRoot" + add_child(scene_root) + + WorldBuilder.new(scene_root).build() + _ensure_lighting() + + guard = _build_guard() + scene_root.add_child(guard) + guard.global_position = Vector3(0, 1.6, -2.5) + guard.picked_target.connect(_on_guard_target) + guard.cleared_target.connect(_on_guard_clear) + + selected_scenario = _build_scenario(active_scenario_id) + coordinator = ShiftCoordinator.new() + coordinator.setup(selected_scenario, scenario_duration_s, 4242) + coordinator.npc_spawn_requested.connect(_spawn_npc) + coordinator.shift_ended.connect(_on_shift_ended) + + _build_ui() + coordinator.start() + +var selected_scenario: VenueScenario = null + +func _build_guard() -> GuardController: + var g := GuardController.new() + g.name = "Guard" + var head := Node3D.new() + head.name = "Head" + g.add_child(head) + var camera := Camera3D.new() + camera.name = "Camera3D" + camera.current = true + head.add_child(camera) + var collider := CollisionShape3D.new() + var shape := CapsuleShape3D.new() + shape.radius = 0.4 + shape.height = 1.7 + collider.shape = shape + collider.position = Vector3(0, 0.85, 0) + g.add_child(collider) + return g + +func _build_scenario(id: String) -> VenueScenario: + match id: + "joke_shop": + return ScenarioFactory.joke_shop() + "tv_studio": + return ScenarioFactory.tv_studio() + _: + return ScenarioFactory.hollywood_building() + +func switch_scenario(id: String) -> void: + active_scenario_id = id + selected_scenario = _build_scenario(id) + if coordinator: + restart_shift() + +func restart_shift() -> void: + shift_review_panel.hide() + for n in npcs: + n.queue_free() + npcs.clear() + npc_counter = 0 + inspector_current_events = [] + current_incident_active = false + current_incident_npc_id = "" + coordinator.setup(selected_scenario, scenario_duration_s, 4242) + coordinator.start() + +var inspector_current_events: Array = [] + +func _build_ui() -> void: + hud = Hud.new() + add_child(hud) + radio_panel = RadioPanel.new() + add_child(radio_panel) + radio_panel.hide() + radio_panel.submitted.connect(_on_radio_submitted) + radio_panel.cancelled.connect(func(): + radio_panel.hide() + hud.set_action_hint("")) + shift_review_panel = ShiftReview.new() + add_child(shift_review_panel) + shift_review_panel.hide() + shift_review_panel.restart_requested.connect(restart_shift) + +func _ensure_lighting() -> void: + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-50, -30, 0) + sun.light_energy = 1.2 + scene_root.add_child(sun) + var env_node := WorldEnvironment.new() + var env := Environment.new() + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(0.7, 0.7, 0.75) + env.ambient_light_energy = 0.5 + env_node.environment = env + scene_root.add_child(env_node) + +func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void: + npc_counter += 1 + var avatar := NpcAvatar.new() + avatar.name = "Npc_%d" % npc_counter + var spawn_side: float = 1.0 if npc_counter % 2 == 0 else -1.0 + var spawn_x := spawn_side * (4.0 + float((npc_counter * 13) % 18)) + var spawn_pos := Vector3(spawn_x, 0.5, 6.0 + float((npc_counter * 7) % 16)) + var target_pos := Vector3(0.0, 0.5, 1.2) + avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos) + scene_root.add_child(avatar) + npcs.append(avatar) + +func _on_shift_ended(review: Dictionary) -> void: + shift_review_panel.show_review(review, selected_scenario) + +func _on_guard_target(target: Node3D) -> void: + targeted_npc = target + _begin_incident(target) + +func _on_guard_clear() -> void: + targeted_npc = null + hud.set_target_label("") + hud.set_action_hint("") + +func _begin_incident(avatar: Node3D) -> void: + var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null + if controller == null: + return + current_incident_npc_id = controller.npc_id + current_incident_active = true + current_npc_truth = _read_npc_truth(controller) + var profile: Object = controller.profile + var label := "[%s] %s — %s" % [ + Rules.NPC_TYPE_NAMES.get((profile.npc_type if profile else -1), "?"), + profile.display_name if profile else "?", + controller.intent, + ] + hud.set_target_label(label) + var hint := "" + if profile and profile.npc_type == Rules.NpcType.VIOLENT_THREAT: + hint = "High risk: threaten/force-entry likely. Emergency radio (R) is justified." + elif profile and profile.npc_type == Rules.NpcType.MENTAL_CRISIS: + hint = "Requires patience. Avoid aggressive confrontation." + elif profile and profile.npc_type == Rules.NpcType.AUTHORIZED_GUEST: + hint = "Likely authorized. Press E to verify access." + hud.set_action_hint(hint) + +# ------------------------------------------------------------------ input --- + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and not event.echo: + _handle_key(event.keycode) + +func _handle_key(keycode: Key) -> void: + match keycode: + KEY_E: + if targeted_npc != null and not inspection_open: + _toggle_inspection() + KEY_Q: + if targeted_npc != null and not inspection_open: + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "polite direction") + KEY_F: + hud.toggle_debug() + _: + _handle_action_key(keycode) + +func _handle_action_key(keycode: Key) -> void: + match keycode: + KEY_1: + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "raise hand") + KEY_2: + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "point") + KEY_3: + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "head shake") + KEY_4: + _apply_interaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL, "formal warning") + KEY_5: + _apply_interaction(Rules.Level.BLOCKING, Rules.Tone.PROFESSIONAL, "blocking") + KEY_6: + _open_radio(false) + KEY_R: + _open_radio(true) + KEY_A: + if inspection_open: + decide_access(true) + KEY_D: + if inspection_open: + decide_access(false) + _: + pass + +func _apply_interaction(level: int, tone: int, action_name: String) -> void: + if targeted_npc == null: + hud.log("Aim at someone first.") + return + var controller: Object = _targeted_controller() + if controller == null or coordinator == null: + return + var outcome: Dictionary = controller.apply_player_action(level, tone) + 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: + coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name]) + _react_to_outcome(controller, outcome) + +func _react_to_outcome(controller: Object, outcome: Dictionary) -> void: + var reaction: int = outcome.get("reaction", -1) + var name: String = (controller.profile.display_name if controller.profile else "Person") + match reaction: + Rules.Reaction.COMPLY: + hud.log("%s complies and steps back." % name) + coordinator.crowd.apply_event("de_escalation") + coordinator.reputation.apply("peaceful", 2.0, "resolved peacefully") + _close_incident("peaceful") + Rules.Reaction.LEAVE: + hud.log("%s turns and leaves." % name) + coordinator.crowd.apply_event("calm_professionalism") + _close_incident("left") + Rules.Reaction.ARGUE: + hud.log("%s argues with you." % name) + coordinator.crowd.apply_event("aggressive_behavior") + Rules.Reaction.RECORD: + hud.log("%s starts filming you." % name) + coordinator.reputation.apply("complaint", -3.0, "filmed interaction") + coordinator.crowd.apply_event("aggressive_behavior") + Rules.Reaction.IGNORE: + hud.log("%s ignores you." % name) + Rules.Reaction.THREATEN: + hud.log("%s is threatening you. Emergency radio (R) is justified now." % name) + coordinator.crowd.apply_event("aggressive_behavior") + Rules.Reaction.FORCE_ENTRY: + hud.log("%s is forcing the door!" % name) + coordinator.crowd.apply_event("emergency_responder") + coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt") + Rules.Reaction.RETURN_LATER: + hud.log("%s leaves but may come back." % name) + _: + hud.log("No reaction (yet).") + +func _open_radio(emergency: bool) -> void: + if targeted_npc == null: + hud.log("Address someone first before calling.") + return + var controller: Object = _targeted_controller() + if controller == null: + return + radio_panel.open(controller, emergency, current_npc_truth) + hud.set_action_hint("Fill in the radio report on screen.") + +func _on_radio_submitted(report: Dictionary, emergency: bool) -> void: + hud.set_action_hint("") + var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report) + if not result.get("ok", false): + hud.log("Radio blocked: %s" % result.get("reason", "unknown")) + return + var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth) + if hits > 0: + coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report") + hud.log("Dispatch flags inaccuracies in your report (-%d)." % (4 * hits)) + else: + hud.log("Dispatch received your report. Assistance en route.") + if emergency: + coordinator.crowd.apply_event("emergency_responder") + hud.log("Emergency responder dispatched.") + else: + coordinator.crowd.apply_event("calm_professionalism") + +# ----------------------------------------------------------------- access -- + +func _toggle_inspection() -> void: + if inspection_open: + hud.hide_inspection() + inspection_open = false + return + var controller: Object = _targeted_controller() + if controller == null: + return + inspection_open = true + var artifacts := _artifacts_for(controller) + var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy) + hud.show_inspection(controller, artifacts, result) + +func decide_access(allowed: bool) -> void: + var controller: Object = _targeted_controller() + if controller == null: + hud.hide_inspection() + inspection_open = false + return + var artifacts := _artifacts_for(controller) + var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy) + var granted: bool = result.get("granted", false) + coordinator.recorder.add_access_evidence(controller.npc_id, { + "decided": allowed, + "verified_granted": granted, + "issues": result.get("issues", []), + "checked": result.get("checked_artifacts", []), + }) + if allowed == granted: + if allowed: + hud.log("Entry granted: %s" % ("; ".join(result.get("reasons", [])) if result.get("reasons", []) else "policy satisfied")) + coordinator.reputation.apply("access_judgment", 2.0, "correct admission") + else: + hud.log("Entry denied: %s" % "; ".join(result.get("issues", []))) + coordinator.reputation.apply("access_judgment", 1.0, "correct refusal") + else: + if allowed: + hud.log("You granted entry despite verification failure!") + else: + hud.log("You denied entry despite valid verification!") + coordinator.reputation.apply("access_judgment", -6.0, "wrong access decision") + hud.hide_inspection() + inspection_open = false + +func _artifacts_for(controller: Object) -> Array: + var profile: Object = controller.profile + var npc_type: int = profile.npc_type if profile else -1 + var out: Array = [] + match npc_type: + Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.AUTHORIZED_STAFF: + out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true})) + Rules.NpcType.DELIVERY: + out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"})) + Rules.NpcType.TRESPASSER: + out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {"color": "red", "date": "2024-01-01"})) + Rules.NpcType.TOURIST: + out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {})) + Rules.NpcType.CONFUSED_VISITOR: + out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, {"color": "gold"})) + _: + out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {})) + return out + +func _read_npc_truth(controller: Object) -> Dictionary: + var profile: Object = controller.profile + var npc_type: int = profile.npc_type if profile else -1 + return { + "weapons_seen": npc_type == Rules.NpcType.VIOLENT_THREAT, + "danger_level": 3 if npc_type == Rules.NpcType.VIOLENT_THREAT else 1, + } + +func _targeted_controller() -> Object: + if targeted_npc == null: + return null + if targeted_npc.has_method("get_npc_controller"): + return targeted_npc.get_npc_controller() + return null + +func _close_incident(outcome: String) -> void: + if current_incident_active and not current_incident_npc_id.is_empty(): + coordinator.recorder.close_incident(current_incident_npc_id, outcome) + current_incident_active = false + current_incident_npc_id = "" + +func _physics_process(delta: float) -> void: + if coordinator == null or not coordinator.running: + return + coordinator.tick(delta) + hud.set_shift_state(coordinator, selected_scenario) diff --git a/src/game/guard_controller.gd b/src/game/guard_controller.gd new file mode 100644 index 0000000..a689747 --- /dev/null +++ b/src/game/guard_controller.gd @@ -0,0 +1,83 @@ +class_name GuardController +extends CharacterBody3D +## First-person guard controller: WASD movement, mouse look, interact raycast. + +signal picked_target(target: Node) +signal cleared_target() + +var move_speed: float = 3.2 +var look_sensitivity: float = 0.0022 +var is_ready := false +var targeted_npc: Node3D = null + +@onready var camera: Camera3D = $Head/Camera3D +@onready var head: Node3D = $Head + +func _ready() -> void: + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) + is_ready = true + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: + rotate_y(-event.relative.x * look_sensitivity) + head.rotate_x(-event.relative.y * look_sensitivity) + head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0)) + elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: + Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + else: + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) + +func _physics_process(_delta: float) -> void: + # Movement relative to the head's yaw only. + var forward := -global_transform.basis.z + forward.y = 0.0 + forward = forward.normalized() + var right := global_transform.basis.x + right.y = 0.0 + right = right.normalized() + + var input_dir := Vector2( + Input.get_axis("move_left", "move_right"), + Input.get_axis("move_forward", "move_back") + ) + var direction := (forward * input_dir.y + right * input_dir.x).normalized() + velocity.x = direction.x * move_speed + velocity.z = direction.z * move_speed + velocity.y -= 9.8 * _delta + move_and_slide() + _update_target() + +func _update_target() -> void: + var space := get_world_3d().direct_space_state + var from := camera.global_position + var to := from + -camera.global_transform.basis.z * 8.0 + var query := PhysicsRayQueryParameters3D.create(from, to) + query.collide_with_areas = true + query.collide_with_bodies = true + query.exclude = [get_rid()] + var hit := space.intersect_ray(query) + var new_target: Node3D = null + if hit: + var obj: Object = hit.get("collider") + var parent_avatar: Node3D = _find_avatar(obj) + if parent_avatar != null: + new_target = parent_avatar + if new_target != targeted_npc: + targeted_npc = new_target + if new_target != null: + picked_target.emit(new_target) + else: + cleared_target.emit() + +func _find_avatar(obj: Object) -> Node3D: + # Avatars are CharacterBody3D with a collision shape; guard against self and scene junk. + var n := obj as Node + if n == null: + return null + var current: Node = n + while current != null: + if current.has_method("get_npc_controller"): + return current as Node3D + current = current.get_parent() + return null diff --git a/src/game/main.gd b/src/game/main.gd new file mode 100644 index 0000000..7af3137 --- /dev/null +++ b/src/game/main.gd @@ -0,0 +1,11 @@ +extends Node +## Top-level game entry point. Instantiates the GameManager which owns the world, +## guard, simulation coordinator, and UI. + +const GameManager := preload("res://src/game/game_manager.gd") + +func _ready() -> void: + var manager := GameManager.new() + manager.name = "GameManager" + add_child(manager) + print("[Step Line] game started") \ No newline at end of file diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd new file mode 100644 index 0000000..8ac349f --- /dev/null +++ b/src/game/npc_avatar.gd @@ -0,0 +1,118 @@ +class_name NpcAvatar +extends CharacterBody3D +## Visual + navigation wrapper around a logic NPCController (ADR-0001). +## Gives the controller a body, color-coded by archetype, and simple movement. + +const NPCController := preload("res://src/core/npc_controller.gd") +const Rules := preload("res://src/core/rules.gd") + +var npc: NPCController = null +var spawn_position: Vector3 = Vector3.ZERO +var target_position: Vector3 = Vector3.ZERO +var awaiting_interaction := false +var arrived := false + +const TYPE_COLORS := { + Rules.NpcType.TOURIST: Color(0.90, 0.75, 0.55), + Rules.NpcType.PERFORMER: Color(0.80, 0.55, 0.90), + Rules.NpcType.INFLUENCER: Color(0.95, 0.55, 0.55), + Rules.NpcType.CONFUSED_VISITOR: Color(0.95, 0.85, 0.50), + Rules.NpcType.INTOXICATED: Color(0.60, 0.75, 0.50), + Rules.NpcType.TRESPASSER: Color(0.55, 0.55, 0.75), + Rules.NpcType.VIOLENT_THREAT: Color(0.85, 0.25, 0.25), + Rules.NpcType.MENTAL_CRISIS: Color(0.85, 0.65, 0.45), + Rules.NpcType.AUTHORIZED_STAFF: Color(0.45, 0.70, 0.90), + Rules.NpcType.AUTHORIZED_GUEST: Color(0.60, 0.85, 0.65), + Rules.NpcType.DELIVERY: Color(0.80, 0.70, 0.40), + Rules.NpcType.RESPONDER: Color(0.25, 0.30, 0.85), +} + +func _init() -> void: + npc = NPCController.new() + +func get_npc_controller() -> NPCController: + return npc + +func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3) -> void: + npc.setup("npc_%d" % seed_value, profile, intent, seed_value) + spawn_position = p_spawn + target_position = p_target + position = p_spawn + npc.fsm.state_changed.connect(_on_state_changed) + _build_body(profile.npc_type) + + # Normalize intent targets: approach the entrance, stop near the door. + var collider := CollisionShape3D.new() + var shape := CapsuleShape3D.new() + shape.radius = 0.35 + shape.height = 1.6 + collider.shape = shape + collider.position = Vector3(0, 0.8, 0) + add_child(collider) + +func _build_body(npc_type: int) -> void: + var body := MeshInstance3D.new() + var capsule := CapsuleMesh.new() + capsule.radius = 0.35 + capsule.height = 1.6 + body.mesh = capsule + var mat := StandardMaterial3D.new() + mat.albedo_color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7)) + body.material_override = mat + add_child(body) + var id_tag := MeshInstance3D.new() + var sphere := SphereMesh.new() + sphere.radius = 0.12 + sphere.height = 0.24 + var tag_mat := StandardMaterial3D.new() + tag_mat.albedo_color = Color(1, 1, 1) + id_tag.mesh = sphere + id_tag.material_override = tag_mat + id_tag.position = Vector3(0, 1.05, 0) + id_tag.scale = Vector3(1, 0.4, 1) + add_child(id_tag) + +func _physics_process(delta: float) -> void: + if npc == null or npc.fsm == null: + return + _advance_fsm() + _move_toward_target(delta) + +func _advance_fsm() -> void: + if npc.leave_requested: + var away := global_position + (global_position - spawn_position).normalized() * 40.0 + away.y = 0.5 + target_position = away + return + if arrived and not awaiting_interaction: + awaiting_interaction = true + +func _move_toward_target(delta: float) -> void: + var to_target := target_position - global_position + to_target.y = 0.0 + var dist := to_target.length() + if dist < 0.7: + _arrive() + return + var dir := to_target.normalized() + velocity.x = dir.x * 1.3 + velocity.z = dir.z * 1.3 + velocity.y -= 9.8 * delta + look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP) + move_and_slide() + +func _arrive() -> void: + arrived = true + velocity = Vector3.ZERO + if npc.intent == "enter" or npc.intent == "trespass" or npc.intent == "deliver" or npc.intent == "arrive": + target_position = Vector3(0, 0.5, 1.2) + if (global_position - Vector3(0, 0.5, 1.2)).length() < 0.8: + awaiting_interaction = true + +func _on_state_changed(_node: Object, _from: String, to_state: String) -> void: + match to_state: + "LEAVING": + npc.leave_requested = true + "THREATENING", "FORCING_ENTRY": + # Advance toward the door posture; keep it simple for the prototype. + pass diff --git a/src/game/ui/hud.gd b/src/game/ui/hud.gd new file mode 100644 index 0000000..794f345 --- /dev/null +++ b/src/game/ui/hud.gd @@ -0,0 +1,168 @@ +class_name Hud +extends CanvasLayer +## Minimal on-screen HUD: top status bar, target info, action hints, +## event log, access inspection panel, and a debug overlay. + +const Rules := preload("res://src/core/rules.gd") + +var top_label: Label +var time_label: Label +var target_label: Label +var hint_label: Label +var log_label: Label +var debug_label: Label +var inspect_panel: PanelContainer +var inspect_text: Label +var inspect_meta: Label + +var _debug_enabled := false +var _log_lines: Array[String] = [] + +func _init() -> void: + name = "HUD" + layer = 10 + + var margin := MarginContainer.new() + margin.set_anchors_preset(Control.PRESET_FULL_RECT) + margin.add_theme_constant_override("margin_left", 16) + margin.add_theme_constant_override("margin_top", 12) + margin.add_theme_constant_override("margin_right", 16) + margin.add_theme_constant_override("margin_bottom", 12) + add_child(margin) + + var vbox := VBoxContainer.new() + margin.add_child(vbox) + + var top := HBoxContainer.new() + vbox.add_child(top) + + top_label = Label.new() + top_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + top.add_child(top_label) + + time_label = Label.new() + top.add_child(time_label) + + var spacer := Control.new() + spacer.custom_minimum_size = Vector2(0, 6) + vbox.add_child(spacer) + + target_label = Label.new() + target_label.add_theme_font_size_override("font_size", 16) + vbox.add_child(target_label) + + hint_label = Label.new() + hint_label.modulate = Color(1, 0.9, 0.6) + hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + hint_label.custom_minimum_size = Vector2(0, 40) + vbox.add_child(hint_label) + + var spacer2 := Control.new() + spacer2.custom_minimum_size = Vector2(0, 4) + vbox.add_child(spacer2) + + log_label = Label.new() + log_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + log_label.custom_minimum_size = Vector2(0, 120) + log_label.add_theme_font_size_override("font_size", 13) + vbox.add_child(log_label) + + var bottom_spacer := Control.new() + bottom_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL + vbox.add_child(bottom_spacer) + + var controls := Label.new() + controls.text = "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · A allow · D deny · F debug" + controls.add_theme_font_size_override("font_size", 12) + controls.modulate = Color(0.8, 0.8, 0.85) + vbox.add_child(controls) + + # --- inspect panel (popup center) --- + inspect_panel = PanelContainer.new() + inspect_panel.visible = false + inspect_panel.set_anchors_preset(Control.PRESET_CENTER) + inspect_panel.modulate.a = 1.0 + var ivbox := VBoxContainer.new() + inspect_panel.add_child(ivbox) + inspect_text = Label.new() + inspect_text.add_theme_font_size_override("font_size", 15) + ivbox.add_child(inspect_text) + inspect_meta = Label.new() + inspect_meta.add_theme_font_size_override("font_size", 12) + inspect_meta.modulate = Color(0.8, 0.85, 1.0) + ivbox.add_child(inspect_meta) + var insp_hint := Label.new() + insp_hint.text = "A = allow entry D = deny entry" + insp_hint.add_theme_font_size_override("font_size", 12) + ivbox.add_child(insp_hint) + add_child(inspect_panel) + + # --- debug overlay --- + debug_label = Label.new() + debug_label.visible = false + debug_label.set_anchors_preset(Control.PRESET_BOTTOM_LEFT) + debug_label.position = Vector2(16, 0) + debug_label.add_theme_font_size_override("font_size", 11) + debug_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + add_child(debug_label) + +func set_shift_state(coordinator: Object, scenario: Resource) -> void: + top_label.text = scenario.display_name if scenario else "Step Line" + var mins := int(coordinator.elapsed_s) / 60 + var secs := int(coordinator.elapsed_s) % 60 + time_label.text = "shift %02d:%02d phase %s\nrep %.0f crowd %.0f%%" % [ + mins, secs, + Rules.PHASE_NAMES.get(coordinator.current_phase, "Early"), + coordinator.reputation.rating, + coordinator.crowd.mood * 100.0, + ] + +func set_target_label(text: String) -> void: + target_label.text = text + +func set_action_hint(text: String) -> void: + hint_label.text = text + +func log(text: String) -> void: + _log_lines.append(text) + if _log_lines.size() > 8: + _log_lines.pop_front() + log_label.text = "\n".join(_log_lines) + +func toggle_debug() -> void: + _debug_enabled = not _debug_enabled + debug_label.visible = _debug_enabled + +func refresh_debug(coordinator: Object, npcs: Array) -> void: + if not _debug_enabled: + return + var lines: Array[String] = ["--- DEBUG ---", "shift %.1fs / %.1fs rating %.1f" % [ + coordinator.elapsed_s, coordinator.duration_s, coordinator.reputation.rating]] + for npc in npcs: + var c: Object = npc.get_npc_controller() + if c == null: + continue + var profile: Object = c.profile + lines.append(" [%s] %s intent=%s state=%s esc=%d" % [ + Rules.NPC_TYPE_NAMES.get(profile.npc_type if profile else -1, "?"), + npc.name, c.intent, c.fsm.current_state, c.highest_escalation_received, + ]) + debug_label.text = "\n".join(lines) + +func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -> void: + var profile: Object = controller.profile + var npc_display: String = (profile.display_name if profile else "Person") + var granted: bool = result.get("granted", false) + inspect_text.text = "Access check — %s\nGranted: %s" % [npc_display, "YES" if granted else "NO"] + var lines: Array[String] = [] + for artifact in artifacts: + lines.append(" • " + artifact.summary()) + if result.get("reasons", []).size() > 0: + lines.append("Reasons: " + ", ".join(result.get("reasons", []))) + if result.get("issues", []).size() > 0: + lines.append("Issues: " + "; ".join(result.get("issues", []))) + inspect_meta.text = "\n".join(lines) + inspect_panel.visible = true + +func hide_inspection() -> void: + inspect_panel.visible = false diff --git a/src/game/ui/radio_panel.gd b/src/game/ui/radio_panel.gd new file mode 100644 index 0000000..69047fc --- /dev/null +++ b/src/game/ui/radio_panel.gd @@ -0,0 +1,159 @@ +class_name RadioPanel +extends CanvasLayer +## Radio report form (PLAN.md Level 4 assist / Level 5 emergency). +## Emergency calls use hold-to-activate (press and hold 1.6s) to prevent +## accidental activation. + +signal submitted(report: Dictionary, emergency: bool) +signal cancelled() + +const Rules := preload("res://src/core/rules.gd") + +var panel: PanelContainer +var npc_name_label: Label +var location_edit: LineEdit +var desc_edit: LineEdit +var weapons_check: CheckBox +var danger_option: OptionButton +var action_button: Button +var hold_hint: Label +var status_label: Label + +var _emergency := false +var _npc_id := "" +var _holding := false +var _hold_time := 0.0 +const HOLD_NEEDED := 1.6 +var _truth := {"weapons_seen": false, "danger_level": 0} + +func _init() -> void: + name = "RadioPanel" + layer = 20 + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.55) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(dim) + + panel = PanelContainer.new() + panel.set_anchors_preset(Control.PRESET_CENTER) + add_child(panel) + + var vbox := VBoxContainer.new() + panel.add_child(vbox) + vbox.add_theme_constant_override("separation", 8) + + var title := Label.new() + title.name = "RadioTitle" + title.add_theme_font_size_override("font_size", 18) + vbox.add_child(title) + npc_name_label = Label.new() + vbox.add_child(npc_name_label) + + location_edit = LineEdit.new() + location_edit.placeholder_text = "Location (e.g. front steps)" + location_edit.text = "front entrance" + vbox.add_child(location_edit) + + desc_edit = LineEdit.new() + desc_edit.placeholder_text = "Describe the person and behavior" + vbox.add_child(desc_edit) + + weapons_check = CheckBox.new() + weapons_check.text = "Weapon seen" + vbox.add_child(weapons_check) + + var danger_row := HBoxContainer.new() + vbox.add_child(danger_row) + var danger_label := Label.new() + danger_label.text = "Danger level: " + danger_row.add_child(danger_label) + danger_option = OptionButton.new() + for i in 4: + danger_option.add_item("level %d — %s" % [i, _danger_name(i)], i) + danger_option.select(1) + danger_row.add_child(danger_option) + + hold_hint = Label.new() + hold_hint.text = "" + hold_hint.modulate = Color(1, 0.85, 0.55) + vbox.add_child(hold_hint) + + status_label = Label.new() + status_label.text = "" + vbox.add_child(status_label) + + var buttons := HBoxContainer.new() + vbox.add_child(buttons) + action_button = Button.new() + action_button.text = "Send request" + action_button.button_down.connect(func(): _start_hold()) + action_button.button_up.connect(func(): _stop_hold(false)) + buttons.add_child(action_button) + var cancel_btn := Button.new() + cancel_btn.text = "Cancel" + cancel_btn.pressed.connect(func(): cancelled.emit()) + buttons.add_child(cancel_btn) + +var _title_label: Label + +func open(controller: Object, emergency: bool, truth: Dictionary) -> void: + _emergency = emergency + _truth = truth + _npc_id = controller.npc_id if controller else "" + npc_name_label.text = "Target: %s" % (controller.profile.display_name if controller else "?") + var title: Label = panel.find_child("RadioTitle", true, false) + title.text = "EMERGENCY CALL" if emergency else "Security Assistance" + weapons_check.button_pressed = truth.get("weapons_seen", false) + danger_option.select(truth.get("danger_level", 1)) + hold_hint.text = "Hold the button for %.1fs" % HOLD_NEEDED if emergency else "" + status_label.text = "" + action_button.text = "SEND EMERGENCY" if emergency else "Send request" + _holding = false + _hold_time = 0.0 + visible = true + +func _start_hold() -> void: + _holding = _emergency + _hold_time = 0.0 + +func _stop_hold(cancelled_early: bool) -> void: + if _holding: + _holding = false + if not cancelled_early and _hold_time >= HOLD_NEEDED: + _submit() + else: + status_label.text = "Call cancelled - must hold the button." + +func _process(delta: float) -> void: + if not visible: + return + if _holding: + _hold_time += delta + status_label.text = "Holding... %.0f%%" % (minf(1.0, _hold_time / HOLD_NEEDED) * 100.0) + if _hold_time >= HOLD_NEEDED: + var was := _holding + _holding = false + if was: + _submit() + +func _submit() -> void: + if not visible: + return + var report := { + "npc_id": _npc_id, + "location": location_edit.text, + "description": desc_edit.text, + "behavior": desc_edit.text, + "weapons_seen": weapons_check.button_pressed, + "danger_level": danger_option.get_selected_id(), + } + visible = false + submitted.emit(report, _emergency) + +func _danger_name(level: int) -> String: + match level: + 0: return "none" + 1: return "low" + 2: return "elevated" + 3: return "immediate threat" + _: return "unknown" diff --git a/src/game/ui/shift_review.gd b/src/game/ui/shift_review.gd new file mode 100644 index 0000000..2b678cc --- /dev/null +++ b/src/game/ui/shift_review.gd @@ -0,0 +1,77 @@ +class_name ShiftReview +extends CanvasLayer +## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review). + +signal restart_requested() + +var report_label: Label + +func _init() -> void: + name = "ShiftReview" + layer = 30 + var dim := ColorRect.new() + dim.color = Color(0.04, 0.05, 0.08, 0.92) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(dim) + + var center := VBoxContainer.new() + center.set_anchors_preset(Control.PRESET_CENTER) + center.alignment = BoxContainer.ALIGNMENT_CENTER + center.add_theme_constant_override("separation", 12) + add_child(center) + + var title := Label.new() + title.text = "Shift Review" + title.add_theme_font_size_override("font_size", 28) + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + center.add_child(title) + + report_label = Label.new() + report_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + report_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + report_label.custom_minimum_size = Vector2(720, 0) + center.add_child(report_label) + + var restart := Button.new() + restart.text = "Run another shift" + restart.pressed.connect(func(): restart_requested.emit()) + restart.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + center.add_child(restart) + + var quit_btn := Button.new() + quit_btn.text = "Quit" + quit_btn.pressed.connect(func(): get_tree().quit()) + quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + center.add_child(quit_btn) + +func show_review(review: Dictionary, scenario: Resource) -> void: + var reputation: Dictionary = review.get("reputation", {}) + var incidents: Dictionary = review.get("incidents", {}) + var crowd: Dictionary = review.get("crowd", {}) + var radio: Dictionary = review.get("radio", {}) + var incident_list: Array = incidents.get("incidents", []) + var peaceful := 0 + for inc in incident_list: + if inc.get("outcome", "") in ["peaceful", "left"]: + peaceful += 1 + + var lines: Array[String] = [ + "Venue: %s" % (scenario.display_name if scenario else review.get("scenario_id", "")), + "Grade: %s Score: %.0f/100" % [reputation.get("grade", "?"), reputation.get("rating", 0.0)], + "Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful], + "NPCs encountered: %d" % review.get("spawned_count", 0), + "Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)], + "Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))], + "", + "Highlights:", + ] + var mods: Array = reputation.get("modifiers", []) + var shown := 0 + for mod in mods: + var delta: float = float(mod.get("delta", 0.0)) + lines.append(" %s (%+.0f) %s" % [mod.get("category", ""), delta, mod.get("reason", "")]) + shown += 1 + if shown >= 12: + break + report_label.text = "\n".join(lines) + visible = true \ No newline at end of file diff --git a/src/game/world_builder.gd b/src/game/world_builder.gd new file mode 100644 index 0000000..07804f1 --- /dev/null +++ b/src/game/world_builder.gd @@ -0,0 +1,110 @@ +class_name WorldBuilder +## Procedurally assembles a minimal Hollywood Boulevard entrance prototype: +## steps, building facade, restricted door, sidewalk, pavement, and street edge. +## No art assets required; all geometry is BoxMesh with flat materials. + +var root: Node3D = null + +func _init(root_node: Node3D) -> void: + root = root_node + +func build() -> void: + _build_ground() + _build_building() + _build_steps() + _build_sidewalk() + _build_street() + _build_guard_spawn() + +func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D: + var mesh := BoxMesh.new() + mesh.size = size + var mat := StandardMaterial3D.new() + mat.albedo_color = color + mesh.material = mat + var mi := MeshInstance3D.new() + mi.mesh = mesh + mi.position = pos + mi.cast_shadow = (1 if cast_shadow else 0) as GeometryInstance3D.ShadowCastingSetting + parent.add_child(mi) + return mi + +func _build_ground() -> void: + var ground := StaticBody3D.new() + ground.name = "Ground" + _add_box(root, Vector3(160, 0.5, 160), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26)) + var col := BoxShape3D.new() + col.size = Vector3(160, 1, 160) + var shape_owner := ground.create_shape_owner(ground) + ground.shape_owner_add_shape(shape_owner, col) + root.add_child(ground) + +func _build_building() -> void: + var building := StaticBody3D.new() + building.name = "Building" + # Main facade + _add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.35, 0.32, 0.30)) + # Entrance recess + door recess frame + _add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.10, 0.10, 0.10)) + # Door + _add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.16, 0.15, 0.13)) + # Door window + _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.5, 0.7, 0.9, 1.0)) + # Awning sign bar + _add_box(building, Vector3(8, 0.5, 0.6), Vector3(0, 7.0, -3.6), Color(0.45, 0.44, 0.44)) + var col := BoxShape3D.new() + col.size = Vector3(40, 18, 4) + var so := building.create_shape_owner(building) + building.shape_owner_add_shape(so, col) + building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -2))) + root.add_child(building) + +func _build_steps() -> void: + var steps := StaticBody3D.new() + steps.name = "Steps" + var step_w := 8.0 + var tread := 0.6 + var height := 0.16 + for i in 4: + var y: float = i * height + var z: float = i * tread + _add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5)) + # Landing at the top + _add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45)) + var col := BoxShape3D.new() + col.size = Vector3(step_w, 0.65, tread * 4 + 3) + var so := steps.create_shape_owner(steps) + steps.shape_owner_add_shape(so, col) + steps.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0.32, 0.8))) + root.add_child(steps) + +func _build_sidewalk() -> void: + var sidewalk := StaticBody3D.new() + sidewalk.name = "Sidewalk" + _add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.60, 0.58, 0.56)) + var col := BoxShape3D.new() + col.size = Vector3(30, 0.36, 8) + var so := sidewalk.create_shape_owner(sidewalk) + sidewalk.shape_owner_add_shape(so, col) + sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 8))) + root.add_child(sidewalk) + +func _build_street() -> void: + var street := StaticBody3D.new() + street.name = "Street" + _add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(0.13, 0.13, 0.14)) + # Center dashes + for i in range(-40, 41, 4): + _add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(i * 0.0, 0.11, 22), Color(0.85, 0.85, 0.30)) + var col := BoxShape3D.new() + col.size = Vector3(120, 0.2, 26) + var so := street.create_shape_owner(street) + street.shape_owner_add_shape(so, col) + street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22))) + root.add_child(street) + +func _build_guard_spawn() -> void: + var marker := Marker3D.new() + marker.name = "GuardSpawn" + marker.position = Vector3(0, 1.6, -2.5) + root.add_child(marker) \ No newline at end of file diff --git a/src/test/check_loads.gd b/src/test/check_loads.gd new file mode 100644 index 0000000..cba6ba6 --- /dev/null +++ b/src/test/check_loads.gd @@ -0,0 +1,39 @@ +extends SceneTree +## Dev diagnostic: preload every project script to surface parse errors one by one. + +const FILES := [ + "res://src/core/event_bus.gd", + "res://src/core/rules.gd", + "res://src/core/access_artifact.gd", + "res://src/core/access_policy.gd", + "res://src/core/access_verifier.gd", + "res://src/core/npc_profile.gd", + "res://src/core/npc_controller.gd", + "res://src/core/fsm.gd", + "res://src/core/escalation_manager.gd", + "res://src/core/incident_recorder.gd", + "res://src/core/reputation_evaluator.gd", + "res://src/core/radio_system.gd", + "res://src/core/crowd_system.gd", + "res://src/core/event_scheduler.gd", + "res://src/core/venue_scenario.gd", + "res://src/core/shift_coordinator.gd", + "res://src/content/scenario_factory.gd", + "res://src/game/main.gd", + "res://src/test/test_utils.gd", +] + +func _initialize() -> void: + var failures := 0 + for f in FILES: + var res: Variant = load(f) + if res == null or not (res is GDScript): + push_error("FAILED to load: %s" % f) + failures += 1 + elif not (res as GDScript).can_instantiate(): + push_error("PARSE/COMPILE FAILURE (cannot instantiate): %s" % f) + failures += 1 + else: + print("OK: %s" % f) + print("load failures: %d" % failures) + quit(1 if failures > 0 else 0) diff --git a/src/test/take_screenshot.gd b/src/test/take_screenshot.gd new file mode 100644 index 0000000..f0062db --- /dev/null +++ b/src/test/take_screenshot.gd @@ -0,0 +1,25 @@ +extends SceneTree +## Dev helper: run the game windowed for N frames, save a screenshot, quit. +## Usage: godot --path . -s res://src/test/take_screenshot.gd (windowed) + +var frames := 0 +const TARGET_FRAMES := 300 + +func _initialize() -> void: + # Launch the main scene. + var packed: PackedScene = load("res://scenes/main.tscn") + var inst: Node = packed.instantiate() + root.add_child(inst) + +func _process(_delta: float) -> bool: + frames += 1 + if frames == 60: + var img: Image = root.get_texture().get_image() + var err := img.save_png("user://screenshot.png") + print("screenshot saved err=", err) + quit(0) + return true + if frames > TARGET_FRAMES: + quit(0) + return true + return false diff --git a/src/test/test_runner.gd b/src/test/test_runner.gd new file mode 100644 index 0000000..91ae4f1 --- /dev/null +++ b/src/test/test_runner.gd @@ -0,0 +1,73 @@ +extends SceneTree +## Minimal headless test runner for CI (used in place of an editor-based addon +## until a Godot editor workflow is introduced). +## +## Usage: +## godot --headless --path . --script res://src/test/test_runner.gd +## Exit code 0 = all passed, 1 = failures. + +const TestUtils := preload("res://src/test/test_utils.gd") +const TEST_DIR := "res://src/test/tests" + +func _initialize() -> void: + var test_files := _list_gd_files(TEST_DIR) + var total_passed := 0 + var total_failed := 0 + var failures: Array[String] = [] + + for path in test_files: + var script: GDScript = load(path) + if script == null: + failures.append("Could not load %s" % path) + total_failed += 1 + continue + var instance: Object = script.new() + TestUtils.reset() + var suite_passed := 0 + var suite_failed := 0 + for method_info in instance.get_method_list(): + var method: StringName = method_info["name"] + if not String(method).begins_with("test_"): + continue + if method_info["flags"] & METHOD_FLAG_STATIC: + continue + TestUtils.reset() + var result: Variant = instance.call(method) + var errored: bool = TestUtils._errors.size() > 0 + if result == true and not errored: + suite_passed += 1 + else: + suite_failed += 1 + var detail := "" + if errored: + detail = " :: " + TestUtils.report() + failures.append("%s.%s%s" % [path.get_file(), method, detail]) + total_passed += suite_passed + total_failed += suite_failed + print("Suite %-42s pass=%d fail=%d" % [path.get_file(), suite_passed, suite_failed]) + + print("") + print("==================== TEST SUMMARY ====================") + print("Passed: %d Failed: %d" % [total_passed, total_failed]) + if failures.size() > 0: + print("--------------------------------------------------") + for f in failures: + print("FAIL: %s" % f) + print("======================================================") + quit(0 if total_failed == 0 else 1) + +func _list_gd_files(dir_path: String) -> Array[String]: + var out: Array[String] = [] + var dir := DirAccess.open(dir_path) + if dir == null: + push_error("Cannot open test dir %s" % dir_path) + return out + dir.list_dir_begin() + var file_name := dir.get_next() + while file_name != "": + if not dir.current_is_dir() and file_name.ends_with(".gd"): + out.append(dir_path.path_join(file_name)) + file_name = dir.get_next() + dir.list_dir_end() + out.sort() + return out diff --git a/src/test/test_utils.gd b/src/test/test_utils.gd new file mode 100644 index 0000000..590e403 --- /dev/null +++ b/src/test/test_utils.gd @@ -0,0 +1,32 @@ +class_name TestUtils +## Tiny assertion helpers shared by headless test suites. + +static var _errors: Array[String] = [] + +static func reset() -> void: + _errors.clear() + +## Returns true and records the failure message when condition is false. +static func check(condition: bool, message: String) -> bool: + if not condition: + _errors.append(message) + return condition + +static func check_eq(actual: Variant, expected: Variant, message: String) -> bool: + if actual == expected: + return true + _errors.append("%s (expected %s, got %s)" % [message, str(expected), str(actual)]) + return false + +static func check_dict_has(data: Dictionary, key: String, message: String) -> bool: + if data.has(key): + return true + _errors.append("%s (missing key %s)" % [message, key]) + return false + +static func fail(message: String) -> bool: + _errors.append(message) + return false + +static func report() -> String: + return "\n".join(_errors) diff --git a/src/test/tests/test_access_verifier.gd b/src/test/tests/test_access_verifier.gd new file mode 100644 index 0000000..8ee28c3 --- /dev/null +++ b/src/test/tests/test_access_verifier.gd @@ -0,0 +1,120 @@ +extends RefCounted +## Headless tests for AccessVerifier (ADR-0003, Access Policy). + +const Rules := preload("res://src/core/rules.gd") +const AccessVerifier := preload("res://src/core/access_verifier.gd") +const AccessPolicy := preload("res://src/core/access_policy.gd") +const AccessArtifact := preload("res://src/core/access_artifact.gd") + +var verifier := AccessVerifier.new() + +func test_wristband_valid_grants() -> bool: + var policy := AccessPolicy.new() + policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true } + var artifacts: Array[AccessArtifact] = [ + AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, {"color": "red", "date": "2026-02-13", "event": "gala"}), + ] + var result := verifier.verify(artifacts, policy) + return TestUtils.check_eq(result["granted"], true, "valid wristband should grant access") + +func test_wristband_expired_denies() -> bool: + var policy := AccessPolicy.new() + policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true } + var artifacts: Array[AccessArtifact] = [ + AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.EXPIRED, {}), + ] + var result := verifier.verify(artifacts, policy) + return TestUtils.check_eq(result["granted"], false, "expired wristband must deny") + +func test_wristband_counterfeit_denies() -> bool: + var policy := AccessPolicy.new() + policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true } + var artifacts: Array[AccessArtifact] = [ + AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {}), + ] + var result := verifier.verify(artifacts, policy) + return TestUtils.check_eq(result["granted"], false, "counterfeit wristband must deny") + +func test_missing_wristband_denies() -> bool: + var policy := AccessPolicy.new() + policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true } + var result := verifier.verify([], policy) + return TestUtils.check_eq(result["granted"], false, "missing wristband must deny") + +func test_recognition_grants_without_wristband() -> bool: + ## PLAN.md: "The guard already knows and recognizes them." + var policy := AccessPolicy.new() + policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true } + var artifacts: Array[AccessArtifact] = [ + AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {}), + ] + var result := verifier.verify(artifacts, policy) + return TestUtils.check_eq(result["granted"], true, "recognition should grant access") + +func test_recognition_expired_does_not_grant() -> bool: + var policy := AccessPolicy.new() + policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true } + var artifacts: Array[AccessArtifact] = [ + AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.MISSING, {}), + ] + var result := verifier.verify(artifacts, policy) + return TestUtils.check_eq(result["granted"], false, "missing recognition should not grant under band policy") + +func test_joke_shop_id_and_ticket() -> bool: + var policy := AccessPolicy.new() + policy.required_artifacts = { Rules.AccessKind.ID: true, Rules.AccessKind.TICKET: true } + policy.minimum_age = 21 + var ok: Array[AccessArtifact] = [ + AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 24}), + AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}), + ] + var denied_underage: Array[AccessArtifact] = [ + AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 19}), + AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}), + ] + var denied_no_ticket: Array[AccessArtifact] = [ + AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 24}), + ] + var ok_result := verifier.verify(ok, policy) + var denied_underage_result := verifier.verify(denied_underage, policy) + var denied_no_ticket_result := verifier.verify(denied_no_ticket, policy) + var a := TestUtils.check_eq(ok_result["granted"], true, "valid ID + ticket grants") + var b := TestUtils.check_eq(denied_underage_result["granted"], false, "underage denied") + var c := TestUtils.check_eq(denied_no_ticket_result["granted"], false, "no ticket denied") + return a and b and c + +func test_joke_shop_requires_wand_screen() -> bool: + var policy := AccessPolicy.new() + policy.required_artifacts = { Rules.AccessKind.ID: true, Rules.AccessKind.TICKET: true } + policy.wand_screening_required = true + var artifacts: Array[AccessArtifact] = [ + AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 25}), + AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}), + ] + var result := verifier.verify(artifacts, policy) + return TestUtils.check_eq(result["granted"], false, "unscreened guest denied when screening required") + +func test_tv_studio_any_valid_grants() -> bool: + ## OR-admission: any single valid artifact among several kinds grants entry. + var policy := AccessPolicy.new() + policy.required_artifacts = { + Rules.AccessKind.CREDENTIAL: true, + Rules.AccessKind.WRISTBAND: true, + Rules.AccessKind.TICKET: true, + Rules.AccessKind.GUEST_LIST: true, + } + policy.grant_if_any_required = true + var with_wristband: Array[AccessArtifact] = [ + AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, {}), + ] + var with_guest_list: Array[AccessArtifact] = [ + AccessArtifact.new(Rules.AccessKind.GUEST_LIST, Rules.ArtifactStatus.VALID, {}), + ] + var all_fake: Array[AccessArtifact] = [ + AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {}), + AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.EXPIRED, {}), + ] + var a := TestUtils.check_eq(verifier.verify(with_wristband, policy)["granted"], true, "wristband grants") + var b := TestUtils.check_eq(verifier.verify(with_guest_list, policy)["granted"], true, "guest list grants") + var c := TestUtils.check_eq(verifier.verify(all_fake, policy)["granted"], false, "all invalid denied") + return a and b and c diff --git a/src/test/tests/test_escalation_and_radio.gd b/src/test/tests/test_escalation_and_radio.gd new file mode 100644 index 0000000..256dc82 --- /dev/null +++ b/src/test/tests/test_escalation_and_radio.gd @@ -0,0 +1,52 @@ +extends RefCounted +## Headless tests for EscalationManager and RadioSystem. + +const Rules := preload("res://src/core/rules.gd") +const EscalationManager := preload("res://src/core/escalation_manager.gd") +const RadioSystem := preload("res://src/core/radio_system.gd") + + +func test_level_advance_and_gate() -> bool: + var mgr := EscalationManager.new(Rules.Level.EMERGENCY) + var a := TestUtils.check_eq(mgr.current_level, Rules.Level.OBSERVATION, "starts at observation") + var b := TestUtils.check(mgr.can_act(Rules.Level.FORMAL_WARNING), "warning is allowed") + var c := TestUtils.check(mgr.advance_to(Rules.Level.FORMAL_WARNING, "refused_direction"), "advanced to warning") + return a and b and c + +func test_cannot_exceed_max_level() -> bool: + var mgr := EscalationManager.new(Rules.Level.BLOCKING) + var blocked := TestUtils.check(not mgr.advance_to(Rules.Level.EMERGENCY, "unavailable"), "emergency blocked by venue rules") + var stayed := TestUtils.check_eq(mgr.current_level, Rules.Level.OBSERVATION, "stayed at observation") + return blocked and stayed + +func test_radio_assist_consumes_battery() -> bool: + var radio := RadioSystem.new() + var call: Dictionary = radio.request_assistance({ + "location": "front steps", "description": "person refusing to leave", + "behavior": "refusing", "weapons_seen": false, "danger_level": 1, + }) + var a := TestUtils.check(call["ok"], "assist call accepted") + var b := TestUtils.check(radio.battery < radio.max_battery, "battery consumed") + return a and b + +func test_emergency_cooldown_blocks_spam() -> bool: + var radio := RadioSystem.new() + var first: Dictionary = radio.request_emergency({"danger_level": 3, "weapons_seen": true}) + var a := TestUtils.check(first["ok"], "first emergency accepted") + var second: Dictionary = radio.request_emergency({"danger_level": 3, "weapons_seen": true}) + var b := TestUtils.check(not second["ok"], "second immediately rejected") + var c := TestUtils.check(second["reason"].contains("Cooldown"), "cooldown reason given") + return a and b and c + +func test_emergency_after_cooldown_expires() -> bool: + var radio := RadioSystem.new() + radio.request_emergency({"danger_level": 3, "weapons_seen": true}) + radio.tick(radio.emergency_cooldown_seconds + 1.0) + var again: Dictionary = radio.request_emergency({"danger_level": 3, "weapons_seen": true}) + return TestUtils.check(again["ok"], "emergency available after cooldown") + +func test_dead_battery_blocks_calls() -> bool: + var radio := RadioSystem.new() + radio.battery = 0.0 + var call: Dictionary = radio.request_assistance({"danger_level": 1}) + return TestUtils.check(not call["ok"], "no battery means no call") and TestUtils.check(call["reason"].contains("battery"), "battery reason") diff --git a/src/test/tests/test_npc_reactions.gd b/src/test/tests/test_npc_reactions.gd new file mode 100644 index 0000000..3a106cb --- /dev/null +++ b/src/test/tests/test_npc_reactions.gd @@ -0,0 +1,83 @@ +extends RefCounted +## Headless tests for NPC reaction rolls (PLAN.md: NPC Behavior Types). + +const Rules := preload("res://src/core/rules.gd") +const NpcProfile := preload("res://src/core/npc_profile.gd") +const NPCController := preload("res://src/core/npc_controller.gd") + +func _profile_with_defaults(id: String, npc_type: int, compliance: float) -> NpcProfile: + var p := NpcProfile.new() + p.id = id + p.npc_type = npc_type + p.base_compliance = compliance + return p + +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 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") + +func test_trespasser_frequent_non_compliance() -> bool: + var p := _profile_with_defaults("trespasser", Rules.NpcType.TRESPASSER, 0.2) + var npc := NPCController.new() + npc.setup("t1", p, "trespass", 99, "AT_ENTRANCE") + ## Several rolls at formal warning; default table for this test profile is + ## 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)) + ## 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 + +func test_deterministic_with_seed() -> bool: + ## Same seed and inputs => same reaction sequence. + var p1 := _profile_with_defaults("guest_a", Rules.NpcType.TRESPASSER, 0.4) + var n1 := NPCController.new() + n1.setup("a", p1, "trespass", 4242, "AT_ENTRANCE") + + var p2 := _profile_with_defaults("guest_b", Rules.NpcType.TRESPASSER, 0.4) + var n2 := NPCController.new() + n2.setup("b", p2, "trespass", 4242, "AT_ENTRANCE") + + 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)) + return TestUtils.check_eq(seq_a, seq_b, "same seed produces identical reaction sequence") + +func test_violent_threat_scales_to_threaten() -> bool: + ## Violent archetype with default reactions: from formal warning upward there + ## is a meaningful probability of THREATEN / FORCE_ENTRY. We sample enough + ## rolls to see at least one THREATEN with the violent profile rng. + var p := NpcProfile.new() + p.id = "violent" + p.npc_type = Rules.NpcType.VIOLENT_THREAT + p.base_compliance = 0.05 + p.reactions_by_level = { + Rules.Level.EMERGENCY: { + Rules.Reaction.THREATEN: 40, + Rules.Reaction.FORCE_ENTRY: 30, + Rules.Reaction.LEAVE: 30, + }, + } + var npc := NPCController.new() + 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: + saw_threat = true + return TestUtils.check(saw_threat, "violent profile can threaten at emergency level") + +func test_fsm_transitions() -> bool: + var p := _profile_with_defaults("guest", Rules.NpcType.TOURIST, 0.9) + 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) + var left := TestUtils.check_eq(npc.fsm.current_state, "LEAVING", "complies and leaves") + return started and left diff --git a/src/test/tests/test_scenarios.gd b/src/test/tests/test_scenarios.gd new file mode 100644 index 0000000..381b6c4 --- /dev/null +++ b/src/test/tests/test_scenarios.gd @@ -0,0 +1,67 @@ +extends RefCounted +## Headless tests for venue scenario data (Plan: Venues and Scenarios). + +const Rules := preload("res://src/core/rules.gd") +const VenueScenario := preload("res://src/core/venue_scenario.gd") +const ScenarioFactory := preload("res://src/content/scenario_factory.gd") +const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd") + + +func test_hollywood_building_loads() -> bool: + var scenario: VenueScenario = ScenarioFactory.hollywood_building() + var a := TestUtils.check_eq(scenario.id, "hollywood_building", "scenario id") + var b := TestUtils.check(scenario.npc_profiles.size() > 0, "has profiles") + var c := TestUtils.check(scenario.access_policy.requires(Rules.AccessKind.WRISTBAND), "requires wristband") + return a and b and c + +func test_joke_shop_rules() -> bool: + var scenario: VenueScenario = ScenarioFactory.joke_shop() + var a := TestUtils.check_eq(scenario.access_policy.minimum_age, 21, "21+ club") + var b := TestUtils.check(scenario.access_policy.require_id, "ID required") + var c := TestUtils.check(scenario.access_policy.wand_screening_required, "wand screening required") + var d := TestUtils.check(scenario.equipment.has("wand"), "wand in equipment") + return a and b and c and d + +func test_tv_studio_has_all_npc_types() -> bool: + var scenario: VenueScenario = ScenarioFactory.tv_studio() + var all_types := [Rules.NpcType.TOURIST, Rules.NpcType.PERFORMER, Rules.NpcType.INFLUENCER, + Rules.NpcType.CONFUSED_VISITOR, Rules.NpcType.INTOXICATED, Rules.NpcType.TRESPASSER, + Rules.NpcType.MENTAL_CRISIS, Rules.NpcType.VIOLENT_THREAT, + Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY] + var profile_types: Array = [] + for p in scenario.npc_profiles: + profile_types.append(p.npc_type) + for npc_type in all_types: + if not profile_types.has(npc_type): + return TestUtils.fail("TV studio missing npc type %s" % Rules.NPC_TYPE_NAMES[npc_type]) + return true + +var _spawn_count := 0 + +func _on_spawn(_profile: NpcProfile, _intent: String, _id: String) -> void: + _spawn_count += 1 + +func test_shift_coordinator_runs_a_shift() -> bool: + var scenario: VenueScenario = ScenarioFactory.hollywood_building() + var coordinator := ShiftCoordinator.new() + coordinator.setup(scenario, 60.0, 1234) + _spawn_count = 0 + coordinator.npc_spawn_requested.connect(_on_spawn) + coordinator.start() + for i in 60: + coordinator.tick(1.0) + if not coordinator.running: + break + var a := TestUtils.check(coordinator.elapsed_s > 0.0, "time advanced") + var b := TestUtils.check(_spawn_count > 0, "NPCs spawned during shift") + var review := coordinator.build_review() + var c := TestUtils.check_dict_has(review, "reputation", "review has reputation") + return a and b and c + +func test_emergency_event_requires_emergency() -> bool: + var scenario: VenueScenario = ScenarioFactory.hollywood_building() + var found := false + for event in scenario.event_pool: + if event.get("requires_emergency", false): + found = true + return TestUtils.check(found, "scenario contains a scripted emergency event")