Compare commits
@@ -0,0 +1,16 @@
|
||||
# Git Safety
|
||||
|
||||
## Staging rules
|
||||
|
||||
- **Stage files by name** — never use `git add -A` or `git add .`
|
||||
- Verify no secrets, saves, or binary blobs are staged
|
||||
- Skip files in `.gitignore`
|
||||
- The `.claude/` directory IS tracked — skills and agents belong in the repo
|
||||
|
||||
## Commit conventions
|
||||
|
||||
Use conventional commits: `<type>(<scope>): <summary>`
|
||||
|
||||
Scopes: `agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
||||
|
||||
See `/git-commit` for full commit format, types, CHANGELOG workflow, and examples.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Local Services
|
||||
|
||||
Endpoints are also preconfigured in `db/connectors/config.json`.
|
||||
|
||||
- **Gitea:** `http://git.schweitz.internal` (login: `schweitz`)
|
||||
- **Qdrant:** `http://tower-of-joy:6333/`
|
||||
- **Ollama:** `http://tower-of-joy:11434/` (nomic-embed-text)
|
||||
- **Collection:** `commonwealth` (768 dimensions, cosine distance)
|
||||
@@ -0,0 +1,40 @@
|
||||
# Project Structure (detailed)
|
||||
|
||||
```
|
||||
client/ # Godot 4 client
|
||||
server/ # Rust/bevy_ecs simulation server
|
||||
tooling/ # Build tools, scripts, asset pipelines
|
||||
tests/ # Integration and end-to-end tests
|
||||
.config/ # Configuration files (linters, formatters, CI)
|
||||
.cache/ # Local caches for testing/linting (gitignored)
|
||||
docs/
|
||||
discussions/ # Discussion rounds (archived here when complete)
|
||||
briefings/ # Per-agent context briefings (maintained by Qatux)
|
||||
architecture/ # Technical architecture documents
|
||||
design/ # Game design documents
|
||||
diagrams/ # d2 source + PNG renders
|
||||
sprints/ # Sprint briefings per team
|
||||
workshops/ # Workshop briefs and outputs
|
||||
db/
|
||||
schema.sql # Database schema
|
||||
connectors/ # Connector scripts for SQLite and Qdrant
|
||||
config.json # Endpoint configuration
|
||||
ticket # Ticket CLI
|
||||
sqlite_connector.py # SQLite mini MCP
|
||||
qdrant_connector.py # Qdrant + ollama mini MCP
|
||||
.claude/
|
||||
agents/ # Agent personality files
|
||||
skills/ # Skill definitions
|
||||
rules/ # Auto-loaded instruction modules
|
||||
decisions/ # Decision domain files (source of truth)
|
||||
README.md # Domain index — use this to find specific D-records
|
||||
architecture.md # Architecture decisions
|
||||
perception.md # Perception and information system decisions
|
||||
content.md # Content and narrative decisions
|
||||
scope.md # Scope and feature decisions
|
||||
process.md # Process and workflow decisions
|
||||
questions.md # Open questions (Q-NNN)
|
||||
rejected.md # Rejected proposals (R-NNN)
|
||||
DECISIONS.md # Redirect to decisions/ directory
|
||||
TEAM.md # Team roster and roles
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
# Gitea Access (tea CLI)
|
||||
|
||||
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
|
||||
|
||||
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
|
||||
|
||||
```bash
|
||||
# List open PRs
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
|
||||
# View a PR with comments
|
||||
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
|
||||
|
||||
# Post a comment on a PR (or issue)
|
||||
tooling/tea-comment <NUMBER> "comment body"
|
||||
|
||||
# Approve a PR
|
||||
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||
|
||||
# List issues
|
||||
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
## Key rules
|
||||
|
||||
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
||||
- **Use `--output simple`** for machine-readable output (no table borders)
|
||||
- **For comments, use `tooling/tea-comment <number> "body"`** — handles temp files and cleanup automatically. Works with multi-line strings.
|
||||
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
||||
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
||||
|
||||
## Pull requests
|
||||
|
||||
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
|
||||
|
||||
Always provide all required flags to ensure non-interactive execution:
|
||||
```bash
|
||||
tea pr create \
|
||||
--repo jpmschweitzer/settled-reach \
|
||||
--login schweitz \
|
||||
--title "feat(scope): short description" \
|
||||
--description "PR body here" \
|
||||
--base main \
|
||||
--head branch-name
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
# Team Patterns
|
||||
|
||||
## Model selection
|
||||
|
||||
Default model is Opus 4.6 (200K context). For heavy sessions (workshops,
|
||||
sprint planning, large reviews), switch to extended context on-demand:
|
||||
|
||||
- `/model sonnet[1m]` — Sonnet 4.6 with 1M context window
|
||||
- `/model opus[1m]` — Opus 4.6 with 1M context window
|
||||
- Cost: 2x input + 1.5x output for tokens beyond 200K (Tier 4 required)
|
||||
|
||||
## Large content pushes
|
||||
|
||||
When producing many files (wiki pages, content batches, bulk docs):
|
||||
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
|
||||
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
|
||||
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
||||
|
||||
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
||||
|
||||
## Team monitoring (stuck agent detection)
|
||||
|
||||
When leading a team (sprint, workshop, or any multi-agent session):
|
||||
|
||||
**Agent heartbeat rule** — include in every agent spawn prompt:
|
||||
> If you have been working on a single task for more than 15 minutes
|
||||
> without making progress, message the team lead with what is blocking
|
||||
> you. Do not keep retrying the same approach silently.
|
||||
|
||||
**Team lead proactive checks:**
|
||||
- If an agent has not sent a message in ~20 minutes, ping them for a status update.
|
||||
- **Bottleneck detection:** if other agents are idle and waiting on one agent's output, that agent's silence is a red flag — check on them immediately, do not wait for the next natural message.
|
||||
- When checking on a stuck agent, offer to reassign the task or pull in another agent to help.
|
||||
@@ -44,6 +44,7 @@
|
||||
"Bash(make)",
|
||||
|
||||
"Bash(tea *)",
|
||||
"Bash(tooling/tea-comment *)",
|
||||
|
||||
"Bash(chmod *)",
|
||||
"Bash(ls *)",
|
||||
|
||||
@@ -10,9 +10,9 @@ allowed-tools: Bash, Read, Grep, Glob
|
||||
|
||||
# Search Docs Skill
|
||||
|
||||
Semantic search across project documents. Basic commands (`qdrant-search`,
|
||||
`qdrant-index`, `qdrant-health`, `qdrant-count`) and endpoints are documented
|
||||
in CLAUDE.md. This skill covers advanced operations and workflows.
|
||||
Semantic search across project documents. Endpoints are in
|
||||
`.claude/rules/local-services.md`. This skill covers advanced operations
|
||||
and workflows.
|
||||
|
||||
## Advanced Commands
|
||||
|
||||
|
||||
@@ -136,7 +136,5 @@ chore(meta): release v0.1.0
|
||||
|
||||
## Staging Rules
|
||||
|
||||
- Stage files by name — never use `git add -A` or `git add .`
|
||||
- Verify no secrets, saves, or binary blobs are staged
|
||||
- Skip files in `.gitignore`
|
||||
- The `.claude/` directory IS tracked — skills belong in the repo
|
||||
See `.claude/rules/git-safety.md` for staging rules (always-loaded).
|
||||
These apply to ALL git operations, not just this skill.
|
||||
|
||||
@@ -100,15 +100,11 @@ git diff --stat main...<branch>
|
||||
Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description.
|
||||
|
||||
```bash
|
||||
cat > /tmp/pr-body.md << 'EOF'
|
||||
## Summary
|
||||
...
|
||||
EOF
|
||||
tea pr create \
|
||||
--repo jpmschweitzer/settled-reach \
|
||||
--login schweitz \
|
||||
--title "<title>" \
|
||||
--description "$(cat /tmp/pr-body.md)" \
|
||||
--description "## Summary ..." \
|
||||
--base main \
|
||||
--head <branch>
|
||||
```
|
||||
@@ -134,6 +130,14 @@ Report which tickets were moved to review. Skip tickets that are
|
||||
already `done`, `review`, `cancelled`, or `backlog` (only transition
|
||||
`in_progress` → `review`).
|
||||
|
||||
### 9. Next steps
|
||||
|
||||
If a sprint team is active (you are the team lead), do NOT shut down
|
||||
agents after pushing. The team should remain alive for PR review and
|
||||
potential comment fixes.
|
||||
|
||||
Suggest: "PR created/updated. Run `/pr-review` to review before merge."
|
||||
|
||||
## Arguments
|
||||
|
||||
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
|
||||
|
||||
@@ -16,11 +16,20 @@ on the branch type. All reviewers must approve for a clean review.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine the branch
|
||||
### 0. Branch guard — MUST be on `main`
|
||||
|
||||
If the user provided a branch name as argument, use it. Otherwise use the
|
||||
current branch (`git branch --show-current`). If on `main`, ask the user
|
||||
which branch to review.
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
If the current branch is **not `main`**, stop immediately and tell the user:
|
||||
"PR reviews must be run from the `main` worktree. Switch to `main` first."
|
||||
Do NOT proceed with the review from a team branch.
|
||||
|
||||
### 1. Determine the branch to review
|
||||
|
||||
If the user provided a branch name as argument, use it. Otherwise list open
|
||||
PRs and ask the user which branch to review.
|
||||
|
||||
To list open PRs on Gitea:
|
||||
```bash
|
||||
@@ -174,19 +183,11 @@ After presenting results to the user, post the review as a PR comment.
|
||||
|
||||
Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
|
||||
|
||||
**IMPORTANT — `tea comment` hangs with inline heredocs and multi-line strings.**
|
||||
Always use a two-step approach: write to a temp file first, then pass via `$(cat)`:
|
||||
Post using the `tea-comment` wrapper (handles temp files and cleanup):
|
||||
|
||||
```bash
|
||||
tooling/tea-comment <PR_NUMBER> "review markdown here"
|
||||
```
|
||||
# Step 1: Write review to .tmp/ using the Write tool (no permission prompt)
|
||||
Write(file_path: "<repo_root>/.tmp/review-<branch>.md", content: "...review content...")
|
||||
|
||||
# Step 2: Post to Gitea (separate Bash call)
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat .tmp/review-<branch>.md)"
|
||||
```
|
||||
|
||||
Use the Write tool for step 1 (avoids Bash permission prompts). The `.tmp/`
|
||||
directory is gitignored and exists in the repo root for this purpose.
|
||||
|
||||
## 7. Merging approved PRs
|
||||
|
||||
@@ -203,6 +204,22 @@ tea pr close --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||
Gitea does **not** auto-close PRs when you push a local merge — always close
|
||||
manually with `tea pr close` after pushing.
|
||||
|
||||
### 8. Post-review team actions
|
||||
|
||||
If a sprint team is active and you are the team lead, handle the
|
||||
review outcome:
|
||||
|
||||
**CHANGES_REQUESTED:**
|
||||
The sprint-start lifecycle (step 9c) handles dispatching review
|
||||
comments to agents. After presenting results, remind the lead:
|
||||
"Review requested changes. Create tasks from the warnings/critical
|
||||
issues and dispatch to idle agents, then re-push and re-review."
|
||||
|
||||
**APPROVED:**
|
||||
The sprint-start lifecycle (step 9c) handles shutdown. After
|
||||
presenting results, remind the lead: "Review approved. Proceed with
|
||||
team shutdown per sprint-start step 9c."
|
||||
|
||||
## Tips from practice
|
||||
|
||||
- **Vendor code**: Explicitly note vendor code in the prompt so reviewers focus
|
||||
|
||||
@@ -325,3 +325,57 @@ Output to the user:
|
||||
You are now the team lead. Agents work autonomously — monitor via
|
||||
`TaskList`, communicate via `SendMessage`, and handle blockers as
|
||||
they arise.
|
||||
|
||||
**When all tasks complete:** Do NOT shut down agents. The team stays
|
||||
alive through the PR review cycle. Follow step 9 (post-work lifecycle).
|
||||
|
||||
### 9. Post-work lifecycle
|
||||
|
||||
When all tasks are complete (TaskList shows all completed):
|
||||
|
||||
#### 9a. Commit and push
|
||||
|
||||
Run `/git-commit` to commit all changes, then `/pr-push` to create or
|
||||
update the PR. Do NOT shut down agents — the team stays alive for review.
|
||||
|
||||
#### 9b. Review
|
||||
|
||||
Run `/pr-review` to spawn temporary reviewers. Wait for results.
|
||||
|
||||
#### 9c. Handle review outcome
|
||||
|
||||
**If CHANGES_REQUESTED:**
|
||||
|
||||
1. Parse the review comment table (from the Gitea PR comment or the
|
||||
review output). Extract each warning/critical issue with:
|
||||
- File path and approximate line
|
||||
- Severity (critical / warning / suggestion)
|
||||
- Description
|
||||
|
||||
2. Create a task per warning/critical issue:
|
||||
```
|
||||
TaskCreate(
|
||||
subject: "Review: {short description}",
|
||||
description: "{full issue description from review table, including
|
||||
file path, severity, and reviewer name}",
|
||||
activeForm: "Fixing review comment: {short description}"
|
||||
)
|
||||
```
|
||||
Skip suggestion-severity items unless they are trivial (1-line fixes).
|
||||
|
||||
3. Dispatch to idle agents: send each a message via SendMessage telling
|
||||
them to check TaskList for new review-fix tasks. Agents claim and
|
||||
work tasks as usual.
|
||||
|
||||
4. After all review-fix tasks are complete, re-run `/git-commit` then
|
||||
`/pr-push` to update the PR. Then re-run `/pr-review`.
|
||||
|
||||
5. Repeat this loop until review returns APPROVED.
|
||||
|
||||
**If APPROVED:**
|
||||
|
||||
1. Send `shutdown_request` to all sprint agents.
|
||||
2. Wait for all `shutdown_response` confirmations.
|
||||
3. Call `TeamDelete` to clean up.
|
||||
4. Report: "Sprint {N} {team} complete. PR #{X} approved and ready for
|
||||
merge on main."
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: sprint-status
|
||||
description: >
|
||||
Sprint health check and cleanup sweep. Lists all tickets in the active
|
||||
sprint grouped by status, detects bookkeeping issues (stale tickets,
|
||||
orphan PRs, done-but-open PRs, unassigned work), and shows open work
|
||||
by team. Use when checking sprint progress, before sprint close, or
|
||||
when housekeeping feels off. Triggers on "sprint status", "cleanup
|
||||
sweep", "what's open", "sprint health".
|
||||
user-invocable: true
|
||||
allowed-tools: Task, Read, Grep, Glob
|
||||
---
|
||||
|
||||
# Sprint Status
|
||||
|
||||
**Delegate this entire skill to a subagent** (general-purpose, model: haiku).
|
||||
|
||||
When this skill is invoked, spawn a subagent using the Task tool:
|
||||
|
||||
```
|
||||
Task(
|
||||
subagent_type: "general-purpose",
|
||||
model: "haiku",
|
||||
prompt: "Run /sprint-status. Read the skill at
|
||||
.claude/skills/sprint-status/SKILL.md for the full workflow
|
||||
(below the --- separator), then execute it.",
|
||||
description: "Sprint status report"
|
||||
)
|
||||
```
|
||||
|
||||
Present the subagent's output to the user verbatim. Do NOT run the
|
||||
workflow yourself.
|
||||
|
||||
---
|
||||
|
||||
The remainder of this file is the subagent's reference for executing
|
||||
the workflow.
|
||||
|
||||
## Step 1 — Gather data
|
||||
|
||||
Run these two commands in parallel:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint sweep
|
||||
```
|
||||
|
||||
```bash
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
The `sweep` command returns JSON with:
|
||||
- `sprint` — id, name, goal
|
||||
- `progress` — total, done, pct
|
||||
- `by_status` — tickets grouped into done, review, in_progress, blocked, backlog
|
||||
- `by_team` — per-team counts
|
||||
- `issues` — bookkeeping problems with suggested fix commands
|
||||
|
||||
The `tea pr list` returns open PRs as `#N title` lines.
|
||||
|
||||
## Step 2 — Cross-reference PRs with tickets
|
||||
|
||||
Parse PR head branches from the `tea pr list` output. Known team branches:
|
||||
`server`, `client`, `copy`, `audio`, `visual`, `ci`.
|
||||
|
||||
Detect additional issues:
|
||||
|
||||
- **done_team_open_pr**: A team's tickets are all done but an open PR
|
||||
still exists for that team branch.
|
||||
- **orphan_pr**: An open PR exists on a branch that has no tickets in
|
||||
the active sprint.
|
||||
|
||||
Add these to the issues list from step 1.
|
||||
|
||||
## Step 3 — Format output
|
||||
|
||||
Read `references/output-template.md` for the exact format spec.
|
||||
|
||||
Render the report using data from steps 1-2. Key rules:
|
||||
- Sections ordered: Completed, In Review, In Progress, Blocked, Backlog
|
||||
- Sort tickets within sections by team then ticket ID
|
||||
- Empty sections: show header with "(0)" and "(none)" — no empty table
|
||||
- Bookkeeping Issues: two-column table (Issue, Fix)
|
||||
- Open Work by Team: summary table at the bottom
|
||||
- Issue type labels: `stale_backlog` → "Stale backlog",
|
||||
`unassigned_in_progress` → "Unassigned in_progress",
|
||||
`assigned_but_done` → "Assigned but done",
|
||||
`done_team_open_pr` → "Done team with open PR",
|
||||
`orphan_pr` → "Orphan PR"
|
||||
|
||||
## Step 4 — Suggest actions
|
||||
|
||||
After the formatted report, if there are bookkeeping issues, add a
|
||||
"Suggested fixes" section with the fix command for each issue. Group
|
||||
by issue type for readability.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Sprint Status Output Template
|
||||
|
||||
## Sprint {N}: {Theme} — Status Report
|
||||
|
||||
**Goal:** {goal}
|
||||
**Status:** {status} | {done}/{total} tickets ({pct}%)
|
||||
**Open PRs:** {count} ({branches})
|
||||
|
||||
---
|
||||
|
||||
### Completed ({count})
|
||||
|
||||
| # | Team | Title | Assigned |
|
||||
|---|------|-------|----------|
|
||||
| #{id} | {team} | {title} | {assigned} |
|
||||
|
||||
### In Review ({count})
|
||||
|
||||
| # | Team | Title | PR |
|
||||
|---|------|-------|----|
|
||||
| #{id} | {team} | {title} | #{pr} |
|
||||
|
||||
### In Progress ({count})
|
||||
|
||||
| # | Team | Title | Assigned | Note |
|
||||
|---|------|-------|----------|------|
|
||||
| #{id} | {team} | {title} | {assigned} | |
|
||||
|
||||
### Blocked ({count})
|
||||
|
||||
| # | Team | Title | Blocked by |
|
||||
|---|------|-------|------------|
|
||||
| #{id} | {team} | {title} | #{ids} |
|
||||
|
||||
### Backlog ({count})
|
||||
|
||||
| # | Team | Title | Note |
|
||||
|---|------|-------|----|
|
||||
| #{id} | {team} | {title} | not started |
|
||||
|
||||
---
|
||||
|
||||
### Bookkeeping Issues
|
||||
|
||||
| Issue | Fix |
|
||||
|-------|-----|
|
||||
| {type}: {detail} | `{command}` |
|
||||
|
||||
### Open Work by Team
|
||||
|
||||
| Team | Backlog | In Progress | Review | Blocked | Done |
|
||||
|------|---------|-------------|--------|---------|------|
|
||||
| {team} | {n} | {n} | {n} | {n} | {n} |
|
||||
| **Total** | **{n}** | **{n}** | **{n}** | **{n}** | **{n}** |
|
||||
@@ -10,9 +10,8 @@ allowed-tools: Bash, Read, Grep, Glob
|
||||
|
||||
# Ticket Skill
|
||||
|
||||
Manage the project ticketing database. Basic usage (`ticket list`, `ticket show`,
|
||||
`ticket sprint --active`) and raw SQL wrappers are documented in CLAUDE.md.
|
||||
This skill covers the full command reference.
|
||||
Manage the project ticketing database. Basic usage is in CLAUDE.md's CLI tools
|
||||
section. This skill covers the full command reference.
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ run_check() {
|
||||
|
||||
# --- Checks ---
|
||||
run_check "tooling/check-fact-ids" "fact_id validation"
|
||||
run_check "tooling/check-decision-ids" "decision ID duplication"
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo ""
|
||||
|
||||
@@ -43,3 +43,4 @@ Thumbs.db
|
||||
# Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked
|
||||
.claude/plans/
|
||||
.claude/projects/
|
||||
.claude/agent-memory/
|
||||
|
||||
@@ -6,6 +6,99 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- gdUnit4 CI runner script — headless test execution via `run_gdunit4.gd` with exit code for CI (#205)
|
||||
- Scene testing utilities — SceneHelper class with node existence, signal, and path helpers for gdUnit4 (#206)
|
||||
- GameState apply_snapshot tests — 14 tests covering v2+ fields: game_time, facing, interactions, monologue, stance, inventory (#206)
|
||||
- Game session management — per-game save directories under `user://saves/<timestamp>-<seed>/` per D-085, SessionManager autoload, main menu scene (#258)
|
||||
- Debug visualization overlay — F3-toggled dev overlay with LOS rays, vision cone arcs, NPC path trails, knowledge confidence tags, tick timing sparkline (#348)
|
||||
|
||||
- Protocol v14 — `poi_list`, `examine_result`, `player_knowledge` ObserverSnapshot wire types with live KG serialization (#151, #174, #264)
|
||||
- Minimap rendering — circular 160px diegetic insert overlay with POI dots (colored by category), border arrows for distant POIs, player-centered fixed-north (#151)
|
||||
- Dialogue UI hardening — confrontation italic voice (D-063), examine result overlay with 5s auto-dismiss and confidence coloring (#174)
|
||||
- Knowledge/journal panel — right-side insert panel (J key), facts grouped by entity, contradicted entries in amber with strikethrough, stale entries dimmed, mutual exclusion with dialogue (#264)
|
||||
- Sprint 18 client test suite — 50 gdUnit4 tests for dialogue (D-062, D-063, D-064) and journal (KG parsing, scene structure, UIStrings), plus test plan document
|
||||
- D-084: dual-namespace line ID scheme for auto-generated NPCs — role pool (shared, unchanged) + instance override (opt-in, seeded counter). Resolves Q-028 (#544)
|
||||
- Tier 1 drama module schema (`content/schemas/drama_module.schema.yaml`) — entry conditions, NPC requirements, event sequences, outcomes, pool format (#158)
|
||||
- Smuggling ring v0.1 stub module (`content/modules/tier1/smuggling_ring_v0_1.yaml`) — vertical slice Tier 1 module with 6 NPC roles, dual event sequences, 5 outcomes (#158)
|
||||
- Line ID authoring guide (`docs/design/line-id-authoring-guide.md`) — dual-namespace conventions for hand-authored and auto-generated NPC content
|
||||
- Tier 1 module authoring guide (`docs/design/tier1-module-authoring.md`) — field reference, NPC pattern/motivation tables, design principles, pre-submission checklist
|
||||
- Background tier state machines — schedule, mood, relationships, job tick once per game-minute for Background NPCs (#95, D-026)
|
||||
- NPC vision system — symmetric shadowcasting for Active-tier NPCs, NpcMemory with last-known-position and zone inference (#115, D-011)
|
||||
- NPC player-awareness behavior — PlayerAwareness component tracks LOS duration, suspicion accumulation, routine deviation triggers (#244)
|
||||
- Skill system & combat flag — SkillSet component (BTreeMap<String, u8>), CombatCapability marker from combat_trained skill (#91, D-024)
|
||||
- Player-action social propagation — three-order trust ripple (100%/40%/20%) through RelationshipGraph with cycle prevention (#249, D-029)
|
||||
- Examine mechanic — process_examine_interaction with character-filtered observation text, KG DirectObservation write, examine_result in ObserverSnapshot (#242)
|
||||
- Character goal/pressure framework — CharacterPressure component (exposure/institutional/relationship), wired to snapshot HUD data (#248)
|
||||
- Save state data model — SaveStateV1 struct with MessagePack serialization, roundtrip tests for entity/KG/relationship/clock state (#256)
|
||||
- Tell state derivation wired into ObserverSnapshot — integration tests for Nervous tell on Major secret + high stress (#337)
|
||||
- Sprint 18: Touch planned — 14 tickets (server 9, client 3, copy 2) covering examine mechanic, NPC awareness, social propagation, minimap, dialogue UI, save state model
|
||||
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
|
||||
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
|
||||
- KnowledgeGranted event processing — grants fire at dialogue line selection, runtime NPC KG guardrail (D-079, #546)
|
||||
- ContradictionClaim struct with 600-tick window detection in observe_entity, epistemic neutrality for both sources (D-083, #547)
|
||||
- NPC-to-NPC knowledge transfer system — trust-gated fact exchange, confidence capping at KnowsOf, ToldBy source construction (D-080, #548)
|
||||
- tell_state KG awareness — NPC relationship reads from KG for other-entity state, MVP information boundary (D-082, #549)
|
||||
- Contradiction monologue with pre-resolved entity names, PersonOfInterest relationship shift, THE FRIEND arc event chain (D-083, #550)
|
||||
- Unprompted disclosure system — DisclosureCandidates component, 7 trigger gates, three-layer rate limiting, two-stage trait filter (D-081, #551)
|
||||
- Trait modifier system — Cautious/Gossipy/Loyal/Talkative filter predicates via content-authorable config (D-081, #173)
|
||||
- POI data model and proximity-based discovery system via KnowledgeGranted events (#148, #149)
|
||||
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
|
||||
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
|
||||
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
|
||||
- D-086: Insert icon system — custom SVG icons over icon fonts, authored to insert geometric constraints with lattice_profile weight scaling
|
||||
- Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states
|
||||
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
|
||||
- Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330)
|
||||
- Diegetic time display on insert HUD — station local time (HH:MM), day phase with cycle-tinted color, day number on InsertOverlay (#263)
|
||||
- Relationship color accent on E-Talk overlay — 3px left-edge bar using D-033 palette signals NPC relationship at a glance (#537)
|
||||
- `Constants.format_game_time()` helper for converting game-minutes to HH:MM station time
|
||||
- `/sprint-status` cleanup sweep skill — consistent health report with tickets by status, PR cross-reference, bookkeeping issue detection, and open work by team
|
||||
- `sprint sweep` CLI subcommand — structured JSON output for sprint health checks (grouped tickets, per-team summary, issue detection)
|
||||
- Knowledge Flow & NPC Boundaries workshop — 5 D-records (D-079–D-083) covering grant architecture, NPC-to-NPC propagation, unprompted disclosure, NPC information boundaries MVP, contradiction detection pipeline
|
||||
- 7 knowledge graph implementation tickets (#545–#551) with full dependency chain and line estimates
|
||||
- Contradiction monologue content ticket (#552) for Sera/Kael FRIEND arc
|
||||
- Sprint 17 completion proofs: contradiction detection fires, NPC-to-NPC knowledge transfers
|
||||
- Entity renderer migrated from ColorRect placeholders to Sprite2D with D-019 angle sprites — self_modulate for D-033 tinting, 8→4 octant direction mapping, feet-anchored y-sort (#540)
|
||||
|
||||
### Changed
|
||||
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
|
||||
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
|
||||
- `sprint sweep` JSON trimmed — removed unused fields (`ok`, `sprint.status`, `priority`, `ticket_id`), shortened issue detail strings
|
||||
- Sprint status output template condensed — rendering rules moved to skill definition, bookkeeping table simplified to 2 columns
|
||||
- Model selection documented in CLAUDE.md — `/model sonnet[1m]` and `/model opus[1m]` for 1M context sessions
|
||||
- Sprint 17 briefings updated with workshop results — server (14 tickets), copy (2 tickets), client (2), visual (1)
|
||||
- Q-024 (gossip timing), Q-025 (KG memory), Q-026 (contradiction detection) closed
|
||||
- Sprint 16 closed (8/8 done)
|
||||
- 3D sprite render pipeline — Camera3D at D-019 angle (-72.5° from horizontal), three-point studio lighting rig, orthographic projection, resolution chain 1024→256→64
|
||||
- Generic NPC capsule model (24×32px footprint per D-044) and structural wall model for pipeline validation
|
||||
- Test sprites: 8 runtime 64px sprites (NPC + wall × 4 directions) deployed to client/assets/sprites/
|
||||
- Pipeline documentation (renderer/README.md) — camera spec, lighting rig, resolution chain, model authoring guide
|
||||
- DialogueResponse verb handler — players pick dialogue options and receive follow-up lines via full D-028 four-layer pipeline (#539)
|
||||
- Trust-gated gossip verification — integration tests confirm Secret/Real/Surface tier gating per D-075 (#171)
|
||||
- Line variety tracker wiring — DialogueCooldownTracker prevents repeat lines within 600-tick window (#338)
|
||||
- DialogueResponse cross-language fixture for GDScript testing
|
||||
- Sprint team lifecycle through PR review — teams stay alive for commit → push → review → fix loop → approve → shutdown
|
||||
- Zone_id extraction in game_state.gd optimized from O(N) tile scan to O(1) dictionary lookup — builds _tile_by_coord from member visible_tiles covering both test and live paths (#543)
|
||||
- Shared run_dialogue_pipeline() helper eliminates ~60 lines of duplication between Talk and DialogueResponse systems
|
||||
- Dialogue and monologue line IDs migrated from location-scoped (the-terminal_d_039) to NPC-scoped (kael-davan_d_001) namespace — each NPC has an independent sequence per D-035 (#542)
|
||||
- DialogueCooldownTracker documented as per-player-global by design (NPC-scoped line IDs per D-035 prevent collision)
|
||||
- CONFRONTATION_LINES marked TODO for migration to D-028/D-035 content pipeline
|
||||
- pr-push and pr-review skills updated with team lifecycle awareness
|
||||
|
||||
### Fixed
|
||||
- PR #59 review: stale mood vocabulary updated in line-pool-format.md, style-guide, and content-directory-structure.md to post-Sprint 14 values
|
||||
- PR #59 review: orphaned location-scoped IDs in maintenance-tech.yaml comments and smuggler-inventory.yaml cross-references updated to NPC-scoped
|
||||
- PR #59 review: Lera Sessik tenure corrected from "twelve years" to "eighteen years", NPC header fixed
|
||||
- PR #59 review: ring-operative.yaml fact_id corrected from `location.surveillance_gaps` to `investigation.surveillance_gaps`
|
||||
- Dialogue systems moved from BridgePlugin to NpcPlugin — game logic registers where it belongs (#538)
|
||||
- Schedule ambiguity: emit_observation_events now has explicit .before(advance_tick) constraint
|
||||
- process_dialogue_response updates ActiveDialogue tick and InteractionMemory on follow-up
|
||||
- DialogueResponse range check added (CLOSE_RANGE, matching Talk/Confront pattern)
|
||||
- Weighted selection fallback replaced with unreachable!() — dead code removed
|
||||
- assert!(false) → panic!() in serialization tests (clippy)
|
||||
- SetFacing and TeleportToHub added to roundtrip test coverage
|
||||
|
||||
## [v0.1.15] — 2026-02-23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,51 +1,26 @@
|
||||
# The Settled Reach
|
||||
|
||||
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC (D-020).
|
||||
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC.
|
||||
|
||||
**Official Title:** The Settled Reach (D-021)
|
||||
**Repository name:** settled-reach (formerly commonwealth, renamed for clarity)
|
||||
**Official Title:** The Settled Reach
|
||||
**Repository name:** settled-reach
|
||||
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
client/ # Godot 4 client (D-020)
|
||||
server/ # Rust/bevy_ecs simulation server (D-020)
|
||||
client/ # Godot 4 client
|
||||
server/ # Rust/bevy_ecs simulation server
|
||||
tooling/ # Build tools, scripts, asset pipelines
|
||||
tests/ # Integration and end-to-end tests
|
||||
.config/ # Configuration files (linters, formatters, CI)
|
||||
.cache/ # Local caches for testing/linting (gitignored)
|
||||
docs/
|
||||
discussions/ # Discussion rounds (all rounds archived here per D-022)
|
||||
briefings/ # Per-agent context briefings (maintained by Qatux)
|
||||
architecture/ # Technical architecture documents
|
||||
design/ # Game design documents
|
||||
diagrams/ # d2 source + PNG renders (architecture, data-flow, entity, state, ui)
|
||||
sprints/ # Sprint briefings per team (server.md, client.md, copy.md, joint.md, etc.)
|
||||
workshops/ # Workshop briefs and outputs (per-workshop subdirectories)
|
||||
db/
|
||||
schema.sql # Database schema
|
||||
connectors/ # Connector scripts for SQLite and Qdrant
|
||||
config.json # Endpoint configuration
|
||||
ticket # Ticket CLI (list, show, create, assign, sprint, etc.)
|
||||
sqlite_connector.py # SQLite mini MCP
|
||||
qdrant_connector.py # Qdrant + ollama mini MCP
|
||||
.claude/
|
||||
agents/ # Agent personality files
|
||||
skills/ # Skill definitions
|
||||
decisions/ # Decision domain files (source of truth)
|
||||
README.md # Domain index and query examples
|
||||
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066
|
||||
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078
|
||||
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
|
||||
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065
|
||||
process.md # D-004, D-021, D-022
|
||||
questions.md # Q-001 through Q-011
|
||||
rejected.md # R-001 through R-010
|
||||
DECISIONS.md # Redirect to decisions/ directory
|
||||
TEAM.md # Team roster and roles
|
||||
docs/ # Architecture, design, briefings, sprints, workshops
|
||||
db/ # Schema + connector scripts (ticket CLI, SQLite, Qdrant)
|
||||
.claude/ # Agents, skills, rules
|
||||
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
|
||||
```
|
||||
|
||||
Full annotated tree: `.claude/rules/project-structure.md`
|
||||
|
||||
## DevOps
|
||||
|
||||
See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. All development operations go through the top-level `Makefile` — run `make` for a summary of targets.
|
||||
@@ -54,15 +29,12 @@ See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. A
|
||||
|
||||
### Worktree boundaries
|
||||
|
||||
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) is checked out in its own worktree under that parent. The parent directory also contains shared resources like the ticketing database.
|
||||
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) has its own worktree. The worktree root IS the git root.
|
||||
|
||||
Each worktree contains the full repository: `server/` (Rust backend), `client/` (Godot client), `docs/`, `decisions/`, etc. The worktree root IS the git root — use `git rev-parse --show-toplevel` if in doubt.
|
||||
|
||||
Unless there is a direct instruction or a functional need (e.g. accessing the shared database in the parent directory), **all work must remain within the scope of the git root Claude is running in.**
|
||||
|
||||
- All file paths are relative to the worktree/git root (e.g. `server/src/bridge/types.rs`, `client/scripts/rendering/fog.gd`).
|
||||
- Do not navigate to or access sibling worktrees in the parent directory (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
||||
- Do not navigate above the git root unless explicitly instructed.
|
||||
- **All work must remain within the git root** unless explicitly instructed otherwise.
|
||||
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
|
||||
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
||||
- **Exception — stale git lock files:** Worktree index locks live in the shared `.git` directory (e.g. `main/.git/worktrees/copy/index.lock`). If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own worktree only**. Never touch lock files belonging to other worktrees.
|
||||
|
||||
### Database
|
||||
|
||||
@@ -74,110 +46,25 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
||||
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
|
||||
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
|
||||
|
||||
### Ticket and database access
|
||||
**Prefer the ticket CLI over raw SQL.** The CLI handles column names, joins, and output formatting correctly:
|
||||
```bash
|
||||
db/connectors/ticket list --sprint 2 --team server
|
||||
db/connectors/ticket show 78
|
||||
db/connectors/ticket sprint --active
|
||||
```
|
||||
### CLI tools
|
||||
|
||||
### Sprint CLI
|
||||
**Use the sprint CLI for sprint-scoped operations.** It batches ticket queries and formats output for agent consumption:
|
||||
```bash
|
||||
db/connectors/sprint status # Current sprint progress
|
||||
db/connectors/sprint status --team server # Team-scoped view
|
||||
db/connectors/sprint start-work --team client # Full context dump for starting work
|
||||
db/connectors/sprint prepare # Prepare next sprint (candidates + gaps)
|
||||
db/connectors/sprint start # Activate a planned sprint
|
||||
db/connectors/sprint stop # Complete an active sprint
|
||||
```
|
||||
Team is auto-detected from the current git branch (if not `main`). Sprint is auto-detected from DB state.
|
||||
**Prefer CLI wrappers over raw SQL.** Never use the `sqlite3` CLI — it crashes in Claude Code (std::bad_alloc). Use the wrapper scripts instead.
|
||||
|
||||
Only fall back to raw SQL for queries the CLI doesn't support. **Never use the `sqlite3` CLI** — it crashes in Claude Code due to a known std::bad_alloc bug. Use the wrapper scripts instead:
|
||||
```bash
|
||||
db/connectors/sqlite-query "SELECT * FROM tickets WHERE status='in_progress'"
|
||||
db/connectors/sqlite-exec "UPDATE tickets SET status='done' WHERE id=1"
|
||||
```
|
||||
|
||||
### Qdrant / document search
|
||||
```bash
|
||||
db/connectors/qdrant-search "asymmetric information design"
|
||||
db/connectors/qdrant-index docs/briefings/tyre.md
|
||||
db/connectors/qdrant-health
|
||||
db/connectors/qdrant-count
|
||||
```
|
||||
|
||||
### Gitea access (tea CLI)
|
||||
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
|
||||
|
||||
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
|
||||
|
||||
```bash
|
||||
# List open PRs
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
|
||||
# View a PR with comments
|
||||
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
|
||||
|
||||
# Post a comment on a PR (or issue)
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "comment body"
|
||||
|
||||
# Approve a PR
|
||||
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||
|
||||
# List issues
|
||||
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
Key rules:
|
||||
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
||||
- **Use `--output simple`** for machine-readable output (no table borders)
|
||||
- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`:
|
||||
```bash
|
||||
# Step 1: Write content to .tmp/ (gitignored) using the Write tool
|
||||
# Step 2: Post via cat
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "$(cat .tmp/review-branch.md)"
|
||||
```
|
||||
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
||||
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
||||
| Tool | Command | Full reference |
|
||||
|------|---------|----------------|
|
||||
| Tickets | `db/connectors/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
|
||||
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
||||
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
|
||||
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
|
||||
| Decisions | `db/connectors/decision next`, `claim`, `check-dupes` | — |
|
||||
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
|
||||
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||
|
||||
### File conventions
|
||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
||||
- **Claim IDs before writing:** `db/connectors/decision claim D <domain> "title"` — prevents ID collisions across worktrees
|
||||
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
|
||||
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
||||
- Briefings: one per agent, updated after decision-producing rounds
|
||||
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
|
||||
|
||||
### Commit conventions
|
||||
Use conventional commits with project-specific scopes:
|
||||
`agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
||||
|
||||
### Pull requests
|
||||
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
|
||||
|
||||
Always provide all required flags to ensure non-interactive execution:
|
||||
```bash
|
||||
tea pr create \
|
||||
--repo jpmschweitzer/settled-reach \
|
||||
--login schweitz \
|
||||
--title "feat(scope): short description" \
|
||||
--description "PR body here" \
|
||||
--base main \
|
||||
--head branch-name
|
||||
```
|
||||
|
||||
### Large content pushes (team pattern)
|
||||
When producing many files (wiki pages, content batches, bulk docs):
|
||||
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
|
||||
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
|
||||
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
||||
|
||||
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
||||
|
||||
### Local services
|
||||
- Gitea: `http://git.schweitz.internal` (login: `schweitz`)
|
||||
- Qdrant: `http://tower-of-joy:6333/`
|
||||
- Ollama: `http://tower-of-joy:11434/` (nomic-embed-text)
|
||||
- Collection: `commonwealth` (768 dimensions, cosine distance)
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -125,6 +125,17 @@ knowledge_panel:
|
||||
confidence_medium: "Likely"
|
||||
confidence_low: "Unconfirmed"
|
||||
confidence_rumor: "Hearsay"
|
||||
# D-041 KnowledgeConfidence levels — displayed in journal panel
|
||||
confidence_direct: "Confirmed"
|
||||
confidence_knowsdetails: "Detailed"
|
||||
confidence_knowsof: "Known"
|
||||
confidence_suspects: "Unconfirmed"
|
||||
# D-041 KnowledgeSource labels — displayed in journal panel
|
||||
source_directobservation: "Observed"
|
||||
source_toldby: "Told"
|
||||
source_heard: "Overheard"
|
||||
source_inferred: "Inferred"
|
||||
source_background: "Prior"
|
||||
|
||||
# ============================================================
|
||||
# TUTORIAL TEXT (DIEGETIC)
|
||||
@@ -159,6 +170,8 @@ dialogue:
|
||||
menu:
|
||||
pause_title: "Paused"
|
||||
resume: "Resume"
|
||||
new_game: "New Game"
|
||||
continue: "Continue"
|
||||
settings: "Settings"
|
||||
save_game: "Save"
|
||||
load_game: "Load"
|
||||
|
||||
@@ -11,7 +11,7 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="The Settled Reach"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
run/main_scene="res://scenes/main_menu.tscn"
|
||||
config/features=PackedStringArray("4.6", "GL Compatibility")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
@@ -23,6 +23,7 @@ InputMapper="*res://scripts/autoloads/input_mapper.gd"
|
||||
UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||
SessionManager="*res://scripts/autoloads/session_manager.gd"
|
||||
|
||||
[audio]
|
||||
|
||||
@@ -125,6 +126,11 @@ debug_overlay={
|
||||
"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":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
open_journal={
|
||||
"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":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
teleport_hub={
|
||||
"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":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=26 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
@@ -22,6 +22,9 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
||||
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
|
||||
[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"]
|
||||
[ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"]
|
||||
[ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -118,6 +121,9 @@ zoom = Vector2(2, 2)
|
||||
[node name="InsertOverlay" type="CanvasLayer" parent="."]
|
||||
layer = 10
|
||||
|
||||
; #263: Time display — diegetic insert clock, top-left placeholder (D-013, D-031)
|
||||
[node name="TimeDisplay" parent="InsertOverlay" instance=ExtResource("23_tdisplay")]
|
||||
|
||||
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
|
||||
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
|
||||
|
||||
@@ -130,6 +136,15 @@ layer = 10
|
||||
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
|
||||
[node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")]
|
||||
|
||||
; #151: Minimap — diegetic insert overlay, top-right, 160px circle (D-013, D-049 z-layer 6)
|
||||
[node name="Minimap" parent="InsertOverlay" instance=ExtResource("7_minimap")]
|
||||
|
||||
; #174: Examine result — non-interactive observe text overlay, auto-dismisses 5s (D-061 adjacent)
|
||||
[node name="ExamineDisplay" parent="InsertOverlay" instance=ExtResource("24_examine")]
|
||||
|
||||
; #264: Journal panel — knowledge graph review, toggle J key, read-only (D-041)
|
||||
[node name="JournalPanel" parent="InsertOverlay" instance=ExtResource("25_journal")]
|
||||
|
||||
; --- UI layer (CanvasLayer 20) ---
|
||||
; HUD, monologue, cursor — always visible, not affected by fog or camera.
|
||||
[node name="UILayer" type="CanvasLayer" parent="."]
|
||||
@@ -137,8 +152,6 @@ layer = 20
|
||||
|
||||
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
|
||||
|
||||
[node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")]
|
||||
|
||||
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
|
||||
|
||||
; D-053: Stance indicator — top-right, color-coded
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://main_menu_sr"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/main_menu.gd" id="1_mainmenu"]
|
||||
|
||||
; Main menu — New Game / Continue / Quit.
|
||||
; #258: D-085 per-game save directory created on New Game.
|
||||
|
||||
[node name="MainMenu" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_mainmenu")
|
||||
|
||||
[node name="Background" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0.05, 0.05, 0.08, 1.0)
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -120.0
|
||||
offset_top = -80.0
|
||||
offset_right = 120.0
|
||||
offset_bottom = 100.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 16
|
||||
alignment = 1
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "THE SETTLED REACH"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 36
|
||||
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0)
|
||||
|
||||
[node name="Spacer" type="Control" parent="VBox"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 24)
|
||||
|
||||
[node name="NewGameBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "NEW GAME"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="ContinueBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "CONTINUE"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="QuitBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "QUIT"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
|
||||
@@ -4,6 +4,11 @@ extends Node
|
||||
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
|
||||
# Tiles use format: [{x, y, z, type}].
|
||||
var current_snapshot: Dictionary = {}
|
||||
|
||||
# D-085 (#258): Active game session identifier. Format: <YYYYMMDD>-<HHMMSS>-<hex6>
|
||||
# Set by SessionManager.new_game() or SessionManager.resume_game().
|
||||
# Empty string when no session is active (main menu state).
|
||||
var current_game_id: String = ""
|
||||
var current_tick: int = 0
|
||||
var player_position: Vector2 = Vector2.ZERO
|
||||
var visible_entities: Array = []
|
||||
@@ -70,6 +75,21 @@ var dialogue_response: Variant = null # {line_id, text, speaker_entity_id}
|
||||
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
|
||||
var conversation_ended: Array = [] # [{speaker_id, target_id}]
|
||||
|
||||
# v10 fields (#151, D-013): Discovered POIs from server (#148/#149).
|
||||
# Format: [{poi_id, name, x, y, z, category}]. Persists between snapshots unless
|
||||
# server explicitly sends an empty array (cleared locations are not typical in v0.1).
|
||||
# Populated from snapshot "poi_list" field — only updated when field present.
|
||||
var discovered_pois: Array = []
|
||||
|
||||
# v14 fields (#174, #242): Character-filtered examine result.
|
||||
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
|
||||
var current_examine_result: Variant = null
|
||||
|
||||
# v14 fields (#264, D-041): Player knowledge graph dump for journal panel.
|
||||
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
|
||||
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
|
||||
var player_knowledge: Variant = null
|
||||
|
||||
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
|
||||
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
|
||||
var medium_sound_events: Array = []
|
||||
@@ -240,16 +260,35 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
|
||||
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
|
||||
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
|
||||
# positional indexing — use the same tile iteration below instead.
|
||||
current_zone_id = ""
|
||||
var _px := int(player_position.x)
|
||||
var _py := int(player_position.y)
|
||||
for _ztile in visible_tiles:
|
||||
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
|
||||
current_zone_id = _ztile.get("zone_id", "")
|
||||
break
|
||||
# v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player.
|
||||
# Accepts "discovered_pois" or "poi_list" key — both map to the same client field.
|
||||
# Only update if the field is present — absence means "no change since last tick".
|
||||
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
|
||||
discovered_pois = snapshot.discovered_pois
|
||||
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
|
||||
discovered_pois = snapshot.poi_list
|
||||
|
||||
# v14: examine_result (#174, #242) — character-filtered observation from Examine verb.
|
||||
if snapshot.has("examine_result") and snapshot.examine_result is Dictionary:
|
||||
current_examine_result = snapshot.examine_result
|
||||
else:
|
||||
current_examine_result = null
|
||||
|
||||
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||
# Only update when field is present (null means no change, server sends when KG changes).
|
||||
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
||||
player_knowledge = snapshot.player_knowledge
|
||||
|
||||
# D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles
|
||||
# (populated above from either "tiles" test-mode key or "visible_tiles" live key).
|
||||
# Must use the member var, not snapshot.visible_tiles, so test mode is covered.
|
||||
var _tile_by_coord: Dictionary = {}
|
||||
for vtile in visible_tiles:
|
||||
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
|
||||
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
|
||||
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
|
||||
var player_tile = _tile_by_coord.get(player_pos_key, null)
|
||||
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
# Derives visible_positions when not explicitly provided (real server mode)
|
||||
|
||||
@@ -19,6 +19,7 @@ enum Action {
|
||||
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
||||
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
||||
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
||||
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
|
||||
SET_FACING, # D-054: facing octant update (no movement)
|
||||
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
||||
}
|
||||
@@ -106,6 +107,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
action = Action.TOGGLE_STANCE_DOWN
|
||||
elif event.is_action_pressed("bug_report"):
|
||||
action = Action.BUG_REPORT
|
||||
elif event.is_action_pressed("open_journal"):
|
||||
action = Action.OPEN_JOURNAL
|
||||
elif event.is_action_pressed("teleport_hub"):
|
||||
if GameState.gauntlet_mode:
|
||||
action = Action.TELEPORT_HUB
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
extends Node
|
||||
## D-085 (#258): Game session lifecycle manager.
|
||||
## Creates per-game save directories on New Game, resumes existing sessions,
|
||||
## and handles quit-to-menu flow with save confirmation.
|
||||
##
|
||||
## All save dirs live under user://saves/<game-id>/ where game-id is
|
||||
## <YYYYMMDD>-<HHMMSS>-<hex6> (e.g. "20260225-143022-a7b3f1").
|
||||
|
||||
const SAVES_DIR := "user://saves/"
|
||||
const GAME_SCENE := "res://scenes/main.tscn"
|
||||
const MENU_SCENE := "res://scenes/main_menu.tscn"
|
||||
|
||||
var _quit_dialog: ConfirmationDialog = null
|
||||
|
||||
|
||||
## Generate a new game-id, create its save directory, and activate the session.
|
||||
## Returns the new game-id string.
|
||||
func new_game() -> String:
|
||||
var now := Time.get_datetime_dict_from_system()
|
||||
var timestamp := "%04d%02d%02d-%02d%02d%02d" % [
|
||||
now.year, now.month, now.day,
|
||||
now.hour, now.minute, now.second,
|
||||
]
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.randomize()
|
||||
var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF)
|
||||
var game_id := "%s-%s" % [timestamp, hex_seed]
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
var err := DirAccess.make_dir_recursive_absolute(save_path)
|
||||
if err != OK:
|
||||
push_error("SessionManager: failed to create save dir %s: %s" % [
|
||||
save_path, error_string(err)])
|
||||
GameState.current_game_id = game_id
|
||||
return game_id
|
||||
|
||||
|
||||
## Resume an existing game session by setting the active game-id.
|
||||
func resume_game(game_id: String) -> void:
|
||||
GameState.current_game_id = game_id
|
||||
|
||||
|
||||
## List all game directories under user://saves/ sorted by last-modified (most recent first).
|
||||
## Returns Array of {game_id: String, modified_time: int, newest_save: String}.
|
||||
func list_game_dirs() -> Array:
|
||||
var dir := DirAccess.open(SAVES_DIR)
|
||||
if dir == null:
|
||||
return []
|
||||
var results: Array = []
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if dir.current_is_dir() and not entry.begins_with("."):
|
||||
var dir_path := SAVES_DIR + entry + "/"
|
||||
var newest_save := _find_newest_save(dir_path)
|
||||
var mtime: int = 0
|
||||
if newest_save != "":
|
||||
mtime = FileAccess.get_modified_time(dir_path + newest_save)
|
||||
results.append({
|
||||
"game_id": entry,
|
||||
"modified_time": mtime,
|
||||
"newest_save": newest_save,
|
||||
})
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return a.modified_time > b.modified_time)
|
||||
return results
|
||||
|
||||
|
||||
## Show "Save before quitting?" confirmation dialog, then return to main menu.
|
||||
## node: the calling scene node (used as dialog parent).
|
||||
## #554: The actual F5 save will be wired here once server supports SaveCommand.
|
||||
func quit_to_menu(node: Node) -> void:
|
||||
if _quit_dialog != null and is_instance_valid(_quit_dialog):
|
||||
return # Dialog already open
|
||||
_quit_dialog = ConfirmationDialog.new()
|
||||
_quit_dialog.title = ""
|
||||
_quit_dialog.dialog_text = UIStrings.get_text("menu.confirm_quit")
|
||||
_quit_dialog.ok_button_text = UIStrings.get_text("menu.confirm_yes")
|
||||
_quit_dialog.cancel_button_text = UIStrings.get_text("menu.confirm_no")
|
||||
node.add_child(_quit_dialog)
|
||||
_quit_dialog.confirmed.connect(_do_quit_to_menu)
|
||||
_quit_dialog.canceled.connect(_cleanup_quit_dialog)
|
||||
_quit_dialog.popup_centered()
|
||||
|
||||
|
||||
func _do_quit_to_menu() -> void:
|
||||
_cleanup_quit_dialog()
|
||||
# #554: F5 quicksave will be triggered here before scene change when server
|
||||
# supports SaveCommand. For now: navigate to menu without saving.
|
||||
GameState.current_game_id = ""
|
||||
get_tree().change_scene_to_file(MENU_SCENE)
|
||||
|
||||
|
||||
func _cleanup_quit_dialog() -> void:
|
||||
if _quit_dialog != null and is_instance_valid(_quit_dialog):
|
||||
_quit_dialog.queue_free()
|
||||
_quit_dialog = null
|
||||
|
||||
|
||||
func _find_newest_save(dir_path: String) -> String:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
return ""
|
||||
var best_name := ""
|
||||
var best_time: int = 0
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if not dir.current_is_dir() and entry.ends_with(".sav"):
|
||||
var mtime := FileAccess.get_modified_time(dir_path + entry)
|
||||
if mtime > best_time:
|
||||
best_time = mtime
|
||||
best_name = entry
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
return best_name
|
||||
@@ -66,8 +66,13 @@ func connect_to_sim() -> void:
|
||||
# Spawn server subprocess
|
||||
if not server_path.is_empty():
|
||||
_server = ServerProcess.new()
|
||||
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876")
|
||||
var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)])
|
||||
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
|
||||
# D-085 (#258): pass --game-id <id> so server logs use the same session identifier.
|
||||
var args := ["127.0.0.1:" + str(server_port)]
|
||||
var game_id: String = GameState.current_game_id
|
||||
if not game_id.is_empty():
|
||||
args.append_array(["--game-id", game_id])
|
||||
var pid := _server.start(server_path, args)
|
||||
if pid <= 0:
|
||||
push_error("SimBridge: failed to start server")
|
||||
_set_state(ConnectionState.ERROR)
|
||||
|
||||
@@ -96,6 +96,11 @@ const FACING_INDICATOR_OFFSET: float = 14.0
|
||||
# two columns of text comfortably, leaves world game visible alongside.
|
||||
const DIALOGUE_MAX_WIDTH: int = 1200
|
||||
|
||||
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
|
||||
static func format_game_time(time_of_day: int) -> String:
|
||||
var clamped: int = clampi(time_of_day, 0, 1439)
|
||||
return "%02d:%02d" % [clamped / 60, clamped % 60]
|
||||
|
||||
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
||||
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ extends Node2D
|
||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||
@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
|
||||
@onready var minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049)
|
||||
@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay
|
||||
@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041)
|
||||
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||
@@ -98,13 +102,15 @@ func _process(delta: float) -> void:
|
||||
interaction_list.set_insert_active(insert_state)
|
||||
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
if minimap and minimap.has_method("set_insert_active"):
|
||||
minimap.set_insert_active(insert_state)
|
||||
|
||||
# D-057: Update interaction list from game state
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise
|
||||
if interaction_list and interaction_list.has_method("update_from_state"):
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if interaction_list.is_showing():
|
||||
interaction_list._hide()
|
||||
interaction_list.hide_list()
|
||||
else:
|
||||
interaction_list.update_from_state()
|
||||
|
||||
@@ -131,6 +137,14 @@ func _process(delta: float) -> void:
|
||||
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
||||
checklist_overlay.update_from_state()
|
||||
|
||||
# #263: Update time display (D-013, D-031)
|
||||
if time_display and time_display.has_method("update_from_state"):
|
||||
time_display.update_from_state()
|
||||
|
||||
# #264: Update journal panel — auto-close on dialogue, refresh if open
|
||||
if journal_panel and journal_panel.has_method("update_from_state"):
|
||||
journal_panel.update_from_state()
|
||||
|
||||
# #511: Update debug overlay (F3 toggle, dev tool)
|
||||
if debug_overlay and debug_overlay.has_method("update_from_state"):
|
||||
debug_overlay.update_from_state()
|
||||
@@ -145,6 +159,9 @@ func _process(delta: float) -> void:
|
||||
# Only activates when no dialogue/confrontation dip is active (D-070).
|
||||
_update_listening_focus()
|
||||
|
||||
# #174: Show examine result if server sent one this tick (#242)
|
||||
_consume_examine_result()
|
||||
|
||||
# Show monologue if server sent one this tick (#414)
|
||||
_consume_monologue()
|
||||
|
||||
@@ -179,6 +196,10 @@ func _process(delta: float) -> void:
|
||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||
bug_report_dialog.start_capture()
|
||||
continue
|
||||
# #264: J — client-only, toggle knowledge journal panel
|
||||
if input.action == InputMapper.Action.OPEN_JOURNAL:
|
||||
_toggle_journal()
|
||||
continue
|
||||
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
||||
if input.action == InputMapper.Action.OPEN_MENU:
|
||||
if settings_dialog:
|
||||
@@ -313,6 +334,9 @@ func _consume_dialogue() -> void:
|
||||
GameState.current_dialogue = null
|
||||
return
|
||||
_last_dialogue_tick = GameState.current_tick
|
||||
# #264: Close journal when dialogue opens (cannot be open simultaneously)
|
||||
if journal_panel and journal_panel.has_method("close"):
|
||||
journal_panel.close()
|
||||
var dlg: Dictionary = GameState.current_dialogue
|
||||
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
||||
_last_dialogue_npc_name = dlg.get("npc_name", "")
|
||||
@@ -451,6 +475,33 @@ func _teleport_transition() -> void:
|
||||
tween.tween_callback(_flash_rect.queue_free)
|
||||
|
||||
|
||||
# #174: Consume examine result — show overlay when server sends character-filtered observation.
|
||||
# Clears after display (single-consume). Dismiss examine when dialogue opens.
|
||||
func _consume_examine_result() -> void:
|
||||
if GameState.current_examine_result == null or not examine_display:
|
||||
return
|
||||
var result: Dictionary = GameState.current_examine_result
|
||||
# Dismiss existing examine result if dialogue is active (focus priority)
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if examine_display.has_method("dismiss"):
|
||||
examine_display.dismiss()
|
||||
else:
|
||||
if examine_display.has_method("show_result"):
|
||||
examine_display.show_result(result)
|
||||
GameState.current_examine_result = null
|
||||
|
||||
|
||||
# #264: Toggle journal panel. Called from input handler when J key pressed.
|
||||
func _toggle_journal() -> void:
|
||||
if not journal_panel:
|
||||
return
|
||||
# Journal and dialogue cannot be open simultaneously (sprint briefing)
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
return
|
||||
if journal_panel.has_method("toggle"):
|
||||
journal_panel.toggle()
|
||||
|
||||
|
||||
# #502: Full-screen color flash — fades from color to transparent over duration.
|
||||
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
||||
func _screen_flash(color: Color, duration: float) -> void:
|
||||
|
||||
@@ -11,7 +11,7 @@ class_name Protocol
|
||||
|
||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||
## Reject snapshots where version != this value.
|
||||
const PROTOCOL_VERSION: int = 13
|
||||
const PROTOCOL_VERSION: int = 14
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
@@ -206,6 +206,70 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"target_id": int(raw_end.get("target_id", 0)),
|
||||
})
|
||||
|
||||
# v14: poi_list (#151) — discovered POIs for minimap rendering.
|
||||
# Each entry: {poi_id, name, x, y, z, category}. Positions in sim tile coords.
|
||||
var poi_list: Array = []
|
||||
var raw_pois: Variant = raw.get("poi_list")
|
||||
if raw_pois is Array:
|
||||
for raw_poi in raw_pois:
|
||||
if raw_poi is Dictionary and raw_poi.has("poi_id") and raw_poi.has("x") and raw_poi.has("y"):
|
||||
poi_list.append({
|
||||
"poi_id": str(raw_poi["poi_id"]),
|
||||
"name": str(raw_poi.get("name", "")),
|
||||
"x": int(raw_poi["x"]),
|
||||
"y": int(raw_poi["y"]),
|
||||
"z": int(raw_poi.get("z", 0)),
|
||||
"category": str(raw_poi.get("category", "Location")),
|
||||
})
|
||||
|
||||
# v14: examine_result (#174, #242) — character-filtered observation text.
|
||||
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
|
||||
var examine_result: Variant = null
|
||||
var raw_examine: Variant = raw.get("examine_result")
|
||||
if raw_examine is Dictionary and raw_examine.has("text"):
|
||||
examine_result = {
|
||||
"entity_id": int(raw_examine.get("entity_id", 0)),
|
||||
"text": str(raw_examine["text"]),
|
||||
"confidence": str(raw_examine.get("confidence", "KnowsOf")),
|
||||
}
|
||||
|
||||
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
|
||||
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
|
||||
var player_knowledge: Variant = null
|
||||
var raw_pk: Variant = raw.get("player_knowledge")
|
||||
if raw_pk is Dictionary:
|
||||
var kg_entities: Array = []
|
||||
var raw_kg_entities: Variant = raw_pk.get("entities")
|
||||
if raw_kg_entities is Array:
|
||||
for raw_ke in raw_kg_entities:
|
||||
if raw_ke is Dictionary and raw_ke.has("entity_id"):
|
||||
kg_entities.append({
|
||||
"entity_id": int(raw_ke["entity_id"]),
|
||||
"name": str(raw_ke.get("name", "Unknown")),
|
||||
"confidence": str(raw_ke.get("confidence", "Suspects")),
|
||||
"source": str(raw_ke.get("source", "")),
|
||||
"state": str(raw_ke.get("state", "Active")),
|
||||
"relationship": str(raw_ke.get("relationship", "Unknown")),
|
||||
"last_observed_tick": int(raw_ke.get("last_observed_tick", 0)),
|
||||
})
|
||||
var kg_facts: Array = []
|
||||
var raw_kg_facts: Variant = raw_pk.get("facts")
|
||||
if raw_kg_facts is Array:
|
||||
for raw_kf in raw_kg_facts:
|
||||
if raw_kf is Dictionary and raw_kf.has("fact_id"):
|
||||
kg_facts.append({
|
||||
"fact_id": str(raw_kf["fact_id"]),
|
||||
"confidence": str(raw_kf.get("confidence", "Suspects")),
|
||||
"source": str(raw_kf.get("source", "")),
|
||||
"state": str(raw_kf.get("state", "Active")),
|
||||
"acquired_tick": int(raw_kf.get("acquired_tick", 0)),
|
||||
})
|
||||
player_knowledge = {
|
||||
"entities": kg_entities,
|
||||
"facts": kg_facts,
|
||||
}
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"entities": entities,
|
||||
@@ -223,6 +287,9 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"pending_recognitions": pending_recognitions,
|
||||
"conversation_events": conversation_events,
|
||||
"conversation_ended": conversation_ended,
|
||||
"poi_list": poi_list,
|
||||
"examine_result": examine_result,
|
||||
"player_knowledge": player_knowledge,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ class_name EntityRenderer
|
||||
extends Node2D
|
||||
|
||||
# Entity renderer — manages entity sprites under the Entities node
|
||||
# Creates/updates/removes ColorRect children based on entity data
|
||||
# Creates/updates/removes Sprite2D children based on entity data
|
||||
# Entity format (from Protocol v2): {entity_id, x, y, z, kind: {variant, data}, visibility}
|
||||
#
|
||||
# Position lerping: entity sprites smoothly slide between tiles instead of snapping.
|
||||
@@ -11,13 +11,15 @@ extends Node2D
|
||||
#
|
||||
# D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive
|
||||
# color from RelationshipState via the knowledge graph.
|
||||
# #540: Sprites at D-019 angle (-72.5° from horizontal). Textures are neutral greyscale;
|
||||
# self_modulate applies D-033 relationship tinting. modulate.a reserved for D-015 dimming.
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source scaled to 32px runtime)
|
||||
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source at 0.5 scale = 32px runtime)
|
||||
const ENTITY_WIDTH: int = 24
|
||||
const ENTITY_HEIGHT: int = 32
|
||||
const ENTITY_OFFSET_X: float = (TILE_SIZE - ENTITY_WIDTH) / 2.0 # center horizontally
|
||||
const ENTITY_OFFSET_Y: float = (TILE_SIZE - ENTITY_HEIGHT) / 2.0 # center vertically for placeholder. Migration: switch to bottom-anchor (offset = TILE_SIZE - ENTITY_HEIGHT) when real sprites land for correct y-sort ordering.
|
||||
const ENTITY_OFFSET_X: float = 0.0 # sprite fills tile width at 0.5 scale
|
||||
const ENTITY_OFFSET_Y: float = TILE_SIZE - ENTITY_HEIGHT # feet-anchored for correct y-sort with D-019 tilt
|
||||
|
||||
# Lerp speed — framerate-independent exponential smoothing.
|
||||
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
|
||||
@@ -27,7 +29,8 @@ const LERP_SPEED: float = 12.0
|
||||
var entity_nodes: Dictionary = {} # entity_id -> Node2D
|
||||
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
|
||||
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
|
||||
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
|
||||
var _entity_tweens: Dictionary = {} # #521: entity_id -> {from: Color, target: Color, elapsed: float}
|
||||
var _entity_facing: Dictionary = {} # #540: entity_id -> String ("north"/"east"/"south"/"west")
|
||||
|
||||
# #521: Color transition duration in seconds (D-033: "0.5s fade")
|
||||
const COLOR_FADE_DURATION: float = 0.5
|
||||
@@ -48,7 +51,7 @@ func _process(delta: float) -> void:
|
||||
if not node.position.is_equal_approx(target):
|
||||
node.position = node.position.lerp(target, weight)
|
||||
|
||||
# #521: Advance color transitions (manual lerp, testable without SceneTree)
|
||||
# #521: Advance self_modulate transitions (manual lerp, testable without SceneTree)
|
||||
var finished_ids: Array = []
|
||||
for entity_id in _entity_tweens.keys():
|
||||
if not entity_nodes.has(entity_id):
|
||||
@@ -57,8 +60,9 @@ func _process(delta: float) -> void:
|
||||
var tween_data: Dictionary = _entity_tweens[entity_id]
|
||||
tween_data.elapsed += delta
|
||||
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
|
||||
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
|
||||
node_c.color = tween_data.from.lerp(tween_data.target, t)
|
||||
var node_s: Sprite2D = entity_nodes[entity_id] as Sprite2D
|
||||
if node_s:
|
||||
node_s.self_modulate = tween_data.from.lerp(tween_data.target, t)
|
||||
if t >= 1.0:
|
||||
finished_ids.append(entity_id)
|
||||
for eid in finished_ids:
|
||||
@@ -92,15 +96,26 @@ func update_entities(entities: Array) -> void:
|
||||
for entity_id in ids_to_remove:
|
||||
_remove_entity_node(entity_id)
|
||||
|
||||
# Create a new entity node with D-033 color and optional facing indicator
|
||||
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
var entity_node = ColorRect.new()
|
||||
entity_node.name = "Entity_" + str(entity_id)
|
||||
entity_node.size = Vector2(ENTITY_WIDTH, ENTITY_HEIGHT)
|
||||
entity_node.pivot_offset = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0)
|
||||
|
||||
# D-033 color by relationship (#521)
|
||||
entity_node.color = _color_for_kind(entity_data)
|
||||
# Create a new entity node with D-033 tint and sprite texture at D-019 angle
|
||||
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
var entity_node := Sprite2D.new()
|
||||
entity_node.name = "Entity_" + str(entity_id)
|
||||
# centered=false: top-left origin aligns with tile grid.
|
||||
# scale=0.5: maps 64px source texture to 32px runtime (D-043, 2x camera = 64px on screen).
|
||||
entity_node.centered = false
|
||||
entity_node.scale = Vector2(0.5, 0.5)
|
||||
|
||||
# Load sprite for current facing direction
|
||||
var direction := _entity_direction(entity_id, entity_data)
|
||||
_entity_facing[entity_id] = direction
|
||||
var tex := _load_sprite_texture(direction)
|
||||
if tex == null:
|
||||
push_error("EntityRenderer: no texture for entity %d direction '%s' — entity will be invisible" % [entity_id, direction])
|
||||
entity_node.texture = tex
|
||||
|
||||
# D-033: self_modulate for relationship tinting; modulate.a is reserved for D-015 dimming.
|
||||
entity_node.self_modulate = _color_for_kind(entity_data)
|
||||
|
||||
add_child(entity_node)
|
||||
entity_nodes[entity_id] = entity_node
|
||||
@@ -121,12 +136,13 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
|
||||
_update_entity_node(entity_id, entity_data)
|
||||
|
||||
# Update an existing entity node (target position, visibility dimming, facing)
|
||||
|
||||
# Update an existing entity node (target position, sprite direction, visibility dimming, facing)
|
||||
func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
if not entity_nodes.has(entity_id):
|
||||
return
|
||||
|
||||
var entity_node = entity_nodes[entity_id]
|
||||
var node = entity_nodes[entity_id]
|
||||
|
||||
# Update target position — the lerp in _process() will smoothly move there.
|
||||
# Server sends tile-center coords (tile 16 → 16.5), floor to get tile index.
|
||||
@@ -136,38 +152,42 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y
|
||||
)
|
||||
|
||||
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
|
||||
# #540: Update sprite texture when facing direction changes
|
||||
var new_dir := _entity_direction(entity_id, entity_data)
|
||||
if new_dir != _entity_facing.get(entity_id, ""):
|
||||
_entity_facing[entity_id] = new_dir
|
||||
var new_tex := _load_sprite_texture(new_dir)
|
||||
if new_tex != null:
|
||||
(node as Sprite2D).texture = new_tex
|
||||
# null: keep previous texture rather than going invisible mid-game
|
||||
|
||||
# #521: Detect relationship change → fade D-033 self_modulate (0.5s via _process)
|
||||
var new_rel: String = entity_data.get("relationship", "Unknown")
|
||||
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
|
||||
if new_rel != old_rel:
|
||||
if new_rel != _entity_relationships.get(entity_id, "Unknown"):
|
||||
_entity_relationships[entity_id] = new_rel
|
||||
var new_color := _color_for_kind(entity_data)
|
||||
_entity_tweens[entity_id] = {
|
||||
"from": entity_node.color,
|
||||
"target": new_color,
|
||||
"from": (node as Sprite2D).self_modulate,
|
||||
"target": _color_for_kind(entity_data),
|
||||
"elapsed": 0.0,
|
||||
}
|
||||
|
||||
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
|
||||
# are compositionally independent — both can change simultaneously without
|
||||
# interference. If alpha tweening is added later, coordinate with color tween.
|
||||
|
||||
# v2: Peripheral vision dimming (D-015)
|
||||
# null visibility (v1 backward compat) defaults to full alpha
|
||||
# D-015: Peripheral vision dimming via modulate.a.
|
||||
# Independent from self_modulate (D-033 tint) — both can change simultaneously.
|
||||
var visibility: Variant = entity_data.get("visibility")
|
||||
var target_alpha := Constants.PERIPHERAL_ALPHA if visibility == "Peripheral" else 1.0
|
||||
if not is_equal_approx(entity_node.modulate.a, target_alpha):
|
||||
entity_node.modulate.a = target_alpha
|
||||
if not is_equal_approx(node.modulate.a, target_alpha):
|
||||
node.modulate.a = target_alpha
|
||||
|
||||
# D-054: Update facing indicator from client-side mouse angle (not server).
|
||||
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
|
||||
if entity_id == GameState.player_entity_id:
|
||||
var indicator = entity_node.get_node_or_null("FacingIndicator")
|
||||
var indicator = node.get_node_or_null("FacingIndicator")
|
||||
if indicator != null:
|
||||
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
|
||||
# Rotate from North basis: add PI/2 to convert.
|
||||
indicator.rotation = InputMapper.facing_angle + PI / 2.0
|
||||
|
||||
|
||||
# Remove an entity node
|
||||
func _remove_entity_node(entity_id: int) -> void:
|
||||
if not entity_nodes.has(entity_id):
|
||||
@@ -179,13 +199,49 @@ func _remove_entity_node(entity_id: int) -> void:
|
||||
_entity_targets.erase(entity_id)
|
||||
_entity_relationships.erase(entity_id)
|
||||
_entity_tweens.erase(entity_id)
|
||||
_entity_facing.erase(entity_id)
|
||||
|
||||
|
||||
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
|
||||
static func _color_for_kind(entity_data: Dictionary) -> Color:
|
||||
return Constants.color_for_entity_kind(entity_data)
|
||||
|
||||
# Add a facing direction indicator triangle to the player entity
|
||||
func _add_facing_indicator(parent_node: Control) -> void:
|
||||
|
||||
# #540: Map entity to current 4-direction sprite key.
|
||||
# Player uses GameState.player_facing (8-octant → 4-cardinal). NPCs default "south".
|
||||
func _entity_direction(entity_id: int, _entity_data: Dictionary) -> String:
|
||||
if entity_id == GameState.player_entity_id:
|
||||
return _octant_to_direction(GameState.player_facing)
|
||||
# NPCs: no facing field in v1 entity format; south is viewer-facing (D-019 angle)
|
||||
return "south"
|
||||
|
||||
|
||||
# Map 8-direction octant string to nearest 4-direction sprite key.
|
||||
# N/NW → north, NE/E → east, SE/S → south, SW/W → west
|
||||
static func _octant_to_direction(octant: String) -> String:
|
||||
match octant:
|
||||
"North", "Northwest": return "north"
|
||||
"Northeast", "East": return "east"
|
||||
"Southeast", "South": return "south"
|
||||
"Southwest", "West": return "west"
|
||||
_:
|
||||
push_warning("EntityRenderer: unrecognised octant '%s' — defaulting to south" % octant)
|
||||
return "south"
|
||||
|
||||
|
||||
# Load the sprite texture for the given 4-direction key.
|
||||
# Falls back to null with a push_warning if the asset is missing.
|
||||
static func _load_sprite_texture(direction: String) -> Texture2D:
|
||||
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
|
||||
if ResourceLoader.exists(path):
|
||||
return load(path) as Texture2D
|
||||
push_warning("EntityRenderer: sprite not found: %s" % path)
|
||||
return null
|
||||
|
||||
|
||||
# Add a facing direction indicator triangle to the player entity.
|
||||
# Indicator position is in Sprite2D local space (64px texture before 0.5 scale → center at (32,32)).
|
||||
func _add_facing_indicator(parent_node: Node2D) -> void:
|
||||
var indicator := Polygon2D.new()
|
||||
indicator.name = "FacingIndicator"
|
||||
var s := Constants.FACING_INDICATOR_SIZE
|
||||
@@ -197,6 +253,7 @@ func _add_facing_indicator(parent_node: Control) -> void:
|
||||
Vector2(s * 0.6, -offset + s * 0.4),
|
||||
])
|
||||
indicator.color = Constants.ENTITY_COLOR_PLAYER
|
||||
# Position at center of parent ColorRect — rotation around this point
|
||||
indicator.position = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0)
|
||||
# Sprite2D local space: 64px texture at scale 0.5 → center of visible sprite at (32,32).
|
||||
# Indicator rotates around this point to track player facing direction.
|
||||
indicator.position = Vector2(32.0, 32.0)
|
||||
parent_node.add_child(indicator)
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
extends Control
|
||||
# #511: F3 debug overlay — real-time game state display for dev use.
|
||||
## #348: F3 debug overlay — real-time visualization of game state for dev use.
|
||||
## Dev-only: disabled entirely in export builds (OS.is_debug_build() = false).
|
||||
##
|
||||
## Panels:
|
||||
## 1. Stats text (top-left): tick, pos, fps, etc.
|
||||
## 2. World overlays (over game): LOS rays, vision cone, NPC paths, info tags
|
||||
## 3. Tick timing graph (bottom-left): last-30-tick delta sparkline
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const HEADER_COLOR := Color("#e8c547")
|
||||
const LABEL_COLOR := Color("#8890a0")
|
||||
@@ -8,31 +18,141 @@ const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
|
||||
const FONT_SIZE := 12
|
||||
const LINE_HEIGHT := 16
|
||||
const PADDING := Vector2(10, 8)
|
||||
const COL_GAP := 16 # gap between left and right columns
|
||||
const COL_GAP := 16
|
||||
|
||||
# World overlay colors
|
||||
const LOS_COLOR := Color(0.27, 0.78, 0.65, 0.50)
|
||||
const PLAYER_DOT_COLOR := Color(0.88, 0.77, 0.28, 0.85)
|
||||
const CONE_FORWARD_COLOR := Color(0.27, 0.78, 0.65, 0.12)
|
||||
const CONE_PERIPHERAL_COLOR := Color(0.20, 0.55, 0.80, 0.07)
|
||||
const CONE_RING_COLOR := Color(0.27, 0.78, 0.65, 0.55)
|
||||
const NPC_PATH_COLOR := Color(0.83, 0.48, 0.35, 0.75)
|
||||
const NPC_DOT_COLOR := Color(0.83, 0.48, 0.35, 0.90)
|
||||
const TAG_BG_COLOR := Color(0.05, 0.05, 0.10, 0.80)
|
||||
const TAG_TEXT_COLOR := Color("#c8d0e0")
|
||||
const GRAPH_BG_COLOR := Color(0.06, 0.06, 0.10, 0.82)
|
||||
const GRAPH_LINE_COLOR := Color("#6bc9a6")
|
||||
const GRAPH_WARN_COLOR := Color("#e8c547")
|
||||
|
||||
# Vision cone geometry (radians)
|
||||
# Forward: ±60° around facing direction (120° total)
|
||||
# Peripheral: ±60° to ±120° on each side (60° band each side)
|
||||
const CONE_FORWARD_HALF: float = PI / 3.0 # 60°
|
||||
const CONE_PERIPHERAL_HALF: float = PI * 2.0 / 3.0 # 120°
|
||||
const CONE_ARC_STEPS: int = 20
|
||||
|
||||
# NPC path history
|
||||
const NPC_HISTORY_LEN: int = 12
|
||||
const NPC_DOT_RADIUS: float = 3.5
|
||||
const PLAYER_DOT_RADIUS: float = 5.0
|
||||
|
||||
# Tick timing graph
|
||||
const GRAPH_W: float = 160.0
|
||||
const GRAPH_H: float = 48.0
|
||||
const GRAPH_MARGIN: float = 10.0
|
||||
const TICK_HISTORY_LEN: int = 30
|
||||
const TICK_WARN_MS: float = 120.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _cached_font: Font = null
|
||||
var _dev_mode: bool = false
|
||||
|
||||
# NPC path history: entity_id (int) → Array of Vector2 (world positions)
|
||||
var _npc_paths: Dictionary = {}
|
||||
var _last_tick_processed: int = -1
|
||||
|
||||
# Tick timing ring
|
||||
var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot
|
||||
var _tick_deltas: Array = [] # ms between consecutive snapshots
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
_dev_mode = OS.is_debug_build()
|
||||
visible = false
|
||||
_cached_font = ThemeDB.fallback_font
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _dev_mode:
|
||||
return
|
||||
if event.is_action_pressed("debug_overlay"):
|
||||
visible = not visible
|
||||
if visible:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
if not visible:
|
||||
if not _dev_mode or not visible:
|
||||
return
|
||||
|
||||
# Record tick arrival time for timing graph
|
||||
var now_ms := Time.get_ticks_msec()
|
||||
if _last_tick_processed != GameState.current_tick:
|
||||
_last_tick_processed = GameState.current_tick
|
||||
if _tick_times.size() > 0:
|
||||
_tick_deltas.append(float(now_ms - _tick_times.back()))
|
||||
if _tick_deltas.size() > TICK_HISTORY_LEN:
|
||||
_tick_deltas.pop_front()
|
||||
_tick_times.append(now_ms)
|
||||
if _tick_times.size() > TICK_HISTORY_LEN + 1:
|
||||
_tick_times.pop_front()
|
||||
_update_npc_paths()
|
||||
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _update_npc_paths() -> void:
|
||||
var seen_ids: Dictionary = {}
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
var kind_variant: String = entity.get("kind", {}).get("variant", "")
|
||||
if kind_variant != "Npc":
|
||||
continue
|
||||
var eid: int = entity.get("entity_id", -1)
|
||||
if eid < 0:
|
||||
continue
|
||||
seen_ids[eid] = true
|
||||
var pos := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
|
||||
if not _npc_paths.has(eid):
|
||||
_npc_paths[eid] = []
|
||||
var path: Array = _npc_paths[eid]
|
||||
if path.size() == 0 or path.back() != pos:
|
||||
path.append(pos)
|
||||
if path.size() > NPC_HISTORY_LEN:
|
||||
path.pop_front()
|
||||
# Prune entities no longer visible
|
||||
for eid in _npc_paths.keys():
|
||||
if not seen_ids.has(eid):
|
||||
_npc_paths.erase(eid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Draw dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw() -> void:
|
||||
if not visible:
|
||||
return
|
||||
_draw_stats_panel()
|
||||
_draw_world_overlays()
|
||||
_draw_tick_graph()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 1: Stats text (top-left)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw_stats_panel() -> void:
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
|
||||
# Build lines as [label, value, label, value] pairs (two columns)
|
||||
var left_lines: Array = []
|
||||
var right_lines: Array = []
|
||||
|
||||
@@ -71,7 +191,10 @@ func _draw() -> void:
|
||||
left_lines.append(["mode", mode_str])
|
||||
right_lines.append(["gauntlet", gauntlet_str])
|
||||
|
||||
# Measure column widths
|
||||
var gid := GameState.current_game_id
|
||||
left_lines.append(["game_id", gid if gid != "" else "-"])
|
||||
right_lines.append(["npc_paths", str(_npc_paths.size())])
|
||||
|
||||
var left_label_w: float = 0.0
|
||||
var left_value_w: float = 0.0
|
||||
var right_label_w: float = 0.0
|
||||
@@ -89,27 +212,240 @@ func _draw() -> void:
|
||||
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
||||
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||
var line_count: int = maxi(left_lines.size(), right_lines.size())
|
||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
|
||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
|
||||
|
||||
# Header
|
||||
var y: float = PADDING.y + FONT_SIZE
|
||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
# Data lines (two columns)
|
||||
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
||||
for i in range(line_count):
|
||||
if i < left_lines.size():
|
||||
var lbl: String = left_lines[i][0] + ": "
|
||||
var val: String = left_lines[i][1]
|
||||
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
if i < right_lines.size():
|
||||
var lbl: String = right_lines[i][0] + ": "
|
||||
var val: String = right_lines[i][1]
|
||||
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 2: World overlays
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw_world_overlays() -> void:
|
||||
var vp := get_viewport()
|
||||
if vp == null:
|
||||
return
|
||||
# get_canvas_transform() applies Camera2D — valid for CanvasLayer 0 content.
|
||||
# The DebugOverlay is on UILayer (layer 20) so its own draw space IS screen space.
|
||||
# Using this transform converts world coords → screen pixel coords for the overlays.
|
||||
var canvas_xf := vp.get_canvas_transform()
|
||||
var player_screen := _w2s(GameState.player_position, canvas_xf)
|
||||
|
||||
_draw_vision_cone(player_screen, canvas_xf)
|
||||
_draw_los_rays(player_screen, canvas_xf)
|
||||
_draw_npc_paths(canvas_xf)
|
||||
_draw_info_tags(canvas_xf)
|
||||
|
||||
|
||||
# Convert world tile position → screen pixel position
|
||||
func _w2s(world_pos: Vector2, canvas_xf: Transform2D) -> Vector2:
|
||||
return canvas_xf * (world_pos * Constants.TILE_SIZE)
|
||||
|
||||
|
||||
# Vision cone: filled forward sector + peripheral bands.
|
||||
# Uses player facing direction and visibility sector distance.
|
||||
func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||
var facing_angle := _facing_to_angle(GameState.player_facing)
|
||||
|
||||
# Estimate visible radius from furthest visibility sector tile
|
||||
var max_d: float = 4.0
|
||||
for vpos in GameState.visibility_sectors.keys():
|
||||
var d := Vector2(vpos.x, vpos.y).distance_to(GameState.player_position)
|
||||
if d > max_d:
|
||||
max_d = d
|
||||
var scale_x := canvas_xf.x.length()
|
||||
var r: float = clampf(max_d * Constants.TILE_SIZE * scale_x, 40.0, 280.0)
|
||||
|
||||
# Helper: build a polygon fan from center outward over arc [angle_from, angle_to]
|
||||
var forward_from := facing_angle - CONE_FORWARD_HALF
|
||||
var forward_to := facing_angle + CONE_FORWARD_HALF
|
||||
var perip_l_from := facing_angle - CONE_PERIPHERAL_HALF
|
||||
var perip_l_to := facing_angle - CONE_FORWARD_HALF
|
||||
var perip_r_from := facing_angle + CONE_FORWARD_HALF
|
||||
var perip_r_to := facing_angle + CONE_PERIPHERAL_HALF
|
||||
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR)
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR)
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR)
|
||||
|
||||
# Forward arc boundary ring
|
||||
draw_arc(player_screen, r, forward_from, forward_to, CONE_ARC_STEPS, CONE_RING_COLOR, 1.0)
|
||||
|
||||
# Player dot
|
||||
draw_circle(player_screen, PLAYER_DOT_RADIUS, PLAYER_DOT_COLOR)
|
||||
|
||||
|
||||
# Build a filled polygon fan from center through an arc
|
||||
func _arc_polygon(center: Vector2, radius: float, angle_from: float, angle_to: float) -> PackedVector2Array:
|
||||
var pts := PackedVector2Array()
|
||||
pts.append(center)
|
||||
for i in range(CONE_ARC_STEPS + 1):
|
||||
var t := float(i) / float(CONE_ARC_STEPS)
|
||||
var a := angle_from + t * (angle_to - angle_from)
|
||||
pts.append(center + Vector2(cos(a), sin(a)) * radius)
|
||||
return pts
|
||||
|
||||
|
||||
# Dashed LOS lines from player to each visible non-player entity
|
||||
func _draw_los_rays(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
if entity.get("kind", {}).get("variant", "") == "Player":
|
||||
continue
|
||||
var entity_world := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
|
||||
var entity_screen := _w2s(entity_world, canvas_xf)
|
||||
var rel: String = entity.get("relationship", "Unknown")
|
||||
var color := Constants.color_for_relationship(rel)
|
||||
color.a = 0.45
|
||||
draw_dashed_line(player_screen, entity_screen, color, 1.0, 6.0)
|
||||
draw_circle(entity_screen, NPC_DOT_RADIUS, Color(color.r, color.g, color.b, 0.7))
|
||||
|
||||
|
||||
# Fading NPC movement path trails from position history
|
||||
func _draw_npc_paths(canvas_xf: Transform2D) -> void:
|
||||
for eid in _npc_paths.keys():
|
||||
var path: Array = _npc_paths[eid]
|
||||
if path.size() < 2:
|
||||
continue
|
||||
for i in range(1, path.size()):
|
||||
var a_screen := _w2s(path[i - 1], canvas_xf)
|
||||
var b_screen := _w2s(path[i], canvas_xf)
|
||||
var alpha := float(i) / float(path.size())
|
||||
draw_line(a_screen, b_screen, Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5)
|
||||
draw_circle(_w2s(path.back(), canvas_xf), NPC_DOT_RADIUS, NPC_DOT_COLOR)
|
||||
|
||||
|
||||
# Information state tags above visible NPCs from player_knowledge
|
||||
func _draw_info_tags(canvas_xf: Transform2D) -> void:
|
||||
if GameState.player_knowledge == null:
|
||||
return
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
var knowledge: Dictionary = GameState.player_knowledge
|
||||
|
||||
# Build entity_id → knowledge entry lookup
|
||||
var kg_by_id: Dictionary = {}
|
||||
for entry in knowledge.get("entities", []):
|
||||
if entry is Dictionary and entry.has("entity_id"):
|
||||
kg_by_id[entry.entity_id] = entry
|
||||
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
if entity.get("kind", {}).get("variant", "") != "Npc":
|
||||
continue
|
||||
var eid: int = entity.get("entity_id", -1)
|
||||
if not kg_by_id.has(eid):
|
||||
continue
|
||||
var kg_entry: Dictionary = kg_by_id[eid]
|
||||
var confidence: String = kg_entry.get("confidence", "Unknown")
|
||||
var name_str: String = kg_entry.get("name", "?")
|
||||
var label := "%s [%s]" % [name_str, confidence]
|
||||
|
||||
var entity_screen := _w2s(Vector2(entity.get("x", 0.0), entity.get("y", 0.0)), canvas_xf)
|
||||
var tag_baseline := entity_screen.y - 18.0
|
||||
var text_w := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1).x
|
||||
var tag_rect := Rect2(
|
||||
entity_screen.x - text_w / 2.0 - 3.0,
|
||||
tag_baseline - FONT_SIZE + 2.0,
|
||||
text_w + 6.0,
|
||||
FONT_SIZE)
|
||||
draw_rect(tag_rect, TAG_BG_COLOR)
|
||||
draw_string(font,
|
||||
Vector2(entity_screen.x - text_w / 2.0, tag_baseline),
|
||||
label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, TAG_TEXT_COLOR)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 3: Tick timing sparkline (bottom-left)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw_tick_graph() -> void:
|
||||
if _tick_deltas.size() < 2:
|
||||
return
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
var vp_size := get_viewport_rect().size
|
||||
var box_x := GRAPH_MARGIN
|
||||
var label_h := LINE_HEIGHT
|
||||
var box_y := vp_size.y - GRAPH_H - label_h - GRAPH_MARGIN
|
||||
|
||||
draw_rect(Rect2(box_x, box_y, GRAPH_W, GRAPH_H + label_h), GRAPH_BG_COLOR)
|
||||
draw_string(font,
|
||||
Vector2(box_x + 4, box_y + FONT_SIZE + 1),
|
||||
"tick ms (n=%d)" % _tick_deltas.size(),
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||
|
||||
var chart_top := box_y + label_h
|
||||
var chart_left := box_x + 4.0
|
||||
var chart_w := GRAPH_W - 8.0
|
||||
var chart_h := GRAPH_H - 4.0
|
||||
|
||||
# Max value for scale
|
||||
var max_ms: float = TICK_WARN_MS
|
||||
for d in _tick_deltas:
|
||||
if float(d) > max_ms:
|
||||
max_ms = float(d)
|
||||
max_ms *= 1.1
|
||||
|
||||
# Warn threshold dashed line
|
||||
var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms)
|
||||
draw_dashed_line(
|
||||
Vector2(chart_left, warn_y), Vector2(chart_left + chart_w, warn_y),
|
||||
Color(GRAPH_WARN_COLOR.r, GRAPH_WARN_COLOR.g, GRAPH_WARN_COLOR.b, 0.3),
|
||||
1.0, 4.0)
|
||||
|
||||
# Sparkline
|
||||
var n := _tick_deltas.size()
|
||||
var prev_pt := Vector2.ZERO
|
||||
for i in range(n):
|
||||
var x := chart_left + chart_w * (float(i) / float(n - 1))
|
||||
var clamped := clampf(float(_tick_deltas[i]), 0.0, max_ms)
|
||||
var y := chart_top + chart_h * (1.0 - clamped / max_ms)
|
||||
var pt := Vector2(x, y)
|
||||
var color := GRAPH_WARN_COLOR if float(_tick_deltas[i]) > TICK_WARN_MS else GRAPH_LINE_COLOR
|
||||
if i > 0:
|
||||
draw_line(prev_pt, pt, color, 1.5)
|
||||
draw_circle(pt, 2.0, color)
|
||||
prev_pt = pt
|
||||
|
||||
# Average label
|
||||
var avg_ms := 0.0
|
||||
for d in _tick_deltas:
|
||||
avg_ms += float(d)
|
||||
avg_ms /= float(_tick_deltas.size())
|
||||
draw_string(font,
|
||||
Vector2(chart_left + chart_w - 54.0, chart_top + chart_h + FONT_SIZE - 2),
|
||||
"avg %.0fms" % avg_ms,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North)
|
||||
static func _facing_to_angle(facing: String) -> float:
|
||||
match facing:
|
||||
"North": return -PI / 2.0
|
||||
"Northeast": return -PI / 4.0
|
||||
"East": return 0.0
|
||||
"Southeast": return PI / 4.0
|
||||
"South": return PI / 2.0
|
||||
"Southwest": return PI * 3.0 / 4.0
|
||||
"West": return PI
|
||||
"Northwest": return -PI * 3.0 / 4.0
|
||||
_: return -PI / 2.0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
うtickヘ,ヲaction�DialogueResponseげtarget_entity_id*ォresponse_idーkael-davan_d_001
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env -S godot -s
|
||||
## gdUnit4 CI runner for the Settled Reach client.
|
||||
##
|
||||
## Run all client tests headlessly:
|
||||
## godot --headless --path client/ \
|
||||
## -s res://tests/run_gdunit4.gd \
|
||||
## -- --ignoreHeadlessMode -a res://tests/
|
||||
##
|
||||
## Run a specific test file:
|
||||
## godot --headless --path client/ \
|
||||
## -s res://tests/run_gdunit4.gd \
|
||||
## -- --ignoreHeadlessMode -a res://tests/test_protocol.gd
|
||||
##
|
||||
## Exit code: 0 = all pass, non-zero = failures.
|
||||
##
|
||||
## D-030 (architecture.md): gdUnit4 is the confirmed Godot test framework.
|
||||
## Test output format: JSON summary per D-030 sub-decision #6.
|
||||
extends SceneTree
|
||||
|
||||
var _cli_runner: GdUnitTestCIRunner
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
|
||||
_cli_runner = GdUnitTestCIRunner.new()
|
||||
root.add_child(_cli_runner)
|
||||
|
||||
|
||||
func _finalize() -> void:
|
||||
queue_delete(_cli_runner)
|
||||
@@ -135,43 +135,43 @@ func test_npc_uses_relationship_color_unknown() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_npc_uses_relationship_color_friendly() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
if not _entity_uses_relationship(renderer):
|
||||
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
|
||||
renderer.queue_free()
|
||||
return
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_npc_uses_relationship_color_poi() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_npc_uses_relationship_color_hostile() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_player_color_ignores_relationship() -> void:
|
||||
@@ -179,8 +179,8 @@ func test_player_color_ignores_relationship() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(1, "Player", "Hostile")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[1] as ColorRect
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||
var node: Sprite2D = renderer.entity_nodes[1] as Sprite2D
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_object_color_ignores_relationship() -> void:
|
||||
@@ -188,8 +188,8 @@ func test_object_color_ignores_relationship() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(3, "Object", "Friendly")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[3] as ColorRect
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
var node: Sprite2D = renderer.entity_nodes[3] as Sprite2D
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
@@ -204,20 +204,20 @@ func test_color_shift_not_instant() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
|
||||
renderer.update_entities(entities_before)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
# Change relationship to Hostile
|
||||
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
|
||||
renderer.update_entities(entities_after)
|
||||
# Immediately after update, color should NOT yet be the target
|
||||
var color_after_immediate: Color = node.color
|
||||
# Immediately after update, self_modulate should NOT yet be the target
|
||||
var color_after_immediate: Color = node.self_modulate
|
||||
if not _renderer_has_tween_support(renderer):
|
||||
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
|
||||
renderer.queue_free()
|
||||
return
|
||||
# The color should NOT be exactly the target yet (tween in progress)
|
||||
# The self_modulate should NOT be exactly the target yet (tween in progress)
|
||||
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
@@ -236,8 +236,8 @@ func test_color_shift_reaches_target() -> void:
|
||||
while elapsed < 1.5:
|
||||
renderer._process(1.0 / 60.0)
|
||||
elapsed += 1.0 / 60.0
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_color_shift_mid_transition_retrigger() -> void:
|
||||
@@ -263,8 +263,8 @@ func test_color_shift_mid_transition_retrigger() -> void:
|
||||
while elapsed < 1.0:
|
||||
renderer._process(1.0 / 60.0)
|
||||
elapsed += 1.0 / 60.0
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
@@ -275,10 +275,10 @@ func test_color_shift_same_relationship_no_tween() -> void:
|
||||
return
|
||||
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var color_first: Color = node.color
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
var color_first: Color = node.self_modulate
|
||||
renderer.update_entities(entities)
|
||||
var color_second: Color = node.color
|
||||
var color_second: Color = node.self_modulate
|
||||
assert_that(color_first).is_equal(color_second)
|
||||
renderer.queue_free()
|
||||
|
||||
@@ -354,10 +354,12 @@ func _entity_uses_relationship(renderer: Node2D) -> bool:
|
||||
renderer.update_entities([friendly, hostile])
|
||||
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
|
||||
return false
|
||||
var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect
|
||||
var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect
|
||||
var f_color: Color = f_node.color
|
||||
var h_color: Color = h_node.color
|
||||
var f_node: Sprite2D = renderer.entity_nodes[10] as Sprite2D
|
||||
var h_node: Sprite2D = renderer.entity_nodes[11] as Sprite2D
|
||||
if not f_node or not h_node:
|
||||
return false
|
||||
var f_color: Color = f_node.self_modulate
|
||||
var h_color: Color = h_node.self_modulate
|
||||
var uses_rel: bool = not f_color.is_equal_approx(h_color)
|
||||
renderer.update_entities([])
|
||||
return uses_rel
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
## Sprint 19 — Debug visualization overlay (#348)
|
||||
## F3 toggle, world overlays, tick timing graph.
|
||||
## Extends the existing debug_overlay.gd stub.
|
||||
class_name TestDebugOverlaySprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
|
||||
const DEBUG_SCRIPT_PATH: String = "res://scripts/ui/debug_overlay.gd"
|
||||
|
||||
|
||||
func _make_overlay() -> Control:
|
||||
## Instantiate a standalone DebugOverlay control for unit testing.
|
||||
## Does not require the full main.tscn scene tree.
|
||||
var script := load(DEBUG_SCRIPT_PATH)
|
||||
if script == null:
|
||||
push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip")
|
||||
return null
|
||||
var node := Control.new()
|
||||
node.set_script(script)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.player_position = Vector2(10.0, 10.0)
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.current_tick = 1
|
||||
GameState.player_knowledge = null
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.visible_entities = []
|
||||
GameState.player_knowledge = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Script existence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_script_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
|
||||
"debug_overlay.gd must exist at res://scripts/ui/debug_overlay.gd (#348)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Instantiation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_instantiates_without_crash() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_that(ol).is_not_null()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_debug_overlay_starts_hidden() -> void:
|
||||
## Overlay starts hidden — only appears when F3 pressed.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.visible).override_failure_message(
|
||||
"DebugOverlay must start hidden (visible=false)"
|
||||
).is_false()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dev-only guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has_method("update_from_state")).override_failure_message(
|
||||
"DebugOverlay must have update_from_state() method"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_crash_when_hidden() -> void:
|
||||
## update_from_state() called while hidden must not crash.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = false
|
||||
ol.update_from_state() # Should be a no-op, no crash
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_crash_when_visible() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
# Simulate a minimal snapshot tick
|
||||
GameState.current_tick = 42
|
||||
ol.update_from_state()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NPC path tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_npc_paths_field_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("_npc_paths")).override_failure_message(
|
||||
"DebugOverlay must have _npc_paths field for NPC movement history"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_updated_on_state_update() -> void:
|
||||
## After update_from_state with an NPC entity, _npc_paths should have an entry.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 2,
|
||||
"x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"relationship": "Unknown",
|
||||
}]
|
||||
GameState.current_tick = 100
|
||||
ol.update_from_state()
|
||||
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||
"_npc_paths must record NPC positions from visible_entities"
|
||||
).is_greater(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_not_populated_for_player_entity() -> void:
|
||||
## Player entities must not appear in NPC path history.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 1,
|
||||
"x": 10.0, "y": 10.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null},
|
||||
}]
|
||||
GameState.current_tick = 101
|
||||
ol.update_from_state()
|
||||
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||
"Player entity must not appear in _npc_paths"
|
||||
).is_equal(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_max_length_respected() -> void:
|
||||
## Path history must not grow beyond NPC_HISTORY_LEN entries.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
# Simulate NPC moving each tick — inject 20 ticks of movement
|
||||
for i in range(20):
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 5,
|
||||
"x": float(12 + i), "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"relationship": "Unknown",
|
||||
}]
|
||||
GameState.current_tick = 200 + i
|
||||
ol.update_from_state()
|
||||
var path: Array = ol._npc_paths.get(5, [])
|
||||
assert_int(path.size()).override_failure_message(
|
||||
"NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN
|
||||
).is_less_equal(ol.NPC_HISTORY_LEN)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tick timing ring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_tick_deltas_field_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("_tick_deltas")).override_failure_message(
|
||||
"DebugOverlay must have _tick_deltas field for timing sparkline"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_deltas_accumulate_over_state_updates() -> void:
|
||||
## Each new tick snapshot should add a delta to _tick_deltas.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
for i in range(5):
|
||||
GameState.current_tick = 300 + i
|
||||
ol.update_from_state()
|
||||
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||
"_tick_deltas must accumulate entries from successive ticks"
|
||||
).is_greater(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_deltas_max_length_respected() -> void:
|
||||
## _tick_deltas must not grow beyond TICK_HISTORY_LEN.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
for i in range(50):
|
||||
GameState.current_tick = 400 + i
|
||||
ol.update_from_state()
|
||||
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||
"_tick_deltas must not exceed TICK_HISTORY_LEN entries"
|
||||
).is_less_equal(ol.TICK_HISTORY_LEN)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants defined
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_npc_history_len_constant_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message(
|
||||
"DebugOverlay must have NPC_HISTORY_LEN constant"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_history_len_constant_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message(
|
||||
"DebugOverlay must have TICK_HISTORY_LEN constant"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_warn_ms_constant_defined() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("TICK_WARN_MS")).override_failure_message(
|
||||
"DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Facing angle helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_facing_to_angle_north() -> void:
|
||||
## North = -PI/2 in Godot 2D (up on screen)
|
||||
var angle := _fetch_facing_angle("North")
|
||||
assert_float(angle).override_failure_message(
|
||||
"_facing_to_angle('North') must return -PI/2"
|
||||
).is_equal_approx(-PI / 2.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_east() -> void:
|
||||
var angle := _fetch_facing_angle("East")
|
||||
assert_float(angle).is_equal_approx(0.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_south() -> void:
|
||||
var angle := _fetch_facing_angle("South")
|
||||
assert_float(angle).is_equal_approx(PI / 2.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_west() -> void:
|
||||
var angle := _fetch_facing_angle("West")
|
||||
assert_float(angle).is_equal_approx(PI, 0.001)
|
||||
|
||||
|
||||
func _fetch_facing_angle(facing: String) -> float:
|
||||
## Helper: load script and call static method.
|
||||
var script = load(DEBUG_SCRIPT_PATH)
|
||||
if script == null:
|
||||
return 0.0
|
||||
# In GDScript 4, static methods can be called via an instance
|
||||
var tmp := Control.new()
|
||||
tmp.set_script(script)
|
||||
add_child(tmp)
|
||||
var result := tmp._facing_to_angle(facing)
|
||||
tmp.queue_free()
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-scene placement: DebugOverlay on UILayer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_in_main_scene_ui_layer() -> void:
|
||||
## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay.
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestDebugOverlaySprint19: main.tscn not found — skip")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var ui_layer := scene.get_node_or_null("UILayer")
|
||||
assert_that(ui_layer != null).override_failure_message(
|
||||
"UILayer must exist in main.tscn"
|
||||
).is_true()
|
||||
if ui_layer == null: return
|
||||
var overlay := ui_layer.get_node_or_null("DebugOverlay")
|
||||
assert_that(overlay != null).override_failure_message(
|
||||
"DebugOverlay must be a child of UILayer in main.tscn (#348)"
|
||||
).is_true()
|
||||
@@ -0,0 +1,598 @@
|
||||
## Sprint 18 — Dialogue UI hardening + examine result display (#174)
|
||||
## Spec refs: D-061 (dialogue box), D-062 (invisible locked options), D-063 (confrontation),
|
||||
## D-064 (walk-away), D-078 (overheard log)
|
||||
##
|
||||
## Test plan from joint.md:
|
||||
## "Manual: examine result appears as overlay, auto-dismisses.
|
||||
## Dialogue options confirmed: no locked/grayed options visible.
|
||||
## Confrontation option in italic voice."
|
||||
##
|
||||
## Unit-testable coverage here:
|
||||
## - D-062: no locked/grayed option mechanism in dialogue_box.gd
|
||||
## - D-063: confrontation beat duration, signal, dim alpha
|
||||
## - D-064: walk-away fires dialogue_dismissed signal
|
||||
## - GameState: current_dialogue parsing
|
||||
## - GameState: current_examine_result parsing (test-first — impl TBD in #174)
|
||||
## - BBCode: escape contract (Hoshe #2 regression guard)
|
||||
## - Dirty flag: _log_dirty optimization (Hoshe #1 regression guard)
|
||||
class_name TestDialogueSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _make_dialogue_box() -> Control:
|
||||
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
|
||||
push_warning("TestDialogueSprint18: dialogue_box.tscn not found — scene tests skipped")
|
||||
return null
|
||||
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_options(texts: Array[String], confrontation_flags: Array[bool] = []) -> Array:
|
||||
var opts: Array = []
|
||||
for i in range(texts.size()):
|
||||
var opt: Dictionary = {
|
||||
"text": texts[i],
|
||||
"response_id": "r%d" % i,
|
||||
"priority": i,
|
||||
}
|
||||
if confrontation_flags.size() > i:
|
||||
opt["confrontation"] = confrontation_flags[i]
|
||||
opts.append(opt)
|
||||
return opts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
if GameState.has("current_examine_result"):
|
||||
GameState.current_examine_result = null
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
if GameState.has("current_examine_result"):
|
||||
GameState.current_examine_result = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-062: No locked/grayed options
|
||||
## Per D-062: locked options are INVISIBLE — not shown at all.
|
||||
## The client renders all received options as active, clickable labels.
|
||||
## Server responsibility: omit locked options from the array.
|
||||
## Test verifies: no disabled/locked styling is applied to any rendered option.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d062_rendered_options_have_no_disabled_state() -> void:
|
||||
## D-062: All options from server render as active controls.
|
||||
## No option should have mouse_filter=IGNORE (which would indicate disabled).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
box.show_dialogue("NPC", "Hello.", _make_options(["Option A", "Option B", "Option C"]))
|
||||
|
||||
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
|
||||
assert_that(options_container != null).override_failure_message("OptionsContainer must exist").is_true()
|
||||
|
||||
var labels := options_container.get_children()
|
||||
assert_int(labels.size()).override_failure_message("3 options must render as 3 labels").is_equal(3)
|
||||
|
||||
for label in labels:
|
||||
# MOUSE_FILTER_STOP = 0: active and clickable — correct for D-062
|
||||
# MOUSE_FILTER_IGNORE = 2: would indicate disabled — D-062 violation
|
||||
assert_int(label.mouse_filter).override_failure_message(
|
||||
"Option '%s' must be mouse_filter=STOP (0), not IGNORE (2) — D-062 requires no locked options" % label.text
|
||||
).is_not_equal(Control.MOUSE_FILTER_IGNORE)
|
||||
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d062_no_lock_icon_children_on_options() -> void:
|
||||
## D-062: Options must have no lock icon children (no TextureRect/Sprite2D children).
|
||||
## Any child node on an option label would indicate a locked-option indicator.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Only option"]))
|
||||
|
||||
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
|
||||
if options_container == null: box.queue_free(); return
|
||||
|
||||
var labels := options_container.get_children()
|
||||
assert_int(labels.size()).is_greater(0)
|
||||
|
||||
for label in labels:
|
||||
assert_int(label.get_child_count()).override_failure_message(
|
||||
"Option label must have no child nodes — no lock icons, no decorators (D-062)"
|
||||
).is_equal(0)
|
||||
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d062_max_options_constant_is_three() -> void:
|
||||
## D-061: max 3 response options. D-062: if >3 options arrive, only top 3 by priority
|
||||
## are shown — server must omit locked ones, client only truncates to 3.
|
||||
## Verify MAX_OPTIONS constant is locked at 3.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_int(box.MAX_OPTIONS).override_failure_message(
|
||||
"MAX_OPTIONS must be 3 per D-061 spec"
|
||||
).is_equal(3)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d062_server_sends_four_options_only_three_render() -> void:
|
||||
## D-061/D-062: If server sends 4 options (shouldn't happen but guard),
|
||||
## only top 3 by priority render. No 4th option appears.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var opts := _make_options(["A", "B", "C", "D"])
|
||||
# Assign explicit priorities so sort is deterministic
|
||||
for i in range(opts.size()):
|
||||
opts[i]["priority"] = i
|
||||
box.show_dialogue("NPC", "Speech.", opts)
|
||||
|
||||
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
|
||||
if options_container == null: box.queue_free(); return
|
||||
|
||||
assert_int(options_container.get_child_count()).override_failure_message(
|
||||
"Only 3 options must render even when server sends 4 (D-061 truncation)"
|
||||
).is_equal(3)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-063: Confrontation beat
|
||||
## Confrontation options trigger a 1.5s pre-delivery monologue beat.
|
||||
## Panel dims during beat. confrontation_monologue signal fires.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d063_beat_duration_within_spec() -> void:
|
||||
## D-063: The beat duration must be 1–2 seconds per spec.
|
||||
## Current implementation: CONFRONTATION_BEAT_DURATION = 1.5s.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_float(box.CONFRONTATION_BEAT_DURATION).override_failure_message(
|
||||
"D-063: confrontation beat must be 1.0–2.0 seconds"
|
||||
).is_between(1.0, 2.0)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d063_dim_alpha_is_set() -> void:
|
||||
## D-063: The dialogue box dims during the confrontation beat.
|
||||
## CONFRONTATION_DIM_ALPHA must be below 1.0 (not full opacity).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
|
||||
"D-063: confrontation dim alpha must be < 1.0 (panel visibly dims)"
|
||||
).is_less(1.0)
|
||||
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
|
||||
"D-063: confrontation dim alpha must be > 0.0 (panel still visible)"
|
||||
).is_greater(0.0)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
|
||||
## D-063: Selecting a confrontation option fires confrontation_monologue signal.
|
||||
## This delivers the 1-2 second internal monologue beat to MonologueDisplay.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var signal_fired := false
|
||||
var received_text := ""
|
||||
box.confrontation_monologue.connect(func(text: String, _dur: float):
|
||||
signal_fired = true
|
||||
received_text = text
|
||||
)
|
||||
|
||||
# Show dialogue with one confrontation option
|
||||
var opts := _make_options(["I know what you did."], [true])
|
||||
box.show_dialogue("NPC", "Everything is fine.", opts)
|
||||
|
||||
# Press option 1 (index 0)
|
||||
box._on_option_pressed(0)
|
||||
|
||||
assert_bool(signal_fired).override_failure_message(
|
||||
"D-063: confrontation_monologue signal must fire when confrontation option is selected"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d063_non_confrontation_option_does_not_fire_beat_signal() -> void:
|
||||
## D-063: Standard (non-confrontation) options must NOT fire confrontation_monologue.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var signal_fired := false
|
||||
box.confrontation_monologue.connect(func(_text: String, _dur: float):
|
||||
signal_fired = true
|
||||
)
|
||||
|
||||
var opts := _make_options(["A normal response."], [false])
|
||||
box.show_dialogue("NPC", "Hello.", opts)
|
||||
box._on_option_pressed(0)
|
||||
|
||||
assert_bool(signal_fired).override_failure_message(
|
||||
"D-063: confrontation_monologue must NOT fire for standard options"
|
||||
).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-064: Walk-away mechanic
|
||||
## WASD during active conversation fires dialogue_dismissed signal.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d064_walk_away_actions_constant_not_empty() -> void:
|
||||
## D-064: _WALK_AWAY_ACTIONS must include at least the 8 movement directions.
|
||||
## Prevents accidental empty-array regression.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_int(box._WALK_AWAY_ACTIONS.size()).override_failure_message(
|
||||
"D-064: _WALK_AWAY_ACTIONS must list movement directions (minimum 4)"
|
||||
).is_greater_equal(4)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d064_walk_away_actions_include_cardinal_directions() -> void:
|
||||
## D-064: All four cardinal directions (WASD) must be walk-away triggers.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var actions: Array = box._WALK_AWAY_ACTIONS
|
||||
for required in [&"move_north", &"move_south", &"move_east", &"move_west"]:
|
||||
assert_bool(required in actions).override_failure_message(
|
||||
"D-064: '%s' must be in _WALK_AWAY_ACTIONS" % required
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d064_dialogue_dismissed_signal_connection() -> void:
|
||||
## D-064: dialogue_dismissed signal must exist on DialogueBox.
|
||||
## The signal drives walk-away behavior in main.gd.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_bool(box.has_signal("dialogue_dismissed")).override_failure_message(
|
||||
"D-064: dialogue_dismissed signal must exist on DialogueBox"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: current_dialogue snapshot parsing
|
||||
## D-061: current_dialogue is set from snapshot, null when absent.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_current_dialogue_set_from_snapshot() -> void:
|
||||
## apply_snapshot with current_dialogue dict populates the field.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {
|
||||
"npc_name": "Kael Davan",
|
||||
"npc_entity_id": 42,
|
||||
"speech": "You don't belong here.",
|
||||
"options": [{"text": "I'm just passing through.", "response_id": "r001", "priority": 1}],
|
||||
},
|
||||
})
|
||||
assert_that(GameState.current_dialogue).is_not_null()
|
||||
assert_that(GameState.current_dialogue.get("npc_name")).is_equal("Kael Davan")
|
||||
|
||||
|
||||
func test_gamestate_current_dialogue_null_when_absent() -> void:
|
||||
## apply_snapshot without current_dialogue clears the field.
|
||||
## Prevents stale dialogue from persisting across ticks.
|
||||
GameState.current_dialogue = {"npc_name": "Ghost", "speech": "Stale."}
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.current_dialogue).is_null()
|
||||
|
||||
|
||||
func test_gamestate_current_dialogue_null_when_non_dict() -> void:
|
||||
## Non-dict current_dialogue is rejected — defensive against malformed server data.
|
||||
GameState.apply_snapshot({"tick": 1, "current_dialogue": "not-a-dict"})
|
||||
assert_that(GameState.current_dialogue).is_null()
|
||||
|
||||
|
||||
func test_gamestate_current_dialogue_options_survive_roundtrip() -> void:
|
||||
## The options array must survive snapshot parsing for DialogueBox to render them.
|
||||
var options := [
|
||||
{"text": "A", "response_id": "r1", "priority": 1},
|
||||
{"text": "B", "response_id": "r2", "priority": 2},
|
||||
]
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {
|
||||
"npc_name": "NPC",
|
||||
"npc_entity_id": 1,
|
||||
"speech": "Choose.",
|
||||
"options": options,
|
||||
},
|
||||
})
|
||||
assert_that(GameState.current_dialogue).is_not_null()
|
||||
var parsed_opts: Array = GameState.current_dialogue.get("options", [])
|
||||
assert_int(parsed_opts.size()).is_equal(2)
|
||||
assert_that(parsed_opts[0].get("response_id")).is_equal("r1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: current_examine_result snapshot parsing (test-first, #174)
|
||||
## Sprint 18: examine verb returns character-filtered observation text.
|
||||
## Field: examine_result: {entity_id: int, text: String, confidence: String} | null
|
||||
## GameState must expose current_examine_result for the overlay display node.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_examine_result_field_exists() -> void:
|
||||
## GameState must have a current_examine_result field (Sprint 18, #174).
|
||||
## Fails until Stig adds the field to game_state.gd.
|
||||
assert_bool(GameState.has("current_examine_result")).override_failure_message(
|
||||
"GameState must have 'current_examine_result' field (Sprint 18 #174 — add to game_state.gd)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_by_default() -> void:
|
||||
## current_examine_result defaults to null (no examine active).
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_null_by_default: field not yet added — skip")
|
||||
return
|
||||
GameState.current_examine_result = null
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_set_from_snapshot() -> void:
|
||||
## apply_snapshot with examine_result dict populates current_examine_result.
|
||||
## Wire format (joint.md): {entity_id: int, text: String, confidence: String}
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_set_from_snapshot: field not yet added — skip")
|
||||
return
|
||||
GameState.apply_snapshot({
|
||||
"tick": 5,
|
||||
"examine_result": {
|
||||
"entity_id": 12,
|
||||
"text": "Kael Davan — nervous energy. He's scanning exits.",
|
||||
"confidence": "KnowsOf",
|
||||
},
|
||||
})
|
||||
assert_that(GameState.current_examine_result).is_not_null()
|
||||
assert_that(GameState.current_examine_result.get("text")).contains("Kael Davan")
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_when_absent() -> void:
|
||||
## apply_snapshot without examine_result must clear the field.
|
||||
## Prevents stale examine overlay persisting beyond auto-dismiss window.
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_null_when_absent: field not yet added — skip")
|
||||
return
|
||||
GameState.current_examine_result = {"entity_id": 5, "text": "Stale.", "confidence": "Suspects"}
|
||||
GameState.apply_snapshot({"tick": 6})
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_when_non_dict() -> void:
|
||||
## Malformed examine_result (not a dict) must be rejected.
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_null_when_non_dict: field not yet added — skip")
|
||||
return
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": "bad-value"})
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void:
|
||||
## entity_id is needed to anchor the overlay above the correct entity.
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_entity_id_survives_roundtrip: field not yet added — skip")
|
||||
return
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"examine_result": {"entity_id": 99, "text": "Observed.", "confidence": "Direct"},
|
||||
})
|
||||
assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BBCode injection guard (regression: Hoshe #2)
|
||||
## Server-sourced text containing BBCode brackets must be escaped.
|
||||
## Note: dialogue_box.gd has no class_name — call _escape_bbcode via instance.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_escape_bbcode_brackets_in_server_text() -> void:
|
||||
## _escape_bbcode must convert '[' to '[lb]' to prevent BBCode injection.
|
||||
## Regression test: a malicious NPC name like "[wave]Evil[/wave]" must render
|
||||
## as plain text in the dialogue log.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var escaped: String = box._escape_bbcode("[wave]Evil NPC[/wave]")
|
||||
assert_that(escaped).is_not_equal("[wave]Evil NPC[/wave]")
|
||||
assert_that(escaped).contains("[lb]")
|
||||
assert_bool(escaped.begins_with("[")).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_escape_bbcode_plain_text_unchanged() -> void:
|
||||
## Non-BBCode text must not be modified by _escape_bbcode.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var plain := "Kael Davan"
|
||||
assert_that(box._escape_bbcode(plain)).is_equal(plain)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_escape_bbcode_multiple_brackets() -> void:
|
||||
## Multiple '[' characters all get escaped.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var text := "[b]Bold[/b] and [i]italic[/i]"
|
||||
var escaped: String = box._escape_bbcode(text)
|
||||
assert_bool(escaped.contains("[b]")).is_false()
|
||||
assert_bool(escaped.contains("[i]")).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log dirty flag optimization (regression: Hoshe #1)
|
||||
## _log_dirty prevents per-frame O(n) BBCode rebuilds.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_log_dirty_false_on_init() -> void:
|
||||
## _log_dirty starts false — no rebuild needed before first line.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_bool(box._log_dirty).override_failure_message(
|
||||
"_log_dirty must be false on init — no unnecessary rebuild"
|
||||
).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_log_dirty_set_after_append_line() -> void:
|
||||
## Appending a line sets _log_dirty = true.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
box.append_line("NPC", "Player", "Hello.", false)
|
||||
assert_bool(box._log_dirty).override_failure_message(
|
||||
"_log_dirty must be true after append_line to trigger rebuild next _process"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_log_dirty_cleared_after_process() -> void:
|
||||
## After _process(), _log_dirty is cleared (rebuild done).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
box.append_line("NPC", "Player", "One line.", false)
|
||||
assert_bool(box._log_dirty).is_true()
|
||||
box._process(0.0)
|
||||
assert_bool(box._log_dirty).override_failure_message(
|
||||
"_log_dirty must be false after _process (rebuild consumed the flag)"
|
||||
).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-061: Dialogue box size constraints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d061_max_height_ratio_is_twenty_percent() -> void:
|
||||
## D-061: dialogue box must occupy max 20% viewport height.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_float(box.MAX_HEIGHT_RATIO).override_failure_message(
|
||||
"D-061: MAX_HEIGHT_RATIO must be 0.2 (20% viewport height)"
|
||||
).is_equal_approx(0.2, 0.001)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d061_fade_in_is_200ms() -> void:
|
||||
## D-061: fade-in on dialogue appearance is 0.2s.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_float(box.FADE_IN).override_failure_message(
|
||||
"D-061: FADE_IN must be 0.2s"
|
||||
).is_equal_approx(0.2, 0.001)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d064_fade_out_is_300ms() -> void:
|
||||
## D-064: walk-away fade-out is 300ms per spec.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_float(box.FADE_OUT).override_failure_message(
|
||||
"D-064: FADE_OUT must be 0.3s (300ms walk-away fade)"
|
||||
).is_equal_approx(0.3, 0.001)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Passive log entry (D-078: overheard NPC-NPC)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_passive_entry_uses_bar_glyph() -> void:
|
||||
## D-078: Overheard NPC-NPC lines get ┃ prefix (PASSIVE_GLYPH).
|
||||
## Verifies the glyph constant is the correct Unicode bar character.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_that(box.PASSIVE_GLYPH).override_failure_message(
|
||||
"D-078: PASSIVE_GLYPH must be ┃ (U+2503) + space"
|
||||
).is_equal("\u2503 ")
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_passive_entry_appended_and_marked_is_passive() -> void:
|
||||
## D-078: append_conversation_event creates a passive log entry.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var event := {
|
||||
"speaker_id": 10, "target_id": 11,
|
||||
"speaker_name": "Guard A", "target_name": "Guard B",
|
||||
"occluded_line": "Did you see the detective?",
|
||||
"speaker_color_index": 0, "target_color_index": 1,
|
||||
}
|
||||
box.append_conversation_event(event)
|
||||
|
||||
assert_int(box._log_entries.size()).is_equal(1)
|
||||
assert_bool(box._log_entries[0].is_passive).override_failure_message(
|
||||
"D-078: overheard entry must be marked is_passive = true"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_passive_entry_empty_text_not_appended() -> void:
|
||||
## D-078: Conversation event with empty occluded_line is silently dropped.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
box.append_conversation_event({
|
||||
"speaker_id": 1, "target_id": 2,
|
||||
"speaker_name": "A", "target_name": "B",
|
||||
"occluded_line": "",
|
||||
})
|
||||
assert_int(box._log_entries.size()).override_failure_message(
|
||||
"D-078: empty occluded_line must not produce a log entry"
|
||||
).is_equal(0)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# has_active_entries / is_dialogue_active
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_has_active_entries_false_on_init() -> void:
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_bool(box.has_active_entries()).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_has_active_entries_true_after_append() -> void:
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
box.append_line("NPC", "Player", "Hey.", false)
|
||||
assert_bool(box.has_active_entries()).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_is_dialogue_active_false_on_init() -> void:
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_bool(box.is_dialogue_active()).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_is_dialogue_active_true_after_show_dialogue() -> void:
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(box.is_dialogue_active()).is_true()
|
||||
box.queue_free()
|
||||
@@ -0,0 +1,298 @@
|
||||
## Sprint 17 — UX: E-Talk overlay improvement (#537) — Phase 1
|
||||
## Tests for relationship color indicator in interaction_list.gd.
|
||||
##
|
||||
## Spec refs:
|
||||
## D-033 (entity color = relationship to player)
|
||||
## D-051 (diegetic insert display — insert-only overlay)
|
||||
## D-057 (interaction list, z-layer 6)
|
||||
##
|
||||
## Phase 1 scope (2026-02-24):
|
||||
## Color bar only — NPC name and dialogue tier deferred to Phase 2 pending
|
||||
## server protocol change (no known_attributes in wire protocol v13).
|
||||
##
|
||||
## Implementation: client/ui/interaction_list.gd
|
||||
## `var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM`
|
||||
## `func _cache_entity_relationship() -> void`
|
||||
## 3px left-edge bar drawn in _draw() at 85% alpha, called from update_from_state()
|
||||
class_name TestETalkOverlaySprint17
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
GameState.nearby_interactions = []
|
||||
GameState.visible_entities = []
|
||||
GameState.player_stance = ""
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.nearby_interactions = []
|
||||
GameState.visible_entities = []
|
||||
GameState.player_stance = ""
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# D-033: Constants.color_for_relationship() — palette baseline (pure logic)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_relationship_unknown_maps_to_teal() -> void:
|
||||
assert_that(Constants.color_for_relationship("Unknown")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
func test_relationship_friendly_maps_to_green() -> void:
|
||||
assert_that(Constants.color_for_relationship("Friendly")).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
|
||||
func test_relationship_poi_maps_to_amber() -> void:
|
||||
assert_that(Constants.color_for_relationship("PersonOfInterest")).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
|
||||
func test_relationship_hostile_maps_to_red() -> void:
|
||||
assert_that(Constants.color_for_relationship("Hostile")).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
|
||||
func test_relationship_known_falls_back_to_unknown_color() -> void:
|
||||
# "Known" not matched in color_for_relationship() — falls through _ → ENTITY_COLOR_UNKNOWN
|
||||
assert_that(Constants.color_for_relationship("Known")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
func test_relationship_unknown_string_falls_back_to_unknown() -> void:
|
||||
assert_that(Constants.color_for_relationship("SomeNewState")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# UIStrings: relationship state labels exist (Phase 2 pre-fixture)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_ui_strings_has_relationship_unknown_label() -> void:
|
||||
assert_that(UIStrings.has_key("relationship_states.unknown.label")).is_true()
|
||||
|
||||
func test_ui_strings_has_relationship_known_label() -> void:
|
||||
assert_that(UIStrings.has_key("relationship_states.known.label")).is_true()
|
||||
|
||||
func test_ui_strings_has_relationship_friendly_label() -> void:
|
||||
assert_that(UIStrings.has_key("relationship_states.friendly.label")).is_true()
|
||||
|
||||
func test_ui_strings_has_relationship_poi_label() -> void:
|
||||
assert_that(UIStrings.has_key("relationship_states.person_of_interest.label")).is_true()
|
||||
|
||||
func test_ui_strings_has_relationship_hostile_label() -> void:
|
||||
assert_that(UIStrings.has_key("relationship_states.hostile.label")).is_true()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# _relationship_color initial state
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_relationship_color_default_is_implant_dim() -> void:
|
||||
# Before any update, _relationship_color starts at IMPLANT_TEXT_DIM
|
||||
var list = _make_list()
|
||||
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||
list.queue_free()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# _cache_entity_relationship(): D-033 palette via update_from_state() (#537 core)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_cache_relationship_unknown_sets_unknown_color() -> void:
|
||||
_setup_npc_interaction(2, "Unknown")
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_friendly_sets_green() -> void:
|
||||
_setup_npc_interaction(2, "Friendly")
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_poi_sets_amber() -> void:
|
||||
_setup_npc_interaction(2, "PersonOfInterest")
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_hostile_sets_red() -> void:
|
||||
_setup_npc_interaction(2, "Hostile")
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_absent_field_uses_unknown_color() -> void:
|
||||
# Entity present but "relationship" key missing → get("relationship", "Unknown") → Unknown color
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"visibility": "Forward",
|
||||
}]
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||
}]
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_entity_not_in_visible_uses_dim_fallback() -> void:
|
||||
# Entity in nearby_interactions but NOT in visible_entities → IMPLANT_TEXT_DIM fallback
|
||||
GameState.visible_entities = []
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||
}]
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_updates_when_relationship_changes() -> void:
|
||||
# First call: Unknown
|
||||
_setup_npc_interaction(2, "Unknown")
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
# Relationship shifts → re-cache picks up new value
|
||||
_setup_npc_interaction(2, "Hostile")
|
||||
list.update_from_state()
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
list.queue_free()
|
||||
|
||||
func test_cache_relationship_targets_correct_entity_by_id() -> void:
|
||||
# Two entities visible; nearby_interactions[0] is the target (entity 2, Friendly)
|
||||
# Entity 3 (Hostile) must not pollute the color
|
||||
GameState.visible_entities = [
|
||||
{
|
||||
"entity_id": 2, "x": 11.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible",
|
||||
},
|
||||
{
|
||||
"entity_id": 3, "x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"visibility": "Forward", "relationship": "Hostile", "observation": "Visible",
|
||||
},
|
||||
]
|
||||
GameState.nearby_interactions = [
|
||||
{
|
||||
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||
},
|
||||
{
|
||||
"entity_id": 3, "entity_type": "Npc", "distance": 2,
|
||||
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||
},
|
||||
]
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
# interaction[0] = entity 2 (Friendly) → green bar
|
||||
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
list.queue_free()
|
||||
|
||||
func test_z_layer_is_insert_canvas() -> void:
|
||||
# D-049 / D-057: interaction list lives on InsertOverlay (CanvasLayer 10)
|
||||
var list = _make_list()
|
||||
assert_that(list.get_z_layer()).is_equal(Constants.CANVAS_INSERT)
|
||||
list.queue_free()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Regression: interaction_list public API unaffected by #537 changes
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_list_hides_when_no_interactions() -> void:
|
||||
GameState.nearby_interactions = []
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list.is_showing()).is_false()
|
||||
list.queue_free()
|
||||
|
||||
func test_list_shows_on_npc_interaction() -> void:
|
||||
_setup_npc_interaction(2, "Unknown")
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list.is_showing()).is_true()
|
||||
list.queue_free()
|
||||
|
||||
func test_list_get_selected_verb_returns_first_verb() -> void:
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "ExamineNpc", "label": "Look", "priority": 2, "available": true},
|
||||
],
|
||||
}]
|
||||
var list = _make_list()
|
||||
assert_that(list.get_selected_verb()).is_equal("Talk")
|
||||
list.queue_free()
|
||||
|
||||
func test_list_get_interaction_target_returns_entity_id() -> void:
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 5, "x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible",
|
||||
}]
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 5, "entity_type": "Npc", "distance": 1,
|
||||
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||
}]
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list.get_interaction_target()).is_equal(5)
|
||||
list.queue_free()
|
||||
|
||||
func test_list_suppressed_when_insert_inactive() -> void:
|
||||
_setup_npc_interaction(2, "Unknown")
|
||||
var list = _make_list()
|
||||
list.set_insert_active(false)
|
||||
list.update_from_state()
|
||||
assert_that(list.is_showing()).is_false()
|
||||
list.queue_free()
|
||||
|
||||
func test_list_hides_on_empty_verb_list() -> void:
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": [],
|
||||
}]
|
||||
var list = _make_list()
|
||||
list.update_from_state()
|
||||
assert_that(list.is_showing()).is_false()
|
||||
list.queue_free()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Phase 2 placeholders — deferred pending server protocol change (no known_attributes in v13)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func skip_test_npc_name_displayed_when_known() -> void:
|
||||
pass
|
||||
|
||||
func skip_test_dialogue_tier_context_hint_for_friendly() -> void:
|
||||
pass
|
||||
|
||||
func skip_test_dialogue_tier_context_hint_for_hostile() -> void:
|
||||
pass
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func _make_list() -> Control:
|
||||
var scene = load("res://ui/interaction_list.tscn")
|
||||
var list = scene.instantiate()
|
||||
add_child(list) # _ready() fires here — @onready var _vbox resolves
|
||||
return list
|
||||
|
||||
|
||||
## Set up GameState with a single NPC entity + matching interaction for tests.
|
||||
func _setup_npc_interaction(entity_id: int, relationship: String) -> void:
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": entity_id, "x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"visibility": "Forward", "relationship": relationship, "observation": "Visible",
|
||||
}]
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": entity_id, "entity_type": "Npc", "distance": 1,
|
||||
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||
}]
|
||||
@@ -0,0 +1,156 @@
|
||||
## GameState.apply_snapshot() tests — v2+ field coverage.
|
||||
##
|
||||
## Complements test_snapshot_parsing.gd (which covers v1 basics: tick, entities,
|
||||
## player_position). This file covers v2+ fields and derived state.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestGameState
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
# Reset fields touched by these tests to known defaults.
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.player_facing = "North"
|
||||
GameState.game_time = {}
|
||||
GameState.nearby_interactions = []
|
||||
GameState.current_monologue = null
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_inventory = []
|
||||
GameState.stationary_ticks = 0
|
||||
GameState.insert_active = true
|
||||
|
||||
|
||||
# -- v2: game_time (D-031) -------------------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_game_time() -> void:
|
||||
var snapshot := {
|
||||
"tick": 10,
|
||||
"entities": [],
|
||||
"game_time": {"day": 3, "time_of_day": 480, "day_phase": "Morning", "tick_rate": 1},
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_that(GameState.game_time).is_not_null()
|
||||
assert_that(GameState.game_time.get("day")).is_equal(3)
|
||||
assert_that(GameState.game_time.get("time_of_day")).is_equal(480)
|
||||
|
||||
|
||||
func test_apply_snapshot_game_time_missing_keeps_previous() -> void:
|
||||
GameState.game_time = {"day": 2, "time_of_day": 360}
|
||||
GameState.apply_snapshot({"tick": 5, "entities": []})
|
||||
# No "game_time" key → field unchanged
|
||||
assert_that(GameState.game_time.get("day")).is_equal(2)
|
||||
|
||||
|
||||
# -- v2: player_facing (D-015) --------------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_player_facing() -> void:
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [],
|
||||
"player_facing": "Southeast",
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_that(GameState.player_facing).is_equal("Southeast")
|
||||
|
||||
|
||||
func test_apply_snapshot_player_facing_missing_keeps_default() -> void:
|
||||
GameState.player_facing = "West"
|
||||
GameState.apply_snapshot({"tick": 1, "entities": []})
|
||||
assert_that(GameState.player_facing).is_equal("West")
|
||||
|
||||
|
||||
# -- v4: nearby_interactions (#404/#405) ----------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_nearby_interactions() -> void:
|
||||
var interactions := [
|
||||
{"entity_id": 5, "entity_type": "Npc", "distance": 1.2, "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]},
|
||||
]
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": interactions})
|
||||
assert_that(GameState.nearby_interactions.size()).is_equal(1)
|
||||
assert_that(GameState.nearby_interactions[0].get("entity_id")).is_equal(5)
|
||||
|
||||
|
||||
func test_apply_snapshot_nearby_interactions_absent_clears_list() -> void:
|
||||
GameState.nearby_interactions = [{"entity_id": 1}]
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.nearby_interactions.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- v5: current_monologue (#414) -----------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_monologue() -> void:
|
||||
var monologue := {"id": "m1", "text": "Something is off here.", "duration_seconds": 4.0, "priority": 1, "is_urgent": false}
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "current_monologue": monologue})
|
||||
assert_that(GameState.current_monologue).is_not_null()
|
||||
assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.")
|
||||
|
||||
|
||||
func test_apply_snapshot_monologue_absent_clears_field() -> void:
|
||||
GameState.current_monologue = {"id": "old", "text": "Old line."}
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.current_monologue).is_null()
|
||||
|
||||
|
||||
# -- v6: player_stance (#449, D-053) --------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_player_stance() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "player_stance": "Crouch"})
|
||||
assert_that(GameState.player_stance).is_equal("Crouch")
|
||||
|
||||
|
||||
# -- v6: player_inventory (#449, D-065) -----------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_player_inventory() -> void:
|
||||
var inventory := [{"item_id": 42, "name": "Security pass", "slot": 0}]
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "player_inventory": inventory})
|
||||
assert_that(GameState.player_inventory.size()).is_equal(1)
|
||||
assert_that(GameState.player_inventory[0].get("name")).is_equal("Security pass")
|
||||
|
||||
|
||||
func test_apply_snapshot_inventory_absent_clears_list() -> void:
|
||||
GameState.player_inventory = [{"item_id": 1}]
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.player_inventory.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- Stationary tick counter (D-071) -------------------------------------
|
||||
|
||||
func test_stationary_ticks_increments_when_player_position_unchanged() -> void:
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
}
|
||||
GameState.apply_snapshot(snapshot) # first call: position changes from ZERO
|
||||
GameState.apply_snapshot(snapshot) # second call: position unchanged → +1
|
||||
assert_int(GameState.stationary_ticks).is_greater(0)
|
||||
|
||||
|
||||
func test_stationary_ticks_resets_on_player_movement() -> void:
|
||||
var s1 := {
|
||||
"tick": 1,
|
||||
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
}
|
||||
var s2 := {
|
||||
"tick": 2,
|
||||
"entities": [{"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
}
|
||||
GameState.apply_snapshot(s1)
|
||||
GameState.apply_snapshot(s1) # stationary
|
||||
assert_int(GameState.stationary_ticks).is_greater(0)
|
||||
GameState.apply_snapshot(s2) # moved → reset
|
||||
assert_int(GameState.stationary_ticks).is_equal(0)
|
||||
|
||||
|
||||
# -- insert_active (OQ-07, #522) -----------------------------------------
|
||||
|
||||
func test_apply_snapshot_insert_active_false() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "insert_active": false})
|
||||
assert_bool(GameState.insert_active).is_false()
|
||||
|
||||
|
||||
func test_apply_snapshot_insert_active_defaults_true_when_absent() -> void:
|
||||
GameState.insert_active = false
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_bool(GameState.insert_active).is_true()
|
||||
@@ -0,0 +1,601 @@
|
||||
## Sprint 18 — Knowledge/journal display (#264)
|
||||
## Spec refs: D-041 (knowledge graph data model), D-027 (vertical slice — KG display),
|
||||
## D-042 (UIStrings for all labels)
|
||||
##
|
||||
## Tests now run against live Stig implementation.
|
||||
## Wire format per game_state.gd v14:
|
||||
## player_knowledge: {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]}
|
||||
##
|
||||
## NOTE: player_knowledge PERSISTS between snapshots (no-clear behavior, by design).
|
||||
## The server sends KG updates only when the graph changes — absence = no change.
|
||||
## Contrast with current_examine_result which DOES clear each snapshot.
|
||||
class_name TestJournalSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const JOURNAL_SCENE_PATH: String = "res://ui/journal_panel.tscn"
|
||||
|
||||
func _make_journal_panel() -> Control:
|
||||
if not ResourceLoader.exists(JOURNAL_SCENE_PATH):
|
||||
push_warning("TestJournalSprint18: journal_panel.tscn not found — scene tests skipped")
|
||||
return null
|
||||
var node: Control = load(JOURNAL_SCENE_PATH).instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_kg_entity(overrides: Dictionary = {}) -> Dictionary:
|
||||
## Wire format per game_state.gd v14 / Stig's Stig confirmation (2026-02-25).
|
||||
## entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]
|
||||
var base: Dictionary = {
|
||||
"entity_id": 42,
|
||||
"name": "Kael Davan",
|
||||
"confidence": "KnowsOf",
|
||||
"source": "DirectObservation",
|
||||
"state": "Active",
|
||||
"relationship": "PersonOfInterest",
|
||||
"last_observed_tick": 1024,
|
||||
}
|
||||
base.merge(overrides, true)
|
||||
return base
|
||||
|
||||
|
||||
func _make_player_knowledge(entities: Array = []) -> Dictionary:
|
||||
if entities.is_empty():
|
||||
entities = [_make_kg_entity()]
|
||||
return {"entities": entities}
|
||||
|
||||
|
||||
func _entries_container(panel: Control) -> Node:
|
||||
return panel.get_node_or_null(
|
||||
"PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.player_knowledge = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
GameState.current_tick = 0
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.player_knowledge = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: player_knowledge snapshot parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_player_knowledge_field_exists() -> void:
|
||||
## GameState must have player_knowledge field (v14, #264).
|
||||
assert_bool(GameState.has("player_knowledge")).override_failure_message(
|
||||
"GameState must have 'player_knowledge' field (Sprint 18 #264)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_null_by_default() -> void:
|
||||
GameState.player_knowledge = null
|
||||
assert_that(GameState.player_knowledge).is_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_set_from_snapshot() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 10,
|
||||
"player_knowledge": _make_player_knowledge(),
|
||||
})
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
assert_bool(GameState.player_knowledge.has("entities")).is_true()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_persists_when_absent() -> void:
|
||||
## IMPORTANT: player_knowledge does NOT clear when absent from snapshot.
|
||||
## Server sends KG updates only on change — absence means "no change since last tick".
|
||||
## This is intentional behavior (journal should not flash empty every tick).
|
||||
GameState.player_knowledge = _make_player_knowledge()
|
||||
GameState.apply_snapshot({"tick": 11})
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_null_when_non_dict() -> void:
|
||||
## Malformed player_knowledge (non-dict) must be rejected.
|
||||
## First set a valid value, then try to overwrite with invalid
|
||||
GameState.player_knowledge = _make_player_knowledge()
|
||||
GameState.apply_snapshot({"tick": 1, "player_knowledge": "bad-value"})
|
||||
# Non-dict is rejected — previous value preserved (or null if first time)
|
||||
# The implementation only updates on Dictionary type, so value persists
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_entities_survive_roundtrip() -> void:
|
||||
var entities := [
|
||||
_make_kg_entity({"name": "Kael Davan", "state": "Active"}),
|
||||
_make_kg_entity({"name": "Lysa Orin", "state": "Contradicted", "entity_id": 55}),
|
||||
]
|
||||
GameState.apply_snapshot({"tick": 5, "player_knowledge": {"entities": entities}})
|
||||
var parsed_entities: Array = GameState.player_knowledge.get("entities", [])
|
||||
assert_int(parsed_entities.size()).is_equal(2)
|
||||
assert_that(parsed_entities[0].get("name")).is_equal("Kael Davan")
|
||||
assert_that(parsed_entities[1].get("state")).is_equal("Contradicted")
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_updated_when_new_data_arrives() -> void:
|
||||
## When server sends a new player_knowledge, it replaces the previous value.
|
||||
GameState.apply_snapshot({"tick": 1, "player_knowledge": _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Person A"}),
|
||||
])})
|
||||
GameState.apply_snapshot({"tick": 2, "player_knowledge": _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Person A"}),
|
||||
_make_kg_entity({"name": "Person B", "entity_id": 99}),
|
||||
])})
|
||||
var entities: Array = GameState.player_knowledge.get("entities", [])
|
||||
assert_int(entities.size()).is_equal(2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: scene and API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_journal_panel_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(JOURNAL_SCENE_PATH)).override_failure_message(
|
||||
"Journal panel scene must exist at res://ui/journal_panel.tscn"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_journal_panel_instantiates_without_crash() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_that(panel).is_not_null()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_toggle_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("toggle")).override_failure_message(
|
||||
"JournalPanel must have toggle() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_close_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("close")).override_failure_message(
|
||||
"JournalPanel must have close() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_is_open_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("is_open")).override_failure_message(
|
||||
"JournalPanel must have is_open() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_update_from_state_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("update_from_state")).override_failure_message(
|
||||
"JournalPanel must have update_from_state() method (called from main.gd)"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_closed_on_init() -> void:
|
||||
## Panel starts hidden — not open by default.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"JournalPanel must be closed on _ready()"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_toggle_opens() -> void:
|
||||
## First toggle() opens the panel.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.toggle()
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"toggle() must set is_open() = true"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_toggle_closes() -> void:
|
||||
## Second toggle() closes the panel.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.toggle() # open
|
||||
panel.toggle() # close
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Second toggle() must close the panel"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_close_when_already_closed_is_safe() -> void:
|
||||
## close() on an already-closed panel must not crash.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.close()
|
||||
assert_bool(panel.is_open()).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: entry rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_journal_panel_entries_container_exists() -> void:
|
||||
## EntriesContainer is the VBoxContainer that holds entity entries.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var container := _entries_container(panel)
|
||||
assert_that(container != null).override_failure_message(
|
||||
"EntriesContainer must exist at PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_shows_entries_when_knowledge_populated() -> void:
|
||||
## Opening panel with player_knowledge set creates entry nodes in EntriesContainer.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Kael Davan"}),
|
||||
])
|
||||
panel.toggle() # calls _show_panel() -> _rebuild_entries()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"EntriesContainer must have children when player_knowledge is populated"
|
||||
).is_greater(0)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_shows_empty_state_when_no_knowledge() -> void:
|
||||
## Empty state Label is shown when player_knowledge is null.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = null
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## Empty state = exactly 1 child (the "Nothing logged yet." label)
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"EntriesContainer should have 1 child (empty state label) when knowledge is null"
|
||||
).is_equal(1)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_two_entities_create_more_entries() -> void:
|
||||
## Two entities create more entries than one (header + detail each, plus spacers).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Entity A", "entity_id": 1}),
|
||||
_make_kg_entity({"name": "Entity B", "entity_id": 2}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## Each entity: header_rtl + detail_rtl + spacer = 3 nodes. Two entities = 6 min.
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"Two entities must create at least 6 child nodes (2 × [header + detail + spacer])"
|
||||
).is_greater_equal(6)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_contradicted_entity_uses_strikethrough() -> void:
|
||||
## D-041: Contradicted entities must have strikethrough in their header BBCode.
|
||||
## journal_panel.gd renders [s]Name[/s] for Contradicted state.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Bad Guy", "state": "Contradicted"}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## First child should be the header RichTextLabel with [s]...[/s]
|
||||
if container.get_child_count() == 0:
|
||||
push_warning("test_journal_panel_contradicted_entity_uses_strikethrough: no entries — skip")
|
||||
panel.queue_free(); return
|
||||
|
||||
var first_child := container.get_child(0)
|
||||
if first_child is RichTextLabel:
|
||||
assert_that(first_child.text).override_failure_message(
|
||||
"Contradicted entity header must contain [s] (strikethrough) BBCode"
|
||||
).contains("[s]")
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_active_entity_no_strikethrough() -> void:
|
||||
## Active entity must NOT have strikethrough in its header.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Good Guy", "state": "Active"}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
if container.get_child_count() == 0:
|
||||
push_warning("test_journal_panel_active_entity_no_strikethrough: no entries — skip")
|
||||
panel.queue_free(); return
|
||||
|
||||
var first_child := container.get_child(0)
|
||||
if first_child is RichTextLabel:
|
||||
assert_bool(first_child.text.contains("[s]")).override_failure_message(
|
||||
"Active entity header must NOT have strikethrough — only Contradicted gets [s]"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: mutual exclusion with dialogue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_closes_journal_when_dialogue_active() -> void:
|
||||
## Sprint briefing: journal must close when dialogue opens.
|
||||
## update_from_state() is called from main.gd on each snapshot.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
panel.toggle() # open journal
|
||||
assert_bool(panel.is_open()).is_true()
|
||||
|
||||
GameState.dialogue_active = true
|
||||
panel.update_from_state()
|
||||
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Journal must close when GameState.dialogue_active = true (update_from_state() called)"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_close_when_dialogue_inactive() -> void:
|
||||
## update_from_state() must NOT close journal when dialogue is not active.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
panel.toggle() # open journal
|
||||
GameState.dialogue_active = false
|
||||
panel.update_from_state()
|
||||
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Journal must stay open when dialogue is inactive"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UIStrings: confidence and source label keys (D-042 — now via UIStrings)
|
||||
## CONFIDENCE_LABELS and SOURCE_LABELS dicts were removed from journal_panel.gd.
|
||||
## Labels now come from UIStrings: knowledge_panel.confidence_* / knowledge_panel.source_*
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_ui_strings_confidence_direct_exists() -> void:
|
||||
## D-042: confidence label for "Direct" tier must be in UIStrings.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_direct")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_direct' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_knowsdetails_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsdetails")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_knowsdetails' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_knowsof_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsof")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_knowsof' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_suspects_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_suspects")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_suspects' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_all_confidence_keys_non_empty() -> void:
|
||||
## All four confidence label values must be non-empty strings.
|
||||
var keys := [
|
||||
"knowledge_panel.confidence_direct",
|
||||
"knowledge_panel.confidence_knowsdetails",
|
||||
"knowledge_panel.confidence_knowsof",
|
||||
"knowledge_panel.confidence_suspects",
|
||||
]
|
||||
for key in keys:
|
||||
if not UIStrings.has_key(key): continue
|
||||
assert_bool(UIStrings.get_text(key).length() > 0).override_failure_message(
|
||||
"UIStrings key '%s' must be non-empty" % key
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_directobservation_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_directobservation")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_directobservation' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_toldby_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_toldby")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_toldby' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_heard_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_heard")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_heard' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: _state_color() contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_state_color_contradicted_is_amber() -> void:
|
||||
## D-041: Contradicted → amber tint (ENTITY_COLOR_POI) — THE FRIEND arc surface.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Contradicted")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Contradicted') must return ENTITY_COLOR_POI (amber)"
|
||||
).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_stale_is_dimmed() -> void:
|
||||
## Stale → dimmed text color (IMPLANT_TEXT_DIM).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Stale")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Stale') must return IMPLANT_TEXT_DIM"
|
||||
).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_active_is_normal() -> void:
|
||||
## Active → normal insert text color (INSERT_COLOR_TEXT).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Active")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Active') must return INSERT_COLOR_TEXT"
|
||||
).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_contradicted_differs_from_active() -> void:
|
||||
## Contradicted and Active must have visually distinct colors.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var contradicted := panel._state_color("Contradicted")
|
||||
var active := panel._state_color("Active")
|
||||
assert_that(contradicted).is_not_equal(active)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_stale_differs_from_active() -> void:
|
||||
## Stale and Active must have visually distinct colors.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var stale := panel._state_color("Stale")
|
||||
var active := panel._state_color("Active")
|
||||
assert_that(stale).is_not_equal(active)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UIStrings: knowledge_panel keys (D-042)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_ui_strings_knowledge_panel_tab_contacts_exists() -> void:
|
||||
## Journal title uses knowledge_panel.tab_contacts.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.tab_contacts")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.tab_contacts' key (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_empty_state_exists() -> void:
|
||||
## Empty state message uses knowledge_panel.empty_state.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.empty_state")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.empty_state' key (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_empty_state_non_empty() -> void:
|
||||
if not UIStrings.has_key("knowledge_panel.empty_state"): return
|
||||
assert_bool(UIStrings.get_text("knowledge_panel.empty_state").length() > 0).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_tab_contacts_non_empty() -> void:
|
||||
if not UIStrings.has_key("knowledge_panel.tab_contacts"): return
|
||||
assert_bool(UIStrings.get_text("knowledge_panel.tab_contacts").length() > 0).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-042: CONFIDENCE_LABELS/SOURCE_LABELS now via UIStrings — FIXED (2026-02-25)
|
||||
## Previously filed as a gap: journal_panel.gd had hardcoded CONFIDENCE_LABELS dict.
|
||||
## Fixed by Stig: dicts removed, all labels now use UIStrings.get_text("knowledge_panel.*").
|
||||
## Regression guard: verify the dicts are gone and UIStrings fallback works.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d042_fixed_panel_has_no_confidence_labels_dict() -> void:
|
||||
## Regression: CONFIDENCE_LABELS dict must NOT exist on journal_panel — it was removed.
|
||||
## If this test fails, the hardcoded dict was accidentally re-introduced.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.get("CONFIDENCE_LABELS") == null).override_failure_message(
|
||||
"D-042 regression: CONFIDENCE_LABELS dict must be removed from journal_panel.gd"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_d042_fixed_panel_has_no_source_labels_dict() -> void:
|
||||
## Regression: SOURCE_LABELS dict must NOT exist on journal_panel — it was removed.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.get("SOURCE_LABELS") == null).override_failure_message(
|
||||
"D-042 regression: SOURCE_LABELS dict must be removed from journal_panel.gd"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_d042_uistrings_fallback_for_unknown_confidence() -> void:
|
||||
## UIStrings falls back to the key string itself for missing keys.
|
||||
## journal_panel.gd relies on this for graceful degradation.
|
||||
var fallback := UIStrings.get_text("knowledge_panel.confidence_nonexistent_level")
|
||||
assert_that(fallback).override_failure_message(
|
||||
"UIStrings fallback must return the key string itself for unknown keys"
|
||||
).is_equal("knowledge_panel.confidence_nonexistent_level")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_canvas_insert_constant_is_10() -> void:
|
||||
assert_int(Constants.CANVAS_INSERT).is_equal(10)
|
||||
|
||||
|
||||
func test_journal_fade_constants_reasonable() -> void:
|
||||
## FADE_IN and FADE_OUT must be short (< 0.5s) for responsive UI.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_float(panel.FADE_IN).is_between(0.0, 0.5)
|
||||
assert_float(panel.FADE_OUT).is_between(0.0, 0.5)
|
||||
panel.queue_free()
|
||||
@@ -0,0 +1,189 @@
|
||||
## Sprint 19 — Game session management (#258, D-085)
|
||||
## Per-game save directories: created on New Game, resumed via game-id.
|
||||
## SessionManager autoload: new_game(), resume_game(), list_game_dirs().
|
||||
class_name TestSessionManagerSprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_game_id = ""
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.current_game_id = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState.current_game_id field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_current_game_id_field_exists() -> void:
|
||||
## D-085: GameState must have current_game_id field.
|
||||
assert_bool(GameState.has("current_game_id")).override_failure_message(
|
||||
"GameState must have 'current_game_id' field (D-085 #258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_current_game_id_default_is_empty_string() -> void:
|
||||
## Before any session starts, current_game_id is empty.
|
||||
GameState.current_game_id = ""
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"GameState.current_game_id default must be empty string"
|
||||
).is_empty()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionManager autoload exists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_session_manager_autoload_exists() -> void:
|
||||
## SessionManager must be registered as an autoload.
|
||||
var sm := Engine.get_singleton("SessionManager")
|
||||
assert_that(sm != null).override_failure_message(
|
||||
"SessionManager must be registered as autoload in project.godot (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# new_game() — game-id format and GameState update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_new_game_returns_non_empty_string() -> void:
|
||||
var game_id := SessionManager.new_game()
|
||||
assert_str(game_id).override_failure_message(
|
||||
"SessionManager.new_game() must return a non-empty game-id string"
|
||||
).is_not_empty()
|
||||
|
||||
|
||||
func test_new_game_sets_current_game_id_on_gamestate() -> void:
|
||||
var game_id := SessionManager.new_game()
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"new_game() must set GameState.current_game_id"
|
||||
).is_equal(game_id)
|
||||
|
||||
|
||||
func test_new_game_id_format_has_two_dashes() -> void:
|
||||
## Format: <YYYYMMDD>-<HHMMSS>-<hex6> — two separator dashes.
|
||||
var game_id := SessionManager.new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts.size()).override_failure_message(
|
||||
"game-id must have format <YYYYMMDD>-<HHMMSS>-<hex6> (3 parts separated by '-')"
|
||||
).is_equal(3)
|
||||
|
||||
|
||||
func test_new_game_id_first_part_is_8_digits() -> void:
|
||||
## First part is YYYYMMDD — 8 decimal digits.
|
||||
var game_id := SessionManager.new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[0].length()).override_failure_message(
|
||||
"game-id first part (date) must be 8 characters (YYYYMMDD)"
|
||||
).is_equal(8)
|
||||
|
||||
|
||||
func test_new_game_id_second_part_is_6_digits() -> void:
|
||||
## Second part is HHMMSS — 6 decimal digits.
|
||||
var game_id := SessionManager.new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[1].length()).override_failure_message(
|
||||
"game-id second part (time) must be 6 characters (HHMMSS)"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
func test_new_game_id_third_part_is_6_hex_chars() -> void:
|
||||
## Third part is 6 hex characters (RNG seed).
|
||||
var game_id := SessionManager.new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[2].length()).override_failure_message(
|
||||
"game-id third part (hex seed) must be 6 characters"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
func test_new_game_ids_are_unique() -> void:
|
||||
## Two rapid new_game() calls should produce different IDs
|
||||
## (different RNG seeds; same-second timestamps are valid but seeds differ).
|
||||
var id1 := SessionManager.new_game()
|
||||
var id2 := SessionManager.new_game()
|
||||
# Check that hex seeds differ (they almost certainly will)
|
||||
var seed1 := id1.split("-")[2]
|
||||
var seed2 := id2.split("-")[2]
|
||||
assert_str(seed1).override_failure_message(
|
||||
"Successive new_game() calls should have different RNG seeds"
|
||||
).is_not_equal(seed2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resume_game() — sets GameState.current_game_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_resume_game_sets_current_game_id() -> void:
|
||||
var test_id := "20260225-143022-a7b3f1"
|
||||
SessionManager.resume_game(test_id)
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"resume_game() must set GameState.current_game_id to the given id"
|
||||
).is_equal(test_id)
|
||||
|
||||
|
||||
func test_resume_game_overwrites_previous_game_id() -> void:
|
||||
SessionManager.resume_game("20260225-100000-aabbcc")
|
||||
SessionManager.resume_game("20260225-120000-112233")
|
||||
assert_str(GameState.current_game_id).is_equal("20260225-120000-112233")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main menu scene
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_main_menu_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message(
|
||||
"Main menu scene must exist at res://scenes/main_menu.tscn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_main_menu_instantiates_without_crash() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
assert_that(scene).is_not_null()
|
||||
|
||||
|
||||
func test_main_menu_has_new_game_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/NewGameBtn")
|
||||
assert_that(btn != null).override_failure_message(
|
||||
"Main menu must have VBox/NewGameBtn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_main_menu_has_continue_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/ContinueBtn")
|
||||
assert_that(btn != null).override_failure_message(
|
||||
"Main menu must have VBox/ContinueBtn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project main scene changed to main_menu.tscn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_project_main_scene_is_main_menu() -> void:
|
||||
## D-085: project boots to main menu, not directly to game scene.
|
||||
var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "")
|
||||
assert_str(scene_path).override_failure_message(
|
||||
"project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)"
|
||||
).is_equal("res://scenes/main_menu.tscn")
|
||||
@@ -0,0 +1,211 @@
|
||||
## Sprint 16 #543: GameState.current_zone_id population tests.
|
||||
## Verifies that apply_snapshot() correctly extracts zone_id from the player's
|
||||
## current tile after the O(N) → O(1) refactor. Behavior must be identical
|
||||
## before and after the change (net-zero behavioral change per ticket spec).
|
||||
## Spec refs: D-073, #543, #529.
|
||||
class_name TestSnapshotZoneId
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_zone_id = ""
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_tiles = []
|
||||
GameState.visible_positions = {}
|
||||
GameState.visibility_sectors = {}
|
||||
|
||||
|
||||
# -- Happy path (S16-Z01) ------------------------------------------------------
|
||||
|
||||
func test_zone_id_populated_when_player_on_zone_tile() -> void:
|
||||
# S16-Z01: Player at (5,5), visible_tiles has zone_id "zone_alpha" at (5,5).
|
||||
# D-073: current_zone_id must be set to the server-authoritative zone_id.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 3, "y": 3, "z": 0, "type": "floor", "zone_id": "zone_foyer"},
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||
{"x": 7, "y": 7, "z": 0, "type": "floor", "zone_id": "zone_beta"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"current_zone_id must match zone_id at player tile (5,5)"
|
||||
).is_equal("zone_alpha")
|
||||
assert_that(GameState.current_zone_id.is_empty()).override_failure_message(
|
||||
"current_zone_id must be non-empty when player is on a zone-tagged tile"
|
||||
).is_false()
|
||||
|
||||
|
||||
# -- Missing zone_id field (S16-Z02) -------------------------------------------
|
||||
|
||||
func test_zone_id_empty_when_tile_lacks_zone_field() -> void:
|
||||
# S16-Z02: Player's tile exists but has no zone_id key → empty string.
|
||||
# Server may send tiles without zone_id when tile is unzoned.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"Tile without zone_id field → current_zone_id must default to empty string"
|
||||
).is_equal("")
|
||||
|
||||
|
||||
# -- Player off-tile (S16-Z03) -------------------------------------------------
|
||||
|
||||
func test_zone_id_empty_when_player_not_on_any_tile() -> void:
|
||||
# S16-Z03: Player at (99,99) but no tile at that position → empty string.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 99.0, "y": 99.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"Player at (99,99) with no matching tile → current_zone_id must be empty"
|
||||
).is_equal("")
|
||||
|
||||
|
||||
# -- Empty tiles (S16-Z04) -----------------------------------------------------
|
||||
|
||||
func test_zone_id_empty_on_empty_visible_tiles() -> void:
|
||||
# S16-Z04: No visible tiles at all → empty string, no crash.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"Empty visible_tiles → current_zone_id must be empty string (no crash)"
|
||||
).is_equal("")
|
||||
|
||||
|
||||
# -- Correct tile selected among many (S16-Z05) --------------------------------
|
||||
|
||||
func test_zone_id_selects_correct_tile_among_many() -> void:
|
||||
# S16-Z05: 10x10 tile grid, player at (6,4). Only zone_6_4 must be selected.
|
||||
# Tests that the lookup doesn't accidentally match a neighboring tile.
|
||||
var tiles: Array = []
|
||||
for tx in range(10):
|
||||
for ty in range(10):
|
||||
tiles.append({"x": tx, "y": ty, "z": 0, "type": "floor",
|
||||
"zone_id": "zone_%d_%d" % [tx, ty]})
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 6.0, "y": 4.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": tiles,
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"Player at (6,4) must get zone_6_4, not a neighboring tile"
|
||||
).is_equal("zone_6_4")
|
||||
|
||||
|
||||
# -- Zone transition (S16-Z06) -------------------------------------------------
|
||||
|
||||
func test_zone_id_updates_on_zone_transition() -> void:
|
||||
# S16-Z06: Player moves from zone_alpha (5,5) to zone_beta (6,5).
|
||||
# current_zone_id must update on each snapshot.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).is_equal("zone_alpha")
|
||||
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 6.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"After moving to (6,5), current_zone_id must update to zone_beta"
|
||||
).is_equal("zone_beta")
|
||||
|
||||
|
||||
# -- Regression: visible_positions unaffected (S16-Z07) -----------------------
|
||||
|
||||
func test_visible_positions_unaffected_by_zone_id_refactor() -> void:
|
||||
# S16-Z07: The O(1) refactor must not break visible_positions population.
|
||||
# Both zone_id and visible_positions derive from the same visible_tiles loop —
|
||||
# verify both are correctly populated after a single apply_snapshot().
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha", "visibility": "Forward"},
|
||||
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta", "visibility": "Peripheral"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message(
|
||||
"visible_positions must still contain (5,5) after zone_id refactor"
|
||||
).is_true()
|
||||
assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_true()
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"current_zone_id must be zone_alpha after same apply_snapshot call"
|
||||
).is_equal("zone_alpha")
|
||||
|
||||
|
||||
# -- Fractional player position (S16-Z08) --------------------------------------
|
||||
|
||||
func test_zone_id_uses_int_truncation_of_player_position() -> void:
|
||||
# S16-Z08: Player at (5.7, 5.9) → int(5.7)=5, int(5.9)=5 → matches tile (5,5).
|
||||
# Server sends player coords as floats; zone lookup must truncate to tile index.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.7, "y": 5.9, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"Player at (5.7,5.9) must match tile (5,5) — int() truncation applies"
|
||||
).is_equal("zone_alpha")
|
||||
|
||||
|
||||
# -- Test-mode tiles key (S16-Z09) --------------------------------------------
|
||||
|
||||
func test_zone_id_works_with_test_mode_tiles_key() -> void:
|
||||
# S16-Z09: Test mode sends "tiles" key, not "visible_tiles".
|
||||
# The O(1) refactor uses member visible_tiles (covers both paths).
|
||||
# Regression guard: if _tile_by_coord is moved into the snapshot.visible_tiles
|
||||
# block only, this test fails — catching the exact regression Tyre flagged.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"Test-mode 'tiles' key must populate zone_id via member visible_tiles"
|
||||
).is_equal("zone_alpha")
|
||||
@@ -0,0 +1,292 @@
|
||||
## Sprint 16 #540: Sprite integration tests.
|
||||
## Tests z-sorting with real sprites, 24x32 D-044 footprint within D-066 64x64
|
||||
## bounding box, sprite asset existence from #541, and fog shader independence.
|
||||
## Spec refs: D-019, D-043, D-044, D-049, D-066, #540, #541.
|
||||
class_name TestSpriteIntegration
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _make_entity_renderer() -> Node2D:
|
||||
var renderer = Node2D.new()
|
||||
renderer.set_script(EntityRendererScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
|
||||
# -- Footprint constants: D-044 spec (S16-S01, S16-S02) -----------------------
|
||||
|
||||
func test_entity_footprint_matches_d044_spec() -> void:
|
||||
# S16-S01: D-044 specifies 24x32 entity footprint within 32x32 visual tile.
|
||||
# (64x64 source sprite scaled to 32px runtime at 2x retina per D-066).
|
||||
assert_that(EntityRenderer.ENTITY_WIDTH).override_failure_message(
|
||||
"D-044: ENTITY_WIDTH must be 24px"
|
||||
).is_equal(24)
|
||||
assert_that(EntityRenderer.ENTITY_HEIGHT).override_failure_message(
|
||||
"D-044: ENTITY_HEIGHT must be 32px"
|
||||
).is_equal(32)
|
||||
|
||||
|
||||
func test_entity_footprint_within_d066_2x2_sim_tile_bounding_box() -> void:
|
||||
# S16-S02: D-066 requires entity sprite footprint contained within 2x2 sim tile
|
||||
# bounding box. At 32px/tile → 64x64px max. Entity must fit to keep interaction
|
||||
# range (2 sim tiles) accurate with the tilted perspective.
|
||||
var tile_2x: int = Constants.TILE_SIZE * 2
|
||||
assert_that(EntityRenderer.ENTITY_WIDTH <= tile_2x).override_failure_message(
|
||||
"D-066: ENTITY_WIDTH %d must fit within 2x tile width %dpx" % [
|
||||
EntityRenderer.ENTITY_WIDTH, tile_2x]
|
||||
).is_true()
|
||||
assert_that(EntityRenderer.ENTITY_HEIGHT <= tile_2x).override_failure_message(
|
||||
"D-066: ENTITY_HEIGHT %d must fit within 2x tile height %dpx" % [
|
||||
EntityRenderer.ENTITY_HEIGHT, tile_2x]
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_entity_width_fits_within_single_tile() -> void:
|
||||
# S16-S03: Entity width (24) < TILE_SIZE (32) → centered within tile.
|
||||
# Ensures horizontal centering offset is positive and entity doesn't overflow.
|
||||
assert_that(EntityRenderer.ENTITY_WIDTH < Constants.TILE_SIZE).override_failure_message(
|
||||
"Entity width must be less than TILE_SIZE for centered layout"
|
||||
).is_true()
|
||||
assert_that(EntityRenderer.ENTITY_OFFSET_X >= 0.0).override_failure_message(
|
||||
"ENTITY_OFFSET_X must be non-negative for horizontal centering"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Pixel position (S16-S04) -------------------------------------------------
|
||||
|
||||
func test_entity_pixel_position_at_tile_3_7() -> void:
|
||||
# S16-S04: Entity at tile (3.0, 7.0) → pixel position must be
|
||||
# (3 * TILE_SIZE + ENTITY_OFFSET_X, 7 * TILE_SIZE + ENTITY_OFFSET_Y).
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 10, "x": 3.0, "y": 7.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[10]
|
||||
var expected_x := 3.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X
|
||||
var expected_y := 7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||
assert_that(node.position.x).override_failure_message(
|
||||
"Entity x must be tile_x * TILE_SIZE + ENTITY_OFFSET_X"
|
||||
).is_equal_approx(expected_x, 0.1)
|
||||
assert_that(node.position.y).override_failure_message(
|
||||
"Entity y must be tile_y * TILE_SIZE + ENTITY_OFFSET_Y"
|
||||
).is_equal_approx(expected_y, 0.1)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- Z-sort ordering: D-049 y-based (S16-S05, S16-S06) -----------------------
|
||||
|
||||
func test_z_sort_south_entity_has_higher_pixel_y() -> void:
|
||||
# S16-S05: D-049 y-sort — entity at y=8 (south) must have higher pixel.y
|
||||
# than entity at y=4 (north). Godot y-sort renders higher-y on top.
|
||||
# With tilted sprites, south-facing entity must visually overlap northern.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entities := [
|
||||
{"entity_id": 20, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"entity_id": 21, "x": 5.0, "y": 8.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
]
|
||||
renderer.update_entities(entities)
|
||||
var north_node = renderer.entity_nodes[20]
|
||||
var south_node = renderer.entity_nodes[21]
|
||||
assert_that(south_node.position.y > north_node.position.y).override_failure_message(
|
||||
"Entity at y=8 must have higher pixel.y than entity at y=4 for y-sort"
|
||||
).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_z_sort_y_position_difference_equals_tile_size() -> void:
|
||||
# S16-S06: Two entities one tile apart in y → pixel y difference = TILE_SIZE.
|
||||
# Verifies position calculation is consistent for adjacent tiles.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entities := [
|
||||
{"entity_id": 30, "x": 5.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"entity_id": 31, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
]
|
||||
renderer.update_entities(entities)
|
||||
var node3 = renderer.entity_nodes[30]
|
||||
var node4 = renderer.entity_nodes[31]
|
||||
var delta_y := node4.position.y - node3.position.y
|
||||
assert_that(delta_y).override_failure_message(
|
||||
"Adjacent tiles must differ by exactly TILE_SIZE (%dpx) in y" % Constants.TILE_SIZE
|
||||
).is_equal_approx(float(Constants.TILE_SIZE), 0.1)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_z_sort_same_y_different_x_no_y_difference() -> void:
|
||||
# S16-S07: Two entities at same y but different x → same pixel.y.
|
||||
# Horizontal position must not affect y-sort order.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entities := [
|
||||
{"entity_id": 40, "x": 2.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"entity_id": 41, "x": 8.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
]
|
||||
renderer.update_entities(entities)
|
||||
var left_node = renderer.entity_nodes[40]
|
||||
var right_node = renderer.entity_nodes[41]
|
||||
assert_that(left_node.position.y).override_failure_message(
|
||||
"Entities at same y-tile must have same pixel.y regardless of x"
|
||||
).is_equal_approx(right_node.position.y, 0.1)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- Sprite assets from #541 (S16-S08, S16-S09) --------------------------------
|
||||
|
||||
func test_npc_sprite_assets_exist_for_all_cardinal_directions() -> void:
|
||||
# S16-S08: #541 delivers 64px NPC sprites for all four cardinal directions.
|
||||
# entity_renderer.gd must be able to load these paths.
|
||||
for direction in ["north", "east", "south", "west"]:
|
||||
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
|
||||
assert_that(ResourceLoader.exists(path)).override_failure_message(
|
||||
"NPC sprite missing: %s" % path
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_wall_sprite_assets_exist_for_all_cardinal_directions() -> void:
|
||||
# S16-S09: #541 delivers 64px wall sprites for all four cardinal directions.
|
||||
for direction in ["north", "east", "south", "west"]:
|
||||
var path := "res://assets/sprites/wall_structural_%s_64.png" % direction
|
||||
assert_that(ResourceLoader.exists(path)).override_failure_message(
|
||||
"Wall sprite missing: %s" % path
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Fog shader independence: D-019 (S16-S10, S16-S11) -----------------------
|
||||
|
||||
func test_fog_shader_script_and_gdshader_load_correctly() -> void:
|
||||
# S16-S10: fog_shader.gd and fog.gdshader must remain intact after sprite
|
||||
# changes. D-019: "fog vision cone math remains pure 2D" — unaffected by
|
||||
# the art-direction tilt baked into sprites.
|
||||
assert_that(ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")).override_failure_message(
|
||||
"fog_shader.gd must load correctly — must not be affected by sprite changes"
|
||||
).is_true()
|
||||
assert_that(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
|
||||
"fog.gdshader must exist — fog is screen-space and sprite-independent"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_update_runs_independently_of_entity_renderer_state() -> void:
|
||||
# S16-S11: FogState.update_from_state() must succeed with no entity renderer
|
||||
# active. D-019: fog driven by LOS mask (visible_positions), not sprites.
|
||||
var fog = get_node_or_null("/root/FogState")
|
||||
if fog == null:
|
||||
push_warning("TestSpriteIntegration: FogState not available — fog independence test skipped")
|
||||
return
|
||||
# Provide visibility data but no entity renderer context
|
||||
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
|
||||
GameState.visibility_sectors = {
|
||||
Vector2i(5, 5): "Forward",
|
||||
Vector2i(6, 5): "Peripheral",
|
||||
}
|
||||
if fog.has_method("update_from_state"):
|
||||
fog.update_from_state()
|
||||
assert_that(fog.visibility_texture).override_failure_message(
|
||||
"FogState visibility_texture must be populated independently of sprite state"
|
||||
).is_not_null()
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
# -- Direction mapping: _octant_to_direction (S16-S12 through S16-S21) --------
|
||||
|
||||
func test_octant_north_maps_to_north() -> void:
|
||||
# S16-S12: "North" → "north"
|
||||
assert_that(EntityRenderer._octant_to_direction("North")).is_equal("north")
|
||||
|
||||
func test_octant_northwest_maps_to_north() -> void:
|
||||
# S16-S13: "Northwest" → "north" (grouped with North per mapping spec)
|
||||
assert_that(EntityRenderer._octant_to_direction("Northwest")).is_equal("north")
|
||||
|
||||
func test_octant_northeast_maps_to_east() -> void:
|
||||
# S16-S14: "Northeast" → "east"
|
||||
assert_that(EntityRenderer._octant_to_direction("Northeast")).is_equal("east")
|
||||
|
||||
func test_octant_east_maps_to_east() -> void:
|
||||
# S16-S15: "East" → "east"
|
||||
assert_that(EntityRenderer._octant_to_direction("East")).is_equal("east")
|
||||
|
||||
func test_octant_southeast_maps_to_south() -> void:
|
||||
# S16-S16: "Southeast" → "south"
|
||||
assert_that(EntityRenderer._octant_to_direction("Southeast")).is_equal("south")
|
||||
|
||||
func test_octant_south_maps_to_south() -> void:
|
||||
# S16-S17: "South" → "south"
|
||||
assert_that(EntityRenderer._octant_to_direction("South")).is_equal("south")
|
||||
|
||||
func test_octant_southwest_maps_to_west() -> void:
|
||||
# S16-S18: "Southwest" → "west"
|
||||
assert_that(EntityRenderer._octant_to_direction("Southwest")).is_equal("west")
|
||||
|
||||
func test_octant_west_maps_to_west() -> void:
|
||||
# S16-S19: "West" → "west"
|
||||
assert_that(EntityRenderer._octant_to_direction("West")).is_equal("west")
|
||||
|
||||
func test_octant_unknown_string_falls_back_to_south() -> void:
|
||||
# S16-S20: Unknown string → "south" fallback (safe default — viewer-facing per D-019)
|
||||
assert_that(EntityRenderer._octant_to_direction("Unknown")).is_equal("south")
|
||||
assert_that(EntityRenderer._octant_to_direction("invalid")).is_equal("south")
|
||||
|
||||
func test_octant_empty_string_falls_back_to_south() -> void:
|
||||
# S16-S21: Empty string → "south" fallback
|
||||
assert_that(EntityRenderer._octant_to_direction("")).is_equal("south")
|
||||
|
||||
|
||||
# -- Direction mapping: _entity_direction (S16-S22 through S16-S25) ----------
|
||||
|
||||
func test_entity_direction_npc_always_south() -> void:
|
||||
# S16-S22: NPC entity → always "south" regardless of any data field.
|
||||
# NPCs have no facing in v1 entity format; south is viewer-facing (D-019 angle).
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
# entity_id 99 is not the player
|
||||
var dir := renderer._entity_direction(99, {"entity_id": 99,
|
||||
"kind": {"variant": "Npc", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"NPC entity must always return 'south'"
|
||||
).is_equal("south")
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_direction_player_uses_player_facing() -> void:
|
||||
# S16-S23: Player entity → uses GameState.player_facing via _octant_to_direction.
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_facing = "North"
|
||||
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||
"kind": {"variant": "Player", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"Player entity with player_facing='North' must return 'north'"
|
||||
).is_equal("north")
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_direction_player_facing_east() -> void:
|
||||
# S16-S24: Player facing "East" → "east"
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_facing = "East"
|
||||
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||
"kind": {"variant": "Player", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"Player entity with player_facing='East' must return 'east'"
|
||||
).is_equal("east")
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_direction_player_facing_diagonal_uses_nearest_cardinal() -> void:
|
||||
# S16-S25: Player facing "Northwest" → "north" (nearest cardinal mapping).
|
||||
# Diagonal octants map to one of the four cardinal sprite sets.
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_facing = "Northwest"
|
||||
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||
"kind": {"variant": "Player", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"Player entity with player_facing='Northwest' must return 'north'"
|
||||
).is_equal("north")
|
||||
renderer.queue_free()
|
||||
@@ -0,0 +1,318 @@
|
||||
## Sprint 17 — Time display on insert HUD (#263)
|
||||
## Tests for Constants.format_game_time(), InsertClock wiring, and GameState integration.
|
||||
##
|
||||
## Spec refs:
|
||||
## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display)
|
||||
## D-051 (diegetic insert display)
|
||||
##
|
||||
## Implementation: client/ui/time_display.gd — draw-based Control at InsertOverlay/TimeDisplay.
|
||||
## Format function: Constants.format_game_time(time_of_day: int) -> String (extracted for
|
||||
## testability from time_display.gd:46 inline Constants.format_game_time(tod)).
|
||||
##
|
||||
## Private state access: Tests read _time_str, _phase_str, _day_str, _has_data directly
|
||||
## because time_display.gd is draw-based (no Label nodes to inspect). This is an accepted
|
||||
## test pattern for draw-based UI — the private vars ARE the rendered output contract.
|
||||
## If the rendering approach changes (e.g. to Label nodes), these tests should switch to
|
||||
## reading Label.text via public node paths instead.
|
||||
class_name TestTimeDisplaySprint17
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var _clock: Control = null
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
GameState.game_time = {}
|
||||
var ClockScript = load("res://ui/time_display.gd")
|
||||
_clock = Control.new()
|
||||
_clock.set_script(ClockScript)
|
||||
add_child(_clock)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
if _clock and is_instance_valid(_clock):
|
||||
_clock.queue_free()
|
||||
_clock = null
|
||||
GameState.game_time = {}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Constants.format_game_time() — pure logic, D-031
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_format_midnight() -> void:
|
||||
assert_that(Constants.format_game_time(0)).is_equal("00:00")
|
||||
|
||||
func test_format_morning_start() -> void:
|
||||
# 360 game-minutes = 6 h exactly (Morning phase boundary, D-031)
|
||||
assert_that(Constants.format_game_time(360)).is_equal("06:00")
|
||||
|
||||
func test_format_noon() -> void:
|
||||
assert_that(Constants.format_game_time(720)).is_equal("12:00")
|
||||
|
||||
func test_format_evening_start() -> void:
|
||||
assert_that(Constants.format_game_time(1080)).is_equal("18:00")
|
||||
|
||||
func test_format_end_of_day() -> void:
|
||||
# Last valid minute — must not wrap or overflow
|
||||
assert_that(Constants.format_game_time(1439)).is_equal("23:59")
|
||||
|
||||
func test_format_pads_single_digit_hour() -> void:
|
||||
# 30 min = 00:30
|
||||
assert_that(Constants.format_game_time(30)).is_equal("00:30")
|
||||
|
||||
func test_format_pads_single_digit_minute() -> void:
|
||||
# 121 min = 02:01
|
||||
assert_that(Constants.format_game_time(121)).is_equal("02:01")
|
||||
|
||||
func test_format_half_past_hour() -> void:
|
||||
assert_that(Constants.format_game_time(90)).is_equal("01:30")
|
||||
|
||||
func test_format_arbitrary_midday() -> void:
|
||||
# 835 min = 13:55
|
||||
assert_that(Constants.format_game_time(835)).is_equal("13:55")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# InsertClock initial state
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_insert_clock_initial_time_str_is_placeholder() -> void:
|
||||
# Before any snapshot, _time_str must be "--:--" (not shown by _draw)
|
||||
assert_that(_clock._time_str).is_equal("--:--")
|
||||
|
||||
func test_insert_clock_initial_phase_str_is_empty() -> void:
|
||||
assert_that(_clock._phase_str).is_equal("")
|
||||
|
||||
func test_insert_clock_initial_day_str_is_empty() -> void:
|
||||
assert_that(_clock._day_str).is_equal("")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# InsertClock.update_from_state() — reads GameState.game_time
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_formats_time_str() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("12:00")
|
||||
|
||||
func test_update_from_state_sets_phase_str() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 1080, "day_phase": "Evening", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._phase_str).is_equal("Evening")
|
||||
|
||||
func test_update_from_state_sets_day_str_one_indexed() -> void:
|
||||
# Day 0 from server → "D1" display (1-indexed)
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._day_str).is_equal("D1")
|
||||
|
||||
func test_update_from_state_day_2() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 1, "time_of_day": 50, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._day_str).is_equal("D2")
|
||||
|
||||
func test_update_from_state_midnight() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("00:00")
|
||||
|
||||
func test_update_from_state_end_of_day() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 1439, "day_phase": "Night", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("23:59")
|
||||
|
||||
func test_update_from_state_skips_empty_game_time() -> void:
|
||||
# Empty game_time must not overwrite --:-- (guard in update_from_state)
|
||||
GameState.game_time = {}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("--:--")
|
||||
|
||||
func test_update_from_state_deduplicates_same_tick() -> void:
|
||||
# Calling twice with identical data must produce same result (signature cache)
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("06:00")
|
||||
# Call again — result unchanged, no crash
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("06:00")
|
||||
|
||||
func test_update_from_state_updates_on_new_tick() -> void:
|
||||
# time_of_day changes → signature changes → _time_str updates
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 60, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("01:00")
|
||||
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 120, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("02:00")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# InsertClock.PHASE_COLORS — all four D-031 phases have colors
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_phase_colors_has_morning() -> void:
|
||||
assert_that(_clock.PHASE_COLORS.has("Morning")).is_true()
|
||||
|
||||
func test_phase_colors_has_afternoon() -> void:
|
||||
assert_that(_clock.PHASE_COLORS.has("Afternoon")).is_true()
|
||||
|
||||
func test_phase_colors_has_evening() -> void:
|
||||
assert_that(_clock.PHASE_COLORS.has("Evening")).is_true()
|
||||
|
||||
func test_phase_colors_has_night() -> void:
|
||||
assert_that(_clock.PHASE_COLORS.has("Night")).is_true()
|
||||
|
||||
func test_phase_color_applied_after_update() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._phase_color).is_equal(_clock.PHASE_COLORS["Morning"])
|
||||
|
||||
func test_phase_color_unknown_phase_uses_dim_fallback() -> void:
|
||||
# Unknown phase string → Constants.IMPLANT_TEXT_DIM
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 100, "day_phase": "Twilight", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._phase_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# GameState: game_time field parsing (confirms apply_snapshot wiring)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_game_time_populated_from_snapshot() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 5, "entities": [],
|
||||
"game_time": {
|
||||
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
|
||||
},
|
||||
})
|
||||
assert_that(GameState.game_time.get("time_of_day")).is_equal(720)
|
||||
|
||||
func test_game_time_all_four_phases_store_correctly() -> void:
|
||||
for phase in ["Morning", "Afternoon", "Evening", "Night"]:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "entities": [],
|
||||
"game_time": {
|
||||
"day": 0, "time_of_day": 100, "day_phase": phase, "tick_rate": "Full",
|
||||
},
|
||||
})
|
||||
assert_that(GameState.game_time.get("day_phase")).is_equal(phase)
|
||||
|
||||
func test_game_time_missing_from_snapshot_preserves_previous() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.game_time.get("time_of_day")).is_equal(360)
|
||||
|
||||
func test_game_time_zero_time_of_day_stored() -> void:
|
||||
# time_of_day = 0 (midnight) must not be treated as falsy/missing
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full"},
|
||||
})
|
||||
assert_that(GameState.game_time.get("time_of_day")).is_equal(0)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# SimBridge test mode: game_time fields are valid
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_sim_bridge_snapshot_has_game_time() -> void:
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.has("game_time")).is_true()
|
||||
assert_that(snap.game_time is Dictionary).is_true()
|
||||
|
||||
func test_sim_bridge_game_time_has_required_fields() -> void:
|
||||
var snap = SimBridge._test_snapshot()
|
||||
var gt: Dictionary = snap.game_time
|
||||
assert_that(gt.has("day")).is_true()
|
||||
assert_that(gt.has("time_of_day")).is_true()
|
||||
assert_that(gt.has("day_phase")).is_true()
|
||||
assert_that(gt.has("tick_rate")).is_true()
|
||||
|
||||
func test_sim_bridge_time_of_day_is_non_negative() -> void:
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.game_time.get("time_of_day", -1) as int).is_greater_equal(0)
|
||||
|
||||
func test_sim_bridge_time_of_day_within_day_bounds() -> void:
|
||||
# D-031: 1440 game-minutes per day, valid range 0..1439
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.game_time.get("time_of_day", 0) as int).is_less_equal(1439)
|
||||
|
||||
func test_sim_bridge_day_phase_is_valid() -> void:
|
||||
var snap = SimBridge._test_snapshot()
|
||||
var phase: String = snap.game_time.get("day_phase", "")
|
||||
assert_that(["Morning", "Afternoon", "Evening", "Night"].has(phase)).is_true()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Scene: InsertClock node at InsertOverlay/TimeDisplay
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_insert_clock_exists_in_ui_layer() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var instance = scene.instantiate()
|
||||
auto_free(instance)
|
||||
add_child(instance)
|
||||
|
||||
assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null()
|
||||
|
||||
func test_insert_clock_time_str_updates_after_process() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var instance = scene.instantiate()
|
||||
auto_free(instance)
|
||||
add_child(instance)
|
||||
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
|
||||
})
|
||||
instance._process(0.016)
|
||||
|
||||
var clock = instance.get_node_or_null("InsertOverlay/TimeDisplay")
|
||||
assert_that(clock).is_not_null()
|
||||
assert_that(clock._time_str).is_equal("12:00")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Regression: debug_overlay still reads game_time correctly (#511)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_reads_game_time_day_phase() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
|
||||
})
|
||||
assert_that(GameState.game_time.get("day_phase")).is_equal("Morning")
|
||||
|
||||
func test_debug_overlay_reads_game_time_tick_rate() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Half"},
|
||||
})
|
||||
assert_that(GameState.game_time.get("tick_rate")).is_equal("Half")
|
||||
@@ -110,13 +110,14 @@ func test_stance_indicator_exists_in_ui_layer() -> void:
|
||||
|
||||
|
||||
func test_minimap_placeholder_exists_in_ui_layer() -> void:
|
||||
# D-013: Minimap/insert placeholder must be in UILayer (not implemented yet).
|
||||
# D-013/D-049: Minimap is on InsertOverlay (z-layer 6), NOT UILayer.
|
||||
# Sprint 18 #151 (Stig): moved from UILayer to InsertOverlay per D-049 spec.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
assert_that(_instance.get_node_or_null("UILayer/Minimap")).is_not_null()
|
||||
assert_that(_instance.get_node_or_null("InsertOverlay/Minimap")).is_not_null()
|
||||
|
||||
|
||||
func test_hud_exists_in_ui_layer() -> void:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
## Scene testing utilities for gdUnit4 tests.
|
||||
##
|
||||
## Loads a scene, instantiates it into the test suite's node tree,
|
||||
## and provides helpers for node existence, signal, and node-path queries.
|
||||
##
|
||||
## Usage (from a GdUnitTestSuite subclass):
|
||||
## var helper := SceneHelper.create(self, "res://scenes/main.tscn")
|
||||
## helper.assert_node_exists("World")
|
||||
## var world := helper.get_node_at("World")
|
||||
## helper.monitor_signal(world, "ready")
|
||||
## # ... trigger something ...
|
||||
## helper.assert_signal_emitted(world, "ready")
|
||||
##
|
||||
## Design constraints (D-030): server-free, no running autoload dependencies.
|
||||
class_name SceneHelper
|
||||
extends RefCounted
|
||||
|
||||
var _suite # GdUnitTestSuite — untyped to avoid load-order dependency
|
||||
var _scene: Node
|
||||
# signal_key -> int. Key is "<node_instance_id>:<signal_name>" for uniqueness.
|
||||
var _signal_hits: Dictionary = {}
|
||||
|
||||
|
||||
## Load, instantiate, and attach a scene to the test suite's node tree.
|
||||
## The scene node is registered for auto-free by gdUnit4.
|
||||
## Returns a helper instance; fails the test if the scene cannot be loaded.
|
||||
static func create(suite: GdUnitTestSuite, scene_path: String) -> SceneHelper:
|
||||
var helper := SceneHelper.new()
|
||||
helper._suite = suite
|
||||
|
||||
var packed: PackedScene = load(scene_path)
|
||||
if packed == null:
|
||||
suite.assert_that(packed).override_failure_message(
|
||||
"SceneHelper: could not load scene at '%s'" % scene_path
|
||||
).is_not_null()
|
||||
return helper
|
||||
|
||||
helper._scene = packed.instantiate()
|
||||
suite.auto_free(helper._scene)
|
||||
suite.add_child(helper._scene)
|
||||
return helper
|
||||
|
||||
|
||||
## Returns the scene root node.
|
||||
func scene() -> Node:
|
||||
return _scene
|
||||
|
||||
|
||||
## Assert that a node at node_path exists under the scene root.
|
||||
## Fails the current test if the node is absent.
|
||||
func assert_node_exists(node_path: String) -> void:
|
||||
var node := _scene.get_node_or_null(NodePath(node_path))
|
||||
_suite.assert_that(node).override_failure_message(
|
||||
"SceneHelper: expected node at path '%s' — not found" % node_path
|
||||
).is_not_null()
|
||||
|
||||
|
||||
## Return the node at node_path under the scene root, or null if absent.
|
||||
func get_node_at(node_path: String) -> Node:
|
||||
return _scene.get_node_or_null(NodePath(node_path))
|
||||
|
||||
|
||||
## Begin tracking emissions of signal_name on node.
|
||||
## Must be called before the action that triggers the signal.
|
||||
## Fails the test if node does not have the named signal.
|
||||
func monitor_signal(node: Node, signal_name: String) -> void:
|
||||
if not node.has_signal(signal_name):
|
||||
_suite.assert_that(false).override_failure_message(
|
||||
"SceneHelper: node '%s' has no signal '%s'" % [node.name, signal_name]
|
||||
).is_true()
|
||||
return
|
||||
var key := _signal_key(node, signal_name)
|
||||
_signal_hits[key] = 0
|
||||
# Lambda accepts up to 4 positional args to tolerate signals with up to 4 params.
|
||||
# GDScript default-param lambdas handle being called with fewer args correctly.
|
||||
node.connect(signal_name, func(a := null, b := null, c := null, d := null):
|
||||
_signal_hits[key] = _signal_hits.get(key, 0) + 1
|
||||
)
|
||||
|
||||
|
||||
## Assert that signal_name was emitted at least once since monitor_signal().
|
||||
## Fails the test if monitor_signal() was not called first, or if count is zero.
|
||||
func assert_signal_emitted(node: Node, signal_name: String) -> void:
|
||||
var key := _signal_key(node, signal_name)
|
||||
if not _signal_hits.has(key):
|
||||
_suite.assert_that(false).override_failure_message(
|
||||
"SceneHelper: '%s' was not monitored — call monitor_signal() first" % signal_name
|
||||
).is_true()
|
||||
return
|
||||
var count: int = _signal_hits[key]
|
||||
_suite.assert_int(count).override_failure_message(
|
||||
"SceneHelper: signal '%s' on '%s' was not emitted (count=%d)" % [
|
||||
signal_name, node.name, count
|
||||
]
|
||||
).is_greater(0)
|
||||
|
||||
|
||||
static func _signal_key(node: Node, signal_name: String) -> String:
|
||||
return "%d:%s" % [node.get_instance_id(), signal_name]
|
||||
@@ -354,26 +354,45 @@ func _show_options(options: Array) -> void:
|
||||
var raw_text: String = opt.get("text", "")
|
||||
var is_confrontation: bool = opt.get("confrontation", false)
|
||||
|
||||
var label := Label.new()
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
|
||||
var numbered_text := "%d. %s" % [i + 1, raw_text]
|
||||
label.text = numbered_text
|
||||
# D-063: Confrontation options render italic — first-person voice, weighted differently.
|
||||
# Use RichTextLabel with BBCode [i] tags for confrontation; plain Label for standard.
|
||||
var ctrl: Control
|
||||
if is_confrontation:
|
||||
var rtl := RichTextLabel.new()
|
||||
rtl.bbcode_enabled = true
|
||||
rtl.fit_content = true
|
||||
rtl.scroll_active = false
|
||||
rtl.add_theme_font_size_override("normal_font_size", 14)
|
||||
rtl.add_theme_color_override("default_color", Color(
|
||||
Constants.INSERT_COLOR_TEXT.r * 1.08,
|
||||
Constants.INSERT_COLOR_TEXT.g * 0.96,
|
||||
Constants.INSERT_COLOR_TEXT.b * 0.90,
|
||||
1.0
|
||||
)) # Slight warm tint for confrontation weight
|
||||
rtl.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
rtl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
rtl.text = "[i]%d. %s[/i]" % [i + 1, raw_text]
|
||||
ctrl = rtl
|
||||
else:
|
||||
var label := Label.new()
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
label.text = "%d. %s" % [i + 1, raw_text]
|
||||
ctrl = label
|
||||
|
||||
var idx := i
|
||||
label.gui_input.connect(func(event: InputEvent):
|
||||
ctrl.gui_input.connect(func(event: InputEvent):
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
_on_option_pressed(idx)
|
||||
)
|
||||
label.mouse_entered.connect(_make_hover_on(label))
|
||||
label.mouse_exited.connect(_make_hover_off(label))
|
||||
ctrl.mouse_entered.connect(_make_hover_on(ctrl))
|
||||
ctrl.mouse_exited.connect(_make_hover_off(ctrl))
|
||||
|
||||
options_container.add_child(label)
|
||||
_option_controls.append(label)
|
||||
options_container.add_child(ctrl)
|
||||
_option_controls.append(ctrl)
|
||||
_option_response_ids.append(opt.get("response_id", ""))
|
||||
_option_texts.append(raw_text)
|
||||
_option_is_confrontation.append(is_confrontation)
|
||||
@@ -519,7 +538,7 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
|
||||
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
||||
static func _escape_bbcode(text: String) -> String:
|
||||
return text.replace("[", "[lb]")
|
||||
return text.replace("[", "[lb]").replace("]", "[rb]")
|
||||
|
||||
|
||||
## Get a stable color for a character name, with contrast floor enforcement.
|
||||
@@ -575,19 +594,22 @@ func _expire_entries() -> void:
|
||||
var removed := false
|
||||
var has_fading := false
|
||||
|
||||
# Remove expired non-pinned entries from the front (oldest first)
|
||||
while _log_entries.size() > 0:
|
||||
var entry: Dictionary = _log_entries[0]
|
||||
# Remove expired non-pinned entries (oldest first, skipping pinned)
|
||||
var i := 0
|
||||
while i < _log_entries.size():
|
||||
var entry: Dictionary = _log_entries[i]
|
||||
if entry.pinned:
|
||||
break # Pinned entries never expire
|
||||
i += 1
|
||||
continue # Pinned entries never expire — keep scanning
|
||||
var age: int = now - entry.timestamp_msec
|
||||
if age < total_lifetime_msec:
|
||||
# Check if this entry is in the fading phase
|
||||
if age > int(_entry_lifetime * 1000.0):
|
||||
has_fading = true
|
||||
break
|
||||
_log_entries.remove_at(0)
|
||||
_log_entries.remove_at(i)
|
||||
removed = true
|
||||
# Don't increment i — element at i is now the next entry
|
||||
|
||||
# Check remaining entries for fading state
|
||||
if not has_fading:
|
||||
@@ -623,11 +645,18 @@ func _clear_options() -> void:
|
||||
|
||||
|
||||
# Hover callbacks
|
||||
static func _make_hover_on(label: Control) -> Callable:
|
||||
static func _make_hover_on(ctrl: Control) -> Callable:
|
||||
return func():
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
|
||||
# RichTextLabel uses "default_color"; Label uses "font_color"
|
||||
if ctrl is RichTextLabel:
|
||||
ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
|
||||
else:
|
||||
ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
|
||||
|
||||
|
||||
static func _make_hover_off(label: Control) -> Callable:
|
||||
static func _make_hover_off(ctrl: Control) -> Callable:
|
||||
return func():
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
if ctrl is RichTextLabel:
|
||||
ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
|
||||
else:
|
||||
ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
extends Control
|
||||
|
||||
## Examine result display — #174, D-061 adjacent.
|
||||
##
|
||||
## Shows the character-filtered text returned by the Examine verb (#242).
|
||||
## Non-interactive overlay. Auto-dismisses after DISMISS_DELAY seconds.
|
||||
## Diegetic: reads as the neural insert processing what the character observed.
|
||||
##
|
||||
## Positioned in InsertOverlay (CanvasLayer 10, z-layer 6).
|
||||
## Only one examine result is shown at a time — new result replaces old.
|
||||
|
||||
const DISMISS_DELAY: float = 5.0 # Auto-dismiss after 5 seconds
|
||||
const FADE_IN: float = 0.18
|
||||
const FADE_OUT: float = 0.35
|
||||
|
||||
# Confidence → alpha modifier: Direct is brightest, Suspects is dimmest
|
||||
const CONFIDENCE_ALPHA: Dictionary = {
|
||||
"Direct": 1.0,
|
||||
"KnowsDetails": 0.9,
|
||||
"KnowsOf": 0.75,
|
||||
"Suspects": 0.6,
|
||||
}
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
|
||||
|
||||
var _dismiss_tween: Tween = null
|
||||
var _active: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
modulate.a = 0.0
|
||||
visible = false
|
||||
|
||||
|
||||
## Show an examine result. Called from main.gd when GameState.current_examine_result is set.
|
||||
## result: {entity_id, text, confidence}
|
||||
func show_result(result: Dictionary) -> void:
|
||||
var text: String = result.get("text", "")
|
||||
var confidence: String = result.get("confidence", "KnowsOf")
|
||||
|
||||
if text.is_empty():
|
||||
return
|
||||
|
||||
# Cancel any in-progress dismiss
|
||||
if _dismiss_tween and _dismiss_tween.is_valid():
|
||||
_dismiss_tween.kill()
|
||||
|
||||
# Apply confidence-based alpha to the insert color
|
||||
var alpha: float = CONFIDENCE_ALPHA.get(confidence, 0.75)
|
||||
var col := Color(Constants.INSERT_COLOR_TEXT.r, Constants.INSERT_COLOR_TEXT.g,
|
||||
Constants.INSERT_COLOR_TEXT.b, alpha)
|
||||
text_label.add_theme_color_override("default_color", col)
|
||||
text_label.text = text
|
||||
|
||||
visible = true
|
||||
_active = true
|
||||
modulate.a = 0.0
|
||||
|
||||
# Fade in, then auto-dismiss
|
||||
_dismiss_tween = create_tween()
|
||||
_dismiss_tween.tween_property(self, "modulate:a", 1.0, FADE_IN)
|
||||
_dismiss_tween.tween_interval(DISMISS_DELAY)
|
||||
_dismiss_tween.tween_callback(_start_fade_out)
|
||||
|
||||
|
||||
func _start_fade_out() -> void:
|
||||
if not _active:
|
||||
return
|
||||
var t := create_tween()
|
||||
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
t.tween_callback(func():
|
||||
visible = false
|
||||
_active = false
|
||||
)
|
||||
|
||||
|
||||
## Dismiss immediately (e.g. when dialogue opens).
|
||||
func dismiss() -> void:
|
||||
if not _active:
|
||||
return
|
||||
if _dismiss_tween and _dismiss_tween.is_valid():
|
||||
_dismiss_tween.kill()
|
||||
_start_fade_out()
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return _active
|
||||
@@ -0,0 +1,50 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://examine_display_sr"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/examine_display.gd" id="1_examine"]
|
||||
|
||||
; ExamineDisplay — non-interactive observe result overlay. D-013, #174.
|
||||
; Auto-dismisses after 5s. Positioned center-right, 40% from top.
|
||||
; InsertOverlay (CanvasLayer 10). Diegetic: insert processing observed data.
|
||||
|
||||
[node name="ExamineDisplay" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 3
|
||||
anchor_left = 1.0
|
||||
anchor_top = 0.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 0.0
|
||||
offset_left = -440.0
|
||||
offset_top = 120.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = 240.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
modulate = Color(1, 1, 1, 0)
|
||||
script = ExtResource("1_examine")
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 12
|
||||
theme_override_constants/margin_top = 8
|
||||
theme_override_constants/margin_right = 12
|
||||
theme_override_constants/margin_bottom = 8
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="TextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"]
|
||||
layout_mode = 2
|
||||
bbcode_enabled = true
|
||||
fit_content = true
|
||||
scroll_active = false
|
||||
mouse_filter = 2
|
||||
theme_override_font_sizes/normal_font_size = 13
|
||||
theme_override_colors/default_color = Color(0.784, 0.816, 0.878, 0.75)
|
||||
@@ -29,6 +29,8 @@ var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
|
||||
var _selected_index: int = 0
|
||||
var _active_tween: Tween = null
|
||||
var _verb_labels: Array[Label] = []
|
||||
# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar
|
||||
var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBox
|
||||
|
||||
@@ -39,10 +41,14 @@ func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
var _last_screen_pos: Vector2 = Vector2.ZERO
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _showing:
|
||||
_update_screen_position()
|
||||
queue_redraw()
|
||||
if position != _last_screen_pos:
|
||||
_last_screen_pos = position
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
@@ -52,6 +58,9 @@ func _draw() -> void:
|
||||
var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2)
|
||||
draw_rect(bg_rect, INSERT_BG)
|
||||
draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0)
|
||||
# #537: D-033 relationship color accent — 3px left-edge bar signals NPC relationship
|
||||
var bar_rect := Rect2(-pad, -pad, 3.0, bg_rect.size.y)
|
||||
draw_rect(bar_rect, _relationship_color * Color(1, 1, 1, 0.85))
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
@@ -88,6 +97,7 @@ func update_from_state() -> void:
|
||||
_verb_items = sorted
|
||||
_selected_index = 0
|
||||
_cache_entity_position()
|
||||
_cache_entity_relationship()
|
||||
_rebuild_labels()
|
||||
_show()
|
||||
|
||||
@@ -127,6 +137,17 @@ func _cache_entity_position() -> void:
|
||||
return
|
||||
|
||||
|
||||
## #537: Cache relationship color for the target entity (D-033 palette).
|
||||
## Falls back to IMPLANT_TEXT_DIM for non-NPC or unknown entities.
|
||||
func _cache_entity_relationship() -> void:
|
||||
for entity in GameState.visible_entities:
|
||||
if entity.get("entity_id") == _current_target_id:
|
||||
var rel: String = str(entity.get("relationship", "Unknown"))
|
||||
_relationship_color = Constants.color_for_relationship(rel)
|
||||
return
|
||||
_relationship_color = Constants.IMPLANT_TEXT_DIM
|
||||
|
||||
|
||||
## Convert entity world position to screen coords and reposition this Control.
|
||||
## Runs every frame while showing so the list tracks the entity as the camera moves.
|
||||
func _update_screen_position() -> void:
|
||||
@@ -187,6 +208,10 @@ func get_visible_verb_count() -> int:
|
||||
return _verb_items.size()
|
||||
|
||||
|
||||
func hide_list() -> void:
|
||||
_hide()
|
||||
|
||||
|
||||
func is_showing() -> bool:
|
||||
return _showing
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
extends Control
|
||||
|
||||
## Journal panel — knowledge graph review. #264, D-041.
|
||||
##
|
||||
## Toggle with J key. Displays accumulated facts from GameState.player_knowledge
|
||||
## grouped by entity. Read-only — no player interaction beyond scrolling.
|
||||
##
|
||||
## KnowledgeState rendering:
|
||||
## Active → normal color (INSERT_COLOR_TEXT)
|
||||
## Stale → dimmed (IMPLANT_TEXT_DIM)
|
||||
## Contradicted → amber tint + strikethrough (ENTITY_COLOR_POI) — THE FRIEND arc surface
|
||||
##
|
||||
## KnowledgeConfidence labels (D-041):
|
||||
## Direct → KnowsDetails → KnowsOf → Suspects
|
||||
##
|
||||
## Cannot be open simultaneously with dialogue (sprint briefing constraint).
|
||||
## Closes when dialogue opens.
|
||||
|
||||
const FADE_IN: float = 0.18
|
||||
const FADE_OUT: float = 0.25
|
||||
|
||||
# D-041/D-042: confidence and source labels loaded from UIStrings (data/ui-strings.yaml).
|
||||
# Keys: knowledge_panel.confidence_{lower} and knowledge_panel.source_{lower}
|
||||
# Fallback: raw value if key not found (UIStrings returns the key itself).
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel
|
||||
@onready var scroll: ScrollContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer
|
||||
@onready var entries_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
|
||||
|
||||
var _visible_state: bool = false
|
||||
var _last_rendered_tick: int = -1
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
visible = false
|
||||
modulate.a = 0.0
|
||||
title_label.text = UIStrings.get_text("knowledge_panel.tab_contacts")
|
||||
|
||||
|
||||
## Toggle open/close. Called from main.gd on J key press.
|
||||
func toggle() -> void:
|
||||
if _visible_state:
|
||||
_hide_panel()
|
||||
else:
|
||||
_show_panel()
|
||||
|
||||
|
||||
## Force-close. Called when dialogue opens.
|
||||
func close() -> void:
|
||||
if _visible_state:
|
||||
_hide_panel()
|
||||
|
||||
|
||||
func is_open() -> bool:
|
||||
return _visible_state
|
||||
|
||||
|
||||
func _show_panel() -> void:
|
||||
_rebuild_entries()
|
||||
_visible_state = true
|
||||
visible = true
|
||||
var t := create_tween()
|
||||
t.tween_property(self, "modulate:a", 1.0, FADE_IN)
|
||||
|
||||
|
||||
func _hide_panel() -> void:
|
||||
_visible_state = false
|
||||
var t := create_tween()
|
||||
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
t.tween_callback(func(): visible = false)
|
||||
|
||||
|
||||
## Rebuild the entry list from GameState.player_knowledge.
|
||||
func _rebuild_entries() -> void:
|
||||
# Clear previous entries
|
||||
for child in entries_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var knowledge: Variant = GameState.player_knowledge
|
||||
if knowledge == null:
|
||||
_add_empty_state()
|
||||
return
|
||||
|
||||
var entities: Array = knowledge.get("entities", [])
|
||||
if entities.is_empty():
|
||||
_add_empty_state()
|
||||
return
|
||||
|
||||
for entity in entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
_add_entity_entry(entity)
|
||||
|
||||
|
||||
func _add_empty_state() -> void:
|
||||
var label := Label.new()
|
||||
label.text = UIStrings.get_text("knowledge_panel.empty_state")
|
||||
label.add_theme_color_override("font_color", Constants.IMPLANT_TEXT_DIM)
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
entries_container.add_child(label)
|
||||
|
||||
|
||||
func _add_entity_entry(entity: Dictionary) -> void:
|
||||
var name_str: String = entity.get("name", "Unknown")
|
||||
var confidence: String = entity.get("confidence", "Suspects")
|
||||
var source: String = entity.get("source", "")
|
||||
var state: String = entity.get("state", "Active")
|
||||
var relationship: String = entity.get("relationship", "Unknown")
|
||||
|
||||
# Entity header — name + relationship status
|
||||
var header_rtl := RichTextLabel.new()
|
||||
header_rtl.bbcode_enabled = true
|
||||
header_rtl.fit_content = true
|
||||
header_rtl.scroll_active = false
|
||||
header_rtl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
header_rtl.add_theme_font_size_override("normal_font_size", 14)
|
||||
|
||||
var rel_color: Color = Constants.color_for_relationship(relationship)
|
||||
var rel_label: String = UIStrings.get_text("relationship_states.%s.label" % relationship.to_lower())
|
||||
if rel_label == "relationship_states.%s.label" % relationship.to_lower():
|
||||
rel_label = relationship # fallback if key missing
|
||||
|
||||
var state_color: Color = _state_color(state)
|
||||
var conf_label: String = UIStrings.get_text("knowledge_panel.confidence_%s" % confidence.to_lower())
|
||||
var src_label: String = _resolve_source_label(source)
|
||||
|
||||
# Build BBCode:
|
||||
# [color=#hex][b]Name[/b][/color] [color=#rel_hex]Status[/color]
|
||||
var name_hex: String = state_color.to_html(false)
|
||||
var rel_hex: String = rel_color.to_html(false)
|
||||
|
||||
var header_bbcode: String
|
||||
if state == "Contradicted":
|
||||
header_bbcode = "[color=#%s][b][s]%s[/s][/b][/color] [color=#%s]%s[/color]" % [
|
||||
name_hex, name_str, rel_hex, rel_label
|
||||
]
|
||||
else:
|
||||
header_bbcode = "[color=#%s][b]%s[/b][/color] [color=#%s]%s[/color]" % [
|
||||
name_hex, name_str, rel_hex, rel_label
|
||||
]
|
||||
header_rtl.text = header_bbcode
|
||||
entries_container.add_child(header_rtl)
|
||||
|
||||
# Detail line — confidence + source
|
||||
var detail_rtl := RichTextLabel.new()
|
||||
detail_rtl.bbcode_enabled = true
|
||||
detail_rtl.fit_content = true
|
||||
detail_rtl.scroll_active = false
|
||||
detail_rtl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
detail_rtl.add_theme_font_size_override("normal_font_size", 12)
|
||||
|
||||
var detail_hex: String = Constants.IMPLANT_TEXT_DIM.to_html(false)
|
||||
var detail_text: String = "%s · %s" % [conf_label, src_label] if src_label else conf_label
|
||||
detail_rtl.text = "[color=#%s]%s[/color]" % [detail_hex, detail_text]
|
||||
entries_container.add_child(detail_rtl)
|
||||
|
||||
# Spacer between entries
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, 6)
|
||||
entries_container.add_child(spacer)
|
||||
|
||||
|
||||
## Resolve a source string from the server into a human-readable label.
|
||||
## Handles plain keys ("DirectObservation", "Heard") and ToldBy(N) format.
|
||||
## ToldBy(N) looks up entity_id N in player_knowledge.entities for the name.
|
||||
func _resolve_source_label(source: String) -> String:
|
||||
if source.is_empty():
|
||||
return ""
|
||||
|
||||
# ToldBy(entity_id) format — e.g. "ToldBy(12)"
|
||||
if source.begins_with("ToldBy(") and source.ends_with(")"):
|
||||
var id_str: String = source.substr(7, source.length() - 8)
|
||||
var entity_id: int = id_str.to_int()
|
||||
var name := _entity_name_for_id(entity_id)
|
||||
var told_prefix: String = UIStrings.get_text("knowledge_panel.source_toldby")
|
||||
if told_prefix == "knowledge_panel.source_toldby":
|
||||
told_prefix = "Told"
|
||||
return "%s: %s" % [told_prefix, name]
|
||||
|
||||
# Plain source key — look up UIStrings
|
||||
var key: String = "knowledge_panel.source_%s" % source.to_lower()
|
||||
return UIStrings.get_text(key)
|
||||
|
||||
|
||||
## Look up an entity name by stable ID from GameState.player_knowledge.entities.
|
||||
func _entity_name_for_id(entity_id: int) -> String:
|
||||
var knowledge: Variant = GameState.player_knowledge
|
||||
if knowledge == null:
|
||||
return "#%d" % entity_id
|
||||
for entity in knowledge.get("entities", []):
|
||||
if entity is Dictionary and int(entity.get("entity_id", -1)) == entity_id:
|
||||
return entity.get("name", "#%d" % entity_id)
|
||||
return "#%d" % entity_id
|
||||
|
||||
|
||||
func _state_color(state: String) -> Color:
|
||||
match state:
|
||||
"Contradicted":
|
||||
return Constants.ENTITY_COLOR_POI # amber — THE FRIEND arc
|
||||
"Stale":
|
||||
return Constants.IMPLANT_TEXT_DIM # dimmed
|
||||
_:
|
||||
return Constants.INSERT_COLOR_TEXT # active — normal
|
||||
|
||||
|
||||
## Called from main.gd on each snapshot to close the panel when dialogue opens.
|
||||
func update_from_state() -> void:
|
||||
if _visible_state and GameState.dialogue_active:
|
||||
close()
|
||||
# Rebuild if open and knowledge data is newer than last render
|
||||
if _visible_state and GameState.current_tick != _last_rendered_tick:
|
||||
_last_rendered_tick = GameState.current_tick
|
||||
_rebuild_entries()
|
||||
@@ -0,0 +1,63 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://journal_panel_sr"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/journal_panel.gd" id="1_journal"]
|
||||
|
||||
; JournalPanel — knowledge graph review. #264, D-041.
|
||||
; Toggle J key. Right side, 380px wide, full height minus margins.
|
||||
; InsertOverlay (CanvasLayer 10, z-layer 6). Read-only, closes with dialogue.
|
||||
|
||||
[node name="JournalPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 3
|
||||
anchor_left = 1.0
|
||||
anchor_top = 0.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -400.0
|
||||
offset_top = 16.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = -16.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
modulate = Color(1, 1, 1, 0)
|
||||
script = ExtResource("1_journal")
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 14
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 14
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Contacts"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 0.9)
|
||||
|
||||
[node name="Separator" type="HSeparator" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/separator_color = Color(0.784, 0.816, 0.878, 0.2)
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="EntriesContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 2
|
||||
@@ -0,0 +1,50 @@
|
||||
extends Control
|
||||
## #258: Main menu — New Game / Continue / Quit.
|
||||
## New Game: generates per-game save directory (D-085), starts game.
|
||||
## Continue: loads most recent save directory (#554 full loading screen deferred).
|
||||
|
||||
const GAME_SCENE := "res://scenes/main.tscn"
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0)
|
||||
const TITLE_COLOR := Color("#c8d0e0")
|
||||
const SUBTITLE_COLOR := Color("#8890a0")
|
||||
const BTN_NORMAL_COLOR := Color("#e8c547")
|
||||
const BTN_DISABLED_COLOR := Color("#4a5060")
|
||||
const FONT_SIZE_TITLE := 36
|
||||
const FONT_SIZE_SUBTITLE := 14
|
||||
const FONT_SIZE_BTN := 15
|
||||
|
||||
@onready var _new_game_btn: Button = $VBox/NewGameBtn
|
||||
@onready var _continue_btn: Button = $VBox/ContinueBtn
|
||||
@onready var _quit_btn: Button = $VBox/QuitBtn
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_new_game_btn.pressed.connect(_on_new_game)
|
||||
_continue_btn.pressed.connect(_on_continue)
|
||||
_quit_btn.pressed.connect(_on_quit)
|
||||
_refresh_continue_state()
|
||||
|
||||
|
||||
func _refresh_continue_state() -> void:
|
||||
var saves := SessionManager.list_game_dirs()
|
||||
_continue_btn.disabled = saves.is_empty()
|
||||
|
||||
|
||||
func _on_new_game() -> void:
|
||||
SessionManager.new_game()
|
||||
get_tree().change_scene_to_file(GAME_SCENE)
|
||||
|
||||
|
||||
func _on_continue() -> void:
|
||||
# #554: Full loading screen blocked until server SaveCommand lands.
|
||||
# For now: automatically load the most recent game directory.
|
||||
var saves := SessionManager.list_game_dirs()
|
||||
if saves.is_empty():
|
||||
return
|
||||
SessionManager.resume_game(saves[0].game_id)
|
||||
get_tree().change_scene_to_file(GAME_SCENE)
|
||||
|
||||
|
||||
func _on_quit() -> void:
|
||||
get_tree().quit()
|
||||
@@ -1,15 +1,172 @@
|
||||
class_name MinimapRenderer
|
||||
extends Control
|
||||
|
||||
# Minimap — small viewport showing local area
|
||||
# Placeholder implementation for now
|
||||
## Minimap — diegetic neural insert overlay. D-013, D-049 z-layer 6.
|
||||
##
|
||||
## Player always centered. Fixed-north (no rotation per D-015).
|
||||
## Nearby POIs (within MINIMAP_RADIUS sim tiles): colored dot/shape at scaled position.
|
||||
## Distant POIs (beyond radius): directional arrow at circle border pointing toward POI.
|
||||
## Frame renders always — the insert is on even when no POIs are discovered.
|
||||
##
|
||||
## POI categories → shapes:
|
||||
## danger / threat / hostile → diamond (ENTITY_COLOR_HOSTILE, red)
|
||||
## evidence / note / clue → square (ENTITY_COLOR_POI, amber)
|
||||
## contact / npc / person → circle (ENTITY_COLOR_UNKNOWN, teal)
|
||||
## location / place / venue → circle (INSERT_COLOR_TEXT, white-blue)
|
||||
## (default) → circle (INSERT_COLOR_TEXT)
|
||||
|
||||
@onready var viewport_container: SubViewportContainer = $SubViewportContainer
|
||||
## Sim tiles visible within the minimap circle. POIs beyond this show as border arrows.
|
||||
const MINIMAP_RADIUS: float = 24.0
|
||||
|
||||
# Visual parameters
|
||||
const FRAME_WIDTH: float = 1.2
|
||||
const PLAYER_DOT_RADIUS: float = 3.5
|
||||
const POI_DOT_RADIUS: float = 3.0
|
||||
const ARROW_HALF: float = 4.5
|
||||
const ARROW_LEN: float = 7.0
|
||||
const NORTH_TICK_LEN: float = 8.0
|
||||
const CARDINAL_TICK_LEN: float = 4.0
|
||||
|
||||
# Colors — insert palette from Constants, tuned for the circular minimap frame
|
||||
const COLOR_BG: Color = Color(0.04, 0.07, 0.12, 0.82)
|
||||
const COLOR_FRAME: Color = Color(0.784, 0.816, 0.878, 0.55) # INSERT_COLOR_TEXT at reduced alpha
|
||||
const COLOR_NORTH: Color = Color(0.784, 0.816, 0.878, 0.9) # Brighter for N tick
|
||||
const COLOR_CARDINAL: Color = Color(0.784, 0.816, 0.878, 0.4) # Dimmer E/S/W ticks
|
||||
const COLOR_PLAYER: Color = Constants.ENTITY_COLOR_PLAYER
|
||||
|
||||
var _insert_active: bool = true
|
||||
|
||||
func _ready() -> void:
|
||||
print("Minimap: Initialized")
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_process(true)
|
||||
|
||||
# Update minimap view (stub)
|
||||
func update_minimap(player_pos: Vector2, entities: Array) -> void:
|
||||
# TODO: Render minimap view
|
||||
# This will show a top-down view of the local area
|
||||
pass
|
||||
func _process(_delta: float) -> void:
|
||||
if _insert_active:
|
||||
queue_redraw()
|
||||
|
||||
## Called from main.gd when GameState.insert_active changes.
|
||||
## Hides the minimap overlay when the neural insert is inactive.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
visible = active
|
||||
|
||||
func _draw() -> void:
|
||||
var sz: Vector2 = get_rect().size
|
||||
var center := sz / 2.0
|
||||
# Outer radius: fill control with 1px edge padding
|
||||
var outer_r: float = minf(sz.x, sz.y) / 2.0 - 1.0
|
||||
|
||||
# --- Background fill ---
|
||||
draw_circle(center, outer_r, COLOR_BG)
|
||||
|
||||
# --- Frame ring ---
|
||||
draw_arc(center, outer_r, 0.0, TAU, 64, COLOR_FRAME, FRAME_WIDTH, true)
|
||||
|
||||
# --- Cardinal ticks (N brighter, E/S/W dimmer) ---
|
||||
_draw_cardinal_ticks(center, outer_r)
|
||||
|
||||
# --- Player dot at center ---
|
||||
draw_circle(center, PLAYER_DOT_RADIUS, COLOR_PLAYER)
|
||||
# Soft bloom ring
|
||||
draw_arc(center, PLAYER_DOT_RADIUS + 1.5, 0.0, TAU, 32,
|
||||
Color(COLOR_PLAYER.r, COLOR_PLAYER.g, COLOR_PLAYER.b, 0.22), 1.0, true)
|
||||
|
||||
# --- POIs ---
|
||||
var pois: Array = GameState.discovered_pois
|
||||
if pois.is_empty():
|
||||
return
|
||||
|
||||
var px: float = GameState.player_position.x
|
||||
var py: float = GameState.player_position.y
|
||||
# Pixels per sim tile within the inner drawable area
|
||||
var inner_r: float = outer_r - FRAME_WIDTH
|
||||
var scale: float = inner_r / MINIMAP_RADIUS
|
||||
|
||||
for poi in pois:
|
||||
if not poi is Dictionary:
|
||||
continue
|
||||
if not poi.has("x") or not poi.has("y"):
|
||||
continue
|
||||
|
||||
var dx: float = float(poi.x) - px
|
||||
var dy: float = float(poi.y) - py
|
||||
var dist: float = sqrt(dx * dx + dy * dy)
|
||||
var category: String = poi.get("category", "")
|
||||
var color: Color = _category_color(category)
|
||||
|
||||
if dist < 0.01:
|
||||
# POI at exact player position — draw at center offset slightly
|
||||
_draw_poi_shape(center + Vector2(0.0, -POI_DOT_RADIUS - 2.0), color, category)
|
||||
elif dist <= MINIMAP_RADIUS:
|
||||
# Nearby: project to screen position within circle
|
||||
var poi_screen := center + Vector2(dx, dy) * scale
|
||||
# Hard-clamp to inner circle boundary (guards floating-point edge cases)
|
||||
var rel := poi_screen - center
|
||||
if rel.length() > inner_r - POI_DOT_RADIUS - 1.0:
|
||||
poi_screen = center + rel.normalized() * (inner_r - POI_DOT_RADIUS - 1.0)
|
||||
_draw_poi_shape(poi_screen, color, category)
|
||||
else:
|
||||
# Distant: arrow at border pointing toward POI direction
|
||||
var dir := Vector2(dx, dy).normalized()
|
||||
var arrow_tip := center + dir * (inner_r - 2.0)
|
||||
_draw_border_arrow(arrow_tip, dir, color)
|
||||
|
||||
|
||||
func _draw_cardinal_ticks(center: Vector2, outer_r: float) -> void:
|
||||
# North tick — longer, brighter, the fixed-north indicator
|
||||
var n_dir := Vector2(0.0, -1.0)
|
||||
draw_line(
|
||||
center + n_dir * (outer_r - NORTH_TICK_LEN),
|
||||
center + n_dir * outer_r,
|
||||
COLOR_NORTH, FRAME_WIDTH + 0.5, true
|
||||
)
|
||||
# East (PI/2), South (PI), West (3PI/2) — shorter, dimmer
|
||||
for angle in [PI / 2.0, PI, 3.0 * PI / 2.0]:
|
||||
var dir := Vector2(cos(angle), sin(angle))
|
||||
draw_line(
|
||||
center + dir * (outer_r - CARDINAL_TICK_LEN),
|
||||
center + dir * outer_r,
|
||||
COLOR_CARDINAL, FRAME_WIDTH, true
|
||||
)
|
||||
|
||||
|
||||
func _draw_poi_shape(pos: Vector2, color: Color, category: String) -> void:
|
||||
match category.to_lower():
|
||||
"danger", "threat", "hostile":
|
||||
# Diamond for danger
|
||||
var s: float = POI_DOT_RADIUS + 1.0
|
||||
draw_polygon(
|
||||
PackedVector2Array([
|
||||
pos + Vector2(0.0, -s), pos + Vector2(s, 0.0),
|
||||
pos + Vector2(0.0, s), pos + Vector2(-s, 0.0)
|
||||
]),
|
||||
PackedColorArray([color, color, color, color])
|
||||
)
|
||||
"evidence", "note", "clue":
|
||||
# Square for evidence/clue
|
||||
var s: float = POI_DOT_RADIUS - 0.5
|
||||
draw_rect(Rect2(pos - Vector2(s, s), Vector2(s * 2.0, s * 2.0)), color)
|
||||
_:
|
||||
draw_circle(pos, POI_DOT_RADIUS, color)
|
||||
|
||||
|
||||
## Arrow tip at `tip`, pointing in `dir`. Arrow body extends ARROW_LEN back from tip.
|
||||
func _draw_border_arrow(tip: Vector2, dir: Vector2, color: Color) -> void:
|
||||
var perp := Vector2(-dir.y, dir.x)
|
||||
var base_center := tip - dir * ARROW_LEN
|
||||
draw_polygon(
|
||||
PackedVector2Array([tip, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]),
|
||||
PackedColorArray([color, color, color])
|
||||
)
|
||||
|
||||
|
||||
func _category_color(category: String) -> Color:
|
||||
match category.to_lower():
|
||||
"danger", "threat", "hostile":
|
||||
return Constants.ENTITY_COLOR_HOSTILE # #d45d5d — red
|
||||
"evidence", "note", "clue":
|
||||
return Constants.ENTITY_COLOR_POI # #e8c547 — amber
|
||||
"contact", "npc", "person":
|
||||
return Constants.ENTITY_COLOR_UNKNOWN # #4a9ebb — teal
|
||||
_:
|
||||
return Constants.INSERT_COLOR_TEXT # #c8d0e0 — white-blue
|
||||
|
||||
@@ -2,33 +2,21 @@
|
||||
|
||||
[ext_resource type="Script" path="res://ui/minimap.gd" id="1_minimap"]
|
||||
|
||||
[node name="Minimap" type="Control"]
|
||||
; MinimapRenderer — diegetic neural insert overlay (D-013, D-049 z-layer 6).
|
||||
; 160x160px circle, top-right corner with 16px margin.
|
||||
; Rendered via _draw() — no SubViewport needed.
|
||||
; Positioned in InsertOverlay (CanvasLayer 10) by main.tscn.
|
||||
|
||||
[node name="MinimapRenderer" type="Control"]
|
||||
custom_minimum_size = Vector2(160, 160)
|
||||
layout_mode = 3
|
||||
anchors_preset = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -200.0
|
||||
offset_bottom = 200.0
|
||||
offset_left = -176.0
|
||||
offset_top = 16.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = 176.0
|
||||
grow_horizontal = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_minimap")
|
||||
|
||||
[node name="SubViewportContainer" type="SubViewportContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="SubViewport" type="SubViewport" parent="SubViewportContainer"]
|
||||
size = Vector2i(200, 200)
|
||||
render_target_update_mode = 4
|
||||
|
||||
[node name="Background" type="ColorRect" parent="SubViewportContainer/SubViewport"]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0.1, 0.1, 0.1, 0.7)
|
||||
|
||||
@@ -121,6 +121,14 @@ func _build_ui() -> void:
|
||||
close_btn.pressed.connect(close)
|
||||
_container.add_child(close_btn)
|
||||
|
||||
# Quit to Menu button (#258: D-085 session management)
|
||||
var quit_btn := Button.new()
|
||||
quit_btn.text = UIStrings.get_text("menu.quit_to_menu")
|
||||
quit_btn.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
quit_btn.add_theme_color_override("font_color", Color("#c87040"))
|
||||
quit_btn.pressed.connect(_on_quit_to_menu)
|
||||
_container.add_child(quit_btn)
|
||||
|
||||
|
||||
func _destroy_ui() -> void:
|
||||
if _container:
|
||||
@@ -153,6 +161,11 @@ func _draw() -> void:
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
|
||||
|
||||
|
||||
func _on_quit_to_menu() -> void:
|
||||
close()
|
||||
SessionManager.quit_to_menu(get_tree().root)
|
||||
|
||||
|
||||
static func _format_db(db: float) -> String:
|
||||
if db <= -40.0:
|
||||
return "mute"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
extends Control
|
||||
|
||||
## #263: Time display — diegetic time readout on the player's neural insert (D-013, D-031).
|
||||
## Shows station local time (HH:MM), day phase, and day number.
|
||||
## Lives on InsertOverlay (CanvasLayer 10) per D-051 diegetic insert principle.
|
||||
## Draw-based for implant visual aesthetic. Updated via update_from_state() from main.gd.
|
||||
##
|
||||
## Placeholder layout — position and style will be refined when #314 wireframe lands.
|
||||
|
||||
const FONT_SIZE_TIME: int = 15
|
||||
const FONT_SIZE_META: int = 10
|
||||
const PADDING := Vector2(10, 7)
|
||||
const BG_COLOR := Color(0.04, 0.05, 0.08, 0.70)
|
||||
const BORDER_COLOR := Color(0.10, 0.20, 0.26, 0.65)
|
||||
|
||||
# Day phase colors — station lighting cycle (D-031)
|
||||
const PHASE_COLORS := {
|
||||
"Morning": Color("#aed6dc"), # pale cyan-blue — early light
|
||||
"Afternoon": Color("#E0F7FA"), # bright cyan-white — full day
|
||||
"Evening": Color("#9EBFC4"), # dimmed — dusk transition
|
||||
"Night": Color("#4a7080"), # dark teal — station nightwatch
|
||||
}
|
||||
|
||||
var _time_str: String = "--:--"
|
||||
var _phase_str: String = ""
|
||||
var _day_str: String = ""
|
||||
var _day_text: String = ""
|
||||
var _phase_color: Color = Constants.IMPLANT_TEXT_DIM
|
||||
var _last_signature: String = ""
|
||||
var _has_data: bool = false
|
||||
|
||||
# Cached geometry — recomputed in update_from_state(), used in _draw()
|
||||
var _time_size: Vector2 = Vector2.ZERO
|
||||
var _phase_size: Vector2 = Vector2.ZERO
|
||||
var _day_size: Vector2 = Vector2.ZERO
|
||||
var _meta_h: float = 0.0
|
||||
var _box_w: float = 0.0
|
||||
var _box_h: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
var gt: Dictionary = GameState.game_time
|
||||
if gt.is_empty():
|
||||
return
|
||||
var tod: int = int(gt.get("time_of_day", 0))
|
||||
var day: int = int(gt.get("day", 0))
|
||||
var phase: String = str(gt.get("day_phase", ""))
|
||||
var sig: String = "%d:%d:%s" % [tod, day, phase]
|
||||
if sig == _last_signature:
|
||||
return
|
||||
_last_signature = sig
|
||||
_time_str = Constants.format_game_time(tod)
|
||||
_phase_str = phase
|
||||
_day_str = "D%d" % (day + 1)
|
||||
_day_text = " " + _day_str
|
||||
_phase_color = PHASE_COLORS.get(phase, Constants.IMPLANT_TEXT_DIM)
|
||||
_has_data = true
|
||||
_cache_geometry()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _cache_geometry() -> void:
|
||||
var font := ThemeDB.fallback_font
|
||||
_time_size = font.get_string_size(_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME)
|
||||
_phase_size = font.get_string_size(_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
|
||||
_day_size = font.get_string_size(_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
|
||||
_meta_h = font.get_string_size("A", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META).y
|
||||
var meta_w := _phase_size.x + _day_size.x
|
||||
var content_w := max(_time_size.x, meta_w)
|
||||
_box_w = content_w + PADDING.x * 2
|
||||
_box_h = PADDING.y * 2 + _time_size.y + 3 + _meta_h
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not _has_data:
|
||||
return
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BG_COLOR)
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BORDER_COLOR, false, 1.0)
|
||||
|
||||
var font := ThemeDB.fallback_font
|
||||
|
||||
# HH:MM (primary, full brightness)
|
||||
draw_string(font, Vector2(PADDING.x, PADDING.y + _time_size.y),
|
||||
_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME, Constants.IMPLANT_TEXT_COLOR)
|
||||
|
||||
# Phase + day number (secondary, dimmed + phase-tinted)
|
||||
var meta_y := PADDING.y + _time_size.y + 3 + _meta_h
|
||||
draw_string(font, Vector2(PADDING.x, meta_y),
|
||||
_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, _phase_color)
|
||||
draw_string(font, Vector2(PADDING.x + _phase_size.x, meta_y),
|
||||
_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, Constants.IMPLANT_TEXT_DIM)
|
||||
@@ -0,0 +1,22 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://b4timedisplay1"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/time_display.gd" id="1_tdisplay"]
|
||||
|
||||
; #263: Time display — top-left placeholder per D-013/D-051.
|
||||
; Position and size will be refined when #314 wireframe lands.
|
||||
; NOTE: draw-based content manages its own layout; Control rect is a
|
||||
; minimum bounding box, not a clip rect. Increase if content grows.
|
||||
[node name="TimeDisplay" type="Control"]
|
||||
anchors_preset = 0
|
||||
anchor_left = 0.0
|
||||
anchor_top = 0.0
|
||||
anchor_right = 0.0
|
||||
anchor_bottom = 0.0
|
||||
offset_left = 16.0
|
||||
offset_top = 16.0
|
||||
offset_right = 200.0
|
||||
offset_bottom = 70.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_tdisplay")
|
||||
@@ -253,7 +253,7 @@ Every dialogue line requires 6 structural tags + up to 3 selection tags.
|
||||
|
||||
| Tag | Type | Format | Description |
|
||||
|-----|------|--------|-------------|
|
||||
| `id` | string | `{location}_{d}_{###}` | Stable line ID. Machine-parseable. Never reused. |
|
||||
| `id` | string | `{npc-slug}_d_{###}` | Stable line ID. Machine-parseable. Never reused. NPC-scoped per D-035 Sprint 15 amendment. |
|
||||
| `text` | string | The line | The authored dialogue text |
|
||||
| `role` | enum | lowercase-slug | Template role, not NPC name. NPCs fill roles at runtime. |
|
||||
| `access` | list | `[public, insider, ...]` | D-028 Layer 1 hard filter. Who can hear this. |
|
||||
@@ -265,7 +265,7 @@ Every dialogue line requires 6 structural tags + up to 3 selection tags.
|
||||
| Tag | Type | Format | Description |
|
||||
|-----|------|--------|-------------|
|
||||
| `topic` | list | `[colleague, cargo, ...]` | D-028 Layer 4 weighted selection. Defaults to `[routine]`. |
|
||||
| `mood` | list | `[comfortable, worried, ...]` | D-028 Layer 4 weighted selection. Defaults to `[comfortable]`. |
|
||||
| `mood` | list | `[content, anxious, ...]` | D-028 Layer 4 weighted selection. Defaults to untagged (neutral). |
|
||||
| `tags` | list | freeform strings | Escape hatch for author intent. |
|
||||
|
||||
#### Authoring-Only Tags (Not Consumed by Engine)
|
||||
@@ -352,7 +352,7 @@ Same trigger, different pool. That's how mirror moments work.
|
||||
|
||||
| Tag | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `id` | string | `{location}_m_{s\|d}_{###}` — `s` for smuggler, `d` for detective |
|
||||
| `id` | string | `{npc-slug}_m_{s\|d}_{###}` — `s` for smuggler, `d` for detective. NPC-scoped per D-035 Sprint 15 amendment. |
|
||||
| `text` | string | The monologue line (160 char max) |
|
||||
| `character` | enum | `smuggler` or `detective` |
|
||||
| `trigger` | enum | What causes this line to fire (see trigger types below) |
|
||||
@@ -600,9 +600,9 @@ See `content/global/enums/topics.yaml`. Note: `crime` is deliberately excluded.
|
||||
|
||||
### Moods (8 values)
|
||||
|
||||
`fond`, `comfortable`, `worried`, `suspicious`, `analytical`, `conflicted`, `concerned`, `relieved`
|
||||
`anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `relieved`, `focused`
|
||||
|
||||
See `content/global/enums/moods.yaml`.
|
||||
See `content/global/enums/moods.yaml`. (Updated Sprint 14 amendment to D-035.)
|
||||
|
||||
### Monologue Triggers (9 values)
|
||||
|
||||
@@ -630,7 +630,7 @@ Before submitting any content, run through these checks. Items marked **HARD** w
|
||||
|
||||
### Dialogue Lines
|
||||
|
||||
- [ ] **HARD:** `id` matches pattern `{location}_d_{###}` (lowercase, underscore-separated)
|
||||
- [ ] **HARD:** `id` matches pattern `{npc-slug}_d_{###}` (e.g. `kael-davan_d_001`) — NPC-scoped per D-035 Sprint 15 amendment
|
||||
- [ ] **HARD:** `role` is a valid lowercase slug matching a template role
|
||||
- [ ] **HARD:** `access` is a non-empty list of valid access tier enums
|
||||
- [ ] **HARD:** `trust` is one of `surface`, `real`, `secret`
|
||||
@@ -643,7 +643,7 @@ Before submitting any content, run through these checks. Items marked **HARD** w
|
||||
|
||||
### Monologue Lines
|
||||
|
||||
- [ ] **HARD:** `id` matches pattern `{location}_m_{s|d}_{###}`
|
||||
- [ ] **HARD:** `id` matches pattern `{npc-slug}_m_{s|d}_{###}` (e.g. `pc-smuggler_m_s_001`) — NPC-scoped per D-035 Sprint 15 amendment
|
||||
- [ ] **HARD:** `text` is 160 characters or fewer
|
||||
- [ ] **HARD:** `trigger` is a valid trigger enum
|
||||
- [ ] **HARD:** Character partition: smuggler lines in smuggler files, detective in detective files
|
||||
@@ -755,9 +755,9 @@ All YAML content files must validate against their JSON Schema:
|
||||
|
||||
| Content Type | Pattern | Example |
|
||||
|-------------|---------|---------|
|
||||
| Dialogue | `{location}_d_{###}` | `transit_d_044` |
|
||||
| Monologue (smuggler) | `{location}_m_s_{###}` | `transit_m_s_011` |
|
||||
| Monologue (detective) | `{location}_m_d_{###}` | `transit_m_d_042` |
|
||||
| Dialogue | `{npc-slug}_d_{###}` | `kael-davan_d_001` |
|
||||
| Monologue (smuggler) | `{npc-slug}_m_s_{###}` | `pc-smuggler_m_s_001` |
|
||||
| Monologue (detective) | `{npc-slug}_m_d_{###}` | `pc-detective_m_d_001` |
|
||||
|
||||
IDs are stable identifiers. Once assigned, never reused or reassigned. Number gaps are acceptable.
|
||||
|
||||
@@ -865,26 +865,26 @@ Every generated line must pass:
|
||||
### Step 2: Assign Tags
|
||||
|
||||
```yaml
|
||||
- id: transit_d_071
|
||||
- id: kael-davan_d_076
|
||||
text: "Morning. Voss kept the rotation thin -- we've got a clear window after 14:00."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [shift_start]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
mood: [content]
|
||||
tags: [ring-operational]
|
||||
```
|
||||
|
||||
### Step 3: Validate
|
||||
|
||||
- `id`: `transit_d_071` -- valid pattern.
|
||||
- `id`: `kael-davan_d_076` -- valid pattern (NPC-scoped per D-035 Sprint 15 amendment). Continues from kael-davan's terminal sequence (_034-_075).
|
||||
- `role`: `dock-worker` -- valid slug.
|
||||
- `access: [insider]` -- only ring members hear this. Detective never sees it. Correct.
|
||||
- `trust: real` -- requires earned trust. Not surface-level small talk. Correct for operational ring dialogue.
|
||||
- `situation: [shift_start]` -- morning shift context. Correct.
|
||||
- `topic: [cargo, routine]` -- covers both operational and schedule content. Correct.
|
||||
- `mood: [comfortable]` -- Phase 1, baseline warm. Correct.
|
||||
- `mood: [content]` -- Phase 1, baseline warm. Correct.
|
||||
|
||||
### Step 4: Dual-Lens Check
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*_(d|m)_[0-9]{3}$",
|
||||
"description": "Stable machine-parseable line ID: {template}_{d|m}_{###}. d = dialogue, m = monologue."
|
||||
"description": "Stable machine-parseable line ID: {npc-slug}_{d|m}_{###} (e.g. kael-davan_d_001, pc-detective_m_d_001). d = dialogue, m = monologue. NPC-scoped per D-035 Sprint 15 amendment — each NPC has an independent sequence starting at _001."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*_d_[0-9]{3}$",
|
||||
"description": "Stable line ID: {location_slug}_d_{###}"
|
||||
"description": "Stable line ID: {npc-slug}_d_{###} (e.g. kael-davan_d_001). NPC-scoped per D-035 Sprint 15 amendment — each NPC has an independent sequence starting at _001."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$",
|
||||
"description": "Stable line ID: {location_slug}_m_{s|d}_{###}. s = smuggler, d = detective."
|
||||
"description": "Stable line ID: {npc-slug}_m_{s|d}_{###} (e.g. pc-smuggler_m_s_001, pc-detective_m_d_001). s = smuggler, d = detective. NPC-scoped per D-035 Sprint 15 amendment — each NPC has an independent sequence starting at _001."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
|
||||
@@ -13,7 +13,7 @@ lines:
|
||||
# RING OPERATIONS — corridor handoffs
|
||||
# ========================================
|
||||
|
||||
- id: maintenance-corridors_d_001
|
||||
- id: kael-davan_d_001
|
||||
text: "Package is in the junction locker. Renn picks it up in ten."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -23,7 +23,7 @@ lines:
|
||||
topic: [cargo]
|
||||
tags: [kael, ring-ops, renn, operational]
|
||||
|
||||
- id: maintenance-corridors_d_002
|
||||
- id: kael-davan_d_002
|
||||
text: "B-7 is clear. Camera cycles in forty seconds — move fast."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -35,7 +35,7 @@ lines:
|
||||
fact_id: investigation.surveillance_gaps
|
||||
confidence: knows_details
|
||||
|
||||
- id: maintenance-corridors_d_003
|
||||
- id: kael-davan_d_003
|
||||
text: "Chalk marks are fresh. Renn came through already. We're on schedule."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -45,7 +45,7 @@ lines:
|
||||
topic: [cargo]
|
||||
tags: [kael, ring-ops, renn, operational]
|
||||
|
||||
- id: maintenance-corridors_d_004
|
||||
- id: kael-davan_d_004
|
||||
text: "Don't use the C-section hatch tonight. Maintenance crew's working late."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -60,7 +60,7 @@ lines:
|
||||
# When the smuggler finds Kael with the unknown contact
|
||||
# ========================================
|
||||
|
||||
- id: maintenance-corridors_d_005
|
||||
- id: kael-davan_d_005
|
||||
text: "What are you — wait. How long have you been standing there?"
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -70,7 +70,7 @@ lines:
|
||||
topic: [danger]
|
||||
tags: [kael, contradiction, phase-3]
|
||||
|
||||
- id: maintenance-corridors_d_006
|
||||
- id: kael-davan_d_006
|
||||
text: "That was nobody. Maintenance contact. It's handled."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -80,7 +80,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [kael, deflection, lying, phase-4]
|
||||
|
||||
- id: maintenance-corridors_d_007
|
||||
- id: kael-davan_d_007
|
||||
text: "I don't have to explain every conversation I have. Drop it."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -94,7 +94,7 @@ lines:
|
||||
# SECRET — corridor confessions
|
||||
# ========================================
|
||||
|
||||
- id: maintenance-corridors_d_008
|
||||
- id: kael-davan_d_008
|
||||
text: "I'm trying to get out. That's all. I just want out."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -104,7 +104,7 @@ lines:
|
||||
topic: [personal, trust]
|
||||
tags: [kael, confession, phase-5]
|
||||
|
||||
- id: maintenance-corridors_d_009
|
||||
- id: kael-davan_d_009
|
||||
text: "Nils will kill me if he finds out. You know that. You know him."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -114,7 +114,7 @@ lines:
|
||||
topic: [danger, trust]
|
||||
tags: [kael, confession, nils, phase-5]
|
||||
|
||||
- id: maintenance-corridors_d_010
|
||||
- id: kael-davan_d_010
|
||||
text: "I found someone who can get me new papers. Off-station. Clean break."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -136,7 +136,7 @@ lines:
|
||||
# ========================================
|
||||
|
||||
# first_meeting: InteractionMemory.count == 0. Any stranger in restricted corridors is a problem.
|
||||
- id: maintenance-corridors_d_011
|
||||
- id: kael-davan_d_011
|
||||
text: "This is a restricted section. Who authorized you back here?"
|
||||
role: dock-worker
|
||||
access: [public]
|
||||
@@ -148,7 +148,7 @@ lines:
|
||||
notes: "Layer 2 greeting — first meeting in restricted corridors. Immediate challenge. Any stranger back here is suspicious regardless of intent."
|
||||
|
||||
# established — peer/smuggler context: count >= 3, RelationshipState: Known/Friendly → Peer/Insider access.
|
||||
- id: maintenance-corridors_d_012
|
||||
- id: kael-davan_d_012
|
||||
text: "You're on time. Clear all the way to the junction."
|
||||
role: dock-worker
|
||||
access: [peer, insider]
|
||||
@@ -160,7 +160,7 @@ lines:
|
||||
notes: "Layer 2 greeting — established, operational. All business. Kael treats a trusted repeat visitor as crew — no pleasantries, just the essential handoff information."
|
||||
|
||||
# established — authority/detective context: count >= 3, RelationshipState: PersonOfInterest → Authority access.
|
||||
- id: maintenance-corridors_d_013
|
||||
- id: kael-davan_d_013
|
||||
text: "You keep showing up back here. You have clearance for this section?"
|
||||
role: dock-worker
|
||||
access: [authority]
|
||||
@@ -172,7 +172,7 @@ lines:
|
||||
notes: "Layer 2 greeting — repeat with authority figure in restricted section. Professionally defensive. Kael challenges them but can't bar entry if they have clearance."
|
||||
|
||||
# post-confrontation: confrontation logged. Kael is cornered on his own operational ground.
|
||||
- id: maintenance-corridors_d_014
|
||||
- id: kael-davan_d_014
|
||||
text: "You followed me here."
|
||||
role: dock-worker
|
||||
access: [peer, insider]
|
||||
|
||||
@@ -21,7 +21,7 @@ lines:
|
||||
# SURFACE COVER — sounds like maintenance work
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_100
|
||||
- id: ring-operative_d_001
|
||||
text: "Running maintenance on the B-7 conduit. Could be a while."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
@@ -35,7 +35,7 @@ lines:
|
||||
detective: "Maintenance worker. Conduit repair. Routine."
|
||||
notes: "'Running maintenance' is the canonical ring cover phrase."
|
||||
|
||||
- id: maintenance-corridors_d_101
|
||||
- id: ring-operative_d_002
|
||||
text: "Bay four access from here is faster than going around. If you're in a hurry."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
@@ -48,7 +48,7 @@ lines:
|
||||
smuggler: "He's telling me the route is faster. This is spatial information, not just courtesy."
|
||||
detective: "Ring operative casually establishes corridor-to-bay-four spatial relationship. Note it."
|
||||
|
||||
- id: maintenance-corridors_d_102
|
||||
- id: ring-operative_d_003
|
||||
text: "Quiet in here tonight. Good working conditions."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
@@ -61,7 +61,7 @@ lines:
|
||||
smuggler: "Dead air confirmed. Meridian's not picking this up."
|
||||
detective: "Routine corridor comment."
|
||||
|
||||
- id: maintenance-corridors_d_103
|
||||
- id: ring-operative_d_004
|
||||
text: "Pael was through here earlier. Seal replacement on the ventilation unit."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
@@ -74,7 +74,7 @@ lines:
|
||||
smuggler: "Pael was through. Legitimate. Corridor's been active — that means traffic covers us."
|
||||
detective: "References Pael's B-7 presence without suspicion. Maintenance workers know each other's routes."
|
||||
|
||||
- id: maintenance-corridors_d_104
|
||||
- id: ring-operative_d_005
|
||||
text: "C-4 access is at the far end. About three minutes from here."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
@@ -94,7 +94,7 @@ lines:
|
||||
# OPERATIONAL COORDINATION — insider/real only
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_105
|
||||
- id: ring-operative_d_006
|
||||
text: "Cans are ready. Bay four, per the schedule."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
@@ -107,7 +107,7 @@ lines:
|
||||
smuggler: "Containers cleared. Bay four. We're on."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_106
|
||||
- id: ring-operative_d_007
|
||||
text: "Manifest is clean. Window opens in eighteen minutes."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
@@ -120,7 +120,7 @@ lines:
|
||||
smuggler: "Paperwork's set. Eighteen minutes. That's earlier than planned — adjust."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_107
|
||||
- id: ring-operative_d_008
|
||||
text: "Usual pickup. Route's the same."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
@@ -133,7 +133,7 @@ lines:
|
||||
smuggler: "Same route, same handoff. No variations."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_108
|
||||
- id: ring-operative_d_009
|
||||
text: "The gap's clear. Scan's done at 14:20. Next window's yours."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
@@ -146,10 +146,10 @@ lines:
|
||||
smuggler: "14:20 scan, then the window opens. Twenty-five minutes clear."
|
||||
detective: "Never hears this line."
|
||||
knowledge_grant:
|
||||
fact_id: location.surveillance_gaps
|
||||
fact_id: investigation.surveillance_gaps
|
||||
confidence: knows_details
|
||||
|
||||
- id: maintenance-corridors_d_109
|
||||
- id: ring-operative_d_010
|
||||
text: "Drin was asking about B-7. Twice today."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
@@ -165,7 +165,7 @@ lines:
|
||||
fact_id: investigation.drin_inspection_pattern
|
||||
confidence: suspects
|
||||
|
||||
- id: maintenance-corridors_d_110
|
||||
- id: ring-operative_d_011
|
||||
text: "Dead air through here to the C-4 junction. No coverage."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
@@ -178,10 +178,10 @@ lines:
|
||||
smuggler: "Full confirmation. Meridian doesn't reach this section."
|
||||
detective: "Never hears this line."
|
||||
knowledge_grant:
|
||||
fact_id: location.surveillance_gaps
|
||||
fact_id: investigation.surveillance_gaps
|
||||
confidence: knows_details
|
||||
|
||||
- id: maintenance-corridors_d_111
|
||||
- id: ring-operative_d_012
|
||||
text: "Voss confirmed the hold clears at 1400. He'll run the bay himself."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
@@ -194,7 +194,7 @@ lines:
|
||||
smuggler: "Voss is personally running bay four at 1400. That's the signal the window is clear."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_112
|
||||
- id: ring-operative_d_013
|
||||
text: "Kael's on dock. He knows."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
@@ -211,7 +211,7 @@ lines:
|
||||
# PRESSURE / WARNING SIGNALS
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_113
|
||||
- id: ring-operative_d_014
|
||||
text: "Commission eyes on the terminal floor. Not here yet."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
@@ -224,7 +224,7 @@ lines:
|
||||
smuggler: "Detective is visible on the main floor. Corridors are still clear."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_114
|
||||
- id: ring-operative_d_015
|
||||
text: "Hold the run. Drin's in the corridor section."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
@@ -237,7 +237,7 @@ lines:
|
||||
smuggler: "Stop. Drin's in position. The window's closed."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_115
|
||||
- id: ring-operative_d_016
|
||||
text: "Maret filed again. Voss has it. Don't move anything tonight."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
@@ -250,7 +250,7 @@ lines:
|
||||
smuggler: "Maret flagged something. Voss is managing it. Stand down tonight."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_116
|
||||
- id: ring-operative_d_017
|
||||
text: "We're clean. Same time tomorrow."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
@@ -267,7 +267,7 @@ lines:
|
||||
# IF DETECTIVE SOMEHOW ENTERS — surface only
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_117
|
||||
- id: ring-operative_d_018
|
||||
text: "Restricted access back here. Service personnel only."
|
||||
role: ring-operative
|
||||
access: [authority, public]
|
||||
@@ -277,7 +277,7 @@ lines:
|
||||
mood: [suspicious]
|
||||
tags: [ring-operative, detective-path, access-denial]
|
||||
|
||||
- id: maintenance-corridors_d_118
|
||||
- id: ring-operative_d_019
|
||||
text: "I'm running maintenance. If there's a Commission access request, file it through Voss."
|
||||
role: ring-operative
|
||||
access: [authority]
|
||||
@@ -290,7 +290,7 @@ lines:
|
||||
smuggler: "Never encounters this."
|
||||
detective: "Operative redirects to Voss. Everyone redirects to Voss. That's structural, not coincidence."
|
||||
|
||||
- id: maintenance-corridors_d_119
|
||||
- id: ring-operative_d_020
|
||||
text: "Nothing unusual back here. Conduit work. Same every cycle."
|
||||
role: ring-operative
|
||||
access: [authority, public]
|
||||
@@ -305,7 +305,7 @@ lines:
|
||||
# Surface-only: covers routine corridor presence without operational tells.
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_120
|
||||
- id: ring-operative_d_021
|
||||
text: "Power relay's been cycling weird. I'm watching it."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
@@ -315,7 +315,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [ring-operative, surface-cover, generation-pass]
|
||||
|
||||
- id: maintenance-corridors_d_121
|
||||
- id: ring-operative_d_022
|
||||
text: "Pael was supposed to handle B-7. He didn't show."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
@@ -325,7 +325,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [ring-operative, surface-cover, pael, generation-pass]
|
||||
|
||||
- id: maintenance-corridors_d_122
|
||||
- id: ring-operative_d_023
|
||||
text: "Watch the floor here. Drain panel's been loose three cycles."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
@@ -335,7 +335,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [ring-operative, surface-cover, generation-pass]
|
||||
|
||||
- id: maintenance-corridors_d_123
|
||||
- id: ring-operative_d_024
|
||||
text: "Airflow's better down the B-section. C-section's got the recycler issue."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
@@ -345,7 +345,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [ring-operative, surface-cover, spatial, generation-pass]
|
||||
|
||||
- id: maintenance-corridors_d_124
|
||||
- id: ring-operative_d_025
|
||||
text: "You want the service bay, it's left at the junction. Don't go right — that's the restricted access."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
@@ -358,7 +358,7 @@ lines:
|
||||
smuggler: "He's directing people away from the route. Social traffic management."
|
||||
detective: "Operative redirects me away from C-4 access junction instinctively."
|
||||
|
||||
- id: maintenance-corridors_d_125
|
||||
- id: ring-operative_d_026
|
||||
text: "Long shift. These corridors look the same after a while."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
@@ -368,7 +368,7 @@ lines:
|
||||
mood: [frustrated]
|
||||
tags: [ring-operative, surface-cover, generation-pass]
|
||||
|
||||
- id: maintenance-corridors_d_126
|
||||
- id: ring-operative_d_027
|
||||
text: "Scan's clear. I checked it an hour ago."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Dialogue: transit-worker at Maintenance Corridors
|
||||
# NPC: Tev Osel (Tier 3, NOBODY/CIVILIAN)
|
||||
# Voice: patient, mildly distracted, confused why anyone is asking him anything.
|
||||
# KEY LINE: maintenance-corridors_d_001 — sounds like surveillance positioning inquiry
|
||||
# KEY LINE: transit-worker_d_001 — sounds like surveillance positioning inquiry
|
||||
# Actually: Tev missed Kosse at the corridor junction and is looking for them
|
||||
# Ticket: #307 | Sprint: 12
|
||||
|
||||
@@ -15,7 +15,7 @@ lines:
|
||||
# Actually: Tev missed his partner at the regular meeting spot.
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_001
|
||||
- id: transit-worker_d_001
|
||||
text: "You see which way Kosse went? Should've been through here twenty minutes ago."
|
||||
role: transit-worker
|
||||
access: [public]
|
||||
@@ -36,7 +36,7 @@ lines:
|
||||
# ROUTINE LINES
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_002
|
||||
- id: transit-worker_d_002
|
||||
text: "They shifted the freight route again. Always forget which junction."
|
||||
role: transit-worker
|
||||
access: [public]
|
||||
@@ -46,7 +46,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [tev, atmospheric]
|
||||
|
||||
- id: maintenance-corridors_d_003
|
||||
- id: transit-worker_d_003
|
||||
text: "Just on break. Out of your way in a minute."
|
||||
role: transit-worker
|
||||
access: [public]
|
||||
@@ -56,7 +56,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [tev, incidental]
|
||||
|
||||
- id: maintenance-corridors_d_004
|
||||
- id: transit-worker_d_004
|
||||
text: "Late freight route runs long. Can't do anything about it."
|
||||
role: transit-worker
|
||||
access: [public]
|
||||
@@ -66,7 +66,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [tev, atmospheric]
|
||||
|
||||
- id: maintenance-corridors_d_005
|
||||
- id: transit-worker_d_005
|
||||
text: "Waiting for someone. Shouldn't be long."
|
||||
role: transit-worker
|
||||
access: [public]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dialogue: bar-owner at The Last Shift
|
||||
# NPC: Lera Osk (Tier 2, SYSTEM/OPERATOR)
|
||||
# NPC: Lera Sessik (Tier 2, ANCHOR/OPERATOR)
|
||||
# Voice: direct, dry. Runs her bar like a territory. Not political — protective.
|
||||
# Ring-aware: knows it exists in her bar. Doesn't ask. Doesn't interfere. Not complicit.
|
||||
# Torek arrangement: she gives him a tab specifically — Commission visibility is useful.
|
||||
@@ -13,7 +13,7 @@ lines:
|
||||
# GREETINGS — territorial welcome
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_100
|
||||
- id: bar-owner_d_001
|
||||
text: "Usual?"
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
@@ -23,7 +23,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, greeting, economy-of-words]
|
||||
|
||||
- id: the-last-shift_d_101
|
||||
- id: bar-owner_d_002
|
||||
text: "Long shift?"
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
@@ -33,7 +33,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, greeting]
|
||||
|
||||
- id: the-last-shift_d_102
|
||||
- id: bar-owner_d_003
|
||||
text: "Bar's clean, drink's cold. That's the deal."
|
||||
role: bar-owner
|
||||
access: [public]
|
||||
@@ -43,7 +43,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, greeting, house-rules]
|
||||
|
||||
- id: the-last-shift_d_103
|
||||
- id: bar-owner_d_004
|
||||
text: "Sess'll get you. I've got something in the back."
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
@@ -57,7 +57,7 @@ lines:
|
||||
# BAR MANAGEMENT — house rules, operations
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_104
|
||||
- id: bar-owner_d_005
|
||||
text: "Fights go outside. First and last warning."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
@@ -67,7 +67,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, house-rules, authority]
|
||||
|
||||
- id: the-last-shift_d_105
|
||||
- id: bar-owner_d_006
|
||||
text: "Tab closes at end of cycle. Not at end of your memory."
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
@@ -77,7 +77,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, tab, house-rules]
|
||||
|
||||
- id: the-last-shift_d_106
|
||||
- id: bar-owner_d_007
|
||||
text: "Torek's running a tab. That's fine. Don't ask me why."
|
||||
role: bar-owner
|
||||
access: [peer]
|
||||
@@ -91,7 +91,7 @@ lines:
|
||||
detective: "Bar owner specifically maintains a tab for a Commission officer. Useful relationship or leverage?"
|
||||
notes: "Lera's Torek arrangement: she keeps him comfortable because knowing where the Commission officer is drinking is better than not knowing."
|
||||
|
||||
- id: the-last-shift_d_107
|
||||
- id: bar-owner_d_008
|
||||
text: "Lattice stays off at the bar. Sign's on the wall."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
@@ -102,7 +102,7 @@ lines:
|
||||
tags: [lera, lattice-free, house-rules]
|
||||
notes: "Not legally enforceable — Lera knows this. It's about atmosphere, not compliance."
|
||||
|
||||
- id: the-last-shift_d_108
|
||||
- id: bar-owner_d_009
|
||||
text: "Grain spirit's back. Limited run. Don't make it last two nights."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
@@ -116,7 +116,7 @@ lines:
|
||||
# PEOPLE — Lera knows everyone
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_109
|
||||
- id: bar-owner_d_010
|
||||
text: "Kael's in the back. If you're looking."
|
||||
role: bar-owner
|
||||
access: [peer, insider]
|
||||
@@ -129,7 +129,7 @@ lines:
|
||||
smuggler: "Good. She keeps track. That's what she does."
|
||||
detective: "Lera knows where Kael Davan is without being asked. She tracks her regulars."
|
||||
|
||||
- id: the-last-shift_d_110
|
||||
- id: bar-owner_d_011
|
||||
text: "Naia's been in early the last few nights. Doesn't say much."
|
||||
role: bar-owner
|
||||
access: [peer]
|
||||
@@ -142,7 +142,7 @@ lines:
|
||||
smuggler: "Lera noticed Naia's off. She notices everything. She's saying it because she wants someone to do something."
|
||||
detective: "Bar owner volunteers behavioral change in Naia Tamm. Lera observes her regulars carefully."
|
||||
|
||||
- id: the-last-shift_d_111
|
||||
- id: bar-owner_d_012
|
||||
text: "Ren? Been here two months. Quiet, pays their tab. That's all I can tell you."
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
@@ -155,7 +155,7 @@ lines:
|
||||
smuggler: "That's Lera's version of a clean assessment. She'd say more if she suspected something."
|
||||
detective: "Lera's read on Ren: quiet, no trouble. If Lera's not worried, the baseline is probably accurate."
|
||||
|
||||
- id: the-last-shift_d_112
|
||||
- id: bar-owner_d_013
|
||||
text: "Tev? Good worker. Three years. Minds their own."
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
@@ -172,7 +172,7 @@ lines:
|
||||
# RING-ADJACENT — Lera's knowledge edge
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_113
|
||||
- id: bar-owner_d_014
|
||||
text: "Whatever's happening in the back corner isn't my business. Don't make it my business."
|
||||
role: bar-owner
|
||||
access: [insider]
|
||||
@@ -186,7 +186,7 @@ lines:
|
||||
detective: "Never hears this line."
|
||||
notes: "Lera's arrangement: she doesn't ask, she doesn't see, and she keeps things stable. Pragmatic, not complicit."
|
||||
|
||||
- id: the-last-shift_d_114
|
||||
- id: bar-owner_d_015
|
||||
text: "Kael's got my number. He's been a regular long enough that that means something."
|
||||
role: bar-owner
|
||||
access: [peer]
|
||||
@@ -203,7 +203,7 @@ lines:
|
||||
# DETECTIVE ENCOUNTER — neutral, unwelcoming
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_115
|
||||
- id: bar-owner_d_016
|
||||
text: "Commission's welcome. Like everyone else. Bar rules apply."
|
||||
role: bar-owner
|
||||
access: [authority, public]
|
||||
@@ -213,7 +213,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, detective-path, neutral-welcome]
|
||||
|
||||
- id: the-last-shift_d_116
|
||||
- id: bar-owner_d_017
|
||||
text: "If someone in my bar has something to say to Commission, they'll say it. I don't push that."
|
||||
role: bar-owner
|
||||
access: [authority]
|
||||
@@ -223,8 +223,8 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, detective-path, institutional-independence]
|
||||
|
||||
- id: the-last-shift_d_117
|
||||
text: "I've run this bar twelve years without a Commission complaint. I'd like to keep it that way."
|
||||
- id: bar-owner_d_018
|
||||
text: "I've run this bar eighteen years without a Commission complaint. I'd like to keep it that way."
|
||||
role: bar-owner
|
||||
access: [authority]
|
||||
trust: surface
|
||||
@@ -233,7 +233,7 @@ lines:
|
||||
mood: [suspicious]
|
||||
tags: [lera, detective-path, authority-assertion]
|
||||
|
||||
- id: the-last-shift_d_118
|
||||
- id: bar-owner_d_019
|
||||
text: "I see a lot. I don't always remember all of it."
|
||||
role: bar-owner
|
||||
access: [authority]
|
||||
@@ -250,7 +250,7 @@ lines:
|
||||
# ELEVATED TRUST — what Lera actually says
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_119
|
||||
- id: bar-owner_d_020
|
||||
text: "Naia's been asking questions I can't answer. Someone should."
|
||||
role: bar-owner
|
||||
access: [peer, insider]
|
||||
@@ -267,7 +267,7 @@ lines:
|
||||
# SIGN-OFF / CLOSING TIME
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_120
|
||||
- id: bar-owner_d_021
|
||||
text: "Last call. Finish up."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
@@ -277,7 +277,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, closing]
|
||||
|
||||
- id: the-last-shift_d_121
|
||||
- id: bar-owner_d_022
|
||||
text: "Bar's closed. Come back next cycle."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
@@ -287,7 +287,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, closing]
|
||||
|
||||
- id: the-last-shift_d_122
|
||||
- id: bar-owner_d_023
|
||||
text: "Good night. Stay out of trouble."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
@@ -301,7 +301,7 @@ lines:
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_123
|
||||
- id: bar-owner_d_024
|
||||
text: "You look tired. Sit."
|
||||
role: bar-owner
|
||||
access: [peer, insider]
|
||||
@@ -311,7 +311,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, greeting, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_124
|
||||
- id: bar-owner_d_025
|
||||
text: "Same as yesterday?"
|
||||
role: bar-owner
|
||||
access: [peer, insider]
|
||||
@@ -321,7 +321,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, greeting, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_125
|
||||
- id: bar-owner_d_026
|
||||
text: "New face. Bar rules are on the wall."
|
||||
role: bar-owner
|
||||
access: [public]
|
||||
@@ -331,7 +331,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, greeting, newcomer, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_126
|
||||
- id: bar-owner_d_027
|
||||
text: "No trouble tonight. I mean it."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
@@ -341,7 +341,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, house-rules, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_127
|
||||
- id: bar-owner_d_028
|
||||
text: "Sess'll get you settled. I'm in the back."
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
@@ -351,7 +351,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, redirect, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_128
|
||||
- id: bar-owner_d_029
|
||||
text: "We close at 2300. Don't make me say it twice."
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
@@ -361,7 +361,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, house-rules, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_129
|
||||
- id: bar-owner_d_030
|
||||
text: "Good crowd tonight. Quiet enough."
|
||||
role: bar-owner
|
||||
access: [peer, insider]
|
||||
@@ -371,7 +371,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [lera, ambient, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_130
|
||||
- id: bar-owner_d_031
|
||||
text: "Bar's yours. I've got accounts."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
|
||||
@@ -12,7 +12,7 @@ lines:
|
||||
# GREETINGS — familiar, community register
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_300
|
||||
- id: bar-regular_d_001
|
||||
text: "Long one today?"
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -22,7 +22,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [bar-regular, greeting]
|
||||
|
||||
- id: the-last-shift_d_301
|
||||
- id: bar-regular_d_002
|
||||
text: "You're in early. Good shift or bad shift?"
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -32,7 +32,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [bar-regular, greeting]
|
||||
|
||||
- id: the-last-shift_d_302
|
||||
- id: bar-regular_d_003
|
||||
text: "Grain spirit's back. I already had two. Don't judge."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -46,7 +46,7 @@ lines:
|
||||
# TERMINAL FLOOR — off-duty opinions
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_303
|
||||
- id: bar-regular_d_004
|
||||
text: "Push today. Bay six was backed up until 2000. Voss wasn't happy."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -56,7 +56,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [bar-regular, terminal, voss]
|
||||
|
||||
- id: the-last-shift_d_304
|
||||
- id: bar-regular_d_005
|
||||
text: "Maret flagged another one. Nobody knows what Voss does with them."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -69,7 +69,7 @@ lines:
|
||||
smuggler: "They're talking about Maret's flags as general knowledge. That's a wider problem than I thought."
|
||||
detective: "Maret's pattern is common knowledge on the floor. Multiple witnesses, informal. That can be confirmed."
|
||||
|
||||
- id: the-last-shift_d_305
|
||||
- id: bar-regular_d_006
|
||||
text: "Bay four's been held all week. Three cycles. Nobody's said what's in it."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -82,7 +82,7 @@ lines:
|
||||
smuggler: "They've noticed bay four. It's becoming visible. Not good."
|
||||
detective: "Bay four hold is common knowledge at worker level. That means it predates any routine explanation."
|
||||
|
||||
- id: the-last-shift_d_306
|
||||
- id: bar-regular_d_007
|
||||
text: "Transition window's the worst part of the job. Twenty minutes to hand off and nothing ever lines up."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -95,7 +95,7 @@ lines:
|
||||
smuggler: "The window is their biggest frustration. And their biggest frustration is the ring's biggest asset."
|
||||
detective: "Workers discuss the transition window as a systematic failure. The gap is known."
|
||||
|
||||
- id: the-last-shift_d_307
|
||||
- id: bar-regular_d_008
|
||||
text: "Commission audit's coming cycle fifteen. Everyone's running around double-checking everything."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -108,7 +108,7 @@ lines:
|
||||
smuggler: "Cycle fifteen. That's the deadline. Get clean before then."
|
||||
detective: "Workers are responding to the audit announcement. Behavioral change in the workforce. Note who isn't anxious."
|
||||
|
||||
- id: the-last-shift_d_308
|
||||
- id: bar-regular_d_009
|
||||
text: "Voss has been on the floor more than usual this cycle. Watching everything."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -125,7 +125,7 @@ lines:
|
||||
# COMMUNITY — the full life of this district
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_309
|
||||
- id: bar-regular_d_010
|
||||
text: "Drifters lost again. Four-nil this time. I'm done watching until they rebuild."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -135,7 +135,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [bar-regular, sports, drifters]
|
||||
|
||||
- id: the-last-shift_d_310
|
||||
- id: bar-regular_d_011
|
||||
text: "Ventilation's still the same in Sector 3. Seven years of 'under discussion.' I've stopped expecting anything."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -145,49 +145,49 @@ lines:
|
||||
mood: [content]
|
||||
tags: [bar-regular, infrastructure, grievance]
|
||||
|
||||
- id: the-last-shift_d_311
|
||||
- id: bar-regular_d_012
|
||||
text: "You see the missing person notice on the board? Vessels Tamm. I don't know a Vessels Tamm."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [community, routine]
|
||||
topic: [personal, routine]
|
||||
mood: [content]
|
||||
tags: [bar-regular, vessels-tamm, notice]
|
||||
dual_lens:
|
||||
smuggler: "Vessels Tamm. Naia's surname is Tamm. Don't chase it — but don't ignore it either."
|
||||
detective: "Vessels Tamm notice is circulating casually. People haven't connected it to Naia Tamm yet."
|
||||
|
||||
- id: the-last-shift_d_312
|
||||
- id: bar-regular_d_013
|
||||
text: "Re-em clinic's backed up four cycles for non-urgent. Main station if it's serious, apparently."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [community, routine]
|
||||
topic: [personal, routine]
|
||||
mood: [content]
|
||||
tags: [bar-regular, clinic, community]
|
||||
|
||||
- id: the-last-shift_d_313
|
||||
- id: bar-regular_d_014
|
||||
text: "Lattice upgrades are up eighteen percent this cycle. That's more than my raise."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, community]
|
||||
topic: [institution, personal]
|
||||
mood: [content]
|
||||
tags: [bar-regular, lattice, economy]
|
||||
dual_lens:
|
||||
smuggler: "That's why the waiting list keeps growing. They're providing a service."
|
||||
detective: "Economic grievance expressed casually. Price pressure is general knowledge. Confirms motive context."
|
||||
|
||||
- id: the-last-shift_d_314
|
||||
- id: bar-regular_d_015
|
||||
text: "Workers' Association is pushing the handoff protocol again. Voss won't budge."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, community]
|
||||
topic: [institution, personal]
|
||||
mood: [suspicious]
|
||||
tags: [bar-regular, workers-association, voss, transition-window]
|
||||
dual_lens:
|
||||
@@ -198,7 +198,7 @@ lines:
|
||||
# DETECTIVE ENCOUNTER — community wariness
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_315
|
||||
- id: bar-regular_d_016
|
||||
text: "You new? Haven't seen you before."
|
||||
role: bar-regular
|
||||
access: [public]
|
||||
@@ -208,7 +208,7 @@ lines:
|
||||
mood: [suspicious]
|
||||
tags: [bar-regular, detective-path, recognition]
|
||||
|
||||
- id: the-last-shift_d_316
|
||||
- id: bar-regular_d_017
|
||||
text: "Commission? I just drink here. Whatever you're after, I probably can't help."
|
||||
role: bar-regular
|
||||
access: [authority]
|
||||
@@ -218,7 +218,7 @@ lines:
|
||||
mood: [suspicious]
|
||||
tags: [bar-regular, detective-path, deflection]
|
||||
|
||||
- id: the-last-shift_d_317
|
||||
- id: bar-regular_d_018
|
||||
text: "Ask Lera. She knows what happens in this bar better than anyone."
|
||||
role: bar-regular
|
||||
access: [authority, public]
|
||||
@@ -232,7 +232,7 @@ lines:
|
||||
# SIGN-OFF
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_318
|
||||
- id: bar-regular_d_019
|
||||
text: "Another cycle done. Same tomorrow."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -242,7 +242,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [bar-regular, closing]
|
||||
|
||||
- id: the-last-shift_d_319
|
||||
- id: bar-regular_d_020
|
||||
text: "Safe home. Watch the corridors after 2200."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -256,7 +256,7 @@ lines:
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_320
|
||||
- id: bar-regular_d_021
|
||||
text: "Pull up a chair. Floor's full otherwise."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -266,7 +266,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [bar-regular, greeting, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_321
|
||||
- id: bar-regular_d_022
|
||||
text: "You're in late. Voss kept you?"
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -276,7 +276,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [bar-regular, greeting, voss, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_322
|
||||
- id: bar-regular_d_023
|
||||
text: "Just got off. Bay six all night. My back knows it."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -286,7 +286,7 @@ lines:
|
||||
mood: [frustrated]
|
||||
tags: [bar-regular, greeting, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_323
|
||||
- id: bar-regular_d_024
|
||||
text: "Drifters are playing Mirren next cycle. Home game. Might actually go."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -296,17 +296,17 @@ lines:
|
||||
mood: [content]
|
||||
tags: [bar-regular, sports, community, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_324
|
||||
- id: bar-regular_d_025
|
||||
text: "Sector 3 meeting went nowhere. Same as last time. Same as the time before that."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, community]
|
||||
topic: [institution, personal]
|
||||
mood: [content]
|
||||
tags: [bar-regular, infrastructure, grievance, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_325
|
||||
- id: bar-regular_d_026
|
||||
text: "New hire's asking questions on the floor again. Good. Someone should."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -316,7 +316,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [bar-regular, new-hire, community, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_326
|
||||
- id: bar-regular_d_027
|
||||
text: "Span gate was loud today. Three cycles now. That's not normal."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
@@ -326,7 +326,7 @@ lines:
|
||||
mood: [suspicious]
|
||||
tags: [bar-regular, infrastructure, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_327
|
||||
- id: bar-regular_d_028
|
||||
text: "Kael's not in tonight. Unusual."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -339,7 +339,7 @@ lines:
|
||||
smuggler: "Kael's absence is visible. Other people notice too."
|
||||
detective: "Kael Davan's routine absence is noted socially. Behavioral change is general knowledge."
|
||||
|
||||
- id: the-last-shift_d_328
|
||||
- id: bar-regular_d_029
|
||||
text: "Lera keeps the grain spirit back until she's sure you're a regular. You must've made the grade."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -349,7 +349,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [bar-regular, lera, humor, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_329
|
||||
- id: bar-regular_d_030
|
||||
text: "Good night. Don't let the Commission ruin your morning."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
|
||||
@@ -12,7 +12,7 @@ lines:
|
||||
# GREETINGS — service register
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_200
|
||||
- id: bartender_d_001
|
||||
text: "What'll it be?"
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
@@ -22,7 +22,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, greeting]
|
||||
|
||||
- id: the-last-shift_d_201
|
||||
- id: bartender_d_002
|
||||
text: "Back early tonight. Long shift?"
|
||||
role: bartender
|
||||
access: [public, peer]
|
||||
@@ -32,7 +32,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, greeting, social]
|
||||
|
||||
- id: the-last-shift_d_202
|
||||
- id: bartender_d_003
|
||||
text: "Grain spirit's back. Lera's running it at cycle price for two more days."
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
@@ -46,7 +46,7 @@ lines:
|
||||
# BAR OBSERVATIONS — social intelligence
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_203
|
||||
- id: bartender_d_004
|
||||
text: "Kael's been in most nights this week. Quiet though. Not his usual."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
@@ -59,7 +59,7 @@ lines:
|
||||
smuggler: "Sess noticed Kael's off. She's reading it as personal. She doesn't know what's underneath."
|
||||
detective: "Bartender flagging behavioral change in Kael Davan. Consistent with other observations."
|
||||
|
||||
- id: the-last-shift_d_204
|
||||
- id: bartender_d_005
|
||||
text: "Torek tipped well again tonight. Always does. Nicer than you'd expect for Commission."
|
||||
role: bartender
|
||||
access: [peer, public]
|
||||
@@ -72,7 +72,7 @@ lines:
|
||||
smuggler: "Torek's making friends with the staff. Not good."
|
||||
detective: "Bartender views Torek favorably. Torek is cultivating the service staff deliberately."
|
||||
|
||||
- id: the-last-shift_d_205
|
||||
- id: bartender_d_006
|
||||
text: "Naia was in earlier. Didn't stay. Left something on the table — looked like a note. Lera has it."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
@@ -85,7 +85,7 @@ lines:
|
||||
smuggler: "Naia left something. Lera has it. Go to Lera."
|
||||
detective: "Naia Tamm left written material at the bar. Lera is holding it. That's a material lead."
|
||||
|
||||
- id: the-last-shift_d_206
|
||||
- id: bartender_d_007
|
||||
text: "Ren? Yeah, they're always in the morning crowd. Don't say much. Good for the morning."
|
||||
role: bartender
|
||||
access: [public, peer]
|
||||
@@ -95,7 +95,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, ren, drifter]
|
||||
|
||||
- id: the-last-shift_d_207
|
||||
- id: bartender_d_008
|
||||
text: "Tev watches the door. Every shift. Lera likes it that way."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
@@ -108,7 +108,7 @@ lines:
|
||||
smuggler: "Tev watches the door for Lera. Not for anyone else. That's the setup."
|
||||
detective: "Tev's entrance-monitoring is confirmed as Lera's request. Not operational surveillance."
|
||||
|
||||
- id: the-last-shift_d_208
|
||||
- id: bartender_d_009
|
||||
text: "Sera was in earlier. She usually stays later. She left when Torek came in."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
@@ -125,7 +125,7 @@ lines:
|
||||
# SOCIAL / COMMUNITY
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_209
|
||||
- id: bartender_d_010
|
||||
text: "Drifters lost again. Tev took it hard. Drank two too many."
|
||||
role: bartender
|
||||
access: [public, peer]
|
||||
@@ -135,7 +135,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, tev, sports, community]
|
||||
|
||||
- id: the-last-shift_d_210
|
||||
- id: bartender_d_011
|
||||
text: "Workers' Association meeting's Thursday. Half the regulars will be here Friday in a mood either way."
|
||||
role: bartender
|
||||
access: [public, peer]
|
||||
@@ -145,7 +145,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, workers-association, community]
|
||||
|
||||
- id: the-last-shift_d_211
|
||||
- id: bartender_d_012
|
||||
text: "Quiet night. Good for everyone."
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
@@ -159,7 +159,7 @@ lines:
|
||||
# DETECTIVE ENCOUNTER — helpful, unfocused
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_212
|
||||
- id: bartender_d_013
|
||||
text: "Commission comes in sometimes. Not usually this early in the cycle."
|
||||
role: bartender
|
||||
access: [authority, public]
|
||||
@@ -169,7 +169,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, detective-path, observation]
|
||||
|
||||
- id: the-last-shift_d_213
|
||||
- id: bartender_d_014
|
||||
text: "I know most of the regulars. Ask me anything. I probably know something."
|
||||
role: bartender
|
||||
access: [authority, public]
|
||||
@@ -182,7 +182,7 @@ lines:
|
||||
smuggler: "Never encounters this line."
|
||||
detective: "Bartender is cooperative. Useful — but she'll answer questions she doesn't know are questions."
|
||||
|
||||
- id: the-last-shift_d_214
|
||||
- id: bartender_d_015
|
||||
text: "Lera's the one who knows the real history of this place. I've only been here four cycles."
|
||||
role: bartender
|
||||
access: [authority, public]
|
||||
@@ -196,7 +196,7 @@ lines:
|
||||
# SIGN-OFF
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_215
|
||||
- id: bartender_d_016
|
||||
text: "Last round's on the board. After that, Lera calls it."
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
@@ -206,7 +206,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, closing]
|
||||
|
||||
- id: the-last-shift_d_216
|
||||
- id: bartender_d_017
|
||||
text: "Good night. Safe walk."
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
@@ -220,7 +220,7 @@ lines:
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_217
|
||||
- id: bartender_d_018
|
||||
text: "The usual? Or trying something different tonight?"
|
||||
role: bartender
|
||||
access: [peer, insider]
|
||||
@@ -230,7 +230,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, greeting, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_218
|
||||
- id: bartender_d_019
|
||||
text: "Busy one tonight. Give me a second."
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
@@ -240,7 +240,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, greeting, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_219
|
||||
- id: bartender_d_020
|
||||
text: "Good shift or bad shift? Your face says one thing, your order'll confirm it."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
@@ -250,7 +250,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, greeting, social, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_220
|
||||
- id: bartender_d_021
|
||||
text: "Lera says the specials board is final. Don't ask me to substitute."
|
||||
role: bartender
|
||||
access: [public, peer]
|
||||
@@ -260,7 +260,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, lera, house-rules, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_221
|
||||
- id: bartender_d_022
|
||||
text: "Torek's in his usual spot. Corner table. Second drink already."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
@@ -270,7 +270,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, torek, observation, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_222
|
||||
- id: bartender_d_023
|
||||
text: "Ren's been here since this morning. Still on the same drink."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
@@ -280,7 +280,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, ren, observation, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_223
|
||||
- id: bartender_d_024
|
||||
text: "Drifters game is on the Meridian relay tonight. If anyone's got a feed, that'll be the crowd."
|
||||
role: bartender
|
||||
access: [public, peer]
|
||||
@@ -290,7 +290,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, sports, community, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_224
|
||||
- id: bartender_d_025
|
||||
text: "Slow night for a change. Enjoy it."
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
@@ -300,7 +300,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [sess, ambient, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_225
|
||||
- id: bartender_d_026
|
||||
text: "Tab's cleared. Lera'll be pleased."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Dialogue: day-worker at The Last Shift
|
||||
# NPC: Ren Tosse (Tier 3, NOBODY/CIVILIAN)
|
||||
# Voice: casual-observant, asks questions without social friction. Just drifting.
|
||||
# KEY LINE: the-last-shift_d_001 — sounds like intel gathering on night operations
|
||||
# KEY LINE: day-worker_d_001 — sounds like intel gathering on night operations
|
||||
# Actually: asking about after-hours freight work availability
|
||||
# Ticket: #307 | Sprint: 12
|
||||
|
||||
@@ -15,7 +15,7 @@ lines:
|
||||
# Actually: asking if there's paid after-hours freight work available.
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_001
|
||||
- id: day-worker_d_001
|
||||
text: "Night rotation still short-handed? Heard the after-hours freight pays decent."
|
||||
role: day-worker
|
||||
access: [public]
|
||||
@@ -36,7 +36,7 @@ lines:
|
||||
# ROUTINE LINES
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_002
|
||||
- id: day-worker_d_002
|
||||
text: "Bay seven any better this cycle? The super was a problem last time."
|
||||
role: day-worker
|
||||
access: [public]
|
||||
@@ -46,7 +46,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [ren, job-seeking]
|
||||
|
||||
- id: the-last-shift_d_003
|
||||
- id: day-worker_d_003
|
||||
text: "Sova's decent. Station layout makes sense once you learn it."
|
||||
role: day-worker
|
||||
access: [public]
|
||||
@@ -56,7 +56,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [ren, atmospheric]
|
||||
|
||||
- id: the-last-shift_d_004
|
||||
- id: day-worker_d_004
|
||||
text: "Just passing through. Maybe not. Depends on the work."
|
||||
role: day-worker
|
||||
access: [public]
|
||||
@@ -66,7 +66,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [ren, drifter]
|
||||
|
||||
- id: the-last-shift_d_005
|
||||
- id: day-worker_d_005
|
||||
text: "Cheap drink here's better than most stations I've stopped at."
|
||||
role: day-worker
|
||||
access: [public]
|
||||
@@ -76,7 +76,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [ren, atmospheric]
|
||||
|
||||
- id: the-last-shift_d_006
|
||||
- id: day-worker_d_006
|
||||
text: "Any idea when the manifest deadline runs? Trying to time when the work window opens."
|
||||
role: day-worker
|
||||
access: [public]
|
||||
|
||||
@@ -12,7 +12,7 @@ lines:
|
||||
# SOCIAL — off-shift casual
|
||||
# ========================================
|
||||
|
||||
- id: the-last-shift_d_001
|
||||
- id: kael-davan_d_015
|
||||
text: "Saved you a seat. Lera's got the spiced rice tonight."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -22,7 +22,7 @@ lines:
|
||||
topic: [personal, colleague]
|
||||
tags: [kael, greeting, phase-1]
|
||||
|
||||
- id: the-last-shift_d_002
|
||||
- id: kael-davan_d_016
|
||||
text: "First round's mine. Don't argue — you covered me last week."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -32,7 +32,7 @@ lines:
|
||||
topic: [personal]
|
||||
tags: [kael, social, phase-1]
|
||||
|
||||
- id: the-last-shift_d_003
|
||||
- id: kael-davan_d_017
|
||||
text: "Voss tried to dock my overtime again. Lera told him off. Wish I'd seen it."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -42,7 +42,7 @@ lines:
|
||||
topic: [colleague, routine]
|
||||
tags: [kael, humor, voss, lera]
|
||||
|
||||
- id: the-last-shift_d_004
|
||||
- id: kael-davan_d_018
|
||||
text: "Torek's holding court at the bar again. How does he afford to drink like that?"
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -55,7 +55,7 @@ lines:
|
||||
fact_id: investigation.torek_spending_pattern
|
||||
confidence: suspects
|
||||
|
||||
- id: the-last-shift_d_005
|
||||
- id: kael-davan_d_019
|
||||
text: "Quiet night. I like these ones. No drama, just people winding down."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
@@ -65,7 +65,7 @@ lines:
|
||||
topic: [personal]
|
||||
tags: [kael, atmospheric]
|
||||
|
||||
- id: the-last-shift_d_006
|
||||
- id: kael-davan_d_020
|
||||
text: "Lera asked about my overtime schedule. Told her I'm trying to cut back."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -79,7 +79,7 @@ lines:
|
||||
# NAIA REFERENCES — personal, unprompted
|
||||
# ========================================
|
||||
|
||||
- id: the-last-shift_d_007
|
||||
- id: kael-davan_d_021
|
||||
text: "Naia's meeting me here after her shift. Try not to embarrass me."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -89,7 +89,7 @@ lines:
|
||||
topic: [personal]
|
||||
tags: [kael, unprompted, naia-reference]
|
||||
|
||||
- id: the-last-shift_d_008
|
||||
- id: kael-davan_d_022
|
||||
text: "Naia wants to plan a trip. Off-station. Somewhere with actual sky. Sounds expensive."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -99,7 +99,7 @@ lines:
|
||||
topic: [personal]
|
||||
tags: [kael, unprompted, naia-reference]
|
||||
|
||||
- id: the-last-shift_d_009
|
||||
- id: kael-davan_d_023
|
||||
text: "She worries. I tell her it's just dock work. She doesn't buy it anymore."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -113,7 +113,7 @@ lines:
|
||||
# RING OPS — bar context, trust-gated
|
||||
# ========================================
|
||||
|
||||
- id: the-last-shift_d_010
|
||||
- id: kael-davan_d_024
|
||||
text: "Nils wants to talk. Tomorrow, bay side. Said it's about volume."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -123,7 +123,7 @@ lines:
|
||||
topic: [danger]
|
||||
tags: [kael, ring-ops, nils]
|
||||
|
||||
- id: the-last-shift_d_011
|
||||
- id: kael-davan_d_025
|
||||
text: "Keep it light tonight. Torek's listening and he doesn't know when to stop."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -133,7 +133,7 @@ lines:
|
||||
topic: [danger, colleague]
|
||||
tags: [kael, ring-ops, torek, caution]
|
||||
|
||||
- id: the-last-shift_d_012
|
||||
- id: kael-davan_d_026
|
||||
text: "Lera knows more than she lets on. She won't say anything — but don't test it."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -150,7 +150,7 @@ lines:
|
||||
# POST-CONTRADICTION — at the bar, damaged trust
|
||||
# ========================================
|
||||
|
||||
- id: the-last-shift_d_013
|
||||
- id: kael-davan_d_027
|
||||
text: "I saved your seat. ...Force of habit."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -160,7 +160,7 @@ lines:
|
||||
topic: [personal]
|
||||
tags: [kael, contaminated-trust, phase-5]
|
||||
|
||||
- id: the-last-shift_d_014
|
||||
- id: kael-davan_d_028
|
||||
text: "Naia keeps asking why I'm quiet. I told her work stress. Not wrong."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -170,7 +170,7 @@ lines:
|
||||
topic: [personal]
|
||||
tags: [kael, contaminated-trust, naia-reference, phase-5]
|
||||
|
||||
- id: the-last-shift_d_015
|
||||
- id: kael-davan_d_029
|
||||
text: "We're good, right? You and me. We're still good."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -187,7 +187,7 @@ lines:
|
||||
# ========================================
|
||||
|
||||
# first_meeting: InteractionMemory.count == 0. RelationshipState: Unknown → Public access only.
|
||||
- id: the-last-shift_d_016
|
||||
- id: kael-davan_d_030
|
||||
text: "Evening. Friend of someone's, or first time at Lera's?"
|
||||
role: dock-worker
|
||||
access: [public]
|
||||
@@ -199,7 +199,7 @@ lines:
|
||||
notes: "Layer 2 greeting — first meeting at the bar. Kael is curious about a stranger but not suspicious. Off-shift, looser than at the terminal."
|
||||
|
||||
# established — peer/smuggler context: count >= 3, RelationshipState: Known/Friendly → Peer/Insider access.
|
||||
- id: the-last-shift_d_017
|
||||
- id: kael-davan_d_031
|
||||
text: "You made it. I was starting to think something came up."
|
||||
role: dock-worker
|
||||
access: [peer, insider]
|
||||
@@ -211,7 +211,7 @@ lines:
|
||||
notes: "Layer 2 greeting — established, bar context. Warm, they were expected. Kael was watching for them."
|
||||
|
||||
# established — authority/detective context: count >= 3, RelationshipState: PersonOfInterest → Authority access.
|
||||
- id: the-last-shift_d_018
|
||||
- id: kael-davan_d_032
|
||||
text: "Still in the district. Thought you'd be done by now."
|
||||
role: dock-worker
|
||||
access: [authority]
|
||||
@@ -223,7 +223,7 @@ lines:
|
||||
notes: "Layer 2 greeting — repeat with authority figure, bar context. Politely questioning their continued presence. The bar is supposed to be off-limits from work."
|
||||
|
||||
# post-confrontation: confrontation in InteractionMemory.notable_events. NPC mood shifted to suspicious/frustrated.
|
||||
- id: the-last-shift_d_019
|
||||
- id: kael-davan_d_033
|
||||
text: "You came back."
|
||||
role: dock-worker
|
||||
access: [peer, insider]
|
||||
|
||||
@@ -9,7 +9,7 @@ location: the-last-shift
|
||||
role: commission-liaison
|
||||
lines:
|
||||
|
||||
- id: the-last-shift_d_204
|
||||
- id: pc-detective_d_001
|
||||
text: "Good evening. Off the clock, more or less. Enjoying the local hospitality."
|
||||
role: commission-liaison
|
||||
access: [public]
|
||||
@@ -19,7 +19,7 @@ lines:
|
||||
topic: [personal]
|
||||
tags: [pc-detective, greeting, public-tier]
|
||||
|
||||
- id: the-last-shift_d_205
|
||||
- id: pc-detective_d_002
|
||||
text: "Ms. Venn recommended the ale. She was right. This district has its charms."
|
||||
role: commission-liaison
|
||||
access: [public]
|
||||
@@ -28,7 +28,7 @@ lines:
|
||||
topic: [routine, colleague]
|
||||
tags: [pc-detective, social, public-tier, sera]
|
||||
|
||||
- id: the-last-shift_d_206
|
||||
- id: pc-detective_d_003
|
||||
text: "Just unwinding. The review can wait until morning. Have a good evening."
|
||||
role: commission-liaison
|
||||
access: [public]
|
||||
|
||||
@@ -9,7 +9,7 @@ location: the-last-shift
|
||||
role: dock-worker
|
||||
lines:
|
||||
|
||||
- id: the-last-shift_d_201
|
||||
- id: pc-smuggler_d_001
|
||||
text: "Evening. Just having a drink with friends. Can I help you?"
|
||||
role: dock-worker
|
||||
access: [public]
|
||||
@@ -19,7 +19,7 @@ lines:
|
||||
topic: [personal]
|
||||
tags: [pc-smuggler, greeting, public-tier]
|
||||
|
||||
- id: the-last-shift_d_202
|
||||
- id: pc-smuggler_d_002
|
||||
text: "Lera's ale is the one good thing about a ten-hour shift. Don't let anyone tell you different."
|
||||
role: dock-worker
|
||||
access: [public]
|
||||
@@ -29,7 +29,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [pc-smuggler, social, public-tier]
|
||||
|
||||
- id: the-last-shift_d_203
|
||||
- id: pc-smuggler_d_003
|
||||
text: "Off the clock, thanks. Whatever it is, it can wait until morning."
|
||||
role: dock-worker
|
||||
access: [authority]
|
||||
|
||||
@@ -38,7 +38,7 @@ lines:
|
||||
# Phase 3-5: same warmth, but the detective is now watching
|
||||
# ============================================================
|
||||
|
||||
- id: the-last-shift_d_101
|
||||
- id: sera-venn_d_001
|
||||
text: "There you are. Grab a seat — Lera's doing that thing with the grain spirit again. Don't ask, just trust me and order the ale."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -48,7 +48,7 @@ lines:
|
||||
mood: [warm, content]
|
||||
tags: [greeting, phase-1, establishes-warmth]
|
||||
|
||||
- id: the-last-shift_d_102
|
||||
- id: sera-venn_d_002
|
||||
text: "Corner booth's free. I saved it — force of habit."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -58,7 +58,7 @@ lines:
|
||||
mood: [warm]
|
||||
tags: [greeting, phase-1, spatial-anchor]
|
||||
|
||||
- id: the-last-shift_d_103
|
||||
- id: sera-venn_d_003
|
||||
text: "You look like you've been reading manifests all day. Sit. I'll get you something that isn't recycled air."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -68,7 +68,7 @@ lines:
|
||||
mood: [warm, content]
|
||||
tags: [greeting, phase-1-2]
|
||||
|
||||
- id: the-last-shift_d_104
|
||||
- id: sera-venn_d_004
|
||||
text: "Evening. Same booth, same drink?"
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -78,7 +78,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [greeting, phase-2-5, repeatable]
|
||||
|
||||
- id: the-last-shift_d_105
|
||||
- id: sera-venn_d_005
|
||||
text: "Good timing. Naia was here earlier — you just missed her."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -88,7 +88,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [greeting, phase-2, naia-reference]
|
||||
|
||||
- id: the-last-shift_d_106
|
||||
- id: sera-venn_d_006
|
||||
text: "Grab a seat. Quiet night so far — Lera's in a good mood, which means the ale's fresh."
|
||||
role: bar-regular
|
||||
access: [peer, public]
|
||||
@@ -98,7 +98,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [greeting, phase-1-3]
|
||||
|
||||
- id: the-last-shift_d_107
|
||||
- id: sera-venn_d_007
|
||||
text: "Hey. Long day?"
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -108,7 +108,7 @@ lines:
|
||||
mood: [frustrated]
|
||||
tags: [greeting, phase-3-5, post-pattern, shorter]
|
||||
|
||||
- id: the-last-shift_d_108
|
||||
- id: sera-venn_d_008
|
||||
text: "Same time, same place. You're becoming a regular."
|
||||
role: bar-regular
|
||||
access: [peer, public]
|
||||
@@ -123,7 +123,7 @@ lines:
|
||||
# What Sera says during normal bar evenings
|
||||
# ============================================================
|
||||
|
||||
- id: the-last-shift_d_110
|
||||
- id: sera-venn_d_009
|
||||
text: "The Last Shift. Official name. Everyone calls it Lera's, though. Eighteen years behind that counter."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -133,7 +133,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [orientation, district-background]
|
||||
|
||||
- id: the-last-shift_d_111
|
||||
- id: sera-venn_d_010
|
||||
text: "See the card game in the corner? That's Harek's table. Security officer, off-duty. Harmless — just loud."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -143,7 +143,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [orientation, npc-introduction]
|
||||
|
||||
- id: the-last-shift_d_112
|
||||
- id: sera-venn_d_011
|
||||
text: "Most of the after-shift crowd comes from the Terminal. Dock workers, scheduler types. They drink hard and leave early."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -153,7 +153,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [orientation, district-background]
|
||||
|
||||
- id: the-last-shift_d_113
|
||||
- id: sera-venn_d_012
|
||||
text: "Sova's not bad, once you know the rhythms. Shift change is when things move. Between shifts is when you can breathe."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -163,7 +163,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [setting-atmosphere]
|
||||
|
||||
- id: the-last-shift_d_114
|
||||
- id: sera-venn_d_013
|
||||
text: "I do lattice calibration, mostly. Commission-standard equipment checks. Relay nodes, terminal diagnostics. Exciting stuff."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -173,7 +173,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [self-introduction, institutional-background]
|
||||
|
||||
- id: the-last-shift_d_115
|
||||
- id: sera-venn_d_014
|
||||
text: "I transferred here fourteen months ago. Border-system relay posting before this. Sova was supposed to be the quiet one."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -183,7 +183,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [backstory, phase-1]
|
||||
|
||||
- id: the-last-shift_d_116
|
||||
- id: sera-venn_d_015
|
||||
text: "Lera remembers every regular's order. Eighteen years of that. Don't ask how she does it — she'll just pour you one and prove it."
|
||||
role: bar-regular
|
||||
access: [peer, public]
|
||||
@@ -193,7 +193,7 @@ lines:
|
||||
mood: [warm]
|
||||
tags: [community-texture]
|
||||
|
||||
- id: the-last-shift_d_117
|
||||
- id: sera-venn_d_016
|
||||
text: "The shift crowd clears out by 19:00. After that it's just us — regulars, a few stragglers. The good hours."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -203,7 +203,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [setting-atmosphere, routine]
|
||||
|
||||
- id: the-last-shift_d_118
|
||||
- id: sera-venn_d_017
|
||||
text: "You adapting to station gravity yet? Point-nine-three standard takes a week to stop noticing."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -213,7 +213,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [setting-detail, phase-1]
|
||||
|
||||
- id: the-last-shift_d_119
|
||||
- id: sera-venn_d_018
|
||||
text: "I like this booth. Corner. You can see the door and the bar. Force of institutional habit, I suppose."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -232,7 +232,7 @@ lines:
|
||||
|
||||
# --- Surface trust ---
|
||||
|
||||
- id: the-last-shift_d_120
|
||||
- id: sera-venn_d_019
|
||||
text: "The Commission runs maintenance checks quarterly. I handle the field side — equipment calibration, relay diagnostics. Standard."
|
||||
role: bar-regular
|
||||
access: [peer, authority]
|
||||
@@ -242,7 +242,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [institutional-background]
|
||||
|
||||
- id: the-last-shift_d_121
|
||||
- id: sera-venn_d_020
|
||||
text: "Naia teaches the station kids. Primary education, mostly. She's good at it — patient. The kids adore her."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -252,7 +252,7 @@ lines:
|
||||
mood: [warm]
|
||||
tags: [naia-reference, phase-2]
|
||||
|
||||
- id: the-last-shift_d_122
|
||||
- id: sera-venn_d_021
|
||||
text: "The district runs on freight. Terminal handles most of it — container processing, manifest filing. Bread and butter."
|
||||
role: bar-regular
|
||||
access: [peer, public]
|
||||
@@ -262,7 +262,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [district-context]
|
||||
|
||||
- id: the-last-shift_d_123
|
||||
- id: sera-venn_d_022
|
||||
text: "I don't know the dock workers well. I'm Commission, they're logistics. Different worlds, same station."
|
||||
role: bar-regular
|
||||
access: [peer, authority]
|
||||
@@ -272,7 +272,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [deflection-surface, distance-from-hub]
|
||||
|
||||
- id: the-last-shift_d_124
|
||||
- id: sera-venn_d_023
|
||||
text: "Most people here are decent. Working shifts, paying bills, coming to Lera's. It's a community."
|
||||
role: bar-regular
|
||||
access: [peer, public]
|
||||
@@ -282,7 +282,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [community-framing]
|
||||
|
||||
- id: the-last-shift_d_125
|
||||
- id: sera-venn_d_024
|
||||
text: "I can look things up if you need. Commission terminals access compliance records, shift logs, equipment histories. Part of the job."
|
||||
role: bar-regular
|
||||
access: [peer, authority]
|
||||
@@ -294,7 +294,7 @@ lines:
|
||||
|
||||
# --- Real trust ---
|
||||
|
||||
- id: the-last-shift_d_130
|
||||
- id: sera-venn_d_025
|
||||
text: "Naia's been worried lately. About Kael — her partner. He's a dock worker at the Terminal. Late nights, evasive answers. She confided in me."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -307,7 +307,7 @@ lines:
|
||||
fact_id: "relationship.kael_naia_connection"
|
||||
confidence: "knows_of"
|
||||
|
||||
- id: the-last-shift_d_131
|
||||
- id: sera-venn_d_026
|
||||
text: "The Commission kiosk on Level 3 logs every compliance check. If someone ran an unauthorized scan, it would show up there. Hypothetically."
|
||||
role: bar-regular
|
||||
access: [peer, authority]
|
||||
@@ -317,7 +317,7 @@ lines:
|
||||
mood: [focused]
|
||||
tags: [institutional-access, phase-2-3, self-incriminating-hint]
|
||||
|
||||
- id: the-last-shift_d_132
|
||||
- id: sera-venn_d_027
|
||||
text: "I worry about Naia. She doesn't deserve whatever's happening. She just wants Kael to come home at a reasonable hour."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -327,7 +327,7 @@ lines:
|
||||
mood: [frustrated, anxious]
|
||||
tags: [motivation-reveal, phase-2-3]
|
||||
|
||||
- id: the-last-shift_d_133
|
||||
- id: sera-venn_d_028
|
||||
text: "Compliance data goes back eighteen months on the Commission systems. Anyone with field-tech access could cross-reference shift records with manifest filings."
|
||||
role: bar-regular
|
||||
access: [peer, authority]
|
||||
@@ -340,7 +340,7 @@ lines:
|
||||
fact_id: "investigation.manifest_discrepancy"
|
||||
confidence: "suspects"
|
||||
|
||||
- id: the-last-shift_d_134
|
||||
- id: sera-venn_d_029
|
||||
text: "I'm not an investigator. I calibrate equipment and file reports. But I notice things. Patterns in the data, mostly."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -350,7 +350,7 @@ lines:
|
||||
mood: [focused]
|
||||
tags: [self-awareness, capability-reveal]
|
||||
|
||||
- id: the-last-shift_d_135
|
||||
- id: sera-venn_d_030
|
||||
text: "You know me. I wouldn't sit on something if I thought it mattered. But not everything that looks irregular IS irregular."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -360,7 +360,7 @@ lines:
|
||||
mood: [anxious]
|
||||
tags: [deflection-real-tier, phase-4, the-lie]
|
||||
|
||||
- id: the-last-shift_d_136
|
||||
- id: sera-venn_d_031
|
||||
text: "Some of the regulars here have hard lives. Grey economy, tight margins. I'm not judging. I'm just a field tech who likes the ale."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -372,7 +372,7 @@ lines:
|
||||
|
||||
# --- Secret trust ---
|
||||
|
||||
- id: the-last-shift_d_140
|
||||
- id: sera-venn_d_032
|
||||
text: "I ran a compliance check. Unauthorized. Six weeks ago. Naia was so worried about Kael — I thought I could help. I found manifest discrepancies tied to his shifts."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -385,7 +385,7 @@ lines:
|
||||
fact_id: "investigation.manifest_discrepancy"
|
||||
confidence: "knows_details"
|
||||
|
||||
- id: the-last-shift_d_141
|
||||
- id: sera-venn_d_033
|
||||
text: "If I report it, Naia's life falls apart. Kael gets investigated. Maybe arrested. She didn't ask me to find this. I did it on my own."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -395,7 +395,7 @@ lines:
|
||||
mood: [anxious]
|
||||
tags: [motivation-confession, phase-5]
|
||||
|
||||
- id: the-last-shift_d_142
|
||||
- id: sera-venn_d_034
|
||||
text: "The irregularities correlate with Torek Lintar's inspection shifts. That's why I can't — I can't be near him. He doesn't know I know."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -408,7 +408,7 @@ lines:
|
||||
fact_id: "investigation.shift_mismatch"
|
||||
confidence: "knows_details"
|
||||
|
||||
- id: the-last-shift_d_143
|
||||
- id: sera-venn_d_035
|
||||
text: "I don't know what you're looking for. I told you everything I know. If you're asking whether I'm hiding something — I'm a field tech, not an analyst."
|
||||
role: bar-regular
|
||||
access: [peer, authority]
|
||||
@@ -418,7 +418,7 @@ lines:
|
||||
mood: [suspicious, anxious]
|
||||
tags: [denial-under-pressure, phase-4-5, the-wall]
|
||||
|
||||
- id: the-last-shift_d_144
|
||||
- id: sera-venn_d_036
|
||||
text: "Every tip I gave you was real. The district background, the shift patterns, the regulars. I didn't steer you wrong. I just... didn't give you everything."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -434,7 +434,7 @@ lines:
|
||||
# Phase 1-2: genuinely helpful. Phase 3-5: overcompensation.
|
||||
# ============================================================
|
||||
|
||||
- id: the-last-shift_d_150
|
||||
- id: sera-venn_d_037
|
||||
text: "Oh — before I forget. The dock scheduler, Maret? She's sharp. If you need freight records context, she's the one to ask."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -447,7 +447,7 @@ lines:
|
||||
fact_id: "relationship.trust_network"
|
||||
confidence: "suspects"
|
||||
|
||||
- id: the-last-shift_d_151
|
||||
- id: sera-venn_d_038
|
||||
text: "I checked the relay calibration logs for the Terminal this morning. Nothing flagged. But the data throughput was higher than standard — more manifests processed than usual."
|
||||
role: bar-regular
|
||||
access: [peer, authority]
|
||||
@@ -457,7 +457,7 @@ lines:
|
||||
mood: [focused]
|
||||
tags: [unprompted, overcompensation, phase-3-4]
|
||||
|
||||
- id: the-last-shift_d_152
|
||||
- id: sera-venn_d_039
|
||||
text: "I asked around — quietly — about the shift rotation schedule. Voss has been adjusting it more than usual this cycle. Might mean nothing."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -467,7 +467,7 @@ lines:
|
||||
mood: [focused, frustrated]
|
||||
tags: [unprompted, overcompensation, phase-3-4]
|
||||
|
||||
- id: the-last-shift_d_153
|
||||
- id: sera-venn_d_040
|
||||
text: "The maintenance corridor access logs are Commission-maintained. I can pull them if you need. Just say the word."
|
||||
role: bar-regular
|
||||
access: [peer, authority]
|
||||
@@ -477,7 +477,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [unprompted, information-offer, overcompensation, phase-3-5]
|
||||
|
||||
- id: the-last-shift_d_154
|
||||
- id: sera-venn_d_041
|
||||
text: "Naia mentioned Kael came home late again last night. Third time this week. She's trying not to make it a thing."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -490,7 +490,7 @@ lines:
|
||||
fact_id: "relationship.kael_naia_connection"
|
||||
confidence: "knows_details"
|
||||
|
||||
- id: the-last-shift_d_155
|
||||
- id: sera-venn_d_042
|
||||
text: "You should know — Lera watches out for her regulars. If people have been asking about you, she'd know. She just won't volunteer it."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -505,7 +505,7 @@ lines:
|
||||
# Phase 3-4: avoidance excuses, topic deflection, controlled evasion
|
||||
# ============================================================
|
||||
|
||||
- id: the-last-shift_d_160
|
||||
- id: sera-venn_d_043
|
||||
text: "Actually, I should head out. Early shift tomorrow — calibration rounds start at 07:30."
|
||||
role: bar-regular
|
||||
access: [peer, public]
|
||||
@@ -515,7 +515,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [avoidance-excuse-1, torek-trigger, phase-3]
|
||||
|
||||
- id: the-last-shift_d_161
|
||||
- id: sera-venn_d_044
|
||||
text: "Sorry — headache coming on. Station air does that sometimes. I'll catch you tomorrow."
|
||||
role: bar-regular
|
||||
access: [peer, public]
|
||||
@@ -525,7 +525,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [avoidance-excuse-2, torek-trigger, phase-3]
|
||||
|
||||
- id: the-last-shift_d_162
|
||||
- id: sera-venn_d_045
|
||||
text: "I need to check something at the Commission kiosk. Forgot to file a diagnostic report. I'll be quick — or not. You know how the system is."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -535,7 +535,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [avoidance-excuse-3, torek-trigger, phase-3]
|
||||
|
||||
- id: the-last-shift_d_163
|
||||
- id: sera-venn_d_046
|
||||
text: "Torek? He's just loud. I don't love loud drunks. Nothing personal — I just like a quieter evening."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -545,7 +545,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [torek-deflection, phase-4, the-deflection]
|
||||
|
||||
- id: the-last-shift_d_164
|
||||
- id: sera-venn_d_047
|
||||
text: "Manifests? I wouldn't know — I'm on the equipment side, not the freight side. Different department entirely."
|
||||
role: bar-regular
|
||||
access: [peer, authority]
|
||||
@@ -555,7 +555,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [topic-deflection, cargo-avoidance, phase-3-5]
|
||||
|
||||
- id: the-last-shift_d_165
|
||||
- id: sera-venn_d_048
|
||||
text: "Anyway — did you try the ale? Lera got a new supply run in. Much better than last week's batch."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -565,7 +565,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [redirect, warmth-recovery, phase-4]
|
||||
|
||||
- id: the-last-shift_d_166
|
||||
- id: sera-venn_d_049
|
||||
text: "I'm just a field tech. I maintain equipment. I don't track people or shipments — that's your department."
|
||||
role: bar-regular
|
||||
access: [peer, authority]
|
||||
@@ -575,7 +575,7 @@ lines:
|
||||
mood: [suspicious]
|
||||
tags: [deflection-firm, phase-4-5, the-wall]
|
||||
|
||||
- id: the-last-shift_d_167
|
||||
- id: sera-venn_d_050
|
||||
text: "Let's talk about something else. How's the investigation going — generally? Making progress?"
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -591,7 +591,7 @@ lines:
|
||||
# Same surface warmth, but calibrated. Trust is contaminated.
|
||||
# ============================================================
|
||||
|
||||
- id: the-last-shift_d_170
|
||||
- id: sera-venn_d_051
|
||||
text: "Same booth. Drink's on me tonight."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -601,7 +601,7 @@ lines:
|
||||
mood: [warm]
|
||||
tags: [post-discovery, surface-warmth, phase-5]
|
||||
|
||||
- id: the-last-shift_d_171
|
||||
- id: sera-venn_d_052
|
||||
text: "I pulled the relay diagnostic data you asked about. Clean. No anomalies in the terminal equipment this quarter."
|
||||
role: bar-regular
|
||||
access: [peer, authority]
|
||||
@@ -611,7 +611,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [post-discovery, overcompensation, phase-5]
|
||||
|
||||
- id: the-last-shift_d_172
|
||||
- id: sera-venn_d_053
|
||||
text: "You've been quiet tonight. Everything all right with the case?"
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -621,7 +621,7 @@ lines:
|
||||
mood: [frustrated]
|
||||
tags: [post-discovery, awareness, phase-5]
|
||||
|
||||
- id: the-last-shift_d_173
|
||||
- id: sera-venn_d_054
|
||||
text: "I know you're good at what you do. That's why I'm glad you're here. This district needed someone thorough."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -631,7 +631,7 @@ lines:
|
||||
mood: [warm, anxious]
|
||||
tags: [post-discovery, genuine-but-contaminated, phase-5]
|
||||
|
||||
- id: the-last-shift_d_174
|
||||
- id: sera-venn_d_055
|
||||
text: "If there's anything I can help with — anything at all — you just ask. You know that."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -650,7 +650,7 @@ lines:
|
||||
|
||||
# first_meeting (detective path): RelationshipState Unknown → Public/Authority access.
|
||||
# Sera clocks them as Commission immediately. Makes them welcome anyway.
|
||||
- id: the-last-shift_d_175
|
||||
- id: sera-venn_d_056
|
||||
text: "Commission? Don't worry, I don't bite. I'm Sera. Corner booth's better — you can see the whole room."
|
||||
role: bar-regular
|
||||
access: [public, authority]
|
||||
@@ -663,7 +663,7 @@ lines:
|
||||
|
||||
# established (detective path): count >= 3, RelationshipState: Known/Friendly → Peer access.
|
||||
# Warmer, more personal. Sera has been waiting for them.
|
||||
- id: the-last-shift_d_176
|
||||
- id: sera-venn_d_057
|
||||
text: "I was hoping you'd come in. Saved the corner booth. How'd today go?"
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
@@ -676,7 +676,7 @@ lines:
|
||||
|
||||
# first_meeting (smuggler path): smuggler is dock crew, Sera knows the district crowd.
|
||||
# Peer/insider access from the start — Sera has social network, not institutional distance.
|
||||
- id: the-last-shift_d_177
|
||||
- id: sera-venn_d_058
|
||||
text: "Terminal crew? Grab a seat. Kael usually sits in the back if you're looking for him."
|
||||
role: bar-regular
|
||||
access: [peer, insider]
|
||||
@@ -689,7 +689,7 @@ lines:
|
||||
|
||||
# post-confrontation: confrontation logged. Sera's surface warmth is present but qualified.
|
||||
# She doesn't know where they stand. The welcome is real but uncertain.
|
||||
- id: the-last-shift_d_178
|
||||
- id: sera-venn_d_059
|
||||
text: "You're back. I wasn't sure you would be."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
|
||||
@@ -13,7 +13,7 @@ lines:
|
||||
# ARRIVALS — checking in
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_500
|
||||
- id: courier_d_001
|
||||
text: "Route's clear. Moving on schedule."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
@@ -23,7 +23,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [renn, greeting, operational]
|
||||
|
||||
- id: the-terminal_d_501
|
||||
- id: courier_d_002
|
||||
text: "Manifest for bay three. Needs a scheduler stamp."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
@@ -33,7 +33,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [renn, greeting, procedural]
|
||||
|
||||
- id: the-terminal_d_502
|
||||
- id: courier_d_003
|
||||
text: "Inbound from Havara. Three cans, all matched."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
@@ -47,7 +47,7 @@ lines:
|
||||
# SURFACE ROUTINE — low-information, on-script
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_503
|
||||
- id: courier_d_004
|
||||
text: "Not staying. Just the drop."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
@@ -57,7 +57,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [renn, operational, brief]
|
||||
|
||||
- id: the-terminal_d_504
|
||||
- id: courier_d_005
|
||||
text: "Paperwork's clean. You can check."
|
||||
role: courier
|
||||
access: [public, peer, authority]
|
||||
@@ -67,7 +67,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [renn, procedural, documentation]
|
||||
|
||||
- id: the-terminal_d_505
|
||||
- id: courier_d_006
|
||||
text: "Pickup's scheduled at 1600. I'll be back."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
@@ -77,7 +77,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [renn, operational]
|
||||
|
||||
- id: the-terminal_d_506
|
||||
- id: courier_d_007
|
||||
text: "Corridor's clear from my end. No delays."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
@@ -94,7 +94,7 @@ lines:
|
||||
# RING-ADJACENT — insider/real trust only
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_507
|
||||
- id: courier_d_008
|
||||
text: "Documentation's handled. Bay four, per the note."
|
||||
role: courier
|
||||
access: [insider]
|
||||
@@ -107,7 +107,7 @@ lines:
|
||||
smuggler: "Renn's confirming the paperwork trail. Bay four, as arranged."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: the-terminal_d_508
|
||||
- id: courier_d_009
|
||||
text: "Transit stamp's current. Nothing flagged at the gate."
|
||||
role: courier
|
||||
access: [insider]
|
||||
@@ -117,7 +117,7 @@ lines:
|
||||
mood: [focused]
|
||||
tags: [renn, ring-ops, documentation]
|
||||
|
||||
- id: the-terminal_d_509
|
||||
- id: courier_d_010
|
||||
text: "Pickup window is the same as last cycle. Twenty minutes."
|
||||
role: courier
|
||||
access: [insider]
|
||||
@@ -130,7 +130,7 @@ lines:
|
||||
smuggler: "Twenty minutes. Transition window confirmation."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: the-terminal_d_510
|
||||
- id: courier_d_011
|
||||
text: "Tell Voss the hold note clears at 1400."
|
||||
role: courier
|
||||
access: [insider]
|
||||
@@ -147,7 +147,7 @@ lines:
|
||||
# DETECTIVE ENCOUNTER — surface cooperation
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_511
|
||||
- id: courier_d_012
|
||||
text: "Licensed freight courier. Manifest's available on request."
|
||||
role: courier
|
||||
access: [authority]
|
||||
@@ -157,7 +157,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [renn, detective-path, procedural]
|
||||
|
||||
- id: the-terminal_d_512
|
||||
- id: courier_d_013
|
||||
text: "I drop documentation. What's inside the containers isn't my business."
|
||||
role: courier
|
||||
access: [authority]
|
||||
@@ -170,7 +170,7 @@ lines:
|
||||
smuggler: "That's the answer. Clean and correct."
|
||||
detective: "Classic courier deflection. Legally accurate. Operationally suspicious."
|
||||
|
||||
- id: the-terminal_d_513
|
||||
- id: courier_d_014
|
||||
text: "I've been running this route for two years. Nothing unusual."
|
||||
role: courier
|
||||
access: [authority, public]
|
||||
@@ -184,7 +184,7 @@ lines:
|
||||
# DEPARTURES
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_514
|
||||
- id: courier_d_015
|
||||
text: "That's the drop. I'm out."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
@@ -194,7 +194,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [renn, closing]
|
||||
|
||||
- id: the-terminal_d_515
|
||||
- id: courier_d_016
|
||||
text: "Next run's tomorrow. Early."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
@@ -208,7 +208,7 @@ lines:
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_516
|
||||
- id: courier_d_017
|
||||
text: "Two stops today. First one's done."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
@@ -218,7 +218,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [renn, greeting, generation-pass]
|
||||
|
||||
- id: the-terminal_d_517
|
||||
- id: courier_d_018
|
||||
text: "Outbound for Havara. Needs a bay assignment."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
@@ -228,7 +228,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [renn, greeting, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_518
|
||||
- id: courier_d_019
|
||||
text: "Gate's backed up. Running twenty minutes late."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
@@ -238,7 +238,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [renn, delay, generation-pass]
|
||||
|
||||
- id: the-terminal_d_519
|
||||
- id: courier_d_020
|
||||
text: "Paperwork's in order. Same as always."
|
||||
role: courier
|
||||
access: [public, peer, authority]
|
||||
@@ -248,7 +248,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [renn, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_520
|
||||
- id: courier_d_021
|
||||
text: "Quick turnaround today. Back out by 1300."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
@@ -258,7 +258,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [renn, operational, generation-pass]
|
||||
|
||||
- id: the-terminal_d_521
|
||||
- id: courier_d_022
|
||||
text: "Signed off on bay three. That's me done here."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
|
||||
@@ -12,7 +12,7 @@ lines:
|
||||
# GREETINGS — shift arrival
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_200
|
||||
- id: dock-worker_d_001
|
||||
text: "Push today. Bay six's backed up."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -22,7 +22,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, greeting]
|
||||
|
||||
- id: the-terminal_d_201
|
||||
- id: dock-worker_d_002
|
||||
text: "Late night last cycle. Bay eight ran until 2300."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -32,7 +32,7 @@ lines:
|
||||
mood: [frustrated]
|
||||
tags: [dock-worker, greeting]
|
||||
|
||||
- id: the-terminal_d_202
|
||||
- id: dock-worker_d_003
|
||||
text: "Voss is on already. Floor's locked down tight."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
@@ -49,7 +49,7 @@ lines:
|
||||
# FLOOR TALK — routine, cargo, observation
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_203
|
||||
- id: dock-worker_d_004
|
||||
text: "Bay four's been on hold three days. Nobody says why."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -62,7 +62,7 @@ lines:
|
||||
smuggler: "He doesn't know what's in it. Just annoyed at the backup."
|
||||
detective: "Worker notices Bay 4 hold as unusual. Not suspicious — just a worker tracking the floor."
|
||||
|
||||
- id: the-terminal_d_204
|
||||
- id: dock-worker_d_005
|
||||
text: "Manifest looks clean. Three inbounds, two outbounds. Light day."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -72,7 +72,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, manifest]
|
||||
|
||||
- id: the-terminal_d_205
|
||||
- id: dock-worker_d_006
|
||||
text: "Maret's been at that terminal all morning. Never good."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
@@ -85,7 +85,7 @@ lines:
|
||||
smuggler: "Maret reviewing something. If she flags a discrepancy, Voss'll sit on it. He always does."
|
||||
detective: "Workers have noticed Maret's behavior. Whatever she found, it's not small."
|
||||
|
||||
- id: the-terminal_d_206
|
||||
- id: dock-worker_d_007
|
||||
text: "Drin ran bay six again last cycle. Third inspection this week."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -98,7 +98,7 @@ lines:
|
||||
smuggler: "Drin on six while bay four stays held. Scheduled or arranged?"
|
||||
detective: "Workers notice inspection distribution. Drin on six, bay four untouched."
|
||||
|
||||
- id: the-terminal_d_207
|
||||
- id: dock-worker_d_008
|
||||
text: "Transition window's twice as long with Commission eyes on the floor."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
@@ -111,7 +111,7 @@ lines:
|
||||
smuggler: "Commission presence during transition. Window's compromised."
|
||||
detective: "Workers slow down when Commission is visible. Normal — or trained."
|
||||
|
||||
- id: the-terminal_d_208
|
||||
- id: dock-worker_d_009
|
||||
text: "Good crew this cycle. Linn and Pael are both on."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -121,7 +121,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, pael, community]
|
||||
|
||||
- id: the-terminal_d_209
|
||||
- id: dock-worker_d_010
|
||||
text: "Cargo mark's off by forty, according to Maret. Voss logged it and moved on."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
@@ -137,7 +137,7 @@ lines:
|
||||
fact_id: investigation.oversight_gap_pattern
|
||||
confidence: suspects
|
||||
|
||||
- id: the-terminal_d_210
|
||||
- id: dock-worker_d_011
|
||||
text: "C-4 containers haven't been checked in three days. Not our problem, apparently."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
@@ -154,7 +154,7 @@ lines:
|
||||
# COMMUNITY / CASUAL
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_211
|
||||
- id: dock-worker_d_012
|
||||
text: "You heading to the Last Shift after? Lera's got the grain spirit back."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -164,7 +164,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, social, the-last-shift]
|
||||
|
||||
- id: the-terminal_d_212
|
||||
- id: dock-worker_d_013
|
||||
text: "Workers' Association meeting's cycle twenty-two. Handoff protocol's back on the agenda."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -174,7 +174,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, workers-association, community]
|
||||
|
||||
- id: the-terminal_d_213
|
||||
- id: dock-worker_d_014
|
||||
text: "Drifters lost again. Four-three. Pael seemed to take it personally."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -188,7 +188,7 @@ lines:
|
||||
# DETECTIVE ENCOUNTER — deflection, redirect
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_214
|
||||
- id: dock-worker_d_015
|
||||
text: "Manifest questions go to the scheduler. I'm floor-side."
|
||||
role: dock-worker
|
||||
access: [authority, public]
|
||||
@@ -198,7 +198,7 @@ lines:
|
||||
mood: [suspicious]
|
||||
tags: [dock-worker, detective-path, redirect]
|
||||
|
||||
- id: the-terminal_d_215
|
||||
- id: dock-worker_d_016
|
||||
text: "Commission's asked before. Same answers. We run a clean floor."
|
||||
role: dock-worker
|
||||
access: [authority]
|
||||
@@ -208,7 +208,7 @@ lines:
|
||||
mood: [suspicious]
|
||||
tags: [dock-worker, detective-path, deflection]
|
||||
|
||||
- id: the-terminal_d_216
|
||||
- id: dock-worker_d_017
|
||||
text: "Voss handles discrepancies. That's above my level."
|
||||
role: dock-worker
|
||||
access: [authority, peer]
|
||||
@@ -222,7 +222,7 @@ lines:
|
||||
# PEER CANDOR — elevated trust
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_217
|
||||
- id: dock-worker_d_018
|
||||
text: "Maret's going to push it. She doesn't leave a number sitting."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
@@ -235,7 +235,7 @@ lines:
|
||||
smuggler: "Maret's not going to drop it. That's the problem."
|
||||
detective: "Workers think Maret will force the issue. She may be the break."
|
||||
|
||||
- id: the-terminal_d_218
|
||||
- id: dock-worker_d_019
|
||||
text: "Voss runs a tight floor. Maybe too tight, if you take my meaning."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
@@ -252,7 +252,7 @@ lines:
|
||||
# SIGN-OFF
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_219
|
||||
- id: dock-worker_d_020
|
||||
text: "Bay's clear. Good shift."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -262,7 +262,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, closing]
|
||||
|
||||
- id: the-terminal_d_220
|
||||
- id: dock-worker_d_021
|
||||
text: "Night crew's here. We're done."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -276,7 +276,7 @@ lines:
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_221
|
||||
- id: dock-worker_d_022
|
||||
text: "Early start. Bay two's already loaded."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -286,7 +286,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, greeting, generation-pass]
|
||||
|
||||
- id: the-terminal_d_222
|
||||
- id: dock-worker_d_023
|
||||
text: "Air recyclers are loud this morning. Must be the outer ring issue again."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -296,7 +296,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, station, generation-pass]
|
||||
|
||||
- id: the-terminal_d_223
|
||||
- id: dock-worker_d_024
|
||||
text: "Pael's in early. Something must be broken in B-7 again."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
@@ -306,7 +306,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, pael, generation-pass]
|
||||
|
||||
- id: the-terminal_d_224
|
||||
- id: dock-worker_d_025
|
||||
text: "Running light today. Maybe I'll actually make it to the Last Shift before close."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -316,7 +316,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, social, generation-pass]
|
||||
|
||||
- id: the-terminal_d_225
|
||||
- id: dock-worker_d_026
|
||||
text: "Bay three's got a scan hold. Not my problem. Not my bay."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -326,7 +326,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, scan-hold, generation-pass]
|
||||
|
||||
- id: the-terminal_d_226
|
||||
- id: dock-worker_d_027
|
||||
text: "New hire's back. Asking questions again. Good kid."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
@@ -336,7 +336,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, new-hire, generation-pass]
|
||||
|
||||
- id: the-terminal_d_227
|
||||
- id: dock-worker_d_028
|
||||
text: "Handoff's in ten. Don't disappear on me."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
@@ -346,7 +346,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, handoff, generation-pass]
|
||||
|
||||
- id: the-terminal_d_228
|
||||
- id: dock-worker_d_029
|
||||
text: "Span gate cycled early. All inbounds are going to be late. Plan around it."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -356,7 +356,7 @@ lines:
|
||||
mood: [suspicious]
|
||||
tags: [dock-worker, span-gate, generation-pass]
|
||||
|
||||
- id: the-terminal_d_229
|
||||
- id: dock-worker_d_030
|
||||
text: "Linn's not in today. We're running two short on bay six."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -366,7 +366,7 @@ lines:
|
||||
mood: [frustrated]
|
||||
tags: [dock-worker, staffing, generation-pass]
|
||||
|
||||
- id: the-terminal_d_230
|
||||
- id: dock-worker_d_031
|
||||
text: "Lunch is in the break room. Don't touch the one with Pael's name on it. He will notice."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -376,7 +376,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, pael, humor, generation-pass]
|
||||
|
||||
- id: the-terminal_d_231
|
||||
- id: dock-worker_d_032
|
||||
text: "Overtime's posted if you want it. Bay eight, 1800 to 2200."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -386,7 +386,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [dock-worker, overtime, generation-pass]
|
||||
|
||||
- id: the-terminal_d_232
|
||||
- id: dock-worker_d_033
|
||||
text: "Off on time today. First time this cycle."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
|
||||
@@ -12,7 +12,7 @@ lines:
|
||||
# GREETINGS — Phase 1: Comfort (0-10 min)
|
||||
# ========================================
|
||||
|
||||
- id: the-terminal_d_001
|
||||
- id: kael-davan_d_034
|
||||
text: "There you are. Good — I was starting to wonder."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -22,7 +22,7 @@ lines:
|
||||
topic: [colleague]
|
||||
tags: [kael, greeting, phase-1]
|
||||
|
||||
- id: the-terminal_d_002
|
||||
- id: kael-davan_d_035
|
||||
text: "Morning. Freight's stacked clean today. Should be a smooth one."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -32,7 +32,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [kael, greeting, phase-1]
|
||||
|
||||
- id: the-terminal_d_003
|
||||
- id: kael-davan_d_036
|
||||
text: "Voss has the schedule posted already. Keen today. That's suspicious."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -42,7 +42,7 @@ lines:
|
||||
topic: [colleague, routine]
|
||||
tags: [kael, greeting, humor]
|
||||
|
||||
- id: the-terminal_d_004
|
||||
- id: kael-davan_d_037
|
||||
text: "Hey. Grab a coffee before Voss finds something to complain about."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -52,7 +52,7 @@ lines:
|
||||
topic: [colleague]
|
||||
tags: [kael, greeting, phase-1]
|
||||
|
||||
- id: the-terminal_d_005
|
||||
- id: kael-davan_d_038
|
||||
text: "You look like you slept about as well as I did."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
@@ -63,7 +63,7 @@ lines:
|
||||
tags: [kael, greeting, casual]
|
||||
|
||||
# Public greeting for detective / outsiders
|
||||
- id: the-terminal_d_006
|
||||
- id: kael-davan_d_039
|
||||
text: "Can I help you? If you're looking for the supervisor, Voss is by the schedule board."
|
||||
role: dock-worker
|
||||
access: [public, authority]
|
||||
@@ -77,7 +77,7 @@ lines:
|
||||
# SHIFT TALK — routine dialogue
|
||||
# ========================================
|
||||
|
||||
- id: the-terminal_d_007
|
||||
- id: kael-davan_d_040
|
||||
text: "Bay three's running behind. If we don't clear it by shift end, Voss is going to lose it."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -87,7 +87,7 @@ lines:
|
||||
topic: [routine, cargo]
|
||||
tags: [kael, operational]
|
||||
|
||||
- id: the-terminal_d_008
|
||||
- id: kael-davan_d_041
|
||||
text: "Manifest says this one's forty kilos over. Logging error, probably."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -100,7 +100,7 @@ lines:
|
||||
fact_id: investigation.manifest_discrepancy
|
||||
confidence: suspects
|
||||
|
||||
- id: the-terminal_d_009
|
||||
- id: kael-davan_d_042
|
||||
text: "Night shift left the dock in decent shape for once. Miracles happen."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -110,7 +110,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [kael, humor]
|
||||
|
||||
- id: the-terminal_d_010
|
||||
- id: kael-davan_d_043
|
||||
text: "Loading arm three's been grinding all week. Someone should file a maintenance ticket."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -120,7 +120,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [kael, environmental]
|
||||
|
||||
- id: the-terminal_d_011
|
||||
- id: kael-davan_d_044
|
||||
text: "Maret rerouted the bay four queue. Tight schedule today."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -130,7 +130,7 @@ lines:
|
||||
topic: [cargo, colleague]
|
||||
tags: [kael, operational, maret]
|
||||
|
||||
- id: the-terminal_d_012
|
||||
- id: kael-davan_d_045
|
||||
text: "Shift transition in twenty. After that it's Drin's problem."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -140,7 +140,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [kael, operational]
|
||||
|
||||
- id: the-terminal_d_013
|
||||
- id: kael-davan_d_046
|
||||
text: "Naia's got a school event tonight. I'm heading home straight after shift."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -150,7 +150,7 @@ lines:
|
||||
topic: [personal]
|
||||
tags: [kael, naia-reference, casual]
|
||||
|
||||
- id: the-terminal_d_014
|
||||
- id: kael-davan_d_047
|
||||
text: "New hire started on bay seven. Keen. Reminds me of my first week."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
@@ -164,7 +164,7 @@ lines:
|
||||
# RING COORDINATION — insider, real trust
|
||||
# ========================================
|
||||
|
||||
- id: the-terminal_d_015
|
||||
- id: kael-davan_d_048
|
||||
text: "Container 4471 is flagged. I'll reroute it during shift transition."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -174,7 +174,7 @@ lines:
|
||||
topic: [cargo]
|
||||
tags: [kael, ring-ops, operational, phase-2]
|
||||
|
||||
- id: the-terminal_d_016
|
||||
- id: kael-davan_d_049
|
||||
text: "Voss kept the window clear. Twenty minutes, maybe twenty-five."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -184,7 +184,7 @@ lines:
|
||||
topic: [cargo, routine]
|
||||
tags: [kael, ring-ops, operational, phase-2]
|
||||
|
||||
- id: the-terminal_d_017
|
||||
- id: kael-davan_d_050
|
||||
text: "Renn's on the corridor side. Package moves when I give the signal."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -194,7 +194,7 @@ lines:
|
||||
topic: [cargo]
|
||||
tags: [kael, ring-ops, renn, phase-2]
|
||||
|
||||
- id: the-terminal_d_018
|
||||
- id: kael-davan_d_051
|
||||
text: "Manifest's clean on my end. Maret processed it without questions."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -204,7 +204,7 @@ lines:
|
||||
topic: [cargo]
|
||||
tags: [kael, ring-ops, maret, phase-2]
|
||||
|
||||
- id: the-terminal_d_019
|
||||
- id: kael-davan_d_052
|
||||
text: "Nils wants higher volume next cycle. I told him we're already tight."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -214,7 +214,7 @@ lines:
|
||||
topic: [danger, trust]
|
||||
tags: [kael, ring-ops, nils, tension, phase-2]
|
||||
|
||||
- id: the-terminal_d_020
|
||||
- id: kael-davan_d_053
|
||||
text: "Drin waved through bay four without checking. At least he's reliable."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -227,7 +227,7 @@ lines:
|
||||
fact_id: investigation.drin_inspection_pattern
|
||||
confidence: knows_details
|
||||
|
||||
- id: the-terminal_d_021
|
||||
- id: kael-davan_d_054
|
||||
text: "Keep an eye on Maret. She's been looking at the manifests longer than usual."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -237,7 +237,7 @@ lines:
|
||||
topic: [colleague, danger]
|
||||
tags: [kael, ring-ops, maret, caution, phase-2]
|
||||
|
||||
- id: the-terminal_d_022
|
||||
- id: kael-davan_d_055
|
||||
text: "There's a sealed one in the batch tonight. Nils says components. Don't open it."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -251,7 +251,7 @@ lines:
|
||||
# TRUST-GATED — deep trust, secret tier
|
||||
# ========================================
|
||||
|
||||
- id: the-terminal_d_023
|
||||
- id: kael-davan_d_056
|
||||
text: "Some days I wonder how long we can keep this up."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -261,7 +261,7 @@ lines:
|
||||
topic: [trust, personal]
|
||||
tags: [kael, vulnerability, friend-arc]
|
||||
|
||||
- id: the-terminal_d_024
|
||||
- id: kael-davan_d_057
|
||||
text: "Naia asked me again last night. Where I go. Why I'm late. I hate lying to her."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -271,7 +271,7 @@ lines:
|
||||
topic: [personal, trust]
|
||||
tags: [kael, naia-reference, vulnerability]
|
||||
|
||||
- id: the-terminal_d_025
|
||||
- id: kael-davan_d_058
|
||||
text: "If something happens to me — look out for Naia. Promise me that."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -285,7 +285,7 @@ lines:
|
||||
# UNPROMPTED — Kael volunteers or warns
|
||||
# ========================================
|
||||
|
||||
- id: the-terminal_d_026
|
||||
- id: kael-davan_d_059
|
||||
text: "Heads up — Voss is in a mood. Keep your head down this shift."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -295,7 +295,7 @@ lines:
|
||||
topic: [colleague, danger]
|
||||
tags: [kael, unprompted, warning]
|
||||
|
||||
- id: the-terminal_d_027
|
||||
- id: kael-davan_d_060
|
||||
text: "I'm buying at Lera's after shift. You're coming. No arguments."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -305,7 +305,7 @@ lines:
|
||||
topic: [personal, colleague]
|
||||
tags: [kael, unprompted, social]
|
||||
|
||||
- id: the-terminal_d_028
|
||||
- id: kael-davan_d_061
|
||||
text: "Torek's been running his mouth at the bar. You might want to have a word."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -319,7 +319,7 @@ lines:
|
||||
# DEFLECTION — Phase 2-4: Post-contradiction
|
||||
# ========================================
|
||||
|
||||
- id: the-terminal_d_029
|
||||
- id: kael-davan_d_062
|
||||
text: "It's nothing. Just work stuff."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -329,7 +329,7 @@ lines:
|
||||
topic: [personal]
|
||||
tags: [kael, deflection, phase-4]
|
||||
|
||||
- id: the-terminal_d_030
|
||||
- id: kael-davan_d_063
|
||||
text: "Don't worry about it. I've got it handled."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -339,7 +339,7 @@ lines:
|
||||
topic: [personal, trust]
|
||||
tags: [kael, deflection, phase-4]
|
||||
|
||||
- id: the-terminal_d_031
|
||||
- id: kael-davan_d_064
|
||||
text: "I was checking on a maintenance issue. That's all."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -349,7 +349,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [kael, deflection, lying, phase-4]
|
||||
|
||||
- id: the-terminal_d_032
|
||||
- id: kael-davan_d_065
|
||||
text: "You're reading too much into it. Come on — shift's starting."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
@@ -359,7 +359,7 @@ lines:
|
||||
topic: [routine, trust]
|
||||
tags: [kael, deflection, subject-change, phase-4]
|
||||
|
||||
- id: the-terminal_d_033
|
||||
- id: kael-davan_d_066
|
||||
text: "Can we not do this here? Voss is watching."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -374,7 +374,7 @@ lines:
|
||||
# ========================================
|
||||
|
||||
# Path A: Gentle confrontation
|
||||
- id: the-terminal_d_034
|
||||
- id: kael-davan_d_067
|
||||
text: "I... yeah. I need to tell you something. But not here."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -385,7 +385,7 @@ lines:
|
||||
tags: [kael, confession-path, phase-5]
|
||||
|
||||
# Path B: Harsh pressure
|
||||
- id: the-terminal_d_035
|
||||
- id: kael-davan_d_068
|
||||
text: "You want to report me? Go ahead. See what Nils does to both of us."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -396,7 +396,7 @@ lines:
|
||||
tags: [kael, hostile-path, phase-5]
|
||||
|
||||
# If smuggler pushes further (gentle)
|
||||
- id: the-terminal_d_036
|
||||
- id: kael-davan_d_069
|
||||
text: "Naia can't keep living like this. Neither can I. I'm looking for a way out."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -407,7 +407,7 @@ lines:
|
||||
tags: [kael, confession, naia-reference, phase-5]
|
||||
|
||||
# If detective confronts (authority path)
|
||||
- id: the-terminal_d_037
|
||||
- id: kael-davan_d_070
|
||||
text: "I don't know what you're talking about. I load containers. That's my job."
|
||||
role: dock-worker
|
||||
access: [public, authority]
|
||||
@@ -416,7 +416,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [kael, deflection, detective-path]
|
||||
|
||||
- id: the-terminal_d_038
|
||||
- id: kael-davan_d_071
|
||||
text: "You want to ask questions, talk to Voss. He runs the schedule."
|
||||
role: dock-worker
|
||||
access: [public, authority]
|
||||
@@ -432,7 +432,7 @@ lines:
|
||||
# ========================================
|
||||
|
||||
# first_meeting: InteractionMemory.count == 0. RelationshipState: Unknown → Public access only.
|
||||
- id: the-terminal_d_050
|
||||
- id: kael-davan_d_072
|
||||
text: "This section is dock crew and logistics. If you're Commission or admin, check-in's back that way."
|
||||
role: dock-worker
|
||||
access: [public]
|
||||
@@ -444,7 +444,7 @@ lines:
|
||||
notes: "Layer 2 greeting — first meeting. Kael is helpful but orienting the stranger away from his work."
|
||||
|
||||
# established — peer/smuggler context: count >= 3, RelationshipState: Known/Friendly → Peer/Insider access.
|
||||
- id: the-terminal_d_051
|
||||
- id: kael-davan_d_073
|
||||
text: "You're back. Good — bay three's been quiet. I'll fill you in."
|
||||
role: dock-worker
|
||||
access: [peer, insider]
|
||||
@@ -456,7 +456,7 @@ lines:
|
||||
notes: "Layer 2 greeting — established. Warm, practical, skips pleasantries. Treats them as crew."
|
||||
|
||||
# established — authority/detective context: count >= 3, RelationshipState: PersonOfInterest → Authority access.
|
||||
- id: the-terminal_d_052
|
||||
- id: kael-davan_d_074
|
||||
text: "Back again. I'm on shift — if you have questions, make them quick."
|
||||
role: dock-worker
|
||||
access: [authority]
|
||||
@@ -468,7 +468,7 @@ lines:
|
||||
notes: "Layer 2 greeting — repeat with authority figure. Professional, clipped, not hostile. Kael can't refuse but won't volunteer."
|
||||
|
||||
# post-confrontation: confrontation in InteractionMemory.notable_events. NPC mood shifted to suspicious/frustrated.
|
||||
- id: the-terminal_d_053
|
||||
- id: kael-davan_d_075
|
||||
text: "Didn't expect you today."
|
||||
role: dock-worker
|
||||
access: [peer, insider]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Dialogue: maintenance-tech at The Terminal
|
||||
# NPC: Pael Varren (Tier 3, NOBODY/CIVILIAN)
|
||||
# Voice: tired-competent, minimal, dry. Knows the infrastructure. Knows nothing else.
|
||||
# KEY LINE: the-terminal_d_040 — sounds like surveillance knowledge, is a maintenance complaint
|
||||
# KEY LINE: maintenance-tech_d_001 — sounds like surveillance knowledge, is a maintenance complaint
|
||||
# Ticket: #307 | Sprint: 12
|
||||
|
||||
location: the-terminal
|
||||
@@ -14,7 +14,7 @@ lines:
|
||||
# Actually: the B-7 seal fails when people use the corridor too much. Pael notices load.
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_040
|
||||
- id: maintenance-tech_d_001
|
||||
text: "B-7's had three seal failures this cycle. Someone's been through there more than they should."
|
||||
role: maintenance-tech
|
||||
access: [public]
|
||||
@@ -35,7 +35,7 @@ lines:
|
||||
# ROUTINE LINES
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_041
|
||||
- id: maintenance-tech_d_002
|
||||
text: "Air recycler's running hot again. Third deferred ticket this week."
|
||||
role: maintenance-tech
|
||||
access: [public]
|
||||
@@ -45,7 +45,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [pael, atmospheric]
|
||||
|
||||
- id: the-terminal_d_042
|
||||
- id: maintenance-tech_d_003
|
||||
text: "Loading arm three's been on my list for a month. Management keeps deferring it."
|
||||
role: maintenance-tech
|
||||
access: [public]
|
||||
@@ -55,7 +55,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [pael, atmospheric]
|
||||
|
||||
- id: the-terminal_d_043
|
||||
- id: maintenance-tech_d_004
|
||||
text: "If the gate tone shifts, stay clear of bay six. She needs recalibration."
|
||||
role: maintenance-tech
|
||||
access: [public]
|
||||
@@ -65,7 +65,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [pael, warning, helpful]
|
||||
|
||||
- id: the-terminal_d_044
|
||||
- id: maintenance-tech_d_005
|
||||
text: "Not me. I just fix them."
|
||||
role: maintenance-tech
|
||||
access: [public]
|
||||
@@ -75,7 +75,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [pael, response]
|
||||
|
||||
- id: the-terminal_d_045
|
||||
- id: maintenance-tech_d_006
|
||||
text: "Gate hum's been three cycles off-spec. Nobody cares until it stops completely."
|
||||
role: maintenance-tech
|
||||
access: [public]
|
||||
|
||||
@@ -12,7 +12,7 @@ lines:
|
||||
# GREETINGS — early days
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_400
|
||||
- id: new-hire_d_001
|
||||
text: "Still figuring out the bay numbering. Six and eight are in the wrong order."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -22,7 +22,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [new-hire, greeting, orientation]
|
||||
|
||||
- id: the-terminal_d_401
|
||||
- id: new-hire_d_002
|
||||
text: "Voss told me to check in with you. Is that the standard thing?"
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -32,7 +32,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [new-hire, greeting, voss]
|
||||
|
||||
- id: the-terminal_d_402
|
||||
- id: new-hire_d_003
|
||||
text: "First full cycle on the floor. Trying to stay out of the way."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -46,7 +46,7 @@ lines:
|
||||
# FRESH OBSERVATIONS — the dangerous innocence
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_403
|
||||
- id: new-hire_d_004
|
||||
text: "Does bay four usually get held that long? The other bays turned over twice already."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -59,7 +59,7 @@ lines:
|
||||
smuggler: "New eyes on bay four. He doesn't know what he's looking at. But he noticed."
|
||||
detective: "New hire asking exactly the right question without knowing it."
|
||||
|
||||
- id: the-terminal_d_404
|
||||
- id: new-hire_d_005
|
||||
text: "I saw someone go into C-4 last night without a scan log. Is that normal?"
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -76,7 +76,7 @@ lines:
|
||||
confidence: suspects
|
||||
notes: "High-value inadvertent witness line. Operationally dangerous for the ring."
|
||||
|
||||
- id: the-terminal_d_405
|
||||
- id: new-hire_d_006
|
||||
text: "Why does the transition window only get twenty minutes? The stations I trained at ran thirty."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -89,7 +89,7 @@ lines:
|
||||
smuggler: "He's asking about the window. Doesn't know why it matters. Answer carefully."
|
||||
detective: "New hire confirms the window length is unusually short. File that."
|
||||
|
||||
- id: the-terminal_d_406
|
||||
- id: new-hire_d_007
|
||||
text: "Do containers usually get rerouted mid-hold? One went out through bay four while I thought it was still held."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -105,7 +105,7 @@ lines:
|
||||
fact_id: investigation.manifest_discrepancy
|
||||
confidence: suspects
|
||||
|
||||
- id: the-terminal_d_407
|
||||
- id: new-hire_d_008
|
||||
text: "Maintenance corridor access is just off C-4, right? I took the wrong turn twice."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -122,7 +122,7 @@ lines:
|
||||
# LEARNING / QUESTIONS
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_408
|
||||
- id: new-hire_d_009
|
||||
text: "What's the Commission kiosk actually for? Nobody seems to use it."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -132,7 +132,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [new-hire, commission, curiosity]
|
||||
|
||||
- id: the-terminal_d_409
|
||||
- id: new-hire_d_010
|
||||
text: "Maret seems stressed. Is that always the case or is something going on?"
|
||||
role: new-hire
|
||||
access: [peer]
|
||||
@@ -145,7 +145,7 @@ lines:
|
||||
smuggler: "He noticed Maret. Good that he's asking, not reporting. Manage this."
|
||||
detective: "New hire reads Maret's stress without context. Confirms it's visible."
|
||||
|
||||
- id: the-terminal_d_410
|
||||
- id: new-hire_d_011
|
||||
text: "Voss seems really serious. Is he always like that?"
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -155,7 +155,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [new-hire, voss, curiosity]
|
||||
|
||||
- id: the-terminal_d_411
|
||||
- id: new-hire_d_012
|
||||
text: "How long before Voss trusts you to run a bay on your own?"
|
||||
role: new-hire
|
||||
access: [peer]
|
||||
@@ -169,7 +169,7 @@ lines:
|
||||
# SIGN-OFF
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_412
|
||||
- id: new-hire_d_013
|
||||
text: "That was a full shift. I'll have the bay numbers down by next cycle."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -179,7 +179,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [new-hire, closing]
|
||||
|
||||
- id: the-terminal_d_413
|
||||
- id: new-hire_d_014
|
||||
text: "Good day. Lot to learn. But good."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -193,7 +193,7 @@ lines:
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_414
|
||||
- id: new-hire_d_015
|
||||
text: "Is there a trick to the manifest terminal or does it always lag like this?"
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -203,7 +203,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [new-hire, orientation, generation-pass]
|
||||
|
||||
- id: the-terminal_d_415
|
||||
- id: new-hire_d_016
|
||||
text: "How long has Maret been here? She seems like she knows everything."
|
||||
role: new-hire
|
||||
access: [peer]
|
||||
@@ -213,7 +213,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [new-hire, maret, curiosity, generation-pass]
|
||||
|
||||
- id: the-terminal_d_416
|
||||
- id: new-hire_d_017
|
||||
text: "Nobody told me about the span gate frequency. I kept thinking something was wrong."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -223,7 +223,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [new-hire, orientation, generation-pass]
|
||||
|
||||
- id: the-terminal_d_417
|
||||
- id: new-hire_d_018
|
||||
text: "The Last Shift — is that actually a bar? I haven't been yet."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -233,7 +233,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [new-hire, social, generation-pass]
|
||||
|
||||
- id: the-terminal_d_418
|
||||
- id: new-hire_d_019
|
||||
text: "I thought the scanner on bay three was broken. Turns out you have to hold the badge longer."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -243,7 +243,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [new-hire, orientation, humor, generation-pass]
|
||||
|
||||
- id: the-terminal_d_419
|
||||
- id: new-hire_d_020
|
||||
text: "Who's Drin? I keep hearing the name but haven't met them."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
@@ -253,7 +253,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [new-hire, drin, curiosity, generation-pass]
|
||||
|
||||
- id: the-terminal_d_420
|
||||
- id: new-hire_d_021
|
||||
text: "Three cycles in and I still can't tell when Voss is satisfied. Is there a tell?"
|
||||
role: new-hire
|
||||
access: [peer]
|
||||
|
||||
@@ -17,7 +17,7 @@ lines:
|
||||
# Second playthrough: "That's me."
|
||||
# ========================================
|
||||
|
||||
- id: the-terminal_d_044
|
||||
- id: pc-detective_d_004
|
||||
text: "Good morning. Commission Liaison Office. I'm conducting a routine compliance review."
|
||||
role: commission-liaison
|
||||
access: [public]
|
||||
@@ -27,7 +27,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [pc-detective, greeting, public-tier]
|
||||
|
||||
- id: the-terminal_d_045
|
||||
- id: pc-detective_d_005
|
||||
text: "Standard review. Manifest reconciliation, scheduling compliance. Nothing unusual."
|
||||
role: commission-liaison
|
||||
access: [public]
|
||||
@@ -36,7 +36,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [pc-detective, purpose-question, public-tier]
|
||||
|
||||
- id: the-terminal_d_046
|
||||
- id: pc-detective_d_006
|
||||
text: "Only been here a few days. Seems like a well-run operation."
|
||||
role: commission-liaison
|
||||
access: [public]
|
||||
@@ -45,7 +45,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [pc-detective, district-question, public-tier]
|
||||
|
||||
- id: the-terminal_d_047
|
||||
- id: pc-detective_d_007
|
||||
text: "Ms. Venn? Colleague. We've worked in the same division."
|
||||
role: commission-liaison
|
||||
access: [public]
|
||||
@@ -54,7 +54,7 @@ lines:
|
||||
topic: [colleague]
|
||||
tags: [pc-detective, sera-question, public-tier]
|
||||
|
||||
- id: the-terminal_d_048
|
||||
- id: pc-detective_d_008
|
||||
text: "I appreciate the interest, but I'm sure you have cargo to route. I won't keep you."
|
||||
role: commission-liaison
|
||||
access: [public, authority]
|
||||
|
||||
@@ -17,7 +17,7 @@ lines:
|
||||
# Second playthrough: "That's me."
|
||||
# ========================================
|
||||
|
||||
- id: the-terminal_d_039
|
||||
- id: pc-smuggler_d_004
|
||||
text: "Morning. Can I help you with something?"
|
||||
role: dock-worker
|
||||
access: [public]
|
||||
@@ -27,7 +27,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [pc-smuggler, greeting, public-tier]
|
||||
|
||||
- id: the-terminal_d_040
|
||||
- id: pc-smuggler_d_005
|
||||
text: "Cargo routing. Containers come in, I process them out. Standard stuff."
|
||||
role: dock-worker
|
||||
access: [public, authority]
|
||||
@@ -36,7 +36,7 @@ lines:
|
||||
topic: [routine, cargo]
|
||||
tags: [pc-smuggler, work-question, public-tier]
|
||||
|
||||
- id: the-terminal_d_041
|
||||
- id: pc-smuggler_d_006
|
||||
text: "It's a freight hub. Busy during shifts, quiet after. Same as anywhere."
|
||||
role: dock-worker
|
||||
access: [public]
|
||||
@@ -45,7 +45,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [pc-smuggler, hub-question, public-tier]
|
||||
|
||||
- id: the-terminal_d_042
|
||||
- id: pc-smuggler_d_007
|
||||
text: "Good crew. We do our shifts, go home. Nothing exciting."
|
||||
role: dock-worker
|
||||
access: [public, authority]
|
||||
@@ -54,7 +54,7 @@ lines:
|
||||
topic: [colleague, routine]
|
||||
tags: [pc-smuggler, colleagues-question, public-tier]
|
||||
|
||||
- id: the-terminal_d_043
|
||||
- id: pc-smuggler_d_008
|
||||
text: "Look, I just move containers. You'd want to talk to Voss about scheduling."
|
||||
role: dock-worker
|
||||
access: [authority]
|
||||
|
||||
@@ -13,7 +13,7 @@ lines:
|
||||
# GREETINGS — scheduler's opening read
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_300
|
||||
- id: scheduler_d_001
|
||||
text: "Manifest's current. Bay assignments are posted."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -23,7 +23,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [maret, procedural, greeting]
|
||||
|
||||
- id: the-terminal_d_301
|
||||
- id: scheduler_d_002
|
||||
text: "Container 4471 is still in C-4. Scheduled for bay six by 1400. Check with Voss if the hold's lifted."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -39,7 +39,7 @@ lines:
|
||||
fact_id: investigation.manifest_discrepancy
|
||||
confidence: suspects
|
||||
|
||||
- id: the-terminal_d_302
|
||||
- id: scheduler_d_003
|
||||
text: "Three inbounds today. All verified against the manifest. No gaps so far."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -53,7 +53,7 @@ lines:
|
||||
# DISCREPANCY PATTERN — the core arc
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_303
|
||||
- id: scheduler_d_004
|
||||
text: "Container 4474 shows a forty-seven kilo discrepancy. I've flagged it twice."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -69,7 +69,7 @@ lines:
|
||||
fact_id: investigation.oversight_gap_pattern
|
||||
confidence: suspects
|
||||
|
||||
- id: the-terminal_d_304
|
||||
- id: scheduler_d_005
|
||||
text: "It was logged as administrative. I don't know what that means for a weight discrepancy."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -82,7 +82,7 @@ lines:
|
||||
smuggler: "She doesn't know what 'administrative' means either. Voss's answer covered nothing."
|
||||
detective: "Maret knows the answer she got wasn't a real answer. She's still sitting with that."
|
||||
|
||||
- id: the-terminal_d_305
|
||||
- id: scheduler_d_006
|
||||
text: "The transition window anomalies cluster at shift change. I've run the numbers four times. It's not random."
|
||||
role: scheduler
|
||||
access: [peer]
|
||||
@@ -98,7 +98,7 @@ lines:
|
||||
fact_id: investigation.manifest_discrepancy
|
||||
confidence: knows_details
|
||||
|
||||
- id: the-terminal_d_306
|
||||
- id: scheduler_d_007
|
||||
text: "Three separate manifest entries show weight variations under ten kilos. Within rounding, technically."
|
||||
role: scheduler
|
||||
access: [peer]
|
||||
@@ -111,7 +111,7 @@ lines:
|
||||
smuggler: "She's tracking the small ones too. But 'within rounding' is what Voss told her. She's not satisfied."
|
||||
detective: "Sub-threshold variations. Small enough to dismiss individually. Maret's running aggregate analysis."
|
||||
|
||||
- id: the-terminal_d_307
|
||||
- id: scheduler_d_008
|
||||
text: "I filed a formal notice with Voss. He reviewed it and told me it was resolved. It's not resolved."
|
||||
role: scheduler
|
||||
access: [peer]
|
||||
@@ -131,7 +131,7 @@ lines:
|
||||
# ROUTINE SCHEDULING — grounded work
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_308
|
||||
- id: scheduler_d_009
|
||||
text: "Bay eight's running on reduced capacity until 1700. Route inbounds through six."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -141,7 +141,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [maret, operational]
|
||||
|
||||
- id: the-terminal_d_309
|
||||
- id: scheduler_d_010
|
||||
text: "Drin's running the inspection on bay six this cycle. Bay four's on hold per Voss's directive."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -154,7 +154,7 @@ lines:
|
||||
smuggler: "Maret confirmed it: bay four is Voss's directive. Not a system hold."
|
||||
detective: "The bay four hold comes directly from Voss, not from the system. Discretionary hold."
|
||||
|
||||
- id: the-terminal_d_310
|
||||
- id: scheduler_d_011
|
||||
text: "Overtime requests go through Meridian. I can't process them on paper anymore."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -164,7 +164,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [maret, administrative]
|
||||
|
||||
- id: the-terminal_d_311
|
||||
- id: scheduler_d_012
|
||||
text: "Manifest update at 1300. If your containers aren't posted by then, they don't make the cycle."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -178,7 +178,7 @@ lines:
|
||||
# DETECTIVE ENCOUNTER — access and resistance
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_312
|
||||
- id: scheduler_d_013
|
||||
text: "Commission manifest access requires a formal request through Voss. I can tell you what's on the public record."
|
||||
role: scheduler
|
||||
access: [authority]
|
||||
@@ -188,7 +188,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [maret, detective-path, procedural]
|
||||
|
||||
- id: the-terminal_d_313
|
||||
- id: scheduler_d_014
|
||||
text: "The flagged entries are in the system. They were logged as reviewed and closed. That's what I can confirm."
|
||||
role: scheduler
|
||||
access: [authority]
|
||||
@@ -201,7 +201,7 @@ lines:
|
||||
smuggler: "Never encounters this line."
|
||||
detective: "Maret confirms the flags exist in the system. And she's using careful language: 'what I can confirm.' There's more."
|
||||
|
||||
- id: the-terminal_d_314
|
||||
- id: scheduler_d_015
|
||||
text: "I'm not in a position to speak to why certain reviews were closed the way they were."
|
||||
role: scheduler
|
||||
access: [authority]
|
||||
@@ -218,7 +218,7 @@ lines:
|
||||
# ELEVATED TRUST — Maret breaks professional distance
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_315
|
||||
- id: scheduler_d_016
|
||||
text: "The pattern I'm seeing doesn't make sense for equipment failure. It's too consistent."
|
||||
role: scheduler
|
||||
access: [peer, insider]
|
||||
@@ -234,7 +234,7 @@ lines:
|
||||
fact_id: investigation.manifest_discrepancy
|
||||
confidence: knows_details
|
||||
|
||||
- id: the-terminal_d_316
|
||||
- id: scheduler_d_017
|
||||
text: "I filed a second formal notice two cycles ago. It's been under review since then. By Voss."
|
||||
role: scheduler
|
||||
access: [peer]
|
||||
@@ -250,7 +250,7 @@ lines:
|
||||
fact_id: behavioral.voss_awareness
|
||||
confidence: knows_details
|
||||
|
||||
- id: the-terminal_d_317
|
||||
- id: scheduler_d_018
|
||||
text: "I don't think I should be the one telling you this."
|
||||
role: scheduler
|
||||
access: [insider]
|
||||
@@ -265,7 +265,7 @@ lines:
|
||||
# SIGN-OFF
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_318
|
||||
- id: scheduler_d_019
|
||||
text: "Manifest posted for the evening shift. I'm off at 1500."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -275,7 +275,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [maret, closing]
|
||||
|
||||
- id: the-terminal_d_319
|
||||
- id: scheduler_d_020
|
||||
text: "Numbers don't lie. People lie about numbers."
|
||||
role: scheduler
|
||||
access: [peer]
|
||||
@@ -290,7 +290,7 @@ lines:
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_320
|
||||
- id: scheduler_d_021
|
||||
text: "Bay assignments updated as of 0900. Check before you touch anything."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -300,7 +300,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [maret, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_321
|
||||
- id: scheduler_d_022
|
||||
text: "Inbound from the Havara corridor is delayed six hours. Adjust your bay queue."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -310,7 +310,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [maret, operational, generation-pass]
|
||||
|
||||
- id: the-terminal_d_322
|
||||
- id: scheduler_d_023
|
||||
text: "Scan log for bay six is still open from last cycle. Close it before end of shift."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -320,7 +320,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [maret, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_323
|
||||
- id: scheduler_d_024
|
||||
text: "Overtime requests need scheduler approval now. Voss changed the process."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -330,7 +330,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [maret, voss, administrative, generation-pass]
|
||||
|
||||
- id: the-terminal_d_324
|
||||
- id: scheduler_d_025
|
||||
text: "If a container's not logged by 1300, it doesn't make the cycle. That's the rule."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -340,7 +340,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [maret, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_325
|
||||
- id: scheduler_d_026
|
||||
text: "End-of-cycle reconciliation is at 1430. Be available."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -350,7 +350,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [maret, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_326
|
||||
- id: scheduler_d_027
|
||||
text: "Weight tolerance is plus or minus five kilos. Anything outside that gets a flag."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
@@ -360,7 +360,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [maret, procedural, manifest, generation-pass]
|
||||
|
||||
- id: the-terminal_d_327
|
||||
- id: scheduler_d_028
|
||||
text: "Commission audit prep starts next cycle. Get your documentation current."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
|
||||
@@ -12,7 +12,7 @@ lines:
|
||||
# GREETINGS — shift arrival
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_100
|
||||
- id: shift-supervisor_d_001
|
||||
text: "You're late. Don't be late."
|
||||
role: shift-supervisor
|
||||
access: [public, peer, insider]
|
||||
@@ -22,7 +22,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, greeting, authority]
|
||||
|
||||
- id: the-terminal_d_101
|
||||
- id: shift-supervisor_d_002
|
||||
text: "Manifest's posted. Bay assignments are final — don't renegotiate them with me."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -32,7 +32,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, procedural]
|
||||
|
||||
- id: the-terminal_d_102
|
||||
- id: shift-supervisor_d_003
|
||||
text: "Bay 4's running a review hold. Nobody touches that freight until I say."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -45,7 +45,7 @@ lines:
|
||||
smuggler: "Review hold on Bay 4. Voss knows the container's in the queue. Is he covering it or flagging it?"
|
||||
detective: "Supervisor flagging Bay 4 himself. Either due diligence or containment."
|
||||
|
||||
- id: the-terminal_d_103
|
||||
- id: shift-supervisor_d_004
|
||||
text: "Commission liaison's in the district this cycle. Keep the floor clean."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -62,7 +62,7 @@ lines:
|
||||
# SHIFT AUTHORITY — routine management
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_104
|
||||
- id: shift-supervisor_d_005
|
||||
text: "Drin was supposed to run the bay six inspection. Tell him I'm asking."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -72,7 +72,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, drin, authority]
|
||||
|
||||
- id: the-terminal_d_105
|
||||
- id: shift-supervisor_d_006
|
||||
text: "Transition window's twenty minutes. Not twenty-five, not thirty. Twenty."
|
||||
role: shift-supervisor
|
||||
access: [public, peer, insider]
|
||||
@@ -82,7 +82,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, procedural, transition-window]
|
||||
|
||||
- id: the-terminal_d_106
|
||||
- id: shift-supervisor_d_007
|
||||
text: "Overtime requests go through the Meridian form. Don't bring them to me."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -92,7 +92,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, procedural]
|
||||
|
||||
- id: the-terminal_d_107
|
||||
- id: shift-supervisor_d_008
|
||||
text: "Bay eight's going to downtime at 1300. Plan around it."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -102,7 +102,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, operational]
|
||||
|
||||
- id: the-terminal_d_108
|
||||
- id: shift-supervisor_d_009
|
||||
text: "Maret flagged a discrepancy. I've reviewed it. It's logged. Move on."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -118,7 +118,7 @@ lines:
|
||||
fact_id: investigation.oversight_gap_pattern
|
||||
confidence: suspects
|
||||
|
||||
- id: the-terminal_d_109
|
||||
- id: shift-supervisor_d_010
|
||||
text: "Freight's moving. That's what matters. Questions slow things down."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -132,7 +132,7 @@ lines:
|
||||
# SCHEDULING COVER — insider, real trust
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_110
|
||||
- id: shift-supervisor_d_011
|
||||
text: "Bay four is clear for transition. I've adjusted the inspection schedule."
|
||||
role: shift-supervisor
|
||||
access: [insider]
|
||||
@@ -145,7 +145,7 @@ lines:
|
||||
smuggler: "He adjusted the schedule. The window is actually clear. Voss is part of this."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: the-terminal_d_111
|
||||
- id: shift-supervisor_d_012
|
||||
text: "Drin's on bay six. Bay four's mine tonight."
|
||||
role: shift-supervisor
|
||||
access: [insider]
|
||||
@@ -155,7 +155,7 @@ lines:
|
||||
mood: [focused]
|
||||
tags: [voss, ring-ops, scheduling-cover, drin]
|
||||
|
||||
- id: the-terminal_d_112
|
||||
- id: shift-supervisor_d_013
|
||||
text: "The hold note is administrative. Don't treat it as anything else."
|
||||
role: shift-supervisor
|
||||
access: [insider]
|
||||
@@ -170,7 +170,7 @@ lines:
|
||||
# DETECTIVE ENCOUNTER — authority tier
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_113
|
||||
- id: shift-supervisor_d_014
|
||||
text: "You'll need to submit a formal access request through Commission channels for any restricted data."
|
||||
role: shift-supervisor
|
||||
access: [authority]
|
||||
@@ -180,7 +180,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, detective-path, institutional-resistance]
|
||||
|
||||
- id: the-terminal_d_114
|
||||
- id: shift-supervisor_d_015
|
||||
text: "The manifest's available to any Commission officer. Ask the scheduler."
|
||||
role: shift-supervisor
|
||||
access: [authority]
|
||||
@@ -190,7 +190,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, detective-path, redirect]
|
||||
|
||||
- id: the-terminal_d_115
|
||||
- id: shift-supervisor_d_016
|
||||
text: "My workers perform their duties. If there's an irregularity, it'll show in the log."
|
||||
role: shift-supervisor
|
||||
access: [authority]
|
||||
@@ -200,7 +200,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, detective-path, deflection]
|
||||
|
||||
- id: the-terminal_d_116
|
||||
- id: shift-supervisor_d_017
|
||||
text: "I've been running this floor for six years. The numbers work out."
|
||||
role: shift-supervisor
|
||||
access: [authority, public]
|
||||
@@ -214,7 +214,7 @@ lines:
|
||||
# PRESSURE / CONFRONTATION
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_117
|
||||
- id: shift-supervisor_d_018
|
||||
text: "Close the door."
|
||||
role: shift-supervisor
|
||||
access: [insider, peer]
|
||||
@@ -224,7 +224,7 @@ lines:
|
||||
mood: [frustrated]
|
||||
tags: [voss, confrontation, private]
|
||||
|
||||
- id: the-terminal_d_118
|
||||
- id: shift-supervisor_d_019
|
||||
text: "Maret keeps flagging the discrepancies. That needs to stop being her problem."
|
||||
role: shift-supervisor
|
||||
access: [insider]
|
||||
@@ -237,7 +237,7 @@ lines:
|
||||
fact_id: behavioral.voss_awareness
|
||||
confidence: knows_of
|
||||
|
||||
- id: the-terminal_d_119
|
||||
- id: shift-supervisor_d_020
|
||||
text: "If you've got a problem with how I run this floor, that's a conversation for another time."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -251,7 +251,7 @@ lines:
|
||||
# SIGN-OFF / END OF SHIFT
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_120
|
||||
- id: shift-supervisor_d_021
|
||||
text: "Sign-off is at 1400. Be done."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -261,7 +261,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, procedural]
|
||||
|
||||
- id: the-terminal_d_121
|
||||
- id: shift-supervisor_d_022
|
||||
text: "Night shift takes over at 2200. I don't want to hear about the handoff tomorrow."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -271,7 +271,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, procedural]
|
||||
|
||||
- id: the-terminal_d_122
|
||||
- id: shift-supervisor_d_023
|
||||
text: "Good shift. Tomorrow at 0600."
|
||||
role: shift-supervisor
|
||||
access: [public, peer, insider]
|
||||
@@ -285,7 +285,7 @@ lines:
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_123
|
||||
- id: shift-supervisor_d_024
|
||||
text: "Assignments don't change once they're posted. Read the board."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -295,7 +295,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_124
|
||||
- id: shift-supervisor_d_025
|
||||
text: "If there's a discrepancy, it goes to the scheduler. Not to me directly."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -305,7 +305,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, procedural, maret, generation-pass]
|
||||
|
||||
- id: the-terminal_d_125
|
||||
- id: shift-supervisor_d_026
|
||||
text: "Span gate's cycling at 1100. Clear the bay six access before then."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -315,7 +315,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, operational, generation-pass]
|
||||
|
||||
- id: the-terminal_d_126
|
||||
- id: shift-supervisor_d_027
|
||||
text: "I don't repeat myself on safety calls. If you missed it, ask someone who was listening."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -325,7 +325,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, authority, generation-pass]
|
||||
|
||||
- id: the-terminal_d_127
|
||||
- id: shift-supervisor_d_028
|
||||
text: "End-of-day log goes to Meridian before you leave. No exceptions."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -335,7 +335,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_128
|
||||
- id: shift-supervisor_d_029
|
||||
text: "Inbounds from Havara are on the revised schedule. Check the update."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
@@ -345,7 +345,7 @@ lines:
|
||||
mood: [content]
|
||||
tags: [voss, operational, generation-pass]
|
||||
|
||||
- id: the-terminal_d_129
|
||||
- id: shift-supervisor_d_030
|
||||
text: "Clean floor, clean manifest. That's the job."
|
||||
role: shift-supervisor
|
||||
access: [public, peer, insider]
|
||||
|
||||
@@ -47,8 +47,8 @@ items:
|
||||
notes: >
|
||||
Physical proof of cargo routing irregularities. The manifest shows
|
||||
three containers rerouted during shift transition with weights that
|
||||
don't reconcile. Cross-ref: Kael dialogue the-terminal_d_008
|
||||
("Manifest says this one's forty kilos over") and the-terminal_d_018
|
||||
don't reconcile. Cross-ref: Kael dialogue kael-davan_d_041
|
||||
("Manifest says this one's forty kilos over") and kael-davan_d_051
|
||||
("Manifest's clean on my end").
|
||||
- fact_id: "investigation.cargo_anomaly"
|
||||
relationship: "corroborates"
|
||||
|
||||