Compare commits
39
Commits
2001d89b6c
...
219af065d8
@@ -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,19 @@
|
||||
# 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.
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -174,19 +174,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+22
-9
@@ -7,17 +7,31 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
|
||||
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
|
||||
- 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)
|
||||
|
||||
### Previously added
|
||||
- 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/
|
||||
@@ -27,6 +41,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- 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
|
||||
@@ -41,13 +61,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- assert!(false) → panic!() in serialization tests (clippy)
|
||||
- SetFacing and TeleportToHub added to roundtrip test coverage
|
||||
|
||||
### Changed
|
||||
- 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
|
||||
|
||||
## [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,73 +46,18 @@ 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 ..."` | — |
|
||||
| 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)
|
||||
@@ -149,35 +66,3 @@ Key rules:
|
||||
- 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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=24 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,7 @@
|
||||
[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"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -118,6 +119,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")]
|
||||
|
||||
|
||||
@@ -240,16 +240,16 @@ 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
|
||||
# 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)
|
||||
|
||||
@@ -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,7 @@ 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 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)
|
||||
@@ -104,7 +105,7 @@ func _process(delta: float) -> void:
|
||||
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 +132,10 @@ 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()
|
||||
|
||||
# #511: Update debug overlay (F3 toggle, dev tool)
|
||||
if debug_overlay and debug_overlay.has_method("update_from_state"):
|
||||
debug_overlay.update_from_state()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,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")
|
||||
@@ -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,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")
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
character: detective
|
||||
location: contradiction
|
||||
lines:
|
||||
# Contradiction Monologue — Ticket #552 — Sera/Kael FRIEND Arc
|
||||
# Authored per D-083 (contradiction detection pipeline) and Paula Round 2 Mellanie spec
|
||||
# (docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md, lines 117-158).
|
||||
#
|
||||
# Trigger: contradiction_detected fires when the detective observes Kael in Corridor B-7
|
||||
# while the KG holds Sera's claim that Kael was at dock intake during second shift.
|
||||
# Event payload: source_display_name = "Sera Venn", subject_display_name = "Kael Davan"
|
||||
#
|
||||
# FRIEND-pattern NPCs (Sera, Kael) always use hand-authored lines — generic template fallback
|
||||
# does not fire for this arc.
|
||||
#
|
||||
# Authoring principle: cognitive dissonance, not accusation. The detective doesn't know
|
||||
# who or what is wrong. Uncertainty first, suspicion second, accusation never.
|
||||
#
|
||||
# Phase 2 lines: trust established but not deep — the blindsiding.
|
||||
# Prerequisite: KG entry for Kael has state: Contradicted, source: ToldBy(Sera Venn)
|
||||
# Phase 3 lines: additional context accumulated — Sera's avoidance of Torek observed 3x.
|
||||
# Prerequisite: Phase 2 + Sera's avoidance pattern has fired (three departures logged).
|
||||
#
|
||||
# ID discriminator: _con_ marks contradiction lines per Q-028 pending resolution.
|
||||
# cooldown: omitted — fire-once enforced by trigger semantics (contradiction_detected
|
||||
# fires once per KG state change), not by a cooldown value.
|
||||
#
|
||||
# Note: contradiction_detected is a new trigger type — requires server-side implementation
|
||||
# in monologue.rs (ticket #550). Secondary beat lines use lower priority for sequencing;
|
||||
# server team should implement delay_after_trigger_ticks for proper 3-5 second gap.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PHASE 2 — Primary beat (fires on contradiction_detected, immediate)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_con_001
|
||||
text: "Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 10
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical]
|
||||
notes: >
|
||||
Paula's canonical primary line — preserved verbatim. Flat factual statement: source named,
|
||||
subject named, specific location claim vs. direct observation. The 'I'm' contraction is
|
||||
natural in a surprise moment even for the analytical detective — the observation breaks
|
||||
through the professional register. No interpretation, no accusation.
|
||||
|
||||
- id: pc-detective_m_d_con_002
|
||||
text: "Venn said Kael was at dock intake through second shift. Kael is in B-7. Those aren't compatible."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 9
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical]
|
||||
notes: >
|
||||
Alternate primary — fully analytical register: surname-initial for both (institutional
|
||||
reflex), no contractions, plain declarative close. 'Those aren't compatible' is the
|
||||
detective's version of stating contradiction — clinical, process-language. Fires as
|
||||
alternate to con_001 for variety on replay.
|
||||
|
||||
- id: pc-detective_m_d_con_003
|
||||
text: "She placed Kael at the dock. He's not at the dock."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 9
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-2]
|
||||
notes: >
|
||||
Stripped-down alternate. 'She placed' is the informal of 'Venn stated' — personal register
|
||||
triggered by Sera being THE FRIEND. 'He's not at the dock' — the barest possible statement
|
||||
of contradiction. The brevity is the weight: six words and the investigation changes shape.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PHASE 2 — Secondary beat (fires after short delay, cognitive processing)
|
||||
# Server team (#550): implement delay_after_trigger_ticks: 90 for 3-5s gap.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_con_004
|
||||
text: "One of them is wrong. Sera, or what I'm seeing. Or I'm missing something I don't have yet."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, alone]
|
||||
trigger: contradiction_detected
|
||||
mood: [anxious, focused]
|
||||
priority: 7
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-2, cognitive-dissonance, secondary-beat]
|
||||
notes: >
|
||||
Paula's canonical secondary beat — fires 3-5 seconds after the primary beat.
|
||||
Epistemic neutrality held across three options: Sera is wrong, observation is wrong, or
|
||||
context is missing. No option is dismissed. 'I'm missing something I don't have yet' is
|
||||
the key line — it holds accusation off even against evidence. The detective files it open.
|
||||
Lower priority (7) ensures primary lines fire first; server team should implement
|
||||
delay_after_trigger_ticks for proper sequencing.
|
||||
|
||||
- id: pc-detective_m_d_con_005
|
||||
text: "File it. Don't close it. Either Venn's information was wrong when she gave it, or it was wrong on purpose. That's a different question."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, alone]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical, cognitive-dissonance, secondary-beat]
|
||||
notes: >
|
||||
Alternate secondary beat. 'File it, don't close it' is the detective's internal procedure
|
||||
for unresolved data. The critical distinction: wrong information vs. deliberately wrong
|
||||
information — the detective separates these without assuming either. 'That's a different
|
||||
question' closes the processing loop, marking it unresolved, not dismissed.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PHASE 3 — With accumulated context (Sera's avoidance pattern observed)
|
||||
# Requires: three Sera departures correlated with Torek Lintar arrivals observed.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_con_006
|
||||
text: "Sera told me Kael doesn't make mistakes. He's not where she said he'd be. And she's been avoiding Lintar for three weeks."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 10
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
- id: "npc:torek-lintar"
|
||||
relationship: PersonOfInterest
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-3, pattern-recognition]
|
||||
notes: >
|
||||
Paula's canonical Phase 3 line — preserved verbatim. Three facts in sequence, no explicit
|
||||
connection drawn between them: Sera's testimony about Kael ('doesn't make mistakes'),
|
||||
Kael's actual location, Sera's avoidance behavior. The detective is connecting dots but
|
||||
not announcing the conclusion. The emotional weight is in 'Sera told me' — personal register
|
||||
for the friend relationship — vs. the clinical observation about Lintar.
|
||||
|
||||
- id: pc-detective_m_d_con_007
|
||||
text: "Two facts: Venn placed Kael at dock intake, second shift. Kael is in B-7. One new observation: Venn leaves every time Lintar enters the bar. Worth seeing if those connect."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 9
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
- id: "npc:torek-lintar"
|
||||
relationship: PersonOfInterest
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-3, analytical, pattern-recognition]
|
||||
notes: >
|
||||
Alternate Phase 3 — analytical register throughout, no personal register even for Sera
|
||||
(surname only). 'Worth seeing if those connect' is the detective's verbal tic of flagging
|
||||
without concluding. The numbered format ('Two facts... One new observation') is the
|
||||
detective's internal case-building style. Fully non-accusatory: the connection is not
|
||||
stated, only the intent to look for it.
|
||||
|
||||
- id: pc-detective_m_d_con_008
|
||||
text: "Venn's been managing her exits around Lintar. And she put Kael somewhere he isn't. Let's see what sits at the intersection of those two."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, alone]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
- id: "npc:sera-venn"
|
||||
state: Contradicted
|
||||
- id: "npc:torek-lintar"
|
||||
relationship: PersonOfInterest
|
||||
tags: [contradiction, friend-arc, sera, kael, phase-3, analytical]
|
||||
notes: >
|
||||
Alternate Phase 3. 'Let's see what' verbal tic, no accusation. 'Managing her exits' is
|
||||
the detective naming Sera's avoidance behavior — behavioral description, not motive
|
||||
attribution. 'Put Kael somewhere he isn't' — plain contradiction framing. The intersection
|
||||
remains unstated. Detective is following the thread, not announcing what's on it.
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
character: detective
|
||||
location: tutorial
|
||||
lines:
|
||||
# Diegetic Tutorial Monologue — Ticket #330 — Detective
|
||||
# Authored per sprint-17 copy briefing and D-016 (internal monologue as core system).
|
||||
#
|
||||
# These lines teach mechanics through character voice — not UI instructions.
|
||||
# Distinct from opening.yaml (which covers the first 5 minutes and sets voice/motivation).
|
||||
# These fire on first-time events throughout gameplay, wherever they occur.
|
||||
#
|
||||
# Coverage:
|
||||
# Movement / exploration — first_move, first_new_section
|
||||
# Fog of perception — first_fog_encounter, first_fog_edge
|
||||
# Sound model — first_sound_heard, first_off_screen_sound
|
||||
# NPC interaction — first_npc_proximity, first_npc_face_read
|
||||
# Insert / HUD — first_insert_open, first_insert_contact_flag
|
||||
#
|
||||
# Voice: analytical, procedure-oriented, careful. No contractions in analytical mode.
|
||||
# Colon usage for categorization. 'Let's see what...' and 'Worth flagging' verbal tics.
|
||||
# ID discriminator: _tut_ per sprint briefing.
|
||||
# cooldown: omitted — fire-once enforced by trigger semantics (first_* triggers
|
||||
# fire once per game), not by a cooldown value.
|
||||
# priority: 8 (primary, one per trigger type), 6 (secondary variant, mood-weighted alternate).
|
||||
#
|
||||
# Schema corrections (Gestalt review, Sprint 17):
|
||||
# priority: string "tutorial" → integer. All other monologue files use integer priority;
|
||||
# string value would fail engine parse in monologue.rs.
|
||||
# situation: removed invalid values (movement, exploration) not in D-035 13-situation
|
||||
# enum. Replaced with arrival, routine per actual trigger context.
|
||||
#
|
||||
# Trigger types (first_move, first_new_section, first_fog_encounter, first_fog_edge,
|
||||
# first_sound_heard, first_off_screen_sound, first_npc_proximity, first_npc_face_read,
|
||||
# first_insert_open, first_insert_contact_flag) are extended trigger enum values not in
|
||||
# D-035 v0.1 set — require server-side implementation in monologue.rs.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# MOVEMENT / EXPLORATION
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_tut_001
|
||||
text: "Signage: section designators on the corridor wall. Commission-standard markings. Worth learning before the district learns me."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
trigger: first_move
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, movement, navigation, analytical]
|
||||
notes: >
|
||||
Detective version of the 'check the signage' tutorial. Colon usage for the signage
|
||||
label (section 2.2 verbal pattern). 'Commission-standard markings' — institutional
|
||||
framing; he reads the environment through his professional lens first. 'Worth learning
|
||||
before the district learns me' — dry awareness of his asymmetric visibility in this
|
||||
community. Same teaching as the smuggler version, different cognitive frame.
|
||||
|
||||
- id: pc-detective_m_d_tut_002
|
||||
text: "Three sections in range from the main terminal. Commission briefing had the layout. Let's confirm it against the floor."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [arrival, investigation]
|
||||
trigger: first_new_section
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, movement, navigation, analytical]
|
||||
notes: >
|
||||
Fires on entering a new section for the first time. 'Commission briefing had the
|
||||
layout' — he arrived with a map, now he's verifying it against reality. 'Let's
|
||||
confirm it against the floor' — 'Let's' verbal tic, investigative method stated as
|
||||
principle: documentation is a starting point, not a substitute for observation.
|
||||
Teaches: the district has distinct sections to explore.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# FOG OF PERCEPTION
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_tut_003
|
||||
text: "Visual range terminates at the corridor junction. Consistent with district atmo settings. Anything past that point: unconfirmed."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_fog_encounter
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, fog, perception, analytical]
|
||||
notes: >
|
||||
Detective fog tutorial — analytical register throughout. 'Visual range terminates at'
|
||||
instead of 'Can't see past.' 'Consistent with atmo settings' — he already knows this
|
||||
is an environmental property, not an anomaly. 'Anything past that point: unconfirmed'
|
||||
— colon usage, precise epistemic status. Teaches: perception boundary exists, and
|
||||
the detective categorizes it as an information state, not a barrier. Compare to
|
||||
smuggler's 'Might be worth checking' — same fog, different response.
|
||||
|
||||
- id: pc-detective_m_d_tut_004
|
||||
text: "Can't clear the far bay from here. Going around is the procedure — not a limitation, a working condition."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_fog_edge
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, fog, perception, analytical]
|
||||
notes: >
|
||||
Second fog encounter line — fires at a fog zone boundary. 'Going around is the
|
||||
procedure' — detective frames the limitation as methodology. The second clause
|
||||
('not a limitation, a working condition') is self-instruction: he's telling himself
|
||||
not to treat fog as frustrating but as standard operating environment. Teaches:
|
||||
perception range is persistent throughout gameplay, and the correct response is
|
||||
movement, not waiting.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# SOUND MODEL
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_tut_005
|
||||
text: "Voices. Down the corridor. Source count indeterminate from here — distance degrades clarity."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_sound_heard
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, sound, perception, analytical]
|
||||
notes: >
|
||||
Detective sound tutorial — analytical framing of the briefing's example. 'Source count
|
||||
indeterminate' is the precise version of 'can't tell how many.' 'Distance degrades
|
||||
clarity' — technical, causal. He hears, notes what he can establish (voices, direction)
|
||||
and what he can't (count, content). No contractions. Teaches: sound channel is active
|
||||
and distance-limited. Compare to smuggler's 'Can't make out the words' — same acoustic
|
||||
reality, systematized differently.
|
||||
|
||||
- id: pc-detective_m_d_tut_006
|
||||
text: "Footsteps. Metal-grated surface. Two sets minimum — moving away. Service access corridor, by the acoustic signature."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_off_screen_sound
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, sound, perception, analytical]
|
||||
notes: >
|
||||
Second sound tutorial — fires on an off-screen audio source. The detective parses
|
||||
the sound through multiple analytical dimensions: surface type, count, direction,
|
||||
room type. 'Two sets minimum' — precise but not overclaiming. 'By the acoustic
|
||||
signature' is technical terminology applied to diegetic audio interpretation.
|
||||
Teaches: sound carries more than presence — floor type, distance, count, direction
|
||||
are all extractable. The detective extracts all of them automatically.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# NPC INTERACTION
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_tut_007
|
||||
text: "Civilians in range. Standard approach: observe before engaging. Let them surface what's relevant before directing the conversation."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [social, observation]
|
||||
trigger: first_npc_proximity
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, npc, interaction, analytical]
|
||||
notes: >
|
||||
Detective NPC tutorial — procedural, method-as-principle. 'Civilians in range' is
|
||||
his categorization (Commission-trained: everyone is a witness category until proven
|
||||
otherwise). 'Standard approach' — he has a protocol. 'Let them surface what's relevant'
|
||||
is the investigative principle: don't prime witnesses, let them self-select information.
|
||||
No contractions throughout. Teaches: NPC interaction is player-initiated and
|
||||
approach-sensitive. Different method than the smuggler ('give them reason to') —
|
||||
same outcome, different social model.
|
||||
|
||||
- id: pc-detective_m_d_tut_008
|
||||
text: "Commission credentials flag on approach. They know who I am before I speak. Worth noting who adjusts their behavior."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [social, investigation]
|
||||
trigger: first_npc_face_read
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, npc, observation, analytical, authority]
|
||||
notes: >
|
||||
Fires when the player observes an NPC at close range. The detective's asymmetry:
|
||||
his authority access tier makes him visible in ways the smuggler is not. NPCs react
|
||||
to his presence before interaction begins. 'Worth noting who adjusts their behavior'
|
||||
— 'Worth' verbal tic, sets up behavioral observation as investigation method. Teaches:
|
||||
the detective's Commission credentials affect NPC behavior, and that behavioral
|
||||
response is data.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# INSERT / HUD
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-detective_m_d_tut_009
|
||||
text: "Lattice overlay active: time, contact positions, flagged entities. Commission-linked. Standard procedure to keep it running."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
trigger: first_insert_open
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, insert, hud, analytical]
|
||||
notes: >
|
||||
Detective insert tutorial. Colon usage: catalogs what the overlay contains. 'Commission-
|
||||
linked' — institutional framing, establishes why the detective's HUD is denser than
|
||||
the smuggler's. 'Standard procedure' — not a discovery, a protocol. Teaches: the
|
||||
insert is an active investigation tool, not optional. Compare to smuggler's 'Should
|
||||
show the time and nearby contacts' — same overlay, presented as procedural requirement
|
||||
vs. operational check.
|
||||
|
||||
- id: pc-detective_m_d_tut_010
|
||||
text: "Nine identity flags in range. Cross-referencing case file now. Let's see what matches."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, routine]
|
||||
trigger: first_insert_contact_flag
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, insert, hud, lattice, investigation]
|
||||
notes: >
|
||||
Fires when the insert first populates with flagged entities. Count given precisely
|
||||
('Nine identity flags'). 'Case file' — the detective's overlay cross-references a
|
||||
pre-loaded investigation file, not just a general registry. 'Let's see what matches'
|
||||
— 'Let's see what' verbal tic closing the tutorial sequence. Teaches: the detective's
|
||||
insert is investigation-keyed, matching observed faces against case file entries.
|
||||
This is why his overlay is analytically denser than the smuggler's.
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
character: smuggler
|
||||
location: contradiction
|
||||
lines:
|
||||
# Contradiction Monologue — Ticket #552 — Sera/Kael FRIEND Arc (Smuggler perspective)
|
||||
# Authored per D-083 (contradiction detection pipeline) and Paula Round 2 Mellanie spec
|
||||
# (docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md, lines 117-158).
|
||||
#
|
||||
# Trigger: contradiction_detected fires when the smuggler observes Kael in Corridor B-7
|
||||
# with an unrecognized contact — contradicting Kael's stated location and ring protocol.
|
||||
# Event payload: source_display_name = "Kael Davan", subject_display_name = "Kael Davan"
|
||||
# (Kael is both source and subject: he placed himself elsewhere, smuggler sees him here.)
|
||||
#
|
||||
# FRIEND-pattern NPCs always use hand-authored lines — generic template fallback
|
||||
# does not fire for this arc.
|
||||
#
|
||||
# Authoring principle: cognitive dissonance, not accusation. The smuggler reads social
|
||||
# signals instinctively, but doesn't conclude — she contains the alarm. Street-smart
|
||||
# gut reaction, not analytical procedure.
|
||||
#
|
||||
# Phase 2 lines: first observation — Kael in B-7 with unknown contact.
|
||||
# Prerequisite: KG entry for Kael has state: Contradicted, source: ToldBy(Kael Davan)
|
||||
# Phase 3 lines: behavioral pattern already accumulating — increased lattice checking,
|
||||
# shortened interactions, early exits from break room.
|
||||
# Prerequisite: Phase 2 + Kael's behavioral anomalies observed (count >= 2).
|
||||
#
|
||||
# ID discriminator: _con_ marks contradiction lines per Q-028 pending resolution.
|
||||
# cooldown: omitted — fire-once enforced by trigger semantics (contradiction_detected
|
||||
# fires once per KG state change), not by a cooldown value.
|
||||
#
|
||||
# Note: contradiction_detected is a new trigger type — requires server-side implementation
|
||||
# in monologue.rs (ticket #550). Secondary beat lines use lower priority for sequencing;
|
||||
# server team should implement delay_after_trigger_ticks for proper 3-5 second gap.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PHASE 2 — Primary beat (fires on contradiction_detected, immediate)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_con_001
|
||||
text: "Kael said second shift was clear. He's in B-7. With someone I don't know."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 10
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, kael, phase-2, operational]
|
||||
notes: >
|
||||
Primary line — three beats, three facts. 'Kael said' names the source (friend register,
|
||||
first name). 'He's in B-7' is the observation. 'With someone I don't know' is the alarm:
|
||||
not just wrong location, but outside the ring. Fragment structure throughout — the smuggler
|
||||
clocks a scene in pieces. No accusation, no conclusion: just inventory of what's wrong.
|
||||
|
||||
- id: pc-smuggler_m_s_con_002
|
||||
text: "That's Kael. Restricted corridor. Unrecognized contact. Kael doesn't come to B-7."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 9
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, kael, phase-2, operational]
|
||||
notes: >
|
||||
Alternate primary — catalog format. 'That's Kael' opens with identification, not alarm:
|
||||
recognition first, then the wrong details fall into place. Each fragment adds one layer
|
||||
of what's wrong. Close: 'Kael doesn't come to B-7' — negative statement of prior
|
||||
expectation, which is now violated. No contraction in the closing declarative (emphasis).
|
||||
|
||||
- id: pc-smuggler_m_s_con_003
|
||||
text: "He's in B-7. He told me he'd be heading home after shift. One of those isn't the case."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 9
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, kael, phase-2]
|
||||
notes: >
|
||||
Alternate primary — echoes the Paula canonical structure (observation / prior claim /
|
||||
contradiction stated) but in the smuggler's voice. 'He told me' is personal register
|
||||
(friend, not colleague). 'One of those isn't the case' — the smuggler's understated
|
||||
version of flagging a contradiction: not 'one of them is a lie' but 'isn't the case.'
|
||||
Epistemic containment in plain language.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PHASE 2 — Secondary beat (gut-level recalibration, operational containment)
|
||||
# Server team (#550): implement delay_after_trigger_ticks: 90 for 3-5s gap.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_con_004
|
||||
text: "Maybe there's a reason. There's always a reason. But Kael knows better than to run a meeting in a maintenance corridor without telling me."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, alone]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 7
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, kael, phase-2, cognitive-dissonance, secondary-beat]
|
||||
notes: >
|
||||
Secondary beat — fires 3-5 seconds after primary. Self-challenge first: the smuggler
|
||||
gives Kael benefit of the doubt, then immediately walks it back with operational
|
||||
logic. 'There's always a reason' is the street-smart equivalent of epistemic
|
||||
containment — she's seen strange things before. The closing line is where the alarm
|
||||
lives: this isn't strange, it's a protocol violation.
|
||||
Lower priority (7) ensures primary lines fire first; server team should implement
|
||||
delay_after_trigger_ticks for proper sequencing.
|
||||
|
||||
- id: pc-smuggler_m_s_con_005
|
||||
text: "Don't assume. Could be he's running something for Nils I wasn't told about. Could be something else. The second thing is the problem."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, alone]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tags: [contradiction, friend-arc, kael, phase-2, operational, cognitive-dissonance, secondary-beat]
|
||||
notes: >
|
||||
Alternate secondary beat. 'Don't assume' is the smuggler's self-instruction (operational
|
||||
discipline). Two possibilities held open: ring-authorized (names Nils — the coordinator)
|
||||
vs. outside the ring. 'The second thing is the problem' — the alarm, stated as dry
|
||||
understatement. Operational logic applied to an emotional moment: the smuggler
|
||||
keeps the processing clean even when the stakes are personal.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PHASE 3 — Behavioral pattern accumulated (increased lattice checking, early exits)
|
||||
# Requires: Kael behavioral anomalies observed prior to the B-7 contradiction.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_con_006
|
||||
text: "He's been distracted for three days. Lattice checks every few minutes, leaving lunch early. Now a B-7 meeting with someone outside the ring. Kael's working an angle he hasn't told me about."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused, anxious]
|
||||
priority: 10
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tells_observed: 2
|
||||
tags: [contradiction, friend-arc, kael, phase-3, pattern-recognition, operational]
|
||||
notes: >
|
||||
Phase 3 primary — connects all accumulated behavioral data to the contradiction. Fragment
|
||||
catalog builds the picture: three days of signals, then this. Closing line names the
|
||||
conclusion without naming its implication: 'working an angle' is street-smart language
|
||||
for running something unauthorized, but the smuggler doesn't say 'exit attempt' —
|
||||
she doesn't know that yet.
|
||||
Note: tells_observed is a proposed prerequisite field — server team (#550) to implement
|
||||
or simplify to a different check.
|
||||
|
||||
- id: pc-smuggler_m_s_con_007
|
||||
text: "Kael doesn't run his own contacts. Devra handles external. So either Devra knows about this, or Kael's outside protocol. Neither's good."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [focused]
|
||||
priority: 9
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tells_observed: 2
|
||||
tags: [contradiction, friend-arc, kael, phase-3, operational]
|
||||
notes: >
|
||||
Alternate Phase 3 — operational knowledge applied. The smuggler knows ring structure:
|
||||
Devra manages external contacts, Kael handles cargo logistics. An unrecognized contact
|
||||
means either Devra is running something Kael's involved in (plausible, alarming) or
|
||||
Kael went outside protocol (worse). Both possibilities named, neither closed. 'Neither's
|
||||
good' — the smuggler's understated close for a significant threat read.
|
||||
|
||||
- id: pc-smuggler_m_s_con_008
|
||||
text: "He's been somewhere else in his head all week. Now I know why. Whoever that was — not ring. Kael's looking for a way out."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [investigation, observation]
|
||||
trigger: contradiction_detected
|
||||
mood: [anxious]
|
||||
priority: 8
|
||||
|
||||
prerequisites:
|
||||
entities:
|
||||
- id: "npc:kael-davan"
|
||||
state: Contradicted
|
||||
tells_observed: 2
|
||||
tags: [contradiction, friend-arc, kael, phase-3, intuition]
|
||||
notes: >
|
||||
Alternate Phase 3 — gut read, highest emotional weight. 'Somewhere else in his head'
|
||||
describes the behavioral changes without cataloging them. 'Now I know why' — the click
|
||||
of understanding arriving. 'Not ring' — short, certain, two words. 'Looking for a way
|
||||
out' is street-smart language for exit attempt: the smuggler doesn't know the details
|
||||
but the read is right. This line is the closest the smuggler comes to naming what's
|
||||
happening — and she still doesn't say it explicitly.
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
character: smuggler
|
||||
location: tutorial
|
||||
lines:
|
||||
# Diegetic Tutorial Monologue — Ticket #330 — Smuggler
|
||||
# Authored per sprint-17 copy briefing and D-016 (internal monologue as core system).
|
||||
#
|
||||
# These lines teach mechanics through character voice — not UI instructions.
|
||||
# Distinct from opening.yaml (which covers the first 5 minutes and sets voice/motivation).
|
||||
# These fire on first-time events throughout gameplay, wherever they occur.
|
||||
#
|
||||
# Coverage:
|
||||
# Movement / exploration — first_move, first_new_section
|
||||
# Fog of perception — first_fog_encounter, first_fog_edge
|
||||
# Sound model — first_sound_heard, first_off_screen_sound
|
||||
# NPC interaction — first_npc_proximity, first_npc_face_read
|
||||
# Insert / HUD — first_insert_open, first_insert_contact_flag
|
||||
#
|
||||
# Voice: observational, street-smart, practical. Fragments. Standalone "Good." as tic.
|
||||
# No analytical distance — the smuggler reads, reacts, files, moves on.
|
||||
# ID discriminator: _tut_ per sprint briefing.
|
||||
# cooldown: omitted — fire-once enforced by trigger semantics (first_* triggers
|
||||
# fire once per game), not by a cooldown value.
|
||||
# priority: 8 (primary, one per trigger type), 6 (secondary variant, mood-weighted alternate).
|
||||
#
|
||||
# Schema corrections (Gestalt review, Sprint 17):
|
||||
# priority: string "tutorial" → integer. All other monologue files use integer priority;
|
||||
# string value would fail engine parse in monologue.rs.
|
||||
# situation: removed invalid values (movement, exploration) not in D-035 13-situation
|
||||
# enum. Replaced with arrival, routine, observation per actual trigger context.
|
||||
# mood: [focused] retained where set; valid per D-035 Sprint 14 amendment.
|
||||
#
|
||||
# Trigger types (first_move, first_new_section, first_fog_encounter, first_fog_edge,
|
||||
# first_sound_heard, first_off_screen_sound, first_npc_proximity, first_npc_face_read,
|
||||
# first_insert_open, first_insert_contact_flag) are extended trigger enum values not in
|
||||
# D-035 v0.1 set — require server-side implementation in monologue.rs.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# MOVEMENT / EXPLORATION
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_tut_001
|
||||
text: "These corridors all look the same. Mental note: check the signage."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
trigger: first_move
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, movement, navigation]
|
||||
notes: >
|
||||
Directly from the sprint briefing example. First move teaches navigation: the district
|
||||
looks homogeneous, signage is the tool. 'Mental note' is the smuggler's internal
|
||||
flag for things to remember — operational housekeeping, not analysis. Establishes
|
||||
early: this environment requires active spatial tracking.
|
||||
|
||||
- id: pc-smuggler_m_s_tut_002
|
||||
text: "Two lefts past the junction, right at the supply hatch. That's dock four. Route's in the muscle now."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
trigger: first_new_section
|
||||
mood: [content]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, movement, navigation, operational]
|
||||
notes: >
|
||||
Fires on entering a new section for the first time. 'Route's in the muscle' — the
|
||||
smuggler doesn't memorize spatially, she moves until it's automatic. Practical,
|
||||
self-sufficient. Teaches: the district has multiple sections, each requires its own
|
||||
orientation. 'Dock four' is a landmark. Forward movement is rewarded with familiarity.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# FOG OF PERCEPTION
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_tut_003
|
||||
text: "Can't see past that corner. Might be worth checking."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_fog_encounter
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, fog, perception]
|
||||
notes: >
|
||||
Directly from the sprint briefing example. First fog boundary. Restated as habit:
|
||||
the smuggler doesn't treat the fog as a limitation, she treats every unknown corner
|
||||
as a question worth answering. 'Might be worth' — not paranoia, just operational
|
||||
instinct. Teaches: the perception boundary is navigable, not fixed.
|
||||
|
||||
- id: pc-smuggler_m_s_tut_004
|
||||
text: "Haze cuts off past the junction. Anything in it — can't say."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_fog_edge
|
||||
mood: [focused]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, fog, perception]
|
||||
notes: >
|
||||
Second fog encounter line — fires when the player reaches the boundary of a fog zone
|
||||
(the edge of visible range). 'Can't say' — plain statement of unknowing, not anxiety.
|
||||
The smuggler accepts incomplete information as a working condition, not a failure state.
|
||||
Teaches: perception range is an ongoing factor, not a one-time obstacle.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# SOUND MODEL
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_tut_005
|
||||
text: "Voices down the hall. Can't make out the words from here."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_sound_heard
|
||||
mood: [focused]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, sound, perception]
|
||||
notes: >
|
||||
Directly from the sprint briefing example. First sound heard that's not immediately
|
||||
visible. Teaching: sound carries information, but range and occlusion limit it.
|
||||
'Can't make out the words' establishes both the value of the sound channel (voices =
|
||||
people = relevant) and its limitation (unclear at range). Moving toward source is
|
||||
the implied next step.
|
||||
|
||||
- id: pc-smuggler_m_s_tut_006
|
||||
text: "Footsteps. Two sets. Moving away — good."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [observation]
|
||||
trigger: first_off_screen_sound
|
||||
mood: [content]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, sound, perception, operational]
|
||||
notes: >
|
||||
Second sound line — fires when audio from an off-screen source is detected. The
|
||||
smuggler parses footsteps as count and direction instinctively. 'Moving away — good.'
|
||||
— the standalone 'Good.' tic applied to tactical assessment. Teaches: sound carries
|
||||
directional and movement data, not just presence. Two sets = two people, which is
|
||||
specific enough to be operationally useful.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# NPC INTERACTION
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_tut_007
|
||||
text: "Could ask around. People talk if you give them reason to."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [social, observation]
|
||||
trigger: first_npc_proximity
|
||||
mood: [content]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, npc, interaction, social]
|
||||
notes: >
|
||||
Directly from the sprint briefing example. First time in range of an NPC. The smuggler
|
||||
doesn't approach directly — she flags the option. 'Give them reason to' is street-smart
|
||||
social knowledge: people don't volunteer freely, they need a hook. Teaches: NPC
|
||||
interaction is player-initiated, and approach matters. No mechanics text — just the
|
||||
character's own method stated as personal principle.
|
||||
|
||||
- id: pc-smuggler_m_s_tut_008
|
||||
text: "Know the face. Don't need the name."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [social, observation]
|
||||
trigger: first_npc_face_read
|
||||
mood: [content]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, npc, observation, social]
|
||||
notes: >
|
||||
Fires when the player observes an NPC at close range (face visible). The smuggler
|
||||
reads people by face, not by name — the name comes later if relevant. Teaches: NPCs
|
||||
are identifiable by observation, and the insert overlay will fill in names when
|
||||
available. The smuggler's social intelligence is pre-lattice, instinctive.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# INSERT / HUD
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
- id: pc-smuggler_m_s_tut_009
|
||||
text: "Check the overlay. Should show the time and nearby contacts."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
trigger: first_insert_open
|
||||
mood: [content]
|
||||
priority: 8
|
||||
|
||||
tags: [tutorial, insert, hud, operational]
|
||||
notes: >
|
||||
Directly from the sprint briefing example. First insert/HUD open. The smuggler's
|
||||
overlay shows time and nearby contacts — both operationally critical. 'Should show'
|
||||
— practical, not certain. Teaches: the insert is the primary tool for time-tracking
|
||||
and NPC awareness. Diegetic: she checks it the same way she checks a shift board.
|
||||
|
||||
- id: pc-smuggler_m_s_tut_010
|
||||
text: "Overlay marks Kael two sections over. Good."
|
||||
role: player_character
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine, social]
|
||||
trigger: first_insert_contact_flag
|
||||
mood: [content, warm]
|
||||
priority: 6
|
||||
|
||||
tags: [tutorial, insert, hud, kael, friend-arc]
|
||||
notes: >
|
||||
Fires when the insert first flags a known contact (here: Kael, the FRIEND NPC).
|
||||
'Two sections over' — spatial data from the overlay. 'Good.' standalone tic —
|
||||
warm, settling. Teaches: the insert shows known NPC positions in real-time.
|
||||
Also seeds the Kael relationship: the smuggler checks his location as a habit,
|
||||
not a task. His position is always relevant to her.
|
||||
+96
-4
@@ -7,6 +7,7 @@ Direct DB access only for sprint lifecycle mutations.
|
||||
|
||||
Usage:
|
||||
sprint status [--sprint N] [--team T] Sprint progress and ticket overview
|
||||
sprint sweep [--sprint N] Health check: grouped tickets, issues, team summary (JSON)
|
||||
sprint start [--sprint N] Activate a planned sprint
|
||||
sprint stop [--sprint N] Complete an active sprint
|
||||
sprint start-work [--sprint N] [--team T] Full context dump for starting work
|
||||
@@ -503,6 +504,95 @@ def cmd_start_work(args):
|
||||
print(REMINDER)
|
||||
|
||||
|
||||
def cmd_sweep(args):
|
||||
"""Health check: grouped tickets, bookkeeping issues, team summary (JSON)."""
|
||||
flags, _ = parse_flags(args, ["sprint"])
|
||||
sprint = detect_sprint(flags, prefer_status="active")
|
||||
|
||||
tickets = get_tickets_for_sprint(sprint["id"])
|
||||
|
||||
# Build dependency map for blocked detection
|
||||
blocked_by_map = {} # ticket_id -> [blocker_id, ...]
|
||||
for t in tickets:
|
||||
if t["status"] == "done":
|
||||
continue
|
||||
deps = get_ticket_deps(t["id"])
|
||||
open_blockers = [b["id"] for b in deps.get("blocked_by", []) if b["status"] != "done"]
|
||||
if open_blockers:
|
||||
blocked_by_map[t["id"]] = open_blockers
|
||||
|
||||
# Group by status
|
||||
by_status = {"done": [], "review": [], "in_progress": [], "blocked": [], "backlog": []}
|
||||
for t in tickets:
|
||||
entry = {
|
||||
"id": t["id"],
|
||||
"title": t["title"],
|
||||
"team": t.get("team") or "unassigned",
|
||||
"assigned_to": t.get("assigned_to"),
|
||||
}
|
||||
if t["status"] == "done":
|
||||
by_status["done"].append(entry)
|
||||
elif t["id"] in blocked_by_map:
|
||||
entry["blocked_by"] = blocked_by_map[t["id"]]
|
||||
by_status["blocked"].append(entry)
|
||||
elif t["status"] == "review":
|
||||
by_status["review"].append(entry)
|
||||
elif t["status"] == "in_progress":
|
||||
by_status["in_progress"].append(entry)
|
||||
else:
|
||||
by_status["backlog"].append(entry)
|
||||
|
||||
# Per-team summary
|
||||
by_team = {}
|
||||
for status, items in by_status.items():
|
||||
for item in items:
|
||||
team = item["team"]
|
||||
if team not in by_team:
|
||||
by_team[team] = {"backlog": 0, "in_progress": 0, "review": 0, "blocked": 0, "done": 0, "total": 0}
|
||||
by_team[team][status] = by_team[team].get(status, 0) + 1
|
||||
by_team[team]["total"] += 1
|
||||
|
||||
# Bookkeeping issues
|
||||
issues = []
|
||||
for t in tickets:
|
||||
if t["status"] in ("in_progress", "review") and not t.get("assigned_to"):
|
||||
issues.append({
|
||||
"type": "unassigned_in_progress",
|
||||
"detail": f"#{t['id']} unassigned {t['status']}",
|
||||
"fix": f"db/connectors/ticket assign {t['id']} <agent>",
|
||||
})
|
||||
if t["status"] == "backlog" and sprint["status"] == "active" and t["id"] not in blocked_by_map:
|
||||
issues.append({
|
||||
"type": "stale_backlog",
|
||||
"detail": f"#{t['id']} stale backlog",
|
||||
"fix": f"db/connectors/ticket status {t['id']} in_progress",
|
||||
})
|
||||
if t["status"] == "done" and t.get("assigned_to"):
|
||||
issues.append({
|
||||
"type": "assigned_but_done",
|
||||
"detail": f"#{t['id']} done, still assigned",
|
||||
"fix": f"db/connectors/ticket unassign {t['id']}",
|
||||
})
|
||||
|
||||
# Progress
|
||||
total = len(tickets)
|
||||
done = len(by_status["done"])
|
||||
pct = int(done / total * 100) if total > 0 else 0
|
||||
|
||||
result = {
|
||||
"sprint": {
|
||||
"id": sprint["id"],
|
||||
"name": sprint.get("name", f"Sprint {sprint['id']}"),
|
||||
"goal": sprint.get("goal", ""),
|
||||
},
|
||||
"progress": {"total": total, "done": done, "pct": pct},
|
||||
"by_status": by_status,
|
||||
"by_team": by_team,
|
||||
"issues": issues,
|
||||
}
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
def cmd_prepare(args):
|
||||
flags, _ = parse_flags(args, ["sprint", "team"])
|
||||
sprint = detect_sprint_for_prepare(flags)
|
||||
@@ -606,16 +696,17 @@ HELP = """sprint \u2014 sprint lifecycle and context for agents
|
||||
|
||||
Usage:
|
||||
sprint status [--sprint N] [--team T] Sprint progress and ticket overview
|
||||
sprint sweep [--sprint N] Health check: grouped tickets, issues, team summary (JSON)
|
||||
sprint start [--sprint N] Activate a planned sprint
|
||||
sprint stop [--sprint N] Complete an active sprint
|
||||
sprint start-work [--sprint N] [--team T] Full context dump for starting work
|
||||
sprint prepare [--sprint N] [--team T] Prepare next sprint (candidates + gaps)
|
||||
|
||||
Sprint auto-detection:
|
||||
status/start-work prefer the active sprint
|
||||
start prefer the planning sprint
|
||||
stop prefer the active sprint
|
||||
prepare target next sprint (max id + 1)
|
||||
status/start-work/sweep prefer the active sprint
|
||||
start prefer the planning sprint
|
||||
stop prefer the active sprint
|
||||
prepare target next sprint (max id + 1)
|
||||
|
||||
Team auto-detection:
|
||||
If --team is omitted, uses the current git branch name (unless on main).
|
||||
@@ -632,6 +723,7 @@ def main():
|
||||
|
||||
commands = {
|
||||
"status": cmd_status,
|
||||
"sweep": cmd_sweep,
|
||||
"start": cmd_start,
|
||||
"stop": cmd_stop,
|
||||
"start-work": cmd_start_work,
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,606 @@
|
||||
# Insert/HUD Wireframe and Visual Specification — v0.1
|
||||
## Dual Character Variants: Smuggler + Detective
|
||||
|
||||
**Ticket:** #314
|
||||
**Author:** Araminta (Visual Designer)
|
||||
**Date:** 2026-02-24
|
||||
**Status:** Active — feeds #263 (time display client implementation) and #151 (minimap rendering)
|
||||
**Visual grammar reference:** `docs/design/visual-grammar-v01.md` (§7 Neural Insert Overlay)
|
||||
|
||||
---
|
||||
|
||||
## 1. System Architecture
|
||||
|
||||
The insert HUD is not a UI panel bolted onto the game. It is the player character's **neural lattice** — Settled Reach technology that overlays computational data onto the character's perception. Every element on z-layer 6 is diegetic: the character is experiencing this information through their implant, not reading a game interface.
|
||||
|
||||
This has direct design consequences:
|
||||
|
||||
- The insert does not appear/disappear — it activates and deactivates. The character's lattice is always there; they choose when to engage it.
|
||||
- Labels use the character's vocabulary, not the game's. The smuggler's insert calls it "The Last Shift." The detective's insert calls it "Licensed Establishment, District 7."
|
||||
- The same underlying `ObserverSnapshot` feeds both variants. One parameterized system: `lattice_profile` on the snapshot determines which visual mode renders. Stig builds one HUD framework, not two. The variants are rendering configurations, not separate systems.
|
||||
|
||||
### 1.1 Technical Parameters
|
||||
|
||||
| Parameter | Smuggler | Detective |
|
||||
|-----------|----------|-----------|
|
||||
| `lattice_profile` | `lattice_baseline` | `lattice_augmented` |
|
||||
| Line weight | 1px | 1–2px |
|
||||
| Annotation density | Sparse — essential markers only | Dense — more markers, connection lines |
|
||||
| Passive opacity | 60% | 80% |
|
||||
| Active opacity | 85% | 100% |
|
||||
| Bloom | 2–3px gaussian at 40% blend | 2–3px gaussian at 40% blend |
|
||||
| Chrome color | `#c8d0e0` | `#c8d0e0` |
|
||||
|
||||
### 1.2 Z-Layer Position
|
||||
|
||||
Insert HUD lives entirely on **z-layer 6**. It renders above fog (z-layer 5) and below monologue/UI (z-layer 7). The fog shader does not mask it — insert data is computational, not perceptual. Entity markers can appear in fogged areas if the lattice has position data.
|
||||
|
||||
---
|
||||
|
||||
## 2. Screen Layout
|
||||
|
||||
**Base resolution:** 1920×1080px
|
||||
|
||||
The HUD has four zones:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ [STANCE] [PERCEPTION] [TIME] │ ← HUD chrome band (y: 0–80px)
|
||||
│ │
|
||||
│ │
|
||||
│ WORLD VIEW │
|
||||
│ (insert entity markers rendered here, │
|
||||
│ positioned at entity world locations) │
|
||||
│ │
|
||||
│ ↑ ↑ ↑ ↑ border arrows appear here │
|
||||
│ ← (for off-screen known POIs) → │
|
||||
│ ↓ ↓ ↓ ↓ │
|
||||
│ │
|
||||
│ [INVENTORY] [INVENTORY] │ ← inventory band (y: 816–864px)
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ DIALOGUE BOX (z-layer 7, when active) │ ← bottom 20% (y: 864–1080px)
|
||||
│ [NPC speech] │
|
||||
│ [response options] │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Coordinate system:** Origin (0,0) is top-left. X increases rightward, Y increases downward. All pixel values at 1920×1080 base; Godot 4 DPI scaling handles other resolutions.
|
||||
|
||||
**Dialogue box:** The bottom 20% (216px) is reserved when dialogue is active. Insert overlay remains visible at 60% of its normal opacity when dialogue is open — the lattice keeps running, the character keeps perceiving. The insert does not suppress during dialogue.
|
||||
|
||||
---
|
||||
|
||||
## 3. Time Display
|
||||
|
||||
**Feeds:** Ticket #263 (client time display implementation)
|
||||
|
||||
### 3.1 Position and Dimensions
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Container x | 1720px → 1904px (right-inset 16px, 184px wide) |
|
||||
| Container y | 16px → 64px (48px tall) |
|
||||
| Background | `#0e1218` at 40% opacity — barely-there panel behind text |
|
||||
| Typeface | Michroma Regular 400 |
|
||||
| Alignment | Right-aligned text, right-inset 8px within container |
|
||||
|
||||
This position is intentionally unremarkable. The player should find it without hunting for it.
|
||||
|
||||
### 3.2 Smuggler Variant
|
||||
|
||||
Single line: the time. Informal, just the clock.
|
||||
|
||||
```
|
||||
09:42
|
||||
```
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Text | `{HH}:{MM}` — 24-hour format, always two digits each |
|
||||
| Font size | 24px |
|
||||
| Color | `#c8d0e0` (insert chrome) |
|
||||
| Opacity | 80% passive, 100% when insert active and entity hover |
|
||||
| Y position | Center within container, ~y: 32px |
|
||||
|
||||
No annotation. The smuggler's lattice gives them the time. That's it.
|
||||
|
||||
### 3.3 Detective Variant
|
||||
|
||||
Two lines: clock on top, shift indicator below. The detective thinks in shifts.
|
||||
|
||||
```
|
||||
09:42
|
||||
SHIFT A ·
|
||||
```
|
||||
|
||||
| Element | Value |
|
||||
|---------|-------|
|
||||
| Time text | `{HH}:{MM}` — Michroma 20px, `#c8d0e0` at 80% passive, y: 26px in container |
|
||||
| Phase label | `SHIFT {X}` — Michroma 11px, `#c8d0e0` at 60%, y: 50px in container |
|
||||
| Phase dot | 4px circle, phase-color (see below), same y as phase label |
|
||||
|
||||
**Day phase to shift mapping:**
|
||||
|
||||
| Game time | Shift label | Dot color |
|
||||
|-----------|-------------|-----------|
|
||||
| 05:00–12:00 | `SHIFT A` | `#c8d0e0` (chrome, neutral) |
|
||||
| 12:00–18:00 | `SHIFT B` | `#c8d0e0` (chrome, neutral) |
|
||||
| 18:00–22:00 | `SHIFT C` | `#e8c547` (amber — social hours, higher risk of contacts) |
|
||||
| 22:00–05:00 | `SHIFT OFF` | `#4a9ebb` (teal — night, lower routine activity) |
|
||||
|
||||
The dot is a subtle visual cue, not a state machine icon. It gives the detective's lattice a slightly more annotated feel than the smuggler's plain clock.
|
||||
|
||||
### 3.4 Insert-Off Behavior
|
||||
|
||||
When `insert_active == false`:
|
||||
- Time display dims to 50% of passive opacity
|
||||
- Background panel disappears entirely
|
||||
- Text remains — the character always knows what time it is, even without active lattice engagement. Time is ambient lattice output, not targeting output.
|
||||
- Phase label (detective) also dims to 50%
|
||||
|
||||
The time display is the one persistent insert element. Everything else goes dark on insert deactivation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Entity Markers
|
||||
|
||||
Entity markers appear at each entity's world position — rendered in world space on z-layer 6. They are not minimap icons in a panel; they annotate the world the player is looking at.
|
||||
|
||||
### 4.1 Smuggler Variant — Social Network View
|
||||
|
||||
The smuggler sees their world as a social network. People are dots. Labels are familiar names.
|
||||
|
||||
**Dot specification:**
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Shape | Filled circle |
|
||||
| Diameter | 6px |
|
||||
| Color | D-033 relationship color for this NPC |
|
||||
| Bloom | 2–3px gaussian at 40% blend |
|
||||
| Passive state | 60% opacity |
|
||||
| Active state (hover) | 100% opacity + bloom brightens |
|
||||
| All non-hovered on hover | 40% opacity (focus effect) |
|
||||
|
||||
The dot sits at the entity's sprite anchor point (center-bottom of the 64×64 canvas). It does not float; it attaches to the entity.
|
||||
|
||||
**Label specification:**
|
||||
|
||||
Named NPCs (Kael, Lera, Sera, etc.) always show a casual label. Generic NPCs show no label unless hovered.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Typeface | Michroma 11px |
|
||||
| Color | `#c8d0e0` at 70% passive, 100% on hover |
|
||||
| Position | 8px above the dot, horizontally centered |
|
||||
| Format | First name only for Known NPCs: `Kael` |
|
||||
| Format | Location nickname for POIs: `The Last Shift`, `Kael's usual spot` |
|
||||
| Background | None — label is direct on world view. Keep it legible against zone palettes. |
|
||||
|
||||
**Relationship colors applied:**
|
||||
|
||||
| NPC state | Dot color | Label format |
|
||||
|-----------|-----------|--------------|
|
||||
| Unknown | `#4a9ebb` teal | No label (generic) |
|
||||
| Known/Friendly | `#6bc9a6` green | First name |
|
||||
| Person of Interest | `#e8c547` amber | First name + `?` suffix: `Torek?` |
|
||||
| Hostile | `#d45d5d` red | First name |
|
||||
|
||||
The smuggler does not see "Person of Interest." To them, amber means "something's off about this person" — not a case file designation.
|
||||
|
||||
### 4.2 Detective Variant — Investigation Overlay
|
||||
|
||||
The detective sees their world as a case map. Known individuals are flagged. Locations have institutional labels. The overlay is denser and more annotated.
|
||||
|
||||
**Named NPC marker specification:**
|
||||
|
||||
| NPC type | Shape | Size | Color |
|
||||
|----------|-------|------|-------|
|
||||
| Person of Interest | Diamond (rotated square) | 10px × 10px | `#e8c547` amber |
|
||||
| Known/Friendly | Filled circle | 6px | `#6bc9a6` green |
|
||||
| Unknown | Filled circle | 6px | `#4a9ebb` teal |
|
||||
| Hostile / Suspect | Diamond | 10px × 10px | `#d45d5d` red |
|
||||
|
||||
The diamond shape is the detective's POI designation — it communicates "flagged" without text. The smuggler's variant uses only circles because their lattice doesn't have case-file designation logic.
|
||||
|
||||
**Label specification:**
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Typeface | Michroma 11px |
|
||||
| Color | `#c8d0e0` at 80% passive, 100% on hover |
|
||||
| Position | 10px above the marker, right-aligned to marker center |
|
||||
| NPC label format | `POI: K. Davan` (formal: last name initial + surname) |
|
||||
| NPC label format | `K. Davan` for Known/non-POI |
|
||||
| Location label | See §5 |
|
||||
|
||||
**Evidence annotation:**
|
||||
|
||||
When the detective has logged evidence associated with an entity, a small square appears adjacent to their marker:
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Shape | Filled square |
|
||||
| Size | 4px × 4px |
|
||||
| Position | 4px right of the primary marker |
|
||||
| Color | `#e8c547` amber |
|
||||
| Opacity | 60% passive, 100% on hover |
|
||||
|
||||
This is the insert's way of flagging "you have something on this person." It doesn't say what.
|
||||
|
||||
**Commission grid overlay (Detective only):**
|
||||
|
||||
The detective's augmented lattice renders a very faint reference grid across the viewport. This is the "commission-style" aesthetic — the world read as a site map.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Grid lines | Horizontal + vertical, every 5 visual tiles (~320px) |
|
||||
| Line color | `#333340` at 20% opacity |
|
||||
| Line weight | 1px |
|
||||
| Behavior | Always visible when insert active, disappears on insert-off |
|
||||
|
||||
The grid does not animate, pulse, or react. It is a passive reference overlay. Five lines across the width, five down the height, making a 5×5 grid of reference squares. It is barely visible — felt as structure, not seen as decoration.
|
||||
|
||||
---
|
||||
|
||||
## 5. Location Labels
|
||||
|
||||
Location labels appear at POI anchor points in world space — the entrance tile or center tile of a known location.
|
||||
|
||||
### 5.1 Smuggler Variant
|
||||
|
||||
Casual labels, written in the smuggler's vocabulary. These are their personal pins.
|
||||
|
||||
| Location | Label text |
|
||||
|----------|-----------|
|
||||
| The Last Shift (bar) | `The Last Shift` |
|
||||
| The Terminal (logistics hub) | `The Terminal` |
|
||||
| Maintenance corridor (smuggling space) | `B-7 drop` (or specific pin label) |
|
||||
| Kael's regular position | `Kael's usual spot` |
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Typeface | Michroma 12px |
|
||||
| Color | `#c8d0e0` at 50% — location labels are backgrounded, not foregrounded |
|
||||
| Position | Centered on location anchor, 16px above entity-level (below any entity markers at that location) |
|
||||
| Visibility | Always visible for known locations, fades in on discovery |
|
||||
|
||||
### 5.2 Detective Variant
|
||||
|
||||
Institutional labels, written in Commission case file format.
|
||||
|
||||
| Location | Label text |
|
||||
|----------|-----------|
|
||||
| The Last Shift (bar) | `Licensed Establishment · District 7` |
|
||||
| The Terminal (logistics hub) | `Freight Operations Hub · Level 2` |
|
||||
| Maintenance corridor | `Restricted Access — Corridor B-7` |
|
||||
| Kael's regular position | `K. Davan – Last Sighted` |
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Typeface | Michroma 11px |
|
||||
| Color | `#c8d0e0` at 70% — slightly more prominent than smuggler's labels |
|
||||
| Position | Same as smuggler, centered on anchor |
|
||||
| Secondary text | Dot separator `·` between primary name and category/district |
|
||||
| Visibility | Always visible for known/case-file locations |
|
||||
|
||||
---
|
||||
|
||||
## 6. Border Arrows (Off-Screen Navigation)
|
||||
|
||||
When a known POI or tracked entity is outside the current viewport, a border arrow appears at the screen edge pointing toward them.
|
||||
|
||||
**The camera is locked to the player character (D-014).** Border arrows are the only navigation affordance for off-screen known locations. There is no minimap panel.
|
||||
|
||||
### 6.1 Arrow Specification
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Shape | Equilateral triangle, apex pointing toward screen edge |
|
||||
| Size | 12px × 10px |
|
||||
| Inset from edge | 8px |
|
||||
| Color | Matches the POI's relationship color (D-033), or `#c8d0e0` chrome for neutral known locations |
|
||||
| Opacity | 60% passive, 100% on hover |
|
||||
| Label on hover | Location name (using appropriate variant's label format), Michroma 10px, appears inside-screen adjacent to arrow |
|
||||
|
||||
**Clustering:** When multiple POIs are off-screen in similar directions, arrows stack with 4px gap. Maximum 6 arrows per edge before clustering into a count indicator: `+3` (Michroma 10px, chrome color).
|
||||
|
||||
**Decay:** If the character has only `Suspects` confidence about a location (not `KnowsOf`), the border arrow renders at 30% opacity with a dashed border (1px alternating 2px on / 2px off). "Something might be there."
|
||||
|
||||
### 6.2 Smuggler vs Detective
|
||||
|
||||
- **Smuggler:** Border arrows appear for Known/Friendly NPCs and personal pins. The arrow color is the NPC's relationship color. The label is casual. Unknown NPCs do not generate border arrows.
|
||||
- **Detective:** Border arrows appear for all case-file entries, POIs, and known locations regardless of relationship state. Evidence-flagged entities show the amber evidence square (4px) adjacent to the arrow.
|
||||
|
||||
---
|
||||
|
||||
## 7. Inventory Indicators (Smuggler Only)
|
||||
|
||||
Per D-065: the smuggler can carry up to 3–4 physical items. Icons appear in the lower-right corner, above the dialogue band.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Position | x: 1904px (right inset 16px), y: 816px → 856px |
|
||||
| Icon size | 40×40px |
|
||||
| Gap between icons | 8px horizontal, right-to-left stacking |
|
||||
| Opacity | 80% passive, 100% on hover |
|
||||
| Empty slots | Not displayed — icons only appear when items are carried |
|
||||
| Z-layer | 6 (insert layer), but icons are physical objects, not lattice data |
|
||||
|
||||
**Item icons (placeholder descriptions for #314 — actual sprites are a separate ticket):**
|
||||
- Manifest copy: document silhouette
|
||||
- Corridor access token: hexagonal tag silhouette
|
||||
- Personal comm log: communication device silhouette
|
||||
|
||||
Icons do not disappear when insert is off — they are physical items, not lattice annotations. The character still has pockets whether or not their insert is active.
|
||||
|
||||
**Detective inventory:** The detective has 2 inventory slots but they are knowledge-only in v0.1 (D-065). No inventory icons display for the detective in v0.1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Stance Indicator
|
||||
|
||||
Both variants show the current movement stance. This is ambient information — the player set it, but may have forgotten.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Position | x: 16px → 180px, y: 826px → 856px (lower-left, above dialogue band) |
|
||||
| Typeface | Michroma 11px |
|
||||
| Color | `#c8d0e0` at 50% — intentionally quiet |
|
||||
| Content | Current stance label: `WALK` / `CAREFUL` / `SPRINT` / `CROUCH` |
|
||||
| Behavior | Fades to 30% when dialogue is active |
|
||||
|
||||
The stance indicator never hides; it just dims. It is the quietest element in the HUD.
|
||||
|
||||
---
|
||||
|
||||
## 9. Interaction States
|
||||
|
||||
### 9.1 Insert Active — Default State
|
||||
|
||||
Full overlay as described in §3–§8. Entity markers, location labels, border arrows, time display, stance indicator, inventory icons all render at their passive opacity values.
|
||||
|
||||
### 9.2 Insert Inactive (D-056/D-057)
|
||||
|
||||
When `insert_active == false`:
|
||||
|
||||
| Element | State |
|
||||
|---------|-------|
|
||||
| Entity markers | Hidden |
|
||||
| Location labels | Hidden |
|
||||
| Border arrows | Hidden |
|
||||
| Commission grid (detective) | Hidden |
|
||||
| Evidence annotations | Hidden |
|
||||
| Bloom | Off — no gaussian pass |
|
||||
| Time display | Dims to 50% of passive opacity, background panel disappears |
|
||||
| Stance indicator | Remains — 30% opacity |
|
||||
| Inventory icons | Remain — physical, not insert data |
|
||||
| Cursor | Shape still changes on entity proximity (subconscious physical response per D-056) |
|
||||
|
||||
The world goes quiet. The character still moves, still perceives physically, still has their items. But the lattice stops annotating.
|
||||
|
||||
### 9.3 Entity Hover (Insert Active)
|
||||
|
||||
When the player's cursor enters an entity's interaction radius:
|
||||
|
||||
1. Hovered entity's marker brightens to 100% opacity + bloom intensifies to 60%
|
||||
2. All other entity markers dim to 40% opacity
|
||||
3. Interaction verb appears: Michroma 13px, D-033 color of the target, positioned 16px above the entity marker
|
||||
4. Entity label brightens to 100%
|
||||
|
||||
The interaction verb is the primary affordance for engagement. The verb appears on the insert layer — it disappears when insert is off, and is unaffected by fog (since it's z-layer 6).
|
||||
|
||||
Verb examples: `Talk`, `Observe`, `Follow`, `Confront`, `Examine`
|
||||
|
||||
### 9.4 Dialogue Active
|
||||
|
||||
When a dialogue interaction is in progress:
|
||||
|
||||
| Element | Change |
|
||||
|---------|--------|
|
||||
| Dialogue box | Appears at bottom 20% (y: 864–1080), z-layer 7 |
|
||||
| Monologue (if active) | Floats above dialogue box at y: ~800–860, z-layer 7 |
|
||||
| Insert overlay | Reduces to 60% of active opacity — still visible, lattice still running |
|
||||
| Entity markers | Remain visible but dimmed 40% |
|
||||
| Hovered entity marker | Stays bright — the conversation target stays highlighted |
|
||||
| Inventory icons | Remain visible |
|
||||
| Time display | Remains |
|
||||
| Stance indicator | Dims to 30% |
|
||||
|
||||
The insert doesn't close during dialogue. The character is still aware of their surroundings.
|
||||
|
||||
### 9.5 High Perception Load (D-059)
|
||||
|
||||
When multiple perception modes are active simultaneously, the insert shows diegetic interference:
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Effect | Horizontal scan-line distortion |
|
||||
| Intensity | 5% — subtle, peripheral |
|
||||
| Behavior | Periodic (not constant), short bursts, random intervals 2–8 seconds |
|
||||
| Applies to | All insert elements simultaneously |
|
||||
| Does NOT apply to | UI z-layer 7 (monologue, dialogue) |
|
||||
|
||||
This is a visual warning, not a mechanical penalty. The player reads it as "the lattice is working hard." It should not be alarming; it should be ambient.
|
||||
|
||||
---
|
||||
|
||||
## 10. Wireframe Mockups
|
||||
|
||||
### 10.1 Smuggler Variant — The Last Shift, Evening
|
||||
|
||||
The smuggler is in the bar. Kael is nearby (green — Known/Friendly). An unknown dock worker is in the corner (teal). The bar is a known location.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ CAREFUL 09:42 │
|
||||
│ │
|
||||
│ ↑ The Last Shift (bar: border direction) │
|
||||
│ │
|
||||
│ │
|
||||
│ ● ● │
|
||||
│ Kael [player] ● │
|
||||
│ (green) (cream) (teal) │
|
||||
│ │
|
||||
│ The Last Shift │
|
||||
│ (chrome label, centered) │
|
||||
│ │
|
||||
│ │
|
||||
│ [manifest][token]│
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ [dialogue box — inactive] │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Legend:
|
||||
● = entity dot marker (6px) Kael = label (Michroma 11px, chrome)
|
||||
The Last Shift = location label (Michroma 12px, 50% chrome)
|
||||
[manifest][token] = inventory icons (40×40px, lower-right)
|
||||
09:42 = time display (Michroma 24px, upper-right)
|
||||
CAREFUL = stance (Michroma 11px, lower-left, 50%)
|
||||
```
|
||||
|
||||
### 10.2 Detective Variant — Same Location, Same Time
|
||||
|
||||
The detective is outside the bar, watching the entrance. Kael is inside, behind a wall — the lattice shows his last known position as a dim marker. A POI is approaching from the corridor (off-screen right).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ WALK [ANALYT] 09:42 │
|
||||
│ SHIFT C · │
|
||||
│ +·····+·····+·····+·····+·····+·····+·····+·····+·····+·····+ │
|
||||
│ · ·│
|
||||
│ · ◆ [player] → ·│
|
||||
│ · K. Davan (blue-wh) [POI: arrow, amber, off-screen]·│
|
||||
│ · (amber diamond) ·│
|
||||
│ · ·│
|
||||
│ · Licensed Establishment · District 7 ·│
|
||||
│ +·····+·····+·····+·····+·····+·····+·····+·····+·····+·····+ │
|
||||
│ · ·│
|
||||
│ · ·│
|
||||
│ │
|
||||
│ WALK │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ [dialogue box — inactive] │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Legend:
|
||||
◆ = POI diamond marker (10×10px) K. Davan = formal label (Michroma 11px)
|
||||
+·····+ = commission grid overlay (Michroma thin, 20% opacity)
|
||||
→ = border arrow (off-screen POI, amber — K. Davan connection)
|
||||
Licensed Establishment · District 7 = institutional location label
|
||||
09:42 / SHIFT C · = time display + shift with amber phase dot
|
||||
[ANALYT] = perception mode indicator (placeholder position, upper-center-right)
|
||||
```
|
||||
|
||||
### 10.3 Entity Hover State — Detective, POI Hover
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ WALK [ANALYT] 09:42 │
|
||||
│ SHIFT C · │
|
||||
│ │
|
||||
│ Talk │
|
||||
│ Observe │
|
||||
│ ◆ Confront ←─ interaction verbs (amber, 13px) │
|
||||
│ K. Davan │
|
||||
│ [■]◆ (brightened, 100%, bloom 60%) │
|
||||
│ │
|
||||
│ all other markers at 40% opacity ─────────────────────────→ │
|
||||
│ ● │
|
||||
│ (40%) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
[■] = evidence annotation (4×4px amber square, adjacent to diamond)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Color Reference Summary
|
||||
|
||||
All colors from `visual-grammar-v01.md` §1.5 and §3.2. Reproduced here for implementation reference.
|
||||
|
||||
| Use | Hex | Notes |
|
||||
|-----|-----|-------|
|
||||
| Insert chrome (default) | `#c8d0e0` | Time display, labels, stance, grid |
|
||||
| Entity — Unknown | `#4a9ebb` | Cool teal. Default NPC state. |
|
||||
| Entity — Known/Friendly | `#6bc9a6` | Soft green. Trusted network. |
|
||||
| Entity — POI | `#e8c547` | Warm amber. Flagged. |
|
||||
| Entity — Hostile | `#d45d5d` | Muted red. Subjective danger. |
|
||||
| Player — Detective | `#e0e8ff` | Cool blue-white. |
|
||||
| Player — Smuggler | `#e8e0d0` | Warm cream. |
|
||||
| Unexplored wireframe | `#333340` | Not used in HUD chrome directly — world layer use |
|
||||
| Time panel background | `#0e1218` at 40% | Barely-there. Matches zone ambient palette. |
|
||||
|
||||
**Saturation rule:** Entity D-033 colors must remain the most saturated elements on screen at all times. Nothing in the insert chrome may compete with entity color saturation (D-044, §3.6 of visual grammar). The chrome is cool grey-white — it frames entity colors, never rivals them.
|
||||
|
||||
---
|
||||
|
||||
## 12. Typography Reference
|
||||
|
||||
All text is Michroma Regular 400 (Google Fonts). No exceptions (visual-grammar-v01.md §5.4).
|
||||
|
||||
| Role | Size | Color | Opacity (passive / active) | Z-layer |
|
||||
|------|------|-------|---------------------------|---------|
|
||||
| Time display | 24px (smuggler) / 20px (detective) | `#c8d0e0` | 80% / 100% | 6 |
|
||||
| Phase label (detective) | 11px | `#c8d0e0` | 60% / 80% | 6 |
|
||||
| Entity label (hover) | 11px | `#c8d0e0` | 70% / 100% | 6 |
|
||||
| Location label (smuggler) | 12px | `#c8d0e0` | 50% / 70% | 6 |
|
||||
| Location label (detective) | 11px | `#c8d0e0` | 70% / 90% | 6 |
|
||||
| Interaction verb | 13px | D-033 target color | 95% (always active) | 6 |
|
||||
| Stance indicator | 11px | `#c8d0e0` | 50% / 60% | 6 |
|
||||
| Border arrow label (hover) | 10px | `#c8d0e0` | 0% / 100% (hover-only) | 6 |
|
||||
|
||||
---
|
||||
|
||||
## 13. What This Document Does Not Cover
|
||||
|
||||
These are deliberately out of scope for #314. Separate tickets own them.
|
||||
|
||||
- **Perception mode overlay visual design** — thermal, camera feed, etc. (#315 area, depends on D-017/D-048 full spec)
|
||||
- **Monologue display system** — typography animation, stacking (#315)
|
||||
- **Sound indicator visual design** — fog-edge ping pulse (#317)
|
||||
- **THE FRIEND visual treatment** — special relationship visual affordances (#318)
|
||||
- **Minimap panel** (if a panel approach is ever adopted — currently border arrows handle navigation, no panel exists in v0.1)
|
||||
- **World map / inter-location navigation** — a different system entirely
|
||||
- **Cursor states** — specified in D-056, not repeated here
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Integration Notes for Stig (#263)
|
||||
|
||||
**For the time display implementation (#263):**
|
||||
|
||||
1. The time display container is at **x: 1720–1904, y: 16–64** (right-inset 16px from 1920px width). These are your anchor coordinates.
|
||||
2. The `ObserverSnapshot` needs to expose current game time (hours, minutes) and current day phase. Consult D-031 for the tick-to-time mapping (10 ticks = 1 game-minute).
|
||||
3. Time display text must be **right-aligned** within the container. Don't center it — it should feel like a readout in the upper-right corner.
|
||||
4. The background panel (`#0e1218` at 40%) should be a simple NinePatchRect or ColorRect — minimal, no rounded corners.
|
||||
5. When `insert_active == false`: set time display `modulate.a = 0.5 * passive_opacity` and hide the background panel. The text stays; the container chrome disappears.
|
||||
6. For the detective's phase label: drive the shift string from a simple range lookup on the game hour (see §3.3 table above). The dot color is a color constant mapped from phase — not dynamic.
|
||||
7. The phase label is visible only on `lattice_profile == lattice_augmented`. Single boolean check in the time display node.
|
||||
|
||||
**For the entity marker system (future tickets):**
|
||||
|
||||
- One marker scene, parametrized by shape (circle vs diamond), size, color, bloom intensity
|
||||
- The `lattice_profile` drives shape selection: baseline = circles always, augmented = diamonds for POI state, circles for other states
|
||||
- Evidence annotation (`■`) is a separate child node, shown/hidden based on KnowledgeGraph query for evidence entries on this entity
|
||||
- Commission grid is a separate CanvasLayer node, only visible when `lattice_profile == lattice_augmented` and `insert_active == true`
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — Decision Cross-References
|
||||
|
||||
| Decision | Relevance |
|
||||
|----------|-----------|
|
||||
| D-013 | Diegetic insert/POI navigation. The HUD IS the neural insert. |
|
||||
| D-014 | Minimap spec: "dots when close, border arrows for known distant POIs" |
|
||||
| D-019 | Camera tilt — sprites authored for shallow tilt. Markers sit above entity anchor. |
|
||||
| D-031 | Time system — tick-to-time mapping, day phases. Feeds §3. |
|
||||
| D-033 | Entity color = relationship to player. All marker colors source from this. |
|
||||
| D-048 | Neural insert visual design. Bloom spec, character variants. |
|
||||
| D-049 | Z-layer 6 = insert overlay. Z-layer 7 = UI/monologue. |
|
||||
| D-051 | "Settling is placement." Object density as meaning. Insert labels reflect this. |
|
||||
| D-056 | Cursor states persist when insert-off. |
|
||||
| D-057 | Entity interaction labels suppressed when insert-off. |
|
||||
| D-059 | Perception load → scan-line interference at 5%. |
|
||||
| D-065 | Inventory: 40×40px icons, smuggler-only in v0.1. |
|
||||
| D-066 | Dual-scale grid. Entity markers anchor to visual tile positions. |
|
||||
@@ -517,6 +517,9 @@ pub struct MonologueLine {
|
||||
pub prerequisites: Option<Prerequisites>,
|
||||
#[serde(default)]
|
||||
pub priority: Option<i32>,
|
||||
/// Per-line cooldown in ticks. `None` (omitted in YAML) means no per-line
|
||||
/// cooldown — fire-once lines rely on trigger semantics instead (e.g.,
|
||||
/// `first_*` and `contradiction_detected` triggers fire once by design).
|
||||
#[serde(default)]
|
||||
pub cooldown: Option<i32>,
|
||||
#[serde(default)]
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Post a comment to a Gitea PR or issue.
|
||||
# Usage: tea-comment <number> "comment body"
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: tea-comment <number> <comment>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NUMBER="$1"
|
||||
BODY="$2"
|
||||
HASH=$(echo -n "$BODY" | md5sum | cut -c1-8)
|
||||
TMPFILE="/tmp/tea-comment-${NUMBER}-${HASH}.md"
|
||||
trap 'rm -f "$TMPFILE"' EXIT
|
||||
|
||||
printf '%s' "$BODY" > "$TMPFILE"
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach "$NUMBER" "$(cat "$TMPFILE")"
|
||||
Reference in New Issue
Block a user