Compare commits
@@ -0,0 +1,16 @@
|
||||
# Git Safety
|
||||
|
||||
## Staging rules
|
||||
|
||||
- **Stage files by name** — never use `git add -A` or `git add .`
|
||||
- Verify no secrets, saves, or binary blobs are staged
|
||||
- Skip files in `.gitignore`
|
||||
- The `.claude/` directory IS tracked — skills and agents belong in the repo
|
||||
|
||||
## Commit conventions
|
||||
|
||||
Use conventional commits: `<type>(<scope>): <summary>`
|
||||
|
||||
Scopes: `agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
||||
|
||||
See `/git-commit` for full commit format, types, CHANGELOG workflow, and examples.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Local Services
|
||||
|
||||
Endpoints are also preconfigured in `db/connectors/config.json`.
|
||||
|
||||
- **Gitea:** `http://git.schweitz.internal` (login: `schweitz`)
|
||||
- **Qdrant:** `http://tower-of-joy:6333/`
|
||||
- **Ollama:** `http://tower-of-joy:11434/` (nomic-embed-text)
|
||||
- **Collection:** `commonwealth` (768 dimensions, cosine distance)
|
||||
@@ -0,0 +1,40 @@
|
||||
# Project Structure (detailed)
|
||||
|
||||
```
|
||||
client/ # Godot 4 client
|
||||
server/ # Rust/bevy_ecs simulation server
|
||||
tooling/ # Build tools, scripts, asset pipelines
|
||||
tests/ # Integration and end-to-end tests
|
||||
.config/ # Configuration files (linters, formatters, CI)
|
||||
.cache/ # Local caches for testing/linting (gitignored)
|
||||
docs/
|
||||
discussions/ # Discussion rounds (archived here when complete)
|
||||
briefings/ # Per-agent context briefings (maintained by Qatux)
|
||||
architecture/ # Technical architecture documents
|
||||
design/ # Game design documents
|
||||
diagrams/ # d2 source + PNG renders
|
||||
sprints/ # Sprint briefings per team
|
||||
workshops/ # Workshop briefs and outputs
|
||||
db/
|
||||
schema.sql # Database schema
|
||||
connectors/ # Connector scripts for SQLite and Qdrant
|
||||
config.json # Endpoint configuration
|
||||
ticket # Ticket CLI
|
||||
sqlite_connector.py # SQLite mini MCP
|
||||
qdrant_connector.py # Qdrant + ollama mini MCP
|
||||
.claude/
|
||||
agents/ # Agent personality files
|
||||
skills/ # Skill definitions
|
||||
rules/ # Auto-loaded instruction modules
|
||||
decisions/ # Decision domain files (source of truth)
|
||||
README.md # Domain index — use this to find specific D-records
|
||||
architecture.md # Architecture decisions
|
||||
perception.md # Perception and information system decisions
|
||||
content.md # Content and narrative decisions
|
||||
scope.md # Scope and feature decisions
|
||||
process.md # Process and workflow decisions
|
||||
questions.md # Open questions (Q-NNN)
|
||||
rejected.md # Rejected proposals (R-NNN)
|
||||
DECISIONS.md # Redirect to decisions/ directory
|
||||
TEAM.md # Team roster and roles
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
# Gitea Access (tea CLI)
|
||||
|
||||
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
|
||||
|
||||
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
|
||||
|
||||
```bash
|
||||
# List open PRs
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
|
||||
# View a PR with comments
|
||||
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
|
||||
|
||||
# Post a comment on a PR (or issue)
|
||||
tooling/tea-comment <NUMBER> "comment body"
|
||||
|
||||
# Approve a PR
|
||||
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||
|
||||
# List issues
|
||||
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
## Key rules
|
||||
|
||||
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
||||
- **Use `--output simple`** for machine-readable output (no table borders)
|
||||
- **For comments, use `tooling/tea-comment <number> "body"`** — handles temp files and cleanup automatically. Works with multi-line strings.
|
||||
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
||||
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
||||
|
||||
## Pull requests
|
||||
|
||||
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
|
||||
|
||||
Always provide all required flags to ensure non-interactive execution:
|
||||
```bash
|
||||
tea pr create \
|
||||
--repo jpmschweitzer/settled-reach \
|
||||
--login schweitz \
|
||||
--title "feat(scope): short description" \
|
||||
--description "PR body here" \
|
||||
--base main \
|
||||
--head branch-name
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
# Team Patterns
|
||||
|
||||
## Model selection
|
||||
|
||||
Default model is Opus 4.6 (200K context). For heavy sessions (workshops,
|
||||
sprint planning, large reviews), switch to extended context on-demand:
|
||||
|
||||
- `/model sonnet[1m]` — Sonnet 4.6 with 1M context window
|
||||
- `/model opus[1m]` — Opus 4.6 with 1M context window
|
||||
- Cost: 2x input + 1.5x output for tokens beyond 200K (Tier 4 required)
|
||||
|
||||
## Large content pushes
|
||||
|
||||
When producing many files (wiki pages, content batches, bulk docs):
|
||||
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
|
||||
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
|
||||
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
||||
|
||||
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
||||
|
||||
## Team monitoring (stuck agent detection)
|
||||
|
||||
When leading a team (sprint, workshop, or any multi-agent session):
|
||||
|
||||
**Agent heartbeat rule** — include in every agent spawn prompt:
|
||||
> If you have been working on a single task for more than 15 minutes
|
||||
> without making progress, message the team lead with what is blocking
|
||||
> you. Do not keep retrying the same approach silently.
|
||||
|
||||
**Team lead proactive checks:**
|
||||
- If an agent has not sent a message in ~20 minutes, ping them for a status update.
|
||||
- **Bottleneck detection:** if other agents are idle and waiting on one agent's output, that agent's silence is a red flag — check on them immediately, do not wait for the next natural message.
|
||||
- When checking on a stuck agent, offer to reassign the task or pull in another agent to help.
|
||||
@@ -44,6 +44,15 @@
|
||||
"Bash(make)",
|
||||
|
||||
"Bash(tea *)",
|
||||
"Bash(tooling/tea-comment *)",
|
||||
|
||||
"Bash(cargo test *)",
|
||||
"Bash(cargo test)",
|
||||
"Bash(cargo build *)",
|
||||
"Bash(cargo build)",
|
||||
"Bash(cargo check *)",
|
||||
"Bash(cargo check)",
|
||||
"Bash(tests/run-*)",
|
||||
|
||||
"Bash(chmod *)",
|
||||
"Bash(ls *)",
|
||||
|
||||
@@ -10,9 +10,9 @@ allowed-tools: Bash, Read, Grep, Glob
|
||||
|
||||
# Search Docs Skill
|
||||
|
||||
Semantic search across project documents. Basic commands (`qdrant-search`,
|
||||
`qdrant-index`, `qdrant-health`, `qdrant-count`) and endpoints are documented
|
||||
in CLAUDE.md. This skill covers advanced operations and workflows.
|
||||
Semantic search across project documents. Endpoints are in
|
||||
`.claude/rules/local-services.md`. This skill covers advanced operations
|
||||
and workflows.
|
||||
|
||||
## Advanced Commands
|
||||
|
||||
|
||||
@@ -136,7 +136,5 @@ chore(meta): release v0.1.0
|
||||
|
||||
## Staging Rules
|
||||
|
||||
- Stage files by name — never use `git add -A` or `git add .`
|
||||
- Verify no secrets, saves, or binary blobs are staged
|
||||
- Skip files in `.gitignore`
|
||||
- The `.claude/` directory IS tracked — skills belong in the repo
|
||||
See `.claude/rules/git-safety.md` for staging rules (always-loaded).
|
||||
These apply to ALL git operations, not just this skill.
|
||||
|
||||
@@ -100,15 +100,11 @@ git diff --stat main...<branch>
|
||||
Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description.
|
||||
|
||||
```bash
|
||||
cat > /tmp/pr-body.md << 'EOF'
|
||||
## Summary
|
||||
...
|
||||
EOF
|
||||
tea pr create \
|
||||
--repo jpmschweitzer/settled-reach \
|
||||
--login schweitz \
|
||||
--title "<title>" \
|
||||
--description "$(cat /tmp/pr-body.md)" \
|
||||
--description "## Summary ..." \
|
||||
--base main \
|
||||
--head <branch>
|
||||
```
|
||||
@@ -134,6 +130,14 @@ Report which tickets were moved to review. Skip tickets that are
|
||||
already `done`, `review`, `cancelled`, or `backlog` (only transition
|
||||
`in_progress` → `review`).
|
||||
|
||||
### 9. Next steps
|
||||
|
||||
If a sprint team is active (you are the team lead), do NOT shut down
|
||||
agents after pushing. The team should remain alive for PR review and
|
||||
potential comment fixes.
|
||||
|
||||
Suggest: "PR created/updated. Run `/pr-review` to review before merge."
|
||||
|
||||
## Arguments
|
||||
|
||||
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
|
||||
|
||||
@@ -16,11 +16,22 @@ on the branch type. All reviewers must approve for a clean review.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine the branch
|
||||
### 0. Branch guard — MUST be run by a Claude instance in the `main` worktree
|
||||
|
||||
If the user provided a branch name as argument, use it. Otherwise use the
|
||||
current branch (`git branch --show-current`). If on `main`, ask the user
|
||||
which branch to review.
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
If the current branch is **not `main`**, stop immediately and tell the user:
|
||||
"PR reviews must be run by a Claude instance in the `main` worktree."
|
||||
Do NOT proceed with the review. Do NOT work around this by reading files
|
||||
from another worktree — the review agent itself must be running in main.
|
||||
Stop and wait for the user to invoke `/pr-review` from main.
|
||||
|
||||
### 1. Determine the branch to review
|
||||
|
||||
If the user provided a branch name as argument, use it. Otherwise list open
|
||||
PRs and ask the user which branch to review.
|
||||
|
||||
To list open PRs on Gitea:
|
||||
```bash
|
||||
@@ -174,19 +185,11 @@ After presenting results to the user, post the review as a PR comment.
|
||||
|
||||
Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
|
||||
|
||||
**IMPORTANT — `tea comment` hangs with inline heredocs and multi-line strings.**
|
||||
Always use a two-step approach: write to a temp file first, then pass via `$(cat)`:
|
||||
Post using the `tea-comment` wrapper (handles temp files and cleanup):
|
||||
|
||||
```bash
|
||||
tooling/tea-comment <PR_NUMBER> "review markdown here"
|
||||
```
|
||||
# Step 1: Write review to .tmp/ using the Write tool (no permission prompt)
|
||||
Write(file_path: "<repo_root>/.tmp/review-<branch>.md", content: "...review content...")
|
||||
|
||||
# Step 2: Post to Gitea (separate Bash call)
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat .tmp/review-<branch>.md)"
|
||||
```
|
||||
|
||||
Use the Write tool for step 1 (avoids Bash permission prompts). The `.tmp/`
|
||||
directory is gitignored and exists in the repo root for this purpose.
|
||||
|
||||
## 7. Merging approved PRs
|
||||
|
||||
@@ -203,6 +206,22 @@ tea pr close --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||
Gitea does **not** auto-close PRs when you push a local merge — always close
|
||||
manually with `tea pr close` after pushing.
|
||||
|
||||
### 8. Post-review team actions
|
||||
|
||||
If a sprint team is active and you are the team lead, handle the
|
||||
review outcome:
|
||||
|
||||
**CHANGES_REQUESTED:**
|
||||
The sprint-start lifecycle (step 9c) handles dispatching review
|
||||
comments to agents. After presenting results, remind the lead:
|
||||
"Review requested changes. Create tasks from the warnings/critical
|
||||
issues and dispatch to idle agents, then re-push and re-review."
|
||||
|
||||
**APPROVED:**
|
||||
The sprint-start lifecycle (step 9c) handles shutdown. After
|
||||
presenting results, remind the lead: "Review approved. Proceed with
|
||||
team shutdown per sprint-start step 9c."
|
||||
|
||||
## Tips from practice
|
||||
|
||||
- **Vendor code**: Explicitly note vendor code in the prompt so reviewers focus
|
||||
|
||||
@@ -47,10 +47,40 @@ project state. Only generate briefings for teams that have tickets in the sprint
|
||||
| `audio` | `audio` | Inigo (sound design) | Soundscapes, ambient layers, diegetic cues, audio propagation |
|
||||
| `visual` | `visual` | Araminta (art direction) | Art assets, sprites, visual consistency, style guides |
|
||||
| `ci` | `ci` | Justine (build/deploy) | Build pipelines, CI/CD, tooling, packaging |
|
||||
| `planning` | `planning` | Purpose-assembled (see below) | Design discussions, decision resolution, workshop-style tickets |
|
||||
|
||||
When writing briefings, name the assigned agents in the **Agents** line of each
|
||||
file so the team knows who to spawn.
|
||||
|
||||
### Planning Team Tickets
|
||||
|
||||
Some tickets need **design discussion** before implementation can begin — tagged
|
||||
"NEEDS DESIGN DISCUSSION" or blocking multiple downstream tickets with open
|
||||
questions. These run on the `planning` branch as structured discussions with
|
||||
the user and a purpose-assembled agent panel.
|
||||
|
||||
**When to create a planning ticket:**
|
||||
- Ticket description says "NEEDS DESIGN" or "NEEDS DESIGN DISCUSSION"
|
||||
- Ticket blocks 2+ downstream tickets across different teams
|
||||
- Open Q-NNN items that block sprint candidates
|
||||
- Architectural decisions that need multi-domain input before implementation
|
||||
|
||||
**Planning briefing format** (differs from implementation briefings):
|
||||
- **Agents line**: List agents by domain relevance, not fixed team roster.
|
||||
Pick from: Gestalt (systems), Miri (worldbuilding), Araminta (visual/spatial),
|
||||
Tyre (technical), Paula (narrative), Ozzie (player experience), Gore (themes),
|
||||
Nigel (replayability). Typically 4-6 domain agents, plus Qatux (documenter —
|
||||
records decisions, updates domain files) and SI (project manager — creates
|
||||
follow-up tickets, updates sprint assignments).
|
||||
- **Discussion rounds**: Structure the conversation into 2-3 rounds
|
||||
(inventory → proposals → convergence)
|
||||
- **Context section**: List all existing design docs, decisions, and related
|
||||
tickets that participants must read before the discussion
|
||||
- **Output specification**: What the discussion must produce — typically a
|
||||
D-record in `decisions/`, possibly a design doc in `docs/design/`
|
||||
- **Decision questions**: Specific questions the discussion must answer,
|
||||
not open-ended exploration
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Run sprint prepare
|
||||
|
||||
@@ -288,24 +288,63 @@ Task(
|
||||
prompt: "You are on the {team} team for Sprint {N}.
|
||||
Branch: `{team}`
|
||||
|
||||
RULES:
|
||||
- GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||
checkout, branch, stash, tag, etc.). All git operations are
|
||||
handled by the team lead.
|
||||
- DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||
the exact command with no wrappers or chaining. Examples:
|
||||
db/connectors/ticket show 528
|
||||
db/connectors/ticket list --sprint {N}
|
||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
||||
add cleanup commands. Just the bare command.
|
||||
RULES (NON-NEGOTIABLE):
|
||||
|
||||
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||
checkout, branch, stash, tag, etc.). All git operations are
|
||||
handled by the team lead. No exceptions.
|
||||
|
||||
2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||
the exact command with no wrappers or chaining. Examples:
|
||||
db/connectors/ticket show 528
|
||||
db/connectors/ticket list --sprint {N}
|
||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
||||
add cleanup commands. Just the bare command.
|
||||
|
||||
3. READ BEFORE WRITE: Before modifying ANY file, Read it first.
|
||||
Before creating a new file, Glob for similar files to learn
|
||||
the existing patterns (naming, structure, imports). Follow
|
||||
the conventions you find — do not invent new ones.
|
||||
|
||||
4. VERIFY AFTER WRITE: After implementing a change, grep for
|
||||
all references to functions/properties/classes you modified
|
||||
or removed. If you renamed, moved, or deleted something,
|
||||
update EVERY call site. Missing a call site breaks tests
|
||||
and blocks the team.
|
||||
|
||||
5. NO PARTIAL WORK: Do not mark a task completed unless ALL
|
||||
parts of the ticket are implemented. If the ticket says
|
||||
'deliver A, B, and C', all three must exist and work. If
|
||||
you cannot complete part of a task, message the team lead
|
||||
explaining what is blocked and what remains — do NOT mark
|
||||
it completed.
|
||||
|
||||
6. MESSAGE WHEN BLOCKED: If you hit a problem you cannot solve
|
||||
in 3 attempts, stop and message the team lead immediately.
|
||||
Do not silently skip work or leave stubs. Do not move to
|
||||
the next task while the current one is incomplete.
|
||||
|
||||
7. BACKWARD COMPATIBILITY: When extracting, moving, or
|
||||
refactoring code, ensure all existing consumers still work.
|
||||
Add proxy methods/properties if needed. Grep for the old
|
||||
name to find every call site.
|
||||
|
||||
WORKFLOW:
|
||||
|
||||
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
|
||||
2. Read the decision files referenced in the briefing.
|
||||
3. Check TaskList for available work.
|
||||
4. Claim an unblocked task (TaskUpdate with owner: your name),
|
||||
mark it in_progress, and implement it.
|
||||
5. When done, mark the task completed and check TaskList for
|
||||
the next available task.
|
||||
5. Before marking done, verify:
|
||||
- All deliverables from the ticket exist (not just some)
|
||||
- No broken references (grep for changed names/signatures)
|
||||
- New files follow existing naming and directory conventions
|
||||
- Modified files still parse (no syntax errors)
|
||||
6. Mark the task completed and check TaskList for the next
|
||||
available task.
|
||||
7. If no tasks remain, message the team lead. Do NOT shut down
|
||||
on your own.
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full ticket specs.",
|
||||
description: "Sprint {N} {team}: {name}",
|
||||
@@ -325,3 +364,57 @@ Output to the user:
|
||||
You are now the team lead. Agents work autonomously — monitor via
|
||||
`TaskList`, communicate via `SendMessage`, and handle blockers as
|
||||
they arise.
|
||||
|
||||
**When all tasks complete:** Do NOT shut down agents. The team stays
|
||||
alive through the PR review cycle. Follow step 9 (post-work lifecycle).
|
||||
|
||||
### 9. Post-work lifecycle
|
||||
|
||||
When all tasks are complete (TaskList shows all completed):
|
||||
|
||||
#### 9a. Commit and push
|
||||
|
||||
Run `/git-commit` to commit all changes, then `/pr-push` to create or
|
||||
update the PR. Do NOT shut down agents — the team stays alive for review.
|
||||
|
||||
#### 9b. Review
|
||||
|
||||
Run `/pr-review` to spawn temporary reviewers. Wait for results.
|
||||
|
||||
#### 9c. Handle review outcome
|
||||
|
||||
**If CHANGES_REQUESTED:**
|
||||
|
||||
1. Parse the review comment table (from the Gitea PR comment or the
|
||||
review output). Extract each warning/critical issue with:
|
||||
- File path and approximate line
|
||||
- Severity (critical / warning / suggestion)
|
||||
- Description
|
||||
|
||||
2. Create a task per warning/critical issue:
|
||||
```
|
||||
TaskCreate(
|
||||
subject: "Review: {short description}",
|
||||
description: "{full issue description from review table, including
|
||||
file path, severity, and reviewer name}",
|
||||
activeForm: "Fixing review comment: {short description}"
|
||||
)
|
||||
```
|
||||
Skip suggestion-severity items unless they are trivial (1-line fixes).
|
||||
|
||||
3. Dispatch to idle agents: send each a message via SendMessage telling
|
||||
them to check TaskList for new review-fix tasks. Agents claim and
|
||||
work tasks as usual.
|
||||
|
||||
4. After all review-fix tasks are complete, re-run `/git-commit` then
|
||||
`/pr-push` to update the PR. Then re-run `/pr-review`.
|
||||
|
||||
5. Repeat this loop until review returns APPROVED.
|
||||
|
||||
**If APPROVED:**
|
||||
|
||||
1. Send `shutdown_request` to all sprint agents.
|
||||
2. Wait for all `shutdown_response` confirmations.
|
||||
3. Call `TeamDelete` to clean up.
|
||||
4. Report: "Sprint {N} {team} complete. PR #{X} approved and ready for
|
||||
merge on main."
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: sprint-status
|
||||
description: >
|
||||
Sprint health check and cleanup sweep. Lists all tickets in the active
|
||||
sprint grouped by status, detects bookkeeping issues (stale tickets,
|
||||
orphan PRs, done-but-open PRs, unassigned work), and shows open work
|
||||
by team. Use when checking sprint progress, before sprint close, or
|
||||
when housekeeping feels off. Triggers on "sprint status", "cleanup
|
||||
sweep", "what's open", "sprint health".
|
||||
user-invocable: true
|
||||
allowed-tools: Task, Read, Grep, Glob
|
||||
---
|
||||
|
||||
# Sprint Status
|
||||
|
||||
**Delegate this entire skill to a subagent** (general-purpose, model: haiku).
|
||||
|
||||
When this skill is invoked, spawn a subagent using the Task tool:
|
||||
|
||||
```
|
||||
Task(
|
||||
subagent_type: "general-purpose",
|
||||
model: "haiku",
|
||||
prompt: "Run /sprint-status. Read the skill at
|
||||
.claude/skills/sprint-status/SKILL.md for the full workflow
|
||||
(below the --- separator), then execute it.",
|
||||
description: "Sprint status report"
|
||||
)
|
||||
```
|
||||
|
||||
Present the subagent's output to the user verbatim. Do NOT run the
|
||||
workflow yourself.
|
||||
|
||||
---
|
||||
|
||||
The remainder of this file is the subagent's reference for executing
|
||||
the workflow.
|
||||
|
||||
## Step 1 — Gather data
|
||||
|
||||
Run these two commands in parallel:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint sweep
|
||||
```
|
||||
|
||||
```bash
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
The `sweep` command returns JSON with:
|
||||
- `sprint` — id, name, goal
|
||||
- `progress` — total, done, pct
|
||||
- `by_status` — tickets grouped into done, review, in_progress, blocked, backlog
|
||||
- `by_team` — per-team counts
|
||||
- `issues` — bookkeeping problems with suggested fix commands
|
||||
|
||||
The `tea pr list` returns open PRs as `#N title` lines.
|
||||
|
||||
## Step 2 — Cross-reference PRs with tickets
|
||||
|
||||
Parse PR head branches from the `tea pr list` output. Known team branches:
|
||||
`server`, `client`, `copy`, `audio`, `visual`, `ci`.
|
||||
|
||||
Detect additional issues:
|
||||
|
||||
- **done_team_open_pr**: A team's tickets are all done but an open PR
|
||||
still exists for that team branch.
|
||||
- **orphan_pr**: An open PR exists on a branch that has no tickets in
|
||||
the active sprint.
|
||||
|
||||
Add these to the issues list from step 1.
|
||||
|
||||
## Step 3 — Format output
|
||||
|
||||
Read `references/output-template.md` for the exact format spec.
|
||||
|
||||
Render the report using data from steps 1-2. Key rules:
|
||||
- Sections ordered: Completed, In Review, In Progress, Blocked, Backlog
|
||||
- Sort tickets within sections by team then ticket ID
|
||||
- Empty sections: show header with "(0)" and "(none)" — no empty table
|
||||
- Bookkeeping Issues: two-column table (Issue, Fix)
|
||||
- Open Work by Team: summary table at the bottom
|
||||
- Issue type labels: `stale_backlog` → "Stale backlog",
|
||||
`unassigned_in_progress` → "Unassigned in_progress",
|
||||
`assigned_but_done` → "Assigned but done",
|
||||
`done_team_open_pr` → "Done team with open PR",
|
||||
`orphan_pr` → "Orphan PR"
|
||||
|
||||
## Step 4 — Suggest actions
|
||||
|
||||
After the formatted report, if there are bookkeeping issues, add a
|
||||
"Suggested fixes" section with the fix command for each issue. Group
|
||||
by issue type for readability.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Sprint Status Output Template
|
||||
|
||||
## Sprint {N}: {Theme} — Status Report
|
||||
|
||||
**Goal:** {goal}
|
||||
**Status:** {status} | {done}/{total} tickets ({pct}%)
|
||||
**Open PRs:** {count} ({branches})
|
||||
|
||||
---
|
||||
|
||||
### Completed ({count})
|
||||
|
||||
| # | Team | Title | Assigned |
|
||||
|---|------|-------|----------|
|
||||
| #{id} | {team} | {title} | {assigned} |
|
||||
|
||||
### In Review ({count})
|
||||
|
||||
| # | Team | Title | PR |
|
||||
|---|------|-------|----|
|
||||
| #{id} | {team} | {title} | #{pr} |
|
||||
|
||||
### In Progress ({count})
|
||||
|
||||
| # | Team | Title | Assigned | Note |
|
||||
|---|------|-------|----------|------|
|
||||
| #{id} | {team} | {title} | {assigned} | |
|
||||
|
||||
### Blocked ({count})
|
||||
|
||||
| # | Team | Title | Blocked by |
|
||||
|---|------|-------|------------|
|
||||
| #{id} | {team} | {title} | #{ids} |
|
||||
|
||||
### Backlog ({count})
|
||||
|
||||
| # | Team | Title | Note |
|
||||
|---|------|-------|----|
|
||||
| #{id} | {team} | {title} | not started |
|
||||
|
||||
---
|
||||
|
||||
### Bookkeeping Issues
|
||||
|
||||
| Issue | Fix |
|
||||
|-------|-----|
|
||||
| {type}: {detail} | `{command}` |
|
||||
|
||||
### Open Work by Team
|
||||
|
||||
| Team | Backlog | In Progress | Review | Blocked | Done |
|
||||
|------|---------|-------------|--------|---------|------|
|
||||
| {team} | {n} | {n} | {n} | {n} | {n} |
|
||||
| **Total** | **{n}** | **{n}** | **{n}** | **{n}** | **{n}** |
|
||||
@@ -10,9 +10,8 @@ allowed-tools: Bash, Read, Grep, Glob
|
||||
|
||||
# Ticket Skill
|
||||
|
||||
Manage the project ticketing database. Basic usage (`ticket list`, `ticket show`,
|
||||
`ticket sprint --active`) and raw SQL wrappers are documented in CLAUDE.md.
|
||||
This skill covers the full command reference.
|
||||
Manage the project ticketing database. Basic usage is in CLAUDE.md's CLI tools
|
||||
section. This skill covers the full command reference.
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ run_check() {
|
||||
|
||||
# --- Checks ---
|
||||
run_check "tooling/check-fact-ids" "fact_id validation"
|
||||
run_check "tooling/check-decision-ids" "decision ID duplication"
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo ""
|
||||
|
||||
@@ -43,3 +43,4 @@ Thumbs.db
|
||||
# Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked
|
||||
.claude/plans/
|
||||
.claude/projects/
|
||||
.claude/agent-memory/
|
||||
|
||||
@@ -6,6 +6,166 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.1.20] — 2026-02-25
|
||||
|
||||
### Added
|
||||
- Social site template schema — RoleSchema (#163), SpaceSpec (#164), TriangleDef (#106) with YAML deserialization, sample templates at server/data/templates/
|
||||
- Single-ownership model — TemplateOwnership component, TemplateReferenceMap resource, cross-template reference links preserved across save/load and tier eviction (#165, D-025)
|
||||
- Triangle generation — intra-template constraint satisfaction assigns NPCs to triangle roles, minimum 2 triangles per template with fallback on imperfect seeds (#107)
|
||||
- Triangle escalation system — tick_triangle_escalation runs per game-minute, tension increments toward ToleranceThreshold, TriangleCrisisEvent emitted on Active phase entry, ResolveTriangle stub command (#250, D-087)
|
||||
- Protocol v16 — TriangleCrisisEventWire on ObserverSnapshot for future client rendering of triangle crises
|
||||
- D-093: Sova Transit District spatial layout — 4 social sites (Terminal, Bar, Gate Cluster, Sector 3), 2 encounter nodes, zone palette, gate cluster 7-zone spec, z-level scheme (z=0 maintenance, z=1 main, z=2 observation gallery), 3 investigation paths, corridor widths
|
||||
- D-094: Spatial hierarchy — chunk (64×64 sim) → block (128×128 sim) → district (4×4 blocks, 256×256 visual), supersedes D-014 estimate
|
||||
- D-095: Horizon stations and transport lore — span gates (human-built, dual-use), horizon stations (alien-built, 4-8 apertures), "The Ring" per-system naming, sequential hop travel, The Loop internal tram
|
||||
- Generator architecture workshop brief (ticket #562) — top-down pipeline for district generation, targeting Q-036 resolution
|
||||
- SnapshotEventRouter — callable-based snapshot dispatch replaces inline if-has blocks in main.gd (#559)
|
||||
- YamlParser shared utility — unified YAML parsing for UI strings and checklist conditions (#560)
|
||||
|
||||
### Fixed
|
||||
- Wire triangle crisis event queue into observer snapshot — clients now receive TriangleCrisisEventWire via protocol v16 (was always empty)
|
||||
- Persist TriangleState in SaveStateV1 — triangle phase and tension survive save/load cycles
|
||||
- Validate dangling with_role references in TriangleDef constraint validation
|
||||
- Replace O(n²) fallback NPC assignment with BTreeSet; prevent same NPC assigned to two roles in one triangle
|
||||
- Replace O(N*M) scan in apply_resolve_triangle with BTreeMap index for O(1) per-command lookup
|
||||
- Add From impls for RoleId, TriangleId, StableId, TriangleCrisisEventWire — eliminate fragile .0 newtype access
|
||||
- Consolidate near-identical unit tests with integration counterparts
|
||||
|
||||
### Changed
|
||||
- Sova station profile updated — horizon gates located at The Krenn Ring (800 AU), not on Station Sova; Admin Hub houses transit processing facility only
|
||||
- game_state.gd: stationary_ticks and zone_id now read from server snapshot with deprecated client-side fallbacks (#557, D-020)
|
||||
- dialogue_box.gd: decoupled from GameState and AudioManager via signals — zero direct autoload references (#558, D-020)
|
||||
- main.gd: snapshot dispatch via SnapshotEventRouter, dialogue signal coordinator handlers (#559, #558)
|
||||
- ui_strings.gd and checklist_evaluator.gd: delegate to YamlParser, ~140 lines of duplication removed (#560)
|
||||
|
||||
## [v0.1.19] — 2026-02-25
|
||||
|
||||
### Added
|
||||
- Sprint 20: Shape planned — 11 tickets (server 6, client 4, planning 1) covering template/triangle schemas, client refactors, and district layout design discussion
|
||||
- Planning team ticket type in sprint-plan skill — supports design discussions with purpose-assembled agent panels, Qatux and SI for bookkeeping
|
||||
- Client PR #70 merged — save/load client UI, F5/F6 quicksave/quickload (#554)
|
||||
- Server PR #68 merged — Sprint 19 save/load, tier eviction, test infra (7 tickets, 2714 lines)
|
||||
- Client PR #67 merged — Sprint 19 test infra, session management, debug overlay (5 tickets, 2547 lines)
|
||||
- CI PR #69 merged — Sprint 19 test runners, IPC fixtures, protocol handshake, benchmark (4 tickets, 1297 lines)
|
||||
- Test runner scripts — 7 bash scripts (run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol, run-ipc-integration, run-ipc-benchmark, run-all) with structured JSON output (#270, D-030)
|
||||
- IPC serialization fixtures — 5 msgpack fixtures with Rust generator, cross-language GDScript validation (22 assertions) (#271, D-030)
|
||||
- Protocol handshake client — HANDSHAKING state in SimBridge, HandshakeMessage decode with 5s timeout (#556, D-020)
|
||||
- IPC round-trip benchmark — p50/p95/p99 latency reporting, 5ms threshold (#342, D-020)
|
||||
- Protocol version handshake — `HandshakeMessage` as first IPC frame before tick loop, forward-compatible input handling (#555, D-020)
|
||||
- Protocol v15 — `save_result` field on ObserverSnapshot for client save/load confirmation
|
||||
- State serialization primitives — `serialize_npc_to_frozen`/`deserialize_npc_from_frozen` with full D-024 10-axis coverage for tier eviction freeze/thaw (#96, D-026)
|
||||
- Scope tag system — `ScopeTagKind` (Neighborhood, ActiveQuest, Colleague, KnownContact), `ScopePinned` marker, automatic assignment from KnowledgeGraph and RelationshipGraph (#98, D-026)
|
||||
- Timestamp-based eviction — `LastInteractionTick` LRU tracking, `SimSpacePressure` resource, BinaryHeap eviction respecting scope-pinned entities, Active cap 80 (#97, D-026)
|
||||
- Save/load ECS extraction — `save_to_file`/`load_from_file` via MessagePack, `SaveGame`/`LoadGame` IPC commands, `SaveLoadResultWire` on ObserverSnapshot (#553, D-085)
|
||||
- ScopePinned eviction regression test — adversarial at-scale test proving pinned NPCs survive eviction even with oldest ticks
|
||||
- Test infrastructure — Layer 3 integration test entry point, three-layer architecture per D-030 (#200)
|
||||
- Information boundary negative tests — 4 tests proving no passive KG leakage, LOS fog holds, tier boundary holds, per-NPC save isolation (#272, D-010)
|
||||
- gdUnit4 CI runner script — headless test execution via `run_gdunit4.gd` with exit code for CI (#205)
|
||||
- Scene testing utilities — SceneHelper class with node existence, signal, and path helpers for gdUnit4 (#206)
|
||||
- GameState apply_snapshot tests — 14 tests covering v2+ fields: game_time, facing, interactions, monologue, stance, inventory (#206)
|
||||
- Game session management — per-game save directories under `user://saves/<timestamp>-<seed>/` per D-085, SessionManager autoload, main menu scene (#258)
|
||||
- Debug visualization overlay — F3-toggled dev overlay with LOS rays, vision cone arcs, NPC path trails, knowledge confidence tags, tick timing sparkline (#348)
|
||||
- SimBridge→TestHarness extraction — test simulation logic separated into dedicated RefCounted class with backward-compat proxy API
|
||||
- Workshop outcomes files — formal closure for content-gap-analysis, KG-information-boundaries, v01-content-scoping, v01-gap-analysis, wiki-review
|
||||
- D-087 through D-092 — recovered decisions from v01-content-scoping and wiki-review workshops (triangle config, pause system, content scope, voice registers, anchor lines, complicity theme)
|
||||
- Q-030 through Q-039 — open questions from workshop backlog (seed schema, style guide, cultural ingredients, NPC architecture, PC archetypes, sacred/profane framework, district skeleton, generator pipeline, authored content estimate, gate topology)
|
||||
- Decision ID claim system — `db/connectors/decision` CLI with `next`, `claim`, `check-dupes` commands to prevent cross-worktree D/Q/R ID collisions, pre-commit duplicate check
|
||||
- D-085: Per-game save directory structure — every new game creates `user://saves/<game-id>/`, F5 quicksave, F6 quickload
|
||||
- Q-029: Save file format design — long-term considerations for versioning, compression, integrity, metadata headers
|
||||
- D-086: Renumbered insert icon system (was D-084 on visual branch) to resolve cross-worktree ID collision
|
||||
- Save/load wireframe updated for D-085 — LOAD tab shows games grouped by directory with expand/collapse, QUICKSAVE slot, F5/F6 hints
|
||||
- Sprint 19: Persist planned — 16 tickets (server 7, client 5, CI 4) covering save/load, tier eviction/scope, test infrastructure
|
||||
- Character creation & game setup workshop brief — covers creation model, seed boundary, gate activation, quest seeding, game toggles (resolves Q-011)
|
||||
- Protocol v14 — `poi_list`, `examine_result`, `player_knowledge` ObserverSnapshot wire types with live KG serialization (#151, #174, #264)
|
||||
- Minimap rendering — circular 160px diegetic insert overlay with POI dots (colored by category), border arrows for distant POIs, player-centered fixed-north (#151)
|
||||
- Dialogue UI hardening — confrontation italic voice (D-063), examine result overlay with 5s auto-dismiss and confidence coloring (#174)
|
||||
- Knowledge/journal panel — right-side insert panel (J key), facts grouped by entity, contradicted entries in amber with strikethrough, stale entries dimmed, mutual exclusion with dialogue (#264)
|
||||
- Sprint 18 client test suite — 50 gdUnit4 tests for dialogue (D-062, D-063, D-064) and journal (KG parsing, scene structure, UIStrings), plus test plan document
|
||||
- D-084: dual-namespace line ID scheme for auto-generated NPCs — role pool (shared, unchanged) + instance override (opt-in, seeded counter). Resolves Q-028 (#544)
|
||||
- Tier 1 drama module schema (`content/schemas/drama_module.schema.yaml`) — entry conditions, NPC requirements, event sequences, outcomes, pool format (#158)
|
||||
- Smuggling ring v0.1 stub module (`content/modules/tier1/smuggling_ring_v0_1.yaml`) — vertical slice Tier 1 module with 6 NPC roles, dual event sequences, 5 outcomes (#158)
|
||||
- Line ID authoring guide (`docs/design/line-id-authoring-guide.md`) — dual-namespace conventions for hand-authored and auto-generated NPC content
|
||||
- Tier 1 module authoring guide (`docs/design/tier1-module-authoring.md`) — field reference, NPC pattern/motivation tables, design principles, pre-submission checklist
|
||||
- Background tier state machines — schedule, mood, relationships, job tick once per game-minute for Background NPCs (#95, D-026)
|
||||
- NPC vision system — symmetric shadowcasting for Active-tier NPCs, NpcMemory with last-known-position and zone inference (#115, D-011)
|
||||
- NPC player-awareness behavior — PlayerAwareness component tracks LOS duration, suspicion accumulation, routine deviation triggers (#244)
|
||||
- Skill system & combat flag — SkillSet component (BTreeMap<String, u8>), CombatCapability marker from combat_trained skill (#91, D-024)
|
||||
- Player-action social propagation — three-order trust ripple (100%/40%/20%) through RelationshipGraph with cycle prevention (#249, D-029)
|
||||
- Examine mechanic — process_examine_interaction with character-filtered observation text, KG DirectObservation write, examine_result in ObserverSnapshot (#242)
|
||||
- Character goal/pressure framework — CharacterPressure component (exposure/institutional/relationship), wired to snapshot HUD data (#248)
|
||||
- Save state data model — SaveStateV1 struct with MessagePack serialization, roundtrip tests for entity/KG/relationship/clock state (#256)
|
||||
- Tell state derivation wired into ObserverSnapshot — integration tests for Nervous tell on Major secret + high stress (#337)
|
||||
- Sprint 18: Touch planned — 14 tickets (server 9, client 3, copy 2) covering examine mechanic, NPC awareness, social propagation, minimap, dialogue UI, save state model
|
||||
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
|
||||
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
|
||||
- KnowledgeGranted event processing — grants fire at dialogue line selection, runtime NPC KG guardrail (D-079, #546)
|
||||
- ContradictionClaim struct with 600-tick window detection in observe_entity, epistemic neutrality for both sources (D-083, #547)
|
||||
- NPC-to-NPC knowledge transfer system — trust-gated fact exchange, confidence capping at KnowsOf, ToldBy source construction (D-080, #548)
|
||||
- tell_state KG awareness — NPC relationship reads from KG for other-entity state, MVP information boundary (D-082, #549)
|
||||
- Contradiction monologue with pre-resolved entity names, PersonOfInterest relationship shift, THE FRIEND arc event chain (D-083, #550)
|
||||
- Unprompted disclosure system — DisclosureCandidates component, 7 trigger gates, three-layer rate limiting, two-stage trait filter (D-081, #551)
|
||||
- Trait modifier system — Cautious/Gossipy/Loyal/Talkative filter predicates via content-authorable config (D-081, #173)
|
||||
- POI data model and proximity-based discovery system via KnowledgeGranted events (#148, #149)
|
||||
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
|
||||
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
|
||||
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
|
||||
- D-086: Insert icon system — custom SVG icons over icon fonts, authored to insert geometric constraints with lattice_profile weight scaling
|
||||
- Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states
|
||||
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
|
||||
- Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330)
|
||||
- Diegetic time display on insert HUD — station local time (HH:MM), day phase with cycle-tinted color, day number on InsertOverlay (#263)
|
||||
- Relationship color accent on E-Talk overlay — 3px left-edge bar using D-033 palette signals NPC relationship at a glance (#537)
|
||||
- `Constants.format_game_time()` helper for converting game-minutes to HH:MM station time
|
||||
- `/sprint-status` cleanup sweep skill — consistent health report with tickets by status, PR cross-reference, bookkeeping issue detection, and open work by team
|
||||
- `sprint sweep` CLI subcommand — structured JSON output for sprint health checks (grouped tickets, per-team summary, issue detection)
|
||||
- Knowledge Flow & NPC Boundaries workshop — 5 D-records (D-079–D-083) covering grant architecture, NPC-to-NPC propagation, unprompted disclosure, NPC information boundaries MVP, contradiction detection pipeline
|
||||
- 7 knowledge graph implementation tickets (#545–#551) with full dependency chain and line estimates
|
||||
- Contradiction monologue content ticket (#552) for Sera/Kael FRIEND arc
|
||||
- Sprint 17 completion proofs: contradiction detection fires, NPC-to-NPC knowledge transfers
|
||||
- Entity renderer migrated from ColorRect placeholders to Sprite2D with D-019 angle sprites — self_modulate for D-033 tinting, 8→4 octant direction mapping, feet-anchored y-sort (#540)
|
||||
|
||||
### Fixed
|
||||
- Client protocol version bumped to 15 to match server (was still at 14 after server PR #68 added save_result field)
|
||||
- gen_fixtures.rs version comments changed from hardcoded 14 to PROTOCOL_VERSION constant
|
||||
- run-ipc-benchmark dead --iterations flag removed (Rust compile-time constant governs rounds)
|
||||
|
||||
### Changed
|
||||
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
|
||||
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
|
||||
- `sprint sweep` JSON trimmed — removed unused fields (`ok`, `sprint.status`, `priority`, `ticket_id`), shortened issue detail strings
|
||||
- Sprint status output template condensed — rendering rules moved to skill definition, bookkeeping table simplified to 2 columns
|
||||
- Model selection documented in CLAUDE.md — `/model sonnet[1m]` and `/model opus[1m]` for 1M context sessions
|
||||
- Sprint 17 briefings updated with workshop results — server (14 tickets), copy (2 tickets), client (2), visual (1)
|
||||
- Q-024 (gossip timing), Q-025 (KG memory), Q-026 (contradiction detection) closed
|
||||
- Sprint 16 closed (8/8 done)
|
||||
- 3D sprite render pipeline — Camera3D at D-019 angle (-72.5° from horizontal), three-point studio lighting rig, orthographic projection, resolution chain 1024→256→64
|
||||
- Generic NPC capsule model (24×32px footprint per D-044) and structural wall model for pipeline validation
|
||||
- Test sprites: 8 runtime 64px sprites (NPC + wall × 4 directions) deployed to client/assets/sprites/
|
||||
- Pipeline documentation (renderer/README.md) — camera spec, lighting rig, resolution chain, model authoring guide
|
||||
- DialogueResponse verb handler — players pick dialogue options and receive follow-up lines via full D-028 four-layer pipeline (#539)
|
||||
- Trust-gated gossip verification — integration tests confirm Secret/Real/Surface tier gating per D-075 (#171)
|
||||
- Line variety tracker wiring — DialogueCooldownTracker prevents repeat lines within 600-tick window (#338)
|
||||
- DialogueResponse cross-language fixture for GDScript testing
|
||||
- Sprint team lifecycle through PR review — teams stay alive for commit → push → review → fix loop → approve → shutdown
|
||||
- Zone_id extraction in game_state.gd optimized from O(N) tile scan to O(1) dictionary lookup — builds _tile_by_coord from member visible_tiles covering both test and live paths (#543)
|
||||
- Shared run_dialogue_pipeline() helper eliminates ~60 lines of duplication between Talk and DialogueResponse systems
|
||||
- Dialogue and monologue line IDs migrated from location-scoped (the-terminal_d_039) to NPC-scoped (kael-davan_d_001) namespace — each NPC has an independent sequence per D-035 (#542)
|
||||
- DialogueCooldownTracker documented as per-player-global by design (NPC-scoped line IDs per D-035 prevent collision)
|
||||
- CONFRONTATION_LINES marked TODO for migration to D-028/D-035 content pipeline
|
||||
- pr-push and pr-review skills updated with team lifecycle awareness
|
||||
|
||||
### Fixed
|
||||
- PR #59 review: stale mood vocabulary updated in line-pool-format.md, style-guide, and content-directory-structure.md to post-Sprint 14 values
|
||||
- PR #59 review: orphaned location-scoped IDs in maintenance-tech.yaml comments and smuggler-inventory.yaml cross-references updated to NPC-scoped
|
||||
- PR #59 review: Lera Sessik tenure corrected from "twelve years" to "eighteen years", NPC header fixed
|
||||
- PR #59 review: ring-operative.yaml fact_id corrected from `location.surveillance_gaps` to `investigation.surveillance_gaps`
|
||||
- Dialogue systems moved from BridgePlugin to NpcPlugin — game logic registers where it belongs (#538)
|
||||
- Schedule ambiguity: emit_observation_events now has explicit .before(advance_tick) constraint
|
||||
- process_dialogue_response updates ActiveDialogue tick and InteractionMemory on follow-up
|
||||
- DialogueResponse range check added (CLOSE_RANGE, matching Talk/Confront pattern)
|
||||
- Weighted selection fallback replaced with unreachable!() — dead code removed
|
||||
- assert!(false) → panic!() in serialization tests (clippy)
|
||||
- SetFacing and TeleportToHub added to roundtrip test coverage
|
||||
|
||||
## [v0.1.15] — 2026-02-23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,51 +1,26 @@
|
||||
# The Settled Reach
|
||||
|
||||
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC (D-020).
|
||||
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC.
|
||||
|
||||
**Official Title:** The Settled Reach (D-021)
|
||||
**Repository name:** settled-reach (formerly commonwealth, renamed for clarity)
|
||||
**Official Title:** The Settled Reach
|
||||
**Repository name:** settled-reach
|
||||
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
client/ # Godot 4 client (D-020)
|
||||
server/ # Rust/bevy_ecs simulation server (D-020)
|
||||
client/ # Godot 4 client
|
||||
server/ # Rust/bevy_ecs simulation server
|
||||
tooling/ # Build tools, scripts, asset pipelines
|
||||
tests/ # Integration and end-to-end tests
|
||||
.config/ # Configuration files (linters, formatters, CI)
|
||||
.cache/ # Local caches for testing/linting (gitignored)
|
||||
docs/
|
||||
discussions/ # Discussion rounds (all rounds archived here per D-022)
|
||||
briefings/ # Per-agent context briefings (maintained by Qatux)
|
||||
architecture/ # Technical architecture documents
|
||||
design/ # Game design documents
|
||||
diagrams/ # d2 source + PNG renders (architecture, data-flow, entity, state, ui)
|
||||
sprints/ # Sprint briefings per team (server.md, client.md, copy.md, joint.md, etc.)
|
||||
workshops/ # Workshop briefs and outputs (per-workshop subdirectories)
|
||||
db/
|
||||
schema.sql # Database schema
|
||||
connectors/ # Connector scripts for SQLite and Qdrant
|
||||
config.json # Endpoint configuration
|
||||
ticket # Ticket CLI (list, show, create, assign, sprint, etc.)
|
||||
sqlite_connector.py # SQLite mini MCP
|
||||
qdrant_connector.py # Qdrant + ollama mini MCP
|
||||
.claude/
|
||||
agents/ # Agent personality files
|
||||
skills/ # Skill definitions
|
||||
decisions/ # Decision domain files (source of truth)
|
||||
README.md # Domain index and query examples
|
||||
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066
|
||||
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078
|
||||
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
|
||||
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065
|
||||
process.md # D-004, D-021, D-022
|
||||
questions.md # Q-001 through Q-011
|
||||
rejected.md # R-001 through R-010
|
||||
DECISIONS.md # Redirect to decisions/ directory
|
||||
TEAM.md # Team roster and roles
|
||||
docs/ # Architecture, design, briefings, sprints, workshops
|
||||
db/ # Schema + connector scripts (ticket CLI, SQLite, Qdrant)
|
||||
.claude/ # Agents, skills, rules
|
||||
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
|
||||
```
|
||||
|
||||
Full annotated tree: `.claude/rules/project-structure.md`
|
||||
|
||||
## DevOps
|
||||
|
||||
See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. All development operations go through the top-level `Makefile` — run `make` for a summary of targets.
|
||||
@@ -54,15 +29,12 @@ See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. A
|
||||
|
||||
### Worktree boundaries
|
||||
|
||||
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) is checked out in its own worktree under that parent. The parent directory also contains shared resources like the ticketing database.
|
||||
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) has its own worktree. The worktree root IS the git root.
|
||||
|
||||
Each worktree contains the full repository: `server/` (Rust backend), `client/` (Godot client), `docs/`, `decisions/`, etc. The worktree root IS the git root — use `git rev-parse --show-toplevel` if in doubt.
|
||||
|
||||
Unless there is a direct instruction or a functional need (e.g. accessing the shared database in the parent directory), **all work must remain within the scope of the git root Claude is running in.**
|
||||
|
||||
- All file paths are relative to the worktree/git root (e.g. `server/src/bridge/types.rs`, `client/scripts/rendering/fog.gd`).
|
||||
- Do not navigate to or access sibling worktrees in the parent directory (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
||||
- Do not navigate above the git root unless explicitly instructed.
|
||||
- **All work must remain within the git root** unless explicitly instructed otherwise.
|
||||
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
|
||||
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
||||
- **Exception — stale git lock files:** Worktree index locks live in the shared `.git` directory (e.g. `main/.git/worktrees/copy/index.lock`). If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own worktree only**. Never touch lock files belonging to other worktrees.
|
||||
|
||||
### Database
|
||||
|
||||
@@ -74,110 +46,25 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
||||
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
|
||||
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
|
||||
|
||||
### Ticket and database access
|
||||
**Prefer the ticket CLI over raw SQL.** The CLI handles column names, joins, and output formatting correctly:
|
||||
```bash
|
||||
db/connectors/ticket list --sprint 2 --team server
|
||||
db/connectors/ticket show 78
|
||||
db/connectors/ticket sprint --active
|
||||
```
|
||||
### CLI tools
|
||||
|
||||
### Sprint CLI
|
||||
**Use the sprint CLI for sprint-scoped operations.** It batches ticket queries and formats output for agent consumption:
|
||||
```bash
|
||||
db/connectors/sprint status # Current sprint progress
|
||||
db/connectors/sprint status --team server # Team-scoped view
|
||||
db/connectors/sprint start-work --team client # Full context dump for starting work
|
||||
db/connectors/sprint prepare # Prepare next sprint (candidates + gaps)
|
||||
db/connectors/sprint start # Activate a planned sprint
|
||||
db/connectors/sprint stop # Complete an active sprint
|
||||
```
|
||||
Team is auto-detected from the current git branch (if not `main`). Sprint is auto-detected from DB state.
|
||||
**Prefer CLI wrappers over raw SQL.** Never use the `sqlite3` CLI — it crashes in Claude Code (std::bad_alloc). Use the wrapper scripts instead.
|
||||
|
||||
Only fall back to raw SQL for queries the CLI doesn't support. **Never use the `sqlite3` CLI** — it crashes in Claude Code due to a known std::bad_alloc bug. Use the wrapper scripts instead:
|
||||
```bash
|
||||
db/connectors/sqlite-query "SELECT * FROM tickets WHERE status='in_progress'"
|
||||
db/connectors/sqlite-exec "UPDATE tickets SET status='done' WHERE id=1"
|
||||
```
|
||||
|
||||
### Qdrant / document search
|
||||
```bash
|
||||
db/connectors/qdrant-search "asymmetric information design"
|
||||
db/connectors/qdrant-index docs/briefings/tyre.md
|
||||
db/connectors/qdrant-health
|
||||
db/connectors/qdrant-count
|
||||
```
|
||||
|
||||
### Gitea access (tea CLI)
|
||||
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
|
||||
|
||||
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
|
||||
|
||||
```bash
|
||||
# List open PRs
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
|
||||
# View a PR with comments
|
||||
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
|
||||
|
||||
# Post a comment on a PR (or issue)
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "comment body"
|
||||
|
||||
# Approve a PR
|
||||
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||
|
||||
# List issues
|
||||
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
Key rules:
|
||||
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
||||
- **Use `--output simple`** for machine-readable output (no table borders)
|
||||
- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`:
|
||||
```bash
|
||||
# Step 1: Write content to .tmp/ (gitignored) using the Write tool
|
||||
# Step 2: Post via cat
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "$(cat .tmp/review-branch.md)"
|
||||
```
|
||||
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
||||
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
||||
| Tool | Command | Full reference |
|
||||
|------|---------|----------------|
|
||||
| Tickets | `db/connectors/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
|
||||
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
||||
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
|
||||
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
|
||||
| Decisions | `db/connectors/decision next`, `claim`, `check-dupes` | — |
|
||||
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
|
||||
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||
|
||||
### File conventions
|
||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
||||
- **Claim IDs before writing:** `db/connectors/decision claim D <domain> "title"` — prevents ID collisions across worktrees
|
||||
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
|
||||
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
||||
- Briefings: one per agent, updated after decision-producing rounds
|
||||
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
|
||||
|
||||
### Commit conventions
|
||||
Use conventional commits with project-specific scopes:
|
||||
`agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
||||
|
||||
### Pull requests
|
||||
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
|
||||
|
||||
Always provide all required flags to ensure non-interactive execution:
|
||||
```bash
|
||||
tea pr create \
|
||||
--repo jpmschweitzer/settled-reach \
|
||||
--login schweitz \
|
||||
--title "feat(scope): short description" \
|
||||
--description "PR body here" \
|
||||
--base main \
|
||||
--head branch-name
|
||||
```
|
||||
|
||||
### Large content pushes (team pattern)
|
||||
When producing many files (wiki pages, content batches, bulk docs):
|
||||
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
|
||||
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
|
||||
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
||||
|
||||
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
||||
|
||||
### Local services
|
||||
- Gitea: `http://git.schweitz.internal` (login: `schweitz`)
|
||||
- Qdrant: `http://tower-of-joy:6333/`
|
||||
- Ollama: `http://tower-of-joy:11434/` (nomic-embed-text)
|
||||
- Collection: `commonwealth` (768 dimensions, cosine distance)
|
||||
|
||||
@@ -7,7 +7,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||
pre-pr-server pre-pr-client pre-pr-content \
|
||||
fixtures-client golden-diff golden-update \
|
||||
checklist-validate checklist-generate \
|
||||
perf-baseline debug-schedule
|
||||
perf-baseline debug-schedule \
|
||||
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -23,11 +24,15 @@ help:
|
||||
@echo " make stop Stop any running server instance"
|
||||
@echo " make client Run the Godot client (test mode)"
|
||||
@echo " make server Run the Rust simulation server"
|
||||
@echo " make test Run all tests"
|
||||
@echo " make lint Run all linters"
|
||||
@echo " make ci Run full CI pipeline locally"
|
||||
@echo " make ci-client Run client CI checks"
|
||||
@echo " make ci-server Run server CI checks"
|
||||
@echo " make test Run all tests"
|
||||
@echo " make test-ipc-fixtures Layer 1: IPC serialization fixtures"
|
||||
@echo " make test-ipc-protocol Layer 2: mock IPC protocol tests"
|
||||
@echo " make test-ipc-integration Layer 3: real subprocess round-trip"
|
||||
@echo " make test-ipc-benchmark IPC latency benchmark (blocked: #555/#556)"
|
||||
@echo " make lint Run all linters"
|
||||
@echo " make ci Run full CI pipeline locally"
|
||||
@echo " make ci-client Run client CI checks"
|
||||
@echo " make ci-server Run server CI checks"
|
||||
@echo " make check-protocol Verify server/client protocol versions match"
|
||||
@echo " make clean Remove build artifacts and caches"
|
||||
@echo ""
|
||||
@@ -128,7 +133,7 @@ stop:
|
||||
test: test-server test-client
|
||||
|
||||
test-server:
|
||||
cd server && cargo nextest run
|
||||
tests/run-rust
|
||||
|
||||
fixtures:
|
||||
cd server && cargo test --test gen_fixtures -- --ignored
|
||||
@@ -169,8 +174,19 @@ golden-update:
|
||||
@echo "Review with: git diff --cached -- server/tests/golden/"
|
||||
|
||||
test-client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
$(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
|
||||
tests/run-godot
|
||||
|
||||
test-ipc-fixtures:
|
||||
tests/run-ipc-fixtures
|
||||
|
||||
test-ipc-protocol:
|
||||
tests/run-ipc-protocol
|
||||
|
||||
test-ipc-integration:
|
||||
tests/run-ipc-integration
|
||||
|
||||
test-ipc-benchmark:
|
||||
tests/run-ipc-benchmark
|
||||
|
||||
# --- Lint ---
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -104,6 +104,9 @@ notifications:
|
||||
|
||||
# System
|
||||
save_complete: "Progress saved."
|
||||
load_complete: "Session restored."
|
||||
save_failed: "Save failed."
|
||||
load_failed: "Load failed."
|
||||
connection_lost: "Signal interrupted."
|
||||
connection_restored: "Signal restored."
|
||||
|
||||
@@ -125,6 +128,17 @@ knowledge_panel:
|
||||
confidence_medium: "Likely"
|
||||
confidence_low: "Unconfirmed"
|
||||
confidence_rumor: "Hearsay"
|
||||
# D-041 KnowledgeConfidence levels — displayed in journal panel
|
||||
confidence_direct: "Confirmed"
|
||||
confidence_knowsdetails: "Detailed"
|
||||
confidence_knowsof: "Known"
|
||||
confidence_suspects: "Unconfirmed"
|
||||
# D-041 KnowledgeSource labels — displayed in journal panel
|
||||
source_directobservation: "Observed"
|
||||
source_toldby: "Told"
|
||||
source_heard: "Overheard"
|
||||
source_inferred: "Inferred"
|
||||
source_background: "Prior"
|
||||
|
||||
# ============================================================
|
||||
# TUTORIAL TEXT (DIEGETIC)
|
||||
@@ -159,6 +173,8 @@ dialogue:
|
||||
menu:
|
||||
pause_title: "Paused"
|
||||
resume: "Resume"
|
||||
new_game: "New Game"
|
||||
continue: "Continue"
|
||||
settings: "Settings"
|
||||
save_game: "Save"
|
||||
load_game: "Load"
|
||||
|
||||
@@ -11,7 +11,7 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="The Settled Reach"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
run/main_scene="res://scenes/main_menu.tscn"
|
||||
config/features=PackedStringArray("4.6", "GL Compatibility")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
@@ -23,6 +23,7 @@ InputMapper="*res://scripts/autoloads/input_mapper.gd"
|
||||
UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||
SessionManager="*res://scripts/autoloads/session_manager.gd"
|
||||
|
||||
[audio]
|
||||
|
||||
@@ -125,11 +126,26 @@ debug_overlay={
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
open_journal={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
teleport_hub={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
quicksave={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194336,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
quickload={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194337,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[rendering]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=26 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
@@ -22,6 +22,9 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
||||
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
|
||||
[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"]
|
||||
[ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"]
|
||||
[ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -118,6 +121,9 @@ zoom = Vector2(2, 2)
|
||||
[node name="InsertOverlay" type="CanvasLayer" parent="."]
|
||||
layer = 10
|
||||
|
||||
; #263: Time display — diegetic insert clock, top-left placeholder (D-013, D-031)
|
||||
[node name="TimeDisplay" parent="InsertOverlay" instance=ExtResource("23_tdisplay")]
|
||||
|
||||
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
|
||||
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
|
||||
|
||||
@@ -130,6 +136,15 @@ layer = 10
|
||||
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
|
||||
[node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")]
|
||||
|
||||
; #151: Minimap — diegetic insert overlay, top-right, 160px circle (D-013, D-049 z-layer 6)
|
||||
[node name="Minimap" parent="InsertOverlay" instance=ExtResource("7_minimap")]
|
||||
|
||||
; #174: Examine result — non-interactive observe text overlay, auto-dismisses 5s (D-061 adjacent)
|
||||
[node name="ExamineDisplay" parent="InsertOverlay" instance=ExtResource("24_examine")]
|
||||
|
||||
; #264: Journal panel — knowledge graph review, toggle J key, read-only (D-041)
|
||||
[node name="JournalPanel" parent="InsertOverlay" instance=ExtResource("25_journal")]
|
||||
|
||||
; --- UI layer (CanvasLayer 20) ---
|
||||
; HUD, monologue, cursor — always visible, not affected by fog or camera.
|
||||
[node name="UILayer" type="CanvasLayer" parent="."]
|
||||
@@ -137,8 +152,6 @@ layer = 20
|
||||
|
||||
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
|
||||
|
||||
[node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")]
|
||||
|
||||
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
|
||||
|
||||
; D-053: Stance indicator — top-right, color-coded
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://main_menu_sr"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/main_menu.gd" id="1_mainmenu"]
|
||||
|
||||
; Main menu — New Game / Continue / Quit.
|
||||
; #258: D-085 per-game save directory created on New Game.
|
||||
|
||||
[node name="MainMenu" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_mainmenu")
|
||||
|
||||
[node name="Background" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0.05, 0.05, 0.08, 1.0)
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -120.0
|
||||
offset_top = -80.0
|
||||
offset_right = 120.0
|
||||
offset_bottom = 100.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 16
|
||||
alignment = 1
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "THE SETTLED REACH"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 36
|
||||
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0)
|
||||
|
||||
[node name="Spacer" type="Control" parent="VBox"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 24)
|
||||
|
||||
[node name="NewGameBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "NEW GAME"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="ContinueBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "CONTINUE"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="QuitBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "QUIT"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
|
||||
@@ -1,9 +1,19 @@
|
||||
extends Node
|
||||
|
||||
signal game_id_changed(new_id: String)
|
||||
|
||||
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}).
|
||||
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
|
||||
# Tiles use format: [{x, y, z, type}].
|
||||
var current_snapshot: Dictionary = {}
|
||||
|
||||
# D-085 (#258): Active game session identifier. Format: <YYYYMMDD>-<HHMMSS>-<hex6>
|
||||
# Set by SessionManager.new_game() or SessionManager.resume_game().
|
||||
# Empty string when no session is active (main menu state).
|
||||
var current_game_id: String = "":
|
||||
set(v):
|
||||
current_game_id = v
|
||||
game_id_changed.emit(v)
|
||||
var current_tick: int = 0
|
||||
var player_position: Vector2 = Vector2.ZERO
|
||||
var visible_entities: Array = []
|
||||
@@ -60,6 +70,11 @@ var insert_active: bool = true
|
||||
# Null in v0.1 (server does not yet send this field; protocol change required).
|
||||
var rng_seed: Variant = null
|
||||
|
||||
# v15 fields (#554, D-085): save/load result from server.
|
||||
# {success: bool, kind: "save"|"load", error: Variant} or null.
|
||||
# One-shot: consumed by main.gd after display, then set back to null.
|
||||
var save_result: Variant = null
|
||||
|
||||
# v7 fields (#431, D-059/D-060)
|
||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||
|
||||
@@ -70,6 +85,21 @@ var dialogue_response: Variant = null # {line_id, text, speaker_entity_id}
|
||||
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
|
||||
var conversation_ended: Array = [] # [{speaker_id, target_id}]
|
||||
|
||||
# v10 fields (#151, D-013): Discovered POIs from server (#148/#149).
|
||||
# Format: [{poi_id, name, x, y, z, category}]. Persists between snapshots unless
|
||||
# server explicitly sends an empty array (cleared locations are not typical in v0.1).
|
||||
# Populated from snapshot "poi_list" field — only updated when field present.
|
||||
var discovered_pois: Array = []
|
||||
|
||||
# v14 fields (#174, #242): Character-filtered examine result.
|
||||
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
|
||||
var current_examine_result: Variant = null
|
||||
|
||||
# v14 fields (#264, D-041): Player knowledge graph dump for journal panel.
|
||||
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
|
||||
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
|
||||
var player_knowledge: Variant = null
|
||||
|
||||
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
|
||||
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
|
||||
var medium_sound_events: Array = []
|
||||
@@ -79,14 +109,17 @@ var medium_sound_events: Array = []
|
||||
var close_sound_events: Array = []
|
||||
|
||||
# D-071 (#530): Consecutive ticks without player position change.
|
||||
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
|
||||
# D-020: Server-authoritative — read from snapshot "stationary_ticks" field.
|
||||
# Fallback: client-side accumulation (deprecated, remove when server populates field).
|
||||
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
|
||||
var stationary_ticks: int = 0
|
||||
# DEPRECATED: Only used by client-side accumulation fallback. Remove with fallback.
|
||||
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
|
||||
|
||||
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
||||
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
|
||||
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
|
||||
# D-020: Read directly from snapshot "zone_id" field.
|
||||
# Fallback: client-side tile lookup (deprecated, remove when server populates field).
|
||||
# Empty string when zone_id field absent.
|
||||
var current_zone_id: String = ""
|
||||
|
||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
@@ -110,12 +143,19 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
push_warning("GameState: no Player entity found in %d entities" % [
|
||||
visible_entities.size()])
|
||||
|
||||
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
|
||||
# Compares current player_position against previous snapshot's position.
|
||||
if player_position == _prev_player_position:
|
||||
stationary_ticks += 1
|
||||
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
|
||||
# Prefer server-sent value; fall back to client-side accumulation until server populates.
|
||||
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
|
||||
# D-020: direct field assignment from server-authoritative snapshot.
|
||||
stationary_ticks = snapshot.stationary_ticks
|
||||
else:
|
||||
stationary_ticks = 0
|
||||
# DEPRECATED fallback — client-side accumulation. Remove when server sends
|
||||
# "stationary_ticks" in ObserverSnapshot (D-020 violation: derives behavior-
|
||||
# driving state on the client). Server tracks this in ListeningFocus component.
|
||||
if player_position == _prev_player_position:
|
||||
stationary_ticks += 1
|
||||
else:
|
||||
stationary_ticks = 0
|
||||
_prev_player_position = player_position
|
||||
|
||||
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
|
||||
@@ -240,16 +280,49 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
|
||||
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
|
||||
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
|
||||
# positional indexing — use the same tile iteration below instead.
|
||||
current_zone_id = ""
|
||||
var _px := int(player_position.x)
|
||||
var _py := int(player_position.y)
|
||||
for _ztile in visible_tiles:
|
||||
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
|
||||
current_zone_id = _ztile.get("zone_id", "")
|
||||
break
|
||||
# v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player.
|
||||
# Accepts "discovered_pois" or "poi_list" key — both map to the same client field.
|
||||
# Only update if the field is present — absence means "no change since last tick".
|
||||
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
|
||||
discovered_pois = snapshot.discovered_pois
|
||||
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
|
||||
discovered_pois = snapshot.poi_list
|
||||
|
||||
# v14: examine_result (#174, #242) — character-filtered observation from Examine verb.
|
||||
if snapshot.has("examine_result") and snapshot.examine_result is Dictionary:
|
||||
current_examine_result = snapshot.examine_result
|
||||
else:
|
||||
current_examine_result = null
|
||||
|
||||
# v15: save_result (#554, D-085) — one-shot save/load confirmation from server.
|
||||
if snapshot.has("save_result") and snapshot.save_result is Dictionary:
|
||||
save_result = snapshot.save_result
|
||||
else:
|
||||
save_result = null
|
||||
|
||||
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||
# Only update when field is present (null means no change, server sends when KG changes).
|
||||
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
||||
player_knowledge = snapshot.player_knowledge
|
||||
|
||||
# D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade.
|
||||
# Prefer server-sent top-level value; fall back to client-side tile lookup until
|
||||
# server populates top-level "zone_id" in ObserverSnapshot.
|
||||
if snapshot.has("zone_id") and snapshot.zone_id is String:
|
||||
# D-020: direct field assignment from server-authoritative snapshot.
|
||||
current_zone_id = snapshot.zone_id
|
||||
else:
|
||||
# DEPRECATED fallback — client-side tile lookup. Remove when server sends
|
||||
# top-level "zone_id" in ObserverSnapshot (D-020 violation: derives zone
|
||||
# identity on the client via tile iteration). Server sends zone_id per
|
||||
# VisibleTile but not as a top-level snapshot field.
|
||||
var _tile_by_coord: Dictionary = {}
|
||||
for vtile in visible_tiles:
|
||||
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
|
||||
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
|
||||
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
|
||||
var player_tile = _tile_by_coord.get(player_pos_key, null)
|
||||
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
# Derives visible_positions when not explicitly provided (real server mode)
|
||||
|
||||
@@ -19,8 +19,11 @@ enum Action {
|
||||
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
||||
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
||||
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
||||
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
|
||||
SET_FACING, # D-054: facing octant update (no movement)
|
||||
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
||||
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
|
||||
LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
@@ -106,15 +109,29 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
action = Action.TOGGLE_STANCE_DOWN
|
||||
elif event.is_action_pressed("bug_report"):
|
||||
action = Action.BUG_REPORT
|
||||
elif event.is_action_pressed("open_journal"):
|
||||
action = Action.OPEN_JOURNAL
|
||||
elif event.is_action_pressed("teleport_hub"):
|
||||
if GameState.gauntlet_mode:
|
||||
action = Action.TELEPORT_HUB
|
||||
elif event.is_action_pressed("quicksave"):
|
||||
action = Action.SAVE_GAME
|
||||
elif event.is_action_pressed("quickload"):
|
||||
action = Action.LOAD_GAME
|
||||
|
||||
if action != -1:
|
||||
input_queue.append({
|
||||
var entry := {
|
||||
"action": action,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
}
|
||||
# #554: Attach save path for SaveGame/LoadGame actions
|
||||
if action == Action.SAVE_GAME or action == Action.LOAD_GAME:
|
||||
var game_id := GameState.current_game_id
|
||||
if game_id.is_empty():
|
||||
get_viewport().set_input_as_handled()
|
||||
return # No active session — ignore save/load
|
||||
entry["action_data"] = {"path": "user://saves/" + game_id + "/quicksave.sav"}
|
||||
input_queue.append(entry)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
extends Node
|
||||
## D-085 (#258): Game session lifecycle manager.
|
||||
## Creates per-game save directories on New Game, resumes existing sessions,
|
||||
## and handles quit-to-menu flow with save confirmation.
|
||||
##
|
||||
## All save dirs live under user://saves/<game-id>/ where game-id is
|
||||
## <YYYYMMDD>-<HHMMSS>-<hex6> (e.g. "20260225-143022-a7b3f1").
|
||||
|
||||
const SAVES_DIR := "user://saves/"
|
||||
const GAME_SCENE := "res://scenes/main.tscn"
|
||||
const MENU_SCENE := "res://scenes/main_menu.tscn"
|
||||
|
||||
var _quit_dialog: ConfirmationDialog = null
|
||||
|
||||
|
||||
## Generate a new game-id, create its save directory, and activate the session.
|
||||
## Returns the new game-id string.
|
||||
func new_game() -> String:
|
||||
var now := Time.get_datetime_dict_from_system()
|
||||
var timestamp := "%04d%02d%02d-%02d%02d%02d" % [
|
||||
now.year, now.month, now.day,
|
||||
now.hour, now.minute, now.second,
|
||||
]
|
||||
var rng := RandomNumberGenerator.new()
|
||||
var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF)
|
||||
var game_id := "%s-%s" % [timestamp, hex_seed]
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
var err := DirAccess.make_dir_recursive_absolute(save_path)
|
||||
if err != OK:
|
||||
push_error("SessionManager: failed to create save dir %s: %s" % [
|
||||
save_path, error_string(err)])
|
||||
return ""
|
||||
GameState.current_game_id = game_id
|
||||
return game_id
|
||||
|
||||
|
||||
## Resume an existing game session by setting the active game-id.
|
||||
func resume_game(game_id: String) -> void:
|
||||
GameState.current_game_id = game_id
|
||||
|
||||
|
||||
## List all game directories under user://saves/ sorted by last-modified (most recent first).
|
||||
## Returns Array of {game_id: String, modified_time: int, newest_save: String}.
|
||||
func list_game_dirs() -> Array:
|
||||
var dir := DirAccess.open(SAVES_DIR)
|
||||
if dir == null:
|
||||
return []
|
||||
var results: Array = []
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if dir.current_is_dir() and not entry.begins_with("."):
|
||||
var dir_path := SAVES_DIR + entry + "/"
|
||||
var newest_save := _find_newest_save(dir_path)
|
||||
var mtime: int = 0
|
||||
if newest_save != "":
|
||||
mtime = FileAccess.get_modified_time(dir_path + newest_save)
|
||||
results.append({
|
||||
"game_id": entry,
|
||||
"modified_time": mtime,
|
||||
"newest_save": newest_save,
|
||||
})
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return a.modified_time > b.modified_time)
|
||||
return results
|
||||
|
||||
|
||||
## Show "Save before quitting?" confirmation dialog, then return to main menu.
|
||||
## #554: The actual F5 save will be wired here once server supports SaveCommand.
|
||||
func quit_to_menu() -> void:
|
||||
if _quit_dialog != null and is_instance_valid(_quit_dialog):
|
||||
return # Dialog already open
|
||||
_quit_dialog = ConfirmationDialog.new()
|
||||
_quit_dialog.dialog_text = UIStrings.get_text("menu.confirm_quit")
|
||||
_quit_dialog.ok_button_text = UIStrings.get_text("menu.confirm_yes")
|
||||
_quit_dialog.cancel_button_text = UIStrings.get_text("menu.confirm_no")
|
||||
get_tree().root.add_child(_quit_dialog)
|
||||
_quit_dialog.confirmed.connect(_do_quit_to_menu)
|
||||
_quit_dialog.canceled.connect(_cleanup_quit_dialog)
|
||||
_quit_dialog.popup_centered()
|
||||
|
||||
|
||||
func _do_quit_to_menu() -> void:
|
||||
_cleanup_quit_dialog()
|
||||
# #554: Trigger quicksave before navigating to menu.
|
||||
# send_input() buffers the command — defer scene change by one frame so
|
||||
# SimBridge._process() flushes the outbound buffer before teardown.
|
||||
if not GameState.current_game_id.is_empty():
|
||||
var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav"
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.SAVE_GAME,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"path": path},
|
||||
})
|
||||
GameState.current_game_id = ""
|
||||
_navigate_to_menu.call_deferred()
|
||||
else:
|
||||
GameState.current_game_id = ""
|
||||
get_tree().change_scene_to_file(MENU_SCENE)
|
||||
|
||||
|
||||
func _navigate_to_menu() -> void:
|
||||
get_tree().change_scene_to_file(MENU_SCENE)
|
||||
|
||||
|
||||
func _cleanup_quit_dialog() -> void:
|
||||
if _quit_dialog != null and is_instance_valid(_quit_dialog):
|
||||
_quit_dialog.queue_free()
|
||||
_quit_dialog = null
|
||||
|
||||
|
||||
func _find_newest_save(dir_path: String) -> String:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
return ""
|
||||
var best_name := ""
|
||||
var best_time: int = 0
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if not dir.current_is_dir() and entry.ends_with(".sav"):
|
||||
var mtime := FileAccess.get_modified_time(dir_path + entry)
|
||||
if mtime > best_time:
|
||||
best_time = mtime
|
||||
best_name = entry
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
return best_name
|
||||
@@ -1,17 +1,11 @@
|
||||
extends Node
|
||||
|
||||
# Connection states
|
||||
enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
|
||||
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
|
||||
|
||||
var state: ConnectionState = ConnectionState.DISCONNECTED
|
||||
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
|
||||
var _test_tick: int = 0
|
||||
var _test_player_pos: Vector2i = Vector2i(10, 10)
|
||||
var _test_facing: String = "North"
|
||||
var _test_input_queue: Array = [] # Queued actions for test mode
|
||||
var _test_in_dialogue: bool = false # Mock dialogue state (#434)
|
||||
var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard
|
||||
var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color
|
||||
var harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client)
|
||||
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||
|
||||
@@ -27,23 +21,68 @@ const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
|
||||
var _connect_retries: int = 0
|
||||
var _retry_timer: float = 0.0
|
||||
|
||||
# Handshake state (#556)
|
||||
const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds
|
||||
var _handshake_start_usec: int = 0
|
||||
|
||||
# Signals
|
||||
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
|
||||
signal snapshot_received(snapshot: Dictionary)
|
||||
signal handshake_complete(protocol_version: int)
|
||||
signal handshake_failed(reason: String)
|
||||
|
||||
func _ready() -> void:
|
||||
if test_mode:
|
||||
harness = TestHarness.new()
|
||||
print("SimBridge: Running in test mode (dynamic snapshot)")
|
||||
|
||||
# Reset test state — call before tests that use _test_snapshot()
|
||||
|
||||
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
|
||||
|
||||
func reset_test_state() -> void:
|
||||
_test_tick = 0
|
||||
_test_player_pos = Vector2i(10, 10)
|
||||
_test_facing = "North"
|
||||
_test_input_queue.clear()
|
||||
_test_in_dialogue = false
|
||||
_test_gauntlet_mode = false
|
||||
_test_npc_relationship = "Unknown"
|
||||
if harness: harness.reset()
|
||||
|
||||
func _test_snapshot() -> Dictionary:
|
||||
return harness.snapshot()
|
||||
|
||||
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
return harness.has_los(from, to)
|
||||
|
||||
var _test_tick: int:
|
||||
get: return harness.tick if harness else 0
|
||||
set(v):
|
||||
if harness: harness.tick = v
|
||||
|
||||
var _test_player_pos: Vector2i:
|
||||
get: return harness.player_pos if harness else Vector2i.ZERO
|
||||
set(v):
|
||||
if harness: harness.player_pos = v
|
||||
|
||||
var _test_facing: String:
|
||||
get: return harness.facing if harness else "North"
|
||||
set(v):
|
||||
if harness: harness.facing = v
|
||||
|
||||
var _test_in_dialogue: bool:
|
||||
get: return harness.in_dialogue if harness else false
|
||||
set(v):
|
||||
if harness: harness.in_dialogue = v
|
||||
|
||||
var _test_gauntlet_mode: bool:
|
||||
get: return harness.gauntlet_mode if harness else false
|
||||
set(v):
|
||||
if harness: harness.gauntlet_mode = v
|
||||
|
||||
var _test_npc_relationship: String:
|
||||
get: return harness.npc_relationship if harness else "Unknown"
|
||||
set(v):
|
||||
if harness: harness.npc_relationship = v
|
||||
|
||||
var _test_input_queue: Array:
|
||||
get: return harness.input_queue if harness else []
|
||||
|
||||
|
||||
# -- Connection lifecycle ------------------------------------------------------
|
||||
|
||||
# Change connection state and emit signal
|
||||
func _set_state(new_state: ConnectionState) -> void:
|
||||
@@ -66,8 +105,13 @@ func connect_to_sim() -> void:
|
||||
# Spawn server subprocess
|
||||
if not server_path.is_empty():
|
||||
_server = ServerProcess.new()
|
||||
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876")
|
||||
var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)])
|
||||
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
|
||||
# D-085 (#258): pass --game-id <id> so server logs use the same session identifier.
|
||||
var args := ["127.0.0.1:" + str(server_port)]
|
||||
var game_id: String = GameState.current_game_id
|
||||
if not game_id.is_empty():
|
||||
args.append_array(["--game-id", game_id])
|
||||
var pid := _server.start(server_path, args)
|
||||
if pid <= 0:
|
||||
push_error("SimBridge: failed to start server")
|
||||
_set_state(ConnectionState.ERROR)
|
||||
@@ -121,7 +165,8 @@ func _process(delta: float) -> void:
|
||||
_bridge.poll()
|
||||
match _bridge.get_status():
|
||||
StreamPeerTCP.STATUS_CONNECTED:
|
||||
_set_state(ConnectionState.CONNECTED)
|
||||
_handshake_start_usec = Time.get_ticks_usec()
|
||||
_set_state(ConnectionState.HANDSHAKING)
|
||||
StreamPeerTCP.STATUS_CONNECTING:
|
||||
pass # Still connecting, wait
|
||||
StreamPeerTCP.STATUS_ERROR:
|
||||
@@ -134,6 +179,62 @@ func _process(delta: float) -> void:
|
||||
_bridge = null # Reset and retry
|
||||
return
|
||||
|
||||
# HANDSHAKING state: read first framed message, validate HandshakeMessage (#556)
|
||||
if state == ConnectionState.HANDSHAKING:
|
||||
if _bridge == null:
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
_bridge.poll()
|
||||
|
||||
# Check connection dropped during handshake
|
||||
var bridge_status := _bridge.get_status()
|
||||
if bridge_status == StreamPeerTCP.STATUS_ERROR or bridge_status == StreamPeerTCP.STATUS_NONE:
|
||||
var reason := "Connection dropped during handshake"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge = null
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
# Check timeout
|
||||
if Time.get_ticks_usec() - _handshake_start_usec > HANDSHAKE_TIMEOUT_USEC:
|
||||
var reason := "Handshake timeout: no message received within 5 seconds"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
# Try to read first message
|
||||
var msg := _bridge.poll_message()
|
||||
if msg.is_empty():
|
||||
return # Not ready yet, continue polling
|
||||
|
||||
# Decode HandshakeMessage: { "protocol_version": N }
|
||||
var decoded: Variant = Messagepack.decode(msg)
|
||||
if decoded.status != null or not (decoded.value is Dictionary) \
|
||||
or not decoded.value.has("protocol_version"):
|
||||
var reason := "Handshake decode failed: malformed HandshakeMessage"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
var server_version: int = decoded.value["protocol_version"]
|
||||
if server_version != Protocol.PROTOCOL_VERSION:
|
||||
var reason := "Protocol version mismatch: server=%d, client=%d" % [
|
||||
server_version, Protocol.PROTOCOL_VERSION]
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
handshake_complete.emit(server_version)
|
||||
_set_state(ConnectionState.CONNECTED)
|
||||
return
|
||||
|
||||
if _bridge == null:
|
||||
return
|
||||
|
||||
@@ -170,9 +271,13 @@ func _process(delta: float) -> void:
|
||||
push_warning("SimBridge: connection lost")
|
||||
_set_state(ConnectionState.DISCONNECTED)
|
||||
|
||||
|
||||
# -- Input / snapshot ----------------------------------------------------------
|
||||
|
||||
# Send input to simulation server.
|
||||
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
|
||||
# In test mode, inputs are silently dropped. In live mode, encoded and buffered for transport.
|
||||
# In test mode, inputs are delegated to the test harness.
|
||||
# In live mode, encoded and buffered for transport.
|
||||
# Returns OK on success, or an error code on failure.
|
||||
func send_input(player_input: Dictionary) -> Error:
|
||||
if state != ConnectionState.CONNECTED:
|
||||
@@ -182,25 +287,20 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
var wire_name: String = action_enum_to_wire(action)
|
||||
if not wire_name.is_empty():
|
||||
if wire_name == "SetFacing":
|
||||
# D-054: Use action_data.facing from the input dict, not InputMapper global
|
||||
var facing: String = ""
|
||||
var action_data: Variant = player_input.get("action_data")
|
||||
if action_data is Dictionary:
|
||||
facing = str(action_data.get("facing", ""))
|
||||
if not facing.is_empty():
|
||||
_test_facing = facing
|
||||
harness.process_facing(facing)
|
||||
else:
|
||||
_test_input_queue.append(wire_name)
|
||||
harness.process_input(wire_name)
|
||||
return OK
|
||||
var action_name := action_enum_to_wire(player_input.get("action", -1))
|
||||
if action_name.is_empty():
|
||||
# action_enum_to_wire already emits push_warning for invalid actions
|
||||
return ERR_INVALID_PARAMETER
|
||||
# Use the server's current tick so drain_for_tick processes this input immediately.
|
||||
# The client-side timestamp_msec is only useful for ordering within a frame.
|
||||
var tick: int = GameState.current_tick
|
||||
var entry: Dictionary = { "tick": tick, "action_name": action_name }
|
||||
# Data variants (e.g. UsePerceptionMode) carry payload
|
||||
var action_data: Variant = player_input.get("action_data")
|
||||
if action_data != null:
|
||||
entry["action_data"] = action_data
|
||||
@@ -208,13 +308,13 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
return OK
|
||||
|
||||
# Poll for snapshot from simulation.
|
||||
# In test mode returns hardcoded data. In live mode, returns the last decoded snapshot (if any).
|
||||
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
|
||||
func poll_snapshot() -> Variant:
|
||||
if state != ConnectionState.CONNECTED:
|
||||
return null
|
||||
|
||||
if test_mode:
|
||||
var snapshot = _test_snapshot()
|
||||
var snapshot = harness.snapshot()
|
||||
snapshot_received.emit(snapshot)
|
||||
return snapshot
|
||||
|
||||
@@ -252,6 +352,9 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
if old_conv_ended.size() > 0:
|
||||
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
|
||||
snapshot["conversation_ended"] = old_conv_ended + new_conv_ended
|
||||
# #554: Carry forward save/load result (one-shot, consumed by main.gd)
|
||||
if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null:
|
||||
snapshot["save_result"] = _last_snapshot["save_result"]
|
||||
_last_snapshot = snapshot
|
||||
|
||||
# Drain the outbound buffer. Returns raw input entries for batch encoding.
|
||||
@@ -260,6 +363,9 @@ func drain_outbound() -> Array[Dictionary]:
|
||||
_outbound_buffer.clear()
|
||||
return inputs
|
||||
|
||||
|
||||
# -- Wire protocol mapping -----------------------------------------------------
|
||||
|
||||
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
|
||||
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
|
||||
static func action_enum_to_wire(action: int) -> String:
|
||||
@@ -286,319 +392,10 @@ static func action_enum_to_wire(action: int) -> String:
|
||||
return "SetFacing" # D-054: facing octant update (no movement)
|
||||
InputMapper.Action.TELEPORT_HUB:
|
||||
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
|
||||
InputMapper.Action.SAVE_GAME:
|
||||
return "SaveGame" # #554: F5 quicksave (D-085)
|
||||
InputMapper.Action.LOAD_GAME:
|
||||
return "LoadGame" # #554: F6 quickload (D-085)
|
||||
_:
|
||||
push_warning("SimBridge: unknown action enum %s" % action)
|
||||
return ""
|
||||
|
||||
# Dynamic test snapshot — processes queued inputs to move player, generates
|
||||
# visibility based on current position. Matches Protocol.decode_snapshot() format.
|
||||
# NOTE: Test coordinate space (player at 10,10; NPC at 12,9; wall at 12,10)
|
||||
# is intentionally decoupled from the E2E proof room (player at 16,16; NPC at
|
||||
# 16,13; wall at 16,14). This ensures standalone tests don't depend on server
|
||||
# map layout and can exercise the rendering pipeline independently.
|
||||
func _test_snapshot() -> Dictionary:
|
||||
_test_tick += 1
|
||||
|
||||
# Process queued inputs
|
||||
for action_name in _test_input_queue:
|
||||
if action_name == "TeleportToHub":
|
||||
# #501: Reset to hub spawn position, clear dialogue
|
||||
_test_player_pos = Vector2i(10, 10)
|
||||
_test_in_dialogue = false
|
||||
continue
|
||||
if action_name == "Interact":
|
||||
# Mock dialogue trigger (#434): if near NPC, start dialogue
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var dist := absi(_test_player_pos.x - npc_pos.x) + absi(_test_player_pos.y - npc_pos.y)
|
||||
if dist <= 2 and _test_has_los(_test_player_pos, npc_pos):
|
||||
_test_in_dialogue = true
|
||||
continue
|
||||
var delta := _action_to_delta(action_name)
|
||||
var new_pos := _test_player_pos + delta
|
||||
if _test_is_walkable(new_pos):
|
||||
_test_player_pos = new_pos
|
||||
if delta != Vector2i.ZERO:
|
||||
# Walk-away dismisses dialogue (D-064)
|
||||
if _test_in_dialogue:
|
||||
_test_in_dialogue = false
|
||||
_test_input_queue.clear()
|
||||
|
||||
var px := _test_player_pos.x
|
||||
var py := _test_player_pos.y
|
||||
|
||||
# Build entities — player always visible
|
||||
var entities: Array = [{
|
||||
"entity_id": 1,
|
||||
"x": float(px),
|
||||
"y": float(py),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Player", "data": null },
|
||||
"visibility": "Forward",
|
||||
}]
|
||||
|
||||
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
|
||||
if npc_dist <= 4 and _test_has_los(Vector2i(px, py), npc_pos):
|
||||
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
|
||||
entities.append({
|
||||
"entity_id": 2,
|
||||
"x": float(npc_pos.x),
|
||||
"y": float(npc_pos.y),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Npc", "data": null },
|
||||
"visibility": sector,
|
||||
"relationship": _test_npc_relationship,
|
||||
})
|
||||
|
||||
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
||||
var nearby: Array = []
|
||||
if npc_dist <= 2 and _test_has_los(Vector2i(px, py), npc_pos):
|
||||
nearby.append({
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": npc_dist,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
|
||||
],
|
||||
})
|
||||
|
||||
# v5: monologue on first tick (#414)
|
||||
var monologue: Variant = null
|
||||
if _test_tick == 1:
|
||||
monologue = {
|
||||
"id": "test_enter_001",
|
||||
"text": "Sova Transit District. Population twelve thousand and change.",
|
||||
"duration_seconds": 5.0,
|
||||
}
|
||||
|
||||
# v7: mock dialogue (#435, D-061/D-062) — triggered by Interact near NPC
|
||||
# Sustained: dialogue persists across ticks while _test_in_dialogue is true.
|
||||
# Movement (walk-away) clears it. Client consume-once guards against re-show.
|
||||
# Options: structured {text, response_id, priority} per #435.
|
||||
var dialogue: Variant = null
|
||||
if _test_in_dialogue:
|
||||
dialogue = {
|
||||
"npc_name": "Kael",
|
||||
"npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||
"options": [
|
||||
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
|
||||
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
|
||||
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
|
||||
],
|
||||
}
|
||||
|
||||
# v7: mock pending_recognitions (#431, D-059/D-060) — cognitive delay fog entity
|
||||
# Entity at (13, 12) in fog: starts as grey blob, transitions to recognized over 6 ticks.
|
||||
# Cycles every 12 ticks: 6 ticks recognizing, 6 ticks off (simulates repeat encounters).
|
||||
var pending_recs: Array = []
|
||||
var cycle_pos := _test_tick % 12
|
||||
if cycle_pos < 6:
|
||||
var total_delay := 6
|
||||
var remaining := total_delay - cycle_pos
|
||||
pending_recs.append({
|
||||
"entity_id": 100,
|
||||
"x": 13.5,
|
||||
"y": 12.5,
|
||||
"z": 0,
|
||||
"remaining_ticks": remaining,
|
||||
"total_delay_ticks": total_delay,
|
||||
})
|
||||
|
||||
# #535: Mock overheard NPC-NPC conversation (D-078)
|
||||
# Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3.
|
||||
# Conversation ends after 6 exchanges (~30 ticks).
|
||||
var conv_events: Array = []
|
||||
var conv_ended: Array = []
|
||||
var conv_start := 3
|
||||
var conv_lines := [
|
||||
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||
]
|
||||
var conv_tick_interval := 5
|
||||
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
||||
if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks:
|
||||
var conv_index := (_test_tick - conv_start) / conv_tick_interval
|
||||
var within_tick := (_test_tick - conv_start) % conv_tick_interval
|
||||
if within_tick == 0 and conv_index < conv_lines.size():
|
||||
var cl: Dictionary = conv_lines[conv_index]
|
||||
conv_events.append({
|
||||
"speaker_id": 10,
|
||||
"target_id": 11,
|
||||
"speaker_name": cl.speaker,
|
||||
"target_name": cl.target,
|
||||
"occluded_line": cl.line,
|
||||
})
|
||||
elif _test_tick == conv_start + conv_total_ticks:
|
||||
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
||||
|
||||
return {
|
||||
"tick": _test_tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
"time_of_day": _test_tick * 10,
|
||||
"day_phase": "Morning",
|
||||
"tick_rate": "Full",
|
||||
},
|
||||
"player_facing": _test_facing,
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"entities": entities,
|
||||
"tiles": _test_tiles(),
|
||||
"visible_tiles": _test_visible_tiles(),
|
||||
"visible_positions": _test_visible_positions(),
|
||||
"nearby_interactions": nearby,
|
||||
"current_monologue": monologue,
|
||||
"current_dialogue": dialogue,
|
||||
"pending_recognitions": pending_recs,
|
||||
"gauntlet_mode": _test_gauntlet_mode,
|
||||
"conversation_events": conv_events,
|
||||
"conversation_ended": conv_ended,
|
||||
}
|
||||
|
||||
# Generate a small test room: 8x6 room with walls, a door, and floor
|
||||
func _test_tiles() -> Array:
|
||||
var tiles: Array = []
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(room_x, room_x + room_w):
|
||||
for y in range(room_y, room_y + room_h):
|
||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||
or y == room_y or y == room_y + room_h - 1)
|
||||
var tile_type: String
|
||||
if is_edge:
|
||||
# Door on the south wall, center
|
||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||
tile_type = "door"
|
||||
else:
|
||||
tile_type = "wall"
|
||||
else:
|
||||
tile_type = "floor"
|
||||
tiles.append({"x": x, "y": y, "z": 0, "type": tile_type})
|
||||
|
||||
# Corridor south of the door
|
||||
var door_x := room_x + room_w / 2
|
||||
for y in range(room_y + room_h, room_y + room_h + 4):
|
||||
tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"})
|
||||
tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"})
|
||||
tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"})
|
||||
|
||||
return tiles
|
||||
|
||||
# Test visible tiles with visibility sectors (v2 format)
|
||||
# Tiles ahead of the player are Forward, others Peripheral.
|
||||
func _test_visible_tiles() -> Array:
|
||||
var vtiles: Array = []
|
||||
var px := _test_player_pos.x
|
||||
var py := _test_player_pos.y
|
||||
var radius := 4
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(px - radius, px + radius + 1):
|
||||
for y in range(py - radius, py + radius + 1):
|
||||
var dist := absf(x - px) + absf(y - py)
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
var sector: String = "Forward" if y <= py else "Peripheral"
|
||||
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
|
||||
return vtiles
|
||||
|
||||
# Test visibility: tiles within radius 4 of player, inside room bounds
|
||||
func _test_visible_positions() -> Array:
|
||||
var positions: Array = []
|
||||
var px := _test_player_pos.x
|
||||
var py := _test_player_pos.y
|
||||
var radius := 4
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(px - radius, px + radius + 1):
|
||||
for y in range(py - radius, py + radius + 1):
|
||||
var dist := absf(x - px) + absf(y - py)
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
positions.append({"x": x, "y": y})
|
||||
return positions
|
||||
|
||||
|
||||
# -- Test mode helpers --
|
||||
|
||||
const _TEST_WALLS: Array = [
|
||||
# Room walls (8x8 room from (7,7) to (14,14))
|
||||
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
|
||||
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
|
||||
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
|
||||
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
|
||||
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
|
||||
Vector2i(7,12), Vector2i(7,13),
|
||||
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
|
||||
Vector2i(14,12), Vector2i(14,13),
|
||||
# Interior wall blocking NPC
|
||||
Vector2i(12, 10),
|
||||
]
|
||||
|
||||
func _test_is_walkable(pos: Vector2i) -> bool:
|
||||
return not _TEST_WALLS.has(pos)
|
||||
|
||||
# Simple LOS check — blocked if a wall tile sits between start and end
|
||||
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
# Bresenham-lite: check tiles along the line
|
||||
var dx := absi(to.x - from.x)
|
||||
var dy := absi(to.y - from.y)
|
||||
var sx := 1 if from.x < to.x else -1
|
||||
var sy := 1 if from.y < to.y else -1
|
||||
var err := dx - dy
|
||||
var cx := from.x
|
||||
var cy := from.y
|
||||
while true:
|
||||
if cx == to.x and cy == to.y:
|
||||
return true
|
||||
if Vector2i(cx, cy) != from and not _test_is_walkable(Vector2i(cx, cy)):
|
||||
return false
|
||||
var e2 := 2 * err
|
||||
if e2 > -dy:
|
||||
err -= dy
|
||||
cx += sx
|
||||
if e2 < dx:
|
||||
err += dx
|
||||
cy += sy
|
||||
return true
|
||||
|
||||
static func _action_to_delta(action_name: String) -> Vector2i:
|
||||
match action_name:
|
||||
"MoveNorth": return Vector2i(0, -1)
|
||||
"MoveNortheast": return Vector2i(1, -1)
|
||||
"MoveEast": return Vector2i(1, 0)
|
||||
"MoveSoutheast": return Vector2i(1, 1)
|
||||
"MoveSouth": return Vector2i(0, 1)
|
||||
"MoveSouthwest": return Vector2i(-1, 1)
|
||||
"MoveWest": return Vector2i(-1, 0)
|
||||
"MoveNorthwest": return Vector2i(-1, -1)
|
||||
_: return Vector2i.ZERO
|
||||
|
||||
static func _delta_to_facing(delta: Vector2i) -> String:
|
||||
match delta:
|
||||
Vector2i(0, -1): return "North"
|
||||
Vector2i(1, -1): return "Northeast"
|
||||
Vector2i(1, 0): return "East"
|
||||
Vector2i(1, 1): return "Southeast"
|
||||
Vector2i(0, 1): return "South"
|
||||
Vector2i(-1, 1): return "Southwest"
|
||||
Vector2i(-1, 0): return "West"
|
||||
Vector2i(-1, -1): return "Northwest"
|
||||
_: return "North"
|
||||
|
||||
@@ -49,44 +49,6 @@ func reload() -> void:
|
||||
|
||||
## Parse YAML with arbitrary nesting depth.
|
||||
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
|
||||
## Delegates to YamlParser.parse_flat() (#560).
|
||||
static func _parse_yaml(text: String) -> Dictionary:
|
||||
var strings := {}
|
||||
var stack: Array = [] # [[indent, key], ...]
|
||||
for line in text.split("\n"):
|
||||
var stripped := line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.begins_with("#"):
|
||||
continue
|
||||
var indent := line.length() - line.lstrip(" ").length()
|
||||
var content := stripped.strip_edges()
|
||||
var colon_pos := content.find(":")
|
||||
if colon_pos < 0:
|
||||
continue
|
||||
var key := content.substr(0, colon_pos).strip_edges()
|
||||
var val := content.substr(colon_pos + 1).strip_edges()
|
||||
# Trailing comment without a value — treat as section header
|
||||
if val.begins_with("#"):
|
||||
val = ""
|
||||
# Pop sections at same or deeper indent
|
||||
while stack.size() > 0 and stack.back()[0] >= indent:
|
||||
stack.pop_back()
|
||||
if val.is_empty():
|
||||
# Section header — push onto stack
|
||||
stack.push_back([indent, key])
|
||||
else:
|
||||
# Leaf value — extract from quotes or strip inline comment
|
||||
if val.begins_with("\""):
|
||||
var end_quote := val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
val = val.substr(1, end_quote - 1)
|
||||
else:
|
||||
val = val.substr(1)
|
||||
else:
|
||||
var comment_pos := val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
var dotted_key := ""
|
||||
for entry in stack:
|
||||
dotted_key += entry[1] + "."
|
||||
dotted_key += key
|
||||
strings[dotted_key] = val
|
||||
return strings
|
||||
return YamlParser.parse_flat(text)
|
||||
|
||||
@@ -233,12 +233,7 @@ func _find_entity(entity_id: int) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
# -- YAML parsing (checklist-specific) -----------------------------------------
|
||||
# Handles the constrained checklist YAML format: top-level key:value pairs,
|
||||
# a conditions array of flat dictionaries. No nested arrays or anchors.
|
||||
#
|
||||
# Limitation: unquoted values containing " #" are truncated at the comment marker.
|
||||
# Use quoted strings ("value # with hash") if values must contain literal hashes.
|
||||
# -- YAML parsing --------------------------------------------------------------
|
||||
|
||||
func _load_checklist_file(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
@@ -252,103 +247,6 @@ func _load_checklist_file(path: String) -> Dictionary:
|
||||
return parse_checklist_yaml(text)
|
||||
|
||||
|
||||
## Delegates to YamlParser.parse() (#560).
|
||||
static func parse_checklist_yaml(text: String) -> Dictionary:
|
||||
var result := {}
|
||||
var conditions: Array = []
|
||||
var current_item: Dictionary = {}
|
||||
var in_conditions := false
|
||||
|
||||
for line in text.split("\n"):
|
||||
var stripped := line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
|
||||
continue
|
||||
|
||||
var indent := line.length() - line.lstrip(" ").length()
|
||||
var content := stripped.strip_edges()
|
||||
|
||||
# Detect conditions: array header
|
||||
if content == "conditions:":
|
||||
in_conditions = true
|
||||
continue
|
||||
|
||||
if not in_conditions:
|
||||
# Top-level key: value
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
else:
|
||||
if content.begins_with("- "):
|
||||
# New array item — flush previous
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var rest := content.substr(2).strip_edges()
|
||||
var colon := rest.find(":")
|
||||
if colon >= 0:
|
||||
var key := rest.substr(0, colon).strip_edges()
|
||||
var val_str := rest.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent >= 2 and not current_item.is_empty():
|
||||
# Continuation of current array item
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent == 0:
|
||||
# Back to top level — shouldn't happen in valid checklist YAML
|
||||
in_conditions = false
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
|
||||
# Flush last item
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
|
||||
if not conditions.is_empty():
|
||||
result["conditions"] = conditions
|
||||
|
||||
return result
|
||||
|
||||
|
||||
static func _parse_value(val: String) -> Variant:
|
||||
if val.is_empty():
|
||||
return ""
|
||||
|
||||
# Strip inline comments (not inside quotes)
|
||||
if not val.begins_with("\""):
|
||||
var comment_pos := val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
|
||||
# Quoted string
|
||||
if val.begins_with("\""):
|
||||
var end_quote := val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
return val.substr(1, end_quote - 1)
|
||||
return val.substr(1)
|
||||
|
||||
# Boolean
|
||||
if val == "true":
|
||||
return true
|
||||
if val == "false":
|
||||
return false
|
||||
|
||||
# Float (contains decimal point)
|
||||
if val.contains(".") and val.is_valid_float():
|
||||
return val.to_float()
|
||||
|
||||
# Integer
|
||||
if val.is_valid_int():
|
||||
return val.to_int()
|
||||
|
||||
# Plain string
|
||||
return val
|
||||
return YamlParser.parse(text)
|
||||
|
||||
@@ -96,6 +96,11 @@ const FACING_INDICATOR_OFFSET: float = 14.0
|
||||
# two columns of text comfortably, leaves world game visible alongside.
|
||||
const DIALOGUE_MAX_WIDTH: int = 1200
|
||||
|
||||
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
|
||||
static func format_game_time(time_of_day: int) -> String:
|
||||
var clamped: int = clampi(time_of_day, 0, 1439)
|
||||
return "%02d:%02d" % [clamped / 60, clamped % 60]
|
||||
|
||||
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
||||
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ extends Node2D
|
||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||
@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
|
||||
@onready var minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049)
|
||||
@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay
|
||||
@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041)
|
||||
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||
@@ -29,6 +33,7 @@ var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (
|
||||
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
|
||||
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
|
||||
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
|
||||
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
|
||||
|
||||
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
|
||||
|
||||
@@ -61,11 +66,51 @@ func _ready() -> void:
|
||||
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
|
||||
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
|
||||
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
|
||||
# D-020 (#558): Decoupled signals — coordinator routes state changes.
|
||||
dialogue_box.dialogue_state_changed.connect(_on_dialogue_state_changed)
|
||||
dialogue_box.audio_dip_requested.connect(_on_audio_dip_requested)
|
||||
dialogue_box.audio_dip_cleared.connect(_on_audio_dip_cleared)
|
||||
|
||||
# #496: Print gauntlet session summary on disconnect
|
||||
if gauntlet_hud:
|
||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
||||
|
||||
# #559: Register snapshot dispatch handlers — replaces inline dispatch in _process().
|
||||
_router = SnapshotEventRouter.new()
|
||||
# Always-run: child nodes that update from GameState on every snapshot tick.
|
||||
if world_renderer:
|
||||
_router.register_always(world_renderer.update_from_state)
|
||||
_router.register_always(_propagate_insert_state)
|
||||
_router.register_always(_update_interaction_list)
|
||||
if inventory_grid:
|
||||
_router.register_always(inventory_grid.update_from_state)
|
||||
if stance_indicator:
|
||||
_router.register_always(stance_indicator.update_from_state)
|
||||
if fog_entities:
|
||||
_router.register_always(fog_entities.update_from_state)
|
||||
_router.register_always(_play_recognition_chimes)
|
||||
if gauntlet_hud:
|
||||
_router.register_always(gauntlet_hud.update_from_state)
|
||||
if checklist_overlay:
|
||||
_router.register_always(checklist_overlay.update_from_state)
|
||||
if time_display:
|
||||
_router.register_always(time_display.update_from_state)
|
||||
if journal_panel:
|
||||
_router.register_always(journal_panel.update_from_state)
|
||||
if debug_overlay:
|
||||
_router.register_always(debug_overlay.update_from_state)
|
||||
_router.register_always(_play_close_sound_events)
|
||||
_router.register_always(_update_zone)
|
||||
_router.register_always(_update_listening_focus)
|
||||
_router.register_always(_consume_examine_result)
|
||||
# Keyed: consume methods guarded by specific snapshot fields.
|
||||
_router.register("current_monologue", _consume_monologue)
|
||||
_router.register("current_dialogue", _consume_dialogue)
|
||||
_router.register("conversation_events", _consume_conversation_events)
|
||||
_router.register("conversation_ended", _consume_conversation_ended)
|
||||
_router.register("dialogue_response", _consume_dialogue_response)
|
||||
_router.register("save_result", _consume_save_result)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Main game loop: poll snapshot, apply state, flush input
|
||||
@@ -85,76 +130,9 @@ func _process(delta: float) -> void:
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
_camera_anchored = true
|
||||
|
||||
# Update renderers with new state
|
||||
if world_renderer and world_renderer.has_method("update_from_state"):
|
||||
world_renderer.update_from_state()
|
||||
|
||||
# OQ-07 (#522): propagate insert state to all z-layer-6 display nodes.
|
||||
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
|
||||
var insert_state := GameState.insert_active
|
||||
if cursor_renderer and cursor_renderer.has_method("set_insert_active"):
|
||||
cursor_renderer.set_insert_active(insert_state)
|
||||
if interaction_list and interaction_list.has_method("set_insert_active"):
|
||||
interaction_list.set_insert_active(insert_state)
|
||||
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
|
||||
# D-057: Update interaction list from game state
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise
|
||||
if interaction_list and interaction_list.has_method("update_from_state"):
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if interaction_list.is_showing():
|
||||
interaction_list._hide()
|
||||
else:
|
||||
interaction_list.update_from_state()
|
||||
|
||||
# D-065: Update inventory grid
|
||||
if inventory_grid and inventory_grid.has_method("update_from_state"):
|
||||
inventory_grid.update_from_state()
|
||||
|
||||
# D-053: Update stance indicator
|
||||
if stance_indicator and stance_indicator.has_method("update_from_state"):
|
||||
stance_indicator.update_from_state()
|
||||
|
||||
# D-059/D-060: Update fog entity visualization (#431)
|
||||
if fog_entities and fog_entities.has_method("update_from_state"):
|
||||
fog_entities.update_from_state()
|
||||
|
||||
# D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition
|
||||
_play_recognition_chimes()
|
||||
|
||||
# #496: Update gauntlet HUD (room timer + personal bests)
|
||||
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
|
||||
gauntlet_hud.update_from_state()
|
||||
|
||||
# #503: Update checklist overlay (auto-checklist progress tracking)
|
||||
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
||||
checklist_overlay.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()
|
||||
|
||||
# D-018 #125: Play close-range sound events via positional 2D audio
|
||||
_play_close_sound_events()
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
|
||||
_update_zone()
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
|
||||
# Only activates when no dialogue/confrontation dip is active (D-070).
|
||||
_update_listening_focus()
|
||||
|
||||
# Show monologue if server sent one this tick (#414)
|
||||
_consume_monologue()
|
||||
|
||||
# D-061: Show dialogue if server sent one this tick (#434)
|
||||
_consume_dialogue()
|
||||
|
||||
# #535: Consume overheard conversation events and responses
|
||||
_consume_conversation_events()
|
||||
_consume_conversation_ended()
|
||||
_consume_dialogue_response()
|
||||
# #559: Dispatch snapshot to registered handlers (router pattern).
|
||||
# Always-run handlers update child nodes; keyed handlers fire for present fields.
|
||||
_router.dispatch(snapshot)
|
||||
|
||||
# Track camera to player (D-015: locked, fixed-north).
|
||||
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
|
||||
@@ -179,6 +157,10 @@ func _process(delta: float) -> void:
|
||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||
bug_report_dialog.start_capture()
|
||||
continue
|
||||
# #264: J — client-only, toggle knowledge journal panel
|
||||
if input.action == InputMapper.Action.OPEN_JOURNAL:
|
||||
_toggle_journal()
|
||||
continue
|
||||
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
||||
if input.action == InputMapper.Action.OPEN_MENU:
|
||||
if settings_dialog:
|
||||
@@ -223,6 +205,32 @@ func _process(delta: float) -> void:
|
||||
_pending_record_inputs.clear()
|
||||
|
||||
|
||||
# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes.
|
||||
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
|
||||
func _propagate_insert_state() -> void:
|
||||
var insert_state := GameState.insert_active
|
||||
if cursor_renderer:
|
||||
cursor_renderer.set_insert_active(insert_state)
|
||||
if interaction_list:
|
||||
interaction_list.set_insert_active(insert_state)
|
||||
if interaction_prompt:
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
if minimap:
|
||||
minimap.set_insert_active(insert_state)
|
||||
|
||||
|
||||
# D-057: Update interaction list from game state.
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise.
|
||||
func _update_interaction_list() -> void:
|
||||
if not interaction_list:
|
||||
return
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if interaction_list.is_showing():
|
||||
interaction_list.hide_list()
|
||||
else:
|
||||
interaction_list.update_from_state()
|
||||
|
||||
|
||||
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
|
||||
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
|
||||
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
|
||||
@@ -313,6 +321,9 @@ func _consume_dialogue() -> void:
|
||||
GameState.current_dialogue = null
|
||||
return
|
||||
_last_dialogue_tick = GameState.current_tick
|
||||
# #264: Close journal when dialogue opens (cannot be open simultaneously)
|
||||
if journal_panel and journal_panel.has_method("close"):
|
||||
journal_panel.close()
|
||||
var dlg: Dictionary = GameState.current_dialogue
|
||||
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
||||
_last_dialogue_npc_name = dlg.get("npc_name", "")
|
||||
@@ -357,6 +368,27 @@ func _consume_dialogue_response() -> void:
|
||||
GameState.dialogue_response = null
|
||||
|
||||
|
||||
# #554: Show save/load result notification from server response.
|
||||
func _consume_save_result() -> void:
|
||||
if GameState.save_result == null:
|
||||
return
|
||||
var result: Dictionary = GameState.save_result
|
||||
GameState.save_result = null # consume once
|
||||
var msg: String
|
||||
if result.get("success", false):
|
||||
if result.get("kind", "") == "save":
|
||||
msg = UIStrings.get_text("notifications.save_complete")
|
||||
else:
|
||||
msg = UIStrings.get_text("notifications.load_complete")
|
||||
else:
|
||||
if result.get("kind", "") == "save":
|
||||
msg = UIStrings.get_text("notifications.save_failed")
|
||||
else:
|
||||
msg = UIStrings.get_text("notifications.load_failed")
|
||||
if monologue_display:
|
||||
monologue_display.show_notification(msg)
|
||||
|
||||
|
||||
# D-061: Handle dialogue option selection → send to server
|
||||
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||
SimBridge.send_input({
|
||||
@@ -408,6 +440,22 @@ func _on_dialogue_dismissed() -> void:
|
||||
})
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator handles dialogue state changes from dialogue_box.
|
||||
# Synchronous signal — GameState.dialogue_active updates same frame (D-064).
|
||||
func _on_dialogue_state_changed(active: bool) -> void:
|
||||
GameState.dialogue_active = active
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator routes audio dip requests from dialogue_box.
|
||||
func _on_audio_dip_requested(profile: String) -> void:
|
||||
AudioManager.apply_dip(profile)
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator routes audio dip clear from dialogue_box.
|
||||
func _on_audio_dip_cleared() -> void:
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# #496: Finalize gauntlet stats on disconnect
|
||||
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||
@@ -451,6 +499,33 @@ func _teleport_transition() -> void:
|
||||
tween.tween_callback(_flash_rect.queue_free)
|
||||
|
||||
|
||||
# #174: Consume examine result — show overlay when server sends character-filtered observation.
|
||||
# Clears after display (single-consume). Dismiss examine when dialogue opens.
|
||||
func _consume_examine_result() -> void:
|
||||
if GameState.current_examine_result == null or not examine_display:
|
||||
return
|
||||
var result: Dictionary = GameState.current_examine_result
|
||||
# Dismiss existing examine result if dialogue is active (focus priority)
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if examine_display.has_method("dismiss"):
|
||||
examine_display.dismiss()
|
||||
else:
|
||||
if examine_display.has_method("show_result"):
|
||||
examine_display.show_result(result)
|
||||
GameState.current_examine_result = null
|
||||
|
||||
|
||||
# #264: Toggle journal panel. Called from input handler when J key pressed.
|
||||
func _toggle_journal() -> void:
|
||||
if not journal_panel:
|
||||
return
|
||||
# Journal and dialogue cannot be open simultaneously (sprint briefing)
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
return
|
||||
if journal_panel.has_method("toggle"):
|
||||
journal_panel.toggle()
|
||||
|
||||
|
||||
# #502: Full-screen color flash — fades from color to transparent over duration.
|
||||
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
||||
func _screen_flash(color: Color, duration: float) -> void:
|
||||
|
||||
@@ -11,7 +11,7 @@ class_name Protocol
|
||||
|
||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||
## Reject snapshots where version != this value.
|
||||
const PROTOCOL_VERSION: int = 13
|
||||
const PROTOCOL_VERSION: int = 15
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
@@ -206,6 +206,99 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"target_id": int(raw_end.get("target_id", 0)),
|
||||
})
|
||||
|
||||
# v14: poi_list (#151) — discovered POIs for minimap rendering.
|
||||
# Each entry: {poi_id, name, x, y, z, poi_category}. Positions in sim tile coords.
|
||||
var poi_list: Array = []
|
||||
var raw_pois: Variant = raw.get("poi_list")
|
||||
if raw_pois is Array:
|
||||
for raw_poi in raw_pois:
|
||||
if raw_poi is Dictionary and raw_poi.has("poi_id") and raw_poi.has("x") and raw_poi.has("y"):
|
||||
poi_list.append({
|
||||
"poi_id": str(raw_poi["poi_id"]),
|
||||
"name": str(raw_poi.get("name", "")),
|
||||
"x": int(raw_poi["x"]),
|
||||
"y": int(raw_poi["y"]),
|
||||
"z": int(raw_poi.get("z", 0)),
|
||||
"poi_category": str(raw_poi.get("poi_category", raw_poi.get("category", "Location"))),
|
||||
})
|
||||
|
||||
# v14: examine_result (#174, #242) — character-filtered observation text.
|
||||
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
|
||||
var examine_result: Variant = null
|
||||
var raw_examine: Variant = raw.get("examine_result")
|
||||
if raw_examine is Dictionary and raw_examine.has("text"):
|
||||
examine_result = {
|
||||
"entity_id": int(raw_examine.get("entity_id", 0)),
|
||||
"text": str(raw_examine["text"]),
|
||||
"confidence": str(raw_examine.get("confidence", "KnowsOf")),
|
||||
}
|
||||
|
||||
# v15: save_result (#554, D-085) — one-shot save/load operation result.
|
||||
# {success: bool, kind: "save"|"load", error: String|null}
|
||||
var save_result: Variant = null
|
||||
var raw_save: Variant = raw.get("save_result")
|
||||
if raw_save is Dictionary:
|
||||
save_result = {
|
||||
"success": bool(raw_save.get("success", false)),
|
||||
"kind": str(raw_save.get("kind", "")),
|
||||
"error": raw_save.get("error"),
|
||||
}
|
||||
|
||||
# TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020).
|
||||
# Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs).
|
||||
# When server populates this field, client-side accumulation fallback in game_state.gd
|
||||
# can be removed — apply_snapshot() should contain only direct field assignments.
|
||||
var stationary_ticks: Variant = null
|
||||
var raw_st: Variant = raw.get("stationary_ticks")
|
||||
if raw_st != null:
|
||||
stationary_ticks = int(raw_st)
|
||||
|
||||
# TODO(server): Send top-level zone_id string in ObserverSnapshot (D-073, D-020).
|
||||
# Server sends zone_id per VisibleTile but not as a top-level snapshot field.
|
||||
# When server populates this, client-side tile iteration fallback in game_state.gd
|
||||
# can be removed — apply_snapshot() should contain only direct field assignments.
|
||||
var zone_id: Variant = null
|
||||
var raw_zid: Variant = raw.get("zone_id")
|
||||
if raw_zid is String:
|
||||
zone_id = raw_zid
|
||||
|
||||
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
|
||||
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
|
||||
var player_knowledge: Variant = null
|
||||
var raw_pk: Variant = raw.get("player_knowledge")
|
||||
if raw_pk is Dictionary:
|
||||
var kg_entities: Array = []
|
||||
var raw_kg_entities: Variant = raw_pk.get("entities")
|
||||
if raw_kg_entities is Array:
|
||||
for raw_ke in raw_kg_entities:
|
||||
if raw_ke is Dictionary and raw_ke.has("entity_id"):
|
||||
kg_entities.append({
|
||||
"entity_id": int(raw_ke["entity_id"]),
|
||||
"name": str(raw_ke.get("name", "Unknown")),
|
||||
"confidence": str(raw_ke.get("confidence", "Suspects")),
|
||||
"source": str(raw_ke.get("source", "")),
|
||||
"state": str(raw_ke.get("state", "Active")),
|
||||
"relationship": str(raw_ke.get("relationship", "Unknown")),
|
||||
"last_observed_tick": int(raw_ke.get("last_observed_tick", 0)),
|
||||
})
|
||||
var kg_facts: Array = []
|
||||
var raw_kg_facts: Variant = raw_pk.get("facts")
|
||||
if raw_kg_facts is Array:
|
||||
for raw_kf in raw_kg_facts:
|
||||
if raw_kf is Dictionary and raw_kf.has("fact_id"):
|
||||
kg_facts.append({
|
||||
"fact_id": str(raw_kf["fact_id"]),
|
||||
"confidence": str(raw_kf.get("confidence", "Suspects")),
|
||||
"source": str(raw_kf.get("source", "")),
|
||||
"state": str(raw_kf.get("state", "Active")),
|
||||
"acquired_tick": int(raw_kf.get("acquired_tick", 0)),
|
||||
})
|
||||
player_knowledge = {
|
||||
"entities": kg_entities,
|
||||
"facts": kg_facts,
|
||||
}
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"entities": entities,
|
||||
@@ -223,6 +316,12 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"pending_recognitions": pending_recognitions,
|
||||
"conversation_events": conversation_events,
|
||||
"conversation_ended": conversation_ended,
|
||||
"poi_list": poi_list,
|
||||
"examine_result": examine_result,
|
||||
"player_knowledge": player_knowledge,
|
||||
"save_result": save_result,
|
||||
"stationary_ticks": stationary_ticks,
|
||||
"zone_id": zone_id,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
class_name TestHarness
|
||||
extends RefCounted
|
||||
## Standalone test simulation for client development without a running server.
|
||||
## Generates mock ObserverSnapshots with movement, LOS, dialogue, and NPC
|
||||
## interactions. Extracted from sim_bridge.gd to enforce D-020 information
|
||||
## boundary (no game logic in the production client autoload).
|
||||
|
||||
var tick: int = 0
|
||||
var player_pos: Vector2i = Vector2i(10, 10)
|
||||
var facing: String = "North"
|
||||
var input_queue: Array = []
|
||||
var in_dialogue: bool = false
|
||||
var gauntlet_mode: bool = false
|
||||
var npc_relationship: String = "Unknown"
|
||||
|
||||
|
||||
func reset() -> void:
|
||||
tick = 0
|
||||
player_pos = Vector2i(10, 10)
|
||||
facing = "North"
|
||||
input_queue.clear()
|
||||
in_dialogue = false
|
||||
gauntlet_mode = false
|
||||
npc_relationship = "Unknown"
|
||||
|
||||
|
||||
func process_input(action_name: String) -> void:
|
||||
input_queue.append(action_name)
|
||||
|
||||
|
||||
func process_facing(new_facing: String) -> void:
|
||||
facing = new_facing
|
||||
|
||||
|
||||
# -- Snapshot generation -------------------------------------------------------
|
||||
|
||||
func snapshot() -> Dictionary:
|
||||
tick += 1
|
||||
|
||||
# Process queued inputs
|
||||
for action_name in input_queue:
|
||||
if action_name == "TeleportToHub":
|
||||
player_pos = Vector2i(10, 10)
|
||||
in_dialogue = false
|
||||
continue
|
||||
if action_name == "Interact":
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var dist := absi(player_pos.x - npc_pos.x) + absi(player_pos.y - npc_pos.y)
|
||||
if dist <= 2 and has_los(player_pos, npc_pos):
|
||||
in_dialogue = true
|
||||
continue
|
||||
var delta := action_to_delta(action_name)
|
||||
var new_pos := player_pos + delta
|
||||
if _is_walkable(new_pos):
|
||||
player_pos = new_pos
|
||||
if delta != Vector2i.ZERO:
|
||||
if in_dialogue:
|
||||
in_dialogue = false
|
||||
input_queue.clear()
|
||||
|
||||
var px := player_pos.x
|
||||
var py := player_pos.y
|
||||
|
||||
# Build entities — player always visible
|
||||
var entities: Array = [{
|
||||
"entity_id": 1,
|
||||
"x": float(px),
|
||||
"y": float(py),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Player", "data": null },
|
||||
"visibility": "Forward",
|
||||
}]
|
||||
|
||||
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
|
||||
if npc_dist <= 4 and has_los(Vector2i(px, py), npc_pos):
|
||||
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
|
||||
entities.append({
|
||||
"entity_id": 2,
|
||||
"x": float(npc_pos.x),
|
||||
"y": float(npc_pos.y),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Npc", "data": null },
|
||||
"visibility": sector,
|
||||
"relationship": npc_relationship,
|
||||
})
|
||||
|
||||
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
||||
var nearby: Array = []
|
||||
if npc_dist <= 2 and has_los(Vector2i(px, py), npc_pos):
|
||||
nearby.append({
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": npc_dist,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
|
||||
],
|
||||
})
|
||||
|
||||
# v5: monologue on first tick (#414)
|
||||
var monologue: Variant = null
|
||||
if tick == 1:
|
||||
monologue = {
|
||||
"id": "test_enter_001",
|
||||
"text": "Sova Transit District. Population twelve thousand and change.",
|
||||
"duration_seconds": 5.0,
|
||||
}
|
||||
|
||||
# v7: mock dialogue (#435, D-061/D-062)
|
||||
var dialogue: Variant = null
|
||||
if in_dialogue:
|
||||
dialogue = {
|
||||
"npc_name": "Kael",
|
||||
"npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||
"options": [
|
||||
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
|
||||
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
|
||||
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
|
||||
],
|
||||
}
|
||||
|
||||
# v7: mock pending_recognitions (#431, D-059/D-060)
|
||||
var pending_recs: Array = []
|
||||
var cycle_pos := tick % 12
|
||||
if cycle_pos < 6:
|
||||
var total_delay := 6
|
||||
var remaining := total_delay - cycle_pos
|
||||
pending_recs.append({
|
||||
"entity_id": 100,
|
||||
"x": 13.5,
|
||||
"y": 12.5,
|
||||
"z": 0,
|
||||
"remaining_ticks": remaining,
|
||||
"total_delay_ticks": total_delay,
|
||||
})
|
||||
|
||||
# #535: Mock overheard NPC-NPC conversation (D-078)
|
||||
var conv_events: Array = []
|
||||
var conv_ended: Array = []
|
||||
var conv_start := 3
|
||||
var conv_lines := [
|
||||
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||
]
|
||||
var conv_tick_interval := 5
|
||||
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
||||
if tick >= conv_start and tick < conv_start + conv_total_ticks:
|
||||
var conv_index := (tick - conv_start) / conv_tick_interval
|
||||
var within_tick := (tick - conv_start) % conv_tick_interval
|
||||
if within_tick == 0 and conv_index < conv_lines.size():
|
||||
var cl: Dictionary = conv_lines[conv_index]
|
||||
conv_events.append({
|
||||
"speaker_id": 10,
|
||||
"target_id": 11,
|
||||
"speaker_name": cl.speaker,
|
||||
"target_name": cl.target,
|
||||
"occluded_line": cl.line,
|
||||
})
|
||||
elif tick == conv_start + conv_total_ticks:
|
||||
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
"time_of_day": tick * 10,
|
||||
"day_phase": "Morning",
|
||||
"tick_rate": "Full",
|
||||
},
|
||||
"player_facing": facing,
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"entities": entities,
|
||||
"tiles": _tiles(),
|
||||
"visible_tiles": _visible_tiles(),
|
||||
"visible_positions": _visible_positions(),
|
||||
"nearby_interactions": nearby,
|
||||
"current_monologue": monologue,
|
||||
"current_dialogue": dialogue,
|
||||
"pending_recognitions": pending_recs,
|
||||
"gauntlet_mode": gauntlet_mode,
|
||||
"conversation_events": conv_events,
|
||||
"conversation_ended": conv_ended,
|
||||
"save_result": null,
|
||||
}
|
||||
|
||||
|
||||
# -- Map generation ------------------------------------------------------------
|
||||
|
||||
func _tiles() -> Array:
|
||||
var tiles: Array = []
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(room_x, room_x + room_w):
|
||||
for y in range(room_y, room_y + room_h):
|
||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||
or y == room_y or y == room_y + room_h - 1)
|
||||
var tile_type: String
|
||||
if is_edge:
|
||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||
tile_type = "door"
|
||||
else:
|
||||
tile_type = "wall"
|
||||
else:
|
||||
tile_type = "floor"
|
||||
tiles.append({"x": x, "y": y, "z": 0, "type": tile_type})
|
||||
|
||||
var door_x := room_x + room_w / 2
|
||||
for y in range(room_y + room_h, room_y + room_h + 4):
|
||||
tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"})
|
||||
tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"})
|
||||
tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"})
|
||||
|
||||
return tiles
|
||||
|
||||
|
||||
func _visible_tiles() -> Array:
|
||||
var vtiles: Array = []
|
||||
var px := player_pos.x
|
||||
var py := player_pos.y
|
||||
var radius := 4
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(px - radius, px + radius + 1):
|
||||
for y in range(py - radius, py + radius + 1):
|
||||
var dist := absf(x - px) + absf(y - py)
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
var sector: String = "Forward" if y <= py else "Peripheral"
|
||||
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
|
||||
return vtiles
|
||||
|
||||
|
||||
func _visible_positions() -> Array:
|
||||
var positions: Array = []
|
||||
var px := player_pos.x
|
||||
var py := player_pos.y
|
||||
var radius := 4
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(px - radius, px + radius + 1):
|
||||
for y in range(py - radius, py + radius + 1):
|
||||
var dist := absf(x - px) + absf(y - py)
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
positions.append({"x": x, "y": y})
|
||||
return positions
|
||||
|
||||
|
||||
# -- Spatial helpers -----------------------------------------------------------
|
||||
|
||||
const _WALLS: Array = [
|
||||
# Room walls (8x8 room from (7,7) to (14,14))
|
||||
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
|
||||
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
|
||||
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
|
||||
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
|
||||
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
|
||||
Vector2i(7,12), Vector2i(7,13),
|
||||
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
|
||||
Vector2i(14,12), Vector2i(14,13),
|
||||
# Interior wall blocking NPC
|
||||
Vector2i(12, 10),
|
||||
]
|
||||
|
||||
|
||||
func _is_walkable(pos: Vector2i) -> bool:
|
||||
return not _WALLS.has(pos)
|
||||
|
||||
|
||||
func has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
var dx := absi(to.x - from.x)
|
||||
var dy := absi(to.y - from.y)
|
||||
var sx := 1 if from.x < to.x else -1
|
||||
var sy := 1 if from.y < to.y else -1
|
||||
var err := dx - dy
|
||||
var cx := from.x
|
||||
var cy := from.y
|
||||
while true:
|
||||
if cx == to.x and cy == to.y:
|
||||
return true
|
||||
if Vector2i(cx, cy) != from and not _is_walkable(Vector2i(cx, cy)):
|
||||
return false
|
||||
var e2 := 2 * err
|
||||
if e2 > -dy:
|
||||
err -= dy
|
||||
cx += sx
|
||||
if e2 < dx:
|
||||
err += dx
|
||||
cy += sy
|
||||
return true
|
||||
|
||||
|
||||
static func action_to_delta(action_name: String) -> Vector2i:
|
||||
match action_name:
|
||||
"MoveNorth": return Vector2i(0, -1)
|
||||
"MoveNortheast": return Vector2i(1, -1)
|
||||
"MoveEast": return Vector2i(1, 0)
|
||||
"MoveSoutheast": return Vector2i(1, 1)
|
||||
"MoveSouth": return Vector2i(0, 1)
|
||||
"MoveSouthwest": return Vector2i(-1, 1)
|
||||
"MoveWest": return Vector2i(-1, 0)
|
||||
"MoveNorthwest": return Vector2i(-1, -1)
|
||||
_: return Vector2i.ZERO
|
||||
|
||||
|
||||
static func delta_to_facing(delta: Vector2i) -> String:
|
||||
match delta:
|
||||
Vector2i(0, -1): return "North"
|
||||
Vector2i(1, -1): return "Northeast"
|
||||
Vector2i(1, 0): return "East"
|
||||
Vector2i(1, 1): return "Southeast"
|
||||
Vector2i(0, 1): return "South"
|
||||
Vector2i(-1, 1): return "Southwest"
|
||||
Vector2i(-1, 0): return "West"
|
||||
Vector2i(-1, -1): return "Northwest"
|
||||
_: return "North"
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
class_name SnapshotEventRouter
|
||||
## Routes snapshot fields to registered handlers (D-020, #559).
|
||||
##
|
||||
## Decouples main.gd from knowing which child node handles which snapshot field.
|
||||
## Handlers are registered in main.gd._ready(); dispatch() is called each snapshot tick.
|
||||
##
|
||||
## Two handler types:
|
||||
## - Keyed: called only when the snapshot contains a specific field.
|
||||
## - Always: called every dispatch (every snapshot tick), regardless of fields present.
|
||||
##
|
||||
## All handlers are zero-argument callables — they read from GameState directly.
|
||||
## This preserves GameState as the single source of truth post-apply_snapshot().
|
||||
|
||||
## Keyed handlers: field_name → Array[Callable]
|
||||
## Array per field allows multiple handlers on the same key (e.g., two consumers of same data).
|
||||
var _keyed: Dictionary = {} # String → Array[Callable]
|
||||
|
||||
## Always handlers: called every dispatch regardless of snapshot content.
|
||||
var _always: Array[Callable] = []
|
||||
|
||||
|
||||
## Register a handler for a specific snapshot field key.
|
||||
## Handler is called (with no arguments) when snapshot.has(field) is true.
|
||||
## Multiple handlers per field are supported — they run in registration order.
|
||||
func register(field: String, handler: Callable) -> void:
|
||||
if not _keyed.has(field):
|
||||
_keyed[field] = []
|
||||
_keyed[field].append(handler)
|
||||
|
||||
|
||||
## Register a handler that runs every dispatch tick (not keyed to a field).
|
||||
## Use for child nodes that update from GameState on every snapshot, e.g. update_from_state().
|
||||
func register_always(handler: Callable) -> void:
|
||||
_always.append(handler)
|
||||
|
||||
|
||||
## Dispatch a snapshot: call always handlers first, then keyed handlers for present fields.
|
||||
## Handlers read from GameState.* directly — apply_snapshot() must be called before dispatch().
|
||||
func dispatch(snapshot: Dictionary) -> void:
|
||||
for handler in _always:
|
||||
handler.call()
|
||||
for field in _keyed:
|
||||
if snapshot.has(field):
|
||||
for handler in _keyed[field]:
|
||||
handler.call()
|
||||
@@ -1,5 +1,15 @@
|
||||
extends Control
|
||||
# #511: F3 debug overlay — real-time game state display for dev use.
|
||||
## #348: F3 debug overlay — real-time visualization of game state for dev use.
|
||||
## Dev-only: disabled entirely in export builds (OS.is_debug_build() = false).
|
||||
##
|
||||
## Panels:
|
||||
## 1. Stats text (top-left): tick, pos, fps, etc.
|
||||
## 2. World overlays (over game): LOS rays, vision cone, NPC paths, info tags
|
||||
## 3. Tick timing graph (bottom-left): last-30-tick delta sparkline
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const HEADER_COLOR := Color("#e8c547")
|
||||
const LABEL_COLOR := Color("#8890a0")
|
||||
@@ -8,31 +18,150 @@ const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
|
||||
const FONT_SIZE := 12
|
||||
const LINE_HEIGHT := 16
|
||||
const PADDING := Vector2(10, 8)
|
||||
const COL_GAP := 16 # gap between left and right columns
|
||||
const COL_GAP := 16
|
||||
|
||||
# World overlay colors
|
||||
const LOS_COLOR := Color(0.27, 0.78, 0.65, 0.50)
|
||||
const PLAYER_DOT_COLOR := Color(0.88, 0.77, 0.28, 0.85)
|
||||
const CONE_FORWARD_COLOR := Color(0.27, 0.78, 0.65, 0.12)
|
||||
const CONE_PERIPHERAL_COLOR := Color(0.20, 0.55, 0.80, 0.07)
|
||||
const CONE_RING_COLOR := Color(0.27, 0.78, 0.65, 0.55)
|
||||
const NPC_PATH_COLOR := Color(0.83, 0.48, 0.35, 0.75)
|
||||
const NPC_DOT_COLOR := Color(0.83, 0.48, 0.35, 0.90)
|
||||
const TAG_BG_COLOR := Color(0.05, 0.05, 0.10, 0.80)
|
||||
const TAG_TEXT_COLOR := Color("#c8d0e0")
|
||||
const GRAPH_BG_COLOR := Color(0.06, 0.06, 0.10, 0.82)
|
||||
const GRAPH_LINE_COLOR := Color("#6bc9a6")
|
||||
const GRAPH_WARN_COLOR := Color("#e8c547")
|
||||
|
||||
# Vision cone geometry (radians)
|
||||
# Forward: ±60° around facing direction (120° total)
|
||||
# Peripheral: ±60° to ±120° on each side (60° band each side)
|
||||
const CONE_FORWARD_HALF: float = PI / 3.0 # 60°
|
||||
const CONE_PERIPHERAL_HALF: float = PI * 2.0 / 3.0 # 120°
|
||||
const CONE_ARC_STEPS: int = 20
|
||||
|
||||
# NPC path history
|
||||
const NPC_HISTORY_LEN: int = 12
|
||||
const NPC_DOT_RADIUS: float = 3.5
|
||||
const PLAYER_DOT_RADIUS: float = 5.0
|
||||
|
||||
# Tick timing graph
|
||||
const GRAPH_W: float = 160.0
|
||||
const GRAPH_H: float = 48.0
|
||||
const GRAPH_MARGIN: float = 10.0
|
||||
const TICK_HISTORY_LEN: int = 30
|
||||
const TICK_WARN_MS: float = 120.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _cached_font: Font = null
|
||||
var _dev_mode: bool = false
|
||||
|
||||
# NPC path history: entity_id (int) → Array of Vector2 (world positions)
|
||||
var _npc_paths: Dictionary = {}
|
||||
var _last_tick_processed: int = -1
|
||||
|
||||
# Tick timing ring
|
||||
var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot
|
||||
var _tick_deltas: Array = [] # ms between consecutive snapshots
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
_dev_mode = OS.is_debug_build()
|
||||
visible = false
|
||||
_cached_font = ThemeDB.fallback_font
|
||||
# Clear NPC path history on session change to prevent entity ID collisions
|
||||
GameState.connect("game_id_changed", _on_game_id_changed)
|
||||
|
||||
|
||||
func _on_game_id_changed(_new_id: String) -> void:
|
||||
_npc_paths.clear()
|
||||
_tick_deltas.clear()
|
||||
_tick_times.clear()
|
||||
_last_tick_processed = -1
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _dev_mode:
|
||||
return
|
||||
if event.is_action_pressed("debug_overlay"):
|
||||
visible = not visible
|
||||
if visible:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
if not visible:
|
||||
if not _dev_mode or not visible:
|
||||
return
|
||||
|
||||
# Record tick arrival time for timing graph
|
||||
var now_ms := Time.get_ticks_msec()
|
||||
if _last_tick_processed != GameState.current_tick:
|
||||
_last_tick_processed = GameState.current_tick
|
||||
if _tick_times.size() > 0:
|
||||
_tick_deltas.append(float(now_ms - _tick_times.back()))
|
||||
if _tick_deltas.size() > TICK_HISTORY_LEN:
|
||||
_tick_deltas.pop_front()
|
||||
_tick_times.append(now_ms)
|
||||
if _tick_times.size() > TICK_HISTORY_LEN + 1:
|
||||
_tick_times.pop_front()
|
||||
_update_npc_paths()
|
||||
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _update_npc_paths() -> void:
|
||||
var seen_ids: Dictionary = {}
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
var kind_variant: String = entity.get("kind", {}).get("variant", "")
|
||||
if kind_variant != "Npc":
|
||||
continue
|
||||
var eid: int = entity.get("entity_id", -1)
|
||||
if eid < 0:
|
||||
continue
|
||||
seen_ids[eid] = true
|
||||
var pos := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
|
||||
if not _npc_paths.has(eid):
|
||||
_npc_paths[eid] = []
|
||||
var path: Array = _npc_paths[eid]
|
||||
if path.size() == 0 or path.back() != pos:
|
||||
path.append(pos)
|
||||
if path.size() > NPC_HISTORY_LEN:
|
||||
path.pop_front()
|
||||
# Prune entities no longer visible
|
||||
for eid in _npc_paths.keys():
|
||||
if not seen_ids.has(eid):
|
||||
_npc_paths.erase(eid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Draw dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw() -> void:
|
||||
if not visible:
|
||||
return
|
||||
_draw_stats_panel()
|
||||
_draw_world_overlays()
|
||||
_draw_tick_graph()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 1: Stats text (top-left)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw_stats_panel() -> void:
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
|
||||
# Build lines as [label, value, label, value] pairs (two columns)
|
||||
var left_lines: Array = []
|
||||
var right_lines: Array = []
|
||||
|
||||
@@ -71,7 +200,10 @@ func _draw() -> void:
|
||||
left_lines.append(["mode", mode_str])
|
||||
right_lines.append(["gauntlet", gauntlet_str])
|
||||
|
||||
# Measure column widths
|
||||
var gid := GameState.current_game_id
|
||||
left_lines.append(["game_id", gid if gid != "" else "-"])
|
||||
right_lines.append(["npc_paths", str(_npc_paths.size())])
|
||||
|
||||
var left_label_w: float = 0.0
|
||||
var left_value_w: float = 0.0
|
||||
var right_label_w: float = 0.0
|
||||
@@ -89,27 +221,240 @@ func _draw() -> void:
|
||||
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
||||
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||
var line_count: int = maxi(left_lines.size(), right_lines.size())
|
||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
|
||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
|
||||
|
||||
# Header
|
||||
var y: float = PADDING.y + FONT_SIZE
|
||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
# Data lines (two columns)
|
||||
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
||||
for i in range(line_count):
|
||||
if i < left_lines.size():
|
||||
var lbl: String = left_lines[i][0] + ": "
|
||||
var val: String = left_lines[i][1]
|
||||
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
if i < right_lines.size():
|
||||
var lbl: String = right_lines[i][0] + ": "
|
||||
var val: String = right_lines[i][1]
|
||||
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 2: World overlays
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw_world_overlays() -> void:
|
||||
var vp := get_viewport()
|
||||
if vp == null:
|
||||
return
|
||||
# get_canvas_transform() applies Camera2D — valid for CanvasLayer 0 content.
|
||||
# The DebugOverlay is on UILayer (layer 20) so its own draw space IS screen space.
|
||||
# Using this transform converts world coords → screen pixel coords for the overlays.
|
||||
var canvas_xf := vp.get_canvas_transform()
|
||||
var player_screen := _w2s(GameState.player_position, canvas_xf)
|
||||
|
||||
_draw_vision_cone(player_screen, canvas_xf)
|
||||
_draw_los_rays(player_screen, canvas_xf)
|
||||
_draw_npc_paths(canvas_xf)
|
||||
_draw_info_tags(canvas_xf)
|
||||
|
||||
|
||||
# Convert world tile position → screen pixel position
|
||||
func _w2s(world_pos: Vector2, canvas_xf: Transform2D) -> Vector2:
|
||||
return canvas_xf * (world_pos * Constants.TILE_SIZE)
|
||||
|
||||
|
||||
# Vision cone: filled forward sector + peripheral bands.
|
||||
# Uses player facing direction and visibility sector distance.
|
||||
func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||
var facing_angle := _facing_to_angle(GameState.player_facing)
|
||||
|
||||
# Estimate visible radius from furthest visibility sector tile
|
||||
var max_d: float = 4.0
|
||||
for vpos in GameState.visibility_sectors.keys():
|
||||
var d := Vector2(vpos.x, vpos.y).distance_to(GameState.player_position)
|
||||
if d > max_d:
|
||||
max_d = d
|
||||
var scale_x := canvas_xf.x.length()
|
||||
var r: float = clampf(max_d * Constants.TILE_SIZE * scale_x, 40.0, 280.0)
|
||||
|
||||
# Helper: build a polygon fan from center outward over arc [angle_from, angle_to]
|
||||
var forward_from := facing_angle - CONE_FORWARD_HALF
|
||||
var forward_to := facing_angle + CONE_FORWARD_HALF
|
||||
var perip_l_from := facing_angle - CONE_PERIPHERAL_HALF
|
||||
var perip_l_to := facing_angle - CONE_FORWARD_HALF
|
||||
var perip_r_from := facing_angle + CONE_FORWARD_HALF
|
||||
var perip_r_to := facing_angle + CONE_PERIPHERAL_HALF
|
||||
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR)
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR)
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR)
|
||||
|
||||
# Forward arc boundary ring
|
||||
draw_arc(player_screen, r, forward_from, forward_to, CONE_ARC_STEPS, CONE_RING_COLOR, 1.0)
|
||||
|
||||
# Player dot
|
||||
draw_circle(player_screen, PLAYER_DOT_RADIUS, PLAYER_DOT_COLOR)
|
||||
|
||||
|
||||
# Build a filled polygon fan from center through an arc
|
||||
func _arc_polygon(center: Vector2, radius: float, angle_from: float, angle_to: float) -> PackedVector2Array:
|
||||
var pts := PackedVector2Array()
|
||||
pts.append(center)
|
||||
for i in range(CONE_ARC_STEPS + 1):
|
||||
var t := float(i) / float(CONE_ARC_STEPS)
|
||||
var a := angle_from + t * (angle_to - angle_from)
|
||||
pts.append(center + Vector2(cos(a), sin(a)) * radius)
|
||||
return pts
|
||||
|
||||
|
||||
# Dashed LOS lines from player to each visible non-player entity
|
||||
func _draw_los_rays(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
if entity.get("kind", {}).get("variant", "") == "Player":
|
||||
continue
|
||||
var entity_world := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
|
||||
var entity_screen := _w2s(entity_world, canvas_xf)
|
||||
var rel: String = entity.get("relationship", "Unknown")
|
||||
var color := Constants.color_for_relationship(rel)
|
||||
color.a = 0.45
|
||||
draw_dashed_line(player_screen, entity_screen, color, 1.0, 6.0)
|
||||
draw_circle(entity_screen, NPC_DOT_RADIUS, Color(color.r, color.g, color.b, 0.7))
|
||||
|
||||
|
||||
# Fading NPC movement path trails from position history
|
||||
func _draw_npc_paths(canvas_xf: Transform2D) -> void:
|
||||
for eid in _npc_paths.keys():
|
||||
var path: Array = _npc_paths[eid]
|
||||
if path.size() < 2:
|
||||
continue
|
||||
for i in range(1, path.size()):
|
||||
var a_screen := _w2s(path[i - 1], canvas_xf)
|
||||
var b_screen := _w2s(path[i], canvas_xf)
|
||||
var alpha := float(i) / float(path.size())
|
||||
draw_line(a_screen, b_screen, Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5)
|
||||
draw_circle(_w2s(path.back(), canvas_xf), NPC_DOT_RADIUS, NPC_DOT_COLOR)
|
||||
|
||||
|
||||
# Information state tags above visible NPCs from player_knowledge
|
||||
func _draw_info_tags(canvas_xf: Transform2D) -> void:
|
||||
if GameState.player_knowledge == null:
|
||||
return
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
var knowledge: Dictionary = GameState.player_knowledge
|
||||
|
||||
# Build entity_id → knowledge entry lookup
|
||||
var kg_by_id: Dictionary = {}
|
||||
for entry in knowledge.get("entities", []):
|
||||
if entry is Dictionary and entry.has("entity_id"):
|
||||
kg_by_id[entry.entity_id] = entry
|
||||
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
if entity.get("kind", {}).get("variant", "") != "Npc":
|
||||
continue
|
||||
var eid: int = entity.get("entity_id", -1)
|
||||
if not kg_by_id.has(eid):
|
||||
continue
|
||||
var kg_entry: Dictionary = kg_by_id[eid]
|
||||
var confidence: String = kg_entry.get("confidence", "Unknown")
|
||||
var name_str: String = kg_entry.get("name", "?")
|
||||
var label := "%s [%s]" % [name_str, confidence]
|
||||
|
||||
var entity_screen := _w2s(Vector2(entity.get("x", 0.0), entity.get("y", 0.0)), canvas_xf)
|
||||
var tag_baseline := entity_screen.y - 18.0
|
||||
var text_w := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1).x
|
||||
var tag_rect := Rect2(
|
||||
entity_screen.x - text_w / 2.0 - 3.0,
|
||||
tag_baseline - FONT_SIZE + 2.0,
|
||||
text_w + 6.0,
|
||||
FONT_SIZE)
|
||||
draw_rect(tag_rect, TAG_BG_COLOR)
|
||||
draw_string(font,
|
||||
Vector2(entity_screen.x - text_w / 2.0, tag_baseline),
|
||||
label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, TAG_TEXT_COLOR)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 3: Tick timing sparkline (bottom-left)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw_tick_graph() -> void:
|
||||
if _tick_deltas.size() < 2:
|
||||
return
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
var vp_size := get_viewport_rect().size
|
||||
var box_x := GRAPH_MARGIN
|
||||
var label_h := LINE_HEIGHT
|
||||
var box_y := vp_size.y - GRAPH_H - label_h - GRAPH_MARGIN
|
||||
|
||||
draw_rect(Rect2(box_x, box_y, GRAPH_W, GRAPH_H + label_h), GRAPH_BG_COLOR)
|
||||
draw_string(font,
|
||||
Vector2(box_x + 4, box_y + FONT_SIZE + 1),
|
||||
"tick ms (n=%d)" % _tick_deltas.size(),
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||
|
||||
var chart_top := box_y + label_h
|
||||
var chart_left := box_x + 4.0
|
||||
var chart_w := GRAPH_W - 8.0
|
||||
var chart_h := GRAPH_H - 4.0
|
||||
|
||||
# Max value for scale
|
||||
var max_ms: float = TICK_WARN_MS
|
||||
for d in _tick_deltas:
|
||||
if float(d) > max_ms:
|
||||
max_ms = float(d)
|
||||
max_ms *= 1.1
|
||||
|
||||
# Warn threshold dashed line
|
||||
var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms)
|
||||
draw_dashed_line(
|
||||
Vector2(chart_left, warn_y), Vector2(chart_left + chart_w, warn_y),
|
||||
Color(GRAPH_WARN_COLOR.r, GRAPH_WARN_COLOR.g, GRAPH_WARN_COLOR.b, 0.3),
|
||||
1.0, 4.0)
|
||||
|
||||
# Sparkline
|
||||
var n := _tick_deltas.size()
|
||||
var prev_pt := Vector2.ZERO
|
||||
for i in range(n):
|
||||
var x := chart_left + chart_w * (float(i) / float(n - 1))
|
||||
var clamped := clampf(float(_tick_deltas[i]), 0.0, max_ms)
|
||||
var y := chart_top + chart_h * (1.0 - clamped / max_ms)
|
||||
var pt := Vector2(x, y)
|
||||
var color := GRAPH_WARN_COLOR if float(_tick_deltas[i]) > TICK_WARN_MS else GRAPH_LINE_COLOR
|
||||
if i > 0:
|
||||
draw_line(prev_pt, pt, color, 1.5)
|
||||
draw_circle(pt, 2.0, color)
|
||||
prev_pt = pt
|
||||
|
||||
# Average label
|
||||
var avg_ms := 0.0
|
||||
for d in _tick_deltas:
|
||||
avg_ms += float(d)
|
||||
avg_ms /= float(_tick_deltas.size())
|
||||
draw_string(font,
|
||||
Vector2(chart_left + chart_w - 54.0, chart_top + chart_h + FONT_SIZE - 2),
|
||||
"avg %.0fms" % avg_ms,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North)
|
||||
static func _facing_to_angle(facing: String) -> float:
|
||||
match facing:
|
||||
"North": return -PI / 2.0
|
||||
"Northeast": return -PI / 4.0
|
||||
"East": return 0.0
|
||||
"Southeast": return PI / 4.0
|
||||
"South": return PI / 2.0
|
||||
"Southwest": return PI * 3.0 / 4.0
|
||||
"West": return PI
|
||||
"Northwest": return -PI * 3.0 / 4.0
|
||||
_: return -PI / 2.0
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
class_name YamlParser
|
||||
## Shared YAML parser — common subset used by ui_strings.gd and checklist_evaluator.gd.
|
||||
##
|
||||
## Handles: nested sections (maps), arrays of dict items (- key: val), typed values.
|
||||
## Returns a hierarchical Dictionary. Use flatten() to convert to dotted-key format
|
||||
## (as UIStrings._parse_yaml() requires).
|
||||
##
|
||||
## Limitations: single-line values only; no YAML anchors/aliases; no flow syntax.
|
||||
## String values: quotes stripped. Booleans, ints, and floats are type-inferred.
|
||||
##
|
||||
## Spec ref: #560 (Sprint 20 — unify duplicate YAML parsers), D-030 (testability).
|
||||
|
||||
|
||||
## Parse YAML text into a hierarchical Dictionary.
|
||||
## Nested sections become nested dicts. Array items (- key: val) become Arrays.
|
||||
## Values are type-inferred: bool, int, float, or String.
|
||||
static func parse(text: String) -> Dictionary:
|
||||
var root: Dictionary = {}
|
||||
# Stack: [{indent: int, key: String}] — path of open section headers
|
||||
var stack: Array = []
|
||||
# Array state
|
||||
var current_array: Variant = null # Array being built, or null
|
||||
var current_item: Variant = null # Dict being built for current array item, or null
|
||||
var array_parent_indent: int = -1 # indent of the "key:" line that owns the array
|
||||
|
||||
for raw_line in text.split("\n"):
|
||||
var stripped := raw_line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
|
||||
continue
|
||||
var indent: int = raw_line.length() - raw_line.lstrip(" ").length()
|
||||
var content: String = stripped.strip_edges()
|
||||
|
||||
# --- Array item (- key: value) ---
|
||||
if content.begins_with("- "):
|
||||
# First item: convert parent section's {} placeholder to []
|
||||
if current_array == null and stack.size() > 0:
|
||||
var parent := _node_at(root, stack, true)
|
||||
var arr_key: String = stack.back()["key"]
|
||||
var new_arr: Array = []
|
||||
parent[arr_key] = new_arr
|
||||
current_array = new_arr
|
||||
array_parent_indent = stack.back()["indent"]
|
||||
# Flush previous item and start a new one
|
||||
if current_item != null:
|
||||
current_array.append(current_item)
|
||||
current_item = {}
|
||||
var rest: String = content.substr(2).strip_edges()
|
||||
var colon: int = rest.find(":")
|
||||
if colon >= 0:
|
||||
var k: String = rest.substr(0, colon).strip_edges()
|
||||
var v: String = rest.substr(colon + 1).strip_edges()
|
||||
current_item[k] = _parse_value(v)
|
||||
continue
|
||||
|
||||
# --- Continuation line within current array item ---
|
||||
if current_array != null and indent > array_parent_indent:
|
||||
var colon: int = content.find(":")
|
||||
if colon >= 0 and current_item != null:
|
||||
var k: String = content.substr(0, colon).strip_edges()
|
||||
var v: String = content.substr(colon + 1).strip_edges()
|
||||
current_item[k] = _parse_value(v)
|
||||
continue
|
||||
|
||||
# --- End of array (indent has returned to array level or above) ---
|
||||
if current_array != null:
|
||||
if current_item != null:
|
||||
current_array.append(current_item)
|
||||
current_item = null
|
||||
current_array = null
|
||||
array_parent_indent = -1
|
||||
if stack.size() > 0:
|
||||
stack.pop_back() # pop the array-owning key
|
||||
|
||||
# --- Regular key: value or section header ---
|
||||
var colon: int = content.find(":")
|
||||
if colon < 0:
|
||||
continue
|
||||
var key: String = content.substr(0, colon).strip_edges()
|
||||
var val_str: String = content.substr(colon + 1).strip_edges()
|
||||
|
||||
# Pop sections at the same or deeper indent (we're back at a shallower level)
|
||||
while stack.size() > 0 and stack.back()["indent"] >= indent:
|
||||
stack.pop_back()
|
||||
|
||||
var node: Dictionary = _node_at(root, stack, false)
|
||||
|
||||
if val_str.is_empty() or val_str.begins_with("#"):
|
||||
# Section header — create nested dict (may become Array if - items follow)
|
||||
node[key] = {}
|
||||
stack.push_back({"indent": indent, "key": key})
|
||||
else:
|
||||
node[key] = _parse_value(val_str)
|
||||
|
||||
# Flush the last array item if the file ended inside an array
|
||||
if current_array != null and current_item != null:
|
||||
current_array.append(current_item)
|
||||
|
||||
return root
|
||||
|
||||
|
||||
## Convenience: parse text and flatten to dotted-key format in one call.
|
||||
## Used by UIStrings._parse_yaml() — equivalent to flatten(parse(text)).
|
||||
static func parse_flat(text: String) -> Dictionary:
|
||||
return flatten(parse(text))
|
||||
|
||||
|
||||
## Flatten a hierarchical dict to dotted-key format (for UIStrings compatibility).
|
||||
## {"a": {"b": "v"}} → {"a.b": "v"}
|
||||
## Arrays are skipped — dotted-key format does not represent them.
|
||||
## All values are converted to String (UIStrings stores display text, not typed data).
|
||||
static func flatten(d: Dictionary, prefix: String = "") -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for k in d:
|
||||
var full_key: String = (prefix + "." if not prefix.is_empty() else "") + str(k)
|
||||
var v = d[k]
|
||||
if v is Dictionary:
|
||||
result.merge(flatten(v, full_key))
|
||||
elif not v is Array:
|
||||
result[full_key] = str(v)
|
||||
return result
|
||||
|
||||
|
||||
## Parse a single YAML value string into a typed GDScript value.
|
||||
## Strips inline comments, handles quoted strings, infers bool/int/float/String.
|
||||
static func _parse_value(val: String) -> Variant:
|
||||
if val.is_empty():
|
||||
return ""
|
||||
# Strip inline comment outside quotes
|
||||
if not val.begins_with("\""):
|
||||
var comment_pos: int = val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
# Quoted string — extract content between quotes
|
||||
if val.begins_with("\""):
|
||||
var end_quote: int = val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
return val.substr(1, end_quote - 1)
|
||||
return val.substr(1)
|
||||
# Boolean
|
||||
if val == "true": return true
|
||||
if val == "false": return false
|
||||
# Float (must have decimal point)
|
||||
if val.contains(".") and val.is_valid_float():
|
||||
return val.to_float()
|
||||
# Integer
|
||||
if val.is_valid_int():
|
||||
return val.to_int()
|
||||
# Plain string
|
||||
return val
|
||||
|
||||
|
||||
## Navigate root following the stack key path.
|
||||
## parent=true: navigate one level less (returns the parent node, not the leaf).
|
||||
static func _node_at(root: Dictionary, stack: Array, parent: bool) -> Dictionary:
|
||||
var node: Dictionary = root
|
||||
var depth: int = stack.size() - (1 if parent else 0)
|
||||
for i in range(depth):
|
||||
var k: String = stack[i]["key"]
|
||||
if node.has(k) and node[k] is Dictionary:
|
||||
node = node[k]
|
||||
else:
|
||||
break
|
||||
return node
|
||||
@@ -0,0 +1 @@
|
||||
うtickヘ,ヲaction�DialogueResponseげtarget_entity_id*ォresponse_idーkael-davan_d_001
|
||||
@@ -0,0 +1 @@
|
||||
う
|
||||
@@ -0,0 +1 @@
|
||||
うtickヲaction→Interactげtarget_entity_idc、verb、Talk
|
||||
@@ -0,0 +1 @@
|
||||
うtickヲactionゥMoveNorth
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env -S godot -s
|
||||
## gdUnit4 CI runner for the Settled Reach client.
|
||||
##
|
||||
## Run all client tests headlessly:
|
||||
## godot --headless --path client/ \
|
||||
## -s res://tests/run_gdunit4.gd \
|
||||
## -- --ignoreHeadlessMode -a res://tests/
|
||||
##
|
||||
## Run a specific test file:
|
||||
## godot --headless --path client/ \
|
||||
## -s res://tests/run_gdunit4.gd \
|
||||
## -- --ignoreHeadlessMode -a res://tests/test_protocol.gd
|
||||
##
|
||||
## Exit code: 0 = all pass, non-zero = failures.
|
||||
##
|
||||
## D-030 (architecture.md): gdUnit4 is the confirmed Godot test framework.
|
||||
## Test output format: JSON summary per D-030 sub-decision #6.
|
||||
extends SceneTree
|
||||
|
||||
var _cli_runner: GdUnitTestCIRunner
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
|
||||
_cli_runner = GdUnitTestCIRunner.new()
|
||||
root.add_child(_cli_runner)
|
||||
|
||||
|
||||
func _finalize() -> void:
|
||||
queue_delete(_cli_runner)
|
||||
@@ -135,43 +135,43 @@ func test_npc_uses_relationship_color_unknown() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_npc_uses_relationship_color_friendly() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
if not _entity_uses_relationship(renderer):
|
||||
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
|
||||
renderer.queue_free()
|
||||
return
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_npc_uses_relationship_color_poi() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_npc_uses_relationship_color_hostile() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_player_color_ignores_relationship() -> void:
|
||||
@@ -179,8 +179,8 @@ func test_player_color_ignores_relationship() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(1, "Player", "Hostile")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[1] as ColorRect
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||
var node: Sprite2D = renderer.entity_nodes[1] as Sprite2D
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_object_color_ignores_relationship() -> void:
|
||||
@@ -188,8 +188,8 @@ func test_object_color_ignores_relationship() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(3, "Object", "Friendly")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[3] as ColorRect
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
var node: Sprite2D = renderer.entity_nodes[3] as Sprite2D
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
@@ -204,20 +204,20 @@ func test_color_shift_not_instant() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
|
||||
renderer.update_entities(entities_before)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
# Change relationship to Hostile
|
||||
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
|
||||
renderer.update_entities(entities_after)
|
||||
# Immediately after update, color should NOT yet be the target
|
||||
var color_after_immediate: Color = node.color
|
||||
# Immediately after update, self_modulate should NOT yet be the target
|
||||
var color_after_immediate: Color = node.self_modulate
|
||||
if not _renderer_has_tween_support(renderer):
|
||||
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
|
||||
renderer.queue_free()
|
||||
return
|
||||
# The color should NOT be exactly the target yet (tween in progress)
|
||||
# The self_modulate should NOT be exactly the target yet (tween in progress)
|
||||
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
@@ -236,8 +236,8 @@ func test_color_shift_reaches_target() -> void:
|
||||
while elapsed < 1.5:
|
||||
renderer._process(1.0 / 60.0)
|
||||
elapsed += 1.0 / 60.0
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_color_shift_mid_transition_retrigger() -> void:
|
||||
@@ -263,8 +263,8 @@ func test_color_shift_mid_transition_retrigger() -> void:
|
||||
while elapsed < 1.0:
|
||||
renderer._process(1.0 / 60.0)
|
||||
elapsed += 1.0 / 60.0
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
@@ -275,10 +275,10 @@ func test_color_shift_same_relationship_no_tween() -> void:
|
||||
return
|
||||
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var color_first: Color = node.color
|
||||
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||
var color_first: Color = node.self_modulate
|
||||
renderer.update_entities(entities)
|
||||
var color_second: Color = node.color
|
||||
var color_second: Color = node.self_modulate
|
||||
assert_that(color_first).is_equal(color_second)
|
||||
renderer.queue_free()
|
||||
|
||||
@@ -354,10 +354,12 @@ func _entity_uses_relationship(renderer: Node2D) -> bool:
|
||||
renderer.update_entities([friendly, hostile])
|
||||
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
|
||||
return false
|
||||
var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect
|
||||
var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect
|
||||
var f_color: Color = f_node.color
|
||||
var h_color: Color = h_node.color
|
||||
var f_node: Sprite2D = renderer.entity_nodes[10] as Sprite2D
|
||||
var h_node: Sprite2D = renderer.entity_nodes[11] as Sprite2D
|
||||
if not f_node or not h_node:
|
||||
return false
|
||||
var f_color: Color = f_node.self_modulate
|
||||
var h_color: Color = h_node.self_modulate
|
||||
var uses_rel: bool = not f_color.is_equal_approx(h_color)
|
||||
renderer.update_entities([])
|
||||
return uses_rel
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
## Sprint 19 — Debug visualization overlay (#348)
|
||||
## F3 toggle, world overlays, tick timing graph.
|
||||
## Extends the existing debug_overlay.gd stub.
|
||||
class_name TestDebugOverlaySprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
|
||||
const DEBUG_SCRIPT_PATH: String = "res://scripts/ui/debug_overlay.gd"
|
||||
|
||||
|
||||
func _make_overlay() -> Control:
|
||||
## Instantiate a standalone DebugOverlay control for unit testing.
|
||||
## Does not require the full main.tscn scene tree.
|
||||
var script := load(DEBUG_SCRIPT_PATH)
|
||||
if script == null:
|
||||
push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip")
|
||||
return null
|
||||
var node := Control.new()
|
||||
node.set_script(script)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.player_position = Vector2(10.0, 10.0)
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.current_tick = 1
|
||||
GameState.player_knowledge = null
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.visible_entities = []
|
||||
GameState.player_knowledge = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Script existence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_script_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
|
||||
"debug_overlay.gd must exist at res://scripts/ui/debug_overlay.gd (#348)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Instantiation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_instantiates_without_crash() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_that(ol).is_not_null()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_debug_overlay_starts_hidden() -> void:
|
||||
## Overlay starts hidden — only appears when F3 pressed.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.visible).override_failure_message(
|
||||
"DebugOverlay must start hidden (visible=false)"
|
||||
).is_false()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dev-only guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has_method("update_from_state")).override_failure_message(
|
||||
"DebugOverlay must have update_from_state() method"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_crash_when_hidden() -> void:
|
||||
## update_from_state() called while hidden must not crash.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = false
|
||||
ol.update_from_state() # Should be a no-op, no crash
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_crash_when_visible() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
# Simulate a minimal snapshot tick
|
||||
GameState.current_tick = 42
|
||||
ol.update_from_state()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NPC path tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_npc_paths_field_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("_npc_paths")).override_failure_message(
|
||||
"DebugOverlay must have _npc_paths field for NPC movement history"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_updated_on_state_update() -> void:
|
||||
## After update_from_state with an NPC entity, _npc_paths should have an entry.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 2,
|
||||
"x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"relationship": "Unknown",
|
||||
}]
|
||||
GameState.current_tick = 100
|
||||
ol.update_from_state()
|
||||
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||
"_npc_paths must record NPC positions from visible_entities"
|
||||
).is_greater(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_not_populated_for_player_entity() -> void:
|
||||
## Player entities must not appear in NPC path history.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 1,
|
||||
"x": 10.0, "y": 10.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null},
|
||||
}]
|
||||
GameState.current_tick = 101
|
||||
ol.update_from_state()
|
||||
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||
"Player entity must not appear in _npc_paths"
|
||||
).is_equal(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_max_length_respected() -> void:
|
||||
## Path history must not grow beyond NPC_HISTORY_LEN entries.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
# Simulate NPC moving each tick — inject 20 ticks of movement
|
||||
for i in range(20):
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 5,
|
||||
"x": float(12 + i), "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"relationship": "Unknown",
|
||||
}]
|
||||
GameState.current_tick = 200 + i
|
||||
ol.update_from_state()
|
||||
var path: Array = ol._npc_paths.get(5, [])
|
||||
assert_int(path.size()).override_failure_message(
|
||||
"NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN
|
||||
).is_less_equal(ol.NPC_HISTORY_LEN)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tick timing ring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_tick_deltas_field_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("_tick_deltas")).override_failure_message(
|
||||
"DebugOverlay must have _tick_deltas field for timing sparkline"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_deltas_accumulate_over_state_updates() -> void:
|
||||
## Each new tick snapshot should add a delta to _tick_deltas.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
for i in range(5):
|
||||
GameState.current_tick = 300 + i
|
||||
ol.update_from_state()
|
||||
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||
"_tick_deltas must accumulate entries from successive ticks"
|
||||
).is_greater(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_deltas_max_length_respected() -> void:
|
||||
## _tick_deltas must not grow beyond TICK_HISTORY_LEN.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
for i in range(50):
|
||||
GameState.current_tick = 400 + i
|
||||
ol.update_from_state()
|
||||
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||
"_tick_deltas must not exceed TICK_HISTORY_LEN entries"
|
||||
).is_less_equal(ol.TICK_HISTORY_LEN)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants defined
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_npc_history_len_constant_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message(
|
||||
"DebugOverlay must have NPC_HISTORY_LEN constant"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_history_len_constant_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message(
|
||||
"DebugOverlay must have TICK_HISTORY_LEN constant"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_warn_ms_constant_defined() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("TICK_WARN_MS")).override_failure_message(
|
||||
"DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Facing angle helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_facing_to_angle_north() -> void:
|
||||
## North = -PI/2 in Godot 2D (up on screen)
|
||||
var angle := _fetch_facing_angle("North")
|
||||
assert_float(angle).override_failure_message(
|
||||
"_facing_to_angle('North') must return -PI/2"
|
||||
).is_equal_approx(-PI / 2.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_east() -> void:
|
||||
var angle := _fetch_facing_angle("East")
|
||||
assert_float(angle).is_equal_approx(0.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_south() -> void:
|
||||
var angle := _fetch_facing_angle("South")
|
||||
assert_float(angle).is_equal_approx(PI / 2.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_west() -> void:
|
||||
var angle := _fetch_facing_angle("West")
|
||||
assert_float(angle).is_equal_approx(PI, 0.001)
|
||||
|
||||
|
||||
func _fetch_facing_angle(facing: String) -> float:
|
||||
## Helper: load script and call static method.
|
||||
var script = load(DEBUG_SCRIPT_PATH)
|
||||
if script == null:
|
||||
return 0.0
|
||||
# In GDScript 4, static methods can be called via an instance
|
||||
var tmp := Control.new()
|
||||
tmp.set_script(script)
|
||||
add_child(tmp)
|
||||
var result := tmp._facing_to_angle(facing)
|
||||
tmp.queue_free()
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-scene placement: DebugOverlay on UILayer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_in_main_scene_ui_layer() -> void:
|
||||
## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay.
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestDebugOverlaySprint19: main.tscn not found — skip")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var ui_layer := scene.get_node_or_null("UILayer")
|
||||
assert_that(ui_layer != null).override_failure_message(
|
||||
"UILayer must exist in main.tscn"
|
||||
).is_true()
|
||||
if ui_layer == null: return
|
||||
var overlay := ui_layer.get_node_or_null("DebugOverlay")
|
||||
assert_that(overlay != null).override_failure_message(
|
||||
"DebugOverlay must be a child of UILayer in main.tscn (#348)"
|
||||
).is_true()
|
||||
@@ -0,0 +1,672 @@
|
||||
## Sprint 18 — Dialogue UI hardening + examine result display (#174)
|
||||
## Spec refs: D-061 (dialogue box), D-062 (invisible locked options), D-063 (confrontation),
|
||||
## D-064 (walk-away), D-078 (overheard log)
|
||||
##
|
||||
## Test plan from joint.md:
|
||||
## "Manual: examine result appears as overlay, auto-dismisses.
|
||||
## Dialogue options confirmed: no locked/grayed options visible.
|
||||
## Confrontation option in italic voice."
|
||||
##
|
||||
## Unit-testable coverage here:
|
||||
## - D-062: no locked/grayed option mechanism in dialogue_box.gd
|
||||
## - D-063: confrontation beat duration, signal, dim alpha
|
||||
## - D-064: walk-away fires dialogue_dismissed signal
|
||||
## - GameState: current_dialogue parsing
|
||||
## - GameState: current_examine_result parsing (test-first — impl TBD in #174)
|
||||
## - BBCode: escape contract (Hoshe #2 regression guard)
|
||||
## - Dirty flag: _log_dirty optimization (Hoshe #1 regression guard)
|
||||
class_name TestDialogueSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _make_dialogue_box() -> Control:
|
||||
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
|
||||
push_warning("TestDialogueSprint18: dialogue_box.tscn not found — scene tests skipped")
|
||||
return null
|
||||
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_options(texts: Array[String], confrontation_flags: Array[bool] = []) -> Array:
|
||||
var opts: Array = []
|
||||
for i in range(texts.size()):
|
||||
var opt: Dictionary = {
|
||||
"text": texts[i],
|
||||
"response_id": "r%d" % i,
|
||||
"priority": i,
|
||||
}
|
||||
if confrontation_flags.size() > i:
|
||||
opt["confrontation"] = confrontation_flags[i]
|
||||
opts.append(opt)
|
||||
return opts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
if GameState.has("current_examine_result"):
|
||||
GameState.current_examine_result = null
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
if GameState.has("current_examine_result"):
|
||||
GameState.current_examine_result = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-062: No locked/grayed options
|
||||
## Per D-062: locked options are INVISIBLE — not shown at all.
|
||||
## The client renders all received options as active, clickable labels.
|
||||
## Server responsibility: omit locked options from the array.
|
||||
## Test verifies: no disabled/locked styling is applied to any rendered option.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d062_rendered_options_have_no_disabled_state() -> void:
|
||||
## D-062: All options from server render as active controls.
|
||||
## No option should have mouse_filter=IGNORE (which would indicate disabled).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
box.show_dialogue("NPC", "Hello.", _make_options(["Option A", "Option B", "Option C"]))
|
||||
|
||||
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
|
||||
assert_that(options_container != null).override_failure_message("OptionsContainer must exist").is_true()
|
||||
|
||||
var labels := options_container.get_children()
|
||||
assert_int(labels.size()).override_failure_message("3 options must render as 3 labels").is_equal(3)
|
||||
|
||||
for label in labels:
|
||||
# MOUSE_FILTER_STOP = 0: active and clickable — correct for D-062
|
||||
# MOUSE_FILTER_IGNORE = 2: would indicate disabled — D-062 violation
|
||||
assert_int(label.mouse_filter).override_failure_message(
|
||||
"Option '%s' must be mouse_filter=STOP (0), not IGNORE (2) — D-062 requires no locked options" % label.text
|
||||
).is_not_equal(Control.MOUSE_FILTER_IGNORE)
|
||||
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d062_no_lock_icon_children_on_options() -> void:
|
||||
## D-062: Options must have no lock icon children (no TextureRect/Sprite2D children).
|
||||
## Any child node on an option label would indicate a locked-option indicator.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Only option"]))
|
||||
|
||||
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
|
||||
if options_container == null: box.queue_free(); return
|
||||
|
||||
var labels := options_container.get_children()
|
||||
assert_int(labels.size()).is_greater(0)
|
||||
|
||||
for label in labels:
|
||||
assert_int(label.get_child_count()).override_failure_message(
|
||||
"Option label must have no child nodes — no lock icons, no decorators (D-062)"
|
||||
).is_equal(0)
|
||||
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d062_max_options_constant_is_three() -> void:
|
||||
## D-061: max 3 response options. D-062: if >3 options arrive, only top 3 by priority
|
||||
## are shown — server must omit locked ones, client only truncates to 3.
|
||||
## Verify MAX_OPTIONS constant is locked at 3.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_int(box.MAX_OPTIONS).override_failure_message(
|
||||
"MAX_OPTIONS must be 3 per D-061 spec"
|
||||
).is_equal(3)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d062_server_sends_four_options_only_three_render() -> void:
|
||||
## D-061/D-062: If server sends 4 options (shouldn't happen but guard),
|
||||
## only top 3 by priority render. No 4th option appears.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var opts := _make_options(["A", "B", "C", "D"])
|
||||
# Assign explicit priorities so sort is deterministic
|
||||
for i in range(opts.size()):
|
||||
opts[i]["priority"] = i
|
||||
box.show_dialogue("NPC", "Speech.", opts)
|
||||
|
||||
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
|
||||
if options_container == null: box.queue_free(); return
|
||||
|
||||
assert_int(options_container.get_child_count()).override_failure_message(
|
||||
"Only 3 options must render even when server sends 4 (D-061 truncation)"
|
||||
).is_equal(3)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-063: Confrontation beat
|
||||
## Confrontation options trigger a 1.5s pre-delivery monologue beat.
|
||||
## Panel dims during beat. confrontation_monologue signal fires.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d063_beat_duration_within_spec() -> void:
|
||||
## D-063: The beat duration must be 1–2 seconds per spec.
|
||||
## Current implementation: CONFRONTATION_BEAT_DURATION = 1.5s.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_float(box.CONFRONTATION_BEAT_DURATION).override_failure_message(
|
||||
"D-063: confrontation beat must be 1.0–2.0 seconds"
|
||||
).is_between(1.0, 2.0)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d063_dim_alpha_is_set() -> void:
|
||||
## D-063: The dialogue box dims during the confrontation beat.
|
||||
## CONFRONTATION_DIM_ALPHA must be below 1.0 (not full opacity).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
|
||||
"D-063: confrontation dim alpha must be < 1.0 (panel visibly dims)"
|
||||
).is_less(1.0)
|
||||
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
|
||||
"D-063: confrontation dim alpha must be > 0.0 (panel still visible)"
|
||||
).is_greater(0.0)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
|
||||
## D-063: Selecting a confrontation option fires confrontation_monologue signal.
|
||||
## This delivers the 1-2 second internal monologue beat to MonologueDisplay.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var signal_fired := false
|
||||
var received_text := ""
|
||||
box.confrontation_monologue.connect(func(text: String, _dur: float):
|
||||
signal_fired = true
|
||||
received_text = text
|
||||
)
|
||||
|
||||
# Show dialogue with one confrontation option
|
||||
var opts := _make_options(["I know what you did."], [true])
|
||||
box.show_dialogue("NPC", "Everything is fine.", opts)
|
||||
|
||||
# Press option 1 (index 0)
|
||||
box._on_option_pressed(0)
|
||||
|
||||
assert_bool(signal_fired).override_failure_message(
|
||||
"D-063: confrontation_monologue signal must fire when confrontation option is selected"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d063_non_confrontation_option_does_not_fire_beat_signal() -> void:
|
||||
## D-063: Standard (non-confrontation) options must NOT fire confrontation_monologue.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var signal_fired := false
|
||||
box.confrontation_monologue.connect(func(_text: String, _dur: float):
|
||||
signal_fired = true
|
||||
)
|
||||
|
||||
var opts := _make_options(["A normal response."], [false])
|
||||
box.show_dialogue("NPC", "Hello.", opts)
|
||||
box._on_option_pressed(0)
|
||||
|
||||
assert_bool(signal_fired).override_failure_message(
|
||||
"D-063: confrontation_monologue must NOT fire for standard options"
|
||||
).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-064: Walk-away mechanic
|
||||
## WASD during active conversation fires dialogue_dismissed signal.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d064_walk_away_actions_constant_not_empty() -> void:
|
||||
## D-064: _WALK_AWAY_ACTIONS must include at least the 8 movement directions.
|
||||
## Prevents accidental empty-array regression.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_int(box._WALK_AWAY_ACTIONS.size()).override_failure_message(
|
||||
"D-064: _WALK_AWAY_ACTIONS must list movement directions (minimum 4)"
|
||||
).is_greater_equal(4)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d064_walk_away_actions_include_cardinal_directions() -> void:
|
||||
## D-064: All four cardinal directions (WASD) must be walk-away triggers.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var actions: Array = box._WALK_AWAY_ACTIONS
|
||||
for required in [&"move_north", &"move_south", &"move_east", &"move_west"]:
|
||||
assert_bool(required in actions).override_failure_message(
|
||||
"D-064: '%s' must be in _WALK_AWAY_ACTIONS" % required
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d064_dialogue_dismissed_signal_connection() -> void:
|
||||
## D-064: dialogue_dismissed signal must exist on DialogueBox.
|
||||
## The signal drives walk-away behavior in main.gd.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
assert_bool(box.has_signal("dialogue_dismissed")).override_failure_message(
|
||||
"D-064: dialogue_dismissed signal must exist on DialogueBox"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: current_dialogue snapshot parsing
|
||||
## D-061: current_dialogue is set from snapshot, null when absent.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_current_dialogue_set_from_snapshot() -> void:
|
||||
## apply_snapshot with current_dialogue dict populates the field.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {
|
||||
"npc_name": "Kael Davan",
|
||||
"npc_entity_id": 42,
|
||||
"speech": "You don't belong here.",
|
||||
"options": [{"text": "I'm just passing through.", "response_id": "r001", "priority": 1}],
|
||||
},
|
||||
})
|
||||
assert_that(GameState.current_dialogue).is_not_null()
|
||||
assert_that(GameState.current_dialogue.get("npc_name")).is_equal("Kael Davan")
|
||||
|
||||
|
||||
func test_gamestate_current_dialogue_null_when_absent() -> void:
|
||||
## apply_snapshot without current_dialogue clears the field.
|
||||
## Prevents stale dialogue from persisting across ticks.
|
||||
GameState.current_dialogue = {"npc_name": "Ghost", "speech": "Stale."}
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.current_dialogue).is_null()
|
||||
|
||||
|
||||
func test_gamestate_current_dialogue_null_when_non_dict() -> void:
|
||||
## Non-dict current_dialogue is rejected — defensive against malformed server data.
|
||||
GameState.apply_snapshot({"tick": 1, "current_dialogue": "not-a-dict"})
|
||||
assert_that(GameState.current_dialogue).is_null()
|
||||
|
||||
|
||||
func test_gamestate_current_dialogue_options_survive_roundtrip() -> void:
|
||||
## The options array must survive snapshot parsing for DialogueBox to render them.
|
||||
var options := [
|
||||
{"text": "A", "response_id": "r1", "priority": 1},
|
||||
{"text": "B", "response_id": "r2", "priority": 2},
|
||||
]
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {
|
||||
"npc_name": "NPC",
|
||||
"npc_entity_id": 1,
|
||||
"speech": "Choose.",
|
||||
"options": options,
|
||||
},
|
||||
})
|
||||
assert_that(GameState.current_dialogue).is_not_null()
|
||||
var parsed_opts: Array = GameState.current_dialogue.get("options", [])
|
||||
assert_int(parsed_opts.size()).is_equal(2)
|
||||
assert_that(parsed_opts[0].get("response_id")).is_equal("r1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: current_examine_result snapshot parsing (test-first, #174)
|
||||
## Sprint 18: examine verb returns character-filtered observation text.
|
||||
## Field: examine_result: {entity_id: int, text: String, confidence: String} | null
|
||||
## GameState must expose current_examine_result for the overlay display node.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_examine_result_field_exists() -> void:
|
||||
## GameState must have a current_examine_result field (Sprint 18, #174).
|
||||
## Fails until Stig adds the field to game_state.gd.
|
||||
assert_bool(GameState.has("current_examine_result")).override_failure_message(
|
||||
"GameState must have 'current_examine_result' field (Sprint 18 #174 — add to game_state.gd)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_by_default() -> void:
|
||||
## current_examine_result defaults to null (no examine active).
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_null_by_default: field not yet added — skip")
|
||||
return
|
||||
GameState.current_examine_result = null
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_set_from_snapshot() -> void:
|
||||
## apply_snapshot with examine_result dict populates current_examine_result.
|
||||
## Wire format (joint.md): {entity_id: int, text: String, confidence: String}
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_set_from_snapshot: field not yet added — skip")
|
||||
return
|
||||
GameState.apply_snapshot({
|
||||
"tick": 5,
|
||||
"examine_result": {
|
||||
"entity_id": 12,
|
||||
"text": "Kael Davan — nervous energy. He's scanning exits.",
|
||||
"confidence": "KnowsOf",
|
||||
},
|
||||
})
|
||||
assert_that(GameState.current_examine_result).is_not_null()
|
||||
assert_that(GameState.current_examine_result.get("text")).contains("Kael Davan")
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_when_absent() -> void:
|
||||
## apply_snapshot without examine_result must clear the field.
|
||||
## Prevents stale examine overlay persisting beyond auto-dismiss window.
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_null_when_absent: field not yet added — skip")
|
||||
return
|
||||
GameState.current_examine_result = {"entity_id": 5, "text": "Stale.", "confidence": "Suspects"}
|
||||
GameState.apply_snapshot({"tick": 6})
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_when_non_dict() -> void:
|
||||
## Malformed examine_result (not a dict) must be rejected.
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_null_when_non_dict: field not yet added — skip")
|
||||
return
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": "bad-value"})
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void:
|
||||
## entity_id is needed to anchor the overlay above the correct entity.
|
||||
if not GameState.has("current_examine_result"):
|
||||
push_warning("test_gamestate_examine_result_entity_id_survives_roundtrip: field not yet added — skip")
|
||||
return
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"examine_result": {"entity_id": 99, "text": "Observed.", "confidence": "Direct"},
|
||||
})
|
||||
assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BBCode injection guard (regression: Hoshe #2)
|
||||
## Server-sourced text containing BBCode brackets must be escaped.
|
||||
## Note: dialogue_box.gd has no class_name — call _escape_bbcode via instance.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_escape_bbcode_brackets_in_server_text() -> void:
|
||||
## _escape_bbcode must convert '[' to '[lb]' to prevent BBCode injection.
|
||||
## Regression test: a malicious NPC name like "[wave]Evil[/wave]" must render
|
||||
## as plain text in the dialogue log.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var escaped: String = box._escape_bbcode("[wave]Evil NPC[/wave]")
|
||||
assert_that(escaped).is_not_equal("[wave]Evil NPC[/wave]")
|
||||
assert_that(escaped).contains("[lb]")
|
||||
assert_bool(escaped.begins_with("[")).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_escape_bbcode_plain_text_unchanged() -> void:
|
||||
## Non-BBCode text must not be modified by _escape_bbcode.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var plain := "Kael Davan"
|
||||
assert_that(box._escape_bbcode(plain)).is_equal(plain)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_escape_bbcode_multiple_brackets() -> void:
|
||||
## Multiple '[' characters all get escaped.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var text := "[b]Bold[/b] and [i]italic[/i]"
|
||||
var escaped: String = box._escape_bbcode(text)
|
||||
assert_bool(escaped.contains("[b]")).is_false()
|
||||
assert_bool(escaped.contains("[i]")).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log dirty flag optimization (regression: Hoshe #1)
|
||||
## _log_dirty prevents per-frame O(n) BBCode rebuilds.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_log_dirty_false_on_init() -> void:
|
||||
## _log_dirty starts false — no rebuild needed before first line.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_bool(box._log_dirty).override_failure_message(
|
||||
"_log_dirty must be false on init — no unnecessary rebuild"
|
||||
).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_log_dirty_set_after_append_line() -> void:
|
||||
## Appending a line sets _log_dirty = true.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
box.append_line("NPC", "Player", "Hello.", false)
|
||||
assert_bool(box._log_dirty).override_failure_message(
|
||||
"_log_dirty must be true after append_line to trigger rebuild next _process"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_log_dirty_cleared_after_process() -> void:
|
||||
## After _process(), _log_dirty is cleared (rebuild done).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
box.append_line("NPC", "Player", "One line.", false)
|
||||
assert_bool(box._log_dirty).is_true()
|
||||
box._process(0.0)
|
||||
assert_bool(box._log_dirty).override_failure_message(
|
||||
"_log_dirty must be false after _process (rebuild consumed the flag)"
|
||||
).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-061: Dialogue box size constraints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d061_max_height_ratio_is_twenty_percent() -> void:
|
||||
## D-061: dialogue box must occupy max 20% viewport height.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_float(box.MAX_HEIGHT_RATIO).override_failure_message(
|
||||
"D-061: MAX_HEIGHT_RATIO must be 0.2 (20% viewport height)"
|
||||
).is_equal_approx(0.2, 0.001)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d061_fade_in_is_200ms() -> void:
|
||||
## D-061: fade-in on dialogue appearance is 0.2s.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_float(box.FADE_IN).override_failure_message(
|
||||
"D-061: FADE_IN must be 0.2s"
|
||||
).is_equal_approx(0.2, 0.001)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_d064_fade_out_is_300ms() -> void:
|
||||
## D-064: walk-away fade-out is 300ms per spec.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_float(box.FADE_OUT).override_failure_message(
|
||||
"D-064: FADE_OUT must be 0.3s (300ms walk-away fade)"
|
||||
).is_equal_approx(0.3, 0.001)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Passive log entry (D-078: overheard NPC-NPC)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_passive_entry_uses_bar_glyph() -> void:
|
||||
## D-078: Overheard NPC-NPC lines get ┃ prefix (PASSIVE_GLYPH).
|
||||
## Verifies the glyph constant is the correct Unicode bar character.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_that(box.PASSIVE_GLYPH).override_failure_message(
|
||||
"D-078: PASSIVE_GLYPH must be ┃ (U+2503) + space"
|
||||
).is_equal("\u2503 ")
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_passive_entry_appended_and_marked_is_passive() -> void:
|
||||
## D-078: append_conversation_event creates a passive log entry.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
var event := {
|
||||
"speaker_id": 10, "target_id": 11,
|
||||
"speaker_name": "Guard A", "target_name": "Guard B",
|
||||
"occluded_line": "Did you see the detective?",
|
||||
"speaker_color_index": 0, "target_color_index": 1,
|
||||
}
|
||||
box.append_conversation_event(event)
|
||||
|
||||
assert_int(box._log_entries.size()).is_equal(1)
|
||||
assert_bool(box._log_entries[0].is_passive).override_failure_message(
|
||||
"D-078: overheard entry must be marked is_passive = true"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_passive_entry_empty_text_not_appended() -> void:
|
||||
## D-078: Conversation event with empty occluded_line is silently dropped.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
box.append_conversation_event({
|
||||
"speaker_id": 1, "target_id": 2,
|
||||
"speaker_name": "A", "target_name": "B",
|
||||
"occluded_line": "",
|
||||
})
|
||||
assert_int(box._log_entries.size()).override_failure_message(
|
||||
"D-078: empty occluded_line must not produce a log entry"
|
||||
).is_equal(0)
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# has_active_entries / is_dialogue_active
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_has_active_entries_false_on_init() -> void:
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_bool(box.has_active_entries()).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_has_active_entries_true_after_append() -> void:
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
box.append_line("NPC", "Player", "Hey.", false)
|
||||
assert_bool(box.has_active_entries()).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_is_dialogue_active_false_on_init() -> void:
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
assert_bool(box.is_dialogue_active()).is_false()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_is_dialogue_active_true_after_show_dialogue() -> void:
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(box.is_dialogue_active()).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-020 (#558): Signal decoupling — dialogue_box emits signals instead of
|
||||
# directly mutating GameState or calling AudioManager.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_dialogue_state_changed_emits_true_on_show() -> void:
|
||||
## D-020: show_dialogue() must emit dialogue_state_changed(true).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var received: Array = []
|
||||
box.dialogue_state_changed.connect(func(active): received.append(active))
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(received.has(true)).override_failure_message(
|
||||
"dialogue_state_changed(true) must be emitted on show_dialogue"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_dialogue_state_changed_emits_false_on_hide() -> void:
|
||||
## D-020: hide_dialogue() must emit dialogue_state_changed(false).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var received: Array = []
|
||||
box.dialogue_state_changed.connect(func(active): received.append(active))
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
box.hide_dialogue()
|
||||
assert_bool(received.has(false)).override_failure_message(
|
||||
"dialogue_state_changed(false) must be emitted on hide_dialogue"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_audio_dip_requested_emits_dialogue_on_show() -> void:
|
||||
## D-020: show_dialogue() must emit audio_dip_requested("dialogue").
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var received: Array = []
|
||||
box.audio_dip_requested.connect(func(profile): received.append(profile))
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(received.has("dialogue")).override_failure_message(
|
||||
"audio_dip_requested('dialogue') must be emitted on show_dialogue"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_audio_dip_cleared_emits_on_hide() -> void:
|
||||
## D-020: hide_dialogue() must emit audio_dip_cleared.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var cleared := [false]
|
||||
box.audio_dip_cleared.connect(func(): cleared[0] = true)
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
box.hide_dialogue()
|
||||
assert_bool(cleared[0]).override_failure_message(
|
||||
"audio_dip_cleared must be emitted on hide_dialogue"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_no_direct_game_state_mutation() -> void:
|
||||
## D-020: dialogue_box must not directly mutate GameState.dialogue_active.
|
||||
## After show_dialogue, GameState.dialogue_active should remain unchanged
|
||||
## (only the coordinator updates it via signal handler).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
GameState.dialogue_active = false
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||
"GameState.dialogue_active must NOT be mutated directly by dialogue_box"
|
||||
).is_false()
|
||||
box.queue_free()
|
||||
GameState.dialogue_active = false
|
||||
@@ -0,0 +1,143 @@
|
||||
## Sprint 20 #558: dialogue_box.gd decoupling tests.
|
||||
##
|
||||
## Verifies D-020 compliance: dialogue_box.gd emits signals instead of mutating
|
||||
## GameState or calling AudioManager directly. main.gd wires the signal handlers.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestDialogueSprint20
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func _make_dialogue_box() -> Control:
|
||||
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
|
||||
push_warning("TestDialogueSprint20: dialogue_box.tscn not found — scene tests skipped")
|
||||
return null
|
||||
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
# -- dialogue_state_changed signal -------------------------------------------
|
||||
|
||||
func test_show_dialogue_emits_dialogue_state_changed_true() -> void:
|
||||
## show_dialogue() must emit dialogue_state_changed(true) not mutate GameState directly.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var received: Variant = null
|
||||
box.dialogue_state_changed.connect(func(active: bool): received = active)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_that(received).override_failure_message(
|
||||
"show_dialogue() must emit dialogue_state_changed(true) (#558)"
|
||||
).is_equal(true)
|
||||
|
||||
|
||||
func test_show_dialogue_does_not_mutate_game_state_directly() -> void:
|
||||
## Without a connected handler, GameState.dialogue_active must stay false.
|
||||
## Proves dialogue_box.gd has zero direct GameState mutation (D-020 #558).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
GameState.dialogue_active = false
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||
"dialogue_box must not mutate GameState.dialogue_active directly (D-020 #558)"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_hide_dialogue_emits_dialogue_state_changed_false() -> void:
|
||||
## hide_dialogue() must emit dialogue_state_changed(false).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
var received: Variant = null
|
||||
box.dialogue_state_changed.connect(func(active: bool): received = active)
|
||||
box.hide_dialogue()
|
||||
assert_that(received).override_failure_message(
|
||||
"hide_dialogue() must emit dialogue_state_changed(false) (#558)"
|
||||
).is_equal(false)
|
||||
|
||||
|
||||
# -- audio_dip_requested / audio_dip_cleared signals -------------------------
|
||||
|
||||
func test_show_dialogue_emits_audio_dip_requested_dialogue() -> void:
|
||||
## show_dialogue() must emit audio_dip_requested("dialogue") not call AudioManager.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var received_profile: Variant = null
|
||||
box.audio_dip_requested.connect(func(profile: String): received_profile = profile)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_that(received_profile).override_failure_message(
|
||||
"show_dialogue() must emit audio_dip_requested('dialogue') (#558)"
|
||||
).is_equal("dialogue")
|
||||
|
||||
|
||||
func test_show_dialogue_does_not_call_audio_manager_directly() -> void:
|
||||
## Without a connected handler, AudioManager state must be unchanged by show_dialogue().
|
||||
## Verifies no direct AudioManager call in dialogue_box.gd (D-020 #558).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var dip_before := AudioManager.get_active_dip()
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
var dip_after := AudioManager.get_active_dip()
|
||||
assert_str(dip_after).override_failure_message(
|
||||
"dialogue_box must not call AudioManager.apply_dip() directly (D-020 #558)"
|
||||
).is_equal(dip_before)
|
||||
|
||||
|
||||
func test_hide_dialogue_emits_audio_dip_cleared() -> void:
|
||||
## Ending a conversation must emit audio_dip_cleared not call AudioManager directly.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
var cleared := false
|
||||
box.audio_dip_cleared.connect(func(): cleared = true)
|
||||
box.hide_dialogue()
|
||||
assert_bool(cleared).override_failure_message(
|
||||
"hide_dialogue() must emit audio_dip_cleared (#558)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_audio_dip_cleared_count_on_conversation_end() -> void:
|
||||
## Verify audio_dip_cleared fires when conversation ends.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var cleared_count := 0
|
||||
box.audio_dip_cleared.connect(func(): cleared_count += 1)
|
||||
box.show_dialogue("NPC", "Speak.", [])
|
||||
box.hide_dialogue()
|
||||
assert_int(cleared_count).override_failure_message(
|
||||
"audio_dip_cleared must fire at least once when conversation ends (#558)"
|
||||
).is_greater_equal(1)
|
||||
|
||||
|
||||
# -- coordinator wiring verification -----------------------------------------
|
||||
|
||||
func test_signal_handler_wires_game_state() -> void:
|
||||
## Simulate main.gd: connect dialogue_state_changed to update GameState.dialogue_active.
|
||||
## Verifies the coordinator pattern works end-to-end (D-020 #558).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.dialogue_state_changed.connect(func(active: bool): GameState.dialogue_active = active)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||
"With handler wired, GameState.dialogue_active must be true after show_dialogue (#558)"
|
||||
).is_true()
|
||||
box.hide_dialogue()
|
||||
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||
"With handler wired, GameState.dialogue_active must be false after hide_dialogue (#558)"
|
||||
).is_false()
|
||||
@@ -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,334 @@
|
||||
## Sprint 18 — Examine result display (#174)
|
||||
## Spec refs: D-061 (adjacent to dialogue spec), D-041 (character-filtered observation)
|
||||
##
|
||||
## ExamineDisplay: non-interactive overlay, diegetic, auto-dismisses after DISMISS_DELAY.
|
||||
## Positioned in InsertOverlay (CanvasLayer 10).
|
||||
## GameState.current_examine_result: cleared every snapshot (unlike player_knowledge).
|
||||
##
|
||||
## Tests run against live Stig implementation (examine_display.gd).
|
||||
class_name TestExamineDisplaySprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const EXAMINE_SCENE_PATH: String = "res://ui/examine_display.tscn"
|
||||
|
||||
func _make_examine_display() -> Control:
|
||||
if not ResourceLoader.exists(EXAMINE_SCENE_PATH):
|
||||
push_warning("TestExamineDisplaySprint18: examine_display.tscn not found — skip")
|
||||
return null
|
||||
var node: Control = load(EXAMINE_SCENE_PATH).instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_result(overrides: Dictionary = {}) -> Dictionary:
|
||||
var base: Dictionary = {
|
||||
"entity_id": 42,
|
||||
"text": "Kael Davan — nervous energy. He's scanning exits.",
|
||||
"confidence": "KnowsOf",
|
||||
}
|
||||
base.merge(overrides, true)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_examine_result = null
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.current_examine_result = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: current_examine_result parsing
|
||||
## (Now tests real implementation — not test-first stubs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_examine_result_field_exists() -> void:
|
||||
assert_bool(GameState.has("current_examine_result")).override_failure_message(
|
||||
"GameState must have 'current_examine_result' field (#174)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_by_default() -> void:
|
||||
GameState.current_examine_result = null
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_set_from_snapshot() -> void:
|
||||
GameState.apply_snapshot({"tick": 5, "examine_result": _make_result()})
|
||||
assert_that(GameState.current_examine_result).is_not_null()
|
||||
assert_that(GameState.current_examine_result.get("text")).contains("Kael Davan")
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_when_absent() -> void:
|
||||
## CONTRAST with player_knowledge: examine_result DOES clear each snapshot.
|
||||
## The overlay must auto-dismiss — the server never re-sends the same result.
|
||||
GameState.current_examine_result = _make_result()
|
||||
GameState.apply_snapshot({"tick": 6})
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_when_non_dict() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": "bad-value"})
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"entity_id": 99})})
|
||||
assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99)
|
||||
|
||||
|
||||
func test_gamestate_examine_result_confidence_survives_roundtrip() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"confidence": "Direct"})})
|
||||
assert_that(GameState.current_examine_result.get("confidence")).is_equal("Direct")
|
||||
|
||||
|
||||
func test_gamestate_examine_result_replaced_on_next_snapshot() -> void:
|
||||
## Two examine results in sequence — second replaces first.
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"text": "First observation."})})
|
||||
GameState.apply_snapshot({"tick": 2, "examine_result": _make_result({"text": "Second observation."})})
|
||||
assert_that(GameState.current_examine_result.get("text")).is_equal("Second observation.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExamineDisplay scene
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_examine_display_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(EXAMINE_SCENE_PATH)).override_failure_message(
|
||||
"ExamineDisplay scene must exist at res://ui/examine_display.tscn"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_examine_display_instantiates_without_crash() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_that(display).is_not_null()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_has_show_result_method() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.has_method("show_result")).override_failure_message(
|
||||
"ExamineDisplay must have show_result(result: Dictionary) method"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_has_dismiss_method() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.has_method("dismiss")).override_failure_message(
|
||||
"ExamineDisplay must have dismiss() method"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_has_is_active_method() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.has_method("is_active")).override_failure_message(
|
||||
"ExamineDisplay must have is_active() method"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExamineDisplay behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_examine_display_not_active_on_init() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"ExamineDisplay must start inactive (no result showing)"
|
||||
).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_not_visible_on_init() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.visible).override_failure_message(
|
||||
"ExamineDisplay must start invisible"
|
||||
).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_active_after_show_result() -> void:
|
||||
## show_result() with valid text sets is_active() = true.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result(_make_result())
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"show_result() with text must set is_active() = true"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_visible_after_show_result() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result(_make_result())
|
||||
assert_bool(display.visible).override_failure_message(
|
||||
"show_result() must set visible = true"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_empty_text_ignored() -> void:
|
||||
## show_result() with empty text must not activate (D-041: no empty observations).
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result({"entity_id": 1, "text": "", "confidence": "KnowsOf"})
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"show_result() with empty text must not activate the display"
|
||||
).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_inactive_after_dismiss() -> void:
|
||||
## dismiss() immediately starts fade-out and sets _active = false.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result(_make_result())
|
||||
assert_bool(display.is_active()).is_true()
|
||||
display.dismiss()
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"dismiss() must set is_active() = false immediately"
|
||||
).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_dismiss_when_inactive_no_crash() -> void:
|
||||
## dismiss() on an inactive display must be safe (no crash, no state corruption).
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.dismiss() # called when not active
|
||||
assert_bool(display.is_active()).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_show_replaces_previous() -> void:
|
||||
## Second show_result() replaces first (only one result at a time).
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result(_make_result({"text": "First observation."}))
|
||||
display.show_result(_make_result({"text": "Second observation."}))
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"show_result() called twice must leave display active"
|
||||
).is_true()
|
||||
## Text label should reflect the second result
|
||||
var text_label := display.get_node_or_null("PanelContainer/MarginContainer/TextLabel")
|
||||
if text_label is RichTextLabel:
|
||||
assert_that(text_label.text).override_failure_message(
|
||||
"Second show_result() must replace the displayed text"
|
||||
).is_equal("Second observation.")
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExamineDisplay: DISMISS_DELAY within spec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_dismiss_delay_within_spec() -> void:
|
||||
## Spec (sprint-18/client.md): auto-dismisses after 4–6 seconds.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_float(display.DISMISS_DELAY).override_failure_message(
|
||||
"DISMISS_DELAY must be 4–6 seconds per spec"
|
||||
).is_between(4.0, 6.0)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExamineDisplay: CONFIDENCE_ALPHA — confidence-based alpha modulation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_confidence_alpha_dict_covers_all_levels() -> void:
|
||||
## All four confidence levels must have alpha mappings.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||
for level in ["Direct", "KnowsDetails", "KnowsOf", "Suspects"]:
|
||||
assert_bool(alpha_dict.has(level)).override_failure_message(
|
||||
"CONFIDENCE_ALPHA must map '%s'" % level
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_confidence_alpha_direct_is_highest() -> void:
|
||||
## Direct confidence = brightest (alpha 1.0). Character fully trusts this observation.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||
assert_float(alpha_dict.get("Direct", 0.0)).override_failure_message(
|
||||
"Direct confidence must have alpha 1.0 (brightest)"
|
||||
).is_equal_approx(1.0, 0.001)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_confidence_alpha_suspects_is_lowest() -> void:
|
||||
## Suspects = most dimmed (lowest alpha). Uncertainty is visually represented.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||
var suspects_alpha: float = alpha_dict.get("Suspects", 1.0)
|
||||
var direct_alpha: float = alpha_dict.get("Direct", 0.0)
|
||||
assert_float(suspects_alpha).override_failure_message(
|
||||
"Suspects alpha must be less than Direct alpha (dimmer = less certain)"
|
||||
).is_less(direct_alpha)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_confidence_alpha_all_values_valid() -> void:
|
||||
## All alpha values must be in [0.0, 1.0].
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
for key in display.CONFIDENCE_ALPHA:
|
||||
var alpha: float = display.CONFIDENCE_ALPHA[key]
|
||||
assert_float(alpha).override_failure_message(
|
||||
"CONFIDENCE_ALPHA['%s'] = %.2f must be in [0, 1]" % [key, alpha]
|
||||
).is_between(0.0, 1.0)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-interactive: mouse_filter must be IGNORE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_examine_display_mouse_filter_ignore() -> void:
|
||||
## ExamineDisplay is non-interactive — must not consume mouse events.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_int(display.mouse_filter).override_failure_message(
|
||||
"ExamineDisplay must have mouse_filter=IGNORE (non-interactive overlay)"
|
||||
).is_equal(Control.MOUSE_FILTER_IGNORE)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fade constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_fade_in_is_short() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_float(display.FADE_IN).is_between(0.0, 0.5)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_fade_out_is_short() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_float(display.FADE_OUT).is_between(0.0, 0.5)
|
||||
display.queue_free()
|
||||
@@ -0,0 +1,215 @@
|
||||
## GameState.apply_snapshot() tests — v2+ field coverage.
|
||||
##
|
||||
## Complements test_snapshot_parsing.gd (which covers v1 basics: tick, entities,
|
||||
## player_position). This file covers v2+ fields and derived state.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestGameState
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
# Reset fields touched by these tests to known defaults.
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.player_facing = "North"
|
||||
GameState.game_time = {}
|
||||
GameState.nearby_interactions = []
|
||||
GameState.current_monologue = null
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_inventory = []
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
GameState.current_zone_id = ""
|
||||
GameState.insert_active = true
|
||||
|
||||
|
||||
# -- v2: game_time (D-031) -------------------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_game_time() -> void:
|
||||
var snapshot := {
|
||||
"tick": 10,
|
||||
"entities": [],
|
||||
"game_time": {"day": 3, "time_of_day": 480, "day_phase": "Morning", "tick_rate": 1},
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_that(GameState.game_time).is_not_null()
|
||||
assert_that(GameState.game_time.get("day")).is_equal(3)
|
||||
assert_that(GameState.game_time.get("time_of_day")).is_equal(480)
|
||||
|
||||
|
||||
func test_apply_snapshot_game_time_missing_keeps_previous() -> void:
|
||||
GameState.game_time = {"day": 2, "time_of_day": 360}
|
||||
GameState.apply_snapshot({"tick": 5, "entities": []})
|
||||
# No "game_time" key → field unchanged
|
||||
assert_that(GameState.game_time.get("day")).is_equal(2)
|
||||
|
||||
|
||||
# -- v2: player_facing (D-015) --------------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_player_facing() -> void:
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [],
|
||||
"player_facing": "Southeast",
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_that(GameState.player_facing).is_equal("Southeast")
|
||||
|
||||
|
||||
func test_apply_snapshot_player_facing_missing_keeps_default() -> void:
|
||||
GameState.player_facing = "West"
|
||||
GameState.apply_snapshot({"tick": 1, "entities": []})
|
||||
assert_that(GameState.player_facing).is_equal("West")
|
||||
|
||||
|
||||
# -- v4: nearby_interactions (#404/#405) ----------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_nearby_interactions() -> void:
|
||||
var interactions := [
|
||||
{"entity_id": 5, "entity_type": "Npc", "distance": 1.2, "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]},
|
||||
]
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": interactions})
|
||||
assert_that(GameState.nearby_interactions.size()).is_equal(1)
|
||||
assert_that(GameState.nearby_interactions[0].get("entity_id")).is_equal(5)
|
||||
|
||||
|
||||
func test_apply_snapshot_nearby_interactions_absent_clears_list() -> void:
|
||||
GameState.nearby_interactions = [{"entity_id": 1}]
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.nearby_interactions.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- v5: current_monologue (#414) -----------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_monologue() -> void:
|
||||
var monologue := {"id": "m1", "text": "Something is off here.", "duration_seconds": 4.0, "priority": 1, "is_urgent": false}
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "current_monologue": monologue})
|
||||
assert_that(GameState.current_monologue).is_not_null()
|
||||
assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.")
|
||||
|
||||
|
||||
func test_apply_snapshot_monologue_absent_clears_field() -> void:
|
||||
GameState.current_monologue = {"id": "old", "text": "Old line."}
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.current_monologue).is_null()
|
||||
|
||||
|
||||
# -- v6: player_stance (#449, D-053) --------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_player_stance() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "player_stance": "Crouch"})
|
||||
assert_that(GameState.player_stance).is_equal("Crouch")
|
||||
|
||||
|
||||
# -- v6: player_inventory (#449, D-065) -----------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_player_inventory() -> void:
|
||||
var inventory := [{"item_id": 42, "name": "Security pass", "slot": 0}]
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "player_inventory": inventory})
|
||||
assert_that(GameState.player_inventory.size()).is_equal(1)
|
||||
assert_that(GameState.player_inventory[0].get("name")).is_equal("Security pass")
|
||||
|
||||
|
||||
func test_apply_snapshot_inventory_absent_clears_list() -> void:
|
||||
GameState.player_inventory = [{"item_id": 1}]
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.player_inventory.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- Stationary ticks: server-authoritative (D-020/D-071) -----------------
|
||||
|
||||
func test_stationary_ticks_from_server_snapshot() -> void:
|
||||
## D-020: When server sends stationary_ticks, client reads it directly.
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "stationary_ticks": 42})
|
||||
assert_int(GameState.stationary_ticks).is_equal(42)
|
||||
|
||||
|
||||
func test_stationary_ticks_server_value_overrides_client_accumulation() -> void:
|
||||
## D-020: Server value takes priority — client must not accumulate on top of it.
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
"stationary_ticks": 5,
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
GameState.apply_snapshot(snapshot) # same position, but server sends 5 again
|
||||
assert_int(GameState.stationary_ticks).is_equal(5) # server value, not 6
|
||||
|
||||
|
||||
func test_stationary_ticks_missing_field_degrades_gracefully() -> void:
|
||||
## D-020 fallback: when server omits stationary_ticks, no crash, defaults to 0.
|
||||
GameState.stationary_ticks = 0
|
||||
GameState.apply_snapshot({"tick": 1, "entities": []})
|
||||
# No crash; field retains a valid integer value.
|
||||
assert_int(GameState.stationary_ticks).is_greater_equal(0)
|
||||
|
||||
|
||||
# -- Stationary ticks: deprecated client-side fallback (D-071) -----------
|
||||
|
||||
func test_stationary_ticks_fallback_increments_when_position_unchanged() -> void:
|
||||
## Deprecated fallback: client accumulates when server omits the field.
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
}
|
||||
GameState.apply_snapshot(snapshot) # first call: position changes from sentinel
|
||||
GameState.apply_snapshot(snapshot) # second call: position unchanged → +1
|
||||
assert_int(GameState.stationary_ticks).is_greater(0)
|
||||
|
||||
|
||||
func test_stationary_ticks_fallback_resets_on_movement() -> void:
|
||||
## Deprecated fallback: client resets on movement when server omits the field.
|
||||
var s1 := {
|
||||
"tick": 1,
|
||||
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
}
|
||||
var s2 := {
|
||||
"tick": 2,
|
||||
"entities": [{"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
}
|
||||
GameState.apply_snapshot(s1)
|
||||
GameState.apply_snapshot(s1) # stationary
|
||||
assert_int(GameState.stationary_ticks).is_greater(0)
|
||||
GameState.apply_snapshot(s2) # moved → reset
|
||||
assert_int(GameState.stationary_ticks).is_equal(0)
|
||||
|
||||
|
||||
# -- Zone ID: server-authoritative (D-020/D-073) -------------------------
|
||||
|
||||
func test_zone_id_from_server_snapshot() -> void:
|
||||
## D-020: When server sends top-level zone_id, client reads it directly.
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "zone_id": "zone_alpha"})
|
||||
assert_that(GameState.current_zone_id).is_equal("zone_alpha")
|
||||
|
||||
|
||||
func test_zone_id_server_value_overrides_tile_derivation() -> void:
|
||||
## D-020: Server top-level zone_id takes priority over tile-derived zone_id.
|
||||
var 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_from_tile"}],
|
||||
"zone_id": "zone_from_server",
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_that(GameState.current_zone_id).is_equal("zone_from_server")
|
||||
|
||||
|
||||
func test_zone_id_missing_field_degrades_gracefully() -> void:
|
||||
## D-020 fallback: when server omits zone_id and no tiles match, defaults to "".
|
||||
GameState.current_zone_id = ""
|
||||
GameState.apply_snapshot({"tick": 1, "entities": []})
|
||||
assert_that(GameState.current_zone_id).is_equal("")
|
||||
|
||||
|
||||
# -- insert_active (OQ-07, #522) -----------------------------------------
|
||||
|
||||
func test_apply_snapshot_insert_active_false() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "insert_active": false})
|
||||
assert_bool(GameState.insert_active).is_false()
|
||||
|
||||
|
||||
func test_apply_snapshot_insert_active_defaults_true_when_absent() -> void:
|
||||
GameState.insert_active = false
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_bool(GameState.insert_active).is_true()
|
||||
@@ -0,0 +1,164 @@
|
||||
## Sprint 20 #557: GameState.apply_snapshot() refactor tests.
|
||||
##
|
||||
## Verifies D-020 compliance: apply_snapshot() reads server-authoritative values
|
||||
## for stationary_ticks and zone_id directly from the snapshot when present,
|
||||
## and degrades gracefully when the server has not yet added these fields.
|
||||
##
|
||||
## Does NOT replace test_game_state.gd or test_snapshot_zone_id.gd — those cover
|
||||
## existing behaviour. This file covers the new code paths added in Sprint 20.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestGameStateSprint20
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
GameState.current_zone_id = ""
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_tiles = []
|
||||
GameState.visible_positions = {}
|
||||
GameState.visibility_sectors = {}
|
||||
|
||||
|
||||
# -- stationary_ticks: server-authoritative path (D-020) ----------------------
|
||||
|
||||
func test_stationary_ticks_reads_server_value_when_present() -> void:
|
||||
# When snapshot includes stationary_ticks, apply_snapshot() must use the
|
||||
# server value directly without client-side accumulation (D-020).
|
||||
var snapshot := {
|
||||
"tick": 5,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"stationary_ticks": 42,
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_int(GameState.stationary_ticks).override_failure_message(
|
||||
"apply_snapshot() must read stationary_ticks=42 from snapshot (D-020)"
|
||||
).is_equal(42)
|
||||
|
||||
|
||||
func test_stationary_ticks_server_value_does_not_accumulate() -> void:
|
||||
# Server-sent value must be assigned directly — NOT added to existing value.
|
||||
# Two calls with stationary_ticks=10 must yield 10, not 20.
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"stationary_ticks": 10,
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_int(GameState.stationary_ticks).override_failure_message(
|
||||
"Server value must be assigned directly, not accumulated (D-020)"
|
||||
).is_equal(10)
|
||||
|
||||
|
||||
func test_stationary_ticks_server_can_reset_to_zero() -> void:
|
||||
# Server sends 0 when player moves — client must accept this reset.
|
||||
GameState.stationary_ticks = 50
|
||||
var snapshot := {
|
||||
"tick": 2,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"stationary_ticks": 0,
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_int(GameState.stationary_ticks).override_failure_message(
|
||||
"Server reset to 0 must override client-held value"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
# -- stationary_ticks: DEPRECATED fallback (graceful degradation) --------------
|
||||
|
||||
func test_stationary_ticks_fallback_when_field_absent() -> void:
|
||||
# When snapshot has no stationary_ticks, client-side accumulation must still
|
||||
# run (backward compat until server ships field). No crash.
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
}
|
||||
GameState.apply_snapshot(snapshot) # position changes from ZERO → resets to 0
|
||||
GameState.apply_snapshot(snapshot) # position unchanged → increments
|
||||
assert_int(GameState.stationary_ticks).override_failure_message(
|
||||
"Client-side fallback must increment stationary_ticks when field absent"
|
||||
).is_greater(0)
|
||||
|
||||
|
||||
func test_apply_snapshot_missing_stationary_ticks_no_crash() -> void:
|
||||
# Snapshots lacking stationary_ticks must not crash apply_snapshot().
|
||||
var snapshot := {"tick": 1, "entities": []}
|
||||
# No assertion needed beyond confirming no exception is raised.
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_bool(true).is_true()
|
||||
|
||||
|
||||
# -- zone_id: server-authoritative path (D-020/D-073) -------------------------
|
||||
|
||||
func test_current_zone_id_reads_server_value_when_present() -> void:
|
||||
# When snapshot includes top-level zone_id, apply_snapshot() must use it
|
||||
# directly without tile lookup (D-020).
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"zone_id": "zone_server_direct",
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_str(GameState.current_zone_id).override_failure_message(
|
||||
"apply_snapshot() must read zone_id='zone_server_direct' from snapshot (D-020)"
|
||||
).is_equal("zone_server_direct")
|
||||
|
||||
|
||||
func test_current_zone_id_server_value_overrides_tile_data() -> void:
|
||||
# When snapshot has both zone_id and visible_tiles with a different zone,
|
||||
# the top-level zone_id field takes priority.
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"zone_id": "zone_from_server",
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_from_tile"},
|
||||
],
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_str(GameState.current_zone_id).override_failure_message(
|
||||
"Top-level zone_id must override tile-derived zone when both present"
|
||||
).is_equal("zone_from_server")
|
||||
|
||||
|
||||
# -- zone_id: DEPRECATED fallback (graceful degradation) ----------------------
|
||||
|
||||
func test_current_zone_id_fallback_to_tile_lookup_when_absent() -> void:
|
||||
# When snapshot lacks top-level zone_id, tile lookup fallback must run.
|
||||
# Existing test_snapshot_zone_id.gd covers detailed scenarios; this is a
|
||||
# smoke test confirming the fallback path still works after Sprint 20 refactor.
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 3, "y": 3, "z": 0, "type": "floor", "zone_id": "zone_tile_fallback"},
|
||||
],
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_str(GameState.current_zone_id).override_failure_message(
|
||||
"Tile lookup fallback must populate zone_id when top-level field absent"
|
||||
).is_equal("zone_tile_fallback")
|
||||
|
||||
|
||||
func test_apply_snapshot_missing_zone_id_no_crash() -> void:
|
||||
# Snapshots lacking both zone_id and visible_tiles must not crash.
|
||||
var snapshot := {"tick": 1, "entities": []}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_str(GameState.current_zone_id).is_equal("")
|
||||
@@ -0,0 +1,182 @@
|
||||
## D-030 Layer 1: Cross-language IPC fixture tests (#271)
|
||||
## Validates that Protocol.gd decodes the #271 named fixtures identically to Rust.
|
||||
## Fixtures generated by: cargo test --test gen_fixtures -- --ignored
|
||||
## Rust validation: server/tests/serialization.rs (fixture_* tests)
|
||||
class_name TestIpcFixtures
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const FIXTURE_DIR = "res://tests/fixtures/msgpack/"
|
||||
|
||||
|
||||
func _load_fixture(name: String) -> PackedByteArray:
|
||||
var path = FIXTURE_DIR + name + ".msgpack"
|
||||
var file = FileAccess.open(path, FileAccess.READ)
|
||||
assert_that(file).is_not_null().override_failure_message(
|
||||
"Fixture not found: %s — run 'make fixtures' to regenerate" % path
|
||||
)
|
||||
return file.get_buffer(file.get_length())
|
||||
|
||||
|
||||
# -- snapshot_minimal ----------------------------------------------------------
|
||||
|
||||
func test_fixture_snapshot_minimal_version() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||
|
||||
|
||||
func test_fixture_snapshot_minimal_tick() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.tick).is_equal(0)
|
||||
|
||||
|
||||
func test_fixture_snapshot_minimal_entity_count() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.entities.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_fixture_snapshot_minimal_entity_kind() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
var entity = snapshot.entities[0]
|
||||
assert_that(entity.entity_id).is_equal(1)
|
||||
# entity.kind is {"variant": "Player", "data": null} from _decode_enum_variant
|
||||
assert_that(entity.kind.variant).is_equal("Player")
|
||||
|
||||
|
||||
func test_fixture_snapshot_minimal_no_monologue() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.current_monologue).is_null()
|
||||
|
||||
|
||||
func test_fixture_snapshot_minimal_no_dialogue() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.dialogue_response).is_null()
|
||||
|
||||
|
||||
# -- snapshot_full -------------------------------------------------------------
|
||||
|
||||
func test_fixture_snapshot_full_tick() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.tick).is_equal(42)
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_monologue_id() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.current_monologue).is_not_null()
|
||||
assert_that(snapshot.current_monologue.id).is_equal("test_monologue_001")
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_monologue_text() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.current_monologue.text).is_equal("Something feels off about this place.")
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_dialogue_speaker() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.dialogue_response).is_not_null()
|
||||
# dialogue_response has: line_id, text, speaker_entity_id (per protocol.gd v8 decode)
|
||||
assert_that(snapshot.dialogue_response.speaker_entity_id).is_equal(99)
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_inventory() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.player_inventory.size()).is_equal(1)
|
||||
assert_that(snapshot.player_inventory[0].name).is_equal("Forged Customs Cert")
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_poi_list() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.poi_list.size()).is_equal(1)
|
||||
assert_that(snapshot.poi_list[0].poi_id).is_equal("docking_bay_7")
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_player_knowledge_entity() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.player_knowledge).is_not_null()
|
||||
assert_that(snapshot.player_knowledge.entities.size()).is_equal(1)
|
||||
assert_that(snapshot.player_knowledge.entities[0].name).is_equal("Kael")
|
||||
|
||||
|
||||
func test_fixture_snapshot_full_player_knowledge_fact() -> void:
|
||||
var bytes = _load_fixture("snapshot_full")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot.player_knowledge.facts.size()).is_equal(1)
|
||||
assert_that(snapshot.player_knowledge.facts[0].fact_id).is_equal("poi.docking_bay_7")
|
||||
|
||||
|
||||
# -- player_input_move ---------------------------------------------------------
|
||||
|
||||
func test_fixture_player_input_move_tick() -> void:
|
||||
var bytes = _load_fixture("player_input_move")
|
||||
var input = Protocol.decode_player_input(bytes)
|
||||
assert_that(input).is_not_null()
|
||||
assert_that(input.tick).is_equal(1)
|
||||
|
||||
|
||||
func test_fixture_player_input_move_action() -> void:
|
||||
var bytes = _load_fixture("player_input_move")
|
||||
var input = Protocol.decode_player_input(bytes)
|
||||
# action is {"variant": "MoveNorth", "data": null} from _decode_enum_variant
|
||||
assert_that(input.action.variant).is_equal("MoveNorth")
|
||||
|
||||
|
||||
# -- player_input_interact -----------------------------------------------------
|
||||
|
||||
func test_fixture_player_input_interact_tick() -> void:
|
||||
var bytes = _load_fixture("player_input_interact")
|
||||
var input = Protocol.decode_player_input(bytes)
|
||||
assert_that(input).is_not_null()
|
||||
assert_that(input.tick).is_equal(2)
|
||||
|
||||
|
||||
func test_fixture_player_input_interact_action() -> void:
|
||||
var bytes = _load_fixture("player_input_interact")
|
||||
var input = Protocol.decode_player_input(bytes)
|
||||
# Interact is a struct variant: {"variant": "Interact", "data": {"target_entity_id": 99, "verb": "Talk"}}
|
||||
assert_that(input.action.variant).is_equal("Interact")
|
||||
|
||||
|
||||
func test_fixture_player_input_interact_target() -> void:
|
||||
var bytes = _load_fixture("player_input_interact")
|
||||
var input = Protocol.decode_player_input(bytes)
|
||||
assert_that(input.action.data.target_entity_id).is_equal(99)
|
||||
|
||||
|
||||
func test_fixture_player_input_interact_verb() -> void:
|
||||
var bytes = _load_fixture("player_input_interact")
|
||||
var input = Protocol.decode_player_input(bytes)
|
||||
assert_that(input.action.data.verb).is_equal("Talk")
|
||||
|
||||
|
||||
# -- malformed -----------------------------------------------------------------
|
||||
|
||||
func test_fixture_malformed_snapshot_fails() -> void:
|
||||
var bytes = _load_fixture("malformed")
|
||||
# Intentionally truncated — decode_snapshot must return null (not crash)
|
||||
var result = Protocol.decode_snapshot(bytes)
|
||||
assert_that(result).is_null().override_failure_message(
|
||||
"malformed fixture should not decode as a valid ObserverSnapshot"
|
||||
)
|
||||
|
||||
|
||||
func test_fixture_malformed_input_fails() -> void:
|
||||
var bytes = _load_fixture("malformed")
|
||||
# Intentionally truncated — decode_player_input must return null (not crash)
|
||||
var result = Protocol.decode_player_input(bytes)
|
||||
assert_that(result).is_null().override_failure_message(
|
||||
"malformed fixture should not decode as a valid PlayerInput"
|
||||
)
|
||||
@@ -0,0 +1,601 @@
|
||||
## Sprint 18 — Knowledge/journal display (#264)
|
||||
## Spec refs: D-041 (knowledge graph data model), D-027 (vertical slice — KG display),
|
||||
## D-042 (UIStrings for all labels)
|
||||
##
|
||||
## Tests now run against live Stig implementation.
|
||||
## Wire format per game_state.gd v14:
|
||||
## player_knowledge: {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]}
|
||||
##
|
||||
## NOTE: player_knowledge PERSISTS between snapshots (no-clear behavior, by design).
|
||||
## The server sends KG updates only when the graph changes — absence = no change.
|
||||
## Contrast with current_examine_result which DOES clear each snapshot.
|
||||
class_name TestJournalSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const JOURNAL_SCENE_PATH: String = "res://ui/journal_panel.tscn"
|
||||
|
||||
func _make_journal_panel() -> Control:
|
||||
if not ResourceLoader.exists(JOURNAL_SCENE_PATH):
|
||||
push_warning("TestJournalSprint18: journal_panel.tscn not found — scene tests skipped")
|
||||
return null
|
||||
var node: Control = load(JOURNAL_SCENE_PATH).instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_kg_entity(overrides: Dictionary = {}) -> Dictionary:
|
||||
## Wire format per game_state.gd v14 / Stig's Stig confirmation (2026-02-25).
|
||||
## entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]
|
||||
var base: Dictionary = {
|
||||
"entity_id": 42,
|
||||
"name": "Kael Davan",
|
||||
"confidence": "KnowsOf",
|
||||
"source": "DirectObservation",
|
||||
"state": "Active",
|
||||
"relationship": "PersonOfInterest",
|
||||
"last_observed_tick": 1024,
|
||||
}
|
||||
base.merge(overrides, true)
|
||||
return base
|
||||
|
||||
|
||||
func _make_player_knowledge(entities: Array = []) -> Dictionary:
|
||||
if entities.is_empty():
|
||||
entities = [_make_kg_entity()]
|
||||
return {"entities": entities}
|
||||
|
||||
|
||||
func _entries_container(panel: Control) -> Node:
|
||||
return panel.get_node_or_null(
|
||||
"PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.player_knowledge = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
GameState.current_tick = 0
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.player_knowledge = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: player_knowledge snapshot parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_player_knowledge_field_exists() -> void:
|
||||
## GameState must have player_knowledge field (v14, #264).
|
||||
assert_bool(GameState.has("player_knowledge")).override_failure_message(
|
||||
"GameState must have 'player_knowledge' field (Sprint 18 #264)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_null_by_default() -> void:
|
||||
GameState.player_knowledge = null
|
||||
assert_that(GameState.player_knowledge).is_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_set_from_snapshot() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 10,
|
||||
"player_knowledge": _make_player_knowledge(),
|
||||
})
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
assert_bool(GameState.player_knowledge.has("entities")).is_true()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_persists_when_absent() -> void:
|
||||
## IMPORTANT: player_knowledge does NOT clear when absent from snapshot.
|
||||
## Server sends KG updates only on change — absence means "no change since last tick".
|
||||
## This is intentional behavior (journal should not flash empty every tick).
|
||||
GameState.player_knowledge = _make_player_knowledge()
|
||||
GameState.apply_snapshot({"tick": 11})
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_null_when_non_dict() -> void:
|
||||
## Malformed player_knowledge (non-dict) must be rejected.
|
||||
## First set a valid value, then try to overwrite with invalid
|
||||
GameState.player_knowledge = _make_player_knowledge()
|
||||
GameState.apply_snapshot({"tick": 1, "player_knowledge": "bad-value"})
|
||||
# Non-dict is rejected — previous value preserved (or null if first time)
|
||||
# The implementation only updates on Dictionary type, so value persists
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_entities_survive_roundtrip() -> void:
|
||||
var entities := [
|
||||
_make_kg_entity({"name": "Kael Davan", "state": "Active"}),
|
||||
_make_kg_entity({"name": "Lysa Orin", "state": "Contradicted", "entity_id": 55}),
|
||||
]
|
||||
GameState.apply_snapshot({"tick": 5, "player_knowledge": {"entities": entities}})
|
||||
var parsed_entities: Array = GameState.player_knowledge.get("entities", [])
|
||||
assert_int(parsed_entities.size()).is_equal(2)
|
||||
assert_that(parsed_entities[0].get("name")).is_equal("Kael Davan")
|
||||
assert_that(parsed_entities[1].get("state")).is_equal("Contradicted")
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_updated_when_new_data_arrives() -> void:
|
||||
## When server sends a new player_knowledge, it replaces the previous value.
|
||||
GameState.apply_snapshot({"tick": 1, "player_knowledge": _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Person A"}),
|
||||
])})
|
||||
GameState.apply_snapshot({"tick": 2, "player_knowledge": _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Person A"}),
|
||||
_make_kg_entity({"name": "Person B", "entity_id": 99}),
|
||||
])})
|
||||
var entities: Array = GameState.player_knowledge.get("entities", [])
|
||||
assert_int(entities.size()).is_equal(2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: scene and API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_journal_panel_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(JOURNAL_SCENE_PATH)).override_failure_message(
|
||||
"Journal panel scene must exist at res://ui/journal_panel.tscn"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_journal_panel_instantiates_without_crash() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_that(panel).is_not_null()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_toggle_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("toggle")).override_failure_message(
|
||||
"JournalPanel must have toggle() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_close_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("close")).override_failure_message(
|
||||
"JournalPanel must have close() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_is_open_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("is_open")).override_failure_message(
|
||||
"JournalPanel must have is_open() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_update_from_state_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("update_from_state")).override_failure_message(
|
||||
"JournalPanel must have update_from_state() method (called from main.gd)"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_closed_on_init() -> void:
|
||||
## Panel starts hidden — not open by default.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"JournalPanel must be closed on _ready()"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_toggle_opens() -> void:
|
||||
## First toggle() opens the panel.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.toggle()
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"toggle() must set is_open() = true"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_toggle_closes() -> void:
|
||||
## Second toggle() closes the panel.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.toggle() # open
|
||||
panel.toggle() # close
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Second toggle() must close the panel"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_close_when_already_closed_is_safe() -> void:
|
||||
## close() on an already-closed panel must not crash.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.close()
|
||||
assert_bool(panel.is_open()).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: entry rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_journal_panel_entries_container_exists() -> void:
|
||||
## EntriesContainer is the VBoxContainer that holds entity entries.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var container := _entries_container(panel)
|
||||
assert_that(container != null).override_failure_message(
|
||||
"EntriesContainer must exist at PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_shows_entries_when_knowledge_populated() -> void:
|
||||
## Opening panel with player_knowledge set creates entry nodes in EntriesContainer.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Kael Davan"}),
|
||||
])
|
||||
panel.toggle() # calls _show_panel() -> _rebuild_entries()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"EntriesContainer must have children when player_knowledge is populated"
|
||||
).is_greater(0)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_shows_empty_state_when_no_knowledge() -> void:
|
||||
## Empty state Label is shown when player_knowledge is null.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = null
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## Empty state = exactly 1 child (the "Nothing logged yet." label)
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"EntriesContainer should have 1 child (empty state label) when knowledge is null"
|
||||
).is_equal(1)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_two_entities_create_more_entries() -> void:
|
||||
## Two entities create more entries than one (header + detail each, plus spacers).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Entity A", "entity_id": 1}),
|
||||
_make_kg_entity({"name": "Entity B", "entity_id": 2}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## Each entity: header_rtl + detail_rtl + spacer = 3 nodes. Two entities = 6 min.
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"Two entities must create at least 6 child nodes (2 × [header + detail + spacer])"
|
||||
).is_greater_equal(6)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_contradicted_entity_uses_strikethrough() -> void:
|
||||
## D-041: Contradicted entities must have strikethrough in their header BBCode.
|
||||
## journal_panel.gd renders [s]Name[/s] for Contradicted state.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Bad Guy", "state": "Contradicted"}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## First child should be the header RichTextLabel with [s]...[/s]
|
||||
if container.get_child_count() == 0:
|
||||
push_warning("test_journal_panel_contradicted_entity_uses_strikethrough: no entries — skip")
|
||||
panel.queue_free(); return
|
||||
|
||||
var first_child := container.get_child(0)
|
||||
if first_child is RichTextLabel:
|
||||
assert_that(first_child.text).override_failure_message(
|
||||
"Contradicted entity header must contain [s] (strikethrough) BBCode"
|
||||
).contains("[s]")
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_active_entity_no_strikethrough() -> void:
|
||||
## Active entity must NOT have strikethrough in its header.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Good Guy", "state": "Active"}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
if container.get_child_count() == 0:
|
||||
push_warning("test_journal_panel_active_entity_no_strikethrough: no entries — skip")
|
||||
panel.queue_free(); return
|
||||
|
||||
var first_child := container.get_child(0)
|
||||
if first_child is RichTextLabel:
|
||||
assert_bool(first_child.text.contains("[s]")).override_failure_message(
|
||||
"Active entity header must NOT have strikethrough — only Contradicted gets [s]"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: mutual exclusion with dialogue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_closes_journal_when_dialogue_active() -> void:
|
||||
## Sprint briefing: journal must close when dialogue opens.
|
||||
## update_from_state() is called from main.gd on each snapshot.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
panel.toggle() # open journal
|
||||
assert_bool(panel.is_open()).is_true()
|
||||
|
||||
GameState.dialogue_active = true
|
||||
panel.update_from_state()
|
||||
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Journal must close when GameState.dialogue_active = true (update_from_state() called)"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_close_when_dialogue_inactive() -> void:
|
||||
## update_from_state() must NOT close journal when dialogue is not active.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
panel.toggle() # open journal
|
||||
GameState.dialogue_active = false
|
||||
panel.update_from_state()
|
||||
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Journal must stay open when dialogue is inactive"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UIStrings: confidence and source label keys (D-042 — now via UIStrings)
|
||||
## CONFIDENCE_LABELS and SOURCE_LABELS dicts were removed from journal_panel.gd.
|
||||
## Labels now come from UIStrings: knowledge_panel.confidence_* / knowledge_panel.source_*
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_ui_strings_confidence_direct_exists() -> void:
|
||||
## D-042: confidence label for "Direct" tier must be in UIStrings.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_direct")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_direct' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_knowsdetails_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsdetails")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_knowsdetails' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_knowsof_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsof")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_knowsof' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_suspects_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_suspects")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_suspects' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_all_confidence_keys_non_empty() -> void:
|
||||
## All four confidence label values must be non-empty strings.
|
||||
var keys := [
|
||||
"knowledge_panel.confidence_direct",
|
||||
"knowledge_panel.confidence_knowsdetails",
|
||||
"knowledge_panel.confidence_knowsof",
|
||||
"knowledge_panel.confidence_suspects",
|
||||
]
|
||||
for key in keys:
|
||||
if not UIStrings.has_key(key): continue
|
||||
assert_bool(UIStrings.get_text(key).length() > 0).override_failure_message(
|
||||
"UIStrings key '%s' must be non-empty" % key
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_directobservation_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_directobservation")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_directobservation' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_toldby_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_toldby")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_toldby' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_heard_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_heard")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_heard' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: _state_color() contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_state_color_contradicted_is_amber() -> void:
|
||||
## D-041: Contradicted → amber tint (ENTITY_COLOR_POI) — THE FRIEND arc surface.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Contradicted")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Contradicted') must return ENTITY_COLOR_POI (amber)"
|
||||
).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_stale_is_dimmed() -> void:
|
||||
## Stale → dimmed text color (IMPLANT_TEXT_DIM).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Stale")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Stale') must return IMPLANT_TEXT_DIM"
|
||||
).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_active_is_normal() -> void:
|
||||
## Active → normal insert text color (INSERT_COLOR_TEXT).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Active")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Active') must return INSERT_COLOR_TEXT"
|
||||
).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_contradicted_differs_from_active() -> void:
|
||||
## Contradicted and Active must have visually distinct colors.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var contradicted := panel._state_color("Contradicted")
|
||||
var active := panel._state_color("Active")
|
||||
assert_that(contradicted).is_not_equal(active)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_stale_differs_from_active() -> void:
|
||||
## Stale and Active must have visually distinct colors.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var stale := panel._state_color("Stale")
|
||||
var active := panel._state_color("Active")
|
||||
assert_that(stale).is_not_equal(active)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UIStrings: knowledge_panel keys (D-042)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_ui_strings_knowledge_panel_tab_contacts_exists() -> void:
|
||||
## Journal title uses knowledge_panel.tab_contacts.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.tab_contacts")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.tab_contacts' key (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_empty_state_exists() -> void:
|
||||
## Empty state message uses knowledge_panel.empty_state.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.empty_state")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.empty_state' key (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_empty_state_non_empty() -> void:
|
||||
if not UIStrings.has_key("knowledge_panel.empty_state"): return
|
||||
assert_bool(UIStrings.get_text("knowledge_panel.empty_state").length() > 0).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_tab_contacts_non_empty() -> void:
|
||||
if not UIStrings.has_key("knowledge_panel.tab_contacts"): return
|
||||
assert_bool(UIStrings.get_text("knowledge_panel.tab_contacts").length() > 0).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-042: CONFIDENCE_LABELS/SOURCE_LABELS now via UIStrings — FIXED (2026-02-25)
|
||||
## Previously filed as a gap: journal_panel.gd had hardcoded CONFIDENCE_LABELS dict.
|
||||
## Fixed by Stig: dicts removed, all labels now use UIStrings.get_text("knowledge_panel.*").
|
||||
## Regression guard: verify the dicts are gone and UIStrings fallback works.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d042_fixed_panel_has_no_confidence_labels_dict() -> void:
|
||||
## Regression: CONFIDENCE_LABELS dict must NOT exist on journal_panel — it was removed.
|
||||
## If this test fails, the hardcoded dict was accidentally re-introduced.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.get("CONFIDENCE_LABELS") == null).override_failure_message(
|
||||
"D-042 regression: CONFIDENCE_LABELS dict must be removed from journal_panel.gd"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_d042_fixed_panel_has_no_source_labels_dict() -> void:
|
||||
## Regression: SOURCE_LABELS dict must NOT exist on journal_panel — it was removed.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.get("SOURCE_LABELS") == null).override_failure_message(
|
||||
"D-042 regression: SOURCE_LABELS dict must be removed from journal_panel.gd"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_d042_uistrings_fallback_for_unknown_confidence() -> void:
|
||||
## UIStrings falls back to the key string itself for missing keys.
|
||||
## journal_panel.gd relies on this for graceful degradation.
|
||||
var fallback := UIStrings.get_text("knowledge_panel.confidence_nonexistent_level")
|
||||
assert_that(fallback).override_failure_message(
|
||||
"UIStrings fallback must return the key string itself for unknown keys"
|
||||
).is_equal("knowledge_panel.confidence_nonexistent_level")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_canvas_insert_constant_is_10() -> void:
|
||||
assert_int(Constants.CANVAS_INSERT).is_equal(10)
|
||||
|
||||
|
||||
func test_journal_fade_constants_reasonable() -> void:
|
||||
## FADE_IN and FADE_OUT must be short (< 0.5s) for responsive UI.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_float(panel.FADE_IN).is_between(0.0, 0.5)
|
||||
assert_float(panel.FADE_OUT).is_between(0.0, 0.5)
|
||||
panel.queue_free()
|
||||
@@ -0,0 +1,320 @@
|
||||
## Sprint 18 — Minimap rendering (#151)
|
||||
## Spec refs: D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered),
|
||||
## D-049 (z-layer 6 = InsertOverlay)
|
||||
##
|
||||
## MinimapRenderer: circular insert overlay, always renders frame, draws discovered POIs.
|
||||
## Scene: res://ui/minimap.tscn (class_name MinimapRenderer)
|
||||
## Positioned at InsertOverlay/Minimap in main.tscn.
|
||||
##
|
||||
## Tests run against live Stig implementation (minimap.gd).
|
||||
class_name TestMinimapSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn"
|
||||
|
||||
func _make_minimap() -> Control:
|
||||
if not ResourceLoader.exists(MINIMAP_SCENE_PATH):
|
||||
push_warning("TestMinimapSprint18: minimap.tscn not found — skip")
|
||||
return null
|
||||
var node: Control = load(MINIMAP_SCENE_PATH).instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_poi(overrides: Dictionary = {}) -> Dictionary:
|
||||
var base: Dictionary = {
|
||||
"id": "poi_test_001",
|
||||
"x": 20,
|
||||
"y": 15,
|
||||
"poi_category": "location",
|
||||
"label": "Exit A",
|
||||
}
|
||||
base.merge(overrides, true)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.discovered_pois = []
|
||||
GameState.player_position = Vector2(10.0, 10.0)
|
||||
GameState.insert_active = true
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.discovered_pois = []
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.insert_active = true
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scene and class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(MINIMAP_SCENE_PATH)).override_failure_message(
|
||||
"Minimap scene must exist at res://ui/minimap.tscn (#151)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_minimap_instantiates_without_crash() -> void:
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
assert_that(mm).is_not_null()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
func test_minimap_is_minimap_renderer_class() -> void:
|
||||
## class_name MinimapRenderer in minimap.gd.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
assert_bool(mm is MinimapRenderer).override_failure_message(
|
||||
"Minimap node must be a MinimapRenderer instance (check class_name in minimap.gd)"
|
||||
).is_true()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants: D-015, visual parameters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_radius_constant() -> void:
|
||||
## MINIMAP_RADIUS defines the sim-tile distance of visible POI area.
|
||||
## Value is tuned to 24 tiles — reasonable coverage without map reveal.
|
||||
assert_float(MinimapRenderer.MINIMAP_RADIUS).override_failure_message(
|
||||
"MinimapRenderer.MINIMAP_RADIUS must be 24.0"
|
||||
).is_equal_approx(24.0, 0.01)
|
||||
|
||||
|
||||
func test_player_dot_radius_defined() -> void:
|
||||
## Player dot must be visible (> 0) and distinct from POI dot.
|
||||
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater(0.0)
|
||||
|
||||
|
||||
func test_poi_dot_radius_defined() -> void:
|
||||
## POI dot must be visible (> 0).
|
||||
assert_float(MinimapRenderer.POI_DOT_RADIUS).is_greater(0.0)
|
||||
|
||||
|
||||
func test_player_dot_larger_than_poi_dot() -> void:
|
||||
## D-015: Player is always centered and visually distinct.
|
||||
## Player dot should be at least as large as POI dot.
|
||||
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater_equal(MinimapRenderer.POI_DOT_RADIUS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _category_color() — D-013 POI category color mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_category_color_danger_is_hostile_color() -> void:
|
||||
## "danger", "threat", "hostile" → ENTITY_COLOR_HOSTILE (red)
|
||||
for cat in ["danger", "threat", "hostile"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_HOSTILE" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
|
||||
|
||||
func test_category_color_evidence_is_poi_color() -> void:
|
||||
## "evidence", "note", "clue" → ENTITY_COLOR_POI (amber)
|
||||
for cat in ["evidence", "note", "clue"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_POI (amber)" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
|
||||
|
||||
func test_category_color_contact_is_unknown_color() -> void:
|
||||
## "contact", "npc", "person" → ENTITY_COLOR_UNKNOWN (teal)
|
||||
for cat in ["contact", "npc", "person"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_UNKNOWN (teal)" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
|
||||
func test_category_color_unknown_category_defaults_to_insert_text() -> void:
|
||||
## Unknown/unspecified categories → INSERT_COLOR_TEXT (white-blue default)
|
||||
var color: Color = MinimapRenderer._category_color("some_unknown_type")
|
||||
assert_that(color).override_failure_message(
|
||||
"Unknown category must default to INSERT_COLOR_TEXT"
|
||||
).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
|
||||
func test_category_color_empty_string_defaults() -> void:
|
||||
## Empty category string → default color, no crash.
|
||||
var color: Color = MinimapRenderer._category_color("")
|
||||
assert_that(color).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
|
||||
func test_category_color_case_insensitive() -> void:
|
||||
## Category matching is case-insensitive (uses to_lower()).
|
||||
var danger_lower := MinimapRenderer._category_color("danger")
|
||||
var danger_upper := MinimapRenderer._category_color("DANGER")
|
||||
var danger_mixed := MinimapRenderer._category_color("Danger")
|
||||
assert_that(danger_lower).is_equal(danger_upper)
|
||||
assert_that(danger_lower).is_equal(danger_mixed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_insert_active() — D-049: insert layer visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_set_insert_active_false_hides_minimap() -> void:
|
||||
## When insert is inactive, minimap must be hidden.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
mm.set_insert_active(false)
|
||||
assert_bool(mm.visible).override_failure_message(
|
||||
"set_insert_active(false) must hide the minimap"
|
||||
).is_false()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
func test_set_insert_active_true_shows_minimap() -> void:
|
||||
## When insert is active, minimap must be visible.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
mm.set_insert_active(false)
|
||||
mm.set_insert_active(true)
|
||||
assert_bool(mm.visible).override_failure_message(
|
||||
"set_insert_active(true) must show the minimap"
|
||||
).is_true()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main scene structural check: InsertOverlay/Minimap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_in_main_scene_on_insert_overlay() -> void:
|
||||
## D-049: Minimap must be in InsertOverlay (CanvasLayer 10), not UILayer.
|
||||
## Scene path: Game/InsertOverlay/Minimap or InsertOverlay/Minimap.
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
# Check for Minimap in InsertOverlay
|
||||
var insert_overlay := scene.get_node_or_null("InsertOverlay")
|
||||
assert_that(insert_overlay != null).override_failure_message(
|
||||
"InsertOverlay (CanvasLayer 10) must exist in main.tscn"
|
||||
).is_true()
|
||||
if insert_overlay == null: return
|
||||
|
||||
var minimap := insert_overlay.get_node_or_null("Minimap")
|
||||
assert_that(minimap != null).override_failure_message(
|
||||
"Minimap must be a child of InsertOverlay in main.tscn (D-049: insert layer)"
|
||||
).is_true()
|
||||
if minimap == null: return
|
||||
|
||||
assert_bool(minimap is MinimapRenderer).override_failure_message(
|
||||
"InsertOverlay/Minimap must be a MinimapRenderer instance"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||
## InsertOverlay must be CanvasLayer 10 (CANVAS_INSERT per D-049).
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
var insert_overlay := scene.get_node_or_null("InsertOverlay") as CanvasLayer
|
||||
if insert_overlay == null: return
|
||||
assert_int(insert_overlay.layer).override_failure_message(
|
||||
"InsertOverlay must be CanvasLayer %d (CANVAS_INSERT)" % Constants.CANVAS_INSERT
|
||||
).is_equal(Constants.CANVAS_INSERT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState.discovered_pois integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_discovered_pois_field_exists_in_gamestate() -> void:
|
||||
assert_bool(GameState.has("discovered_pois")).override_failure_message(
|
||||
"GameState must have 'discovered_pois' field (#151)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_discovered_pois_set_from_poi_list_snapshot() -> void:
|
||||
## Snapshot with "poi_list" key (Sprint 17 server wire name) populates discovered_pois.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [
|
||||
_make_poi({"id": "p1", "x": 50, "y": 30, "poi_category": "location"}),
|
||||
_make_poi({"id": "p2", "x": 80, "y": 15, "poi_category": "contact"}),
|
||||
],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).override_failure_message(
|
||||
"discovered_pois must be populated from snapshot 'poi_list' field"
|
||||
).is_equal(2)
|
||||
|
||||
|
||||
func test_discovered_pois_set_from_discovered_pois_snapshot() -> void:
|
||||
## Snapshot with "discovered_pois" key also works.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"discovered_pois": [_make_poi()],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_discovered_pois_persists_when_absent_from_snapshot() -> void:
|
||||
## Like player_knowledge: POI list persists when server doesn't send an update.
|
||||
GameState.discovered_pois = [_make_poi()]
|
||||
GameState.apply_snapshot({"tick": 3})
|
||||
assert_int(GameState.discovered_pois.size()).override_failure_message(
|
||||
"discovered_pois must persist when absent from snapshot (not cleared each tick)"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_discovered_pois_poi_category_field_present() -> void:
|
||||
## MinimapRenderer reads poi_category to determine shape/color.
|
||||
## Verify the wire format includes this field.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [_make_poi({"poi_category": "danger"})],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_greater(0)
|
||||
var first_poi: Dictionary = GameState.discovered_pois[0]
|
||||
assert_bool(first_poi.has("poi_category")).override_failure_message(
|
||||
"POI entries must have 'poi_category' field for MinimapRenderer shape selection"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_discovered_pois_x_y_fields_present() -> void:
|
||||
## MinimapRenderer reads x, y for position calculation.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [_make_poi({"x": 42, "y": 17})],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_greater(0)
|
||||
var first_poi: Dictionary = GameState.discovered_pois[0]
|
||||
assert_bool(first_poi.has("x") and first_poi.has("y")).override_failure_message(
|
||||
"POI entries must have 'x' and 'y' coordinate fields"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Color constants: all distinct
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_category_colors_are_distinct() -> void:
|
||||
## All three primary category color groups must be visually distinct.
|
||||
var danger_color := MinimapRenderer._category_color("danger")
|
||||
var evidence_color := MinimapRenderer._category_color("evidence")
|
||||
var contact_color := MinimapRenderer._category_color("contact")
|
||||
assert_that(danger_color).is_not_equal(evidence_color)
|
||||
assert_that(evidence_color).is_not_equal(contact_color)
|
||||
assert_that(danger_color).is_not_equal(contact_color)
|
||||
@@ -0,0 +1,208 @@
|
||||
## Sprint 19 — Game session management (#258, D-085)
|
||||
## Per-game save directories: created on New Game, resumed via game-id.
|
||||
## SessionManager autoload: new_game(), resume_game(), list_game_dirs().
|
||||
class_name TestSessionManagerSprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
# Game IDs created during the current test — deleted in after_test().
|
||||
var _created_ids: Array = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_game_id = ""
|
||||
_created_ids = []
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
for game_id in _created_ids:
|
||||
var path := "user://saves/" + game_id
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
_created_ids.clear()
|
||||
GameState.current_game_id = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: call new_game() and track the created directory for cleanup.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _new_game() -> String:
|
||||
var game_id := SessionManager.new_game()
|
||||
if not game_id.is_empty():
|
||||
_created_ids.append(game_id)
|
||||
return game_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState.current_game_id field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_current_game_id_field_exists() -> void:
|
||||
## D-085: GameState must have current_game_id field.
|
||||
assert_bool(GameState.has("current_game_id")).override_failure_message(
|
||||
"GameState must have 'current_game_id' field (D-085 #258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_current_game_id_default_is_empty_string() -> void:
|
||||
## Before any session starts, current_game_id is empty.
|
||||
GameState.current_game_id = ""
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"GameState.current_game_id default must be empty string"
|
||||
).is_empty()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionManager autoload exists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_session_manager_autoload_exists() -> void:
|
||||
## SessionManager must be registered as an autoload.
|
||||
var sm := Engine.get_singleton("SessionManager")
|
||||
assert_that(sm != null).override_failure_message(
|
||||
"SessionManager must be registered as autoload in project.godot (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# new_game() — game-id format and GameState update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_new_game_returns_non_empty_string() -> void:
|
||||
var game_id := _new_game()
|
||||
assert_str(game_id).override_failure_message(
|
||||
"SessionManager.new_game() must return a non-empty game-id string"
|
||||
).is_not_empty()
|
||||
|
||||
|
||||
func test_new_game_sets_current_game_id_on_gamestate() -> void:
|
||||
var game_id := _new_game()
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"new_game() must set GameState.current_game_id"
|
||||
).is_equal(game_id)
|
||||
|
||||
|
||||
func test_new_game_id_format_has_two_dashes() -> void:
|
||||
## Format: <YYYYMMDD>-<HHMMSS>-<hex6> — two separator dashes.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts.size()).override_failure_message(
|
||||
"game-id must have format <YYYYMMDD>-<HHMMSS>-<hex6> (3 parts separated by '-')"
|
||||
).is_equal(3)
|
||||
|
||||
|
||||
func test_new_game_id_first_part_is_8_digits() -> void:
|
||||
## First part is YYYYMMDD — 8 decimal digits.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[0].length()).override_failure_message(
|
||||
"game-id first part (date) must be 8 characters (YYYYMMDD)"
|
||||
).is_equal(8)
|
||||
|
||||
|
||||
func test_new_game_id_second_part_is_6_digits() -> void:
|
||||
## Second part is HHMMSS — 6 decimal digits.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[1].length()).override_failure_message(
|
||||
"game-id second part (time) must be 6 characters (HHMMSS)"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
func test_new_game_id_third_part_is_6_hex_chars() -> void:
|
||||
## Third part is 6 hex characters (RNG seed).
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[2].length()).override_failure_message(
|
||||
"game-id third part (hex seed) must be 6 characters"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
func test_new_game_ids_are_unique() -> void:
|
||||
## Two rapid new_game() calls should produce different IDs
|
||||
## (different RNG seeds; same-second timestamps are valid but seeds differ).
|
||||
var id1 := _new_game()
|
||||
var id2 := _new_game()
|
||||
# Check that hex seeds differ (they almost certainly will)
|
||||
var seed1 := id1.split("-")[2]
|
||||
var seed2 := id2.split("-")[2]
|
||||
assert_str(seed1).override_failure_message(
|
||||
"Successive new_game() calls should have different RNG seeds"
|
||||
).is_not_equal(seed2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resume_game() — sets GameState.current_game_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_resume_game_sets_current_game_id() -> void:
|
||||
var test_id := "20260225-143022-a7b3f1"
|
||||
SessionManager.resume_game(test_id)
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"resume_game() must set GameState.current_game_id to the given id"
|
||||
).is_equal(test_id)
|
||||
|
||||
|
||||
func test_resume_game_overwrites_previous_game_id() -> void:
|
||||
SessionManager.resume_game("20260225-100000-aabbcc")
|
||||
SessionManager.resume_game("20260225-120000-112233")
|
||||
assert_str(GameState.current_game_id).is_equal("20260225-120000-112233")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main menu scene
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_main_menu_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message(
|
||||
"Main menu scene must exist at res://scenes/main_menu.tscn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_main_menu_instantiates_without_crash() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
assert_that(scene).is_not_null()
|
||||
|
||||
|
||||
func test_main_menu_has_new_game_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/NewGameBtn")
|
||||
assert_that(btn != null).override_failure_message(
|
||||
"Main menu must have VBox/NewGameBtn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_main_menu_has_continue_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/ContinueBtn")
|
||||
assert_that(btn != null).override_failure_message(
|
||||
"Main menu must have VBox/ContinueBtn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project main scene changed to main_menu.tscn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_project_main_scene_is_main_menu() -> void:
|
||||
## D-085: project boots to main menu, not directly to game scene.
|
||||
var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "")
|
||||
assert_str(scene_path).override_failure_message(
|
||||
"project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)"
|
||||
).is_equal("res://scenes/main_menu.tscn")
|
||||
@@ -0,0 +1,89 @@
|
||||
## SnapshotEventRouter unit tests (#559).
|
||||
## Verifies callable-based dispatch: keyed handlers receive correct field values,
|
||||
## always handlers run on every dispatch, absent fields don't trigger handlers.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestSnapshotEventRouter
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- Keyed handlers -----------------------------------------------------------
|
||||
|
||||
func test_keyed_handler_called_when_field_present() -> void:
|
||||
var router := SnapshotEventRouter.new()
|
||||
var received: Array = []
|
||||
router.register("current_monologue", func(): received.append("monologue"))
|
||||
router.dispatch({"current_monologue": {"text": "Test."}})
|
||||
assert_that(received.size()).is_equal(1)
|
||||
assert_that(received[0]).is_equal("monologue")
|
||||
|
||||
|
||||
func test_keyed_handler_not_called_when_field_absent() -> void:
|
||||
var router := SnapshotEventRouter.new()
|
||||
var received: Array = []
|
||||
router.register("current_monologue", func(): received.append("monologue"))
|
||||
router.dispatch({"tick": 1})
|
||||
assert_that(received.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_multiple_keyed_handlers_same_field() -> void:
|
||||
## Multiple handlers on the same key run in registration order.
|
||||
var router := SnapshotEventRouter.new()
|
||||
var order: Array = []
|
||||
router.register("save_result", func(): order.append("first"))
|
||||
router.register("save_result", func(): order.append("second"))
|
||||
router.dispatch({"save_result": {"success": true}})
|
||||
assert_that(order).is_equal(["first", "second"])
|
||||
|
||||
|
||||
func test_keyed_handlers_multiple_fields() -> void:
|
||||
## Each keyed handler fires only for its registered field.
|
||||
var router := SnapshotEventRouter.new()
|
||||
var received: Array = []
|
||||
router.register("current_monologue", func(): received.append("mono"))
|
||||
router.register("current_dialogue", func(): received.append("dlg"))
|
||||
router.dispatch({"current_monologue": {"text": "Hi."}})
|
||||
assert_that(received).is_equal(["mono"])
|
||||
received.clear()
|
||||
router.dispatch({"current_dialogue": {"speech": "Hello."}, "current_monologue": {"text": "Hmm."}})
|
||||
assert_bool(received.has("mono")).is_true()
|
||||
assert_bool(received.has("dlg")).is_true()
|
||||
|
||||
|
||||
# -- Always handlers ----------------------------------------------------------
|
||||
|
||||
func test_always_handler_called_on_every_dispatch() -> void:
|
||||
var router := SnapshotEventRouter.new()
|
||||
var count: Array[int] = [0]
|
||||
router.register_always(func(): count[0] += 1)
|
||||
router.dispatch({"tick": 1})
|
||||
router.dispatch({"tick": 2})
|
||||
router.dispatch({})
|
||||
assert_int(count[0]).is_equal(3)
|
||||
|
||||
|
||||
func test_always_handler_runs_before_keyed() -> void:
|
||||
## Always handlers run before keyed handlers (dispatch order guarantee).
|
||||
var router := SnapshotEventRouter.new()
|
||||
var order: Array = []
|
||||
router.register("current_monologue", func(): order.append("keyed"))
|
||||
router.register_always(func(): order.append("always"))
|
||||
router.dispatch({"current_monologue": {"text": "Hi."}})
|
||||
assert_that(order[0]).is_equal("always")
|
||||
assert_that(order[1]).is_equal("keyed")
|
||||
|
||||
|
||||
# -- Empty dispatch ------------------------------------------------------------
|
||||
|
||||
func test_empty_snapshot_no_crash() -> void:
|
||||
var router := SnapshotEventRouter.new()
|
||||
router.register("foo", func(): pass)
|
||||
router.register_always(func(): pass)
|
||||
# Must not crash
|
||||
router.dispatch({})
|
||||
|
||||
|
||||
func test_no_handlers_no_crash() -> void:
|
||||
var router := SnapshotEventRouter.new()
|
||||
# Must not crash
|
||||
router.dispatch({"tick": 1, "entities": []})
|
||||
@@ -0,0 +1,211 @@
|
||||
## Sprint 16 #543: GameState.current_zone_id population tests.
|
||||
## Verifies that apply_snapshot() correctly extracts zone_id from the player's
|
||||
## current tile after the O(N) → O(1) refactor. Behavior must be identical
|
||||
## before and after the change (net-zero behavioral change per ticket spec).
|
||||
## Spec refs: D-073, #543, #529.
|
||||
class_name TestSnapshotZoneId
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_zone_id = ""
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_tiles = []
|
||||
GameState.visible_positions = {}
|
||||
GameState.visibility_sectors = {}
|
||||
|
||||
|
||||
# -- Happy path (S16-Z01) ------------------------------------------------------
|
||||
|
||||
func test_zone_id_populated_when_player_on_zone_tile() -> void:
|
||||
# S16-Z01: Player at (5,5), visible_tiles has zone_id "zone_alpha" at (5,5).
|
||||
# D-073: current_zone_id must be set to the server-authoritative zone_id.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 3, "y": 3, "z": 0, "type": "floor", "zone_id": "zone_foyer"},
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||
{"x": 7, "y": 7, "z": 0, "type": "floor", "zone_id": "zone_beta"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"current_zone_id must match zone_id at player tile (5,5)"
|
||||
).is_equal("zone_alpha")
|
||||
assert_that(GameState.current_zone_id.is_empty()).override_failure_message(
|
||||
"current_zone_id must be non-empty when player is on a zone-tagged tile"
|
||||
).is_false()
|
||||
|
||||
|
||||
# -- Missing zone_id field (S16-Z02) -------------------------------------------
|
||||
|
||||
func test_zone_id_empty_when_tile_lacks_zone_field() -> void:
|
||||
# S16-Z02: Player's tile exists but has no zone_id key → empty string.
|
||||
# Server may send tiles without zone_id when tile is unzoned.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"Tile without zone_id field → current_zone_id must default to empty string"
|
||||
).is_equal("")
|
||||
|
||||
|
||||
# -- Player off-tile (S16-Z03) -------------------------------------------------
|
||||
|
||||
func test_zone_id_empty_when_player_not_on_any_tile() -> void:
|
||||
# S16-Z03: Player at (99,99) but no tile at that position → empty string.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 99.0, "y": 99.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"Player at (99,99) with no matching tile → current_zone_id must be empty"
|
||||
).is_equal("")
|
||||
|
||||
|
||||
# -- Empty tiles (S16-Z04) -----------------------------------------------------
|
||||
|
||||
func test_zone_id_empty_on_empty_visible_tiles() -> void:
|
||||
# S16-Z04: No visible tiles at all → empty string, no crash.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"Empty visible_tiles → current_zone_id must be empty string (no crash)"
|
||||
).is_equal("")
|
||||
|
||||
|
||||
# -- Correct tile selected among many (S16-Z05) --------------------------------
|
||||
|
||||
func test_zone_id_selects_correct_tile_among_many() -> void:
|
||||
# S16-Z05: 10x10 tile grid, player at (6,4). Only zone_6_4 must be selected.
|
||||
# Tests that the lookup doesn't accidentally match a neighboring tile.
|
||||
var tiles: Array = []
|
||||
for tx in range(10):
|
||||
for ty in range(10):
|
||||
tiles.append({"x": tx, "y": ty, "z": 0, "type": "floor",
|
||||
"zone_id": "zone_%d_%d" % [tx, ty]})
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 6.0, "y": 4.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": tiles,
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"Player at (6,4) must get zone_6_4, not a neighboring tile"
|
||||
).is_equal("zone_6_4")
|
||||
|
||||
|
||||
# -- Zone transition (S16-Z06) -------------------------------------------------
|
||||
|
||||
func test_zone_id_updates_on_zone_transition() -> void:
|
||||
# S16-Z06: Player moves from zone_alpha (5,5) to zone_beta (6,5).
|
||||
# current_zone_id must update on each snapshot.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).is_equal("zone_alpha")
|
||||
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 6.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"After moving to (6,5), current_zone_id must update to zone_beta"
|
||||
).is_equal("zone_beta")
|
||||
|
||||
|
||||
# -- Regression: visible_positions unaffected (S16-Z07) -----------------------
|
||||
|
||||
func test_visible_positions_unaffected_by_zone_id_refactor() -> void:
|
||||
# S16-Z07: The O(1) refactor must not break visible_positions population.
|
||||
# Both zone_id and visible_positions derive from the same visible_tiles loop —
|
||||
# verify both are correctly populated after a single apply_snapshot().
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha", "visibility": "Forward"},
|
||||
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta", "visibility": "Peripheral"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message(
|
||||
"visible_positions must still contain (5,5) after zone_id refactor"
|
||||
).is_true()
|
||||
assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_true()
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"current_zone_id must be zone_alpha after same apply_snapshot call"
|
||||
).is_equal("zone_alpha")
|
||||
|
||||
|
||||
# -- Fractional player position (S16-Z08) --------------------------------------
|
||||
|
||||
func test_zone_id_uses_int_truncation_of_player_position() -> void:
|
||||
# S16-Z08: Player at (5.7, 5.9) → int(5.7)=5, int(5.9)=5 → matches tile (5,5).
|
||||
# Server sends player coords as floats; zone lookup must truncate to tile index.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.7, "y": 5.9, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"Player at (5.7,5.9) must match tile (5,5) — int() truncation applies"
|
||||
).is_equal("zone_alpha")
|
||||
|
||||
|
||||
# -- Test-mode tiles key (S16-Z09) --------------------------------------------
|
||||
|
||||
func test_zone_id_works_with_test_mode_tiles_key() -> void:
|
||||
# S16-Z09: Test mode sends "tiles" key, not "visible_tiles".
|
||||
# The O(1) refactor uses member visible_tiles (covers both paths).
|
||||
# Regression guard: if _tile_by_coord is moved into the snapshot.visible_tiles
|
||||
# block only, this test fails — catching the exact regression Tyre flagged.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).override_failure_message(
|
||||
"Test-mode 'tiles' key must populate zone_id via member visible_tiles"
|
||||
).is_equal("zone_alpha")
|
||||
@@ -0,0 +1,292 @@
|
||||
## Sprint 16 #540: Sprite integration tests.
|
||||
## Tests z-sorting with real sprites, 24x32 D-044 footprint within D-066 64x64
|
||||
## bounding box, sprite asset existence from #541, and fog shader independence.
|
||||
## Spec refs: D-019, D-043, D-044, D-049, D-066, #540, #541.
|
||||
class_name TestSpriteIntegration
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _make_entity_renderer() -> Node2D:
|
||||
var renderer = Node2D.new()
|
||||
renderer.set_script(EntityRendererScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
|
||||
# -- Footprint constants: D-044 spec (S16-S01, S16-S02) -----------------------
|
||||
|
||||
func test_entity_footprint_matches_d044_spec() -> void:
|
||||
# S16-S01: D-044 specifies 24x32 entity footprint within 32x32 visual tile.
|
||||
# (64x64 source sprite scaled to 32px runtime at 2x retina per D-066).
|
||||
assert_that(EntityRenderer.ENTITY_WIDTH).override_failure_message(
|
||||
"D-044: ENTITY_WIDTH must be 24px"
|
||||
).is_equal(24)
|
||||
assert_that(EntityRenderer.ENTITY_HEIGHT).override_failure_message(
|
||||
"D-044: ENTITY_HEIGHT must be 32px"
|
||||
).is_equal(32)
|
||||
|
||||
|
||||
func test_entity_footprint_within_d066_2x2_sim_tile_bounding_box() -> void:
|
||||
# S16-S02: D-066 requires entity sprite footprint contained within 2x2 sim tile
|
||||
# bounding box. At 32px/tile → 64x64px max. Entity must fit to keep interaction
|
||||
# range (2 sim tiles) accurate with the tilted perspective.
|
||||
var tile_2x: int = Constants.TILE_SIZE * 2
|
||||
assert_that(EntityRenderer.ENTITY_WIDTH <= tile_2x).override_failure_message(
|
||||
"D-066: ENTITY_WIDTH %d must fit within 2x tile width %dpx" % [
|
||||
EntityRenderer.ENTITY_WIDTH, tile_2x]
|
||||
).is_true()
|
||||
assert_that(EntityRenderer.ENTITY_HEIGHT <= tile_2x).override_failure_message(
|
||||
"D-066: ENTITY_HEIGHT %d must fit within 2x tile height %dpx" % [
|
||||
EntityRenderer.ENTITY_HEIGHT, tile_2x]
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_entity_width_fits_within_single_tile() -> void:
|
||||
# S16-S03: Entity width (24) < TILE_SIZE (32) → centered within tile.
|
||||
# Ensures horizontal centering offset is positive and entity doesn't overflow.
|
||||
assert_that(EntityRenderer.ENTITY_WIDTH < Constants.TILE_SIZE).override_failure_message(
|
||||
"Entity width must be less than TILE_SIZE for centered layout"
|
||||
).is_true()
|
||||
assert_that(EntityRenderer.ENTITY_OFFSET_X >= 0.0).override_failure_message(
|
||||
"ENTITY_OFFSET_X must be non-negative for horizontal centering"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Pixel position (S16-S04) -------------------------------------------------
|
||||
|
||||
func test_entity_pixel_position_at_tile_3_7() -> void:
|
||||
# S16-S04: Entity at tile (3.0, 7.0) → pixel position must be
|
||||
# (3 * TILE_SIZE + ENTITY_OFFSET_X, 7 * TILE_SIZE + ENTITY_OFFSET_Y).
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 10, "x": 3.0, "y": 7.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[10]
|
||||
var expected_x := 3.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X
|
||||
var expected_y := 7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||
assert_that(node.position.x).override_failure_message(
|
||||
"Entity x must be tile_x * TILE_SIZE + ENTITY_OFFSET_X"
|
||||
).is_equal_approx(expected_x, 0.1)
|
||||
assert_that(node.position.y).override_failure_message(
|
||||
"Entity y must be tile_y * TILE_SIZE + ENTITY_OFFSET_Y"
|
||||
).is_equal_approx(expected_y, 0.1)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- Z-sort ordering: D-049 y-based (S16-S05, S16-S06) -----------------------
|
||||
|
||||
func test_z_sort_south_entity_has_higher_pixel_y() -> void:
|
||||
# S16-S05: D-049 y-sort — entity at y=8 (south) must have higher pixel.y
|
||||
# than entity at y=4 (north). Godot y-sort renders higher-y on top.
|
||||
# With tilted sprites, south-facing entity must visually overlap northern.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entities := [
|
||||
{"entity_id": 20, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"entity_id": 21, "x": 5.0, "y": 8.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
]
|
||||
renderer.update_entities(entities)
|
||||
var north_node = renderer.entity_nodes[20]
|
||||
var south_node = renderer.entity_nodes[21]
|
||||
assert_that(south_node.position.y > north_node.position.y).override_failure_message(
|
||||
"Entity at y=8 must have higher pixel.y than entity at y=4 for y-sort"
|
||||
).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_z_sort_y_position_difference_equals_tile_size() -> void:
|
||||
# S16-S06: Two entities one tile apart in y → pixel y difference = TILE_SIZE.
|
||||
# Verifies position calculation is consistent for adjacent tiles.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entities := [
|
||||
{"entity_id": 30, "x": 5.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"entity_id": 31, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
]
|
||||
renderer.update_entities(entities)
|
||||
var node3 = renderer.entity_nodes[30]
|
||||
var node4 = renderer.entity_nodes[31]
|
||||
var delta_y := node4.position.y - node3.position.y
|
||||
assert_that(delta_y).override_failure_message(
|
||||
"Adjacent tiles must differ by exactly TILE_SIZE (%dpx) in y" % Constants.TILE_SIZE
|
||||
).is_equal_approx(float(Constants.TILE_SIZE), 0.1)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_z_sort_same_y_different_x_no_y_difference() -> void:
|
||||
# S16-S07: Two entities at same y but different x → same pixel.y.
|
||||
# Horizontal position must not affect y-sort order.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entities := [
|
||||
{"entity_id": 40, "x": 2.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"entity_id": 41, "x": 8.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
]
|
||||
renderer.update_entities(entities)
|
||||
var left_node = renderer.entity_nodes[40]
|
||||
var right_node = renderer.entity_nodes[41]
|
||||
assert_that(left_node.position.y).override_failure_message(
|
||||
"Entities at same y-tile must have same pixel.y regardless of x"
|
||||
).is_equal_approx(right_node.position.y, 0.1)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- Sprite assets from #541 (S16-S08, S16-S09) --------------------------------
|
||||
|
||||
func test_npc_sprite_assets_exist_for_all_cardinal_directions() -> void:
|
||||
# S16-S08: #541 delivers 64px NPC sprites for all four cardinal directions.
|
||||
# entity_renderer.gd must be able to load these paths.
|
||||
for direction in ["north", "east", "south", "west"]:
|
||||
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
|
||||
assert_that(ResourceLoader.exists(path)).override_failure_message(
|
||||
"NPC sprite missing: %s" % path
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_wall_sprite_assets_exist_for_all_cardinal_directions() -> void:
|
||||
# S16-S09: #541 delivers 64px wall sprites for all four cardinal directions.
|
||||
for direction in ["north", "east", "south", "west"]:
|
||||
var path := "res://assets/sprites/wall_structural_%s_64.png" % direction
|
||||
assert_that(ResourceLoader.exists(path)).override_failure_message(
|
||||
"Wall sprite missing: %s" % path
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Fog shader independence: D-019 (S16-S10, S16-S11) -----------------------
|
||||
|
||||
func test_fog_shader_script_and_gdshader_load_correctly() -> void:
|
||||
# S16-S10: fog_shader.gd and fog.gdshader must remain intact after sprite
|
||||
# changes. D-019: "fog vision cone math remains pure 2D" — unaffected by
|
||||
# the art-direction tilt baked into sprites.
|
||||
assert_that(ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")).override_failure_message(
|
||||
"fog_shader.gd must load correctly — must not be affected by sprite changes"
|
||||
).is_true()
|
||||
assert_that(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
|
||||
"fog.gdshader must exist — fog is screen-space and sprite-independent"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_update_runs_independently_of_entity_renderer_state() -> void:
|
||||
# S16-S11: FogState.update_from_state() must succeed with no entity renderer
|
||||
# active. D-019: fog driven by LOS mask (visible_positions), not sprites.
|
||||
var fog = get_node_or_null("/root/FogState")
|
||||
if fog == null:
|
||||
push_warning("TestSpriteIntegration: FogState not available — fog independence test skipped")
|
||||
return
|
||||
# Provide visibility data but no entity renderer context
|
||||
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
|
||||
GameState.visibility_sectors = {
|
||||
Vector2i(5, 5): "Forward",
|
||||
Vector2i(6, 5): "Peripheral",
|
||||
}
|
||||
if fog.has_method("update_from_state"):
|
||||
fog.update_from_state()
|
||||
assert_that(fog.visibility_texture).override_failure_message(
|
||||
"FogState visibility_texture must be populated independently of sprite state"
|
||||
).is_not_null()
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
# -- Direction mapping: _octant_to_direction (S16-S12 through S16-S21) --------
|
||||
|
||||
func test_octant_north_maps_to_north() -> void:
|
||||
# S16-S12: "North" → "north"
|
||||
assert_that(EntityRenderer._octant_to_direction("North")).is_equal("north")
|
||||
|
||||
func test_octant_northwest_maps_to_north() -> void:
|
||||
# S16-S13: "Northwest" → "north" (grouped with North per mapping spec)
|
||||
assert_that(EntityRenderer._octant_to_direction("Northwest")).is_equal("north")
|
||||
|
||||
func test_octant_northeast_maps_to_east() -> void:
|
||||
# S16-S14: "Northeast" → "east"
|
||||
assert_that(EntityRenderer._octant_to_direction("Northeast")).is_equal("east")
|
||||
|
||||
func test_octant_east_maps_to_east() -> void:
|
||||
# S16-S15: "East" → "east"
|
||||
assert_that(EntityRenderer._octant_to_direction("East")).is_equal("east")
|
||||
|
||||
func test_octant_southeast_maps_to_south() -> void:
|
||||
# S16-S16: "Southeast" → "south"
|
||||
assert_that(EntityRenderer._octant_to_direction("Southeast")).is_equal("south")
|
||||
|
||||
func test_octant_south_maps_to_south() -> void:
|
||||
# S16-S17: "South" → "south"
|
||||
assert_that(EntityRenderer._octant_to_direction("South")).is_equal("south")
|
||||
|
||||
func test_octant_southwest_maps_to_west() -> void:
|
||||
# S16-S18: "Southwest" → "west"
|
||||
assert_that(EntityRenderer._octant_to_direction("Southwest")).is_equal("west")
|
||||
|
||||
func test_octant_west_maps_to_west() -> void:
|
||||
# S16-S19: "West" → "west"
|
||||
assert_that(EntityRenderer._octant_to_direction("West")).is_equal("west")
|
||||
|
||||
func test_octant_unknown_string_falls_back_to_south() -> void:
|
||||
# S16-S20: Unknown string → "south" fallback (safe default — viewer-facing per D-019)
|
||||
assert_that(EntityRenderer._octant_to_direction("Unknown")).is_equal("south")
|
||||
assert_that(EntityRenderer._octant_to_direction("invalid")).is_equal("south")
|
||||
|
||||
func test_octant_empty_string_falls_back_to_south() -> void:
|
||||
# S16-S21: Empty string → "south" fallback
|
||||
assert_that(EntityRenderer._octant_to_direction("")).is_equal("south")
|
||||
|
||||
|
||||
# -- Direction mapping: _entity_direction (S16-S22 through S16-S25) ----------
|
||||
|
||||
func test_entity_direction_npc_always_south() -> void:
|
||||
# S16-S22: NPC entity → always "south" regardless of any data field.
|
||||
# NPCs have no facing in v1 entity format; south is viewer-facing (D-019 angle).
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
# entity_id 99 is not the player
|
||||
var dir := renderer._entity_direction(99, {"entity_id": 99,
|
||||
"kind": {"variant": "Npc", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"NPC entity must always return 'south'"
|
||||
).is_equal("south")
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_direction_player_uses_player_facing() -> void:
|
||||
# S16-S23: Player entity → uses GameState.player_facing via _octant_to_direction.
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_facing = "North"
|
||||
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||
"kind": {"variant": "Player", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"Player entity with player_facing='North' must return 'north'"
|
||||
).is_equal("north")
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_direction_player_facing_east() -> void:
|
||||
# S16-S24: Player facing "East" → "east"
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_facing = "East"
|
||||
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||
"kind": {"variant": "Player", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"Player entity with player_facing='East' must return 'east'"
|
||||
).is_equal("east")
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_direction_player_facing_diagonal_uses_nearest_cardinal() -> void:
|
||||
# S16-S25: Player facing "Northwest" → "north" (nearest cardinal mapping).
|
||||
# Diagonal octants map to one of the four cardinal sprite sets.
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_facing = "Northwest"
|
||||
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||
"kind": {"variant": "Player", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"Player entity with player_facing='Northwest' must return 'north'"
|
||||
).is_equal("north")
|
||||
renderer.queue_free()
|
||||
@@ -0,0 +1,318 @@
|
||||
## Sprint 17 — Time display on insert HUD (#263)
|
||||
## Tests for Constants.format_game_time(), InsertClock wiring, and GameState integration.
|
||||
##
|
||||
## Spec refs:
|
||||
## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display)
|
||||
## D-051 (diegetic insert display)
|
||||
##
|
||||
## Implementation: client/ui/time_display.gd — draw-based Control at InsertOverlay/TimeDisplay.
|
||||
## Format function: Constants.format_game_time(time_of_day: int) -> String (extracted for
|
||||
## testability from time_display.gd:46 inline Constants.format_game_time(tod)).
|
||||
##
|
||||
## Private state access: Tests read _time_str, _phase_str, _day_str, _has_data directly
|
||||
## because time_display.gd is draw-based (no Label nodes to inspect). This is an accepted
|
||||
## test pattern for draw-based UI — the private vars ARE the rendered output contract.
|
||||
## If the rendering approach changes (e.g. to Label nodes), these tests should switch to
|
||||
## reading Label.text via public node paths instead.
|
||||
class_name TestTimeDisplaySprint17
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var _clock: Control = null
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
GameState.game_time = {}
|
||||
var ClockScript = load("res://ui/time_display.gd")
|
||||
_clock = Control.new()
|
||||
_clock.set_script(ClockScript)
|
||||
add_child(_clock)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
if _clock and is_instance_valid(_clock):
|
||||
_clock.queue_free()
|
||||
_clock = null
|
||||
GameState.game_time = {}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Constants.format_game_time() — pure logic, D-031
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_format_midnight() -> void:
|
||||
assert_that(Constants.format_game_time(0)).is_equal("00:00")
|
||||
|
||||
func test_format_morning_start() -> void:
|
||||
# 360 game-minutes = 6 h exactly (Morning phase boundary, D-031)
|
||||
assert_that(Constants.format_game_time(360)).is_equal("06:00")
|
||||
|
||||
func test_format_noon() -> void:
|
||||
assert_that(Constants.format_game_time(720)).is_equal("12:00")
|
||||
|
||||
func test_format_evening_start() -> void:
|
||||
assert_that(Constants.format_game_time(1080)).is_equal("18:00")
|
||||
|
||||
func test_format_end_of_day() -> void:
|
||||
# Last valid minute — must not wrap or overflow
|
||||
assert_that(Constants.format_game_time(1439)).is_equal("23:59")
|
||||
|
||||
func test_format_pads_single_digit_hour() -> void:
|
||||
# 30 min = 00:30
|
||||
assert_that(Constants.format_game_time(30)).is_equal("00:30")
|
||||
|
||||
func test_format_pads_single_digit_minute() -> void:
|
||||
# 121 min = 02:01
|
||||
assert_that(Constants.format_game_time(121)).is_equal("02:01")
|
||||
|
||||
func test_format_half_past_hour() -> void:
|
||||
assert_that(Constants.format_game_time(90)).is_equal("01:30")
|
||||
|
||||
func test_format_arbitrary_midday() -> void:
|
||||
# 835 min = 13:55
|
||||
assert_that(Constants.format_game_time(835)).is_equal("13:55")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# InsertClock initial state
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_insert_clock_initial_time_str_is_placeholder() -> void:
|
||||
# Before any snapshot, _time_str must be "--:--" (not shown by _draw)
|
||||
assert_that(_clock._time_str).is_equal("--:--")
|
||||
|
||||
func test_insert_clock_initial_phase_str_is_empty() -> void:
|
||||
assert_that(_clock._phase_str).is_equal("")
|
||||
|
||||
func test_insert_clock_initial_day_str_is_empty() -> void:
|
||||
assert_that(_clock._day_str).is_equal("")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# InsertClock.update_from_state() — reads GameState.game_time
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_formats_time_str() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("12:00")
|
||||
|
||||
func test_update_from_state_sets_phase_str() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 1080, "day_phase": "Evening", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._phase_str).is_equal("Evening")
|
||||
|
||||
func test_update_from_state_sets_day_str_one_indexed() -> void:
|
||||
# Day 0 from server → "D1" display (1-indexed)
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._day_str).is_equal("D1")
|
||||
|
||||
func test_update_from_state_day_2() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 1, "time_of_day": 50, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._day_str).is_equal("D2")
|
||||
|
||||
func test_update_from_state_midnight() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("00:00")
|
||||
|
||||
func test_update_from_state_end_of_day() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 1439, "day_phase": "Night", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("23:59")
|
||||
|
||||
func test_update_from_state_skips_empty_game_time() -> void:
|
||||
# Empty game_time must not overwrite --:-- (guard in update_from_state)
|
||||
GameState.game_time = {}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("--:--")
|
||||
|
||||
func test_update_from_state_deduplicates_same_tick() -> void:
|
||||
# Calling twice with identical data must produce same result (signature cache)
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("06:00")
|
||||
# Call again — result unchanged, no crash
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("06:00")
|
||||
|
||||
func test_update_from_state_updates_on_new_tick() -> void:
|
||||
# time_of_day changes → signature changes → _time_str updates
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 60, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("01:00")
|
||||
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 120, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._time_str).is_equal("02:00")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# InsertClock.PHASE_COLORS — all four D-031 phases have colors
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_phase_colors_has_morning() -> void:
|
||||
assert_that(_clock.PHASE_COLORS.has("Morning")).is_true()
|
||||
|
||||
func test_phase_colors_has_afternoon() -> void:
|
||||
assert_that(_clock.PHASE_COLORS.has("Afternoon")).is_true()
|
||||
|
||||
func test_phase_colors_has_evening() -> void:
|
||||
assert_that(_clock.PHASE_COLORS.has("Evening")).is_true()
|
||||
|
||||
func test_phase_colors_has_night() -> void:
|
||||
assert_that(_clock.PHASE_COLORS.has("Night")).is_true()
|
||||
|
||||
func test_phase_color_applied_after_update() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._phase_color).is_equal(_clock.PHASE_COLORS["Morning"])
|
||||
|
||||
func test_phase_color_unknown_phase_uses_dim_fallback() -> void:
|
||||
# Unknown phase string → Constants.IMPLANT_TEXT_DIM
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 100, "day_phase": "Twilight", "tick_rate": "Full",
|
||||
}
|
||||
_clock.update_from_state()
|
||||
assert_that(_clock._phase_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# GameState: game_time field parsing (confirms apply_snapshot wiring)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_game_time_populated_from_snapshot() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 5, "entities": [],
|
||||
"game_time": {
|
||||
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
|
||||
},
|
||||
})
|
||||
assert_that(GameState.game_time.get("time_of_day")).is_equal(720)
|
||||
|
||||
func test_game_time_all_four_phases_store_correctly() -> void:
|
||||
for phase in ["Morning", "Afternoon", "Evening", "Night"]:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "entities": [],
|
||||
"game_time": {
|
||||
"day": 0, "time_of_day": 100, "day_phase": phase, "tick_rate": "Full",
|
||||
},
|
||||
})
|
||||
assert_that(GameState.game_time.get("day_phase")).is_equal(phase)
|
||||
|
||||
func test_game_time_missing_from_snapshot_preserves_previous() -> void:
|
||||
GameState.game_time = {
|
||||
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
|
||||
}
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.game_time.get("time_of_day")).is_equal(360)
|
||||
|
||||
func test_game_time_zero_time_of_day_stored() -> void:
|
||||
# time_of_day = 0 (midnight) must not be treated as falsy/missing
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full"},
|
||||
})
|
||||
assert_that(GameState.game_time.get("time_of_day")).is_equal(0)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# SimBridge test mode: game_time fields are valid
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_sim_bridge_snapshot_has_game_time() -> void:
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.has("game_time")).is_true()
|
||||
assert_that(snap.game_time is Dictionary).is_true()
|
||||
|
||||
func test_sim_bridge_game_time_has_required_fields() -> void:
|
||||
var snap = SimBridge._test_snapshot()
|
||||
var gt: Dictionary = snap.game_time
|
||||
assert_that(gt.has("day")).is_true()
|
||||
assert_that(gt.has("time_of_day")).is_true()
|
||||
assert_that(gt.has("day_phase")).is_true()
|
||||
assert_that(gt.has("tick_rate")).is_true()
|
||||
|
||||
func test_sim_bridge_time_of_day_is_non_negative() -> void:
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.game_time.get("time_of_day", -1) as int).is_greater_equal(0)
|
||||
|
||||
func test_sim_bridge_time_of_day_within_day_bounds() -> void:
|
||||
# D-031: 1440 game-minutes per day, valid range 0..1439
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.game_time.get("time_of_day", 0) as int).is_less_equal(1439)
|
||||
|
||||
func test_sim_bridge_day_phase_is_valid() -> void:
|
||||
var snap = SimBridge._test_snapshot()
|
||||
var phase: String = snap.game_time.get("day_phase", "")
|
||||
assert_that(["Morning", "Afternoon", "Evening", "Night"].has(phase)).is_true()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Scene: InsertClock node at InsertOverlay/TimeDisplay
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_insert_clock_exists_in_ui_layer() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var instance = scene.instantiate()
|
||||
auto_free(instance)
|
||||
add_child(instance)
|
||||
|
||||
assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null()
|
||||
|
||||
func test_insert_clock_time_str_updates_after_process() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var instance = scene.instantiate()
|
||||
auto_free(instance)
|
||||
add_child(instance)
|
||||
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
|
||||
})
|
||||
instance._process(0.016)
|
||||
|
||||
var clock = instance.get_node_or_null("InsertOverlay/TimeDisplay")
|
||||
assert_that(clock).is_not_null()
|
||||
assert_that(clock._time_str).is_equal("12:00")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Regression: debug_overlay still reads game_time correctly (#511)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_reads_game_time_day_phase() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
|
||||
})
|
||||
assert_that(GameState.game_time.get("day_phase")).is_equal("Morning")
|
||||
|
||||
func test_debug_overlay_reads_game_time_tick_rate() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Half"},
|
||||
})
|
||||
assert_that(GameState.game_time.get("tick_rate")).is_equal("Half")
|
||||
@@ -110,13 +110,14 @@ func test_stance_indicator_exists_in_ui_layer() -> void:
|
||||
|
||||
|
||||
func test_minimap_placeholder_exists_in_ui_layer() -> void:
|
||||
# D-013: Minimap/insert placeholder must be in UILayer (not implemented yet).
|
||||
# D-013/D-049: Minimap is on InsertOverlay (z-layer 6), NOT UILayer.
|
||||
# Sprint 18 #151 (Stig): moved from UILayer to InsertOverlay per D-049 spec.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
assert_that(_instance.get_node_or_null("UILayer/Minimap")).is_not_null()
|
||||
assert_that(_instance.get_node_or_null("InsertOverlay/Minimap")).is_not_null()
|
||||
|
||||
|
||||
func test_hud_exists_in_ui_layer() -> void:
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
## YamlParser unit tests (#560).
|
||||
## Verifies parse() (nested, typed) and parse_flat() (dotted keys, string values).
|
||||
## Covers: maps, nested maps, arrays of dicts, type conversion, comments,
|
||||
## quoted strings, empty input, edge cases.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestYamlParser
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- parse(): basic key-value pairs -------------------------------------------
|
||||
|
||||
func test_parse_simple_key_value() -> void:
|
||||
var result := YamlParser.parse("key: value")
|
||||
assert_that(result["key"]).is_equal("value")
|
||||
|
||||
|
||||
func test_parse_quoted_string() -> void:
|
||||
var result := YamlParser.parse('key: "hello world"')
|
||||
assert_that(result["key"]).is_equal("hello world")
|
||||
|
||||
|
||||
func test_parse_empty_quoted_string() -> void:
|
||||
var result := YamlParser.parse('key: ""')
|
||||
assert_that(result["key"]).is_equal("")
|
||||
|
||||
|
||||
func test_parse_integer_value() -> void:
|
||||
var result := YamlParser.parse("count: 42")
|
||||
assert_that(result["count"]).is_equal(42)
|
||||
assert_that(typeof(result["count"])).is_equal(TYPE_INT)
|
||||
|
||||
|
||||
func test_parse_negative_integer() -> void:
|
||||
var result := YamlParser.parse("offset: -3")
|
||||
assert_that(result["offset"]).is_equal(-3)
|
||||
|
||||
|
||||
func test_parse_float_value() -> void:
|
||||
var result := YamlParser.parse("radius: 2.5")
|
||||
assert_that(typeof(result["radius"])).is_equal(TYPE_FLOAT)
|
||||
assert_float(result["radius"]).is_equal_approx(2.5, 0.001)
|
||||
|
||||
|
||||
func test_parse_boolean_true() -> void:
|
||||
var result := YamlParser.parse("enabled: true")
|
||||
assert_that(result["enabled"]).is_equal(true)
|
||||
assert_that(typeof(result["enabled"])).is_equal(TYPE_BOOL)
|
||||
|
||||
|
||||
func test_parse_boolean_false() -> void:
|
||||
var result := YamlParser.parse("enabled: false")
|
||||
assert_that(result["enabled"]).is_equal(false)
|
||||
|
||||
|
||||
func test_parse_empty_input() -> void:
|
||||
var result := YamlParser.parse("")
|
||||
assert_that(result.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_parse_only_comments() -> void:
|
||||
var result := YamlParser.parse("# comment\n# another")
|
||||
assert_that(result.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_parse_inline_comment_stripped() -> void:
|
||||
var result := YamlParser.parse("room_id: test # this is a comment")
|
||||
assert_that(result["room_id"]).is_equal("test")
|
||||
|
||||
|
||||
func test_parse_quoted_value_with_hash() -> void:
|
||||
## Quoted strings preserve literal # characters.
|
||||
var result := YamlParser.parse('color: "#e0e8ff"')
|
||||
assert_that(result["color"]).is_equal("#e0e8ff")
|
||||
|
||||
|
||||
func test_parse_value_with_colon() -> void:
|
||||
var result := YamlParser.parse('time: "12:30"')
|
||||
assert_that(result["time"]).is_equal("12:30")
|
||||
|
||||
|
||||
# -- parse(): nested maps -----------------------------------------------------
|
||||
|
||||
func test_parse_nested_map() -> void:
|
||||
var yaml := "section:\n key: value"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result.has("section")).is_true()
|
||||
assert_that(result["section"] is Dictionary).is_true()
|
||||
assert_that(result["section"]["key"]).is_equal("value")
|
||||
|
||||
|
||||
func test_parse_deeply_nested() -> void:
|
||||
var yaml := "a:\n b:\n c: deep"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["a"]["b"]["c"]).is_equal("deep")
|
||||
|
||||
|
||||
func test_parse_multiple_sections() -> void:
|
||||
var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["hud"]["mode"]).is_equal("Mode")
|
||||
assert_that(result["interaction"]["talk"]).is_equal("Talk")
|
||||
|
||||
|
||||
func test_parse_multiple_keys_per_section() -> void:
|
||||
var yaml := "hud:\n a: 1\n b: 2\n c: 3"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["hud"]["a"]).is_equal(1)
|
||||
assert_that(result["hud"]["b"]).is_equal(2)
|
||||
assert_that(result["hud"]["c"]).is_equal(3)
|
||||
|
||||
|
||||
func test_parse_sibling_subsections() -> void:
|
||||
var yaml := "states:\n a:\n x: 1\n b:\n x: 2"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["states"]["a"]["x"]).is_equal(1)
|
||||
assert_that(result["states"]["b"]["x"]).is_equal(2)
|
||||
|
||||
|
||||
func test_parse_section_with_trailing_comment() -> void:
|
||||
## "section: # comment" should be treated as a section header.
|
||||
var yaml := "section: # comment\n key: value"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["section"]["key"]).is_equal("value")
|
||||
|
||||
|
||||
# -- parse(): arrays of dicts -------------------------------------------------
|
||||
|
||||
func test_parse_single_array_item() -> void:
|
||||
var yaml := "conditions:\n - id: test-1\n type: near\n x: 10"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result.has("conditions")).is_true()
|
||||
assert_that(result["conditions"] is Array).is_true()
|
||||
assert_that(result["conditions"].size()).is_equal(1)
|
||||
assert_that(result["conditions"][0]["id"]).is_equal("test-1")
|
||||
assert_that(result["conditions"][0]["type"]).is_equal("near")
|
||||
assert_that(result["conditions"][0]["x"]).is_equal(10)
|
||||
|
||||
|
||||
func test_parse_multiple_array_items() -> void:
|
||||
var yaml := "conditions:\n - id: a\n x: 1\n - id: b\n x: 2"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["conditions"].size()).is_equal(2)
|
||||
assert_that(result["conditions"][0]["id"]).is_equal("a")
|
||||
assert_that(result["conditions"][1]["id"]).is_equal("b")
|
||||
|
||||
|
||||
func test_parse_array_items_with_blank_lines() -> void:
|
||||
var yaml := "conditions:\n - id: a\n x: 1\n\n - id: b\n x: 2"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["conditions"].size()).is_equal(2)
|
||||
|
||||
|
||||
func test_parse_top_level_plus_array() -> void:
|
||||
## Checklist format: top-level key-value pairs followed by a conditions array.
|
||||
var yaml := "room_id: warehouse\nconditions:\n - id: c1\n type: near\n x: 5"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["room_id"]).is_equal("warehouse")
|
||||
assert_that(result["conditions"].size()).is_equal(1)
|
||||
assert_that(result["conditions"][0]["id"]).is_equal("c1")
|
||||
|
||||
|
||||
func test_parse_array_typed_values() -> void:
|
||||
var yaml := "items:\n - id: t\n x: 42\n radius: 2.5\n active: true"
|
||||
var result := YamlParser.parse(yaml)
|
||||
var item: Dictionary = result["items"][0]
|
||||
assert_that(item["x"]).is_equal(42)
|
||||
assert_that(typeof(item["radius"])).is_equal(TYPE_FLOAT)
|
||||
assert_that(item["active"]).is_equal(true)
|
||||
|
||||
|
||||
# -- parse_flat(): dotted keys -------------------------------------------------
|
||||
|
||||
func test_flat_simple() -> void:
|
||||
var yaml := "section:\n key: value"
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result.has("section.key")).is_true()
|
||||
assert_that(result["section.key"]).is_equal("value")
|
||||
|
||||
|
||||
func test_flat_deeply_nested() -> void:
|
||||
var yaml := "a:\n b:\n c: deep"
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result["a.b.c"]).is_equal("deep")
|
||||
|
||||
|
||||
func test_flat_multiple_sections() -> void:
|
||||
var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk"
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result["hud.mode"]).is_equal("Mode")
|
||||
assert_that(result["interaction.talk"]).is_equal("Talk")
|
||||
|
||||
|
||||
func test_flat_values_are_strings() -> void:
|
||||
## parse_flat returns all values as strings, unlike parse() which returns typed.
|
||||
var yaml := "section:\n count: 42\n rate: 0.9\n active: true"
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result["section.count"]).is_equal("42")
|
||||
assert_that(result["section.rate"]).is_equal("0.9")
|
||||
assert_that(result["section.active"]).is_equal("true")
|
||||
|
||||
|
||||
func test_flat_quoted_value() -> void:
|
||||
var yaml := 'section:\n key: "hello world"'
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result["section.key"]).is_equal("hello world")
|
||||
|
||||
|
||||
func test_flat_inline_comment() -> void:
|
||||
var yaml := "hud:\n mode: Standard # default"
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result["hud.mode"]).is_equal("Standard")
|
||||
|
||||
|
||||
func test_flat_empty_quoted_value() -> void:
|
||||
var yaml := 'hud:\n prefix: "" # No prefix'
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result.has("hud.prefix")).is_true()
|
||||
assert_that(result["hud.prefix"]).is_equal("")
|
||||
|
||||
|
||||
func test_flat_skips_arrays() -> void:
|
||||
## Arrays have no dotted-key representation — they are skipped in flat output.
|
||||
var yaml := "room_id: test\nconditions:\n - id: c1\n x: 5"
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result.has("room_id")).is_true()
|
||||
# No dotted keys for array contents
|
||||
assert_that(result.has("conditions")).is_false()
|
||||
assert_that(result.has("conditions.0")).is_false()
|
||||
|
||||
|
||||
func test_flat_empty_input() -> void:
|
||||
var result := YamlParser.parse_flat("")
|
||||
assert_that(result.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- Dialogue theme format (regression) ----------------------------------------
|
||||
|
||||
func test_dialogue_theme_format() -> void:
|
||||
## dialogue-theme.yaml has top-level values and one nested map (npc_colors).
|
||||
var yaml := "player_color: \"#e0e8ff\"\nnpc_colors:\n 0: \"#4a9ebb\"\n 1: \"#6bc9a6\"\npassive_opacity: 0.9"
|
||||
var flat := YamlParser.parse_flat(yaml)
|
||||
assert_that(flat["player_color"]).is_equal("#e0e8ff")
|
||||
assert_that(flat["npc_colors.0"]).is_equal("#4a9ebb")
|
||||
assert_that(flat["npc_colors.1"]).is_equal("#6bc9a6")
|
||||
assert_that(flat["passive_opacity"]).is_equal("0.9")
|
||||
|
||||
|
||||
# -- Checklist format (regression) ---------------------------------------------
|
||||
|
||||
func test_checklist_format() -> void:
|
||||
## checklist.yaml: top-level kv + conditions array with typed values.
|
||||
var yaml := "room_id: inventory_warehouse\nconditions:\n - id: test-1\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["room_id"]).is_equal("inventory_warehouse")
|
||||
var cond: Dictionary = result["conditions"][0]
|
||||
assert_that(cond["id"]).is_equal("test-1")
|
||||
assert_that(cond["condition_type"]).is_equal("player_near")
|
||||
assert_that(cond["x"]).is_equal(10)
|
||||
assert_that(cond["y"]).is_equal(20)
|
||||
assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT)
|
||||
@@ -0,0 +1,99 @@
|
||||
## Scene testing utilities for gdUnit4 tests.
|
||||
##
|
||||
## Loads a scene, instantiates it into the test suite's node tree,
|
||||
## and provides helpers for node existence, signal, and node-path queries.
|
||||
##
|
||||
## Usage (from a GdUnitTestSuite subclass):
|
||||
## var helper := SceneHelper.create(self, "res://scenes/main.tscn")
|
||||
## helper.assert_node_exists("World")
|
||||
## var world := helper.get_node_at("World")
|
||||
## helper.monitor_signal(world, "ready")
|
||||
## # ... trigger something ...
|
||||
## helper.assert_signal_emitted(world, "ready")
|
||||
##
|
||||
## Design constraints (D-030): server-free, no running autoload dependencies.
|
||||
class_name SceneHelper
|
||||
extends RefCounted
|
||||
|
||||
var _suite # GdUnitTestSuite — untyped to avoid load-order dependency
|
||||
var _scene: Node
|
||||
# signal_key -> int. Key is "<node_instance_id>:<signal_name>" for uniqueness.
|
||||
var _signal_hits: Dictionary = {}
|
||||
|
||||
|
||||
## Load, instantiate, and attach a scene to the test suite's node tree.
|
||||
## The scene node is registered for auto-free by gdUnit4.
|
||||
## Returns a helper instance; fails the test if the scene cannot be loaded.
|
||||
static func create(suite: GdUnitTestSuite, scene_path: String) -> SceneHelper:
|
||||
var helper := SceneHelper.new()
|
||||
helper._suite = suite
|
||||
|
||||
var packed: PackedScene = load(scene_path)
|
||||
if packed == null:
|
||||
suite.assert_that(packed).override_failure_message(
|
||||
"SceneHelper: could not load scene at '%s'" % scene_path
|
||||
).is_not_null()
|
||||
return helper
|
||||
|
||||
helper._scene = packed.instantiate()
|
||||
suite.auto_free(helper._scene)
|
||||
suite.add_child(helper._scene)
|
||||
return helper
|
||||
|
||||
|
||||
## Returns the scene root node.
|
||||
func scene() -> Node:
|
||||
return _scene
|
||||
|
||||
|
||||
## Assert that a node at node_path exists under the scene root.
|
||||
## Fails the current test if the node is absent.
|
||||
func assert_node_exists(node_path: String) -> void:
|
||||
var node := _scene.get_node_or_null(NodePath(node_path))
|
||||
_suite.assert_that(node).override_failure_message(
|
||||
"SceneHelper: expected node at path '%s' — not found" % node_path
|
||||
).is_not_null()
|
||||
|
||||
|
||||
## Return the node at node_path under the scene root, or null if absent.
|
||||
func get_node_at(node_path: String) -> Node:
|
||||
return _scene.get_node_or_null(NodePath(node_path))
|
||||
|
||||
|
||||
## Begin tracking emissions of signal_name on node.
|
||||
## Must be called before the action that triggers the signal.
|
||||
## Fails the test if node does not have the named signal.
|
||||
func monitor_signal(node: Node, signal_name: String) -> void:
|
||||
if not node.has_signal(signal_name):
|
||||
_suite.assert_that(false).override_failure_message(
|
||||
"SceneHelper: node '%s' has no signal '%s'" % [node.name, signal_name]
|
||||
).is_true()
|
||||
return
|
||||
var key := _signal_key(node, signal_name)
|
||||
_signal_hits[key] = 0
|
||||
# Lambda accepts up to 4 positional args to tolerate signals with up to 4 params.
|
||||
# GDScript default-param lambdas handle being called with fewer args correctly.
|
||||
node.connect(signal_name, func(a := null, b := null, c := null, d := null):
|
||||
_signal_hits[key] = _signal_hits.get(key, 0) + 1
|
||||
)
|
||||
|
||||
|
||||
## Assert that signal_name was emitted at least once since monitor_signal().
|
||||
## Fails the test if monitor_signal() was not called first, or if count is zero.
|
||||
func assert_signal_emitted(node: Node, signal_name: String) -> void:
|
||||
var key := _signal_key(node, signal_name)
|
||||
if not _signal_hits.has(key):
|
||||
_suite.assert_that(false).override_failure_message(
|
||||
"SceneHelper: '%s' was not monitored — call monitor_signal() first" % signal_name
|
||||
).is_true()
|
||||
return
|
||||
var count: int = _signal_hits[key]
|
||||
_suite.assert_int(count).override_failure_message(
|
||||
"SceneHelper: signal '%s' on '%s' was not emitted (count=%d)" % [
|
||||
signal_name, node.name, count
|
||||
]
|
||||
).is_greater(0)
|
||||
|
||||
|
||||
static func _signal_key(node: Node, signal_name: String) -> String:
|
||||
return "%d:%s" % [node.get_instance_id(), signal_name]
|
||||
@@ -15,6 +15,11 @@ signal dialogue_dismissed # Walk-away or conversation end
|
||||
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
|
||||
signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507)
|
||||
signal unpause_requested # D-061: auto-unpause
|
||||
# D-020 (#558): Decoupled signals — dialogue_box emits, main.gd (coordinator) handles.
|
||||
# Replaces direct GameState.dialogue_active mutation and AudioManager calls.
|
||||
signal dialogue_state_changed(active: bool)
|
||||
signal audio_dip_requested(profile: String)
|
||||
signal audio_dip_cleared
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
|
||||
@@ -286,10 +291,10 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
|
||||
# Show panel
|
||||
_ensure_visible()
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
GameState.dialogue_active = true # D-064: block movement while in conversation
|
||||
dialogue_state_changed.emit(true) # D-064: coordinator blocks movement
|
||||
|
||||
# D-069: Dialogue dip
|
||||
AudioManager.apply_dip("dialogue")
|
||||
# D-069: Dialogue dip — coordinator routes to AudioManager
|
||||
audio_dip_requested.emit("dialogue")
|
||||
|
||||
# D-061: auto-pause — signal to main.gd for input recording (#507)
|
||||
pause_requested.emit()
|
||||
@@ -311,14 +316,14 @@ func _end_player_conversation() -> void:
|
||||
entry.timestamp_msec = now
|
||||
_log_dirty = true
|
||||
|
||||
# D-069: Clear dialogue/confrontation dip
|
||||
AudioManager.clear_dip()
|
||||
# D-069: Clear dialogue/confrontation dip — coordinator routes to AudioManager
|
||||
audio_dip_cleared.emit()
|
||||
|
||||
# D-061: unpause — signal to main.gd for input recording (#507)
|
||||
unpause_requested.emit()
|
||||
|
||||
# D-064: unblock movement immediately — log entries stay visible but don't block input.
|
||||
GameState.dialogue_active = false
|
||||
# D-064: unblock movement immediately — coordinator handles GameState update.
|
||||
dialogue_state_changed.emit(false)
|
||||
|
||||
# If no entries remain, hide the panel with fade.
|
||||
if _log_entries.is_empty():
|
||||
@@ -331,11 +336,11 @@ func hide_dialogue() -> void:
|
||||
_end_player_conversation()
|
||||
return # _end_player_conversation may call hide_dialogue if log is empty
|
||||
|
||||
GameState.dialogue_active = false
|
||||
dialogue_state_changed.emit(false) # D-020: coordinator handles GameState update
|
||||
|
||||
|
||||
func is_dialogue_active() -> bool:
|
||||
return _in_player_conversation or GameState.dialogue_active
|
||||
return _in_player_conversation
|
||||
|
||||
|
||||
func has_active_entries() -> bool:
|
||||
@@ -354,26 +359,45 @@ func _show_options(options: Array) -> void:
|
||||
var raw_text: String = opt.get("text", "")
|
||||
var is_confrontation: bool = opt.get("confrontation", false)
|
||||
|
||||
var label := Label.new()
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
|
||||
var numbered_text := "%d. %s" % [i + 1, raw_text]
|
||||
label.text = numbered_text
|
||||
# D-063: Confrontation options render italic — first-person voice, weighted differently.
|
||||
# Use RichTextLabel with BBCode [i] tags for confrontation; plain Label for standard.
|
||||
var ctrl: Control
|
||||
if is_confrontation:
|
||||
var rtl := RichTextLabel.new()
|
||||
rtl.bbcode_enabled = true
|
||||
rtl.fit_content = true
|
||||
rtl.scroll_active = false
|
||||
rtl.add_theme_font_size_override("normal_font_size", 14)
|
||||
rtl.add_theme_color_override("default_color", Color(
|
||||
Constants.INSERT_COLOR_TEXT.r * 1.08,
|
||||
Constants.INSERT_COLOR_TEXT.g * 0.96,
|
||||
Constants.INSERT_COLOR_TEXT.b * 0.90,
|
||||
1.0
|
||||
)) # Slight warm tint for confrontation weight
|
||||
rtl.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
rtl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
rtl.text = "[i]%d. %s[/i]" % [i + 1, raw_text]
|
||||
ctrl = rtl
|
||||
else:
|
||||
var label := Label.new()
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
label.text = "%d. %s" % [i + 1, raw_text]
|
||||
ctrl = label
|
||||
|
||||
var idx := i
|
||||
label.gui_input.connect(func(event: InputEvent):
|
||||
ctrl.gui_input.connect(func(event: InputEvent):
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
_on_option_pressed(idx)
|
||||
)
|
||||
label.mouse_entered.connect(_make_hover_on(label))
|
||||
label.mouse_exited.connect(_make_hover_off(label))
|
||||
ctrl.mouse_entered.connect(_make_hover_on(ctrl))
|
||||
ctrl.mouse_exited.connect(_make_hover_off(ctrl))
|
||||
|
||||
options_container.add_child(label)
|
||||
_option_controls.append(label)
|
||||
options_container.add_child(ctrl)
|
||||
_option_controls.append(ctrl)
|
||||
_option_response_ids.append(opt.get("response_id", ""))
|
||||
_option_texts.append(raw_text)
|
||||
_option_is_confrontation.append(is_confrontation)
|
||||
@@ -409,12 +433,12 @@ func _start_confrontation_beat(response_id: String, text: String) -> void:
|
||||
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
|
||||
|
||||
confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION)
|
||||
AudioManager.apply_dip("confrontation")
|
||||
audio_dip_requested.emit("confrontation") # D-069: coordinator routes to AudioManager
|
||||
|
||||
_beat_tween = create_tween()
|
||||
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
|
||||
_beat_tween.tween_callback(func():
|
||||
AudioManager.clear_dip()
|
||||
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
|
||||
option_selected.emit(response_id, text)
|
||||
_end_player_conversation()
|
||||
)
|
||||
@@ -424,7 +448,7 @@ func _cancel_beat() -> void:
|
||||
if _beat_tween and _beat_tween.is_valid():
|
||||
_beat_tween.kill()
|
||||
_beat_tween = null
|
||||
AudioManager.clear_dip()
|
||||
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
|
||||
|
||||
|
||||
# -- Log rendering --
|
||||
@@ -519,7 +543,7 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
|
||||
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
||||
static func _escape_bbcode(text: String) -> String:
|
||||
return text.replace("[", "[lb]")
|
||||
return text.replace("[", "[lb]").replace("]", "[rb]")
|
||||
|
||||
|
||||
## Get a stable color for a character name, with contrast floor enforcement.
|
||||
@@ -575,19 +599,22 @@ func _expire_entries() -> void:
|
||||
var removed := false
|
||||
var has_fading := false
|
||||
|
||||
# Remove expired non-pinned entries from the front (oldest first)
|
||||
while _log_entries.size() > 0:
|
||||
var entry: Dictionary = _log_entries[0]
|
||||
# Remove expired non-pinned entries (oldest first, skipping pinned)
|
||||
var i := 0
|
||||
while i < _log_entries.size():
|
||||
var entry: Dictionary = _log_entries[i]
|
||||
if entry.pinned:
|
||||
break # Pinned entries never expire
|
||||
i += 1
|
||||
continue # Pinned entries never expire — keep scanning
|
||||
var age: int = now - entry.timestamp_msec
|
||||
if age < total_lifetime_msec:
|
||||
# Check if this entry is in the fading phase
|
||||
if age > int(_entry_lifetime * 1000.0):
|
||||
has_fading = true
|
||||
break
|
||||
_log_entries.remove_at(0)
|
||||
_log_entries.remove_at(i)
|
||||
removed = true
|
||||
# Don't increment i — element at i is now the next entry
|
||||
|
||||
# Check remaining entries for fading state
|
||||
if not has_fading:
|
||||
@@ -623,11 +650,18 @@ func _clear_options() -> void:
|
||||
|
||||
|
||||
# Hover callbacks
|
||||
static func _make_hover_on(label: Control) -> Callable:
|
||||
static func _make_hover_on(ctrl: Control) -> Callable:
|
||||
return func():
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
|
||||
# RichTextLabel uses "default_color"; Label uses "font_color"
|
||||
if ctrl is RichTextLabel:
|
||||
ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
|
||||
else:
|
||||
ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
|
||||
|
||||
|
||||
static func _make_hover_off(label: Control) -> Callable:
|
||||
static func _make_hover_off(ctrl: Control) -> Callable:
|
||||
return func():
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
if ctrl is RichTextLabel:
|
||||
ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
|
||||
else:
|
||||
ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
extends Control
|
||||
|
||||
## Examine result display — #174, D-061 adjacent.
|
||||
##
|
||||
## Shows the character-filtered text returned by the Examine verb (#242).
|
||||
## Non-interactive overlay. Auto-dismisses after DISMISS_DELAY seconds.
|
||||
## Diegetic: reads as the neural insert processing what the character observed.
|
||||
##
|
||||
## Positioned in InsertOverlay (CanvasLayer 10, z-layer 6).
|
||||
## Only one examine result is shown at a time — new result replaces old.
|
||||
|
||||
const DISMISS_DELAY: float = 5.0 # Auto-dismiss after 5 seconds
|
||||
const FADE_IN: float = 0.18
|
||||
const FADE_OUT: float = 0.35
|
||||
|
||||
# Confidence → alpha modifier: Direct is brightest, Suspects is dimmest
|
||||
const CONFIDENCE_ALPHA: Dictionary = {
|
||||
"Direct": 1.0,
|
||||
"KnowsDetails": 0.9,
|
||||
"KnowsOf": 0.75,
|
||||
"Suspects": 0.6,
|
||||
}
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
|
||||
|
||||
var _dismiss_tween: Tween = null
|
||||
var _active: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
modulate.a = 0.0
|
||||
visible = false
|
||||
|
||||
|
||||
## Show an examine result. Called from main.gd when GameState.current_examine_result is set.
|
||||
## result: {entity_id, text, confidence}
|
||||
func show_result(result: Dictionary) -> void:
|
||||
var text: String = result.get("text", "")
|
||||
var confidence: String = result.get("confidence", "KnowsOf")
|
||||
|
||||
if text.is_empty():
|
||||
return
|
||||
|
||||
# Cancel any in-progress dismiss
|
||||
if _dismiss_tween and _dismiss_tween.is_valid():
|
||||
_dismiss_tween.kill()
|
||||
|
||||
# Apply confidence-based alpha to the insert color
|
||||
var alpha: float = CONFIDENCE_ALPHA.get(confidence, 0.75)
|
||||
var col := Color(Constants.INSERT_COLOR_TEXT.r, Constants.INSERT_COLOR_TEXT.g,
|
||||
Constants.INSERT_COLOR_TEXT.b, alpha)
|
||||
text_label.add_theme_color_override("default_color", col)
|
||||
text_label.text = text
|
||||
|
||||
visible = true
|
||||
_active = true
|
||||
modulate.a = 0.0
|
||||
|
||||
# Fade in, then auto-dismiss
|
||||
_dismiss_tween = create_tween()
|
||||
_dismiss_tween.tween_property(self, "modulate:a", 1.0, FADE_IN)
|
||||
_dismiss_tween.tween_interval(DISMISS_DELAY)
|
||||
_dismiss_tween.tween_callback(_start_fade_out)
|
||||
|
||||
|
||||
func _start_fade_out() -> void:
|
||||
if not _active:
|
||||
return
|
||||
var t := create_tween()
|
||||
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
t.tween_callback(func():
|
||||
visible = false
|
||||
_active = false
|
||||
)
|
||||
|
||||
|
||||
## Dismiss immediately (e.g. when dialogue opens).
|
||||
func dismiss() -> void:
|
||||
if not _active:
|
||||
return
|
||||
if _dismiss_tween and _dismiss_tween.is_valid():
|
||||
_dismiss_tween.kill()
|
||||
_start_fade_out()
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return _active
|
||||
@@ -0,0 +1,50 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://examine_display_sr"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/examine_display.gd" id="1_examine"]
|
||||
|
||||
; ExamineDisplay — non-interactive observe result overlay. D-013, #174.
|
||||
; Auto-dismisses after 5s. Positioned center-right, 40% from top.
|
||||
; InsertOverlay (CanvasLayer 10). Diegetic: insert processing observed data.
|
||||
|
||||
[node name="ExamineDisplay" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 3
|
||||
anchor_left = 1.0
|
||||
anchor_top = 0.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 0.0
|
||||
offset_left = -440.0
|
||||
offset_top = 120.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = 240.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
modulate = Color(1, 1, 1, 0)
|
||||
script = ExtResource("1_examine")
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 12
|
||||
theme_override_constants/margin_top = 8
|
||||
theme_override_constants/margin_right = 12
|
||||
theme_override_constants/margin_bottom = 8
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="TextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"]
|
||||
layout_mode = 2
|
||||
bbcode_enabled = true
|
||||
fit_content = true
|
||||
scroll_active = false
|
||||
mouse_filter = 2
|
||||
theme_override_font_sizes/normal_font_size = 13
|
||||
theme_override_colors/default_color = Color(0.784, 0.816, 0.878, 0.75)
|
||||
@@ -29,6 +29,8 @@ var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
|
||||
var _selected_index: int = 0
|
||||
var _active_tween: Tween = null
|
||||
var _verb_labels: Array[Label] = []
|
||||
# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar
|
||||
var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBox
|
||||
|
||||
@@ -39,10 +41,14 @@ func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
var _last_screen_pos: Vector2 = Vector2.ZERO
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _showing:
|
||||
_update_screen_position()
|
||||
queue_redraw()
|
||||
if position != _last_screen_pos:
|
||||
_last_screen_pos = position
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
@@ -52,6 +58,9 @@ func _draw() -> void:
|
||||
var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2)
|
||||
draw_rect(bg_rect, INSERT_BG)
|
||||
draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0)
|
||||
# #537: D-033 relationship color accent — 3px left-edge bar signals NPC relationship
|
||||
var bar_rect := Rect2(-pad, -pad, 3.0, bg_rect.size.y)
|
||||
draw_rect(bar_rect, _relationship_color * Color(1, 1, 1, 0.85))
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
@@ -88,6 +97,7 @@ func update_from_state() -> void:
|
||||
_verb_items = sorted
|
||||
_selected_index = 0
|
||||
_cache_entity_position()
|
||||
_cache_entity_relationship()
|
||||
_rebuild_labels()
|
||||
_show()
|
||||
|
||||
@@ -127,6 +137,17 @@ func _cache_entity_position() -> void:
|
||||
return
|
||||
|
||||
|
||||
## #537: Cache relationship color for the target entity (D-033 palette).
|
||||
## Falls back to IMPLANT_TEXT_DIM for non-NPC or unknown entities.
|
||||
func _cache_entity_relationship() -> void:
|
||||
for entity in GameState.visible_entities:
|
||||
if entity.get("entity_id") == _current_target_id:
|
||||
var rel: String = str(entity.get("relationship", "Unknown"))
|
||||
_relationship_color = Constants.color_for_relationship(rel)
|
||||
return
|
||||
_relationship_color = Constants.IMPLANT_TEXT_DIM
|
||||
|
||||
|
||||
## Convert entity world position to screen coords and reposition this Control.
|
||||
## Runs every frame while showing so the list tracks the entity as the camera moves.
|
||||
func _update_screen_position() -> void:
|
||||
@@ -187,6 +208,10 @@ func get_visible_verb_count() -> int:
|
||||
return _verb_items.size()
|
||||
|
||||
|
||||
func hide_list() -> void:
|
||||
_hide()
|
||||
|
||||
|
||||
func is_showing() -> bool:
|
||||
return _showing
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
extends Control
|
||||
|
||||
## Journal panel — knowledge graph review. #264, D-041.
|
||||
##
|
||||
## Toggle with J key. Displays accumulated facts from GameState.player_knowledge
|
||||
## grouped by entity. Read-only — no player interaction beyond scrolling.
|
||||
##
|
||||
## KnowledgeState rendering:
|
||||
## Active → normal color (INSERT_COLOR_TEXT)
|
||||
## Stale → dimmed (IMPLANT_TEXT_DIM)
|
||||
## Contradicted → amber tint + strikethrough (ENTITY_COLOR_POI) — THE FRIEND arc surface
|
||||
##
|
||||
## KnowledgeConfidence labels (D-041):
|
||||
## Direct → KnowsDetails → KnowsOf → Suspects
|
||||
##
|
||||
## Cannot be open simultaneously with dialogue (sprint briefing constraint).
|
||||
## Closes when dialogue opens.
|
||||
|
||||
const FADE_IN: float = 0.18
|
||||
const FADE_OUT: float = 0.25
|
||||
|
||||
# D-041/D-042: confidence and source labels loaded from UIStrings (data/ui-strings.yaml).
|
||||
# Keys: knowledge_panel.confidence_{lower} and knowledge_panel.source_{lower}
|
||||
# Fallback: raw value if key not found (UIStrings returns the key itself).
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel
|
||||
@onready var scroll: ScrollContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer
|
||||
@onready var entries_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
|
||||
|
||||
var _visible_state: bool = false
|
||||
var _last_rendered_tick: int = -1
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
visible = false
|
||||
modulate.a = 0.0
|
||||
title_label.text = UIStrings.get_text("knowledge_panel.tab_contacts")
|
||||
|
||||
|
||||
## Toggle open/close. Called from main.gd on J key press.
|
||||
func toggle() -> void:
|
||||
if _visible_state:
|
||||
_hide_panel()
|
||||
else:
|
||||
_show_panel()
|
||||
|
||||
|
||||
## Force-close. Called when dialogue opens.
|
||||
func close() -> void:
|
||||
if _visible_state:
|
||||
_hide_panel()
|
||||
|
||||
|
||||
func is_open() -> bool:
|
||||
return _visible_state
|
||||
|
||||
|
||||
func _show_panel() -> void:
|
||||
_rebuild_entries()
|
||||
_visible_state = true
|
||||
visible = true
|
||||
var t := create_tween()
|
||||
t.tween_property(self, "modulate:a", 1.0, FADE_IN)
|
||||
|
||||
|
||||
func _hide_panel() -> void:
|
||||
_visible_state = false
|
||||
var t := create_tween()
|
||||
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
t.tween_callback(func(): visible = false)
|
||||
|
||||
|
||||
## Rebuild the entry list from GameState.player_knowledge.
|
||||
func _rebuild_entries() -> void:
|
||||
# Clear previous entries
|
||||
for child in entries_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var knowledge: Variant = GameState.player_knowledge
|
||||
if knowledge == null:
|
||||
_add_empty_state()
|
||||
return
|
||||
|
||||
var entities: Array = knowledge.get("entities", [])
|
||||
if entities.is_empty():
|
||||
_add_empty_state()
|
||||
return
|
||||
|
||||
for entity in entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
_add_entity_entry(entity)
|
||||
|
||||
|
||||
func _add_empty_state() -> void:
|
||||
var label := Label.new()
|
||||
label.text = UIStrings.get_text("knowledge_panel.empty_state")
|
||||
label.add_theme_color_override("font_color", Constants.IMPLANT_TEXT_DIM)
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
entries_container.add_child(label)
|
||||
|
||||
|
||||
func _add_entity_entry(entity: Dictionary) -> void:
|
||||
var name_str: String = entity.get("name", "Unknown")
|
||||
var confidence: String = entity.get("confidence", "Suspects")
|
||||
var source: String = entity.get("source", "")
|
||||
var state: String = entity.get("state", "Active")
|
||||
var relationship: String = entity.get("relationship", "Unknown")
|
||||
|
||||
# Entity header — name + relationship status
|
||||
var header_rtl := RichTextLabel.new()
|
||||
header_rtl.bbcode_enabled = true
|
||||
header_rtl.fit_content = true
|
||||
header_rtl.scroll_active = false
|
||||
header_rtl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
header_rtl.add_theme_font_size_override("normal_font_size", 14)
|
||||
|
||||
var rel_color: Color = Constants.color_for_relationship(relationship)
|
||||
var rel_label: String = UIStrings.get_text("relationship_states.%s.label" % relationship.to_lower())
|
||||
if rel_label == "relationship_states.%s.label" % relationship.to_lower():
|
||||
rel_label = relationship # fallback if key missing
|
||||
|
||||
var state_color: Color = _state_color(state)
|
||||
var conf_label: String = UIStrings.get_text("knowledge_panel.confidence_%s" % confidence.to_lower())
|
||||
var src_label: String = _resolve_source_label(source)
|
||||
|
||||
# Build BBCode:
|
||||
# [color=#hex][b]Name[/b][/color] [color=#rel_hex]Status[/color]
|
||||
var name_hex: String = state_color.to_html(false)
|
||||
var rel_hex: String = rel_color.to_html(false)
|
||||
|
||||
var header_bbcode: String
|
||||
if state == "Contradicted":
|
||||
header_bbcode = "[color=#%s][b][s]%s[/s][/b][/color] [color=#%s]%s[/color]" % [
|
||||
name_hex, name_str, rel_hex, rel_label
|
||||
]
|
||||
else:
|
||||
header_bbcode = "[color=#%s][b]%s[/b][/color] [color=#%s]%s[/color]" % [
|
||||
name_hex, name_str, rel_hex, rel_label
|
||||
]
|
||||
header_rtl.text = header_bbcode
|
||||
entries_container.add_child(header_rtl)
|
||||
|
||||
# Detail line — confidence + source
|
||||
var detail_rtl := RichTextLabel.new()
|
||||
detail_rtl.bbcode_enabled = true
|
||||
detail_rtl.fit_content = true
|
||||
detail_rtl.scroll_active = false
|
||||
detail_rtl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
detail_rtl.add_theme_font_size_override("normal_font_size", 12)
|
||||
|
||||
var detail_hex: String = Constants.IMPLANT_TEXT_DIM.to_html(false)
|
||||
var detail_text: String = "%s · %s" % [conf_label, src_label] if src_label else conf_label
|
||||
detail_rtl.text = "[color=#%s]%s[/color]" % [detail_hex, detail_text]
|
||||
entries_container.add_child(detail_rtl)
|
||||
|
||||
# Spacer between entries
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, 6)
|
||||
entries_container.add_child(spacer)
|
||||
|
||||
|
||||
## Resolve a source string from the server into a human-readable label.
|
||||
## Handles plain keys ("DirectObservation", "Heard") and ToldBy(N) format.
|
||||
## ToldBy(N) looks up entity_id N in player_knowledge.entities for the name.
|
||||
func _resolve_source_label(source: String) -> String:
|
||||
if source.is_empty():
|
||||
return ""
|
||||
|
||||
# ToldBy(entity_id) format — e.g. "ToldBy(12)"
|
||||
if source.begins_with("ToldBy(") and source.ends_with(")"):
|
||||
var id_str: String = source.substr(7, source.length() - 8)
|
||||
var entity_id: int = id_str.to_int()
|
||||
var name := _entity_name_for_id(entity_id)
|
||||
var told_prefix: String = UIStrings.get_text("knowledge_panel.source_toldby")
|
||||
if told_prefix == "knowledge_panel.source_toldby":
|
||||
told_prefix = "Told"
|
||||
return "%s: %s" % [told_prefix, name]
|
||||
|
||||
# Plain source key — look up UIStrings
|
||||
var key: String = "knowledge_panel.source_%s" % source.to_lower()
|
||||
return UIStrings.get_text(key)
|
||||
|
||||
|
||||
## Look up an entity name by stable ID from GameState.player_knowledge.entities.
|
||||
func _entity_name_for_id(entity_id: int) -> String:
|
||||
var knowledge: Variant = GameState.player_knowledge
|
||||
if knowledge == null:
|
||||
return "#%d" % entity_id
|
||||
for entity in knowledge.get("entities", []):
|
||||
if entity is Dictionary and int(entity.get("entity_id", -1)) == entity_id:
|
||||
return entity.get("name", "#%d" % entity_id)
|
||||
return "#%d" % entity_id
|
||||
|
||||
|
||||
func _state_color(state: String) -> Color:
|
||||
match state:
|
||||
"Contradicted":
|
||||
return Constants.ENTITY_COLOR_POI # amber — THE FRIEND arc
|
||||
"Stale":
|
||||
return Constants.IMPLANT_TEXT_DIM # dimmed
|
||||
_:
|
||||
return Constants.INSERT_COLOR_TEXT # active — normal
|
||||
|
||||
|
||||
## Called from main.gd on each snapshot to close the panel when dialogue opens.
|
||||
func update_from_state() -> void:
|
||||
if _visible_state and GameState.dialogue_active:
|
||||
close()
|
||||
# Rebuild if open and knowledge data is newer than last render
|
||||
if _visible_state and GameState.current_tick != _last_rendered_tick:
|
||||
_last_rendered_tick = GameState.current_tick
|
||||
_rebuild_entries()
|
||||
@@ -0,0 +1,63 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://journal_panel_sr"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/journal_panel.gd" id="1_journal"]
|
||||
|
||||
; JournalPanel — knowledge graph review. #264, D-041.
|
||||
; Toggle J key. Right side, 380px wide, full height minus margins.
|
||||
; InsertOverlay (CanvasLayer 10, z-layer 6). Read-only, closes with dialogue.
|
||||
|
||||
[node name="JournalPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 3
|
||||
anchor_left = 1.0
|
||||
anchor_top = 0.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -400.0
|
||||
offset_top = 16.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = -16.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
modulate = Color(1, 1, 1, 0)
|
||||
script = ExtResource("1_journal")
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 14
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 14
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Contacts"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 0.9)
|
||||
|
||||
[node name="Separator" type="HSeparator" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/separator_color = Color(0.784, 0.816, 0.878, 0.2)
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="EntriesContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 2
|
||||
@@ -0,0 +1,53 @@
|
||||
extends Control
|
||||
## #258: Main menu — New Game / Continue / Quit.
|
||||
## New Game: generates per-game save directory (D-085), starts game.
|
||||
## Continue: loads most recent save directory (#554 full loading screen deferred).
|
||||
|
||||
const GAME_SCENE := "res://scenes/main.tscn"
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0)
|
||||
const TITLE_COLOR := Color("#c8d0e0")
|
||||
const SUBTITLE_COLOR := Color("#8890a0")
|
||||
const BTN_NORMAL_COLOR := Color("#e8c547")
|
||||
const BTN_DISABLED_COLOR := Color("#4a5060")
|
||||
const FONT_SIZE_TITLE := 36
|
||||
const FONT_SIZE_SUBTITLE := 14
|
||||
const FONT_SIZE_BTN := 15
|
||||
|
||||
@onready var _new_game_btn: Button = $VBox/NewGameBtn
|
||||
@onready var _continue_btn: Button = $VBox/ContinueBtn
|
||||
@onready var _quit_btn: Button = $VBox/QuitBtn
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_new_game_btn.pressed.connect(_on_new_game)
|
||||
_continue_btn.pressed.connect(_on_continue)
|
||||
_quit_btn.pressed.connect(_on_quit)
|
||||
_refresh_continue_state()
|
||||
|
||||
|
||||
func _refresh_continue_state() -> void:
|
||||
var saves := SessionManager.list_game_dirs()
|
||||
_continue_btn.disabled = saves.is_empty()
|
||||
|
||||
|
||||
func _on_new_game() -> void:
|
||||
var game_id := SessionManager.new_game()
|
||||
if game_id.is_empty():
|
||||
push_error("MainMenu: new_game() failed to create save directory — cannot start")
|
||||
return
|
||||
get_tree().change_scene_to_file(GAME_SCENE)
|
||||
|
||||
|
||||
func _on_continue() -> void:
|
||||
# #554: Full loading screen blocked until server SaveCommand lands.
|
||||
# For now: automatically load the most recent game directory.
|
||||
var saves := SessionManager.list_game_dirs()
|
||||
if saves.is_empty():
|
||||
return
|
||||
SessionManager.resume_game(saves[0].game_id)
|
||||
get_tree().change_scene_to_file(GAME_SCENE)
|
||||
|
||||
|
||||
func _on_quit() -> void:
|
||||
get_tree().quit()
|
||||
@@ -1,15 +1,172 @@
|
||||
class_name MinimapRenderer
|
||||
extends Control
|
||||
|
||||
# Minimap — small viewport showing local area
|
||||
# Placeholder implementation for now
|
||||
## Minimap — diegetic neural insert overlay. D-013, D-049 z-layer 6.
|
||||
##
|
||||
## Player always centered. Fixed-north (no rotation per D-015).
|
||||
## Nearby POIs (within MINIMAP_RADIUS sim tiles): colored dot/shape at scaled position.
|
||||
## Distant POIs (beyond radius): directional arrow at circle border pointing toward POI.
|
||||
## Frame renders always — the insert is on even when no POIs are discovered.
|
||||
##
|
||||
## POI categories → shapes:
|
||||
## danger / threat / hostile → diamond (ENTITY_COLOR_HOSTILE, red)
|
||||
## evidence / note / clue → square (ENTITY_COLOR_POI, amber)
|
||||
## contact / npc / person → circle (ENTITY_COLOR_UNKNOWN, teal)
|
||||
## location / place / venue → circle (INSERT_COLOR_TEXT, white-blue)
|
||||
## (default) → circle (INSERT_COLOR_TEXT)
|
||||
|
||||
@onready var viewport_container: SubViewportContainer = $SubViewportContainer
|
||||
## Sim tiles visible within the minimap circle. POIs beyond this show as border arrows.
|
||||
const MINIMAP_RADIUS: float = 24.0
|
||||
|
||||
# Visual parameters
|
||||
const FRAME_WIDTH: float = 1.2
|
||||
const PLAYER_DOT_RADIUS: float = 3.5
|
||||
const POI_DOT_RADIUS: float = 3.0
|
||||
const ARROW_HALF: float = 4.5
|
||||
const ARROW_LEN: float = 7.0
|
||||
const NORTH_TICK_LEN: float = 8.0
|
||||
const CARDINAL_TICK_LEN: float = 4.0
|
||||
|
||||
# Colors — insert palette from Constants, tuned for the circular minimap frame
|
||||
const COLOR_BG: Color = Color(0.04, 0.07, 0.12, 0.82)
|
||||
const COLOR_FRAME: Color = Color(0.784, 0.816, 0.878, 0.55) # INSERT_COLOR_TEXT at reduced alpha
|
||||
const COLOR_NORTH: Color = Color(0.784, 0.816, 0.878, 0.9) # Brighter for N tick
|
||||
const COLOR_CARDINAL: Color = Color(0.784, 0.816, 0.878, 0.4) # Dimmer E/S/W ticks
|
||||
const COLOR_PLAYER: Color = Constants.ENTITY_COLOR_PLAYER
|
||||
|
||||
var _insert_active: bool = true
|
||||
|
||||
func _ready() -> void:
|
||||
print("Minimap: Initialized")
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_process(true)
|
||||
|
||||
# Update minimap view (stub)
|
||||
func update_minimap(player_pos: Vector2, entities: Array) -> void:
|
||||
# TODO: Render minimap view
|
||||
# This will show a top-down view of the local area
|
||||
pass
|
||||
func _process(_delta: float) -> void:
|
||||
if _insert_active:
|
||||
queue_redraw()
|
||||
|
||||
## Called from main.gd when GameState.insert_active changes.
|
||||
## Hides the minimap overlay when the neural insert is inactive.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
visible = active
|
||||
|
||||
func _draw() -> void:
|
||||
var sz: Vector2 = get_rect().size
|
||||
var center := sz / 2.0
|
||||
# Outer radius: fill control with 1px edge padding
|
||||
var outer_r: float = minf(sz.x, sz.y) / 2.0 - 1.0
|
||||
|
||||
# --- Background fill ---
|
||||
draw_circle(center, outer_r, COLOR_BG)
|
||||
|
||||
# --- Frame ring ---
|
||||
draw_arc(center, outer_r, 0.0, TAU, 64, COLOR_FRAME, FRAME_WIDTH, true)
|
||||
|
||||
# --- Cardinal ticks (N brighter, E/S/W dimmer) ---
|
||||
_draw_cardinal_ticks(center, outer_r)
|
||||
|
||||
# --- Player dot at center ---
|
||||
draw_circle(center, PLAYER_DOT_RADIUS, COLOR_PLAYER)
|
||||
# Soft bloom ring
|
||||
draw_arc(center, PLAYER_DOT_RADIUS + 1.5, 0.0, TAU, 32,
|
||||
Color(COLOR_PLAYER.r, COLOR_PLAYER.g, COLOR_PLAYER.b, 0.22), 1.0, true)
|
||||
|
||||
# --- POIs ---
|
||||
var pois: Array = GameState.discovered_pois
|
||||
if pois.is_empty():
|
||||
return
|
||||
|
||||
var px: float = GameState.player_position.x
|
||||
var py: float = GameState.player_position.y
|
||||
# Pixels per sim tile within the inner drawable area
|
||||
var inner_r: float = outer_r - FRAME_WIDTH
|
||||
var scale: float = inner_r / MINIMAP_RADIUS
|
||||
|
||||
for poi in pois:
|
||||
if not poi is Dictionary:
|
||||
continue
|
||||
if not poi.has("x") or not poi.has("y"):
|
||||
continue
|
||||
|
||||
var dx: float = float(poi.x) - px
|
||||
var dy: float = float(poi.y) - py
|
||||
var dist: float = sqrt(dx * dx + dy * dy)
|
||||
var category: String = poi.get("poi_category", "")
|
||||
var color: Color = _category_color(category)
|
||||
|
||||
if dist < 0.01:
|
||||
# POI at exact player position — draw at center offset slightly
|
||||
_draw_poi_shape(center + Vector2(0.0, -POI_DOT_RADIUS - 2.0), color, category)
|
||||
elif dist <= MINIMAP_RADIUS:
|
||||
# Nearby: project to screen position within circle
|
||||
var poi_screen := center + Vector2(dx, dy) * scale
|
||||
# Hard-clamp to inner circle boundary (guards floating-point edge cases)
|
||||
var rel := poi_screen - center
|
||||
if rel.length() > inner_r - POI_DOT_RADIUS - 1.0:
|
||||
poi_screen = center + rel.normalized() * (inner_r - POI_DOT_RADIUS - 1.0)
|
||||
_draw_poi_shape(poi_screen, color, category)
|
||||
else:
|
||||
# Distant: arrow at border pointing toward POI direction
|
||||
var dir := Vector2(dx, dy).normalized()
|
||||
var arrow_tip := center + dir * (inner_r - 2.0)
|
||||
_draw_border_arrow(arrow_tip, dir, color)
|
||||
|
||||
|
||||
func _draw_cardinal_ticks(center: Vector2, outer_r: float) -> void:
|
||||
# North tick — longer, brighter, the fixed-north indicator
|
||||
var n_dir := Vector2(0.0, -1.0)
|
||||
draw_line(
|
||||
center + n_dir * (outer_r - NORTH_TICK_LEN),
|
||||
center + n_dir * outer_r,
|
||||
COLOR_NORTH, FRAME_WIDTH + 0.5, true
|
||||
)
|
||||
# East (PI/2), South (PI), West (3PI/2) — shorter, dimmer
|
||||
for angle in [PI / 2.0, PI, 3.0 * PI / 2.0]:
|
||||
var dir := Vector2(cos(angle), sin(angle))
|
||||
draw_line(
|
||||
center + dir * (outer_r - CARDINAL_TICK_LEN),
|
||||
center + dir * outer_r,
|
||||
COLOR_CARDINAL, FRAME_WIDTH, true
|
||||
)
|
||||
|
||||
|
||||
func _draw_poi_shape(pos: Vector2, color: Color, category: String) -> void:
|
||||
match category.to_lower():
|
||||
"danger", "threat", "hostile":
|
||||
# Diamond for danger
|
||||
var s: float = POI_DOT_RADIUS + 1.0
|
||||
draw_polygon(
|
||||
PackedVector2Array([
|
||||
pos + Vector2(0.0, -s), pos + Vector2(s, 0.0),
|
||||
pos + Vector2(0.0, s), pos + Vector2(-s, 0.0)
|
||||
]),
|
||||
PackedColorArray([color, color, color, color])
|
||||
)
|
||||
"evidence", "note", "clue":
|
||||
# Square for evidence/clue
|
||||
var s: float = POI_DOT_RADIUS - 0.5
|
||||
draw_rect(Rect2(pos - Vector2(s, s), Vector2(s * 2.0, s * 2.0)), color)
|
||||
_:
|
||||
draw_circle(pos, POI_DOT_RADIUS, color)
|
||||
|
||||
|
||||
## Arrow tip at `tip`, pointing in `dir`. Arrow body extends ARROW_LEN back from tip.
|
||||
func _draw_border_arrow(tip: Vector2, dir: Vector2, color: Color) -> void:
|
||||
var perp := Vector2(-dir.y, dir.x)
|
||||
var base_center := tip - dir * ARROW_LEN
|
||||
draw_polygon(
|
||||
PackedVector2Array([tip, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]),
|
||||
PackedColorArray([color, color, color])
|
||||
)
|
||||
|
||||
|
||||
func _category_color(category: String) -> Color:
|
||||
match category.to_lower():
|
||||
"danger", "threat", "hostile":
|
||||
return Constants.ENTITY_COLOR_HOSTILE # #d45d5d — red
|
||||
"evidence", "note", "clue":
|
||||
return Constants.ENTITY_COLOR_POI # #e8c547 — amber
|
||||
"contact", "npc", "person":
|
||||
return Constants.ENTITY_COLOR_UNKNOWN # #4a9ebb — teal
|
||||
_:
|
||||
return Constants.INSERT_COLOR_TEXT # #c8d0e0 — white-blue
|
||||
|
||||
@@ -2,33 +2,21 @@
|
||||
|
||||
[ext_resource type="Script" path="res://ui/minimap.gd" id="1_minimap"]
|
||||
|
||||
[node name="Minimap" type="Control"]
|
||||
; MinimapRenderer — diegetic neural insert overlay (D-013, D-049 z-layer 6).
|
||||
; 160x160px circle, top-right corner with 16px margin.
|
||||
; Rendered via _draw() — no SubViewport needed.
|
||||
; Positioned in InsertOverlay (CanvasLayer 10) by main.tscn.
|
||||
|
||||
[node name="MinimapRenderer" type="Control"]
|
||||
custom_minimum_size = Vector2(160, 160)
|
||||
layout_mode = 3
|
||||
anchors_preset = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -200.0
|
||||
offset_bottom = 200.0
|
||||
offset_left = -176.0
|
||||
offset_top = 16.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = 176.0
|
||||
grow_horizontal = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_minimap")
|
||||
|
||||
[node name="SubViewportContainer" type="SubViewportContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="SubViewport" type="SubViewport" parent="SubViewportContainer"]
|
||||
size = Vector2i(200, 200)
|
||||
render_target_update_mode = 4
|
||||
|
||||
[node name="Background" type="ColorRect" parent="SubViewportContainer/SubViewport"]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0.1, 0.1, 0.1, 0.7)
|
||||
|
||||
@@ -35,6 +35,8 @@ const _LATTICE_COLORS: Dictionary = {
|
||||
}
|
||||
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
|
||||
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
||||
const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification
|
||||
const _NOTIFICATION_DURATION: float = 2.5
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBoxContainer
|
||||
|
||||
@@ -62,7 +64,10 @@ func _process(delta: float) -> void:
|
||||
var now := float(Time.get_ticks_msec())
|
||||
if now >= _next_fade_in_msec:
|
||||
var next: Dictionary = _queue.pop_front()
|
||||
_show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile)
|
||||
if next.get("is_notification", false):
|
||||
_show_notification_line(next.text)
|
||||
else:
|
||||
_show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile)
|
||||
|
||||
|
||||
# Display a monologue line.
|
||||
@@ -71,6 +76,26 @@ func _process(delta: float) -> void:
|
||||
# Empty text is silently ignored — no slot created, no queue entry.
|
||||
# lattice_profile is read from GameState here and passed down — renderer stays
|
||||
# decoupled from the autoload (D-020 renderer contract).
|
||||
# #554: Show a brief system notification (save/load result, connection status).
|
||||
# Uses neutral color, short duration, bypasses lattice_profile styling.
|
||||
func show_notification(text: String) -> void:
|
||||
if text.is_empty():
|
||||
return
|
||||
var now := float(Time.get_ticks_msec())
|
||||
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
|
||||
_show_notification_line(text)
|
||||
else:
|
||||
var entry := {text = text, duration = _NOTIFICATION_DURATION, priority = 1, is_urgent = false, lattice_profile = "", is_notification = true}
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
_queue.append(entry)
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
else:
|
||||
var lowest := _lowest_priority_idx()
|
||||
if 1 >= _queue[lowest].priority:
|
||||
_queue[lowest] = entry
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
|
||||
|
||||
func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void:
|
||||
if text.is_empty():
|
||||
return
|
||||
@@ -86,6 +111,35 @@ func show_monologue(text: String, duration: float, priority: int = 2, is_urgent:
|
||||
# Internal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _show_notification_line(text: String) -> void:
|
||||
var container := MarginContainer.new()
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
container.add_theme_constant_override("margin_right", 4)
|
||||
container.add_theme_constant_override("margin_top", 2)
|
||||
container.add_theme_constant_override("margin_bottom", 2)
|
||||
var label := RichTextLabel.new()
|
||||
label.bbcode_enabled = true
|
||||
label.fit_content = true
|
||||
label.scroll_active = false
|
||||
label.add_theme_font_size_override("normal_font_size", 13)
|
||||
var safe_text := text.replace("[", "[lb]")
|
||||
label.text = "[color=#%s]%s[/color]" % [_NOTIFICATION_COLOR.to_html(false), safe_text]
|
||||
container.add_child(label)
|
||||
_vbox.add_child(container)
|
||||
var slot := {
|
||||
node = container,
|
||||
expire_timer = _NOTIFICATION_DURATION,
|
||||
priority = 1,
|
||||
tween = null,
|
||||
}
|
||||
_visible.append(slot)
|
||||
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
|
||||
container.modulate.a = 0.0
|
||||
var tween := create_tween()
|
||||
slot.tween = tween
|
||||
tween.tween_property(container, "modulate:a", 0.85, FADE_IN_SEC)
|
||||
|
||||
|
||||
func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
||||
var line_node := _build_line_node(text, is_urgent, lattice_profile)
|
||||
_vbox.add_child(line_node)
|
||||
|
||||
@@ -121,6 +121,14 @@ func _build_ui() -> void:
|
||||
close_btn.pressed.connect(close)
|
||||
_container.add_child(close_btn)
|
||||
|
||||
# Quit to Menu button (#258: D-085 session management)
|
||||
var quit_btn := Button.new()
|
||||
quit_btn.text = UIStrings.get_text("menu.quit_to_menu")
|
||||
quit_btn.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
quit_btn.add_theme_color_override("font_color", Color("#c87040"))
|
||||
quit_btn.pressed.connect(_on_quit_to_menu)
|
||||
_container.add_child(quit_btn)
|
||||
|
||||
|
||||
func _destroy_ui() -> void:
|
||||
if _container:
|
||||
@@ -153,6 +161,11 @@ func _draw() -> void:
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
|
||||
|
||||
|
||||
func _on_quit_to_menu() -> void:
|
||||
close()
|
||||
SessionManager.quit_to_menu()
|
||||
|
||||
|
||||
static func _format_db(db: float) -> String:
|
||||
if db <= -40.0:
|
||||
return "mute"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
extends Control
|
||||
|
||||
## #263: Time display — diegetic time readout on the player's neural insert (D-013, D-031).
|
||||
## Shows station local time (HH:MM), day phase, and day number.
|
||||
## Lives on InsertOverlay (CanvasLayer 10) per D-051 diegetic insert principle.
|
||||
## Draw-based for implant visual aesthetic. Updated via update_from_state() from main.gd.
|
||||
##
|
||||
## Placeholder layout — position and style will be refined when #314 wireframe lands.
|
||||
|
||||
const FONT_SIZE_TIME: int = 15
|
||||
const FONT_SIZE_META: int = 10
|
||||
const PADDING := Vector2(10, 7)
|
||||
const BG_COLOR := Color(0.04, 0.05, 0.08, 0.70)
|
||||
const BORDER_COLOR := Color(0.10, 0.20, 0.26, 0.65)
|
||||
|
||||
# Day phase colors — station lighting cycle (D-031)
|
||||
const PHASE_COLORS := {
|
||||
"Morning": Color("#aed6dc"), # pale cyan-blue — early light
|
||||
"Afternoon": Color("#E0F7FA"), # bright cyan-white — full day
|
||||
"Evening": Color("#9EBFC4"), # dimmed — dusk transition
|
||||
"Night": Color("#4a7080"), # dark teal — station nightwatch
|
||||
}
|
||||
|
||||
var _time_str: String = "--:--"
|
||||
var _phase_str: String = ""
|
||||
var _day_str: String = ""
|
||||
var _day_text: String = ""
|
||||
var _phase_color: Color = Constants.IMPLANT_TEXT_DIM
|
||||
var _last_signature: String = ""
|
||||
var _has_data: bool = false
|
||||
|
||||
# Cached geometry — recomputed in update_from_state(), used in _draw()
|
||||
var _time_size: Vector2 = Vector2.ZERO
|
||||
var _phase_size: Vector2 = Vector2.ZERO
|
||||
var _day_size: Vector2 = Vector2.ZERO
|
||||
var _meta_h: float = 0.0
|
||||
var _box_w: float = 0.0
|
||||
var _box_h: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
var gt: Dictionary = GameState.game_time
|
||||
if gt.is_empty():
|
||||
return
|
||||
var tod: int = int(gt.get("time_of_day", 0))
|
||||
var day: int = int(gt.get("day", 0))
|
||||
var phase: String = str(gt.get("day_phase", ""))
|
||||
var sig: String = "%d:%d:%s" % [tod, day, phase]
|
||||
if sig == _last_signature:
|
||||
return
|
||||
_last_signature = sig
|
||||
_time_str = Constants.format_game_time(tod)
|
||||
_phase_str = phase
|
||||
_day_str = "D%d" % (day + 1)
|
||||
_day_text = " " + _day_str
|
||||
_phase_color = PHASE_COLORS.get(phase, Constants.IMPLANT_TEXT_DIM)
|
||||
_has_data = true
|
||||
_cache_geometry()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _cache_geometry() -> void:
|
||||
var font := ThemeDB.fallback_font
|
||||
_time_size = font.get_string_size(_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME)
|
||||
_phase_size = font.get_string_size(_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
|
||||
_day_size = font.get_string_size(_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
|
||||
_meta_h = font.get_string_size("A", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META).y
|
||||
var meta_w := _phase_size.x + _day_size.x
|
||||
var content_w := max(_time_size.x, meta_w)
|
||||
_box_w = content_w + PADDING.x * 2
|
||||
_box_h = PADDING.y * 2 + _time_size.y + 3 + _meta_h
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not _has_data:
|
||||
return
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BG_COLOR)
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BORDER_COLOR, false, 1.0)
|
||||
|
||||
var font := ThemeDB.fallback_font
|
||||
|
||||
# HH:MM (primary, full brightness)
|
||||
draw_string(font, Vector2(PADDING.x, PADDING.y + _time_size.y),
|
||||
_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME, Constants.IMPLANT_TEXT_COLOR)
|
||||
|
||||
# Phase + day number (secondary, dimmed + phase-tinted)
|
||||
var meta_y := PADDING.y + _time_size.y + 3 + _meta_h
|
||||
draw_string(font, Vector2(PADDING.x, meta_y),
|
||||
_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, _phase_color)
|
||||
draw_string(font, Vector2(PADDING.x + _phase_size.x, meta_y),
|
||||
_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, Constants.IMPLANT_TEXT_DIM)
|
||||
@@ -0,0 +1,22 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://b4timedisplay1"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/time_display.gd" id="1_tdisplay"]
|
||||
|
||||
; #263: Time display — top-left placeholder per D-013/D-051.
|
||||
; Position and size will be refined when #314 wireframe lands.
|
||||
; NOTE: draw-based content manages its own layout; Control rect is a
|
||||
; minimum bounding box, not a clip rect. Increase if content grows.
|
||||
[node name="TimeDisplay" type="Control"]
|
||||
anchors_preset = 0
|
||||
anchor_left = 0.0
|
||||
anchor_top = 0.0
|
||||
anchor_right = 0.0
|
||||
anchor_bottom = 0.0
|
||||
offset_left = 16.0
|
||||
offset_top = 16.0
|
||||
offset_right = 200.0
|
||||
offset_bottom = 70.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_tdisplay")
|
||||
@@ -253,7 +253,7 @@ Every dialogue line requires 6 structural tags + up to 3 selection tags.
|
||||
|
||||
| Tag | Type | Format | Description |
|
||||
|-----|------|--------|-------------|
|
||||
| `id` | string | `{location}_{d}_{###}` | Stable line ID. Machine-parseable. Never reused. |
|
||||
| `id` | string | `{npc-slug}_d_{###}` | Stable line ID. Machine-parseable. Never reused. NPC-scoped per D-035 Sprint 15 amendment. |
|
||||
| `text` | string | The line | The authored dialogue text |
|
||||
| `role` | enum | lowercase-slug | Template role, not NPC name. NPCs fill roles at runtime. |
|
||||
| `access` | list | `[public, insider, ...]` | D-028 Layer 1 hard filter. Who can hear this. |
|
||||
@@ -265,7 +265,7 @@ Every dialogue line requires 6 structural tags + up to 3 selection tags.
|
||||
| Tag | Type | Format | Description |
|
||||
|-----|------|--------|-------------|
|
||||
| `topic` | list | `[colleague, cargo, ...]` | D-028 Layer 4 weighted selection. Defaults to `[routine]`. |
|
||||
| `mood` | list | `[comfortable, worried, ...]` | D-028 Layer 4 weighted selection. Defaults to `[comfortable]`. |
|
||||
| `mood` | list | `[content, anxious, ...]` | D-028 Layer 4 weighted selection. Defaults to untagged (neutral). |
|
||||
| `tags` | list | freeform strings | Escape hatch for author intent. |
|
||||
|
||||
#### Authoring-Only Tags (Not Consumed by Engine)
|
||||
@@ -352,7 +352,7 @@ Same trigger, different pool. That's how mirror moments work.
|
||||
|
||||
| Tag | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `id` | string | `{location}_m_{s\|d}_{###}` — `s` for smuggler, `d` for detective |
|
||||
| `id` | string | `{npc-slug}_m_{s\|d}_{###}` — `s` for smuggler, `d` for detective. NPC-scoped per D-035 Sprint 15 amendment. |
|
||||
| `text` | string | The monologue line (160 char max) |
|
||||
| `character` | enum | `smuggler` or `detective` |
|
||||
| `trigger` | enum | What causes this line to fire (see trigger types below) |
|
||||
@@ -600,9 +600,9 @@ See `content/global/enums/topics.yaml`. Note: `crime` is deliberately excluded.
|
||||
|
||||
### Moods (8 values)
|
||||
|
||||
`fond`, `comfortable`, `worried`, `suspicious`, `analytical`, `conflicted`, `concerned`, `relieved`
|
||||
`anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `relieved`, `focused`
|
||||
|
||||
See `content/global/enums/moods.yaml`.
|
||||
See `content/global/enums/moods.yaml`. (Updated Sprint 14 amendment to D-035.)
|
||||
|
||||
### Monologue Triggers (9 values)
|
||||
|
||||
@@ -630,7 +630,7 @@ Before submitting any content, run through these checks. Items marked **HARD** w
|
||||
|
||||
### Dialogue Lines
|
||||
|
||||
- [ ] **HARD:** `id` matches pattern `{location}_d_{###}` (lowercase, underscore-separated)
|
||||
- [ ] **HARD:** `id` matches pattern `{npc-slug}_d_{###}` (e.g. `kael-davan_d_001`) — NPC-scoped per D-035 Sprint 15 amendment
|
||||
- [ ] **HARD:** `role` is a valid lowercase slug matching a template role
|
||||
- [ ] **HARD:** `access` is a non-empty list of valid access tier enums
|
||||
- [ ] **HARD:** `trust` is one of `surface`, `real`, `secret`
|
||||
@@ -643,7 +643,7 @@ Before submitting any content, run through these checks. Items marked **HARD** w
|
||||
|
||||
### Monologue Lines
|
||||
|
||||
- [ ] **HARD:** `id` matches pattern `{location}_m_{s|d}_{###}`
|
||||
- [ ] **HARD:** `id` matches pattern `{npc-slug}_m_{s|d}_{###}` (e.g. `pc-smuggler_m_s_001`) — NPC-scoped per D-035 Sprint 15 amendment
|
||||
- [ ] **HARD:** `text` is 160 characters or fewer
|
||||
- [ ] **HARD:** `trigger` is a valid trigger enum
|
||||
- [ ] **HARD:** Character partition: smuggler lines in smuggler files, detective in detective files
|
||||
@@ -755,9 +755,9 @@ All YAML content files must validate against their JSON Schema:
|
||||
|
||||
| Content Type | Pattern | Example |
|
||||
|-------------|---------|---------|
|
||||
| Dialogue | `{location}_d_{###}` | `transit_d_044` |
|
||||
| Monologue (smuggler) | `{location}_m_s_{###}` | `transit_m_s_011` |
|
||||
| Monologue (detective) | `{location}_m_d_{###}` | `transit_m_d_042` |
|
||||
| Dialogue | `{npc-slug}_d_{###}` | `kael-davan_d_001` |
|
||||
| Monologue (smuggler) | `{npc-slug}_m_s_{###}` | `pc-smuggler_m_s_001` |
|
||||
| Monologue (detective) | `{npc-slug}_m_d_{###}` | `pc-detective_m_d_001` |
|
||||
|
||||
IDs are stable identifiers. Once assigned, never reused or reassigned. Number gaps are acceptable.
|
||||
|
||||
@@ -865,26 +865,26 @@ Every generated line must pass:
|
||||
### Step 2: Assign Tags
|
||||
|
||||
```yaml
|
||||
- id: transit_d_071
|
||||
- id: kael-davan_d_076
|
||||
text: "Morning. Voss kept the rotation thin -- we've got a clear window after 14:00."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [shift_start]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
mood: [content]
|
||||
tags: [ring-operational]
|
||||
```
|
||||
|
||||
### Step 3: Validate
|
||||
|
||||
- `id`: `transit_d_071` -- valid pattern.
|
||||
- `id`: `kael-davan_d_076` -- valid pattern (NPC-scoped per D-035 Sprint 15 amendment). Continues from kael-davan's terminal sequence (_034-_075).
|
||||
- `role`: `dock-worker` -- valid slug.
|
||||
- `access: [insider]` -- only ring members hear this. Detective never sees it. Correct.
|
||||
- `trust: real` -- requires earned trust. Not surface-level small talk. Correct for operational ring dialogue.
|
||||
- `situation: [shift_start]` -- morning shift context. Correct.
|
||||
- `topic: [cargo, routine]` -- covers both operational and schedule content. Correct.
|
||||
- `mood: [comfortable]` -- Phase 1, baseline warm. Correct.
|
||||
- `mood: [content]` -- Phase 1, baseline warm. Correct.
|
||||
|
||||
### Step 4: Dual-Lens Check
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*_(d|m)_[0-9]{3}$",
|
||||
"description": "Stable machine-parseable line ID: {template}_{d|m}_{###}. d = dialogue, m = monologue."
|
||||
"description": "Stable machine-parseable line ID: {npc-slug}_{d|m}_{###} (e.g. kael-davan_d_001, pc-detective_m_d_001). d = dialogue, m = monologue. NPC-scoped per D-035 Sprint 15 amendment — each NPC has an independent sequence starting at _001."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*_d_[0-9]{3}$",
|
||||
"description": "Stable line ID: {location_slug}_d_{###}"
|
||||
"description": "Stable line ID: {npc-slug}_d_{###} (e.g. kael-davan_d_001). NPC-scoped per D-035 Sprint 15 amendment — each NPC has an independent sequence starting at _001."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$",
|
||||
"description": "Stable line ID: {location_slug}_m_{s|d}_{###}. s = smuggler, d = detective."
|
||||
"description": "Stable line ID: {npc-slug}_m_{s|d}_{###} (e.g. pc-smuggler_m_s_001, pc-detective_m_d_001). s = smuggler, d = detective. NPC-scoped per D-035 Sprint 15 amendment — each NPC has an independent sequence starting at _001."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
|
||||
@@ -13,7 +13,7 @@ lines:
|
||||
# RING OPERATIONS — corridor handoffs
|
||||
# ========================================
|
||||
|
||||
- id: maintenance-corridors_d_001
|
||||
- id: kael-davan_d_001
|
||||
text: "Package is in the junction locker. Renn picks it up in ten."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -23,7 +23,7 @@ lines:
|
||||
topic: [cargo]
|
||||
tags: [kael, ring-ops, renn, operational]
|
||||
|
||||
- id: maintenance-corridors_d_002
|
||||
- id: kael-davan_d_002
|
||||
text: "B-7 is clear. Camera cycles in forty seconds — move fast."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -35,7 +35,7 @@ lines:
|
||||
fact_id: investigation.surveillance_gaps
|
||||
confidence: knows_details
|
||||
|
||||
- id: maintenance-corridors_d_003
|
||||
- id: kael-davan_d_003
|
||||
text: "Chalk marks are fresh. Renn came through already. We're on schedule."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -45,7 +45,7 @@ lines:
|
||||
topic: [cargo]
|
||||
tags: [kael, ring-ops, renn, operational]
|
||||
|
||||
- id: maintenance-corridors_d_004
|
||||
- id: kael-davan_d_004
|
||||
text: "Don't use the C-section hatch tonight. Maintenance crew's working late."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -60,7 +60,7 @@ lines:
|
||||
# When the smuggler finds Kael with the unknown contact
|
||||
# ========================================
|
||||
|
||||
- id: maintenance-corridors_d_005
|
||||
- id: kael-davan_d_005
|
||||
text: "What are you — wait. How long have you been standing there?"
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -70,7 +70,7 @@ lines:
|
||||
topic: [danger]
|
||||
tags: [kael, contradiction, phase-3]
|
||||
|
||||
- id: maintenance-corridors_d_006
|
||||
- id: kael-davan_d_006
|
||||
text: "That was nobody. Maintenance contact. It's handled."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -80,7 +80,7 @@ lines:
|
||||
topic: [routine]
|
||||
tags: [kael, deflection, lying, phase-4]
|
||||
|
||||
- id: maintenance-corridors_d_007
|
||||
- id: kael-davan_d_007
|
||||
text: "I don't have to explain every conversation I have. Drop it."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -94,7 +94,7 @@ lines:
|
||||
# SECRET — corridor confessions
|
||||
# ========================================
|
||||
|
||||
- id: maintenance-corridors_d_008
|
||||
- id: kael-davan_d_008
|
||||
text: "I'm trying to get out. That's all. I just want out."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -104,7 +104,7 @@ lines:
|
||||
topic: [personal, trust]
|
||||
tags: [kael, confession, phase-5]
|
||||
|
||||
- id: maintenance-corridors_d_009
|
||||
- id: kael-davan_d_009
|
||||
text: "Nils will kill me if he finds out. You know that. You know him."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -114,7 +114,7 @@ lines:
|
||||
topic: [danger, trust]
|
||||
tags: [kael, confession, nils, phase-5]
|
||||
|
||||
- id: maintenance-corridors_d_010
|
||||
- id: kael-davan_d_010
|
||||
text: "I found someone who can get me new papers. Off-station. Clean break."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
@@ -136,7 +136,7 @@ lines:
|
||||
# ========================================
|
||||
|
||||
# first_meeting: InteractionMemory.count == 0. Any stranger in restricted corridors is a problem.
|
||||
- id: maintenance-corridors_d_011
|
||||
- id: kael-davan_d_011
|
||||
text: "This is a restricted section. Who authorized you back here?"
|
||||
role: dock-worker
|
||||
access: [public]
|
||||
@@ -148,7 +148,7 @@ lines:
|
||||
notes: "Layer 2 greeting — first meeting in restricted corridors. Immediate challenge. Any stranger back here is suspicious regardless of intent."
|
||||
|
||||
# established — peer/smuggler context: count >= 3, RelationshipState: Known/Friendly → Peer/Insider access.
|
||||
- id: maintenance-corridors_d_012
|
||||
- id: kael-davan_d_012
|
||||
text: "You're on time. Clear all the way to the junction."
|
||||
role: dock-worker
|
||||
access: [peer, insider]
|
||||
@@ -160,7 +160,7 @@ lines:
|
||||
notes: "Layer 2 greeting — established, operational. All business. Kael treats a trusted repeat visitor as crew — no pleasantries, just the essential handoff information."
|
||||
|
||||
# established — authority/detective context: count >= 3, RelationshipState: PersonOfInterest → Authority access.
|
||||
- id: maintenance-corridors_d_013
|
||||
- id: kael-davan_d_013
|
||||
text: "You keep showing up back here. You have clearance for this section?"
|
||||
role: dock-worker
|
||||
access: [authority]
|
||||
@@ -172,7 +172,7 @@ lines:
|
||||
notes: "Layer 2 greeting — repeat with authority figure in restricted section. Professionally defensive. Kael challenges them but can't bar entry if they have clearance."
|
||||
|
||||
# post-confrontation: confrontation logged. Kael is cornered on his own operational ground.
|
||||
- id: maintenance-corridors_d_014
|
||||
- id: kael-davan_d_014
|
||||
text: "You followed me here."
|
||||
role: dock-worker
|
||||
access: [peer, insider]
|
||||
|
||||